The pop-up listing the QSOs behind a Stats band/mode square gets a cleaner header (band chip, hint), banded/hover rows and roomier spacing. Callsigns are now clickable and open the QSO editor (App.openEdit threaded through DetailsPanel → BandSlotGrid → SlotQSOModal as onEditQso), matching the double-click-to-edit of the log grids.
453 lines
21 KiB
TypeScript
453 lines
21 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 { 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[]; // 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. swatch = the
|
|
// background class (or a special ring marker for the current-entry cell).
|
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
|
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
|
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
|
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
|
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
|
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
|
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
|
];
|
|
|
|
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
|
const desc =
|
|
status === 'call_c' ? 'This callsign confirmed' :
|
|
status === 'call_w' ? 'This callsign worked (not confirmed)' :
|
|
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
|
|
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
|
|
'Never worked';
|
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
|
}
|
|
|
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
|
// Cell drill-down: which band+class the operator clicked, or null.
|
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
|
// 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]);
|
|
|
|
// "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],
|
|
);
|
|
const sunBlock = sun ? (
|
|
<div className="ml-auto flex items-center gap-3 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.5 text-warning" />
|
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Sunset className="size-3.5 text-info" />
|
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
|
</span>
|
|
<span className="text-muted-foreground">UTC</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<section
|
|
className={cn(
|
|
'flex items-center gap-4 px-3 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-[220px] 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-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
|
{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 & 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-[26px]" />
|
|
{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((cls) => {
|
|
const classCurrent = classMatchesMode(cls, currentMode);
|
|
return (
|
|
<tr key={cls}>
|
|
<th
|
|
className={cn(
|
|
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{cls}
|
|
</th>
|
|
{cols.map((b) => {
|
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
|
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
|
return (
|
|
<td
|
|
key={b.tag}
|
|
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
|
className={cn(
|
|
'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-warning ring-inset',
|
|
)}
|
|
/>
|
|
);
|
|
})}
|
|
</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(
|
|
'inline-block size-3 rounded shrink-0',
|
|
l.swatch,
|
|
l.ring && 'ring-2 ring-warning ring-inset',
|
|
)}
|
|
/>
|
|
{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 = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
|
|
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>
|
|
);
|
|
}
|