This commit is contained in:
2026-06-06 00:02:56 +02:00
parent 51d3a734e8
commit 922a185208
10 changed files with 941 additions and 131 deletions
+6 -3
View File
@@ -793,10 +793,13 @@ export default function App() {
function applyModePreset(m: string) {
if (rstUserEditedRef.current) return;
// Prefer the user's configured preset RST; otherwise fall back to the mode
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
// so switching SSB→CW flips 59→599 even without a configured preset.
const p = modePresets.find((x) => x.name === m);
if (!p) return;
if (p.default_rst_sent) setRstSent(p.default_rst_sent);
if (p.default_rst_rcvd) setRstRcvd(p.default_rst_rcvd);
const fallback = rstOptions(m, rstLists)[0] || '';
setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback);
}
useEffect(() => { refresh(); }, [refresh]);
+35 -6
View File
@@ -13,12 +13,16 @@ import { cn } from '@/lib/utils';
import {
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
GetAwardReferenceMeta, UpdateAwardReferenceList,
ListAwardReferences, SaveAwardReference, DeleteAwardReference,
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
} from '../../wailsjs/go/main/App';
// Above this many references the editor stops loading the whole list and
// switches to search-only (mirrors Log4OM's "Too many items" behaviour).
const REF_LIST_CAP = 1000;
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
export type AwardDef = {
@@ -358,24 +362,44 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
const [showBulk, setShowBulk] = useState(false);
const [hasBuiltin, setHasBuiltin] = useState(false);
const total = meta?.count ?? 0;
const large = total > REF_LIST_CAP;
// Small lists are loaded whole and filtered client-side; large lists (POTA,
// 85k parks) are search-only to stay responsive.
const load = () => {
if (!code) return;
if (total > REF_LIST_CAP) { setRefs([]); return; }
setBusy(true);
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
};
useEffect(load, [code]);
useEffect(load, [code, total]);
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
// Server-side search for large lists (debounced, min 2 chars).
useEffect(() => {
if (!large) return;
const s = q.trim();
if (s.length < 2) { setRefs([]); return; }
const t = window.setTimeout(() => {
setBusy(true);
SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false));
}, 200);
return () => window.clearTimeout(t);
}, [code, q, large]);
async function populateBuiltin() {
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
const sel = refs.find((r) => r.code === selCode) || null;
// Large lists are already filtered by the server; small lists filter locally.
const filtered = useMemo(() => {
if (large) return refs;
const s = q.trim().toUpperCase();
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
}, [refs, q]);
}, [refs, q, large]);
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
@@ -407,7 +431,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{refs.length}</span></span>
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
<div className="flex-1" />
<Select value="" onValueChange={applyPreset}>
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
@@ -440,8 +464,13 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="flex-1 overflow-auto">
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Loading</div>}
{!busy && filtered.length === 0 && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Searching</div>}
{!busy && large && q.trim().length < 2 && (
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
</div>
)}
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
{filtered.map((r) => (
<button key={r.code} onClick={() => setSelCode(r.code)}
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
+7 -1
View File
@@ -6,9 +6,13 @@ import {
} from '@/components/ui/select';
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
type AwardDef = { code: string; name: string; dxcc_filter?: number[] | null; dynamic?: boolean };
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
type Meta = { code: string; count: number; can_update: boolean };
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
// are computed, never manually picked, so they don't belong in this picker.
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'state', 'cont', 'country', 'grid']);
interface Props {
dxcc?: number;
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
@@ -44,6 +48,8 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
// for a French call but not for others.
const awards = useMemo(() => {
return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
+298 -76
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, CheckCircle2, Search, Pencil } from 'lucide-react';
import { GetAwards } from '../../wailsjs/go/main/App';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3 } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -10,7 +10,7 @@ type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
ref: string; name?: string; group?: string; subgrp?: string;
worked: boolean; confirmed: boolean; validated: boolean;
bands: string[]; confirmed_bands: string[];
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
};
type AwardResult = {
code: string; name: string; dimension: string;
@@ -18,6 +18,28 @@ type AwardResult = {
bands: BandCount[]; refs: AwardRef[];
};
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
// Fixed band columns for the matrix view (Log4OM-style).
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
// Per-band status for a reference, highest first.
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none';
function cellStatus(r: AwardRef, band: string): CellStatus {
if (r.validated_bands?.includes(band)) return 'validated';
if (r.confirmed_bands?.includes(band)) return 'confirmed';
if (r.bands?.includes(band)) return 'worked';
return 'none';
}
const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-emerald-500 text-white',
confirmed: 'bg-amber-400 text-amber-950',
worked: 'bg-stone-400 text-white',
none: '',
};
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
function pct(n: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.round((n / total) * 100));
@@ -34,35 +56,78 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
);
}
type AwardListItem = { code: string; name: string; valid?: boolean };
export function AwardsPanel() {
const [results, setResults] = useState<AwardResult[]>([]);
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [selected, setSelected] = useState<string>('DXCC');
const [selected, setSelected] = useState<string>('');
const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
async function load() {
// Lazily fetch the statistics matrix when the Stats view is shown.
useEffect(() => {
if (view !== 'stats' || !selected) return;
setStatsLoading(true);
GetAwardStats(selected)
.then((s) => setStats(s as any))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, [view, selected]);
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) {
if (!code) return;
if (!force && byCode[code]) { setSelected(code); return; }
setSelected(code);
setLoading(true);
setErr('');
try {
setResults((await GetAwards()) as any);
const r = (await GetAward(code)) as any as AwardResult;
setByCode((m) => ({ ...m, [code]: r }));
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
const current = results.find((r) => r.code === selected) ?? results[0];
// Load the award list (no QSO scan), then compute only the first award.
async function loadList() {
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs.map((d) => ({ code: d.code, name: d.name, valid: d.valid }));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
} catch (e: any) {
setErr(String(e?.message ?? e));
}
}
useEffect(() => { loadList(); }, []);
const current = byCode[selected];
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
if (!q) return current.refs;
return current.refs.filter((r) => r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q));
}, [current, refSearch]);
return current.refs.filter((r) => {
if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
return true;
});
}, [current, refSearch, refFilter]);
return (
<div className="flex h-full min-h-0">
@@ -75,34 +140,44 @@ export function AwardsPanel() {
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
<Pencil className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={load} disabled={loading} title="Recalculate">
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
title="Rescan all QSOs and recompute this award">
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
Rescan
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={load} />
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{results.map((r) => (
<button
key={r.code}
onClick={() => { setSelected(r.code); setRefSearch(''); }}
className={cn(
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
current?.code === r.code && 'bg-accent/60',
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-semibold text-sm">{r.code}</span>
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-600">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
</span>
</div>
<div className="text-[11px] text-muted-foreground truncate mb-1">{r.name}</div>
<ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />
</button>
))}
{awardList.map((a) => {
const r = byCode[a.code];
return (
<button
key={a.code}
onClick={() => { setRefSearch(''); compute(a.code); }}
className={cn(
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
selected === a.code && 'bg-accent/60',
a.valid === false && 'opacity-50',
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-semibold text-sm">{a.code}</span>
{r ? (
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-600">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
</span>
) : (
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
)}
</div>
<div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
{r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
</button>
);
})}
</div>
</div>
@@ -146,55 +221,202 @@ export function AwardsPanel() {
</div>
)}
{/* References table */}
<div className="flex items-center gap-2 px-4 py-2">
{/* References toolbar */}
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} reference{filteredRefs.length > 1 ? 's' : ''}</span>
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<div className="flex-1" />
{/* Legend */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
</div>
</div>
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? (
<span className="text-muted-foreground/70"> missing</span>
) : r.validated ? (
<span className="inline-flex items-center gap-1 text-sky-600"><CheckCircle2 className="size-3" /> valid.</span>
) : r.confirmed ? (
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
) : (
<span className="text-amber-600">worked</span>
)}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
<span key={b} className={cn('mr-1', r.confirmed_bands.includes(b) && 'text-emerald-600 font-semibold')}>{b}</span>
))}
</td>
{/* Statistics matrix: status × band, by mode category */}
{view === 'stats' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
{statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
) : (
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
</tr>
</thead>
<tbody>
{stats.rows.map((row, i) => {
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{row.cells.map((c, j) => (
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
))}
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
{GRID_BANDS.map((b) => (
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
))}
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{GRID_BANDS.map((b) => {
const s = cellStatus(r, b);
return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-9 h-5" /> : (
<button
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span>
: r.confirmed ? <span className="text-amber-600">confirmed</span>
: <span className="text-stone-500">worked</span>}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
)}
</div>
);
}
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
AwardCellQSOs(code, cell.ref, cell.band)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
}, [code, cell.ref, cell.band]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
{cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
<div className="flex-1" />
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i} className="border-b border-border/30">
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
</div>
</div>
);
}
+6
View File
@@ -21,6 +21,8 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
export function ClearLookupCache():Promise<void>;
@@ -97,12 +99,16 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAward(arg1:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>;
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>;
+12
View File
@@ -14,6 +14,10 @@ export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
}
export function AwardFields() {
return window['go']['main']['App']['AwardFields']();
}
@@ -166,6 +170,10 @@ export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1);
}
export function GetAwardDefs() {
return window['go']['main']['App']['GetAwardDefs']();
}
@@ -178,6 +186,10 @@ export function GetAwardReferenceMeta() {
return window['go']['main']['App']['GetAwardReferenceMeta']();
}
export function GetAwardStats(arg1) {
return window['go']['main']['App']['GetAwardStats'](arg1);
}
export function GetAwards() {
return window['go']['main']['App']['GetAwards']();
}
+54
View File
@@ -164,6 +164,7 @@ export namespace award {
validated: boolean;
bands: string[];
confirmed_bands: string[];
validated_bands: string[];
static createFrom(source: any = {}) {
return new Ref(source);
@@ -180,6 +181,7 @@ export namespace award {
this.validated = source["validated"];
this.bands = source["bands"];
this.confirmed_bands = source["confirmed_bands"];
this.validated_bands = source["validated_bands"];
}
}
export class Result {
@@ -676,6 +678,58 @@ export namespace main {
this.can_update = source["can_update"];
}
}
export class AwardStatRow {
label: string;
cells: number[];
total: number;
grand_total: number;
static createFrom(source: any = {}) {
return new AwardStatRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.cells = source["cells"];
this.total = source["total"];
this.grand_total = source["grand_total"];
}
}
export class AwardStatsResult {
code: string;
bands: string[];
rows: AwardStatRow[];
static createFrom(source: any = {}) {
return new AwardStatsResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.bands = source["bands"];
this.rows = this.convertValues(source["rows"], AwardStatRow);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class BackupSettings {
enabled: boolean;
folder: string;