Files
OpsLog/frontend/src/components/ContestPanel.tsx
T

218 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Trophy } from 'lucide-react';
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
// so a running contest (serial counter included) survives a restart.
export type ContestSession = {
active: boolean;
code: string; // ADIF CONTEST_ID written to each QSO
name: string;
exchange: 'serial' | 'fixed';
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
sentFixed: string; // the value YOU send when the exchange is fixed
nextSerial: number; // running sent serial number
startISO: string; // contest window start (UTC ISO) — only QSOs after count
endISO: string; // contest window end (UTC ISO, empty = open)
};
export const CONTEST_DEFAULT: ContestSession = {
active: false, code: '', name: '', exchange: 'serial',
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
startISO: '', endISO: '',
};
// Contests run on UTC, so the date/time inputs are treated as UTC.
const utcInputToISO = (v: string) => (v ? new Date(v + ':00Z').toISOString() : '');
const isoToUtcInput = (iso: string) => (iso ? new Date(iso).toISOString().slice(0, 16) : '');
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
const KIND_LABEL: Record<string, string> = {
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
};
type Def = { code: string; name: string; exchange: string };
type Stats = { qsos: number; by_band?: { band: string; count: number }[] | null; mult: number; mult_label: string; score: number; last_hour: number };
function Stat({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-3 py-2 text-center">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="text-xl font-black tabular-nums" style={accent ? { color: accent } : undefined}>{value}</div>
</div>
);
}
export function ContestPanel({ session, onChange }: {
session: ContestSession;
onChange: (patch: Partial<ContestSession>) => void;
}) {
const { t } = useI18n();
const [contests, setContests] = useState<Def[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
// Poll the scoreboard while a contest is running.
useEffect(() => {
if (!session.active || !session.code) { setStats(null); return; }
let alive = true;
const load = () => ContestStats(
session.code,
session.exchange === 'fixed' ? kindKey(session.fixedKind) : 'serial',
session.startISO, session.endISO,
).then((s) => { if (alive) setStats(s as Stats); }).catch(() => {});
load();
const id = window.setInterval(load, 4000);
return () => { alive = false; window.clearInterval(id); };
}, [session.active, session.code, session.exchange, session.fixedKind, session.startISO, session.endISO]);
const pickContest = (code: string) => {
const def = contests.find((c) => c.code === code);
if (!def) { onChange({ code: '', name: '' }); return; }
if (def.exchange === 'serial' || def.exchange === 'rst') {
onChange({ code: def.code, name: def.name, exchange: 'serial' });
} else {
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
}
};
return (
<div className="h-full overflow-y-auto p-4 space-y-4">
{/* Setup card. */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
<Trophy className="size-4 text-warning" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
{session.active && <span className="ml-auto rounded bg-warning px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-warning-foreground">{t('ctp.live')}</span>}
</div>
<div className="p-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm flex-1 min-w-[220px]">
<option value="">{t('ctp.chooseContest')}</option>
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
{session.code && <code className="text-xs text-muted-foreground">{session.code}</code>}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-md border border-border overflow-hidden">
{(['serial', 'fixed'] as const).map((x) => (
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
className={cn('px-3 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
session.exchange === x ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{x === 'serial' ? t('ctp.serial') : t('ctp.fixedExchange')}
</button>
))}
</div>
{session.exchange === 'serial' ? (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('ctp.nextSerial')}
<input type="number" min={1} value={session.nextSerial}
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
className="w-20 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
</label>
) : (
<div className="flex items-center gap-2">
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm">
{FIXED_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
<input value={session.sentFixed} onChange={(e) => onChange({ sentFixed: e.target.value.toUpperCase() })}
placeholder={t('ctp.yourExchangePh')} className="w-32 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
</div>
)}
</div>
{/* Contest window (UTC). Only QSOs inside it count toward the score and
dupe check — so old runs of the same contest aren't included. */}
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('ctp.startUtc')}
<input type="datetime-local" value={isoToUtcInput(session.startISO)}
onChange={(e) => onChange({ startISO: utcInputToISO(e.target.value) })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('ctp.endUtc')}
<input type="datetime-local" value={isoToUtcInput(session.endISO)}
onChange={(e) => onChange({ endISO: utcInputToISO(e.target.value) })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
</label>
<span className="text-[11px] text-muted-foreground">{t('ctp.windowHint')}</span>
</div>
<div className="flex items-center gap-2 pt-1">
{session.active ? (
<button type="button" onClick={() => onChange({ active: false })}
className="rounded-md border border-danger/60 bg-danger/10 px-4 py-1.5 text-sm font-bold text-danger hover:bg-danger/20">
{t('ctp.stopContest')}
</button>
) : (
<button type="button" disabled={!session.code}
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
className="rounded-md bg-warning px-4 py-1.5 text-sm font-bold text-warning-foreground hover:bg-warning disabled:opacity-40">
{t('ctp.startContest')}
</button>
)}
<span className="text-xs text-muted-foreground">
{session.active ? t('ctp.activeHint') : t('ctp.inactiveHint')}
</span>
</div>
</div>
</div>
{/* Scoreboard. */}
{session.active && (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.scoreboard')}</span>
<span className="text-[10px] text-muted-foreground">{t('ctp.estimate')}</span>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Stat label={t('ctp.qsos')} value={stats?.qsos ?? 0} />
<Stat label={stats?.mult_label || t('ctp.mult')} value={stats?.mult ?? 0} accent="#f59e0b" />
<Stat label={t('ctp.scoreEst')} value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
<Stat label={t('ctp.last60')} value={stats?.last_hour ?? 0} />
</div>
{stats && stats.by_band && stats.by_band.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left px-3 py-1.5 font-bold">{t('ctp.band')}</th>
<th className="text-right px-3 py-1.5 font-bold">{t('ctp.qsos')}</th>
</tr></thead>
<tbody>
{stats.by_band.map((r) => (
<tr key={r.band} className="border-t border-border/60">
<td className="px-3 py-1 font-mono">{r.band}</td>
<td className="px-3 py-1 text-right tabular-nums">{r.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
);
}
// kindKey maps a fixed-kind label back to the backend's exchange key for mult
// counting.
function kindKey(label: string): string {
const m: Record<string, string> = {
'CQ Zone': 'cq-zone', 'ITU Zone': 'itu-zone', 'State/Prov': 'state',
'Département': 'dept', 'Grid': 'grid', 'Name': 'name', 'Section': 'section', 'Age': 'age',
};
return m[label] || 'state';
}