Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 |
@@ -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 {
|
||||
@@ -5544,9 +5569,26 @@ var bulkFieldColumns = map[string]string{
|
||||
"qsl_sent": "qsl_sent",
|
||||
"qsl_rcvd": "qsl_rcvd",
|
||||
"qsl_via": "qsl_via",
|
||||
"qrz_sent": "qrzcom_qso_upload_status",
|
||||
"qrz_rcvd": "qrzcom_qso_download_status",
|
||||
"clublog_sent": "clublog_qso_upload_status",
|
||||
"hrdlog_sent": "hrdlog_qso_upload_status",
|
||||
// Old ids kept so anything holding one keeps working.
|
||||
"qrz_upload": "qrzcom_qso_upload_status",
|
||||
"clublog_upload": "clublog_qso_upload_status",
|
||||
"hrdlog_upload": "hrdlog_qso_upload_status",
|
||||
// Confirmation DATES. A status without its date is half the record: an
|
||||
// operator correcting a batch after an import needs both.
|
||||
"qsl_sent_date": "qsl_sent_date",
|
||||
"qsl_rcvd_date": "qsl_rcvd_date",
|
||||
"lotw_sent_date": "lotw_sent_date",
|
||||
"lotw_rcvd_date": "lotw_rcvd_date",
|
||||
"eqsl_sent_date": "eqsl_sent_date",
|
||||
"eqsl_rcvd_date": "eqsl_rcvd_date",
|
||||
"qrz_sent_date": "qrzcom_qso_upload_date",
|
||||
"qrz_rcvd_date": "qrzcom_qso_download_date",
|
||||
"clublog_sent_date": "clublog_qso_upload_date",
|
||||
"hrdlog_sent_date": "hrdlog_qso_upload_date",
|
||||
// My station / operator
|
||||
"station_callsign": "station_callsign",
|
||||
"operator": "operator",
|
||||
@@ -6541,7 +6583,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.reloadCAT()
|
||||
a.restartAsync("cat", a.reloadCAT)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11764,12 +11806,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 +12169,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 +12902,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.startUltrabeam()
|
||||
a.restartAsync("antenna", a.startUltrabeam)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13408,7 +13475,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.startAmps()
|
||||
a.restartAsync("amp", a.startAmps)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13544,7 +13611,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 +14056,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 {
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
[
|
||||
{
|
||||
"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.",
|
||||
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar."
|
||||
],
|
||||
"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.",
|
||||
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.21.5",
|
||||
"date": "2026-07-27",
|
||||
|
||||
+74
-5
@@ -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 (
|
||||
<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() {
|
||||
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 = (
|
||||
<div className="flex flex-col w-44" data-esm="call">
|
||||
<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 && (
|
||||
<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" />…
|
||||
@@ -3510,8 +3563,14 @@ export default function App() {
|
||||
)}
|
||||
<Input
|
||||
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',
|
||||
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}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -4235,7 +4294,7 @@ export default function App() {
|
||||
{compact ? (
|
||||
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
|
||||
// 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="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
|
||||
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
||||
@@ -4256,7 +4315,15 @@ export default function App() {
|
||||
</Button>
|
||||
</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="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">
|
||||
@@ -4624,6 +4691,8 @@ export default function App() {
|
||||
>
|
||||
<Minimize2 className="size-3.5" />
|
||||
</Button>
|
||||
{/* Window controls — the OS title bar is gone, so they live here. */}
|
||||
<WindowControls />
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'70cm': [430000, 440000],
|
||||
};
|
||||
|
||||
// Sub-band shading: CW / digital / phone.
|
||||
//
|
||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||||
// categorical hues — never the status tokens. They used to use success / info /
|
||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||||
// a giant "new band" wash. Status colours are reserved for status.
|
||||
//
|
||||
// All three are drawn from the COOL end of the categorical order and laid down at
|
||||
// low opacity, so the band plan stays background context while the warm spot pills
|
||||
// keep the foreground to themselves.
|
||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||
// are read on top of it.
|
||||
const SEG_OPACITY = 0.13;
|
||||
|
||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||
'60m': [[5350, 5450, SEG_PHONE]],
|
||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
height={totalH}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{segments.map(([s, e, cls], i) => {
|
||||
{segments.map(([s, e, colour], i) => {
|
||||
const y1 = freqToY(Math.min(e, hi));
|
||||
const y2 = freqToY(Math.max(s, lo));
|
||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
||||
return (
|
||||
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
|
||||
fill={colour} fillOpacity={SEG_OPACITY} />
|
||||
);
|
||||
})}
|
||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||
</svg>
|
||||
@@ -559,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||
<span className="mx-0.5 opacity-40">|</span>
|
||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
{t('bmp.footerHint')}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldKind = 'status' | 'text' | 'freq';
|
||||
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||
|
||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||
@@ -21,16 +21,31 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const FIELDS: FieldDef[] = [
|
||||
// QSL / upload status
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
// One channel at a time, each with its status and its date, in the same order
|
||||
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||
// status" or "… date", which is also what the ADIF field is called.
|
||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
@@ -117,7 +132,7 @@ type Props = {
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [field, setField] = useState('qsl_sent');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -181,6 +196,29 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : def.kind === 'date' ? (
|
||||
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
|
||||
// writes an empty value — which is how a wrong date is removed
|
||||
// from a batch, the same meaning as leaving a text field blank.
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-xs font-mono"
|
||||
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
|
||||
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
|
||||
title={t('bulk.todayUtc')}
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
// UTC: the whole log is UTC, and near midnight the local day
|
||||
// is the wrong one.
|
||||
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
|
||||
}}
|
||||
>{t('bulk.today')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MenuItem =
|
||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
||||
// accent highlights an item that is an OFFER rather than a command (Donate).
|
||||
// Generic on purpose: a hard-coded label test in the renderer would break the
|
||||
// moment the menu is translated.
|
||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
|
||||
| { type: 'separator' };
|
||||
|
||||
export interface Menu {
|
||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||
onSelect={() => onAction(item.action)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
||||
);
|
||||
}
|
||||
|
||||
// AdifDateInput — a real date picker over an ADIF date.
|
||||
//
|
||||
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||||
// are converted at the edge and the log keeps its ADIF form. The native control
|
||||
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||||
// and keyboard entry for free, which a hand-rolled popover would have to
|
||||
// reimplement and get wrong.
|
||||
//
|
||||
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||||
// a plain text box instead, so it stays visible and correctable rather than
|
||||
// silently disappearing behind an empty picker.
|
||||
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||||
const raw = (value ?? '').trim();
|
||||
const valid = /^\d{8}$/.test(raw);
|
||||
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||||
const today = () => {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||||
// wrong one.
|
||||
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||||
};
|
||||
if (raw !== '' && !valid) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={iso}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value; // "" when cleared
|
||||
onChange(v ? v.replace(/-/g, '') : '');
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||||
onClick={today} title="Today (UTC)">
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -580,8 +630,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
SetWinkeyerTrace,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -1093,6 +1094,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
||||
});
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||||
const [wkTrace, setWkTrace] = useState(false);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
@@ -3363,6 +3366,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Protocol trace — for reporting a keyer that misbehaves. Session
|
||||
only, deliberately not persisted: it is a diagnostic, and a log
|
||||
full of hex bytes helps nobody who forgot it was on. */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
|
||||
{t('wk.trace')}
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ type Stats = {
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||
// Chronological series across the SELECTED period, one per bucket size; empty
|
||||
// when that bucket would be too fine for the period (see the Go side).
|
||||
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
|
||||
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
|
||||
// Period / contest metrics.
|
||||
window_start: string; window_end: string; window_hours: number;
|
||||
avg_per_hour: number; avg_per_active: number;
|
||||
@@ -509,19 +513,44 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
|
||||
// panel — not a window of its own. It used to be four fixed rolling windows
|
||||
// anchored on the newest QSO, which ignored the period filter entirely: picking
|
||||
// a year up top left this chart showing the last 7 days.
|
||||
const ACT_GRAN = [
|
||||
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||
{ key: 'day', tkey: 'stats.granDay' },
|
||||
{ key: 'week', tkey: 'stats.granWeek' },
|
||||
{ key: 'month', tkey: 'stats.granMonth' },
|
||||
{ key: 'year', tkey: 'stats.granYear' },
|
||||
] as const;
|
||||
type Gran = typeof ACT_GRAN[number]['key'];
|
||||
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
|
||||
|
||||
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<string>('timeline');
|
||||
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
||||
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||
const seriesFor = (k: Gran): Bucket[] => (
|
||||
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
|
||||
);
|
||||
|
||||
// Auto picks the finest bucket that stays readable: the backend leaves a
|
||||
// series empty when it would be too fine for the period, so "the first
|
||||
// non-empty one" is exactly the right rule and needs no thresholds here.
|
||||
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
|
||||
// A bucket the user forces but that is too fine for the period falls back to
|
||||
// the next coarser one rather than showing an empty chart.
|
||||
let shown: Gran = g === 'auto' ? auto : g;
|
||||
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
|
||||
const series = seriesFor(shown);
|
||||
const steppedUp = g !== 'auto' && shown !== g;
|
||||
|
||||
return (
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
||||
<button type="button" onClick={() => setG('auto')}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t('stats.granAuto')}
|
||||
</button>
|
||||
{ACT_GRAN.map((o) => (
|
||||
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
@@ -529,14 +558,39 @@ function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: an
|
||||
{t(o.tkey)}
|
||||
</button>
|
||||
))}
|
||||
{steppedUp && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||
)}
|
||||
</div>
|
||||
{g === 'timeline'
|
||||
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||
{series.length > 60
|
||||
? <AreaTrend data={series} empty={empty} />
|
||||
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// RhythmCard — WHEN the operator is on the air over the selected period, which is
|
||||
// a different question from "how much lately" and so gets its own card. The hour
|
||||
// histogram used to cover a single day, too little to read anything from.
|
||||
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<'hour' | 'weekday'>('hour');
|
||||
return (
|
||||
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
|
||||
<button key={k} type="button" onClick={() => setG(k)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t(tk)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||
|
||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||
@@ -603,6 +657,8 @@ export function StatsPanel() {
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
|
||||
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
|
||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||
} as Stats);
|
||||
@@ -818,6 +874,7 @@ export function StatsPanel() {
|
||||
</Card>
|
||||
|
||||
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||
<RhythmCard stats={stats} t={t} empty={empty} />
|
||||
|
||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
|
||||
+10
-10
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.21.5';
|
||||
export const APP_VERSION = '0.21.6';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -907,6 +907,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -539,8 +539,20 @@ var uploadStatusCols = map[string]bool{
|
||||
"lotw_sent": true,
|
||||
"eqsl_sent": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation dates (ADIF YYYYMMDD strings, so plain TEXT like the rest).
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
}
|
||||
|
||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||
|
||||
+116
-3
@@ -171,6 +171,27 @@ type Stats struct {
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.21.5"
|
||||
appVersion = "0.21.6"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user