From 965ed4c7923890e6484bbd483b5a795237bde0d0 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 27 Aug 2026 08:55:37 +0200 Subject: [PATCH] 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