feat: New badges in cluster view for new Counties and new POTA
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+3102
-3143
File diff suppressed because it is too large
Load Diff
+54
-2
@@ -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, ¬es,
|
&name, &qth, &address, &comment, ¬es,
|
||||||
&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
|
||||||
|
|||||||
Reference in New Issue
Block a user