feat: New badges in cluster view for new Counties and new POTA

This commit is contained in:
2026-07-17 15:28:36 +02:00
parent dd3b51a2ae
commit eb2ff8ed59
13 changed files with 3299 additions and 3189 deletions
+48 -4
View File
@@ -2396,6 +2396,22 @@ func (a *App) migrateAwardDefs() {
a.setSettingGlobal(keyAwardEditsSeeded, "1") a.setSettingGlobal(keyAwardEditsSeeded, "1")
} }
// One-time: the US Counties award was briefly carried under the code
// "USCOUNTIES" before being renamed to the official "USA-CA". Drop the orphan
// (def + references) so the operator isn't left with a stale, empty duplicate.
// It never shipped beyond development, so this is unconditional.
for i := 0; i < len(migrated); i++ {
if strings.EqualFold(strings.TrimSpace(migrated[i].Code), "USCOUNTIES") {
migrated = append(migrated[:i], migrated[i+1:]...)
i--
changed = true
if a.awardRefs != nil {
_, _ = a.awardRefs.ReplaceAll(a.ctx, "USCOUNTIES", nil)
}
applog.Printf("awards: removed legacy USCOUNTIES award (renamed to USA-CA)")
}
}
// Reconcile with the catalog: awards ADDED since this operator last saved, and // Reconcile with the catalog: awards ADDED since this operator last saved, and
// shipped awards whose definition we have since FIXED (a higher Version). Both // shipped awards whose definition we have since FIXED (a higher Version). Both
// must be written back, or the editor would keep showing the old set. // must be written back, or the editor would keep showing the old set.
@@ -4255,7 +4271,7 @@ func freeAwardCode(code string, taken map[string]int) string {
// builtinRefsVersion is bumped whenever the built-in reference data changes // builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the // (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table. // derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "3" const builtinRefsVersion = "5"
// seedBuiltinReferences populates the reference lists of built-in awards. // seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored), or already up to date: seed only awards that // - First run (no version stored), or already up to date: seed only awards that
@@ -12027,9 +12043,10 @@ func (a *App) GetClusterStatus() []cluster.ServerStatus {
// SpotQuery is one (call, band, mode) tuple sent for status colouring. // SpotQuery is one (call, band, mode) tuple sent for status colouring.
type SpotQuery struct { type SpotQuery struct {
Call string `json:"call"` Call string `json:"call"`
Band string `json:"band"` Band string `json:"band"`
Mode string `json:"mode"` Mode string `json:"mode"`
POTARef string `json:"pota_ref,omitempty"` // park id if the spot is a POTA activation
} }
// SpotStatus is the per-tuple result. Status is one of: // SpotStatus is the per-tuple result. Status is one of:
@@ -12051,6 +12068,12 @@ type SpotStatus struct {
// (any band, any mode). Drives the per-call text highlight, in // (any band, any mode). Drives the per-call text highlight, in
// addition to the entity-level Status (NEW / NEW BAND / …). // addition to the entity-level Status (NEW / NEW BAND / …).
WorkedCall bool `json:"worked_call"` WorkedCall bool `json:"worked_call"`
// NewCounty/NewPOTA are ORTHOGONAL to Status: a station already worked for
// its DXCC entity can still be a never-worked US county or POTA park. County
// is resolved from the callsign via the offline ULS store (US only, and only
// when that database has been downloaded); POTA from the spot's tagged park.
NewCounty bool `json:"new_county"`
NewPOTA bool `json:"new_pota"`
} }
// ClusterSpotStatuses takes a batch of spots and returns slot status for // ClusterSpotStatuses takes a batch of spots and returns slot status for
@@ -12095,6 +12118,10 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// "I've already QSO'd this exact station" even when the band/mode // "I've already QSO'd this exact station" even when the band/mode
// makes the entity check say "new-band" or "new-slot". // makes the entity check say "new-band" or "new-slot".
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx) workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. Both built once per batch.
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
for i, q := range spots { for i, q := range spots {
out[i] = SpotStatus{ out[i] = SpotStatus{
Call: q.Call, Call: q.Call,
@@ -12104,6 +12131,23 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok { if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
out[i].WorkedCall = true out[i].WorkedCall = true
} }
// NEW POTA: the spot's tagged park, never worked before.
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
if _, done := workedPOTA[ref]; !done {
out[i].NewPOTA = true
}
}
// NEW COUNTY: resolve the callsign's home county from the offline ULS
// store (US only; inert until downloaded) and flag if never worked.
if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok {
if key := award.USCountyKey(loc.State, loc.County); key != "" {
if _, done := workedCounties[key]; !done {
out[i].NewCounty = true
}
}
}
}
if a.dxcc == nil { if a.dxcc == nil {
continue continue
} }
+21 -2
View File
@@ -25,8 +25,24 @@ func dxccForState(st string) int {
} }
} }
// territories we exclude — USA-CA is the 50 states (+ DC). // territories we exclude entirely — plus DC, which USA-CA does not count as a
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true} // county.
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
// count as counties: the independent cities of Virginia and Carson City (NV).
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
// one of these counts toward a bordering county under the award rules.
func excludedUSACA(state, name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
switch state {
case "VA":
return strings.HasSuffix(n, " city")
case "NV":
return n == "carson city"
}
return false
}
func main() { func main() {
f, err := os.Open(os.Args[1]) f, err := os.Open(os.Args[1])
@@ -51,6 +67,9 @@ func main() {
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") { if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
continue continue
} }
if excludedUSACA(st, name) {
continue
}
// State header rows have an all-caps state NAME and state code "NA" // State header rows have an all-caps state NAME and state code "NA"
// (already skipped). County rows have a 2-letter state code. // (already skipped). County rows have a 2-letter state code.
code := award.USCountyKey(st, name) code := award.USCountyKey(st, name)
+5 -3
View File
@@ -1063,7 +1063,7 @@ export default function App() {
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on // Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour. // different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({}); const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
// === Modals === // === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null); const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -2010,14 +2010,14 @@ export default function App() {
// "new-slot" because the lookup key carried mode="". // "new-slot" because the lookup key carried mode="".
useEffect(() => { useEffect(() => {
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string }[] = []; const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const s of spots) { for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz); const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue; if (seen.has(k) || spotStatus[k]) continue;
seen.add(k); seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode }); unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
} }
if (unknown.length === 0) return; if (unknown.length === 0) return;
try { try {
@@ -2031,6 +2031,8 @@ export default function App() {
country: r.country, country: r.country,
continent: (r as any).continent, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
}; };
} }
return next; return next;
+29 -13
View File
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
country?: string; country?: string;
continent?: string; continent?: string;
worked_call?: boolean; worked_call?: boolean;
new_county?: boolean;
new_pota?: boolean;
}; };
type Props = { type Props = {
@@ -141,26 +143,40 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
}, },
{ {
group: 'Spot', label: t('clg2.c.status'), colId: 'status', group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 96, sortable: true, headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true, defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others) // Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell. // is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
valueGetter: (p: any) => { valueGetter: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return t('clg2.newDxcc'); const parts: string[] = [];
if (s?.status === 'new-band') return t('clg2.newBand'); if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
if (s?.status === 'new-mode') return t('clg2.newMode'); else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
if (s?.status === 'new-slot') return t('clg2.newSlot'); else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
return s?.worked_call ? t('clg2.wkdCall') : ''; else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
return parts.join(' ');
}, },
cellRenderer: (p: any) => { cellRenderer: (p: any) => {
const b = statusBadge(t, statusFor(p)); const s = statusFor(p);
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>; const badges: { text: string; fg: string; bg: string }[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push({ text: t('clg2.newCounty'), fg: '#6b21a8', bg: '#f3e8ff' });
if (s?.new_pota) badges.push({ text: t('clg2.newPota'), fg: '#166534', bg: '#dcfce7' });
if (badges.length === 0) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
return ( return (
<span style={{ <span style={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10, {badges.map((bd, i) => (
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap', <span key={i} style={{
}}>{b.text}</span> backgroundColor: bd.bg, color: bd.fg, fontWeight: 700, fontSize: 10,
padding: '1px 5px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
))}
</span>
); );
}, },
tooltipValueGetter: (p: any) => { tooltipValueGetter: (p: any) => {
+11 -4
View File
@@ -233,6 +233,13 @@ const GRP_KEYS: Record<string, string> = {
}; };
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g); export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
// Award columns are governed SOLELY by the awardShown code-set, never by AG
// Grid's saved column state. Stripping them here (on both save and restore)
// stops a stale saved state from re-hiding a shown award column on every
// awardCols rebuild — the desync that made award columns vanish mid-session.
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
@@ -335,12 +342,12 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty // Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache. // (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(COL_STATE_KEY).then((remote) => { loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote); seedLocal(COL_STATE_KEY, remote);
} }
}); });
@@ -348,7 +355,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, stripAwardCols(state));
}, []); }, []);
// The award columns load asynchronously; when they arrive (or change) the // The award columns load asynchronously; when they arrive (or change) the
@@ -359,7 +366,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
File diff suppressed because one or more lines are too long
+6
View File
@@ -2387,6 +2387,7 @@ export namespace main {
call: string; call: string;
band: string; band: string;
mode: string; mode: string;
pota_ref?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new SpotQuery(source); return new SpotQuery(source);
@@ -2397,6 +2398,7 @@ export namespace main {
this.call = source["call"]; this.call = source["call"];
this.band = source["band"]; this.band = source["band"];
this.mode = source["mode"]; this.mode = source["mode"];
this.pota_ref = source["pota_ref"];
} }
} }
export class SpotStatus { export class SpotStatus {
@@ -2407,6 +2409,8 @@ export namespace main {
continent?: string; continent?: string;
status: string; status: string;
worked_call: boolean; worked_call: boolean;
new_county: boolean;
new_pota: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new SpotStatus(source); return new SpotStatus(source);
@@ -2421,6 +2425,8 @@ export namespace main {
this.continent = source["continent"]; this.continent = source["continent"];
this.status = source["status"]; this.status = source["status"];
this.worked_call = source["worked_call"]; this.worked_call = source["worked_call"];
this.new_county = source["new_county"];
this.new_pota = source["new_pota"];
} }
} }
export class StartupStatus { export class StartupStatus {
+6 -2
View File
@@ -318,7 +318,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", "grid4", "state", "us_county", "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",
} }
@@ -693,7 +693,11 @@ func USCountyKey(state, cnty string) string {
if len(st) != 2 || co == "" { if len(st) != 2 || co == "" {
return "" return ""
} }
return st + "," + co // Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
// this internal match key uses "/".
return st + "/" + co
} }
// labelRef fills a worked reference's name/group from the reference list (or the // labelRef fills a worked reference's name/group from the reference list (or the
@@ -1,8 +1,8 @@
{ {
"def": { "def": {
"code": "USCOUNTIES", "code": "USA-CA",
"name": "US Counties (USA-CA)", "name": "USA-CA (US Counties)",
"description": "Worked US counties (CQ USA-CA). Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes.", "description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
"valid": true, "valid": true,
"protected": true, "protected": true,
"type": "QSOFIELDS", "type": "QSOFIELDS",
@@ -11,6 +11,7 @@
"exact_match": true, "exact_match": true,
"pattern": "", "pattern": "",
"ref_display": "name", "ref_display": "name",
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
"dxcc_filter": [ "dxcc_filter": [
291, 291,
110, 110,
+9 -9
View File
@@ -6,15 +6,15 @@ func TestUSCountyKey(t *testing.T) {
cases := []struct { cases := []struct {
state, cnty, want string state, cnty, want string
}{ }{
{"MA", "MA,MIDDLESEX", "MA,MIDDLESEX"}, // LoTW "ST,County" shape {"MA", "MA,MIDDLESEX", "MA/MIDDLESEX"}, // LoTW "ST,County" shape
{"NJ", "Middlesex", "NJ,MIDDLESEX"}, // bare name + state column {"NJ", "Middlesex", "NJ/MIDDLESEX"}, // bare name + state column
{"TX", "Montgomery", "TX,MONTGOMERY"}, // title case {"TX", "Montgomery", "TX/MONTGOMERY"}, // title case
{"FL", "Saint Lucie", "FL,STLUCIE"}, // Saint→St, space dropped {"FL", "Saint Lucie", "FL/STLUCIE"}, // Saint→St, space dropped
{"FL", "St. Lucie", "FL,STLUCIE"}, // FIPS abbreviation, period dropped {"FL", "St. Lucie", "FL/STLUCIE"}, // FIPS abbreviation, period dropped
{"AK", "Matanuska-Susitna", "AK,MATANUSKASUSITNA"}, // hyphen→space→dropped {"AK", "Matanuska-Susitna", "AK/MATANUSKASUSITNA"}, // hyphen→space→dropped
{"IL", "De Kalb", "IL,DEKALB"}, // split name {"IL", "De Kalb", "IL/DEKALB"}, // split name
{"IL", "DeKalb County", "IL,DEKALB"}, // suffix + one word {"IL", "DeKalb County", "IL/DEKALB"}, // suffix + one word
{"LA", "Acadia Parish", "LA,ACADIA"}, // parish suffix {"LA", "Acadia Parish", "LA/ACADIA"}, // parish suffix
{"", "Honolulu", ""}, // no state → no match {"", "Honolulu", ""}, // no state → no match
{"HI", "0", ""}, // garbage {"HI", "0", ""}, // garbage
{"HI", "", ""}, // empty {"HI", "", ""}, // empty
+2 -2
View File
@@ -17,7 +17,7 @@ import (
// - WAC → continent code ("EU", "NA", …) // - WAC → continent code ("EU", "NA", …)
// - WAS → ADIF STATE code ("AL", …) // - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D) // - DDFM → "D06" (the award pattern captures the leading D)
// - USCOUNTIES → canonical "STATE,COUNTY" key (see award.usCountyKey) // - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
func BuiltinRefs(code string) ([]Ref, bool) { func BuiltinRefs(code string) ([]Ref, bool) {
switch code { switch code {
case "DXCC": case "DXCC":
@@ -30,7 +30,7 @@ func BuiltinRefs(code string) ([]Ref, bool) {
return usStates().Refs, true return usStates().Refs, true
case "DDFM": case "DDFM":
return frenchDepartments(), true return frenchDepartments(), true
case "USCOUNTIES": case "USA-CA":
return usCounties(), true return usCounties(), true
} }
return nil, false return nil, false
File diff suppressed because it is too large Load Diff
+54 -2
View File
@@ -1605,7 +1605,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
// column to this list AND populate it in scanAwardQSO below, or that award will // column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation. // silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` + const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` + `grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` + `name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json` `qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
@@ -1640,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
qsoDateStr string qsoDateStr string
freqHz sql.NullInt64 freqHz sql.NullInt64
grid, vucc, country, state sql.NullString grid, vucc, country, state sql.NullString
cnty sql.NullString
cont, iotaRef, sota, pota sql.NullString cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64 dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString name, qth, address sql.NullString
@@ -1649,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
) )
if err := s.Scan( if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode, &q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota, &grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes, &name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON, &qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil { ); err != nil {
@@ -1664,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
q.VUCCGrids = vucc.String q.VUCCGrids = vucc.String
q.Country = country.String q.Country = country.String
q.State = state.String q.State = state.String
q.County = cnty.String
q.Continent = cont.String q.Continent = cont.String
if cqz.Valid { if cqz.Valid {
v := int(cqz.Int64) v := int(cqz.Int64)
@@ -1782,6 +1784,56 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
return out, rows.Err() return out, rows.Err()
} }
// WorkedCountyKeys returns the set of counties already worked, keyed by the
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var state, cnty string
if err := rows.Scan(&state, &cnty); err != nil {
return nil, err
}
if k := keyFn(state, cnty); k != "" {
out[k] = struct{}{}
}
}
return out, rows.Err()
}
// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 256)
for rows.Next() {
var ref string
if err := rows.Scan(&ref); err != nil {
return nil, err
}
for _, p := range strings.Split(ref, ",") {
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
out[p] = struct{}{}
}
}
}
return out, rows.Err()
}
// Count returns the total number of QSOs in the database. // Count returns the total number of QSOs in the database.
func (r *Repo) Count(ctx context.Context) (int64, error) { func (r *Repo) Count(ctx context.Context) (int64, error) {
var n int64 var n int64