diff --git a/app.go b/app.go index f478ac2..b9bb6fb 100644 --- a/app.go +++ b/app.go @@ -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)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59fbda1..a83f8f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2706,7 +2706,7 @@ export default function App() { - + @@ -2940,7 +2940,7 @@ export default function App() { Fix country & zones (cty.dat + ClubLog) - Recompute Country, DXCC & 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 & 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 Update duplicates to re-fix QSOs already in your log. diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 23e1a47..15de1e1 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -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(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 */}
- {err &&
{err}
} + {err &&
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}
} {!cur ? (
Select or create an award.
) : ( @@ -293,7 +310,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { @@ -530,9 +552,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /> patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" />
- patchSel({ score: parseInt(e.target.value, 10) || 0 })} /> - patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /> - patchSel({ gridsquare: e.target.value })} /> +
+ + patchSel({ score: parseInt(e.target.value, 10) || 0 })} /> +
+
+ + patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /> +
+
+ + patchSel({ gridsquare: e.target.value })} /> +
diff --git a/frontend/src/components/AwardRefSelector.tsx b/frontend/src/components/AwardRefSelector.tsx index 1daf926..aee3984 100644 --- a/frontend/src/components/AwardRefSelector.tsx +++ b/frontend/src/components/AwardRefSelector.tsx @@ -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 => { + 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; diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index fbeb9b6..cea7609 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -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([]); // 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(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() { setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} /> + {/* Quick selector — scales when there are many awards. */} + {awardList.length > 0 && ( +
+ +
+ )}
{err &&
{err}
} {awardList.map((a) => { @@ -236,6 +256,13 @@ export function AwardsPanel() { ))}
{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''} +
{/* Legend */}
@@ -364,6 +391,83 @@ export function AwardsPanel() { {cell && current && ( setCell(null)} /> )} + {showMissing && current && ( + setShowMissing(false)} onEditQSO={onEditQSO} /> + )} +
+ ); +} + +// 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([]); + 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 ( +
+
e.stopPropagation()}> +
+ + {code} — contacts missing a reference + {name && {name}} +
+ + +
+
+ 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.'} +
+
+ {loading ? ( +
Scanning…
+ ) : qsos.length === 0 ? ( +
+ No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.) +
+ ) : ( + + + + + + {qsos.map((q, i) => ( + onEditQSO && q.id && onEditQSO(q.id as number)}> + + + + + + + + ))} + +
Date (UTC)CallsignBandModeCountryQTH / Note
{fmt(q.qso_date)}{q.callsign}{q.band}{q.mode}{q.country}{q.qth || q.notes}
+ )} +
+
+ {stations} station{stations > 1 ? 's' : ''} ·{' '} + {qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference +
+
); } diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index db6cb1e..9f6b51c 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -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(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([]); @@ -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 ? : } Sync hunter log +