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:
+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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user