Files
OpsLog/frontend/src/components/BandSlotGrid.tsx
T
rouggy 3f97084246 style(matrix): pin the label column so RTTY cannot shift it
The width was set for three characters, so the column grew when the
rotation came round to RTTY and every band beneath it moved — which the
eye reads as the matrix sliding rather than the row changing. Sized once
for the longest label it can show and pinned there, header spacer
included; past four characters (PSK31, MSK144) the type gives way
instead of the column.
2026-09-01 23:50:14 +02:00

547 lines
26 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { isQSLConfirmed } from '@/lib/qsl';
import { useI18n } from '@/lib/i18n';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
interface Props {
wb: WorkedBefore | null;
busy: boolean;
currentBand: string;
currentMode: string;
bands?: string[];
// The operator's configured mode list, in THEIR order: the digital row
// rotates through it.
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
// to an entity with no position at all, and the block simply does not appear.
lat?: number;
lon?: number;
// Set when the matrix is showing a QSO picked in the log grid rather than the
// entry form — labelled so the two can never be confused.
forCall?: string;
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
// clickable. Threaded down from App.openEdit.
onEditQso?: (id: number) => void;
}
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
// strip the trailing "m" for meter bands (160m→160), and shorten cm bands
// (13cm→13c) so the column stays narrow.
function bandColLabel(tag: string): string {
if (tag === '2m') return 'V';
if (tag === '70cm') return 'U';
if (tag.endsWith('cm')) return tag.replace('cm', 'c');
return tag.replace(/m$/, '');
}
// Default 13-column band layout, used when the operator hasn't configured bands.
const DEFAULT_BANDS: { tag: string; label: string }[] = [
{ tag: '160m', label: '160' },
{ tag: '80m', label: '80' },
{ tag: '60m', label: '60' },
{ tag: '40m', label: '40' },
{ tag: '30m', label: '30' },
{ tag: '20m', label: '20' },
{ tag: '17m', label: '17' },
{ tag: '15m', label: '15' },
{ tag: '12m', label: '12' },
{ tag: '10m', label: '10' },
{ tag: '6m', label: '6' },
{ tag: '2m', label: 'V' },
{ tag: '70cm', label: 'U' },
];
const CLASSES = ['PH', 'CW', 'DIG'] as const;
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
function classMatchesMode(cls: string, mode: string): boolean {
const u = (mode || '').toUpperCase();
if (cls === 'PH') return PHONE_MODES.has(u);
if (cls === 'CW') return u === 'CW';
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
}
// Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level
// ramp per hue so confirmed / worked / not-worked stay distinguishable on both
// light and dark surfaces (the generic status -muted fills were too dark on the
// dark themes to tell "worked" from "not worked" apart). Light-warm reproduces
// the original emerald/indigo/stone colours exactly.
const STATUS_CLASSES: Record<string, string> = {
call_c: 'bg-mx-call-conf',
call_w: 'bg-mx-call-work',
dxcc_c: 'bg-mx-dx-conf',
dxcc_w: 'bg-mx-dx-work',
};
// Legend entries, in the same colour order as the cells — and the same order and
// 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; mark?: string; 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: 'w', label: 'mx.markWork' },
{ swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
];
// CallMark — "this callsign has already been worked on this slot".
//
// Drawn the same way on every cell, whatever colour the entity status gave it:
// the operator learns one shape and reads it without first working out what the
// background means. Only the fill changes, and only with the callsign's own
// state (worked / confirmed) — never with the entity's. The ring is the theme
// background, which is what keeps the dot legible over all five cell colours.
function CallMark({ state = 'w' }: { state?: string }) {
return (
<span
className={cn(
'pointer-events-none absolute top-[4px] right-[4px] size-[5px] rounded-full ring-1 ring-background',
state === 'c' ? 'bg-mx-mark-conf' : 'bg-mx-mark-work',
)}
/>
);
}
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');
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, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// The DIGITAL row is a rotation, not a fixed row.
//
// One row for every digital mode would be the honest layout and there is no
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
// So the row keeps its place and changes what it answers: DIG (all of them),
// then each digital mode the operator actually uses, in the order their mode
// list gives, then back to DIG. The backend publishes the same cells under
// both the class name and the raw mode, so a rotation costs no round trip.
const digModes = useMemo(
() => (modes ?? [])
.map((m) => (m || '').toUpperCase().trim())
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes],
);
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
// A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
// Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
() => (bands && bands.length ? bands.map((tag) => ({ tag, label: bandColLabel(tag) })) : DEFAULT_BANDS),
[bands],
);
const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0;
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0;
const statusMap = useMemo(() => {
const m = new Map<string, string>();
for (const s of wb?.band_status ?? []) {
m.set(`${s.band}|${s.class}`, s.status);
}
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).
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
// DXCC-style grouping instead (Settings → General), where all digital modes
// count as ONE — then FT4 after FT8 is just "worked".
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
const normMode = (m: string): string => {
const u = (m || '').toUpperCase().trim();
if (!groupDigital) return u;
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
};
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = normMode(currentMode);
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
// New Band = the band is new (the mode was worked on another band).
// New Mode = the mode is new (the band was worked in another mode).
// New Slot = both band and mode already worked — just not together.
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
const newBandMode = slotNew && !bandWorked && !modeWorked;
const newBand = slotNew && !bandWorked && modeWorked;
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
// is on). Hotter colour the more wanted the entity is.
const mwBadge = mwRank > 0 ? (
<Badge
title={`ClubLog Most Wanted #${mwRank}`}
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
mwRank <= 100 ? 'bg-danger text-danger-foreground'
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
: 'bg-muted text-muted-foreground')}
>
MW #{mwRank}
</Badge>
) : null;
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
// about to open or close on their side, which is the reason to look at a DX
// station's grey line at all. Recomputed only when the position changes.
const sun = useMemo(
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
[lat, lon],
);
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
// header that has to hold the callsign, the badges and the band grid, and it
// was what pushed the whole row onto a second line. Two short times one above
// the other take a fraction of that and no extra height: the row is already
// taller than one line of text.
//
// "UTC" moves into the tooltip with them — the times are monospaced and always
// UTC everywhere in OpsLog, so the label was spending width to repeat a
// convention the operator already lives by.
const sunBlock = sun ? (
<div className="ml-auto flex flex-col items-end leading-tight text-xs shrink-0"
title="Sunrise / sunset at the DX station (UTC)">
{sun.polarDay ? (
<span className="font-semibold text-warning">midnight sun</span>
) : sun.polarNight ? (
<span className="font-semibold text-info">polar night</span>
) : (
<>
<span className="flex items-center gap-1">
<Sunrise className="size-3 text-warning" />
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
</span>
<span className="flex items-center gap-1">
<Sunset className="size-3 text-info" />
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
</span>
</>
)}
</div>
) : null;
return (
<section
className={cn(
'flex items-center gap-2 px-2 py-2 flex-wrap shrink-0',
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{newOne ? (
<>
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
<Star className="size-3 fill-current" />
NEW ONE
</Badge>
<span className="text-xs text-warning-muted-foreground">
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity
</span>
{mwBadge}
</>
) : hasDxcc ? (
<>
{/* Says WHOSE stats these are when they come from a row picked in
the log rather than from what's being typed. */}
{forCall && (
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
title="Stats for the QSO selected in the log">
{forCall}
</Badge>
)}
<Badge className="bg-primary text-primary-foreground px-2 py-0.5 text-xs normal-case font-semibold tracking-normal leading-tight">
{dxccName || `DXCC #${dxcc}`}
</Badge>
{mwBadge}
<span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity
{callCount > 0 && (
<>
{' · '}
<strong className="text-foreground font-semibold">{callCount}</strong>{' '}
with this call
</>
)}
</span>
{(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1">
{newBandMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{newBand && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band</Badge>}
{newMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Mode</Badge>}
{newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div>
)}
</>
) : busy ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
<Radio className="size-3.5 animate-pulse" />
looking up
</span>
) : (
<span className="text-xs text-muted-foreground italic">
Type a callsign to see entity stats
</span>
)}
{sunBlock}
</div>
<div className="flex flex-col gap-2">
<table className="border-separate" style={{ borderSpacing: 3 }}>
<thead>
<tr>
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
{cols.map((b) => (
<th
key={b.tag}
className={cn(
'font-mono text-[11px] font-semibold px-1 text-center',
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
)}
>
{b.label}
</th>
))}
</tr>
</thead>
<tbody>
{CLASSES.map((clsBase) => {
const cls = clsBase === 'DIG' ? digRow : clsBase;
// On a specific digital mode the "you are here" mark has to be that
// mode, not any digital one — otherwise every FT4 entry lights the
// FT8 row it happens to be cycled to.
const classCurrent = cls === clsBase
? classMatchesMode(cls, currentMode)
: (currentMode || '').toUpperCase() === cls;
return (
<tr key={cls}>
<th
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
className={cn(
// Sized once for the LONGEST label the rotation can show,
// and pinned there: a column that grows when RTTY comes
// round shifts every band beneath it, and the eye reads
// that as the matrix moving rather than the row changing.
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
// Beyond four characters (PSK31, MSK144) the type gives way
// instead of the column.
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)}
>
{cls}
</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, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'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 state={mine} /> : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{/* Colour legend — sits in the spare room under the matrix. */}
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap pl-[26px]">
{LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span
className={cn(
'relative inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-mx-cur ring-inset',
)}
>
{l.mark ? <CallMark state={l.mark} /> : null}
</span>
{t(l.label)}
</span>
))}
</div>
</div>
{slot && (
<SlotQSOModal
call={wb?.callsign ?? ''}
dxcc={dxcc}
entity={dxccName}
band={slot.band}
cls={slot.cls}
onClose={() => setSlot(null)}
onEdit={onEditQso}
/>
)}
</section>
);
}
// ── Cell drill-down ──────────────────────────────────────────────────────────
// The contacts behind one band+class cell: this exact callsign AND anyone else
// in the entity, because that is the pair of facts the cell's colour encodes.
// The call's own QSOs are marked so the two never blur together.
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
}) {
const [rows, setRows] = useState<any[] | null>(null);
const [err, setErr] = useState('');
useEffect(() => {
let dead = false;
BandSlotQSOs(call, dxcc, band, cls)
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
return () => { dead = true; };
}, [call, dxcc, band, cls]);
useEffect(() => {
// Capture phase: the app's global ESC handler resets the entry form, and
// closing a dialog must not also wipe what the operator was typing.
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
}
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
<div className="min-w-0">
<div className="font-semibold text-sm leading-tight truncate">
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
</div>
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-auto">
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
{!rows ? (
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading
</p>
) : rows.length === 0 ? (
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
<th className="text-left font-medium px-3 py-1.5">Mode</th>
<th className="text-left font-medium px-3 py-1.5">Freq</th>
<th className="text-left font-medium px-3 py-1.5">Name</th>
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
</tr>
</thead>
<tbody>
{rows.map((q, i) => {
const cfm = isQSLConfirmed(q.lotw_rcvd) || isQSLConfirmed(q.eqsl_rcvd) || isQSLConfirmed(q.qsl_rcvd);
const mine = q.callsign === call;
return (
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
</td>
<td className="px-3 py-1.5">
{onEdit ? (
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
{q.callsign}
</button>
) : (
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
)}
</td>
<td className="px-3 py-1.5">{q.mode}</td>
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success"></span> : <span className="text-muted-foreground/30">·</span>}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}