awards
This commit is contained in:
@@ -189,6 +189,7 @@ const (
|
|||||||
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
|
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
|
||||||
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
|
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
|
||||||
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
|
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
|
// 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
|
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).
|
// GetPOTAToken returns the stored pota.app session token (for the settings UI).
|
||||||
func (a *App) GetPOTAToken() string {
|
func (a *App) GetPOTAToken() string {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -1672,6 +1712,8 @@ type POTASyncResult struct {
|
|||||||
Added int `json:"added"` // new QSOs inserted (addMissing)
|
Added int `json:"added"` // new QSOs inserted (addMissing)
|
||||||
Unmatched int `json:"unmatched"` // no local QSO and not added
|
Unmatched int `json:"unmatched"` // no local QSO and not added
|
||||||
UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped)
|
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
|
// 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.
|
// (same QSO at several parks, logged within minutes) are appended.
|
||||||
// When addMissing is true, hunter-log entries whose callsign isn't in the log
|
// 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).
|
// 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 {
|
if a.qso == nil || a.settings == nil {
|
||||||
return POTASyncResult{}, fmt.Errorf("db not initialized")
|
return POTASyncResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -1690,6 +1735,14 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return POTASyncResult{}, err
|
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
|
var all []qso.QSO
|
||||||
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||||
all = append(all, q)
|
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 nferWindow = 15 * time.Minute // append a 2nd park only for the same physical QSO
|
||||||
const maxDetail = 300
|
const maxDetail = 300
|
||||||
res := POTASyncResult{Fetched: len(entries)}
|
res := POTASyncResult{Fetched: len(entries), MyCall: myCall}
|
||||||
toUpdate := map[int]struct{}{}
|
toUpdate := map[int]struct{}{}
|
||||||
var toAdd []pota.HunterQSO
|
var toAdd []pota.HunterQSO
|
||||||
addUnmatched := func(e pota.HunterQSO, reason string, qsoID int64) {
|
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 {
|
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() {
|
if e.Date.IsZero() {
|
||||||
addUnmatched(e, "POTA entry has no usable date", 0)
|
addUnmatched(e, "POTA entry has no usable date", 0)
|
||||||
continue
|
continue
|
||||||
@@ -2142,7 +2201,7 @@ func bandForHz(hz int64) string {
|
|||||||
func isComputedAwardField(field string) bool {
|
func isComputedAwardField(field string) bool {
|
||||||
switch field {
|
switch field {
|
||||||
// Purely derived from the callsign / cty.dat — never assigned by hand.
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
// NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields
|
// 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
|
im.SkipDuplicates = true
|
||||||
}
|
}
|
||||||
// When the user opts to fix countries on import, recompute from cty.dat and
|
// 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
|
// then apply ClubLog's date-ranged exceptions, which take precedence (e.g.
|
||||||
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
|
// TO2A on 2012-10-27 → French Guiana, not the cty.dat "TO" → France). We
|
||||||
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
|
// 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 {
|
if applyCty {
|
||||||
im.Enrich = func(q *qso.QSO) {
|
im.Enrich = func(q *qso.QSO) {
|
||||||
a.enrichContactedFromCtyForce(q)
|
a.enrichContactedFromCtyForce(q)
|
||||||
if clEnabled {
|
if clLoaded {
|
||||||
a.applyClublogException(q, false)
|
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
|
// matching local QSOs' received status. LoTW only for now (the canonical
|
||||||
// confirmation system); runs in the background emitting the same
|
// confirmation system); runs in the background emitting the same
|
||||||
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
|
// "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 {
|
if a.qso == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
svc := extsvc.Service(service)
|
svc := extsvc.Service(service)
|
||||||
cfg := a.loadExternalServices()
|
cfg := a.loadExternalServices()
|
||||||
go a.runDownloadConfirmations(svc, cfg, addNotFound)
|
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
|
||||||
return nil
|
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) {
|
emit := func(line string) {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||||
@@ -4413,20 +4481,31 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
matched, total, added := 0, 0, 0
|
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 {
|
switch svc {
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
since := ""
|
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
||||||
if a.settings != nil {
|
if sinceDate != "" {
|
||||||
// Scoped to the active profile — each identity tracks its own
|
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
|
||||||
// LoTW account's last incremental-download date.
|
|
||||||
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
|
|
||||||
}
|
|
||||||
if since != "" {
|
|
||||||
emit("Downloading LoTW confirmations received since " + since + "…")
|
|
||||||
} else {
|
} else {
|
||||||
emit("Downloading all LoTW confirmations…")
|
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 {
|
if err != nil {
|
||||||
emit("Download failed: " + err.Error())
|
emit("Download failed: " + err.Error())
|
||||||
done(matched, total)
|
done(matched, total)
|
||||||
@@ -4509,7 +4588,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
case extsvc.ServiceQRZ:
|
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")
|
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emit("Fetch failed: " + err.Error())
|
emit("Fetch failed: " + err.Error())
|
||||||
@@ -4557,6 +4644,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
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++
|
total++
|
||||||
date := rec["qrzcom_qso_download_date"]
|
date := rec["qrzcom_qso_download_date"]
|
||||||
if date == "" {
|
if date == "" {
|
||||||
@@ -4624,6 +4715,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(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))
|
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:
|
default:
|
||||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||||
|
|||||||
@@ -2706,7 +2706,7 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||||
<AwardsPanel />
|
<AwardsPanel onEditQSO={openEdit} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
@@ -2940,7 +2940,7 @@ export default function App() {
|
|||||||
<span>
|
<span>
|
||||||
Fix country & zones (cty.dat + ClubLog)
|
Fix country & zones (cty.dat + ClubLog)
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ type AwardRef = {
|
|||||||
|
|
||||||
type Preset = { key: string; name: string; field: string; dxcc: number; refs: 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 = [
|
const CONFIRM_SRC = [
|
||||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
{ 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 [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState('');
|
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()
|
const loadMeta = () => GetAwardReferenceMeta()
|
||||||
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
|
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -214,7 +227,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toUpperCase();
|
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]);
|
}, [defs, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -251,7 +268,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<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 ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
<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">
|
<Field2 label="Award type">
|
||||||
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
<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>
|
</Select>
|
||||||
</Field2>
|
</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>
|
<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="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>
|
<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">
|
<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>
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<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>
|
<Label className="text-xs text-muted-foreground">Score</Label>
|
||||||
<Field2 label="Grid"><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
<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>
|
||||||
<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 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>
|
</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
|
// 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
|
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
|
||||||
// pickable (a lookup rarely fills the JA prefecture or VE province).
|
// 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.
|
// If DXCC-filtered auto-results exceed this, require the user to type instead.
|
||||||
const AUTO_SHOW_MAX = 100;
|
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.
|
// For dynamic lists, restrict to the contacted entity; otherwise load all.
|
||||||
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
|
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
|
// 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".
|
// so we can distinguish "all results shown" from "too many to list".
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoResults([]);
|
setAutoResults([]);
|
||||||
// Dynamic lists need an entity to scope to; predefined lists load regardless.
|
// Dynamic lists need an entity to scope to; predefined lists load regardless.
|
||||||
if (isDynamic && !dxcc) return;
|
if (isDynamic && !dxcc) return;
|
||||||
SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1)
|
searchRefs('', AUTO_SHOW_MAX + 1)
|
||||||
.then((r) => setAutoResults((r ?? []) as any))
|
.then((r) => setAutoResults(r))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [awardCode, dxcc, isDynamic, refDxcc]);
|
}, [awardCode, dxcc, isDynamic, refDxcc]);
|
||||||
|
|
||||||
// Typed search (2+ chars).
|
// Typed search (2+ chars).
|
||||||
@@ -103,12 +118,12 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
|
setSearchResults(await searchRefs(q, 50));
|
||||||
setSearchResults((r ?? []) as any);
|
|
||||||
} catch { setSearchResults([]); }
|
} catch { setSearchResults([]); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [awardCode, q, refDxcc]);
|
}, [awardCode, q, refDxcc]);
|
||||||
|
|
||||||
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
|
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3 } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
|
||||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
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 };
|
type AwardListItem = { code: string; name: string; valid?: boolean };
|
||||||
|
|
||||||
export function AwardsPanel() {
|
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
// first time it's selected (or when explicitly rescanned).
|
// 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 [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
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 [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -105,7 +107,9 @@ export function AwardsPanel() {
|
|||||||
async function loadList() {
|
async function loadList() {
|
||||||
try {
|
try {
|
||||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
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);
|
setAwardList(list);
|
||||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||||
if (first) compute(first.code);
|
if (first) compute(first.code);
|
||||||
@@ -147,6 +151,22 @@ export function AwardsPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
<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">
|
<div className="flex-1 overflow-auto">
|
||||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||||
{awardList.map((a) => {
|
{awardList.map((a) => {
|
||||||
@@ -236,6 +256,13 @@ export function AwardsPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
<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" />
|
<div className="flex-1" />
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
@@ -364,6 +391,83 @@ export function AwardsPanel() {
|
|||||||
{cell && current && (
|
{cell && current && (
|
||||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type LogQSO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
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 = [
|
const SENT_STATUSES = [
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'Requested' },
|
||||||
@@ -81,10 +81,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
const [potaAddMissing, setPotaAddMissing] = useState(false);
|
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() {
|
async function syncPota() {
|
||||||
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
|
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)); }
|
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
|
||||||
finally { setPotaSyncing(false); }
|
finally { setPotaSyncing(false); }
|
||||||
}
|
}
|
||||||
@@ -143,6 +145,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
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 [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
||||||
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
||||||
@@ -219,8 +225,13 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function download() {
|
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);
|
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); }
|
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" />}
|
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||||
Sync hunter log
|
Sync hunter log
|
||||||
</Button>
|
</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)">
|
<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)} />
|
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||||
Add not-found QSOs to my log
|
Add not-found QSOs to my log
|
||||||
@@ -285,7 +300,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{service === 'pota' && potaRes && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{service === 'paper' && paperRows.length > 0 && (
|
{service === 'paper' && paperRows.length > 0 && (
|
||||||
@@ -363,7 +378,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{potaRes && (
|
{potaRes && (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
<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>
|
</div>
|
||||||
{potaRes.unmatched_list?.length > 0 && (
|
{potaRes.unmatched_list?.length > 0 && (
|
||||||
<table className="w-full text-xs border-collapse">
|
<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) */}
|
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
|
||||||
{service !== 'pota' && service !== 'paper' && (
|
{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 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}
|
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||||
title="Fetch confirmations from the service and update received status">
|
title="Fetch confirmations from the service and update received status">
|
||||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||||
</Button>
|
</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">
|
<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)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
Add not-found
|
Add not-found
|
||||||
|
|||||||
@@ -433,6 +433,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
<div className="flex flex-col gap-2.5">
|
<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>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>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>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><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||||
</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 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 BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
@@ -87,7 +89,7 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
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>;
|
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 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>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export function AwardFields() {
|
|||||||
return window['go']['main']['App']['AwardFields']();
|
return window['go']['main']['App']['AwardFields']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardMissingQSOs(arg1) {
|
||||||
|
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1, arg2) {
|
export function BulkUpdateQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -146,8 +150,8 @@ export function DownloadClublogCty() {
|
|||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1, arg2) {
|
export function DownloadConfirmations(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1, arg2) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
@@ -606,8 +610,8 @@ export function SwitchCATRig(arg1) {
|
|||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1) {
|
export function SyncPOTAHunterLog(arg1, arg2) {
|
||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestClublogUpload() {
|
export function TestClublogUpload() {
|
||||||
|
|||||||
@@ -1038,6 +1038,8 @@ export namespace main {
|
|||||||
added: number;
|
added: number;
|
||||||
unmatched: number;
|
unmatched: number;
|
||||||
unmatched_list: POTAUnmatched[];
|
unmatched_list: POTAUnmatched[];
|
||||||
|
skipped_other_call: number;
|
||||||
|
my_call: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new POTASyncResult(source);
|
return new POTASyncResult(source);
|
||||||
@@ -1051,6 +1053,8 @@ export namespace main {
|
|||||||
this.added = source["added"];
|
this.added = source["added"];
|
||||||
this.unmatched = source["unmatched"];
|
this.unmatched = source["unmatched"];
|
||||||
this.unmatched_list = this.convertValues(source["unmatched_list"], POTAUnmatched);
|
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 {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|||||||
+43
-1
@@ -152,7 +152,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
|||||||
func Fields() []string {
|
func Fields() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||||
"state", "cont", "country", "grid",
|
"state", "cont", "country", "grid", "grid4",
|
||||||
"iota", "sota_ref", "pota_ref", "wwff",
|
"iota", "sota_ref", "pota_ref", "wwff",
|
||||||
"name", "qth", "address", "comment", "note",
|
"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.
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
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).
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
func EmissionOf(mode string) string { return emissionOf(mode) }
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||||
|
|
||||||
@@ -709,6 +715,11 @@ func fieldRaw(field string, q *qso.QSO) string {
|
|||||||
return q.Country
|
return q.Country
|
||||||
case "grid":
|
case "grid":
|
||||||
return q.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":
|
case "iota":
|
||||||
return q.IOTA
|
return q.IOTA
|
||||||
case "sota_ref":
|
case "sota_ref":
|
||||||
@@ -738,6 +749,37 @@ func fieldRaw(field string, q *qso.QSO) string {
|
|||||||
return ""
|
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 {
|
func dxccAllowed(dxcc *int, filter []int) bool {
|
||||||
if dxcc == nil {
|
if dxcc == nil {
|
||||||
return false
|
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 {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range 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.
|
// a park activator, carrying the park reference to stamp onto the local QSO.
|
||||||
type HunterQSO struct {
|
type HunterQSO struct {
|
||||||
Worked string `json:"worked"` // activator callsign (the station worked)
|
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)
|
Date time.Time `json:"date"` // QSO date/time (UTC)
|
||||||
Band string `json:"band"` // ADIF band, e.g. "20m"
|
Band string `json:"band"` // ADIF band, e.g. "20m"
|
||||||
Mode string `json:"mode"` // logged mode
|
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{
|
out = append(out, HunterQSO{
|
||||||
Worked: act,
|
Worked: act,
|
||||||
|
Hunter: strings.ToUpper(strings.TrimSpace(e.WorkedCallsign)), // your call for this hunt
|
||||||
Date: parseHunterTime(e.QSODateTime),
|
Date: parseHunterTime(e.QSODateTime),
|
||||||
Band: strings.ToLower(strings.TrimSpace(e.Band)),
|
Band: strings.ToLower(strings.TrimSpace(e.Band)),
|
||||||
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),
|
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),
|
||||||
|
|||||||
Reference in New Issue
Block a user