This commit is contained in:
2026-06-07 01:11:37 +02:00
parent 17f7a00bd7
commit 16c04fc12b
13 changed files with 418 additions and 52 deletions
+116 -21
View File
@@ -189,6 +189,7 @@ const (
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -1635,6 +1636,45 @@ func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
return out, err
}
// AwardMissingQSOs returns the contacts that fall within an award's scope but
// yield NO reference — so they're silently excluded from the award. Example:
// a French QSO (DXCC 227, in DDFM scope) whose note has no "Dxx" department.
// The operator can then open each and add the missing reference.
//
// Only awards with a DXCC scope are meaningful here: without it, "in scope" is
// the whole log, so e.g. POTA would report every non-POTA QSO. Such awards
// return an empty list (the UI explains why).
func (a *App) AwardMissingQSOs(code string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return nil, fmt.Errorf("unknown award %q", code)
}
if len(def.DXCCFilter) == 0 {
return []qso.QSO{}, nil // not meaningful without a DXCC scope
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
// In the award's scope, yet no reference extracted → a gap to fix.
if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 {
out = append(out, q)
}
return nil
})
return out, err
}
// GetPOTAToken returns the stored pota.app session token (for the settings UI).
func (a *App) GetPOTAToken() string {
if a.settings == nil {
@@ -1672,6 +1712,8 @@ type POTASyncResult struct {
Added int `json:"added"` // new QSOs inserted (addMissing)
Unmatched int `json:"unmatched"` // no local QSO and not added
UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped)
SkippedOtherCall int `json:"skipped_other_call"` // hunts made under another callsign (onlyMyCall)
MyCall string `json:"my_call"` // the profile call used for the onlyMyCall filter
}
// SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on
@@ -1681,7 +1723,10 @@ type POTASyncResult struct {
// (same QSO at several parks, logged within minutes) are appended.
// When addMissing is true, hunter-log entries whose callsign isn't in the log
// at all are inserted as new QSOs (callsign/date/band/mode/park, cty.dat-enriched).
func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
// onlyMyCall, when true, processes only hunts made under the active profile's
// callsign — so hunts you made under another call (e.g. XV9Q, NQ2H) that aren't
// in this logbook are skipped rather than reported as "not in your log".
func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResult, error) {
if a.qso == nil || a.settings == nil {
return POTASyncResult{}, fmt.Errorf("db not initialized")
}
@@ -1690,6 +1735,14 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
if err != nil {
return POTASyncResult{}, err
}
// The active profile's callsign drives the onlyMyCall filter (base call, so
// F4BPO/P and F4BPO are the same identity).
myCall := ""
if a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
myCall = pota.BaseCall(p.Callsign)
}
}
var all []qso.QSO
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
all = append(all, q)
@@ -1707,7 +1760,7 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
const nferWindow = 15 * time.Minute // append a 2nd park only for the same physical QSO
const maxDetail = 300
res := POTASyncResult{Fetched: len(entries)}
res := POTASyncResult{Fetched: len(entries), MyCall: myCall}
toUpdate := map[int]struct{}{}
var toAdd []pota.HunterQSO
addUnmatched := func(e pota.HunterQSO, reason string, qsoID int64) {
@@ -1724,6 +1777,12 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
}
for _, e := range entries {
// Skip hunts made under another of your callsigns (not this profile's).
// They legitimately aren't in this logbook, so don't flag them as errors.
if onlyMyCall && myCall != "" && e.Hunter != "" && pota.BaseCall(e.Hunter) != myCall {
res.SkippedOtherCall++
continue
}
if e.Date.IsZero() {
addUnmatched(e, "POTA entry has no usable date", 0)
continue
@@ -2142,7 +2201,7 @@ func bandForHz(hz int64) string {
func isComputedAwardField(field string) bool {
switch field {
// Purely derived from the callsign / cty.dat — never assigned by hand.
case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid":
case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid", "grid4":
return true
}
// NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields
@@ -2796,14 +2855,21 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
im.SkipDuplicates = true
}
// When the user opts to fix countries on import, recompute from cty.dat and
// then apply ClubLog's date-ranged exceptions (which take precedence) if
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
// then apply ClubLog's date-ranged exceptions, which take precedence (e.g.
// TO2A on 2012-10-27 → French Guiana, not the cty.dat "TO" → France). We
// apply ClubLog whenever its data is LOADED, regardless of the live
// entry-form toggle: "apply cty" is an explicit request for the most
// accurate entity, and skipping ClubLog would DOWNGRADE DXpedition QSOs the
// source ADIF already had right. If the cache isn't loaded yet, try once.
if applyCty && a.clublog != nil && !a.clublog.Loaded() {
_ = a.clublog.EnsureLoaded()
}
clLoaded := a.clublog != nil && a.clublog.Loaded()
if applyCty {
im.Enrich = func(q *qso.QSO) {
a.enrichContactedFromCtyForce(q)
if clEnabled {
a.applyClublogException(q, false)
if clLoaded {
a.applyClublogException(q, true) // force: explicit import-time correction
}
}
}
@@ -4389,17 +4455,19 @@ type ConfirmationItem struct {
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
func (a *App) DownloadConfirmations(service string, addNotFound bool) error {
// since controls the date window: "" = everything, "last" = incremental since
// the service's last successful download, or an explicit "YYYY-MM-DD".
func (a *App) DownloadConfirmations(service string, addNotFound bool, since string) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound)
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) {
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
@@ -4413,20 +4481,31 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
ctx := context.Background()
matched, total, added := 0, 0, 0
// resolveSince turns the UI's request into a concrete date (or ""):
// "" → all
// "last" → the service's stored last-download date (incremental)
// "date" → used verbatim (expected YYYY-MM-DD)
resolveSince := func(lastKey string) string {
s := strings.TrimSpace(since)
if strings.EqualFold(s, "last") {
if a.settings != nil {
v, _ := a.settings.Get(ctx, a.profileScope()+lastKey)
return strings.TrimSpace(v)
}
return ""
}
return s
}
switch svc {
case extsvc.ServiceLoTW:
since := ""
if a.settings != nil {
// Scoped to the active profile — each identity tracks its own
// LoTW account's last incremental-download date.
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
}
if since != "" {
emit("Downloading LoTW confirmations received since " + since + "…")
sinceDate := resolveSince(keyExtLoTWLastDownload)
if sinceDate != "" {
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
} else {
emit("Downloading all LoTW confirmations…")
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since)
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
@@ -4509,7 +4588,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
}
case extsvc.ServiceQRZ:
emit("Fetching QRZ.com logbook…")
// QRZ's FETCH API has no server-side date filter, so we pull the logbook
// and (when a window is requested) skip records older than sinceDate by
// QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload)
if sinceDate != "" {
emit("Fetching QRZ.com logbook (QSOs since " + sinceDate + ")…")
} else {
emit("Fetching QRZ.com logbook…")
}
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
if err != nil {
emit("Fetch failed: " + err.Error())
@@ -4557,6 +4644,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
if !ok {
return nil
}
// Date window (client-side): skip QSOs older than the requested date.
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
return nil
}
total++
date := rec["qrzcom_qso_download_date"]
if date == "" {
@@ -4624,6 +4715,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
// Remember today so a later "since last download" pull is incremental.
if a.settings != nil {
_ = a.settings.Set(ctx, a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
+2 -2
View File
@@ -2706,7 +2706,7 @@ export default function App() {
</TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel />
<AwardsPanel onEditQSO={openEdit} />
</TabsContent>
</Tabs>
</section>
@@ -2940,7 +2940,7 @@ export default function App() {
<span>
Fix country &amp; zones (cty.dat + ClubLog)
<span className="block text-xs text-muted-foreground mt-0.5">
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF Reunion, TO2A 2012 French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log.
</span>
</span>
</label>
+38 -7
View File
@@ -44,7 +44,11 @@ type AwardRef = {
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
// Award types mirror Log4OM: REFERENCE (we supply a list), QSOFIELDS (scan any
// QSO field — handles state/grid4/zones/etc.), CALLSIGN. The type is
// organizational only; matching is driven by the field/pattern/dynamic options,
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
@@ -141,6 +145,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
// forever; the longer text (export path) gets a bit more time.
useEffect(() => {
if (!err) return;
const t = window.setTimeout(() => setErr(''), 8000);
return () => window.clearTimeout(t);
}, [err]);
const loadMeta = () => GetAwardReferenceMeta()
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
.catch(() => {});
@@ -214,7 +227,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
return defs.map((d, i) => ({ d, i })).filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q));
// Keep the original index `i` (used for selection/patch) but display the
// list sorted alphabetically by code — scales as the catalogue grows.
return defs.map((d, i) => ({ d, i }))
.filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q))
.sort((a, b) => a.d.code.localeCompare(b.d.code));
}, [defs, search]);
return (
@@ -251,7 +268,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all">{err}</div>}
{err && <div onClick={() => setErr('')} title="Click to dismiss" className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
) : (
@@ -293,7 +310,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label="Award type">
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>{AWARD_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}</SelectContent>
<SelectContent>{
// Keep a legacy type (DXCC/GRID) selectable if an existing
// award still uses it, so it isn't silently changed.
(cur.type && !AWARD_TYPES.includes(cur.type) ? [cur.type, ...AWARD_TYPES] : AWARD_TYPES)
.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)
}</SelectContent>
</Select>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
@@ -530,9 +552,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
<div className="grid grid-cols-3 gap-3">
<Field2 label="Score"><Input type="number" className="h-8 font-mono" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Bonus"><Input type="number" className="h-8 font-mono" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Grid"><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Score</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Bonus</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Grid</Label>
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
</div>
</div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
</div>
+20 -5
View File
@@ -15,7 +15,7 @@ type Meta = { code: string; count: number; can_update: boolean };
// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
// pickable (a lookup rarely fills the JA prefecture or VE province).
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid']);
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4']);
// If DXCC-filtered auto-results exceed this, require the user to type instead.
const AUTO_SHOW_MAX = 100;
@@ -86,15 +86,30 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
// For dynamic lists, restrict to the contacted entity; otherwise load all.
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
// Search helper with a DXCC fallback: try the entity-scoped query first, but
// if it finds nothing AND we were filtering by DXCC, retry unfiltered. This
// fixes awards whose references carry no per-ref DXCC (e.g. SOTA summits,
// where the country is in the summit prefix F/AB-001, not a DXCC column) —
// otherwise filtering by entity returns zero. POTA/IOTA keep entity filtering
// when their refs do match.
const searchRefs = async (query: string, limit: number): Promise<AwardRef[]> => {
let r = (await SearchAwardReferences(awardCode, query, refDxcc, limit)) as any as AwardRef[];
if ((!r || r.length === 0) && refDxcc > 0) {
r = (await SearchAwardReferences(awardCode, query, 0, limit)) as any as AwardRef[];
}
return r ?? [];
};
// Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1
// so we can distinguish "all results shown" from "too many to list".
useEffect(() => {
setAutoResults([]);
// Dynamic lists need an entity to scope to; predefined lists load regardless.
if (isDynamic && !dxcc) return;
SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1)
.then((r) => setAutoResults((r ?? []) as any))
searchRefs('', AUTO_SHOW_MAX + 1)
.then((r) => setAutoResults(r))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [awardCode, dxcc, isDynamic, refDxcc]);
// Typed search (2+ chars).
@@ -103,12 +118,12 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
const t = window.setTimeout(async () => {
setBusy(true);
try {
const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
setSearchResults((r ?? []) as any);
setSearchResults(await searchRefs(q, 50));
} catch { setSearchResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [awardCode, q, refDxcc]);
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
+108 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
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 { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
@@ -58,7 +59,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
type AwardListItem = { code: string; name: string; valid?: boolean };
export function AwardsPanel() {
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
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).
@@ -71,6 +72,7 @@ export function AwardsPanel() {
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 [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -105,7 +107,9 @@ export function AwardsPanel() {
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 }));
const list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
.sort((a, b) => a.code.localeCompare(b.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
@@ -147,6 +151,22 @@ export function AwardsPanel() {
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
{/* Quick selector — scales when there are many awards. */}
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
<SelectContent className="max-h-80">
{awardList.map((a) => (
<SelectItem key={a.code} value={a.code} className="text-xs">
<span className="font-semibold">{a.code}</span>
<span className="text-muted-foreground"> {a.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => {
@@ -236,6 +256,13 @@ export function AwardsPanel() {
))}
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<button
onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
>
<AlertTriangle className="size-3" /> Missing refs
</button>
<div className="flex-1" />
{/* Legend */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
@@ -364,6 +391,83 @@ export function AwardsPanel() {
{cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
)}
{showMissing && current && (
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
)}
</div>
);
}
// MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM).
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
AwardMissingQSOs(code)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [code]);
// Distinct stations vs total contacts (a station may appear on several QSOs).
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
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-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span>
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
<div className="flex-1" />
<button onClick={load} disabled={loading}
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
title="Recompute now — contacts you've fixed drop off the list">
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
{onEditQSO && ' Click a row to open the QSO and add the reference.'}
</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" /> Scanning</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity e.g. DDFM, WAS, RAC, WAJA.)
</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-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i}
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')}
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}>
<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-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
</div>
</div>
</div>
);
}
+45 -6
View File
@@ -42,7 +42,7 @@ type LogQSO = {
};
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[] };
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
const SENT_STATUSES = [
{ v: 'R', label: 'Requested' },
@@ -81,10 +81,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
const [potaAddMissing, setPotaAddMissing] = useState(false);
// Only sync hunts made under the active profile's callsign (skip XV9Q/NQ2H…).
const [potaOnlyMyCall, setPotaOnlyMyCall] = useState(true);
async function syncPota() {
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing)) as any as POTASync); }
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing, potaOnlyMyCall)) as any as POTASync); }
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
finally { setPotaSyncing(false); }
}
@@ -143,6 +145,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
// Download date window: 'last' = incremental since last pull, 'date' = from a
// chosen date, 'all' = everything.
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
const [sinceDate, setSinceDate] = useState('');
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
@@ -219,8 +225,13 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}
async function download() {
// Resolve the date window into the backend's `since` argument:
// 'all' → "" (everything)
// 'last' → "last" (incremental since last successful pull)
// 'date' → "YYYY-MM-DD" (the chosen date; falls back to all if empty)
const since = sinceMode === 'last' ? 'last' : sinceMode === 'date' ? sinceDate.trim() : '';
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
try { await DownloadConfirmations(service, addNotFound); }
try { await DownloadConfirmations(service, addNotFound, since); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
@@ -246,6 +257,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
Sync hunter log
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
Only my profile callsign
</label>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
Add not-found QSOs to my log
@@ -285,7 +300,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<div className="flex-1" />
{service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground">
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched / {potaRes.fetched}
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
</span>
)}
{service === 'paper' && paperRows.length > 0 && (
@@ -363,7 +378,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{potaRes && (
<>
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries). Rescan the POTA award to count the new references.
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
{potaRes.skipped_other_call > 0 && (
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
)}
{' '}Rescan the POTA award to count the new references.
</div>
{potaRes.unmatched_list?.length > 0 && (
<table className="w-full text-xs border-collapse">
@@ -512,11 +531,31 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" /> Download confirmations
</Button>
{/* Date window */}
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
<SelectTrigger className="h-8 w-[150px] text-xs" title="How far back to download">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last">Since last download</SelectItem>
<SelectItem value="date">Since date</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
{sinceMode === 'date' && (
<input
type="date"
value={sinceDate}
onChange={(e) => setSinceDate(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
/>
)}
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
+10
View File
@@ -433,6 +433,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
<div className="flex flex-col gap-2.5">
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
<div>
<Label>Continent</Label>
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">—</SelectItem>
{['NA', 'SA', 'EU', 'AF', 'AS', 'OC', 'AN'].map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
</div>
+4 -2
View File
@@ -29,6 +29,8 @@ export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array
export function AwardFields():Promise<Array<string>>;
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function ClearLookupCache():Promise<void>;
@@ -87,7 +89,7 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
@@ -317,7 +319,7 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean):Promise<main.POTASyncResult>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestClublogUpload():Promise<string>;
+8 -4
View File
@@ -30,6 +30,10 @@ export function AwardFields() {
return window['go']['main']['App']['AwardFields']();
}
export function AwardMissingQSOs(arg1) {
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
}
export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
@@ -146,8 +150,8 @@ export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
export function DownloadConfirmations(arg1, arg2) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
}
export function DuplicateProfile(arg1, arg2) {
@@ -606,8 +610,8 @@ export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
export function SyncPOTAHunterLog(arg1) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1);
export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
}
export function TestClublogUpload() {
+4
View File
@@ -1038,6 +1038,8 @@ export namespace main {
added: number;
unmatched: number;
unmatched_list: POTAUnmatched[];
skipped_other_call: number;
my_call: string;
static createFrom(source: any = {}) {
return new POTASyncResult(source);
@@ -1051,6 +1053,8 @@ export namespace main {
this.added = source["added"];
this.unmatched = source["unmatched"];
this.unmatched_list = this.convertValues(source["unmatched_list"], POTAUnmatched);
this.skipped_other_call = source["skipped_other_call"];
this.my_call = source["my_call"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
+43 -1
View File
@@ -152,7 +152,7 @@ func Migrate(defs []Def) ([]Def, bool) {
func Fields() []string {
return []string{
"dxcc", "cqz", "ituz", "prefix", "callsign",
"state", "cont", "country", "grid",
"state", "cont", "country", "grid", "grid4",
"iota", "sota_ref", "pota_ref", "wwff",
"name", "qth", "address", "comment", "note",
}
@@ -421,6 +421,12 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
// sources (lotw|qsl|eqsl). Exported for the statistics view.
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
// InScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission, dates) — independent of whether a reference was
// found. Used to surface "in scope but no reference" gaps (e.g. a French QSO
// missing its department for DDFM).
func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
func EmissionOf(mode string) string { return emissionOf(mode) }
@@ -709,6 +715,11 @@ func fieldRaw(field string, q *qso.QSO) string {
return q.Country
case "grid":
return q.Grid
case "grid4":
// VUCC: distinct 4-character grid squares. A QSO on a grid line carries
// several in VUCC_GRIDS; otherwise the 4-char prefix of GRIDSQUARE. The
// comma-joined result is split into one reference per square downstream.
return grid4Refs(q)
case "iota":
return q.IOTA
case "sota_ref":
@@ -738,6 +749,37 @@ func fieldRaw(field string, q *qso.QSO) string {
return ""
}
// grid4Refs returns the distinct 4-character grid squares a QSO contributes —
// from VUCC_GRIDS (a comma list when the contact straddles grid lines) if set,
// else the 4-char prefix of GRIDSQUARE. Joined with commas so the matcher
// counts each square separately (VUCC awards).
func grid4Refs(q *qso.QSO) string {
src := strings.TrimSpace(q.VUCCGrids)
if src == "" {
src = strings.TrimSpace(q.Grid)
}
if src == "" {
return ""
}
seen := map[string]struct{}{}
var out []string
for _, tok := range strings.FieldsFunc(src, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
g := strings.ToUpper(strings.TrimSpace(tok))
if len(g) > 4 {
g = g[:4]
}
if g == "" {
continue
}
if _, ok := seen[g]; ok {
continue
}
seen[g] = struct{}{}
out = append(out, g)
}
return strings.Join(out, ",")
}
func dxccAllowed(dxcc *int, filter []int) bool {
if dxcc == nil {
return false
+18
View File
@@ -128,6 +128,24 @@ func TestComputeMatchByDescription(t *testing.T) {
}
}
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
// 4-char prefix of GRIDSQUARE.
func TestComputeGrid4VUCC(t *testing.T) {
def := Def{Code: "VUCC", Type: TypeGrid, Field: "grid4", Dynamic: true,
Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "K1ABC", Band: "6m", Grid: "FN31PR", LOTWRcvd: "Y"}, // → FN31
{Callsign: "W2DEF", Band: "6m", VUCCGrids: "FN20,FN21,FN30,FN31"}, // grid-line: 4 squares
{Callsign: "W3GHI", Band: "2m", Grid: "FN20XX"}, // → FN20 (dup of above)
}
r := Compute([]Def{def}, qsos, nil, nil)[0]
// Distinct squares: FN31, FN20, FN21, FN30 = 4
if r.Worked != 4 {
t.Errorf("VUCC worked = %d, want 4 (%v)", r.Worked, refCodes(r))
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
+2
View File
@@ -17,6 +17,7 @@ const hunterLogURL = "https://api.pota.app/user/logbook?hunterOnly=1&page=%d&siz
// a park activator, carrying the park reference to stamp onto the local QSO.
type HunterQSO struct {
Worked string `json:"worked"` // activator callsign (the station worked)
Hunter string `json:"hunter"` // YOUR callsign for this hunt (may vary: F4BPO, XV9Q…)
Date time.Time `json:"date"` // QSO date/time (UTC)
Band string `json:"band"` // ADIF band, e.g. "20m"
Mode string `json:"mode"` // logged mode
@@ -95,6 +96,7 @@ func FetchHunterLog(ctx context.Context, token string, logf func(string, ...any)
}
out = append(out, HunterQSO{
Worked: act,
Hunter: strings.ToUpper(strings.TrimSpace(e.WorkedCallsign)), // your call for this hunt
Date: parseHunterTime(e.QSODateTime),
Band: strings.ToLower(strings.TrimSpace(e.Band)),
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),