fix: QSL Manager properly calculating new slot (Digi modes are grouped together)
This commit is contained in:
@@ -7760,9 +7760,24 @@ type ConfirmationItem struct {
|
|||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
NewDXCC bool `json:"new_dxcc"`
|
NewDXCC bool `json:"new_dxcc"`
|
||||||
NewBand bool `json:"new_band"`
|
NewBand bool `json:"new_band"`
|
||||||
|
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
||||||
NewSlot bool `json:"new_slot"`
|
NewSlot bool `json:"new_slot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
||||||
|
// Manager. Slots are counted by mode CLASS (Phone/CW/Digital); the raw-mode
|
||||||
|
// granularity (RTTY ≠ FT8) is kept only for the cluster/matrix "new slot" flag.
|
||||||
|
func (a *App) GetSlotStats() qso.SlotStats {
|
||||||
|
if a.qso == nil {
|
||||||
|
return qso.SlotStats{}
|
||||||
|
}
|
||||||
|
st, err := a.qso.GetSlotStats(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return qso.SlotStats{}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
|
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
|
||||||
// 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
|
||||||
@@ -7887,12 +7902,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
Country: q.Country,
|
Country: q.Country,
|
||||||
}
|
}
|
||||||
if dxccNum != 0 {
|
if dxccNum != 0 {
|
||||||
|
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||||
|
// confirmation is "new mode/slot" only if no digital mode was
|
||||||
|
// confirmed there before (RTTY and FT8 are the same class here).
|
||||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||||
|
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||||
sets.DXCC[dxccNum] = true
|
sets.DXCC[dxccNum] = true
|
||||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||||
|
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||||
}
|
}
|
||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
return nil
|
return nil
|
||||||
@@ -8047,12 +8067,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||||
}
|
}
|
||||||
if dxccNum != 0 {
|
if dxccNum != 0 {
|
||||||
|
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||||
|
// confirmation is "new mode/slot" only if no digital mode was
|
||||||
|
// confirmed there before (RTTY and FT8 are the same class here).
|
||||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||||
|
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||||
sets.DXCC[dxccNum] = true
|
sets.DXCC[dxccNum] = true
|
||||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||||
|
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||||
}
|
}
|
||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
return nil
|
return nil
|
||||||
@@ -8163,12 +8188,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||||
}
|
}
|
||||||
if dxccNum != 0 {
|
if dxccNum != 0 {
|
||||||
|
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||||
|
// confirmation is "new mode/slot" only if no digital mode was
|
||||||
|
// confirmed there before (RTTY and FT8 are the same class here).
|
||||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||||
|
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||||
sets.DXCC[dxccNum] = true
|
sets.DXCC[dxccNum] = true
|
||||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||||
|
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||||
}
|
}
|
||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||||
|
|
||||||
|
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||||
|
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||||
|
const [stats, setStats] = useState<{ slots_worked: number; slots_confirmed: number; dxcc_worked: number; dxcc_confirmed: number }>(
|
||||||
|
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0 });
|
||||||
|
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
refreshCounts(); // a download may have added confirmations
|
||||||
});
|
});
|
||||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||||
setConfirmations((list ?? []) as Confirmation[]);
|
setConfirmations((list ?? []) as Confirmation[]);
|
||||||
setViewMode('confirmations');
|
setViewMode('confirmations');
|
||||||
|
refreshCounts();
|
||||||
});
|
});
|
||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||||
case 'dxcc': return c.new_dxcc;
|
case 'dxcc': return c.new_dxcc;
|
||||||
case 'band': return c.new_band;
|
case 'band': return c.new_band;
|
||||||
|
case 'mode': return c.new_mode;
|
||||||
case 'slot': return c.new_slot;
|
case 'slot': return c.new_slot;
|
||||||
default: return true;
|
default: return true;
|
||||||
}
|
}
|
||||||
@@ -353,6 +363,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI). */}
|
||||||
|
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||||
|
<span className="font-semibold">{stats.slots_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||||
|
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{service === 'pota' && potaRes && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||||
@@ -371,6 +396,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
|
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -491,6 +517,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<td className="py-1 px-2">
|
<td className="py-1 px-2">
|
||||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||||
|
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
|
||||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||||
: <span className="text-muted-foreground/50">—</span>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -382,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
|||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
|
|
||||||
export function GetSolarData():Promise<solar.Data>;
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|||||||
@@ -722,6 +722,10 @@ export function GetSecretStatus() {
|
|||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSlotStats() {
|
||||||
|
return window['go']['main']['App']['GetSlotStats']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSolarData() {
|
export function GetSolarData() {
|
||||||
return window['go']['main']['App']['GetSolarData']();
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3659,6 +3659,24 @@ export namespace qso {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class SlotStats {
|
||||||
|
slots_worked: number;
|
||||||
|
slots_confirmed: number;
|
||||||
|
dxcc_worked: number;
|
||||||
|
dxcc_confirmed: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SlotStats(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.slots_worked = source["slots_worked"];
|
||||||
|
this.slots_confirmed = source["slots_confirmed"];
|
||||||
|
this.dxcc_worked = source["dxcc_worked"];
|
||||||
|
this.dxcc_confirmed = source["dxcc_confirmed"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Stats {
|
export class Stats {
|
||||||
total: number;
|
total: number;
|
||||||
unique_calls: number;
|
unique_calls: number;
|
||||||
|
|||||||
+67
-4
@@ -1958,18 +1958,80 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
|
|||||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
||||||
|
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
|
||||||
|
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
|
||||||
|
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
|
||||||
|
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
|
||||||
type ConfirmedSets struct {
|
type ConfirmedSets struct {
|
||||||
DXCC map[int]bool // dxcc entity confirmed
|
DXCC map[int]bool // dxcc entity confirmed
|
||||||
Band map[string]bool // "dxcc|band"
|
Band map[string]bool // "dxcc|band"
|
||||||
Slot map[string]bool // "dxcc|band|mode"
|
Mode map[string]bool // "dxcc|class"
|
||||||
|
Slot map[string]bool // "dxcc|band|class"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
|
// Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
|
||||||
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
|
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
|
||||||
|
func ModeClassKey(dxcc int, mode string) string {
|
||||||
|
return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
|
||||||
|
}
|
||||||
|
func SlotClassKey(dxcc int, band, mode string) string {
|
||||||
|
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
|
||||||
func SlotKey(dxcc int, band, mode string) string {
|
func SlotKey(dxcc int, band, mode string) string {
|
||||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
|
||||||
|
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
|
||||||
|
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
|
||||||
|
// how slots are conventionally counted (a band in a class, not in each digital
|
||||||
|
// sub-mode).
|
||||||
|
type SlotStats struct {
|
||||||
|
SlotsWorked int `json:"slots_worked"` // distinct DXCC × band × class, all QSOs
|
||||||
|
SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
|
||||||
|
DXCCWorked int `json:"dxcc_worked"` // distinct DXCC entities worked
|
||||||
|
DXCCConfirmed int `json:"dxcc_confirmed"` // distinct DXCC entities confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||||
|
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||||
|
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
|
||||||
|
CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
|
||||||
|
FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return SlotStats{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
dxccW, slotW := map[int]bool{}, map[string]bool{}
|
||||||
|
dxccC, slotC := map[int]bool{}, map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var dxcc, conf int
|
||||||
|
var band, mode string
|
||||||
|
if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
|
||||||
|
return SlotStats{}, err
|
||||||
|
}
|
||||||
|
if dxcc == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := SlotClassKey(dxcc, band, mode)
|
||||||
|
dxccW[dxcc] = true
|
||||||
|
if band != "" {
|
||||||
|
slotW[key] = true
|
||||||
|
}
|
||||||
|
if conf == 1 {
|
||||||
|
dxccC[dxcc] = true
|
||||||
|
if band != "" {
|
||||||
|
slotC[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SlotStats{SlotsWorked: len(slotW), SlotsConfirmed: len(slotC), DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC)}, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||||
// OR together (guards the dynamic SQL).
|
// OR together (guards the dynamic SQL).
|
||||||
var confirmedCols = map[string]bool{
|
var confirmedCols = map[string]bool{
|
||||||
@@ -1985,7 +2047,7 @@ var confirmedCols = map[string]bool{
|
|||||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||||
// {qrzcom_qso_download_status}.
|
// {qrzcom_qso_download_status}.
|
||||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
|
||||||
var conds []string
|
var conds []string
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
if confirmedCols[c] {
|
if confirmedCols[c] {
|
||||||
@@ -2014,7 +2076,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
|||||||
}
|
}
|
||||||
sets.DXCC[dxcc] = true
|
sets.DXCC[dxcc] = true
|
||||||
sets.Band[BandKey(dxcc, band)] = true
|
sets.Band[BandKey(dxcc, band)] = true
|
||||||
sets.Slot[SlotKey(dxcc, band, mode)] = true
|
sets.Mode[ModeClassKey(dxcc, mode)] = true
|
||||||
|
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
|
||||||
}
|
}
|
||||||
return sets, rows.Err()
|
return sets, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user