diff --git a/app.go b/app.go index c691cca..8e5a638 100644 --- a/app.go +++ b/app.go @@ -3775,6 +3775,170 @@ func (a *App) ListContests() []contest.Def { return contest.List() } +// ContestBandRow is one band's QSO count in the contest scoreboard. +type ContestBandRow struct { + Band string `json:"band"` + Count int `json:"count"` +} + +// ContestStatsResult is the live contest scoreboard. Score is a best-effort +// ESTIMATE (qsos × mult): real contest scoring is per-contest and rule-heavy, so +// this is a working indicator, not an official claim. +type ContestStatsResult struct { + QSOs int `json:"qsos"` + ByBand []ContestBandRow `json:"by_band"` + Mult int `json:"mult"` + MultLabel string `json:"mult_label"` + Score int `json:"score"` + LastHour int `json:"last_hour"` // QSOs in the last 60 min (rate) +} + +// parseTimeISO parses an RFC3339(/nano) timestamp; ok=false for an empty/invalid +// value (meaning "no bound"). +func parseTimeISO(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t, true + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, true + } + return time.Time{}, false +} + +// ContestStats computes the scoreboard for the given CONTEST_ID within the +// [startISO, endISO] window (either bound may be empty = open). exchange is the +// active exchange kind, which decides what counts as a multiplier. +func (a *App) ContestStats(contestID, exchange, startISO, endISO string) ContestStatsResult { + res := ContestStatsResult{} + if a.qso == nil || strings.TrimSpace(contestID) == "" { + return res + } + start, hasStart := parseTimeISO(startISO) + end, hasEnd := parseTimeISO(endISO) + f := qso.QueryFilter{ + Conditions: []qso.Condition{{Field: "contest_id", Op: "eq", Value: contestID}}, + Match: "AND", Limit: 1_000_000, + } + bandCount := map[string]int{} + multSet := map[string]struct{}{} + label := "DXCC" + cutoff := time.Now().Add(-time.Hour) + _ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error { + if hasStart && q.QSODate.Before(start) { + return nil + } + if hasEnd && q.QSODate.After(end) { + return nil + } + res.QSOs++ + if q.Band != "" { + bandCount[q.Band]++ + } + if !q.QSODate.IsZero() && q.QSODate.After(cutoff) { + res.LastHour++ + } + switch exchange { + case "cq-zone": + label = "Zones" + if q.CQZ != nil && *q.CQZ > 0 { + multSet[fmt.Sprintf("Z%d", *q.CQZ)] = struct{}{} + } + case "itu-zone": + label = "Zones" + if q.ITUZ != nil && *q.ITUZ > 0 { + multSet[fmt.Sprintf("I%d", *q.ITUZ)] = struct{}{} + } + case "dept", "state", "name", "grid", "age", "section": + label = "Mults" + k := strings.ToUpper(strings.TrimSpace(q.SRXString)) + if k == "" && q.SRX != nil { + k = fmt.Sprintf("%d", *q.SRX) + } + if k != "" { + multSet[k] = struct{}{} + } + default: // serial and others + if strings.Contains(strings.ToUpper(contestID), "WPX") { + label = "Prefixes" + if p := award.WPXPrefix(q.Callsign); p != "" { + multSet[p] = struct{}{} + } + } else { + label = "DXCC" + if q.DXCC != nil && *q.DXCC > 0 { + multSet[fmt.Sprintf("%d", *q.DXCC)] = struct{}{} + } + } + } + return nil + }) + res.Mult = len(multSet) + res.MultLabel = label + if res.Mult > 0 { + res.Score = res.QSOs * res.Mult + } else { + res.Score = res.QSOs + } + for b, c := range bandCount { + res.ByBand = append(res.ByBand, ContestBandRow{Band: b, Count: c}) + } + if res.ByBand == nil { + res.ByBand = []ContestBandRow{} // never nil → marshals as [] not null (frontend guard) + } + sort.Slice(res.ByBand, func(i, j int) bool { return res.ByBand[i].Band < res.ByBand[j].Band }) + return res +} + +// ContestDupe reports whether call was already worked in this contest on the +// same band + mode class since startISO (the usual dupe rule). Empty band/mode/ +// startISO widens the check. +func (a *App) ContestDupe(call, contestID, band, mode, startISO string) bool { + if a.qso == nil || strings.TrimSpace(call) == "" || strings.TrimSpace(contestID) == "" { + return false + } + start, hasStart := parseTimeISO(startISO) + f := qso.QueryFilter{ + Conditions: []qso.Condition{ + {Field: "contest_id", Op: "eq", Value: contestID}, + {Field: "callsign", Op: "eq", Value: strings.ToUpper(strings.TrimSpace(call))}, + }, + Match: "AND", Limit: 1000, + } + if strings.TrimSpace(band) != "" { + f.Conditions = append(f.Conditions, qso.Condition{Field: "band", Op: "eq", Value: band}) + } + want := contestModeClass(mode) + dupe := false + _ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error { + if hasStart && q.QSODate.Before(start) { + return nil + } + if want == "" || contestModeClass(q.Mode) == want { + dupe = true + } + return nil + }) + return dupe +} + +// contestModeClass collapses a mode to the CW/PH/DIG bucket contests dupe on. +func contestModeClass(mode string) string { + switch strings.ToUpper(strings.TrimSpace(mode)) { + case "": + return "" + case "CW": + return "CW" + case "SSB", "USB", "LSB", "AM", "FM", "PH", "PHONE": + return "PH" + default: + return "DIG" + } +} + 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 8bfbace..1277999 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected, + ContestDupe, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, @@ -68,7 +69,8 @@ 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 { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel'; +import { useI18n } from '@/lib/i18n'; import { AlertsModal } from '@/components/AlertsModal'; import { BulkEditModal } from '@/components/BulkEditModal'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; @@ -247,6 +249,7 @@ function computePrefix(call: string): string { } export default function App() { + const { t } = useI18n(); // === Lists from settings (fallback for first paint) === const [bands, setBands] = useState(DEFAULT_BANDS); const [modes, setModes] = useState(DEFAULT_MODES); @@ -345,6 +348,7 @@ export default function App() { // exchange for the current QSO. const [contest, setContest] = useState(CONTEST_DEFAULT); const [rcv, setRcv] = useState(''); + const [contestDupe, setContestDupe] = useState(false); const [country, setCountry] = useState(''); const [comment, setComment] = useState(''); const [note, setNote] = useState(''); @@ -595,6 +599,18 @@ export default function App() { return next; }); }; + // Contest dupe check: is the typed callsign already worked in this contest on + // the current band + mode? Debounced so it doesn't fire on every keystroke. + useEffect(() => { + if (!contest.active || !contest.code || !callsign.trim()) { setContestDupe(false); return; } + const call = callsign.trim().toUpperCase(); + let alive = true; + const t = window.setTimeout(() => { + ContestDupe(call, contest.code, band, mode, contest.startISO).then((d) => { if (alive) setContestDupe(!!d); }).catch(() => {}); + }, 250); + return () => { alive = false; window.clearTimeout(t); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [callsign, contest.active, contest.code, band, mode, contest.startISO]); // === DX Cluster live state === type ClusterSpot = { @@ -2278,36 +2294,36 @@ export default function App() { } const menus: Menu[] = useMemo(() => [ - { name: 'file', label: 'File', items: [ - { type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' }, - { type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 }, - { type: 'item', label: 'Export Cabrillo…', action: 'file.exportCabrillo', disabled: total === 0 }, + { name: 'file', label: t('menu.file'), items: [ + { type: 'item', label: t('file.import'), action: 'file.import', shortcut: 'Ctrl+O' }, + { type: 'item', label: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 }, + { type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 }, { type: 'separator' }, - { type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 }, + { type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 }, { type: 'separator' }, - { type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true }, + { type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true }, ]}, - { name: 'edit', label: 'Edit', items: [ - { type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, + { name: 'edit', label: t('menu.edit'), items: [ + { type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null }, { type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 }, { type: 'separator' }, - { type: 'item', label: 'Preferences…', action: 'edit.prefs' }, + { type: 'item', label: t('edit.prefs'), action: 'edit.prefs' }, ]}, - { name: 'view', label: 'View', items: [ - { type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' }, - { type: 'item', label: 'Clear filters', action: 'view.clearfilters' }, + { name: 'view', label: t('menu.view'), items: [ + { type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' }, + { type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' }, ]}, - { name: 'tools', label: 'Tools', items: [ - { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' }, - { type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' }, + { name: 'tools', label: t('menu.tools'), items: [ + { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, + { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'separator' }, - { type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' }, - { type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' }, - { type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' }, + { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, + { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, + { type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' }, { type: 'separator' }, - { type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' }, - { type: 'item', label: 'Alert management…', action: 'tools.alerts' }, + { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' }, + { type: 'item', label: t('tools.alerts'), action: 'tools.alerts' }, { type: 'separator' }, // Maintenance — bumped here while we only have one entry. Will move // to a Tools → Maintenance submenu once Clublog + LoTW refresh land. @@ -2317,7 +2333,7 @@ export default function App() { { name: 'help', label: 'Help', items: [ { type: 'item', label: 'About OpsLog', action: 'help.about' }, ]}, - ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]); + ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, t]); function handleMenu(action: string) { switch (action) { @@ -2447,8 +2463,8 @@ export default function App() { // handlers across the two layouts. const callsignBlock = (
-