205 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|