feat: stats timeline, band-map colours, frameless window, and four fixes

Statistics — the activity chart ignored the period selector: picking a year up
top left it showing the last 7 days, two controls contradicting each other. The
buttons were four fixed rolling windows anchored on the newest QSO, not
granularities. They now choose the BUCKET SIZE over the selected period, with an
Auto default and a step-up when a bucket would be too fine (the backend drops a
series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day
histogram covered a single day, too little to read anything from; it moves to
its own card with a weekday view, over the whole period.

Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the
same amber that means "new band" on the pills drawn on top of them. A sub-band
is an identity, not a state: categorical hues now, validated in both themes
(violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and
added to the legend, since identity must never be colour-alone.

Frameless window — the OS title bar was a dead 32px band above a window that
already has its own. Minimise/maximise/close move into the app header, which
carries the drag region and double-click-to-maximise. The drag opt-out is by
ROLE in CSS, so a future button in that bar keeps working without anyone
remembering the rule.

Fixes:
- Saving settings froze the window while a device was slow: restarting a link
  waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM
  Connect when another program holds the rig. The restart is now off the UI
  path; a slow one is logged.
- Multi-screen: a MAXIMISED window was never repositioned, so it came back on
  the primary screen every launch. The corner is now recorded even when
  maximised (it names the monitor) and re-applied while still hidden.
- The Callsign field is marked out at rest — operators were typing the call into
  Name, which sat beside it and looked identical.
- QSL dates get a real picker (native, for the OS calendar and locale order); a
  malformed old value stays in a text box rather than vanishing behind an empty
  one.

Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2
report (one element then a ten-second pause, sidetone that will not switch off)
is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would
break the operators it works for today. The trace logs every byte with the
command name and spells out the firmware family, which is what will settle it.
This commit is contained in:
2026-07-27 20:50:44 +02:00
parent 2b5c195ab4
commit 816a727e88
16 changed files with 668 additions and 79 deletions
+46 -16
View File
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
'70cm': [430000, 440000],
};
// Sub-band shading: CW / digital / phone.
//
// These are IDENTITIES (which mode the segment is for), not states, so they take
// categorical hues — never the status tokens. They used to use success / info /
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
// is "new band" in this very component's legend, so the whole SSB portion read as
// a giant "new band" wash. Status colours are reserved for status.
//
// All three are drawn from the COOL end of the categorical order and laid down at
// low opacity, so the band plan stays background context while the warm spot pills
// keep the foreground to themselves.
// Checked with the palette validator in BOTH themes. Violet was the first pick
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
// protan reader there, i.e. the same colour. Magenta clears every check on both
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
const SEG_CW = 'var(--chart-7)'; // magenta
const SEG_DIGI = 'var(--chart-1)'; // blue
const SEG_PHONE = 'var(--chart-2)'; // aqua
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
'60m': [[5350, 5450, 'fill-warning-muted']],
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
};
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
{label}
</span>
);
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
height={totalH}
preserveAspectRatio="none"
>
{segments.map(([s, e, cls], i) => {
{segments.map(([s, e, colour], i) => {
const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo));
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
return (
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
fill={colour} fillOpacity={SEG_OPACITY} />
);
})}
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg>
@@ -559,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')}
+5 -1
View File
@@ -6,7 +6,10 @@ import {
import { cn } from '@/lib/utils';
export type MenuItem =
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
// accent highlights an item that is an OFFER rather than a command (Donate).
// Generic on purpose: a hard-coded label test in the renderer would break the
// moment the menu is translated.
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
| { type: 'separator' };
export interface Menu {
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
<DropdownMenuItem
key={i}
disabled={item.disabled}
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
onSelect={() => onAction(item.action)}
>
<span>{item.label}</span>
+53 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
);
}
// AdifDateInput — a real date picker over an ADIF date.
//
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
// are converted at the edge and the log keeps its ADIF form. The native control
// is used on purpose: it brings the OS calendar, the locale's day/month order
// and keyboard entry for free, which a hand-rolled popover would have to
// reimplement and get wrong.
//
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
// a plain text box instead, so it stays visible and correctable rather than
// silently disappearing behind an empty picker.
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
const raw = (value ?? '').trim();
const valid = /^\d{8}$/.test(raw);
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
// UTC: every date in the log is UTC, and near midnight the local day is the
// wrong one.
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
};
if (raw !== '' && !valid) {
return (
<div className="flex gap-1">
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
</div>
);
}
return (
<div className="flex gap-1">
<Input
type="date"
value={iso}
disabled={disabled}
onChange={(e) => {
const v = e.target.value; // "" when cleared
onChange(v ? v.replace(/-/g, '') : '');
}}
className="font-mono"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
onClick={today} title="Today (UTC)">
<CalendarDays className="size-3.5" />
</Button>
</div>
);
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
@@ -580,8 +630,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
</div>
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)}
+14
View File
@@ -35,6 +35,7 @@ import {
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
SetWinkeyerTrace,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
@@ -1093,6 +1094,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
// Session-only: the byte trace is a diagnostic, never a saved preference.
const [wkTrace, setWkTrace] = useState(false);
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
// ── Audio (DVK + QSO recorder) ──
@@ -3363,6 +3366,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
</label>
</div>
{/* Protocol trace for reporting a keyer that misbehaves. Session
only, deliberately not persisted: it is a diagnostic, and a log
full of hex bytes helps nobody who forgot it was on. */}
<div className="border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
{t('wk.trace')}
</label>
<p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
</div>
</>
)}
+63 -6
View File
@@ -33,6 +33,10 @@ type Stats = {
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Chronological series across the SELECTED period, one per bucket size; empty
// when that bucket would be too fine for the period (see the Go side).
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
@@ -509,19 +513,44 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
// own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
// panel — not a window of its own. It used to be four fixed rolling windows
// anchored on the newest QSO, which ignored the period filter entirely: picking
// a year up top left this chart showing the last 7 days.
const ACT_GRAN = [
{ key: 'timeline', tkey: 'stats.granTimeline' },
{ key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' },
] as const;
type Gran = typeof ACT_GRAN[number]['key'];
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
const [g, setG] = useState<Gran | 'auto'>('auto');
const seriesFor = (k: Gran): Bucket[] => (
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
);
// Auto picks the finest bucket that stays readable: the backend leaves a
// series empty when it would be too fine for the period, so "the first
// non-empty one" is exactly the right rule and needs no thresholds here.
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
// A bucket the user forces but that is too fine for the period falls back to
// the next coarser one rather than showing an empty chart.
let shown: Gran = g === 'auto' ? auto : g;
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
const series = seriesFor(shown);
const steppedUp = g !== 'auto' && shown !== g;
return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1">
<div className="mb-2 flex flex-wrap gap-1 items-center">
<button type="button" onClick={() => setG('auto')}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('stats.granAuto')}
</button>
{ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
@@ -529,14 +558,39 @@ function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: an
{t(o.tkey)}
</button>
))}
{steppedUp && (
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
)}
</div>
{g === 'timeline'
? <AreaTrend data={stats.by_month} empty={empty} />
{/* Many buckets read better as a filled trend than as a forest of bars. */}
{series.length > 60
? <AreaTrend data={series} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />}
</Card>
);
}
// RhythmCard — WHEN the operator is on the air over the selected period, which is
// a different question from "how much lately" and so gets its own card. The hour
// histogram used to cover a single day, too little to read anything from.
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<'hour' | 'weekday'>('hour');
return (
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
<div className="mb-2 flex flex-wrap gap-1">
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
<button key={k} type="button" onClick={() => setG(k)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(tk)}
</button>
))}
</div>
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
</Card>
);
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
@@ -603,6 +657,8 @@ export function StatsPanel() {
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),
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
@@ -818,6 +874,7 @@ export function StatsPanel() {
</Card>
<ActivityCard stats={stats} t={t} empty={empty} />
<RhythmCard stats={stats} t={t} empty={empty} />
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls