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
+69 -7
View File
@@ -1637,6 +1637,14 @@ func (a *App) saveWindowState() {
if w > 0 && h > 0 { if w > 0 && h > 0 {
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y 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) writeWindowState(a.dataDir, ws)
} }
@@ -1651,7 +1659,24 @@ func (a *App) restoreWindowPosition() {
return return
} }
ws, ok := readWindowState(a.dataDir) 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 return
} }
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { 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 return err
} }
} }
a.reloadCAT() a.restartAsync("cat", a.reloadCAT)
return nil 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 { if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
return err return err
} }
a.reloadCAT() a.restartAsync("cat", a.reloadCAT)
return nil return nil
} }
// reloadCAT (re)starts the CAT manager based on the current settings. // reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config. // 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() { func (a *App) reloadCAT() {
if a.cat == nil { if a.cat == nil {
return return
@@ -12105,7 +12152,10 @@ func (a *App) reloadAfterProfileSwitch() {
if a.extsvc != nil { if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices()) 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() a.startQSORecorderIfEnabled()
} }
@@ -12835,7 +12885,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return err return err
} }
} }
a.startUltrabeam() a.restartAsync("antenna", a.startUltrabeam)
return nil return nil
} }
@@ -13408,7 +13458,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err return err
} }
} }
a.startAmps() a.restartAsync("amp", a.startAmps)
return nil 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 { if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
return err return err
} }
a.startAmps() a.restartAsync("amp", a.startAmps)
return nil return nil
} }
@@ -13989,6 +14039,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
return nil 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. // WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error { func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil { if a.winkeyer == nil {
+28
View File
@@ -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", "version": "0.21.5",
"date": "2026-07-27", "date": "2026-07-27",
+74 -5
View File
@@ -52,7 +52,7 @@ import {
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; 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 { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; 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; 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 (
<div className="flex items-center gap-0.5 -mr-2 ml-1" data-no-drag>
<button type="button" className={btn} onClick={() => WindowMinimise()} title="Minimise" aria-label="Minimise">
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden><path d="M0 5h10" stroke="currentColor" strokeWidth="1.2" /></svg>
</button>
<button type="button" className={btn}
// Flip the icon straight away, then confirm from the runtime: reading it
// back immediately returns the state BEFORE the toggle has been applied.
onClick={() => {
WindowToggleMaximise();
setMax((v) => !v);
setTimeout(() => { WindowIsMaximised().then(setMax).catch(() => {}); }, 150);
}}
title={max ? 'Restore' : 'Maximise'} aria-label={max ? 'Restore' : 'Maximise'}>
{max ? (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="0.6" y="2.6" width="6.8" height="6.8" /><path d="M2.6 2.6V0.6h6.8v6.8H7.4" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="0.6" y="0.6" width="8.8" height="8.8" />
</svg>
)}
</button>
<button type="button"
className="inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-danger hover:text-danger-foreground transition-colors"
onClick={() => Quit()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden stroke="currentColor" strokeWidth="1.2">
<path d="M0.5 0.5l9 9M9.5 0.5l-9 9" />
</svg>
</button>
</div>
);
}
export default function App() { export default function App() {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) === // === 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: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []), ] : []),
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' }, { type: 'item', label: t('help.about'), action: 'help.about' },
]}, ]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]); ], [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.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.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; 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 = ( const callsignBlock = (
<div className="flex flex-col w-44" data-esm="call"> <div className="flex flex-col w-44" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}> <Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')} <span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && ( {lookupBusy && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60"> <span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
<Loader2 className="size-2.5 animate-spin" /> <Loader2 className="size-2.5 animate-spin" />
@@ -3510,8 +3563,14 @@ export default function App() {
)} )}
<Input <Input
ref={callsignRef} ref={callsignRef}
// The call field is marked out at REST, not only on focus: operators were
// typing the callsign into Name, which sits right beside it and looked
// exactly the same. A primary-tinted border plus an inset left bar make
// it the obvious one without adding a colour the theme doesn't own. The
// contest-dupe state still overrides it — that one is a warning.
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card', className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')} 'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
value={callsign} value={callsign}
onChange={(e) => onCallsignInput(e.target.value)} onChange={(e) => onCallsignInput(e.target.value)}
onBlur={() => { onBlur={() => {
@@ -4235,7 +4294,7 @@ export default function App() {
{compact ? ( {compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space // Minimal compact topbar — brand + freq + toggle. Saves vertical space
// so the single-row entry strip fits in a ~140px tall window. // so the single-row entry strip fits in a ~140px tall window.
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0"> <header className="app-dragregion flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" /> <div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
<span className="font-bold text-xs tracking-tight">OpsLog</span> <span className="font-bold text-xs tracking-tight">OpsLog</span>
@@ -4256,7 +4315,15 @@ export default function App() {
</Button> </Button>
</header> </header>
) : ( ) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"> <header
className="app-dragregion grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"
// Double-click anywhere on the bar toggles maximise, as a title bar does.
// Frameless windows get none of that for free.
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest('button,a,input,select,nav,[role="button"]')) return;
WindowToggleMaximise();
}}
>
<div className="flex items-center gap-2 pr-2 border-r border-border/60"> <div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" /> <div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
@@ -4624,6 +4691,8 @@ export default function App() {
> >
<Minimize2 className="size-3.5" /> <Minimize2 className="size-3.5" />
</Button> </Button>
{/* Window controls — the OS title bar is gone, so they live here. */}
<WindowControls />
</div> </div>
</header> </header>
)} )}
+46 -16
View File
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
'70cm': [430000, 440000], '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][]> = { const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']], '160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']], '80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, 'fill-warning-muted']], '60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']], '40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']], '30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']], '20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']], '17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']], '15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']], '12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']], '10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']], '6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
}; };
// Small coloured dot + label used in the band-map legend strip. // 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 ( return (
<span className="inline-flex items-center gap-1"> <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} {label}
</span> </span>
); );
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
height={totalH} height={totalH}
preserveAspectRatio="none" preserveAspectRatio="none"
> >
{segments.map(([s, e, cls], i) => { {segments.map(([s, e, colour], i) => {
const y1 = freqToY(Math.min(e, hi)); const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo)); 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} /> <line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg> </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-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} /> <LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} /> <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>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0"> <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')} {t('bmp.footerHint')}
+5 -1
View File
@@ -6,7 +6,10 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export type MenuItem = 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' }; | { type: 'separator' };
export interface Menu { export interface Menu {
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
<DropdownMenuItem <DropdownMenuItem
key={i} key={i}
disabled={item.disabled} disabled={item.disabled}
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
onSelect={() => onAction(item.action)} onSelect={() => onAction(item.action)}
> >
<span>{item.label}</span> <span>{item.label}</span>
+53 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 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 { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; 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 }) { function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n(); const { t } = useI18n();
return ( 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)} /> ? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />} : <Input disabled value="—" />}
</div> </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.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></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.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && ( {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> <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, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled, GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
SetWinkeyerTrace,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, 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: [], swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
}); });
const [wkPorts, setWkPorts] = useState<string[]>([]); 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 })); const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
// ── Audio (DVK + QSO recorder) ── // ── 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')} <Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
</label> </label>
</div> </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_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_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: 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. // Period / contest metrics.
window_start: string; window_end: string; window_hours: number; window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: 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 // 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 // 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). // 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 = [ const ACT_GRAN = [
{ key: 'timeline', tkey: 'stats.granTimeline' },
{ key: 'day', tkey: 'stats.granDay' }, { key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' }, { key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' }, { key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' }, { key: 'year', tkey: 'stats.granYear' },
] as const; ] 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 }) { function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline'); const [g, setG] = useState<Gran | 'auto'>('auto');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : []; 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 ( return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)"> <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) => ( {ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)} <button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors', 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)} {t(o.tkey)}
</button> </button>
))} ))}
{steppedUp && (
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
)}
</div> </div>
{g === 'timeline' {/* Many buckets read better as a filled trend than as a forest of bars. */}
? <AreaTrend data={stats.by_month} empty={empty} /> {series.length > 60
? <AreaTrend data={series} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />} : <VBars data={series} empty={empty} showValues height={160} />}
</Card> </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) ───────────────────────────────────────── // ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) { 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), 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), 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_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: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op), rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats); } as Stats);
@@ -818,6 +874,7 @@ export function StatsPanel() {
</Card> </Card>
<ActivityCard stats={stats} t={t} empty={empty} /> <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 {/* 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 worked what, and a cap would quietly delete the 9th operator. Scrolls
+8 -8
View File
@@ -40,7 +40,7 @@ const en: Dict = {
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)', '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', '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', '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.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…',
'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}', 'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}',
// Duplicates modal // Duplicates modal
@@ -73,7 +73,7 @@ const en: Dict = {
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)', 'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent', 'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year', '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.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
'stats.byContinentSub': 'Share of the log', '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.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.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.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.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', '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', '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.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.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.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.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.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', '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 laudio RX en mode CW)', 'cwd.tipOff': 'Décodeur CW · clic pour activer (décode laudio RX en mode CW)',
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', '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', '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.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}', '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é. 🎉', '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 dOPERATOR renseigné)', 'stats.byOperatorSub': '« — » = loggé par le titulaire (pas dOPERATOR renseigné)',
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent', 'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année', '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.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
'stats.byContinentSub': 'Part du journal', '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.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.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.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.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é', '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', '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.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.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.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.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.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', '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',
+34
View File
@@ -652,3 +652,37 @@
border: 2px solid var(--background); border: 2px solid var(--background);
} }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } ::-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;
}
+2
View File
@@ -907,6 +907,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>; export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>; export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
+4
View File
@@ -1762,6 +1762,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
}
export function StartCWDecoder() { export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder'](); return window['go']['main']['App']['StartCWDecoder']();
} }
+12
View File
@@ -4204,6 +4204,12 @@ export namespace qso {
by_day7: Bucket[]; by_day7: Bucket[];
by_day30: Bucket[]; by_day30: Bucket[];
by_month12: 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_start: string;
window_end: string; window_end: string;
window_hours: number; window_hours: number;
@@ -4248,6 +4254,12 @@ export namespace qso {
this.by_day7 = this.convertValues(source["by_day7"], Bucket); this.by_day7 = this.convertValues(source["by_day7"], Bucket);
this.by_day30 = this.convertValues(source["by_day30"], Bucket); this.by_day30 = this.convertValues(source["by_day30"], Bucket);
this.by_month12 = this.convertValues(source["by_month12"], 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_start = source["window_start"];
this.window_end = source["window_end"]; this.window_end = source["window_end"];
this.window_hours = source["window_hours"]; this.window_hours = source["window_hours"];
+145 -32
View File
@@ -156,28 +156,49 @@ type Stats struct {
ByMode []Bucket `json:"by_mode"` ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"` ByBand []Bucket `json:"by_band"`
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
ByOperator []Bucket `json:"by_operator"` ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air) ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"` ByContinent []Bucket `json:"by_continent"`
TopEntities []Bucket `json:"top_entities"` TopEntities []Bucket `json:"top_entities"`
ByYear []Bucket `json:"by_year"` // chronological ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for // Rolling chronological activity windows (UTC), anchored to the newest QSO, for
// the "activity over time" chart's day/week/month/year granularity selector. // 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. // 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) 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 ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
ByDay30 []Bucket `json:"by_day30"` // the last 30 days ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months 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 ── // ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says // 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 // 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. // requested [from,to] when given, else the span of the log.
WindowStart string `json:"window_start"` WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"` WindowEnd string `json:"window_end"`
WindowHours float64 `json:"window_hours"` WindowHours float64 `json:"window_hours"`
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate) AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours 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() defer rows.Close()
var ( var (
calls = map[string]struct{}{} calls = map[string]struct{}{}
entities = map[int]struct{}{} entities = map[int]struct{}{}
modeC = map[string]int{} modeC = map[string]int{}
bandC = map[string]int{} bandC = map[string]int{}
bandCat = map[string]*BandCategory{} // per band: cw/phone/data bandCat = map[string]*BandCategory{} // per band: cw/phone/data
opC = map[string]int{} opC = map[string]int{}
stationC = map[string]int{} stationC = map[string]int{}
contC = map[string]int{} contC = map[string]int{}
entityC = map[string]int{} entityC = map[string]int{}
yearC = map[string]int{} yearC = map[string]int{}
monthC = 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 first, last time.Time
) )
for rows.Next() { for rows.Next() {
var ( var (
call, band, mode, cont, country sql.NullString call, band, mode, cont, country sql.NullString
oper, station sql.NullString oper, station sql.NullString
lotw, eqsl, paper sql.NullString lotw, eqsl, paper sql.NullString
dxcc sql.NullInt64 dxcc sql.NullInt64
dateStr, contestID2 sql.NullString dateStr, contestID2 sql.NullString
) )
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc, if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil { &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() ref = time.Now()
} }
s.recentSeries(times, ref) s.recentSeries(times, ref)
s.windowSeries(times, first, last, from, to)
s.ensureNonNil() s.ensureNonNil()
return s, nil 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 // 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 // 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 // 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 { if s.ByMonth == nil {
s.ByMonth = []Bucket{} 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 { if s.Rate == nil {
s.Rate = []Bucket{} s.Rate = []Bucket{}
} }
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
// log itself. // log itself.
// //
// Two rates are reported on purpose, because a single one always flatters: // Two rates are reported on purpose, because a single one always flatters:
// AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number. // - AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you // - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio. // 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. // 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) { func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 { if len(times) == 0 {
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
return out return out
} }
// fillMonths emits EVERY month between the first and last QSO — zeros included — // 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 // 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. // data". A quiet decade must read as a decade at zero, not vanish.
+105 -1
View File
@@ -16,6 +16,7 @@ import (
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"go.bug.st/serial" "go.bug.st/serial"
@@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error {
stop, done := m.stopRead, m.doneRead stop, done := m.stopRead, m.doneRead
m.mu.Unlock() 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) go m.readLoop(p, stop, done)
if err := m.applyConfig(cfg); err != nil { if err := m.applyConfig(cfg); err != nil {
@@ -398,10 +399,112 @@ func (m *Manager) write(b []byte) error {
if p == nil { if p == nil {
return fmt.Errorf("winkeyer: not connected") return fmt.Errorf("winkeyer: not connected")
} }
traceTX(b)
_, err := p.Write(b) _, err := p.Write(b)
return err 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. // Disconnect sends Host Close and releases the port.
func (m *Manager) Disconnect() { func (m *Manager) Disconnect() {
m.mu.Lock() m.mu.Lock()
@@ -473,6 +576,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
} }
return return
} }
traceRX(buf[:n])
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
b := buf[i] b := buf[i]
switch { switch {
+6
View File
@@ -112,6 +112,12 @@ func main() {
MinWidth: 1100, MinWidth: 1100,
MinHeight: 700, MinHeight: 700,
WindowStartState: startState, 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 // Start hidden and reveal only once the saved position has been applied and
// the DOM has painted (OnDomReady → domReady) — so the window appears // the DOM has painted (OnDomReady → domReady) — so the window appears
// already at its final size and position, with no post-launch jump. // already at its final size and position, with no post-launch jump.