From 7a7fa62af7e4d9e398b0ef9aa447fb8dbeb5997b Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 30 Jul 2026 22:22:33 +0200 Subject: [PATCH] feat: choose how dates are displayed (Standard / French / US) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.json | 6 +- frontend/src/App.tsx | 7 +- frontend/src/components/RecentQSOsGrid.tsx | 25 +++--- frontend/src/components/SettingsModal.tsx | 23 +++++ frontend/src/components/WorkedBeforeGrid.tsx | 5 +- frontend/src/lib/dateFormat.ts | 91 ++++++++++++++++++++ frontend/src/lib/i18n.tsx | 4 +- frontend/src/lib/uiPref.ts | 1 + frontend/src/main.tsx | 5 ++ 9 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/dateFormat.ts diff --git a/changelog.json b/changelog.json index 3640a69..edb445d 100644 --- a/changelog.json +++ b/changelog.json @@ -16,7 +16,8 @@ "A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed.", "The award reference table can be sorted by reference (the default) or by description — click either heading, click again to reverse. Both the grid and list views follow.", "Deleting a QSO can now withdraw it from QRZ.com and Club Log as well — Settings → External services, off by default. Club Log matches on callsign, time and band so it works for any QSO; QRZ.com can only remove records OpsLog uploaded itself, since its API deletes by record number only.", - "QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager." + "QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager.", + "Settings → General chooses how dates are displayed: Standard (2026-07-30), French (30-07-2026) or US (07-30-2026). Display only — the log is always stored in the ADIF standard format, and exports are unaffected." ], "fr": [ "Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.", @@ -32,7 +33,8 @@ "Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre.", "Le tableau des références d'un diplôme peut être trié par référence (par défaut) ou par description — cliquez sur l'en-tête, un second clic inverse l'ordre. Les vues grille et liste suivent toutes deux.", "Supprimer un QSO peut désormais le retirer aussi de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut. Club Log retrouve le contact par indicatif, heure et bande, donc cela vaut pour n'importe quel QSO ; QRZ.com ne peut retirer que les enregistrements envoyés par OpsLog, son API ne supprimant que par numéro d'enregistrement.", - "Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL." + "Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL.", + "Réglages → Général permet de choisir l'affichage des dates : Standard (2026-07-30), Français (30-07-2026) ou US (07-30-2026). Affichage seulement — le journal reste toujours enregistré au format standard ADIF, et les exports ne changent pas." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3756a06..680c9de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { } 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 ''; diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 143e62e..449a485 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -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. diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e003d3a..6de7688 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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(getDateFormat); const [wkTrace, setWkTrace] = useState(false); const [civTrace, setCivTrace] = useState(false); useEffect(() => { @@ -5222,6 +5224,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + {/* 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. */} +
+ +
+ {([['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]) => ( + + ))} +
+
+