deat: Added statistics on your log
This commit is contained in:
@@ -3632,6 +3632,50 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLogStats aggregates the logbook for the Statistics dashboard (operators,
|
||||
// modes, bands, entities, activity over time), restricted to a period.
|
||||
//
|
||||
// fromISO/toISO are "YYYY-MM-DD" (or RFC3339); either may be empty for "no
|
||||
// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date
|
||||
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
|
||||
// silently drop today's QSOs.
|
||||
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int) (qso.Stats, error) {
|
||||
if a.qso == nil {
|
||||
return qso.Stats{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
from := parseStatsBound(fromISO, false)
|
||||
to := parseStatsBound(toISO, true)
|
||||
return a.qso.Stats(a.ctx, from, to, contestID, year)
|
||||
}
|
||||
|
||||
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||
// the Statistics picker only ever offers contests you really entered.
|
||||
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.ContestRuns(a.ctx)
|
||||
}
|
||||
|
||||
// parseStatsBound turns a UI date into a UTC bound. endOfDay stretches a bare
|
||||
// date to 23:59:59 so an inclusive "to" really includes that day.
|
||||
func parseStatsBound(s string, endOfDay bool) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
if endOfDay {
|
||||
return t.UTC().Add(24*time.Hour - time.Second)
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (a *App) DeleteQSO(id int64) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
|
||||
@@ -67,6 +67,7 @@ import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { StatsPanel } from '@/components/StatsPanel';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
@@ -616,6 +617,12 @@ export default function App() {
|
||||
setQslTabOpen(false);
|
||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||
}
|
||||
// Statistics is likewise a closable tab, opened from Tools → Statistics.
|
||||
const [statsTabOpen, setStatsTabOpen] = useState(false);
|
||||
function closeStatsTab() {
|
||||
setStatsTabOpen(false);
|
||||
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
||||
}
|
||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||
@@ -2455,6 +2462,7 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'tools', label: t('menu.tools'), items: [
|
||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
@@ -2490,6 +2498,7 @@ export default function App() {
|
||||
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
@@ -4083,6 +4092,21 @@ export default function App() {
|
||||
)}
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||
{statsTabOpen && (
|
||||
<TabsTrigger value="stats" className="gap-1.5">
|
||||
{t('stats.tab')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close Statistics"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeStatsTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{qslTabOpen && (
|
||||
<TabsTrigger value="qsl" className="gap-1.5">
|
||||
QSL Manager
|
||||
@@ -4377,6 +4401,12 @@ export default function App() {
|
||||
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
||||
</TabsContent>
|
||||
|
||||
{statsTabOpen && (
|
||||
<TabsContent value="stats" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<StatsPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{contestTabEnabled && (
|
||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||
<ContestPanel session={contest} onChange={updateContest} />
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Statistics dashboard.
|
||||
//
|
||||
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||
// charts readable and colour-blind-safe):
|
||||
//
|
||||
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||
// encodes identity, never a magnitude the bar's length already shows.
|
||||
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||
// the categories ARE the subject (the continent donut). The order is the
|
||||
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||
// makes them interesting. Close values belong in bars.
|
||||
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||
// a table view gives the WCAG-clean twin.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Bucket = { key: string; count: number };
|
||||
type Gap = { start: string; end: string; minutes: number };
|
||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||
type Stats = {
|
||||
total: number; unique_calls: number; entities: number; continents: number;
|
||||
first_qso: string; last_qso: string;
|
||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
// Period / contest metrics.
|
||||
window_start: string; window_end: string; window_hours: number;
|
||||
avg_per_hour: number; avg_per_active: number;
|
||||
on_air_minutes: number; off_air_minutes: number;
|
||||
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||
gaps: Gap[]; rate: Bucket[];
|
||||
};
|
||||
|
||||
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||
|
||||
const CONTINENT_NAME: Record<string, string> = {
|
||||
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||
};
|
||||
|
||||
const nf = (n: number) => n.toLocaleString('en-US');
|
||||
|
||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||
// on a tooltip to get a number.
|
||||
|
||||
function HBars({ data, max, empty }: { data: Bucket[]; max?: number; empty: string }) {
|
||||
const top = max ? data.slice(0, max) : data;
|
||||
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{top.map((d) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||
<div
|
||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||
// information.
|
||||
|
||||
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||
// (the value is in the tooltip, and every number is in the table view).
|
||||
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||
return (
|
||||
// The container includes the x-axis band — a fixed height that excludes it is
|
||||
// how cards end up with a tiny nested scrollbar.
|
||||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||
{nf(d.count)}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
/>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||
{i % every === 0 ? d.key : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||
// is chaos and goes unread.
|
||||
|
||||
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||
const y = (v: number) => H - (v / peak) * H;
|
||||
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||
const h = hover ?? -1;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="relative" style={{ height }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||
onMouseLeave={() => setHover(null)}>
|
||||
{/* Recessive hairline grid — solid, never dashed. */}
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<defs>
|
||||
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#statsArea)" />
|
||||
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
{h >= 0 && (
|
||||
<>
|
||||
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
</>
|
||||
)}
|
||||
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||
{data.map((_, i) => (
|
||||
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||
))}
|
||||
</svg>
|
||||
{h >= 0 && (
|
||||
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>{data[0].key}</span>
|
||||
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||
<span>{data[data.length - 1].key}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||
// donut would hide precisely the fact that makes them interesting).
|
||||
//
|
||||
// The categories ARE the subject here, so this is the one chart using the
|
||||
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||
|
||||
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||
const [hov, setHov] = useState<number | null>(null);
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||
let acc = 0;
|
||||
const arcs = slots.map((d, i) => {
|
||||
const frac = d.count / total;
|
||||
// A 2px surface gap between segments — the correct separator, not a border.
|
||||
const len = Math.max(0, frac * C - 2);
|
||||
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||
acc += frac;
|
||||
return a;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="relative shrink-0">
|
||||
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||
{arcs.map((a) => (
|
||||
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||
className="transition-[stroke-width] duration-150 cursor-default"
|
||||
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||
))}
|
||||
</svg>
|
||||
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
{hov === null ? (
|
||||
<>
|
||||
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
{slots.map((d, i) => (
|
||||
<li key={d.key}
|
||||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||
hov === i && 'bg-muted')}>
|
||||
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||
{((d.count / total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||
|
||||
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||
<span className="text-[11px] tabular-nums">
|
||||
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||
<div className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Period ───────────────────────────────────────────────────────────────────
|
||||
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||
// the charts would each show a different slice of time.
|
||||
|
||||
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||
const now = new Date();
|
||||
switch (p) {
|
||||
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||
case 'y12': {
|
||||
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'd30': {
|
||||
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'custom': return [from, to];
|
||||
default: return ['', ''];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||
|
||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{data.map((d) => (
|
||||
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function StatsPanel() {
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||
const [period, setPeriod] = useState<Period>('all');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||
// that contest AND lets the window derive from its own span — no date typing.
|
||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||
const [contest, setContest] = useState('');
|
||||
|
||||
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||
|
||||
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
||||
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||
// with it — the backend derives them. Sending a period as well would be two
|
||||
// filters fighting over the same axis.
|
||||
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||
// once, here, rather than guarding at every use site and missing one.
|
||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||
setStats({
|
||||
...raw,
|
||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||
} as Stats);
|
||||
}
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||
useEffect(() => {
|
||||
if (!contest && period === 'custom' && !(from && to)) return;
|
||||
load(period, from, to, contest);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period, from, to, contest]);
|
||||
|
||||
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||
// between two ordinary evenings — numbers that are true and completely useless.
|
||||
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||
// real operating session (which is exactly when an hourly rate chart exists).
|
||||
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||
|
||||
const span = useMemo(() => {
|
||||
if (!stats?.first_qso) return '';
|
||||
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||
}, [stats]);
|
||||
|
||||
if (busy && !stats) {
|
||||
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||
</div>;
|
||||
}
|
||||
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||
if (!stats) return null;
|
||||
|
||||
const empty = t('stats.noData');
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||
|
||||
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||
never from a static list — so it can't offer a contest you never entered.
|
||||
Choosing one supersedes the period: it brings its own window. */}
|
||||
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||
<option value="">{t('stats.noContest')}</option>
|
||||
{runs.map((r) => (
|
||||
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||
{r.id} {r.year} — {nf(r.count)} QSO
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||
{([
|
||||
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||
] as [Period, string][]).map(([p, label], i) => (
|
||||
<button key={p} onClick={() => setPeriod(p)}
|
||||
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!contest && period === 'custom' && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<button onClick={() => setView('charts')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||
</button>
|
||||
<button onClick={() => setView('table')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<Table2 className="size-3.5" />{t('stats.table')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||||
</div>
|
||||
|
||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||
{windowed && stats.total > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||
<div title={t('stats.avgWindowTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.avgActiveTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.best60Tip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||
</div>
|
||||
{/* On-air + off-air = the window, by construction. The first version
|
||||
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||
<div title={t('stats.onAirTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||
{dur(stats.on_air_minutes)}
|
||||
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.rate.length > 1
|
||||
? <VBars data={stats.rate} empty={empty} height={120} />
|
||||
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||
{stats.gaps.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 min-w-0">
|
||||
{stats.gaps.map((g, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||
<span className="tabular-nums text-muted-foreground truncate">
|
||||
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||
</span>
|
||||
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||
style={{
|
||||
background: 'var(--chart-seq-1)',
|
||||
}}>
|
||||
<span className="block h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||
background: 'var(--chart-1)',
|
||||
}} />
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'table' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||
<VBars data={stats.by_band} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||||
<AreaTrend data={stats.by_month} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||||
<HBars data={stats.by_operator} max={8} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||
<Donut data={stats.by_continent} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1">
|
||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byStation')}>
|
||||
<HBars data={stats.by_station} max={4} empty={empty} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,28 @@ const en: Dict = {
|
||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
||||
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
|
||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||
|
||||
'stats.byContinentSub': 'Share of the log',
|
||||
'stats.pAll': 'All', 'stats.pYTD': 'This year', 'stats.p12m': '12 months', 'stats.p30d': '30 days', 'stats.pCustom': 'Custom',
|
||||
'stats.rate': 'Rate', 'stats.rateSub': 'QSOs per hour across the period — silences included',
|
||||
'stats.rateTooLong': 'Period too long for an hourly rate chart (max 31 days)',
|
||||
'stats.avgWindow': 'Avg / hour', 'stats.avgWindowTip': 'QSOs ÷ the WHOLE period, breaks included. The honest rate.',
|
||||
'stats.avgActive': 'Avg / hour on air', 'stats.avgActiveTip': 'QSOs ÷ the hours you actually operated. Flattering — quoting only this is how an 8-hour effort gets sold as a 48-hour score.',
|
||||
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
|
||||
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
||||
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
||||
'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —',
|
||||
'msg.expand': 'Click to read the full message',
|
||||
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
|
||||
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
|
||||
@@ -265,6 +287,28 @@ const fr: Dict = {
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
||||
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
|
||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||
|
||||
'stats.byContinentSub': 'Part du journal',
|
||||
'stats.pAll': 'Tout', 'stats.pYTD': 'Cette année', 'stats.p12m': '12 mois', 'stats.p30d': '30 jours', 'stats.pCustom': 'Personnalisé',
|
||||
'stats.rate': 'Cadence', 'stats.rateSub': 'QSO par heure sur la période — silences compris',
|
||||
'stats.rateTooLong': 'Période trop longue pour une courbe horaire (max 31 jours)',
|
||||
'stats.avgWindow': 'Moy. / heure', 'stats.avgWindowTip': 'QSO ÷ la période ENTIÈRE, pauses comprises. La vraie cadence.',
|
||||
'stats.avgActive': 'Moy. / heure en l’air', 'stats.avgActiveTip': 'QSO ÷ les heures réellement opérées. Flatteur — ne citer que celle-ci, c’est vendre 8 h d’effort comme un score de 48 h.',
|
||||
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
|
||||
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
||||
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
||||
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —',
|
||||
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
|
||||
'offline.tip': '{n} QSO en attente — la base est injoignable',
|
||||
|
||||
@@ -495,6 +495,63 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||
automatic inversion.
|
||||
|
||||
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||
shows.
|
||||
|
||||
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||
recedes toward the surface instead of glowing. */
|
||||
:root,
|
||||
[data-theme="light-warm"],
|
||||
[data-theme="light-cool"],
|
||||
[data-theme="light-sage"] {
|
||||
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||
--chart-2: #1baf7a; /* aqua */
|
||||
--chart-3: #eda100; /* yellow */
|
||||
--chart-4: #008300; /* green */
|
||||
--chart-5: #4a3aa7; /* violet */
|
||||
--chart-6: #e34948; /* red */
|
||||
--chart-7: #e87ba4; /* magenta */
|
||||
--chart-8: #eb6834; /* orange */
|
||||
|
||||
--chart-seq-1: #cde2fb;
|
||||
--chart-seq-2: #9ec5f4;
|
||||
--chart-seq-3: #6da7ec;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #256abf;
|
||||
--chart-seq-6: #104281;
|
||||
}
|
||||
|
||||
[data-theme="dim-slate"],
|
||||
[data-theme="dark-warm"],
|
||||
[data-theme="dark-graphite"],
|
||||
[data-theme="high-contrast"] {
|
||||
--chart-1: #3987e5;
|
||||
--chart-2: #199e70;
|
||||
--chart-3: #c98500;
|
||||
--chart-4: #008300;
|
||||
--chart-5: #9085e9;
|
||||
--chart-6: #e66767;
|
||||
--chart-7: #d55181;
|
||||
--chart-8: #d95926;
|
||||
|
||||
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||
--chart-seq-1: #0d366b;
|
||||
--chart-seq-2: #184f95;
|
||||
--chart-seq-3: #256abf;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #6da7ec;
|
||||
--chart-seq-6: #9ec5f4;
|
||||
}
|
||||
|
||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||
the generated utilities reference var(--…) directly, so overriding a var in
|
||||
a [data-theme] block re-skins every utility at runtime. */
|
||||
|
||||
Vendored
+4
@@ -294,6 +294,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||
@@ -326,6 +328,8 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
|
||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||
|
||||
@@ -546,6 +546,10 @@ export function GetClusterStatus() {
|
||||
return window['go']['main']['App']['GetClusterStatus']();
|
||||
}
|
||||
|
||||
export function GetContestRuns() {
|
||||
return window['go']['main']['App']['GetContestRuns']();
|
||||
}
|
||||
|
||||
export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
@@ -610,6 +614,10 @@ export function GetLogFilePath() {
|
||||
return window['go']['main']['App']['GetLogFilePath']();
|
||||
}
|
||||
|
||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function GetLogbookRevision() {
|
||||
return window['go']['main']['App']['GetLogbookRevision']();
|
||||
}
|
||||
|
||||
@@ -2922,6 +2922,20 @@ export namespace qso {
|
||||
this.status = source["status"];
|
||||
}
|
||||
}
|
||||
export class Bucket {
|
||||
key: string;
|
||||
count: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Bucket(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.key = source["key"];
|
||||
this.count = source["count"];
|
||||
}
|
||||
}
|
||||
export class Condition {
|
||||
field: string;
|
||||
op: string;
|
||||
@@ -2938,6 +2952,42 @@ export namespace qso {
|
||||
this.value = source["value"];
|
||||
}
|
||||
}
|
||||
export class ContestRun {
|
||||
id: string;
|
||||
year: number;
|
||||
count: number;
|
||||
start: string;
|
||||
end: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ContestRun(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.year = source["year"];
|
||||
this.count = source["count"];
|
||||
this.start = source["start"];
|
||||
this.end = source["end"];
|
||||
}
|
||||
}
|
||||
export class Gap {
|
||||
start: string;
|
||||
end: string;
|
||||
minutes: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Gap(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.start = source["start"];
|
||||
this.end = source["end"];
|
||||
this.minutes = source["minutes"];
|
||||
}
|
||||
}
|
||||
export class ListFilter {
|
||||
callsign?: string;
|
||||
band?: string;
|
||||
@@ -3286,6 +3336,94 @@ export namespace qso {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Stats {
|
||||
total: number;
|
||||
unique_calls: number;
|
||||
entities: number;
|
||||
continents: number;
|
||||
first_qso: string;
|
||||
last_qso: string;
|
||||
confirmed_lotw: number;
|
||||
confirmed_eqsl: number;
|
||||
confirmed_qsl: number;
|
||||
confirmed_any: number;
|
||||
by_mode: Bucket[];
|
||||
by_band: Bucket[];
|
||||
by_operator: Bucket[];
|
||||
by_station: Bucket[];
|
||||
by_continent: Bucket[];
|
||||
top_entities: Bucket[];
|
||||
by_year: Bucket[];
|
||||
by_month: Bucket[];
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
window_hours: number;
|
||||
avg_per_hour: number;
|
||||
avg_per_active: number;
|
||||
on_air_minutes: number;
|
||||
off_air_minutes: number;
|
||||
peak_hour_key: string;
|
||||
peak_hour_count: number;
|
||||
best_60: number;
|
||||
gaps: Gap[];
|
||||
rate: Bucket[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Stats(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.total = source["total"];
|
||||
this.unique_calls = source["unique_calls"];
|
||||
this.entities = source["entities"];
|
||||
this.continents = source["continents"];
|
||||
this.first_qso = source["first_qso"];
|
||||
this.last_qso = source["last_qso"];
|
||||
this.confirmed_lotw = source["confirmed_lotw"];
|
||||
this.confirmed_eqsl = source["confirmed_eqsl"];
|
||||
this.confirmed_qsl = source["confirmed_qsl"];
|
||||
this.confirmed_any = source["confirmed_any"];
|
||||
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||
this.by_year = this.convertValues(source["by_year"], Bucket);
|
||||
this.by_month = this.convertValues(source["by_month"], Bucket);
|
||||
this.window_start = source["window_start"];
|
||||
this.window_end = source["window_end"];
|
||||
this.window_hours = source["window_hours"];
|
||||
this.avg_per_hour = source["avg_per_hour"];
|
||||
this.avg_per_active = source["avg_per_active"];
|
||||
this.on_air_minutes = source["on_air_minutes"];
|
||||
this.off_air_minutes = source["off_air_minutes"];
|
||||
this.peak_hour_key = source["peak_hour_key"];
|
||||
this.peak_hour_count = source["peak_hour_count"];
|
||||
this.best_60 = source["best_60"];
|
||||
this.gaps = this.convertValues(source["gaps"], Gap);
|
||||
this.rate = this.convertValues(source["rate"], Bucket);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class WorkedBefore {
|
||||
callsign: string;
|
||||
count: number;
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Statistics over the whole logbook.
|
||||
//
|
||||
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
|
||||
// BYs. Two reasons: the date maths (year / month / hour of day) would need
|
||||
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
|
||||
// which is exactly the kind of thing that silently works on one backend and
|
||||
// breaks on the other; and a single pass over a few columns of a 30k-row log is
|
||||
// a few tens of milliseconds, so the complexity buys nothing.
|
||||
|
||||
// Bucket is one labelled count (mode, band, operator, entity…).
|
||||
type Bucket struct {
|
||||
Key string `json:"key"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||
// are the expensive minutes: they are where the score went.
|
||||
type Gap struct {
|
||||
Start string `json:"start"` // RFC3339 — the last QSO before the silence
|
||||
End string `json:"end"` // the first QSO after it
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// ContestRun is one contest the operator actually took part in, discovered FROM
|
||||
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
|
||||
// the picker only ever offers contests you really entered, and never an empty one.
|
||||
type ContestRun struct {
|
||||
ID string `json:"id"`
|
||||
Year int `json:"year"`
|
||||
Count int `json:"count"`
|
||||
Start string `json:"start"` // first QSO, RFC3339
|
||||
End string `json:"end"` // last QSO
|
||||
}
|
||||
|
||||
// ContestRuns lists every (contest, year) pair present in the logbook, most
|
||||
// recent first.
|
||||
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type key struct {
|
||||
id string
|
||||
year int
|
||||
}
|
||||
agg := map[key]*ContestRun{}
|
||||
for rows.Next() {
|
||||
var id, dateStr sql.NullString
|
||||
if err := rows.Scan(&id, &dateStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cid := strings.ToUpper(strings.TrimSpace(id.String))
|
||||
if cid == "" {
|
||||
continue
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() {
|
||||
continue
|
||||
}
|
||||
k := key{cid, t.Year()}
|
||||
c, ok := agg[k]
|
||||
if !ok {
|
||||
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
|
||||
agg[k] = c
|
||||
}
|
||||
c.Count++
|
||||
if t.Format(time.RFC3339) < c.Start {
|
||||
c.Start = t.Format(time.RFC3339)
|
||||
}
|
||||
if t.Format(time.RFC3339) > c.End {
|
||||
c.End = t.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ContestRun, 0, len(agg))
|
||||
for _, c := range agg {
|
||||
out = append(out, *c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Year != out[j].Year {
|
||||
return out[i].Year > out[j].Year // most recent first
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gapThreshold is the silence that counts as "off the air". Short enough to catch
|
||||
// a real break, long enough not to flag the normal pause between two QSOs.
|
||||
const gapThreshold = 30 * time.Minute
|
||||
|
||||
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
|
||||
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
|
||||
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
|
||||
// looks broken, which is exactly how it first shipped. Past this the UI says
|
||||
// "period too long for an hourly chart" instead of drawing mush.
|
||||
const rateMaxHours = 7 * 24
|
||||
|
||||
// Stats is the whole dashboard payload.
|
||||
type Stats struct {
|
||||
// Headline figures.
|
||||
Total int `json:"total"`
|
||||
UniqueCalls int `json:"unique_calls"`
|
||||
Entities int `json:"entities"` // distinct DXCC entities
|
||||
Continents int `json:"continents"` // distinct continents
|
||||
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
|
||||
LastQSO string `json:"last_qso"`
|
||||
|
||||
// Confirmations (of Total).
|
||||
ConfirmedLoTW int `json:"confirmed_lotw"`
|
||||
ConfirmedEQSL int `json:"confirmed_eqsl"`
|
||||
ConfirmedQSL int `json:"confirmed_qsl"`
|
||||
ConfirmedAny int `json:"confirmed_any"`
|
||||
|
||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||
ByMode []Bucket `json:"by_mode"`
|
||||
ByBand []Bucket `json:"by_band"`
|
||||
ByOperator []Bucket `json:"by_operator"`
|
||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||
ByContinent []Bucket `json:"by_continent"`
|
||||
TopEntities []Bucket `json:"top_entities"`
|
||||
ByYear []Bucket `json:"by_year"` // chronological
|
||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||
|
||||
// ── Period / contest metrics ──
|
||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||
// nothing, but across a contest weekend it is the score. The window is the
|
||||
// requested [from,to] when given, else the span of the log.
|
||||
WindowStart string `json:"window_start"`
|
||||
WindowEnd string `json:"window_end"`
|
||||
WindowHours float64 `json:"window_hours"`
|
||||
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
|
||||
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
|
||||
|
||||
// On-air and off-air are a TIME BUDGET and must add up to the window:
|
||||
// OnAirMinutes + OffAirMinutes == window
|
||||
// The first version counted "clock hours containing at least one QSO" as on-air,
|
||||
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
|
||||
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
|
||||
// measured on incompatible bases can't be compared, and the operator rightly
|
||||
// didn't believe either of them.
|
||||
OnAirMinutes int `json:"on_air_minutes"`
|
||||
OffAirMinutes int `json:"off_air_minutes"`
|
||||
|
||||
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
|
||||
PeakHourCount int `json:"peak_hour_count"`
|
||||
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
|
||||
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
|
||||
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
|
||||
}
|
||||
|
||||
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
|
||||
// so the band chart reads like a band plan instead of a jumble.
|
||||
var bandOrder = map[string]int{
|
||||
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
|
||||
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
|
||||
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
|
||||
}
|
||||
|
||||
// yes reports whether an ADIF confirmation flag means "confirmed".
|
||||
func yes(s string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "Y", "V": // V = verified (LoTW)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stats scans the logbook once and returns every breakdown the dashboard needs,
|
||||
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
|
||||
// the whole log).
|
||||
//
|
||||
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
|
||||
// WHERE. qso_date is a text column whose format differs between the two backends,
|
||||
// so a string comparison would quietly select the wrong rows on one of them. We
|
||||
// already parse every date in this pass; filtering on the parsed value is both
|
||||
// correct and free.
|
||||
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
|
||||
// it is set and no explicit window is given, the window becomes the contest's own
|
||||
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||
// itself without the operator having to look its dates up.
|
||||
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
||||
var s Stats
|
||||
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
||||
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
|
||||
FROM qso`)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var (
|
||||
calls = map[string]struct{}{}
|
||||
entities = map[int]struct{}{}
|
||||
modeC = map[string]int{}
|
||||
bandC = map[string]int{}
|
||||
opC = map[string]int{}
|
||||
stationC = map[string]int{}
|
||||
contC = map[string]int{}
|
||||
entityC = map[string]int{}
|
||||
yearC = map[string]int{}
|
||||
monthC = map[string]int{}
|
||||
times []time.Time // every dated QSO, for the rate / gap maths
|
||||
first, last time.Time
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
call, band, mode, cont, country sql.NullString
|
||||
oper, station sql.NullString
|
||||
lotw, eqsl, paper sql.NullString
|
||||
dxcc sql.NullInt64
|
||||
dateStr, contestID2 sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
|
||||
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
// Contest filter first — same reasoning as the window below: a QSO that
|
||||
// isn't in this contest must not reach ANY bucket.
|
||||
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
||||
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||
// operator charts showing the whole log while only the trend was filtered.
|
||||
// parseTimeLoose is the repo's existing convention for qso_date — it copes
|
||||
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
dated := !t.IsZero()
|
||||
if year > 0 && (!dated || t.Year() != year) {
|
||||
continue
|
||||
}
|
||||
if !from.IsZero() && (!dated || t.Before(from)) {
|
||||
continue
|
||||
}
|
||||
if !to.IsZero() && (!dated || t.After(to)) {
|
||||
continue
|
||||
}
|
||||
|
||||
s.Total++
|
||||
|
||||
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
|
||||
calls[c] = struct{}{}
|
||||
}
|
||||
if dxcc.Valid && dxcc.Int64 > 0 {
|
||||
entities[int(dxcc.Int64)] = struct{}{}
|
||||
}
|
||||
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
|
||||
modeC[m]++
|
||||
}
|
||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||
bandC[b]++
|
||||
}
|
||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||
// it explicitly rather than dropping the QSO from the operator chart.
|
||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||
if op == "" {
|
||||
op = "—"
|
||||
}
|
||||
opC[op]++
|
||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||
stationC[st]++
|
||||
}
|
||||
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||
contC[c]++
|
||||
}
|
||||
if c := strings.TrimSpace(country.String); c != "" {
|
||||
entityC[c]++
|
||||
}
|
||||
|
||||
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
|
||||
if cl {
|
||||
s.ConfirmedLoTW++
|
||||
}
|
||||
if el {
|
||||
s.ConfirmedEQSL++
|
||||
}
|
||||
if pl {
|
||||
s.ConfirmedQSL++
|
||||
}
|
||||
if cl || el || pl {
|
||||
s.ConfirmedAny++
|
||||
}
|
||||
|
||||
// An undated QSO still counts in the mode/band/operator totals above, but
|
||||
// it can't be placed on a time axis — leave it out of the trend rather than
|
||||
// parking it at year zero.
|
||||
if !dated {
|
||||
continue
|
||||
}
|
||||
if first.IsZero() || t.Before(first) {
|
||||
first = t
|
||||
}
|
||||
if last.IsZero() || t.After(last) {
|
||||
last = t
|
||||
}
|
||||
yearC[t.Format("2006")]++
|
||||
monthC[t.Format("2006-01")]++
|
||||
times = append(times, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.UniqueCalls = len(calls)
|
||||
s.Entities = len(entities)
|
||||
s.Continents = len(contC)
|
||||
if !first.IsZero() {
|
||||
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
s.ByMode = topBuckets(modeC, 0)
|
||||
s.ByOperator = topBuckets(opC, 0)
|
||||
s.ByStation = topBuckets(stationC, 0)
|
||||
s.ByContinent = topBuckets(contC, 0)
|
||||
s.TopEntities = topBuckets(entityC, 15)
|
||||
|
||||
// Bands read in band-plan order, not by count — the shape of the chart IS
|
||||
// the band plan, and re-sorting it by size would destroy that.
|
||||
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
|
||||
oa, ob := bandOrder[a], bandOrder[b]
|
||||
if oa == 0 {
|
||||
oa = 99
|
||||
}
|
||||
if ob == 0 {
|
||||
ob = 99
|
||||
}
|
||||
if oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return a < b
|
||||
})
|
||||
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||
// chart would invent activity that never happened. A gap in the log is real
|
||||
// information: it belongs on the chart as zeros.
|
||||
s.ByYear = fillYears(yearC, first, last)
|
||||
s.ByMonth = fillMonths(monthC, first, last)
|
||||
|
||||
s.periodMetrics(times, from, to, first, last)
|
||||
s.ensureNonNil()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ensureNonNil replaces every nil slice with an empty one.
|
||||
//
|
||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
|
||||
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
|
||||
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
|
||||
// (Rate nil). The awards code carries the same guard for the same reason.
|
||||
func (s *Stats) ensureNonNil() {
|
||||
if s.ByMode == nil {
|
||||
s.ByMode = []Bucket{}
|
||||
}
|
||||
if s.ByBand == nil {
|
||||
s.ByBand = []Bucket{}
|
||||
}
|
||||
if s.ByOperator == nil {
|
||||
s.ByOperator = []Bucket{}
|
||||
}
|
||||
if s.ByStation == nil {
|
||||
s.ByStation = []Bucket{}
|
||||
}
|
||||
if s.ByContinent == nil {
|
||||
s.ByContinent = []Bucket{}
|
||||
}
|
||||
if s.TopEntities == nil {
|
||||
s.TopEntities = []Bucket{}
|
||||
}
|
||||
if s.ByYear == nil {
|
||||
s.ByYear = []Bucket{}
|
||||
}
|
||||
if s.ByMonth == nil {
|
||||
s.ByMonth = []Bucket{}
|
||||
}
|
||||
if s.Rate == nil {
|
||||
s.Rate = []Bucket{}
|
||||
}
|
||||
if s.Gaps == nil {
|
||||
s.Gaps = []Gap{}
|
||||
}
|
||||
}
|
||||
|
||||
// periodMetrics derives the rate / off-air figures that make a contest window
|
||||
// readable. The window is the caller's [from,to] when given, else the span of the
|
||||
// log itself.
|
||||
//
|
||||
// Two rates are reported on purpose, because a single one always flatters:
|
||||
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||
// how fast you go when you ARE at the radio.
|
||||
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
||||
func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time) {
|
||||
if len(times) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
|
||||
|
||||
winStart, winEnd := from, to
|
||||
if winStart.IsZero() {
|
||||
winStart = first
|
||||
}
|
||||
if winEnd.IsZero() {
|
||||
winEnd = last
|
||||
}
|
||||
if !winEnd.After(winStart) {
|
||||
return
|
||||
}
|
||||
s.WindowStart = winStart.Format(time.RFC3339)
|
||||
s.WindowEnd = winEnd.Format(time.RFC3339)
|
||||
s.WindowHours = winEnd.Sub(winStart).Hours()
|
||||
if s.WindowHours > 0 {
|
||||
s.AvgPerHour = float64(len(times)) / s.WindowHours
|
||||
}
|
||||
|
||||
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
|
||||
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
|
||||
hourly := map[string]int{}
|
||||
for _, t := range times {
|
||||
hourly[t.Format("2006-01-02 15")]++
|
||||
}
|
||||
for k, v := range hourly {
|
||||
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
|
||||
s.PeakHourKey, s.PeakHourCount = k, v
|
||||
}
|
||||
}
|
||||
|
||||
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45–
|
||||
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
|
||||
// actually quote. Two pointers over the sorted times: O(n).
|
||||
lo := 0
|
||||
for hi := range times {
|
||||
for times[hi].Sub(times[lo]) >= time.Hour {
|
||||
lo++
|
||||
}
|
||||
if n := hi - lo + 1; n > s.Best60 {
|
||||
s.Best60 = n
|
||||
}
|
||||
}
|
||||
|
||||
// Off-air is a TIME BUDGET, and it has to close on the window:
|
||||
// OnAirMinutes + OffAirMinutes == window
|
||||
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
|
||||
// and the tail after the last, when an explicit window was asked for. Skipping
|
||||
// those (the first version did) makes "on air" and "off air" sum to more than
|
||||
// the window, and then neither number is believable.
|
||||
addGap := func(a, b time.Time) {
|
||||
d := b.Sub(a)
|
||||
if d < gapThreshold {
|
||||
return
|
||||
}
|
||||
s.OffAirMinutes += int(d.Minutes())
|
||||
s.Gaps = append(s.Gaps, Gap{
|
||||
Start: a.Format(time.RFC3339),
|
||||
End: b.Format(time.RFC3339),
|
||||
Minutes: int(d.Minutes()),
|
||||
})
|
||||
}
|
||||
addGap(winStart, times[0]) // lead-in
|
||||
for i := 1; i < len(times); i++ { // the silences between QSOs
|
||||
addGap(times[i-1], times[i])
|
||||
}
|
||||
addGap(times[len(times)-1], winEnd) // tail
|
||||
|
||||
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
|
||||
if s.OnAirMinutes < 0 {
|
||||
s.OnAirMinutes = 0
|
||||
}
|
||||
if s.OnAirMinutes > 0 {
|
||||
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
|
||||
}
|
||||
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
|
||||
if len(s.Gaps) > 10 {
|
||||
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
|
||||
}
|
||||
|
||||
// Per-hour rate timeline — the classic contest rate chart. Every hour of the
|
||||
// window, zeros included, so the silences are visible as silences.
|
||||
if s.WindowHours <= rateMaxHours {
|
||||
cur := winStart.Truncate(time.Hour)
|
||||
end := winEnd.Truncate(time.Hour)
|
||||
for !cur.After(end) {
|
||||
s.Rate = append(s.Rate, Bucket{
|
||||
Key: cur.Format("01-02 15"),
|
||||
Count: hourly[cur.Format("2006-01-02 15")],
|
||||
})
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
|
||||
// keeps only the top n.
|
||||
func topBuckets(m map[string]int, n int) []Bucket {
|
||||
out := make([]Bucket, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, Bucket{Key: k, Count: v})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count != out[j].Count {
|
||||
return out[i].Count > out[j].Count
|
||||
}
|
||||
return out[i].Key < out[j].Key
|
||||
})
|
||||
if n > 0 && len(out) > n {
|
||||
out = out[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
|
||||
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
|
||||
out := make([]Bucket, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// fillMonths emits EVERY month between the first and last QSO — zeros included —
|
||||
// so the trend line's x-axis is real time rather than "months that happen to have
|
||||
// data". A quiet decade must read as a decade at zero, not vanish.
|
||||
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
|
||||
if first.IsZero() {
|
||||
return nil
|
||||
}
|
||||
var out []Bucket
|
||||
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
for !cur.After(end) {
|
||||
k := cur.Format("2006-01")
|
||||
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||
cur = cur.AddDate(0, 1, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillYears does the same for the yearly view.
|
||||
func fillYears(m map[string]int, first, last time.Time) []Bucket {
|
||||
if first.IsZero() {
|
||||
return nil
|
||||
}
|
||||
var out []Bucket
|
||||
for y := first.Year(); y <= last.Year(); y++ {
|
||||
k := fmt.Sprintf("%04d", y)
|
||||
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
|
||||
// alphabetically — the order of that chart IS the information.
|
||||
func TestBandPlanOrder(t *testing.T) {
|
||||
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
|
||||
got := sortedBuckets(counts, func(a, b string) bool {
|
||||
oa, ob := bandOrder[a], bandOrder[b]
|
||||
if oa == 0 {
|
||||
oa = 99
|
||||
}
|
||||
if ob == 0 {
|
||||
ob = 99
|
||||
}
|
||||
if oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return a < b
|
||||
})
|
||||
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d buckets, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].Key != want[i] {
|
||||
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
|
||||
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
|
||||
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
|
||||
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
|
||||
// dashboard reads must therefore come back non-nil, even when empty.
|
||||
func TestStatsNoNilSlices(t *testing.T) {
|
||||
var s Stats // the worst case: nothing computed at all
|
||||
s.ensureNonNil()
|
||||
|
||||
checks := map[string]bool{
|
||||
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
|
||||
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
|
||||
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
|
||||
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
|
||||
}
|
||||
for name, isNil := range checks {
|
||||
if isNil {
|
||||
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
|
||||
// window that yields no hourly chart must still produce [] and not null.
|
||||
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||
times := []time.Time{base, base.Add(2 * time.Minute), base.Add(5 * time.Minute)}
|
||||
var s2 Stats
|
||||
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
|
||||
s2.ensureNonNil()
|
||||
if s2.Gaps == nil {
|
||||
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
|
||||
}
|
||||
if len(s2.Gaps) != 0 {
|
||||
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
|
||||
}
|
||||
}
|
||||
|
||||
// Contest metrics over a window. The two traps:
|
||||
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
|
||||
// a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the
|
||||
// rolling figure is the one contesters quote.
|
||||
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
|
||||
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
|
||||
// 8-hour effort gets sold as a 48-hour score.
|
||||
func TestContestPeriodMetrics(t *testing.T) {
|
||||
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||
|
||||
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
|
||||
// then a 2-hour silence, then 3 more.
|
||||
var times []time.Time
|
||||
for i := 0; i < 10; i++ {
|
||||
times = append(times, at(40+i*4)) // 12:40 … 13:16
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
times = append(times, at(240+i*5)) // 16:00 …
|
||||
}
|
||||
|
||||
from := base // 12:00
|
||||
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
|
||||
var s Stats
|
||||
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
|
||||
|
||||
if s.WindowHours != 6 {
|
||||
t.Errorf("window = %.1f h, want 6", s.WindowHours)
|
||||
}
|
||||
// 13 QSOs over a 6 h window.
|
||||
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
|
||||
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
|
||||
}
|
||||
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
|
||||
// would only ever see part of it.
|
||||
if s.Best60 != 10 {
|
||||
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
|
||||
}
|
||||
if s.PeakHourCount >= 10 {
|
||||
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
|
||||
}
|
||||
// THE INVARIANT: on-air + off-air must close on the window. The first version
|
||||
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
|
||||
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
|
||||
// incompatible bases; the operator believed neither, and was right.
|
||||
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
|
||||
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
|
||||
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
|
||||
}
|
||||
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
|
||||
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
|
||||
// ignoring the lead-in and tail is what broke the budget.
|
||||
if s.OffAirMinutes != 40+164+110 {
|
||||
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
|
||||
}
|
||||
if len(s.Gaps) != 3 {
|
||||
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
|
||||
}
|
||||
if s.AvgPerActive <= s.AvgPerHour {
|
||||
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
|
||||
}
|
||||
// The rate timeline covers EVERY hour of the window, silences as zeros.
|
||||
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
|
||||
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
|
||||
}
|
||||
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
|
||||
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
|
||||
}
|
||||
}
|
||||
|
||||
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
|
||||
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
|
||||
// would invent activity that never happened.
|
||||
func TestTimeAxisIsContinuous(t *testing.T) {
|
||||
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
|
||||
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
|
||||
if len(years) != 18 { // 2009..2026 inclusive
|
||||
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
|
||||
}
|
||||
byKey := map[string]int{}
|
||||
for _, b := range years {
|
||||
byKey[b.Key] = b.Count
|
||||
}
|
||||
if byKey["2010"] != 0 || byKey["2018"] != 0 {
|
||||
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
|
||||
}
|
||||
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
|
||||
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
|
||||
}
|
||||
|
||||
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
|
||||
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
|
||||
if len(months) != 207 {
|
||||
t.Fatalf("months = %d, want 207 (continuous)", len(months))
|
||||
}
|
||||
if months[0].Key != "2009-05" || months[0].Count != 49 {
|
||||
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
|
||||
}
|
||||
if months[len(months)-1].Key != "2026-07" {
|
||||
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
|
||||
}
|
||||
// Every step is exactly one month — no jumps.
|
||||
for i := 1; i < len(months); i++ {
|
||||
prev, _ := time.Parse("2006-01", months[i-1].Key)
|
||||
cur, _ := time.Parse("2006-01", months[i].Key)
|
||||
if !prev.AddDate(0, 1, 0).Equal(cur) {
|
||||
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user