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>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,7 @@ const en: Dict = {
|
||||
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…',
|
||||
'help.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…',
|
||||
'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}',
|
||||
// Duplicates modal
|
||||
@@ -73,7 +73,7 @@ const en: Dict = {
|
||||
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or the last day / 7 days / 30 days / 12 months', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year',
|
||||
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per day, week, month or year over the selected period', 'stats.granTimeline': 'Timeline', 'stats.granAuto': 'Auto', 'stats.granDay': 'Per day', 'stats.granWeek': 'Per week', 'stats.granMonth': 'Per month', 'stats.granYear': 'Per year', 'stats.granTooFine': 'too fine for this period — stepped up', 'stats.rhythm': 'When you are on the air', 'stats.rhythmSub': 'Over the selected period, in UTC', 'stats.byHourOfDay': 'By hour', 'stats.byWeekday': 'By day of week',
|
||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||
|
||||
'stats.byContinentSub': 'Share of the log',
|
||||
@@ -154,7 +154,7 @@ const en: Dict = {
|
||||
'wk.speed': 'Speed (WPM)', 'wk.farnsworth': 'Farnsworth', 'wk.leadIn': 'Lead-in (ms)', 'wk.tail': 'Tail (ms)', 'wk.keyPtt': 'Key PTT line',
|
||||
'wk.invert': 'Invert keying (active-LOW) — tick this only if the rig transmits at rest / keys backwards',
|
||||
'wk.serialPort': 'Serial port', 'wk.baud': 'Baud', 'wk.weight': 'Weight', 'wk.ratio': 'Ratio (33-66)', 'wk.sidetone': 'Sidetone (Hz)', 'wk.paddleMode': 'Paddle mode',
|
||||
'wk.swapPaddles': 'Swap paddles', 'wk.autospace': 'Auto-space', 'wk.keyPttShort': 'Key PTT', 'wk.serialEcho': 'Serial echo',
|
||||
'wk.swapPaddles': 'Swap paddles', 'wk.autospace': 'Auto-space', 'wk.keyPttShort': 'Key PTT', 'wk.serialEcho': 'Serial echo', 'wk.trace': 'Log the keyer protocol (diagnostic)', 'wk.traceHint': 'Writes every byte exchanged with the keyer to the diagnostic log. Turn it on, reproduce the problem, then send the log — the WK1/WK2/WK3 command sets differ and this shows which command your firmware read differently. Off again at the next start.',
|
||||
'wk.noPorts': 'No ports found', 'wk.reloadPorts': 'Reload ports', 'wk.comPort': '— COM port —',
|
||||
'wk.macroTitle': 'CW message macros (F1…)', 'wk.macroVars': 'Use variables:', 'wk.macroCut': '(cut numbers: 9→N, 0→T).', 'wk.macroMyCall': 'my call', 'wk.macroHisCall': 'his call', 'wk.macroLog': 'log the QSO when the macro is sent', 'wk.label': 'Label',
|
||||
'sec.relayauto': 'Relay auto-control',
|
||||
@@ -320,7 +320,7 @@ const en: Dict = {
|
||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
|
||||
@@ -438,7 +438,7 @@ const fr: Dict = {
|
||||
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'help.sendLog': 'Envoyer le journal à F4BPO', 'help.sendLogBusy': 'Envoi du journal…',
|
||||
'help.sendLogOk': 'Journal envoyé à F4BPO — merci, 73 !', 'help.sendLogFail': 'Échec de l\'envoi du journal : {err}',
|
||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||
@@ -468,7 +468,7 @@ const fr: Dict = {
|
||||
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou les derniers jour / 7 jours / 30 jours / 12 mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année',
|
||||
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par jour, semaine, mois ou année sur la période choisie', 'stats.granTimeline': 'Chronologie', 'stats.granAuto': 'Auto', 'stats.granDay': 'Par jour', 'stats.granWeek': 'Par semaine', 'stats.granMonth': 'Par mois', 'stats.granYear': 'Par année', 'stats.granTooFine': 'trop fin pour cette période — regroupé', 'stats.rhythm': 'Quand vous trafiquez', 'stats.rhythmSub': 'Sur la période choisie, en UTC', 'stats.byHourOfDay': 'Par heure', 'stats.byWeekday': 'Par jour de semaine',
|
||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||
|
||||
'stats.byContinentSub': 'Part du journal',
|
||||
@@ -548,7 +548,7 @@ const fr: Dict = {
|
||||
'wk.speed': 'Vitesse (WPM)', 'wk.farnsworth': 'Farnsworth', 'wk.leadIn': 'Lead-in (ms)', 'wk.tail': 'Tail (ms)', 'wk.keyPtt': 'Manipuler la ligne PTT',
|
||||
'wk.invert': "Inverser le keying (actif-BAS) — à cocher seulement si la radio émet au repos / manipule à l'envers",
|
||||
'wk.serialPort': 'Port série', 'wk.baud': 'Baud', 'wk.weight': 'Poids', 'wk.ratio': 'Ratio (33-66)', 'wk.sidetone': 'Sidetone (Hz)', 'wk.paddleMode': 'Mode paddle',
|
||||
'wk.swapPaddles': 'Inverser les paddles', 'wk.autospace': 'Auto-espacement', 'wk.keyPttShort': 'Manipuler PTT', 'wk.serialEcho': 'Écho série',
|
||||
'wk.swapPaddles': 'Inverser les paddles', 'wk.autospace': 'Auto-espacement', 'wk.keyPttShort': 'Manipuler PTT', 'wk.serialEcho': 'Écho série', 'wk.trace': 'Journaliser le protocole du keyer (diagnostic)', 'wk.traceHint': "Écrit dans le journal chaque octet échangé avec le keyer. Activez, reproduisez le problème, puis envoyez le journal — les jeux de commandes WK1/WK2/WK3 diffèrent et c'est ce qui montre quelle commande votre microprogramme a lue autrement. Désactivé au prochain démarrage.",
|
||||
'wk.noPorts': 'Aucun port trouvé', 'wk.reloadPorts': 'Recharger les ports', 'wk.comPort': '— port COM —',
|
||||
'wk.macroTitle': 'Macros de messages CW (F1…)', 'wk.macroVars': 'Variables :', 'wk.macroCut': '(chiffres abrégés : 9→N, 0→T).', 'wk.macroMyCall': 'mon indicatif', 'wk.macroHisCall': 'son indicatif', 'wk.macroLog': 'logue le QSO quand la macro est envoyée', 'wk.label': 'Libellé',
|
||||
'sec.relayauto': 'Relais automatiques',
|
||||
@@ -699,7 +699,7 @@ const fr: Dict = {
|
||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
Reference in New Issue
Block a user