diff --git a/app.go b/app.go index f5d46ae..05074ef 100644 --- a/app.go +++ b/app.go @@ -1637,6 +1637,14 @@ func (a *App) saveWindowState() { if w > 0 && h > 0 { ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y } + } else if max { + // The POSITION is recorded even when maximised — it is what identifies the + // MONITOR. Without it a multi-screen operator who always runs maximised got + // no position at all, and Windows reopened OpsLog on the primary screen + // every time. The SIZE is deliberately not touched here: it would be the + // maximised size, which must not become the restored-down size. + x, y := wruntime.WindowGetPosition(a.ctx) + ws.X, ws.Y = x, y } writeWindowState(a.dataDir, ws) } @@ -1651,7 +1659,24 @@ func (a *App) restoreWindowPosition() { return } ws, ok := readWindowState(a.dataDir) - if !ok || ws.Maximised { + if !ok { + return + } + // Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise, + // so a two-screen operator lost OpsLog to the wrong monitor at every launch. + // Un-maximise, move to the saved corner (which names the monitor), maximise + // again — all while the window is still hidden, so nothing flickers. + if ws.Maximised { + if ws.X == 0 && ws.Y == 0 { + return // never recorded (or genuinely the primary corner) — nothing to do + } + if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) { + applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y) + return + } + wruntime.WindowUnmaximise(a.ctx) + wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) + wruntime.WindowMaximise(a.ctx) return } if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { @@ -6541,7 +6566,7 @@ func (a *App) SaveCATSettings(s CATSettings) error { return err } } - a.reloadCAT() + a.restartAsync("cat", a.reloadCAT) return nil } @@ -11764,12 +11789,34 @@ func (a *App) SwitchCATRig(n int) error { if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil { return err } - a.reloadCAT() + a.restartAsync("cat", a.reloadCAT) return nil } // reloadCAT (re)starts the CAT manager based on the current settings. // Called at startup and after the user saves new CAT config. +// restartAsync runs a hardware (re)start off the caller's goroutine. +// +// Saving settings must never block the UI on a device. Restarting a link tears +// the old one down FIRST and waits for its poll goroutine to exit — and that +// goroutine can be tens of seconds deep inside a call that cannot be +// interrupted: OmniRig's COM Connect when another program (MSHV, a contest +// logger) is holding the rig takes ~45 s to give up. Save-and-close froze for +// exactly that long, because the Wails binding was waiting on it. +// +// The restart still takes as long; it just no longer holds the window. The +// duration is logged, since "the rig took 45 s to let go" is invisible +// otherwise and looks like OpsLog hanging. +func (a *App) restartAsync(name string, f func()) { + go func() { + t0 := time.Now() + f() + if d := time.Since(t0); d > 2*time.Second { + applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond)) + } + }() +} + func (a *App) reloadCAT() { if a.cat == nil { return @@ -12105,7 +12152,10 @@ func (a *App) reloadAfterProfileSwitch() { if a.extsvc != nil { a.extsvc.SetConfig(a.loadExternalServices()) } - a.reloadCAT() + // Off the caller's goroutine for the same reason as the settings save: this + // runs from ActivateProfile, a click, and a rig that is slow to release would + // otherwise freeze the switch. + a.restartAsync("cat", a.reloadCAT) a.startQSORecorderIfEnabled() } @@ -12835,7 +12885,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { return err } } - a.startUltrabeam() + a.restartAsync("antenna", a.startUltrabeam) return nil } @@ -13408,7 +13458,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error { return err } } - a.startAmps() + a.restartAsync("amp", a.startAmps) return nil } @@ -13544,7 +13594,7 @@ func (a *App) SaveAmplifiers(list []AmpConfig) error { if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil { return err } - a.startAmps() + a.restartAsync("amp", a.startAmps) return nil } @@ -13989,6 +14039,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error { return nil } +// SetWinkeyerTrace turns the byte-level WinKeyer protocol trace on or off. +// +// The K1EL command set differs across WK1 / WK2 / WK3 and a mismatch is silent: +// the keyer accepts a byte meant for another command and keys something odd +// (one element then a long pause, a sidetone that will not switch off). Reading +// the actual wire is the only way to tell which command the firmware misread, +// and it beats guessing from a description — a guessed fix breaks the operators +// for whom it currently works. +func (a *App) SetWinkeyerTrace(on bool) { + winkeyer.SetTrace(on) +} + // WinkeyerConnect opens the serial link using the saved config. func (a *App) WinkeyerConnect() error { if a.winkeyer == nil { diff --git a/changelog.json b/changelog.json index 67f5fc1..8cae551 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,32 @@ [ + { + "version": "0.21.6", + "date": "2026-07-27", + "en": [ + "Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.", + "Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.", + "Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.", + "Help menu: a Support OpsLog entry, which opens the donation page in your browser.", + "The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.", + "Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.", + "The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.", + "Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.", + "Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).", + "CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly." + ], + "fr": [ + "Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.", + "Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.", + "Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.", + "Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.", + "Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.", + "Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.", + "La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.", + "Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.", + "Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).", + "Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal." + ] + }, { "version": "0.21.5", "date": "2026-07-27", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb457e0..f5005af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,7 +52,7 @@ import { } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs } from '@/lib/awardRefs'; -import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime'; +import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -331,6 +331,54 @@ function computePrefix(call: string): string { return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c; } +// WindowControls — minimise / maximise / close, since the window is frameless. +// +// Deliberately shaped like the Windows ones (flat, wide, close goes red on +// hover) rather than styled like the app's own buttons: these are OS controls, +// and an operator must recognise them without reading them. Close runs the +// normal shutdown path — the same one the OS button used — so the backup and +// on-close uploads still happen. +function WindowControls() { + const [max, setMax] = useState(false); + useEffect(() => { + WindowIsMaximised().then(setMax).catch(() => {}); + }, []); + const btn = 'inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors'; + return ( +
+ + + +
+ ); +} + export default function App() { const { t, lang } = useI18n(); // === Lists from settings (fallback for first paint) === @@ -3292,6 +3340,8 @@ export default function App() { { type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog }, ] : []), { type: 'separator' }, + { type: 'item', label: t('help.donate'), action: 'help.donate', accent: true }, + { type: 'separator' }, { type: 'item', label: t('help.about'), action: 'help.about' }, ]}, ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]); @@ -3325,6 +3375,9 @@ export default function App() { case 'help.about': setShowAbout(true); checkUpdateNow(); break; case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break; case 'help.sendlog': sendLogToDeveloper(); break; + // Opens in the system browser, NOT the app WebView: a payment page must show + // the address bar and padlock the donor knows how to check. + case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break; } } @@ -3456,7 +3509,7 @@ export default function App() { const callsignBlock = (
{t('bmp.footerHint')} diff --git a/frontend/src/components/Menubar.tsx b/frontend/src/components/Menubar.tsx index 96069cb..54f8209 100644 --- a/frontend/src/components/Menubar.tsx +++ b/frontend/src/components/Menubar.tsx @@ -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) { onAction(item.action)} > {item.label} diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index cfe6196..7dc96fe 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -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 ( +
+ onChange(e.target.value)} disabled={disabled} className="font-mono" /> + +
+ ); + } + return ( +
+ { + const v = e.target.value; // "" when cleared + onChange(v ? v.replace(/-/g, '') : ''); + }} + className="font-mono" + /> + +
+ ); +} + 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 ? put(def.rcvd, v)} /> : }
-
put(def.sentDate, e.target.value)} className="font-mono" />
-
put(def.rcvdDate, e.target.value)} className="font-mono" />
+
put(def.sentDate, v)} />
+
put(def.rcvdDate, v)} disabled={!def.rcvdDate} />
{def.via && (
put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} />
)} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1ec1a30..407409b 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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([]); + // Session-only: the byte trace is a diagnostic, never a saved preference. + const [wkTrace, setWkTrace] = useState(false); const setWkField = (patch: Partial) => setWk((s) => ({ ...s, ...patch })); // ── Audio (DVK + QSO recorder) ── @@ -3363,6 +3366,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')} + + {/* 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. */} +
+ +

