feat: choose how dates are displayed (Standard / French / US)

Settings → General, beside the language. An operator reading 2026-07-30 as the
7th of the 30th month is reading their own log wrongly, and that is a display
problem.

Storage stays ISO/ADIF, deliberately and permanently: it is what ADIF
specifies, it sorts correctly as text, and a stored format that followed a UI
preference would make the log unreadable the day the preference changed. Exports
and uploads are untouched.

Two details that decide whether it actually works:

  - The columns are rebuilt when the format changes. The formatters are captured
    inside AG-Grid's column definitions, so nothing else would notice and the
    table would keep the old format until something forced a rebuild.
  - main.tsx re-reads the choice after the portable prefs are pulled from the
    database. The module reads localStorage at import time, which is BEFORE that
    sync — so a copied data/ folder would have shown ISO until the next launch.

UTC is not part of the choice and never will be: a logbook is kept in UTC, and
showing local time would make the display disagree with the QSO's own record
twice a year.
This commit is contained in:
2026-07-30 22:22:33 +02:00
parent 6ee4c430c7
commit 7a7fa62af7
9 changed files with 142 additions and 25 deletions
+2 -5
View File
@@ -100,6 +100,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
@@ -169,11 +170,7 @@ function parseAwardRefs(s: any): Record<string, string> {
}
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
return formatDateTimeUTC(s);
}
function fmtFreq(hz?: number): string {
if (!hz) return '';
+10 -15
View File
@@ -5,6 +5,7 @@ import {
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { qslStatusCellClass } from '@/lib/qslStatus';
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
@@ -86,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
}
// Both formatters follow the display preference (Settings → General). What is
// STORED never changes — see lib/dateFormat.
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
return formatDateTimeUTC(s);
}
function fmtDateOnly(s: any): string {
if (!s) return '';
const t = String(s).trim();
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(t);
if (isNaN(d.getTime())) return t;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
return formatDateOnly(s);
}
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
@@ -304,7 +295,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
// Rebuild the columns when the format changes: the formatters are captured
// inside the column definitions, so nothing else would notice.
const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
+23
View File
@@ -69,6 +69,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
@@ -1143,6 +1144,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// a trace already running came back unticked; the operator ticks the box to
// turn it on, which turns it off, and the log they send has no trace in it.
// Reported by a Xiegu user. Hydrated from the backend below.
const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
const [wkTrace, setWkTrace] = useState(false);
const [civTrace, setCivTrace] = useState(false);
useEffect(() => {
@@ -5222,6 +5224,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
{/* Display only. The log is stored in ISO/ADIF whatever is chosen here
that is what ADIF specifies and what sorts correctly, and a
stored format that followed a preference would make the log
unreadable the day the preference changed. */}
<div className="flex items-center gap-3">
<Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
['fr', t('gen.dateFR'), '30-07-2026 14:25'],
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
<button key={code} type="button" title={sample}
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{label}
<span className="text-[10px] font-mono opacity-70">{sample}</span>
</button>
))}
</div>
</div>
<ThemeSelector />
<label className="flex items-center gap-2 text-sm cursor-pointer">
+4 -1
View File
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
+91
View File
@@ -0,0 +1,91 @@
// How dates are DISPLAYED. Nothing here touches what is stored.
//
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
// upload. This is the reading layer only, because an operator reading
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
// and that is a display problem, not a data one.
//
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
// it is what ADIF specifies, and a log whose stored format followed a UI
// preference would become unreadable the day the preference changed.
import { writeUiPref } from '@/lib/uiPref';
export type DateFormat = 'iso' | 'fr' | 'us';
const KEY = 'opslog.dateFormat';
function read(): DateFormat {
const v = localStorage.getItem(KEY);
return v === 'fr' || v === 'us' ? v : 'iso';
}
let current: DateFormat = read();
// A minimal store so every grid and panel re-renders the moment the format
// changes, rather than showing the old one until the next restart.
const listeners = new Set<() => void>();
export function getDateFormat(): DateFormat {
return current;
}
export function setDateFormat(f: DateFormat): void {
current = f;
writeUiPref(KEY, f);
listeners.forEach((l) => l());
}
export function subscribeDateFormat(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// reloadDateFormat re-reads the stored value — for after the portable prefs
// have been synced from the database at startup, which happens AFTER this
// module's first read.
export function reloadDateFormat(): void {
const v = read();
if (v !== current) {
current = v;
listeners.forEach((l) => l());
}
}
const pad = (n: number) => String(n).padStart(2, '0');
// order arranges the three parts. Separators stay '-' in every format: a slash
// in the French one would be conventional but it also invites the reader to
// take it for a US date, which is exactly the confusion being fixed.
function order(y: number, m: number, d: number): string {
switch (current) {
case 'fr':
return `${pad(d)}-${pad(m)}-${y}`;
case 'us':
return `${pad(m)}-${pad(d)}-${y}`;
}
return `${y}-${pad(m)}-${pad(d)}`;
}
// formatDateTimeUTC renders a timestamp with the time, in UTC.
//
// UTC is not negotiable and is not part of the preference: a logbook is kept in
// UTC, and a date shown in local time would disagree with the QSO's own record
// twice a year.
export function formatDateTimeUTC(s: unknown): string {
if (!s) return '';
const d = new Date(String(s));
if (isNaN(d.getTime())) return String(s);
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
// formatDateOnly renders a date with no time. It accepts both the ADIF form
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
export function formatDateOnly(s: unknown): string {
if (!s) return '';
const t = String(s).trim();
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
const d = new Date(t);
if (isNaN(d.getTime())) return t;
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
+2 -2
View File
@@ -63,7 +63,7 @@ const en: Dict = {
// Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
@@ -475,7 +475,7 @@ const fr: Dict = {
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
+1
View File
@@ -28,6 +28,7 @@ const PORTABLE_KEYS = [
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
// Cluster filter selections — restored on reopen.
+5
View File
@@ -6,6 +6,7 @@ import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n'
import { ThemeProvider, initTheme } from './lib/theme'
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
import { reloadDateFormat } from './lib/dateFormat'
const container = document.getElementById('root')
@@ -18,6 +19,10 @@ syncPortablePrefs().finally(() => {
// Stamp the persisted theme onto <html> before render so the correct
// palette is applied immediately (portable pref hydrated just above).
initTheme()
// The date format module read localStorage at import time, which is BEFORE
// the portable prefs were pulled from the database. Re-read it now, or a
// copied data/ folder would show ISO until the next launch.
reloadDateFormat()
// Catch what a boundary cannot: errors thrown from timers, event handlers and
// rejected promises. Installed before the first render so a fault during
// startup is reported too.