From 9a21c936b1266c5f2494224053cb4a139f3b1ab6 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 28 Aug 2026 08:25:02 +0200 Subject: [PATCH] feat(units): show distances in miles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything is computed in kilometres and converted once at display time, in lib/units — storing miles anywhere would give the same number two sources of truth and a rounding error that grows with every hop. The grids capture the unit inside their column definitions, header and formatter both, so a change is published to them the way the date format already is; without that a toggle would only appear after a language change or a restart. The preference is portable, like the other display ones. --- changelog.json | 6 ++- frontend/src/App.tsx | 7 ++-- frontend/src/components/ChaseNewPanel.tsx | 3 +- frontend/src/components/ClusterGrid.tsx | 14 +++++-- frontend/src/components/MainMap.tsx | 5 ++- frontend/src/components/RecentQSOsGrid.tsx | 10 +++-- frontend/src/components/SettingsModal.tsx | 9 ++++ frontend/src/lib/i18n.tsx | 2 + frontend/src/lib/uiPref.ts | 1 + frontend/src/lib/units.ts | 49 ++++++++++++++++++++++ 10 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/units.ts diff --git a/changelog.json b/changelog.json index 6b4cb0d..5637b35 100644 --- a/changelog.json +++ b/changelog.json @@ -11,7 +11,8 @@ "Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.", "QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.", "LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profile’s own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered.", - "LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty." + "LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty.", + "Distances can be shown in miles (Settings → General): the cluster and Recent QSOs columns, the map path box, the rotator buttons and the band-opening list all follow, and the column headers name the unit." ], "fr": [ "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", @@ -22,7 +23,8 @@ "Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.", "Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.", "LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu.", - "Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt." + "Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt.", + "Les distances peuvent s'afficher en miles (Réglages → Général) : les colonnes du cluster et des QSO récents, l'encart du tracé sur la carte, les boutons du rotor et la liste des ouvertures suivent, et l'unité est indiquée dans les en-têtes de colonne." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f435bd..c08eeb9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,6 +57,7 @@ import { import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App'; +import { formatDistance } from '@/lib/units'; 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'; @@ -6419,7 +6420,7 @@ export default function App() { disabled={disabled} onClick={() => p && goto(p.bearingShort, 'SP')} title={p - ? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km` + ? `Rotate short-path · ${formatDistance(p.distanceShort)}` : (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')} className={cn( 'inline-flex items-center gap-1 px-2 py-0.5 transition-colors', @@ -6435,7 +6436,7 @@ export default function App() { type="button" disabled={disabled} onClick={() => p && goto(p.bearingLong, 'LP')} - title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''} + title={p ? `Rotate long-path · ${formatDistance(p.distanceLong)}` : ''} className={cn( 'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors', disabled @@ -8570,7 +8571,7 @@ export default function App() { "1.5k" and then appended the unit, giving "1.5kkm" — and even written correctly, "1.5k km" makes a reader do arithmetic to recover a number that was four characters long to begin with. */} - {o.median_km} km + {formatDistance(o.median_km)} {/* Out of season is the one an operator must not learn last, so it earns a mark on the badge rather than a line in the tooltip. */} {!o.in_season && !} diff --git a/frontend/src/components/ChaseNewPanel.tsx b/frontend/src/components/ChaseNewPanel.tsx index 9ade90e..5b16f63 100644 --- a/frontend/src/components/ChaseNewPanel.tsx +++ b/frontend/src/components/ChaseNewPanel.tsx @@ -10,6 +10,7 @@ // new is being decoded on FT8/FT4/JS8 near here — not that the band is dead. import { useEffect, useMemo, useState } from 'react'; import { Radar, Loader2, X } from 'lucide-react'; +import { formatDistance } from '@/lib/units'; import { useI18n } from '@/lib/i18n'; import { markerColour } from '@/lib/spotMarkers'; import { cn } from '@/lib/utils'; @@ -186,7 +187,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) { title={[ s.country, s.grid, - s.dist_km ? `${s.dist_km} km` : '', + s.dist_km ? formatDistance(s.dist_km) : '', s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '', ].filter(Boolean).join(' · ')} > diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index 10a143c..7a63456 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -15,6 +15,7 @@ import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; import { markerColour } from '@/lib/spotMarkers'; import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay'; import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs'; +import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units'; import { useI18n } from '@/lib/i18n'; type TFn = (key: string, vars?: Record) => string; @@ -443,8 +444,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ }, { group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km', - headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono', - valueFormatter: (p) => p.value ? String(p.value) : '', + // The header carries the unit, so the cells stay bare numbers and the + // column still sorts on the km the backend sent — converting the VALUE + // would sort miles as if they were kilometres either way, but it would + // also round twice. + headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')', + field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono', + valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '', comparator: (a, b) => (a ?? 0) - (b ?? 0), }, { @@ -523,7 +529,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro const [pickerOpen, setPickerOpen] = useState(false); // Localized column catalog — rebuilt when the language changes. - const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); + const [distUnit, setDistUnit] = useState(distanceUnit); + useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []); + const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]); // A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the // matching column events. Without this guard those events were persisted, so a diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index 3f0e929..4a72b75 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css'; import { nightPolygon } from '../lib/greyline'; import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead'; import { writeUiPref } from '@/lib/uiPref'; +import { formatDistance } from '@/lib/units'; // Persisted free-pan view of the world map (when auto-zoom is off). function loadMapView(): { lat: number; lon: number; zoom: number } | null { @@ -446,8 +447,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b {path && (
-
Dist {Math.round(path.distanceShort).toLocaleString()} km - · LP {Math.round(path.distanceLong).toLocaleString()} km
+
Dist {formatDistance(path.distanceShort)} + · LP {formatDistance(path.distanceLong)}
Az SP {Math.round(path.bearingShort)}° · LP {Math.round(path.bearingLong)}°
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 63ae695..64f0543 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } import { AgGridReact } from 'ag-grid-react'; import { Columns3, FilterX, ListChecks } from 'lucide-react'; import type { QSOForm } from '@/types'; +import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units'; import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, @@ -177,8 +178,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [ { group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, // Derived, not stored: computed from the two locations at display time, like // the cluster grid's own distance column. - { group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono', - valueGetter: (p) => qsoDistanceKm(p.data, myGrid), + { group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', + headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono', + valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; }, comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true }, { group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 }, { group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 }, @@ -335,7 +337,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, // 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]); + const [distUnit, setDistUnit] = useState(distanceUnit); + useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []); + const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]); // 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 75c8036..e5600fc 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -80,6 +80,7 @@ import { } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { writeUiPref } from '@/lib/uiPref'; +import { setUseMiles } from '@/lib/units'; 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'; @@ -1747,6 +1748,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1'); + const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1'); const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1'); // Declared HERE and not in ClusterPanel: that renderer is called as a plain // function by the PANELS map, so it must stay hooks-free. @@ -7221,6 +7223,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} /> {t('gen.groupDigital')} {t('gen.groupDigitalHint')} +