245 lines
9.8 KiB
TypeScript
245 lines
9.8 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Star, Radio } from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { cn } from '@/lib/utils';
|
|
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
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
const STATUS_CLASSES: Record<string, string> = {
|
|
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
|
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
|
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
|
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
|
};
|
|
|
|
// 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-emerald-700', label: 'Call confirmed' },
|
|
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
|
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
|
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
|
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
|
{ swatch: 'bg-stone-200', 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 }: Props) {
|
|
// 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 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).
|
|
const curClass = CLASSES.find((c) => classMatchesMode(c, currentMode));
|
|
const statusKeys = useMemo(() => Array.from(statusMap.keys()), [statusMap]);
|
|
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined;
|
|
const bandWorked = statusKeys.some((k) => k.split('|')[0] === currentBand);
|
|
const modeWorked = !!curClass && statusKeys.some((k) => k.split('|')[1] === curClass);
|
|
const newBand = hasDxcc && !newOne && !bandWorked;
|
|
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked;
|
|
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus;
|
|
// New slot for THIS call: worked the op before, but not on this band+mode.
|
|
const newSlot = !newOne && callCount > 0 && !!curClass && slotStatus !== 'call_c' && slotStatus !== 'call_w';
|
|
|
|
return (
|
|
<section
|
|
className={cn(
|
|
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
|
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-[220px]">
|
|
{newOne ? (
|
|
<>
|
|
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
|
|
<Star className="size-3 fill-current" />
|
|
NEW ONE
|
|
</Badge>
|
|
<span className="text-xs text-amber-900">
|
|
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
|
{' '}· never worked this entity
|
|
</span>
|
|
</>
|
|
) : hasDxcc ? (
|
|
<>
|
|
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
|
{dxccName || `DXCC #${dxcc}`}
|
|
</Badge>
|
|
<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">
|
|
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
|
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
|
{!newBand && !newMode && newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
|
{newSlot && <Badge className="bg-sky-600 text-white 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>
|
|
)}
|
|
</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)}
|
|
className={cn(
|
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
|
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
|
isCurrent && 'ring-2 ring-amber-500 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-amber-500 ring-inset',
|
|
)}
|
|
/>
|
|
{l.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|