awards
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 & zones (cty.dat + ClubLog)
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
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 <strong>Update duplicates</strong> to re-fix QSOs already in your log.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+4
-2
@@ -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>;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user