218 lines
11 KiB
TypeScript
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';
|
|
}
|