From 965ed4c7923890e6484bbd483b5a795237bde0d0 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 08:55:37 +0200 Subject: [PATCH 01/16] feat(matrix): mark the slots already worked with the callsign in hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worked-before cell answered one question with two facts fused into it. bandStatusCode takes the highest of them, so a confirmed entity outranks a worked callsign — right for awards, wrong while chasing: a slot worked with the DXpedition this morning reads "entity confirmed" and says nothing about it. BandStatus now carries the callsign's own answer alongside the entity's, and the cell draws a dot for it. The dot is deliberately identical everywhere — same shape, same colour, over all five backgrounds — so it is read as one thing rather than as five variants of the colour underneath it. --- changelog.json | 8 ++-- frontend/src/components/BandSlotGrid.tsx | 51 ++++++++++++++++++++---- frontend/src/lib/i18n.tsx | 6 +++ frontend/wailsjs/go/models.ts | 2 + internal/qso/qso.go | 25 ++++++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/changelog.json b/changelog.json index f8ed572..4422683 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,12 @@ "version": "0.26.20", "date": "", "en": [ - "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes." + "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.", + "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell." ], "fr": [ - "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change." + "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", + "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité." ] }, { @@ -1929,4 +1931,4 @@ "Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour." ] } -] +] \ No newline at end of file diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index ce7fa9e..a93b7e1 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -81,23 +81,38 @@ const STATUS_CLASSES: Record = { // i18n keys the Appearance panel's colour pickers use, so the two can never // disagree about which green is which. swatch = the background class (or a // special ring marker for the current-entry cell). -const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [ +const LEGEND: { swatch: string; ring?: boolean; mark?: boolean; label: string }[] = [ { swatch: 'bg-mx-call-conf', label: 'mx.callConf' }, { swatch: 'bg-mx-call-work', label: 'mx.callWork' }, { swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' }, { swatch: 'bg-mx-dx-work', label: 'mx.dxWork' }, { swatch: 'bg-mx-none', label: 'mx.none' }, { swatch: 'bg-mx-none', ring: true, label: 'mx.current' }, + { swatch: 'bg-mx-none', mark: true, label: 'mx.thisCall' }, ]; -function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string { +// CallMark — "this callsign has already been worked on this slot". +// +// Drawn exactly the same way on every cell, whatever colour the entity status +// gave it. That is the point: the operator learns one shape, and reads it +// without first working out what the background means. The dot is the theme +// foreground with a ring in the theme background, so it stays legible over all +// five cell colours instead of needing a colour per case. +function CallMark() { + return ( + + ); +} + +function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string { const desc = status === 'call_c' ? t('mx.tipCallConf') : status === 'call_w' ? t('mx.tipCallWork') : status === 'dxcc_c' ? t('mx.tipDxConf') : status === 'dxcc_w' ? t('mx.tipDxWork') : t('mx.tipNone'); - return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`; + const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : ''; + return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`; } export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) { @@ -126,6 +141,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal return m; }, [wb]); + // Worked-with-this-callsign, per cell — carried separately by the backend + // because collapsing it into the status is what hid it. + const callMap = useMemo(() => { + const m = new Map(); + for (const s of wb?.band_status ?? []) { + if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call); + } + return m; + }, [wb]); + // "Newness" of the current band+mode entry, for the award/DX-chase badges. // Derived straight from the entity's real band_status (all bands it was // worked on — not just the operator's configured column list). @@ -308,21 +333,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal {cols.map((b) => { const st = statusMap.get(`${b.tag}|${cls}`) ?? ''; + // The same cell's other answer: worked with THIS callsign + // here. The status above is the entity's, and a confirmed + // entity outranks a worked call — so chasing a DXpedition, + // the cell could say "confirmed" about a contact made years + // ago and nothing about the one made this morning. + const mine = callMap.get(`${b.tag}|${cls}`) ?? ''; const isCurrent = hasCall && b.tag === currentBand && classCurrent; return ( setSlot({ band: b.tag, cls }) : undefined} className={cn( - 'w-[28px] h-[24px] rounded transition-colors p-0', + 'relative w-[28px] h-[24px] rounded transition-colors p-0', st ? STATUS_CLASSES[st] : 'bg-mx-none', // Only a filled cell has anything to show — an empty one // stays inert rather than opening a "no QSOs" dialog. st && 'cursor-pointer hover:brightness-110', isCurrent && 'ring-2 ring-mx-cur ring-inset', )} - /> + > + {mine ? : null} + ); })} @@ -337,11 +370,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal + > + {l.mark ? : null} + {t(l.label)} ))} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index a39c6b8..fad00a2 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -127,6 +127,9 @@ const en: Dict = { 'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)', 'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)', 'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs', + 'mx.thisCall': 'this callsign', + 'mx.tipThisCall': 'already worked with this callsign', + 'mx.tipThisCallConf': 'already confirmed with this callsign', // FTx decodes panel (Tools -> FT decodes) 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only', 'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents', @@ -613,6 +616,9 @@ const fr: Dict = { 'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)', 'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)', 'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO', + 'mx.thisCall': 'cet indicatif', + 'mx.tipThisCall': 'déjà contacté avec cet indicatif', + 'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif', // Panneau des decodes FTx (Outils -> Decodes FT) 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement', 'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 4a5bd09..e1a73eb 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -5027,6 +5027,7 @@ export namespace qso { band: string; class: string; status: string; + call?: string; static createFrom(source: any = {}) { return new BandStatus(source); @@ -5037,6 +5038,7 @@ export namespace qso { this.band = source["band"]; this.class = source["class"]; this.status = source["status"]; + this.call = source["call"]; } } export class Bucket { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 906f042..349f9a4 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1901,10 +1901,24 @@ type WorkedBefore struct { } // BandStatus is one cell in the worked-before grid. +// +// Status is the single highest thing true of the cell, which is what colours +// it. Call is the SAME cell's answer to a different question — "have I worked +// THIS callsign here" — kept separately because the two are asked at the same +// moment and one was hiding the other. +// +// Chasing an expedition, an operator needs both: whether the slot is still +// missing for the entity (does this fill a DXCC hole) and whether this +// expedition has already been worked on it (would this be a dupe). A confirmed +// entity outranks a worked callsign in Status — correctly, for awards — so a +// slot worked with the DX yesterday can read "entity confirmed" and say nothing +// at all about yesterday. type BandStatus struct { Band string `json:"band"` // ADIF lowercase band, e.g. "20m" Class string `json:"class"` // "PH" | "CW" | "DIG" Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" + // Call is "", "w" (worked with this callsign) or "c" (confirmed with it). + Call string `json:"call,omitempty"` } // Band-status codes, lowest first. The ORDER is the rule: a cell shows the @@ -2243,6 +2257,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int, } type cellKey struct{ band, class string } best := map[cellKey]int{} + // The call's own answer per cell, independent of the ladder above. + callByCell := map[cellKey]string{} for statusRows.Next() { var band, mode string var callW, callC, dxccConfirmed int @@ -2255,12 +2271,21 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int, if cur, ok := best[k]; !ok || code > cur { best[k] = code } + // Confirmed beats worked here too, and neither is ever erased by the + // entity: this is only ever about the callsign. + switch { + case callC == 1: + callByCell[k] = "c" + case callW == 1 && callByCell[k] == "": + callByCell[k] = "w" + } } statusRows.Close() codeStr := bandStatusNames for k, code := range best { wb.BandStatus = append(wb.BandStatus, BandStatus{ Band: k.band, Class: k.class, Status: codeStr[code], + Call: callByCell[k], }) } return wb, nil From b3547364d42cc19f089f7b762364c8340b26cad7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 09:01:31 +0200 Subject: [PATCH 02/16] style(matrix): move the worked-with-this-call dot in from the corner --- frontend/src/components/BandSlotGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index a93b7e1..0820888 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -100,7 +100,7 @@ const LEGEND: { swatch: string; ring?: boolean; mark?: boolean; label: string }[ // five cell colours instead of needing a colour per case. function CallMark() { return ( - + ); } From f357dad6298b1d0769a441dc13dc785d2867831c Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 15:29:01 +0200 Subject: [PATCH 03/16] feat(appearance): give the worked-with-this-call dot its own two colours The dot is drawn over all five cell backgrounds, so it cannot borrow one of their colours; it defaults to the theme foreground with a background ring and now takes two overrides of its own, worked and confirmed, alongside the rest of the matrix palette. --- appearance.go | 7 ++++++ changelog.json | 4 +-- frontend/src/components/AppearancePanel.tsx | 6 +++++ frontend/src/components/BandSlotGrid.tsx | 28 +++++++++++++-------- frontend/src/lib/i18n.tsx | 4 +-- frontend/src/lib/matrixColors.ts | 7 +++++- frontend/src/style.css | 7 ++++++ frontend/wailsjs/go/models.ts | 4 +++ 8 files changed, 51 insertions(+), 16 deletions(-) diff --git a/appearance.go b/appearance.go index b2f25ed..2e75d5f 100644 --- a/appearance.go +++ b/appearance.go @@ -174,6 +174,11 @@ type MatrixColors struct { EntityWorked string `json:"entity_worked"` NotWorked string `json:"not_worked"` CurrentEntry string `json:"current_entry"` + // The dot marking a slot already worked with the callsign in hand. It is a + // mark rather than a background, so it needs its own two colours: it is + // drawn on top of all five of the above and must stay legible over each. + MarkWorked string `json:"mark_worked"` + MarkConfirmed string `json:"mark_confirmed"` } // normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e. @@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors { EntityWorked: clean(c.EntityWorked), NotWorked: clean(c.NotWorked), CurrentEntry: clean(c.CurrentEntry), + MarkWorked: clean(c.MarkWorked), + MarkConfirmed: clean(c.MarkConfirmed), } } diff --git a/changelog.json b/changelog.json index 4422683..41c27fa 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,11 @@ "date": "", "en": [ "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.", - "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell." + "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours." ], "fr": [ "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", - "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité." + "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice." ] }, { diff --git a/frontend/src/components/AppearancePanel.tsx b/frontend/src/components/AppearancePanel.tsx index 6cee9e5..1995aa6 100644 --- a/frontend/src/components/AppearancePanel.tsx +++ b/frontend/src/components/AppearancePanel.tsx @@ -127,6 +127,12 @@ function MatrixColorsSection() { + + + + + + From 8865af35fd41d5d662caccc35447a0d00d9349e3 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 21:53:59 +0200 Subject: [PATCH 13/16] feat(qsl): a QRZ button beside the paper-QSL search --- changelog.json | 6 ++++-- frontend/src/components/QSLManagerModal.tsx | 14 +++++++++++++- frontend/src/lib/i18n.tsx | 2 ++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/changelog.json b/changelog.json index 0091edf..bf68c9f 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,8 @@ "Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radio’s own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.", "Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.", "Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSO’s SOTA award reference, as a POTA spot already did. Turn the column on in Columns.", - "Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers." + "Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.", + "QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com." ], "fr": [ "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", @@ -16,7 +17,8 @@ "Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.", "Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.", "Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.", - "Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres." + "Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.", + "Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com." ] }, { diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index fb97738..af75346 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -8,7 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; +import { OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { EventsOn } from '../../wailsjs/runtime/runtime'; @@ -434,6 +434,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: { {paperBusy ? : } {t('qslm.search')} + {/* The station's QRZ page, one click away: writing a card means + reading the address, the manager and whether they even want + paper, and all three are on that page. */} + {t('qslm.paperHint')} ) : ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index aa0f89d..84be736 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -128,6 +128,7 @@ const en: Dict = { 'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)', 'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs', 'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.', + 'qslm.qrzTitle': 'Open this callsign on QRZ.com', 'awp.filterSlotsNotCfmd': 'Slots to confirm', 'awp.slotGap': 'slots to confirm', 'awp.slotGapTip': 'Band-slots worked and not yet confirmed — the difference between the worked and confirmed totals above.', 'mx.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed', @@ -620,6 +621,7 @@ const fr: Dict = { 'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)', 'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO', 'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.", + 'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com', 'awp.filterSlotsNotCfmd': 'Slots à confirmer', 'awp.slotGap': 'slots à confirmer', 'awp.slotGapTip': "Créneaux bande contactés et pas encore confirmés — l'écart entre les totaux contactés et confirmés ci-dessus.", 'mx.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé', From 4366083152daf793584d0f219affc598a424dc96 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 22:43:46 +0200 Subject: [PATCH 14/16] feat(lotw): let the download cover every callsign on the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The download passes qso_owncall so one profile does not pull another's confirmations. That scope also hides them: a QSO made as F4BPO/P is confirmed at LoTW and can never come back to an F4BPO profile, so it stays unconfirmed here for good while the ARRL counts it — found while reconciling a DXCC account against the panel, where it was the whole of the CW gap (Jersey, worked as F4BPO/P). Off by default: the scope is right for anyone whose profiles are separate stations. --- app.go | 22 +++++++++++++++++++++ changelog.json | 6 ++++-- frontend/src/components/QSLManagerModal.tsx | 11 ++++++++++- frontend/src/lib/i18n.tsx | 4 ++++ frontend/wailsjs/go/main/App.d.ts | 4 ++++ frontend/wailsjs/go/main/App.js | 8 ++++++++ 6 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app.go b/app.go index 7dd2935..8f8b1fb 100644 --- a/app.go +++ b/app.go @@ -402,6 +402,7 @@ const ( keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" keyExtLoTWStationLoc = "extsvc.lotw.station_location" + keyExtLoTWAllCalls = "extsvc.lotw.download_all_calls" // download confirmations for EVERY call on the account, not just this profile's keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWKeyPassword = "extsvc.lotw.key_password" keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) @@ -12096,6 +12097,17 @@ func manualRefFor(existing, code string) string { return "" } +// GetLoTWDownloadAllCalls reports whether the LoTW download ignores the +// profile's own call and pulls every callsign on the account. +func (a *App) GetLoTWDownloadAllCalls() bool { + return a.settingOr(keyExtLoTWAllCalls, "") == "1" +} + +// SetLoTWDownloadAllCalls stores that choice. +func (a *App) SetLoTWDownloadAllCalls(on bool) { + a.setSetting(keyExtLoTWAllCalls, map[bool]string{true: "1", false: "0"}[on]) +} + // DownloadConfirmations pulls confirmed QSOs from a service and updates the // matching local QSOs' received status. LoTW only for now (the canonical // confirmation system); runs in the background emitting the same @@ -12167,6 +12179,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, case extsvc.ServiceLoTW: sinceDate := resolveSince(keyExtLoTWLastDownload) ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW) + // A LoTW account holds every call its owner operates — F4BPO, F4BPO/P, + // TM2Q — and the download is normally scoped to the profile's own call so + // one profile does not pull another's confirmations. That scope also + // silently hides them: a QSO made as F4BPO/P is confirmed at LoTW and can + // never be downloaded from the F4BPO profile, so it stays unconfirmed here + // for good while the ARRL counts it. Off by default, because the scope is + // right for anyone whose profiles are separate stations. + if a.settingOr(keyExtLoTWAllCalls, "") == "1" { + ownCall = "" + } callLabel := ownCall if callLabel == "" { callLabel = "all callsigns" diff --git a/changelog.json b/changelog.json index bf68c9f..2576e30 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.", "Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSO’s SOTA award reference, as a POTA spot already did. Turn the column on in Columns.", "Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.", - "QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com." + "QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.", + "LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profile’s own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here." ], "fr": [ "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", @@ -18,7 +19,8 @@ "Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.", "Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.", "Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.", - "Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com." + "Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.", + "LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici." ] }, { diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index af75346..dbae2b8 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -8,7 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; +import { GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { EventsOn } from '../../wailsjs/runtime/runtime'; @@ -255,6 +255,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: { const [searching, setSearching] = useState(false); const [error, setError] = useState(''); const [addNotFound, setAddNotFound] = useState(false); + // LoTW only: pull the whole account rather than this profile's callsign. + const [lotwAllCalls, setLotwAllCalls] = useState(false); + useEffect(() => { GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {}); }, []); // Download date window: 'last' = incremental since last pull, 'date' = from a // chosen date, 'all' = everything. const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last'); @@ -748,6 +751,12 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: { setAddNotFound(!!c)} /> {t('qslm.addNotFound')} + {service === 'lotw' && ( + + )} )}