feat(matrix): mark the slots already worked with the callsign in hand

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.
This commit is contained in:
2026-08-27 08:55:37 +02:00
parent 32dbfbd04e
commit 965ed4c792
5 changed files with 81 additions and 11 deletions
+4 -2
View File
@@ -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é."
]
},
{
+43 -8
View File
@@ -81,23 +81,38 @@ const STATUS_CLASSES: Record<string, string> = {
// 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 (
<span className="pointer-events-none absolute top-[2px] right-[2px] size-[5px] rounded-full bg-foreground ring-1 ring-background" />
);
}
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<string, string>();
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
</th>
{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 (
<td
key={b.tag}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
title={cellTitle(t, b.tag, cls, st, isCurrent, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => 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 ? <CallMark /> : null}
</td>
);
})}
</tr>
@@ -337,11 +370,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span
className={cn(
'inline-block size-3 rounded shrink-0',
'relative inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-mx-cur ring-inset',
)}
/>
>
{l.mark ? <CallMark /> : null}
</span>
{t(l.label)}
</span>
))}
+6
View File
@@ -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',
+2
View File
@@ -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 {
+25
View File
@@ -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