From e590a5870268de03cbbbde345936f3d07d8826b2 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 4 Jul 2026 22:54:41 +0200 Subject: [PATCH] feat: Contest mode added --- app.go | 7 ++ frontend/src/App.tsx | 62 ++++++++++-- frontend/src/components/ContestBar.tsx | 115 +++++++++++++++++++++++ frontend/src/components/QSOEditModal.tsx | 2 +- frontend/wailsjs/go/main/App.d.ts | 3 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 21 +++++ internal/contest/contest.go | 85 +++++++++++++++++ 8 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ContestBar.tsx create mode 100644 internal/contest/contest.go diff --git a/app.go b/app.go index db482a5..c691cca 100644 --- a/app.go +++ b/app.go @@ -29,6 +29,7 @@ import ( "hamlog/internal/cat" "hamlog/internal/clublog" "hamlog/internal/cluster" + "hamlog/internal/contest" "hamlog/internal/cwdecode" "hamlog/internal/db" "hamlog/internal/dxcc" @@ -3768,6 +3769,12 @@ func (a *App) writeCabrillo(path string, iterate func(func(qso.QSO) error) error } // ExportCabrillo writes every QSO to a Cabrillo file. +// ListContests returns the built-in contest catalogue (ADIF CONTEST_ID + default +// exchange), sorted by name, for the contest-mode picker. +func (a *App) ListContests() []contest.Def { + return contest.List() +} + func (a *App) ExportCabrillo(path string) (CabrilloResult, error) { return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) }) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ecdebf..8bfbace 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -68,6 +68,7 @@ import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; +import { ContestBar, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestBar'; import { AlertsModal } from '@/components/AlertsModal'; import { BulkEditModal } from '@/components/BulkEditModal'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; @@ -339,6 +340,11 @@ export default function App() { const [grid, setGrid] = useState(''); const [name, setName] = useState(''); const [qth, setQth] = useState(''); + // Contest mode: a picked contest + exchange config; persisted so a running + // contest (serial counter included) survives a restart. `rcv` is the received + // exchange for the current QSO. + const [contest, setContest] = useState(CONTEST_DEFAULT); + const [rcv, setRcv] = useState(''); const [country, setCountry] = useState(''); const [comment, setComment] = useState(''); const [note, setNote] = useState(''); @@ -576,6 +582,20 @@ export default function App() { }); useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]); + // Contest session: load once, then persist on every change (merge + save). + useEffect(() => { + GetUIPref('opslog.contest').then((s) => { + if (s) { try { setContest({ ...CONTEST_DEFAULT, ...JSON.parse(s) }); } catch {} } + }).catch(() => {}); + }, []); + const updateContest = (patch: Partial) => { + setContest((c) => { + const next = { ...c, ...patch }; + writeUiPref('opslog.contest', JSON.stringify(next)); + return next; + }); + }; + // === DX Cluster live state === type ClusterSpot = { source_id: number; @@ -1833,7 +1853,23 @@ export default function App() { email: details.email, }; applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); + // Contest mode overrides the exchange fields: CONTEST_ID, the sent + // exchange (running serial or fixed value) and the received exchange. + if (contest.active && contest.code) { + const sent = contest.exchange === 'serial' ? String(contest.nextSerial) : contest.sentFixed; + const sE = exchangeFields(sent); + const rE = exchangeFields(rcv.trim()); + Object.assign(payload, { + contest_id: contest.code, + stx: sE.num, stx_string: sE.str, + srx: rE.num, srx_string: rE.str, + }); + } await AddQSO(payload); + // Advance the sent serial only after a successful log. + if (contest.active && contest.code && contest.exchange === 'serial') { + updateContest({ nextSerial: contest.nextSerial + 1 }); + } resetEntry(); // clears the call AND the Worked-before matrix callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name) await refresh(); @@ -1849,7 +1885,7 @@ export default function App() { // Discard any in-progress QSO recording (no-op if it was already saved on // log, or if the recorder is off). QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; - setCallsign(''); setComment(''); setNote(''); + setCallsign(''); setComment(''); setNote(''); setRcv(''); if (!locks.start) setQsoStartedAt(null); if (!locks.end) setQsoEndedAt(null); resetAutoFill(); @@ -2539,7 +2575,22 @@ export default function App() { { setName(e.target.value); markEdited('name'); }} /> ); - const qthBlock = ( + // In contest mode QTH is replaced by the sent (read-only, auto) and received + // exchange fields — the exchange is what matters, not the town. + const contestSnt = contest.exchange === 'serial' + ? String(contest.nextSerial).padStart(3, '0') + : (contest.sentFixed || '—'); + const qthBlock = contest.active ? ( + <> +
+ +
+
+ setRcv(e.target.value.toUpperCase())} /> +
+ + ) : (
{ setQth(e.target.value); markEdited('qth'); }} />
@@ -2602,7 +2653,7 @@ export default function App() { // CQ/ITU zones moved to the Info (F2) tab (DetailsPanel). const freqBlock = (
- + { setName(e.target.value); markEdited('name'); }} />
-
- { setQth(e.target.value); markEdited('qth'); }} /> -
+ {qthBlock} {gridBlock} @@ -3638,6 +3687,7 @@ export default function App() { {/* ===== LOWER: tabbed table / cluster / band map ===== */} {compact ? null : <> +
diff --git a/frontend/src/components/ContestBar.tsx b/frontend/src/components/ContestBar.tsx new file mode 100644 index 0000000..a940cad --- /dev/null +++ b/frontend/src/components/ContestBar.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import { ListContests } from '../../wailsjs/go/main/App'; +import { cn } from '@/lib/utils'; + +// ContestSession is the live contest-mode state, persisted as JSON in a UI pref +// so a running contest survives a restart (serial counter included). +export type ContestSession = { + active: boolean; + code: string; // ADIF CONTEST_ID written to each QSO + name: string; // display name + 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 +}; + +export const CONTEST_DEFAULT: ContestSession = { + active: false, code: '', name: '', exchange: 'serial', + fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1, +}; + +const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom']; + +// Map the catalogue's default exchange hint onto a fixed-kind label. +const KIND_LABEL: Record = { + '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 }; + +export function ContestBar({ session, onChange }: { + session: ContestSession; + onChange: (patch: Partial) => void; +}) { + const [contests, setContests] = useState([]); + useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []); + + 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' }); + } + }; + + if (!session.active) { + return ( +
+ +
+ ); + } + + const snt = session.exchange === 'serial' + ? String(session.nextSerial).padStart(3, '0') + : (session.sentFixed || '—'); + + return ( +
+ + Contest + + + {session.code && {session.code}} + + {/* Serial vs fixed exchange. */} +
+ {(['serial', 'fixed'] as const).map((x) => ( + + ))} +
+ + {session.exchange === 'serial' ? ( + + ) : ( +
+ + onChange({ sentFixed: e.target.value.toUpperCase() })} + placeholder="your exch" className="w-24 rounded-md border border-border bg-card px-2 py-1 text-xs font-mono" /> +
+ )} + + + Snt {snt} + + + +
+ ); +} diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index 3103d4d..7103567 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -426,7 +426,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b {flagURL(draft.dxcc) && }
- + setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 119835b..71645a7 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -14,6 +14,7 @@ import {powergenius} from '../models'; import {winkeyer} from '../models'; import {alerts} from '../models'; import {audio} from '../models'; +import {contest} from '../models'; import {operating} from '../models'; import {udp} from '../models'; import {lookup} from '../models'; @@ -391,6 +392,8 @@ export function ListAwardReferences(arg1:string):Promise>; export function ListClusterServers():Promise>; +export function ListContests():Promise>; + export function ListCountries():Promise>; export function ListOperatingTree():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3e7b2ef..6774c7d 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -746,6 +746,10 @@ export function ListClusterServers() { return window['go']['main']['App']['ListClusterServers'](); } +export function ListContests() { + return window['go']['main']['App']['ListContests'](); +} + export function ListCountries() { return window['go']['main']['App']['ListCountries'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 303a3ce..0abca06 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -879,6 +879,27 @@ export namespace cluster { } +export namespace contest { + + export class Def { + code: string; + name: string; + exchange: string; + + static createFrom(source: any = {}) { + return new Def(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.code = source["code"]; + this.name = source["name"]; + this.exchange = source["exchange"]; + } + } + +} + export namespace extsvc { export class ServiceConfig { diff --git a/internal/contest/contest.go b/internal/contest/contest.go new file mode 100644 index 0000000..2541d69 --- /dev/null +++ b/internal/contest/contest.go @@ -0,0 +1,85 @@ +// Package contest holds a curated catalogue of the major amateur-radio contests +// with their official ADIF CONTEST_ID codes and a default exchange kind. It is +// deliberately a built-in static list (not an online fetch): the ADIF code is +// what must be written to each logged QSO's CONTEST_ID, and those codes don't +// change — bundling them means correct codes, offline, no fragile scraping. +// +// The Exchange field is only a UI hint for what a station typically sends; the +// operator can always override it (serial vs fixed, and the fixed kind) when +// activating the contest. +package contest + +import "sort" + +// Exchange kinds (UI prefill for the sent/received exchange). +const ( + ExSerial = "serial" // RST + running serial number + ExCQZone = "cq-zone" // RST + CQ zone + ExITUZone = "itu-zone" // RST + ITU zone + ExState = "state" // RST + state / province + ExDept = "dept" // RST + département (REF) + ExGrid = "grid" // RST + Maidenhead grid + ExName = "name" // name + state (NAQP-style) + ExClassSection = "class-section" // Field Day: class + ARRL/RAC section + ExRST = "rst" // RST only +) + +// Def is one contest: its ADIF CONTEST_ID, a readable name, and the exchange a +// station typically sends. +type Def struct { + Code string `json:"code"` + Name string `json:"name"` + Exchange string `json:"exchange"` +} + +// catalogue is the built-in list. Codes are ADIF CONTEST_ID values. +var catalogue = []Def{ + {"CQ-WW-CW", "CQ WW DX Contest (CW)", ExCQZone}, + {"CQ-WW-SSB", "CQ WW DX Contest (SSB)", ExCQZone}, + {"CQ-WW-RTTY", "CQ WW DX RTTY Contest", ExCQZone}, + {"CQ-WPX-CW", "CQ WPX Contest (CW)", ExSerial}, + {"CQ-WPX-SSB", "CQ WPX Contest (SSB)", ExSerial}, + {"CQ-WPX-RTTY", "CQ WPX RTTY Contest", ExSerial}, + {"CQ-160-CW", "CQ 160-Meter Contest (CW)", ExState}, + {"CQ-160-SSB", "CQ 160-Meter Contest (SSB)", ExState}, + {"CQ-VHF", "CQ WW VHF Contest", ExGrid}, + {"ARRL-DX-CW", "ARRL International DX Contest (CW)", ExState}, + {"ARRL-DX-SSB", "ARRL International DX Contest (SSB)", ExState}, + {"ARRL-10", "ARRL 10-Meter Contest", ExState}, + {"ARRL-160", "ARRL 160-Meter Contest", ExState}, + {"ARRL-SS-CW", "ARRL Sweepstakes (CW)", ExSerial}, + {"ARRL-SS-SSB", "ARRL Sweepstakes (SSB)", ExSerial}, + {"ARRL-FIELD-DAY", "ARRL Field Day", ExClassSection}, + {"ARRL-RTTY", "ARRL RTTY Roundup", ExState}, + {"ARRL-DIGI", "ARRL Digital Contest", ExGrid}, + {"ARRL-VHF-JAN", "ARRL January VHF Contest", ExGrid}, + {"ARRL-VHF-JUN", "ARRL June VHF Contest", ExGrid}, + {"ARRL-VHF-SEP", "ARRL September VHF Contest", ExGrid}, + {"ARRL-UHF-AUG", "ARRL August UHF Contest", ExGrid}, + {"IARU-HF", "IARU HF World Championship", ExITUZone}, + {"WAE-DX-CW", "WAE DX Contest (CW)", ExSerial}, + {"WAE-DX-SSB", "WAE DX Contest (SSB)", ExSerial}, + {"WAE-DX-RTTY", "WAE DX Contest (RTTY)", ExSerial}, + {"NAQP-CW", "North American QSO Party (CW)", ExName}, + {"NAQP-SSB", "North American QSO Party (SSB)", ExName}, + {"NAQP-RTTY", "North American QSO Party (RTTY)", ExName}, + {"STEW-PERRY", "Stew Perry Topband Challenge", ExGrid}, + {"REF-CW", "REF Contest (CW)", ExDept}, + {"REF-SSB", "REF Contest (SSB)", ExDept}, + {"REF-160M", "REF 160m Contest", ExDept}, + {"OCEANIA-DX-CW", "Oceania DX Contest (CW)", ExSerial}, + {"OCEANIA-DX-SSB", "Oceania DX Contest (SSB)", ExSerial}, + {"HELVETIA", "Helvetia Contest", ExSerial}, + {"UKRAINIAN-DX", "Ukrainian DX Contest", ExSerial}, + {"WW-DIGI", "World Wide Digi DX Contest", ExGrid}, + {"EU-HF-C", "EU HF Championship", ExSerial}, + {"DARC-WAEDC", "WAEDC", ExSerial}, +} + +// List returns the catalogue sorted by name (copy — callers must not mutate). +func List() []Def { + out := make([]Def, len(catalogue)) + copy(out, catalogue) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +}