Files
OpsLog/frontend/src/components/BandSlotGrid.tsx
T
2026-06-02 01:17:26 +02:00

205 lines
7.2 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;
}
// 13-column band layout — no 4m, no SHF (per user preference).
const 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 }: Props) {
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]);
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>
</>
) : 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]" />
{BANDS.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>
{BANDS.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = 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>
);
}