{t('wk.traceHint')}

+
)} diff --git a/frontend/src/components/StatsPanel.tsx b/frontend/src/components/StatsPanel.tsx index 93e52d8..7d92d0c 100644 --- a/frontend/src/components/StatsPanel.tsx +++ b/frontend/src/components/StatsPanel.tsx @@ -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 = { 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('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('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 ( -
+
+ {ACT_GRAN.map((o) => ( ))} + {steppedUp && ( + {t('stats.granTooFine')} + )}
- {g === 'timeline' - ? + {/* Many buckets read better as a filled trend than as a forest of bars. */} + {series.length > 60 + ? : } ); } +// 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 ( + +
+ {([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => ( + + ))} +
+ +
+ ); +} + // ── 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() { + {/* 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 diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 3f8448a..86596df 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -40,7 +40,7 @@ const en: Dict = { 'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)', 'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode', 'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear', - 'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…', + 'menu.help': 'Help', 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…', 'help.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…', 'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}', // Duplicates modal @@ -73,7 +73,7 @@ const en: Dict = { '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': 'Timeline, or the last day / 7 days / 30 days / 12 months', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year', + 'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per day, week, month or year over the selected period', 'stats.granTimeline': 'Timeline', 'stats.granAuto': 'Auto', 'stats.granDay': 'Per day', 'stats.granWeek': 'Per week', 'stats.granMonth': 'Per month', 'stats.granYear': 'Per year', 'stats.granTooFine': 'too fine for this period — stepped up', 'stats.rhythm': 'When you are on the air', 'stats.rhythmSub': 'Over the selected period, in UTC', 'stats.byHourOfDay': 'By hour', 'stats.byWeekday': 'By day of week', 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL', 'stats.byContinentSub': 'Share of the log', @@ -154,7 +154,7 @@ const en: Dict = { 'wk.speed': 'Speed (WPM)', 'wk.farnsworth': 'Farnsworth', 'wk.leadIn': 'Lead-in (ms)', 'wk.tail': 'Tail (ms)', 'wk.keyPtt': 'Key PTT line', 'wk.invert': 'Invert keying (active-LOW) — tick this only if the rig transmits at rest / keys backwards', 'wk.serialPort': 'Serial port', 'wk.baud': 'Baud', 'wk.weight': 'Weight', 'wk.ratio': 'Ratio (33-66)', 'wk.sidetone': 'Sidetone (Hz)', 'wk.paddleMode': 'Paddle mode', - 'wk.swapPaddles': 'Swap paddles', 'wk.autospace': 'Auto-space', 'wk.keyPttShort': 'Key PTT', 'wk.serialEcho': 'Serial echo', + 'wk.swapPaddles': 'Swap paddles', 'wk.autospace': 'Auto-space', 'wk.keyPttShort': 'Key PTT', 'wk.serialEcho': 'Serial echo', 'wk.trace': 'Log the keyer protocol (diagnostic)', 'wk.traceHint': 'Writes every byte exchanged with the keyer to the diagnostic log. Turn it on, reproduce the problem, then send the log — the WK1/WK2/WK3 command sets differ and this shows which command your firmware read differently. Off again at the next start.', 'wk.noPorts': 'No ports found', 'wk.reloadPorts': 'Reload ports', 'wk.comPort': '— COM port —', 'wk.macroTitle': 'CW message macros (F1…)', 'wk.macroVars': 'Use variables:', 'wk.macroCut': '(cut numbers: 9→N, 0→T).', 'wk.macroMyCall': 'my call', 'wk.macroHisCall': 'his call', 'wk.macroLog': 'log the QSO when the macro is sent', 'wk.label': 'Label', 'sec.relayauto': 'Relay auto-control', @@ -320,7 +320,7 @@ const en: Dict = { 'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.', 'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map', 'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge', - 'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)', + 'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)', 'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).', 'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name', 'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging', @@ -438,7 +438,7 @@ const fr: Dict = { 'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)', 'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', 'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer', - 'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…', + 'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…', 'help.sendLog': 'Envoyer le journal à F4BPO', 'help.sendLogBusy': 'Envoi du journal…', 'help.sendLogOk': 'Journal envoyé à F4BPO — merci, 73 !', 'help.sendLogFail': 'Échec de l\'envoi du journal : {err}', 'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉', @@ -468,7 +468,7 @@ const fr: Dict = { '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': 'Chronologie, ou les derniers jour / 7 jours / 30 jours / 12 mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année', + 'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par jour, semaine, mois ou année sur la période choisie', 'stats.granTimeline': 'Chronologie', 'stats.granAuto': 'Auto', 'stats.granDay': 'Par jour', 'stats.granWeek': 'Par semaine', 'stats.granMonth': 'Par mois', 'stats.granYear': 'Par année', 'stats.granTooFine': 'trop fin pour cette période — regroupé', 'stats.rhythm': 'Quand vous trafiquez', 'stats.rhythmSub': 'Sur la période choisie, en UTC', 'stats.byHourOfDay': 'Par heure', 'stats.byWeekday': 'Par jour de semaine', 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier', 'stats.byContinentSub': 'Part du journal', @@ -548,7 +548,7 @@ const fr: Dict = { 'wk.speed': 'Vitesse (WPM)', 'wk.farnsworth': 'Farnsworth', 'wk.leadIn': 'Lead-in (ms)', 'wk.tail': 'Tail (ms)', 'wk.keyPtt': 'Manipuler la ligne PTT', 'wk.invert': "Inverser le keying (actif-BAS) — à cocher seulement si la radio émet au repos / manipule à l'envers", 'wk.serialPort': 'Port série', 'wk.baud': 'Baud', 'wk.weight': 'Poids', 'wk.ratio': 'Ratio (33-66)', 'wk.sidetone': 'Sidetone (Hz)', 'wk.paddleMode': 'Mode paddle', - 'wk.swapPaddles': 'Inverser les paddles', 'wk.autospace': 'Auto-espacement', 'wk.keyPttShort': 'Manipuler PTT', 'wk.serialEcho': 'Écho série', + 'wk.swapPaddles': 'Inverser les paddles', 'wk.autospace': 'Auto-espacement', 'wk.keyPttShort': 'Manipuler PTT', 'wk.serialEcho': 'Écho série', 'wk.trace': 'Journaliser le protocole du keyer (diagnostic)', 'wk.traceHint': "Écrit dans le journal chaque octet échangé avec le keyer. Activez, reproduisez le problème, puis envoyez le journal — les jeux de commandes WK1/WK2/WK3 diffèrent et c'est ce qui montre quelle commande votre microprogramme a lue autrement. Désactivé au prochain démarrage.", 'wk.noPorts': 'Aucun port trouvé', 'wk.reloadPorts': 'Recharger les ports', 'wk.comPort': '— port COM —', 'wk.macroTitle': 'Macros de messages CW (F1…)', 'wk.macroVars': 'Variables :', 'wk.macroCut': '(chiffres abrégés : 9→N, 0→T).', 'wk.macroMyCall': 'mon indicatif', 'wk.macroHisCall': 'son indicatif', 'wk.macroLog': 'logue le QSO quand la macro est envoyée', 'wk.label': 'Libellé', 'sec.relayauto': 'Relais automatiques', @@ -699,7 +699,7 @@ const fr: Dict = { 'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.', 'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande', 'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande', - 'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)', + 'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)', 'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).', 'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom', 'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger', diff --git a/frontend/src/style.css b/frontend/src/style.css index 5b3d2b9..fcc5769 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -652,3 +652,37 @@ border: 2px solid var(--background); } ::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } + +/* ── Frameless window: drag region ────────────────────────────────────────── + The OS title bar is gone (main.go, Frameless), so the app header IS the title + bar and must be draggable. Everything interactive inside it opts OUT: a drag + region swallows clicks, and without this the menus and toolbar buttons would + move the window instead of doing their job. Opting out by ROLE rather than + listing each child keeps a future button working without anyone remembering + this rule. */ +.app-dragregion { + --wails-draggable: drag; +} +.app-dragregion button, +.app-dragregion a, +.app-dragregion input, +.app-dragregion select, +.app-dragregion textarea, +.app-dragregion nav, +.app-dragregion [role='button'], +.app-dragregion [role='menu'], +.app-dragregion [data-no-drag] { + --wails-draggable: no-drag; +} + +/* Native date inputs: the WebView draws its calendar glyph in near-black, which + disappears on the dark themes. Invert it there — the control itself is worth + keeping (OS calendar, locale date order, keyboard entry), only its icon needs + help. */ +[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator, +[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator, +[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator, +[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator { + filter: invert(1) brightness(1.6); + opacity: 0.75; +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9c36a65..9229a11 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -907,6 +907,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise; export function SetUltrabeamDirection(arg1:number):Promise; +export function SetWinkeyerTrace(arg1:boolean):Promise; + export function StartCWDecoder():Promise; export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f360ad1..a55b439 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1762,6 +1762,10 @@ export function SetUltrabeamDirection(arg1) { return window['go']['main']['App']['SetUltrabeamDirection'](arg1); } +export function SetWinkeyerTrace(arg1) { + return window['go']['main']['App']['SetWinkeyerTrace'](arg1); +} + export function StartCWDecoder() { return window['go']['main']['App']['StartCWDecoder'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1ae26a9..cd5435c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -4204,6 +4204,12 @@ export namespace qso { by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[]; + by_day_win: Bucket[]; + by_week_win: Bucket[]; + by_month_win: Bucket[]; + by_year_win: Bucket[]; + by_hour_of_day: Bucket[]; + by_weekday: Bucket[]; window_start: string; window_end: string; window_hours: number; @@ -4248,6 +4254,12 @@ export namespace qso { this.by_day7 = this.convertValues(source["by_day7"], Bucket); this.by_day30 = this.convertValues(source["by_day30"], Bucket); this.by_month12 = this.convertValues(source["by_month12"], Bucket); + this.by_day_win = this.convertValues(source["by_day_win"], Bucket); + this.by_week_win = this.convertValues(source["by_week_win"], Bucket); + this.by_month_win = this.convertValues(source["by_month_win"], Bucket); + this.by_year_win = this.convertValues(source["by_year_win"], Bucket); + this.by_hour_of_day = this.convertValues(source["by_hour_of_day"], Bucket); + this.by_weekday = this.convertValues(source["by_weekday"], Bucket); this.window_start = source["window_start"]; this.window_end = source["window_end"]; this.window_hours = source["window_hours"]; diff --git a/internal/qso/stats.go b/internal/qso/stats.go index 9c230bb..2269cec 100644 --- a/internal/qso/stats.go +++ b/internal/qso/stats.go @@ -156,28 +156,49 @@ type Stats struct { ByMode []Bucket `json:"by_mode"` ByBand []Bucket `json:"by_band"` ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split - 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 + 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 // Rolling chronological activity windows (UTC), anchored to the newest QSO, for // the "activity over time" chart's day/week/month/year granularity selector. // Real dates in order (empty buckets are real, just zero) — no cyclical wrap. - ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23) - ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO - ByDay30 []Bucket `json:"by_day30"` // the last 30 days - ByMonth12 []Bucket `json:"by_month12"` // the last 12 months + ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23) + ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO + ByDay30 []Bucket `json:"by_day30"` // the last 30 days + ByMonth12 []Bucket `json:"by_month12"` // the last 12 months + + // Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket + // size. These are what the activity chart's day/week/month/year selector + // drives: the rolling windows above ignored the period filter, so choosing a + // year in the panel left the chart showing the last 7 days regardless — two + // controls contradicting each other on screen. + // + // A series is EMPTY when it would exceed maxActivityBuckets (per-day over a + // seventeen-year log is ~6000 bars: unreadable, and pointless to transport). + // The UI treats an empty series as "too fine for this period" and steps up. + ByDayWin []Bucket `json:"by_day_win"` + ByWeekWin []Bucket `json:"by_week_win"` + ByMonthWin []Bucket `json:"by_month_win"` + ByYearWin []Bucket `json:"by_year_win"` + + // Rhythm: WHEN the operator is on the air, over the selected period. Distinct + // question from the above ("how much, lately"), so it gets its own card. The + // hour histogram used to cover a single day, which is too little to read + // anything from. + ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC + ByWeekday []Bucket `json:"by_weekday"` // Mon..Sun // ── 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"` + 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 @@ -297,28 +318,28 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, defer rows.Close() var ( - calls = map[string]struct{}{} - entities = map[int]struct{}{} - modeC = map[string]int{} - bandC = map[string]int{} - bandCat = map[string]*BandCategory{} // per band: cw/phone/data - opC = map[string]int{} - stationC = map[string]int{} - contC = map[string]int{} - entityC = map[string]int{} - yearC = map[string]int{} + calls = map[string]struct{}{} + entities = map[int]struct{}{} + modeC = map[string]int{} + bandC = map[string]int{} + bandCat = map[string]*BandCategory{} // per band: cw/phone/data + 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 []entry // every dated QSO (+ its operator), for the rate / gap maths + times []entry // every dated QSO (+ its operator), 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 + 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 { @@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, ref = time.Now() } s.recentSeries(times, ref) + s.windowSeries(times, first, last, from, to) s.ensureNonNil() return s, nil } +// maxActivityBuckets caps a chronological series. Beyond this the chart is a +// grey smear and the payload grows for nothing; the series is dropped and the UI +// steps up to a coarser bucket. +const maxActivityBuckets = 750 + +// windowSeries builds the chronological activity series ACROSS THE SELECTED +// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm. +// +// The period is [from,to] when the caller filtered, else the span of the log — +// the same window the period metrics use, so every card on the panel answers for +// the same slice of time. +func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) { + start, end := from, to + if start.IsZero() { + start = first + } + if end.IsZero() { + end = last + } + if start.IsZero() || end.IsZero() || end.Before(start) { + return + } + start, end = start.UTC(), end.UTC() + + day := map[string]int{} + week := map[string]int{} + month := map[string]int{} + year := map[string]int{} + hour := make([]int, 24) + weekday := make([]int, 7) + for _, e := range times { + t := e.t.UTC() + if t.Before(start) || t.After(end) { + continue + } + day[t.Format("2006-01-02")]++ + // ISO week, so a week is Monday→Sunday everywhere and the key sorts. + wy, wn := t.ISOWeek() + week[fmt.Sprintf("%04d-W%02d", wy, wn)]++ + month[t.Format("2006-01")]++ + year[t.Format("2006")]++ + hour[t.Hour()]++ + weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday + } + + // Every bucket in the range is emitted, including the empty ones: a gap in + // the log is real information and must show as zero, not be closed up. + d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) + dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC) + if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets { + for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) { + s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]}) + } + } + // Weeks: start on the Monday of the first week and step by 7 days. + w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7)) + if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets { + for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) { + wy, wn := w.ISOWeek() + key := fmt.Sprintf("%04d-W%02d", wy, wn) + s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]}) + } + } + m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC) + mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC) + if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets { + for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) { + s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]}) + } + } + for y := start.Year(); y <= end.Year(); y++ { + k := fmt.Sprintf("%04d", y) + s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]}) + } + + for h := 0; h < 24; h++ { + s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]}) + } + for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} { + s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]}) + } +} + // recentSeries builds the rolling chronological activity windows for the chart's // day/week/month/year selector, in UTC and anchored to `ref` (the period end when // filtered, else now). Real dates in order — today and the days before it — so the @@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() { if s.ByMonth == nil { s.ByMonth = []Bucket{} } + // A window series stays EMPTY on purpose when the bucket would be too fine + // for the period — but it must be [] and not null, so the UI can tell + // "too fine" from "not computed". + for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} { + if *p == nil { + *p = []Bucket{} + } + } if s.Rate == nil { s.Rate = []Bucket{} } @@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() { // 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 +// - 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 []entry, from, to, first, last time.Time) { if len(times) == 0 { @@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket { 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. diff --git a/internal/winkeyer/winkeyer.go b/internal/winkeyer/winkeyer.go index a4d9552..91af4fe 100644 --- a/internal/winkeyer/winkeyer.go +++ b/internal/winkeyer/winkeyer.go @@ -16,6 +16,7 @@ import ( "runtime/debug" "strings" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error { stop, done := m.stopRead, m.doneRead m.mu.Unlock() - applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver) + applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver)) go m.readLoop(p, stop, done) if err := m.applyConfig(cfg); err != nil { @@ -398,10 +399,112 @@ func (m *Manager) write(b []byte) error { if p == nil { return fmt.Errorf("winkeyer: not connected") } + traceTX(b) _, err := p.Write(b) return err } +// ── Protocol trace ───────────────────────────────────────────────────── +// +// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures +// it produces are silent: the keyer accepts a byte meant for another command +// and keys something odd. Guessing at that from a description ("it sends one +// element then pauses ten seconds") is how a fix for one firmware breaks the +// others, so the wire is made visible instead. +// +// Off by default — 1200 baud is slow but a long message would still fill the +// log. Enabled per session from the CW settings panel. + +var traceOn atomic.Bool + +// SetTrace turns the byte-level protocol trace on or off. +func SetTrace(on bool) { + traceOn.Store(on) + if on { + applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged") + } +} + +func hexBytes(b []byte) string { + var sb strings.Builder + for i, c := range b { + if i > 0 { + sb.WriteByte(' ') + } + fmt.Fprintf(&sb, "%02X", c) + if c >= 0x20 && c < 0x7F { + fmt.Fprintf(&sb, "(%c)", c) + } + } + return sb.String() +} + +func traceTX(b []byte) { + if !traceOn.Load() || len(b) == 0 { + return + } + applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b)) +} + +func traceRX(b []byte) { + if !traceOn.Load() || len(b) == 0 { + return + } + applog.Printf("winkeyer: RX %s", hexBytes(b)) +} + +// cmdName names the command a TX frame carries, so the trace can be read +// without the datasheet open beside it. +func cmdName(b []byte) string { + if len(b) == 0 || b[0] >= 0x20 { + return "(text)" + } + switch b[0] { + case 0x00: + return "admin" + case 0x01: + return "sidetone" + case 0x02: + return "set wpm" + case 0x03: + return "set weight" + case 0x04: + return "ptt lead/tail" + case 0x05: + return "setup speed pot" + case 0x06: + return "pause" + case 0x0A: + return "clear buffer" + case 0x0D: + return "farnsworth wpm" + case 0x0E: + return "set mode register" + case 0x11: + return "0x11 (WK2: key compensation / WK3: see datasheet)" + case 0x15: + return "request status" + default: + return fmt.Sprintf("cmd 0x%02X", b[0]) + } +} + +// firmwareFamily names the keyer generation from the byte returned by Host +// Open. The version is what decides which command set applies, so it is spelled +// out in the log rather than left as a bare number. +func firmwareFamily(ver int) string { + switch { + case ver == 0: + return "no reply — the keyer did not answer Host Open" + case ver < 20: + return fmt.Sprintf("WK1 (v%d)", ver) + case ver < 30: + return fmt.Sprintf("WK2 (v%d)", ver) + default: + return fmt.Sprintf("WK3 (v%d)", ver) + } +} + // Disconnect sends Host Close and releases the port. func (m *Manager) Disconnect() { m.mu.Lock() @@ -473,6 +576,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) { } return } + traceRX(buf[:n]) for i := 0; i < n; i++ { b := buf[i] switch { diff --git a/main.go b/main.go index 809537e..66a3065 100644 --- a/main.go +++ b/main.go @@ -112,6 +112,12 @@ func main() { MinWidth: 1100, MinHeight: 700, WindowStartState: startState, + // No OS title bar: it was a dead 32-pixel band above a window that already + // has its own title strip. The app header takes over — it carries the drag + // region, the double-click-to-maximise, and the minimise/maximise/close + // buttons. Windows still draws the resize borders and honours Aero Snap, + // which is why the frame is dropped rather than the whole chrome. + Frameless: true, // Start hidden and reveal only once the saved position has been applied and // the DOM has painted (OnDomReady → domReady) — so the window appears // already at its final size and position, with no post-launch jump.