fix: QSL Manager properly calculating new slot (Digi modes are grouped together)

This commit is contained in:
2026-07-16 23:26:04 +02:00
parent 46e3619a38
commit cd13921322
7 changed files with 160 additions and 16 deletions
+36 -6
View File
@@ -7760,9 +7760,24 @@ type ConfirmationItem struct {
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
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
// matching local QSOs' received status. LoTW only for now (the canonical
// 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,
}
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.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.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)
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,
}
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.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.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)
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,
}
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.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.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)
return nil
+31 -4
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
type Confirmation = {
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 = [
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [busy, setBusy] = useState(false);
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(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setBusy(false);
refreshCounts(); // a download may have added confirmations
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations');
refreshCounts();
});
return () => { offLog(); offDone(); offConf(); };
}, []);
}, [refreshCounts]);
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) => {
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 'band': return c.new_band;
case 'mode': return c.new_mode;
case 'slot': return c.new_slot;
default: return true;
}
@@ -353,6 +363,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</>
)}
<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 && (
<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}
@@ -371,6 +396,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent>
</Select>
@@ -491,6 +517,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<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_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>
: <span className="text-muted-foreground/50"></span>}
</td>
File diff suppressed because one or more lines are too long
+2
View File
@@ -382,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>;
export function GetStartupStatus():Promise<main.StartupStatus>;
+4
View File
@@ -722,6 +722,10 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats']();
}
export function GetSolarData() {
return window['go']['main']['App']['GetSolarData']();
}
+18
View File
@@ -3659,6 +3659,24 @@ export namespace qso {
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 {
total: number;
unique_calls: number;
+67 -4
View File
@@ -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
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// 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 {
DXCC map[int]bool // dxcc entity confirmed
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 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 {
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
// OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{
@@ -1985,7 +2047,7 @@ var confirmedCols = map[string]bool{
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}.
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
for _, c := range cols {
if confirmedCols[c] {
@@ -2014,7 +2076,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
}
sets.DXCC[dxcc] = 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()
}