diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 197a169..2b43110 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { } from './hooks'; import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; +import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; import { messageContainsMention } from './utils/messageParser'; import { getStateKey } from './utils/conversationState'; import type { Conversation, Message, RawPacket } from './types'; @@ -91,10 +92,12 @@ export function App() { showCracker, crackerRunning, localLabel, + distanceUnit, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, + setDistanceUnit, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, @@ -564,29 +567,31 @@ export function App() { setContactsLoaded, ]); return ( - + + + ); } diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index db327f5..f9abe0b 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -24,6 +24,7 @@ import { handleKeyboardActivate } from '../utils/a11y'; import { ContactAvatar } from './ContactAvatar'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { toast } from './ui/sonner'; +import { useDistanceUnit } from '../contexts/DistanceUnitContext'; import type { Contact, ContactActiveRoom, @@ -82,6 +83,7 @@ export function ContactInfoPane({ onToggleBlockedKey, onToggleBlockedName, }: ContactInfoPaneProps) { + const { distanceUnit } = useDistanceUnit(); const isNameOnly = contactKey?.startsWith('name:') ?? false; const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null; @@ -315,7 +317,7 @@ export function ContactInfoPane({ )} {distFromUs !== null && ( - + )} {effectiveRoute && ( {contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)} - {distFromUs !== null && ` (${formatDistance(distFromUs)})`} + {distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`} ); } diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index 727e734..e2e5f68 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -14,6 +14,8 @@ import { } from '../utils/pathUtils'; import { formatTime } from '../utils/messageParser'; import { getMapFocusHash } from '../utils/urlHash'; +import { useDistanceUnit } from '../contexts/DistanceUnitContext'; +import type { DistanceUnit } from '../utils/distanceUnits'; const PathRouteMap = lazy(() => import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap })) @@ -44,6 +46,7 @@ export function PathModal({ isResendable, onResend, }: PathModalProps) { + const { distanceUnit } = useDistanceUnit(); const [expandedMaps, setExpandedMaps] = useState>(new Set()); const hasResendActions = isOutgoingChan && messageId !== undefined && onResend; const hasPaths = paths.length > 0; @@ -120,7 +123,8 @@ export function PathModal({ resolvedPaths[0].resolved.sender.lon, resolvedPaths[0].resolved.receiver.lat, resolvedPaths[0].resolved.receiver.lon - )! + )!, + distanceUnit )} @@ -171,7 +175,11 @@ export function PathModal({ )} - + ); })} @@ -227,9 +235,10 @@ export function PathModal({ interface PathVisualizationProps { resolved: ResolvedPath; senderInfo: SenderInfo; + distanceUnit: DistanceUnit; } -function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) { +function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) { // Track previous location for each hop to calculate distances // Returns null if previous hop was ambiguous or has invalid location const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => { @@ -264,6 +273,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) { name={resolved.sender.name} prefix={resolved.sender.prefix} distance={null} + distanceUnit={distanceUnit} isFirst lat={resolved.sender.lat} lon={resolved.sender.lon} @@ -277,6 +287,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) { hop={hop} hopNumber={index + 1} prevLocation={getPrevLocation(index)} + distanceUnit={distanceUnit} /> ))} @@ -286,6 +297,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) { name={resolved.receiver.name} prefix={resolved.receiver.prefix} distance={calculateReceiverDistance(resolved)} + distanceUnit={distanceUnit} isLast lat={resolved.receiver.lat} lon={resolved.receiver.lon} @@ -300,7 +312,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) { {resolved.hasGaps ? '>' : ''} - {formatDistance(resolved.totalDistances[0])} + {formatDistance(resolved.totalDistances[0], distanceUnit)} )} @@ -313,6 +325,7 @@ interface PathNodeProps { name: string; prefix: string; distance: number | null; + distanceUnit: DistanceUnit; isFirst?: boolean; isLast?: boolean; /** Optional coordinates for map link */ @@ -327,6 +340,7 @@ function PathNode({ name, prefix, distance, + distanceUnit, isFirst, isLast, lat, @@ -353,7 +367,9 @@ function PathNode({ {name} {distance !== null && ( - - {formatDistance(distance)} + + - {formatDistance(distance, distanceUnit)} + )} {hasLocation && } @@ -366,9 +382,10 @@ interface HopNodeProps { hop: PathHop; hopNumber: number; prevLocation: { lat: number | null; lon: number | null } | null; + distanceUnit: DistanceUnit; } -function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) { +function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) { const isAmbiguous = hop.matches.length > 1; const isUnknown = hop.matches.length === 0; @@ -417,7 +434,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) { {contact.name || contact.public_key.slice(0, 12)} {dist !== null && ( - - {formatDistance(dist)} + - {formatDistance(dist, distanceUnit)} )} {hasLocation && ( @@ -436,7 +453,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) { {hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)} {hop.distanceFromPrev !== null && ( - - {formatDistance(hop.distanceFromPrev)} + - {formatDistance(hop.distanceFromPrev, distanceUnit)} )} {isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && ( diff --git a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx index 0c2d518..d6ae6ba 100644 --- a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx +++ b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx @@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react'; import { cn } from '@/lib/utils'; import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared'; import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils'; +import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; import type { Contact, RepeaterNeighborsResponse, @@ -35,6 +36,7 @@ export function NeighborsPane({ nodeInfoState: PaneState; repeaterName: string | null; }) { + const { distanceUnit } = useDistanceUnit(); const advertLat = repeaterContact?.lat ?? null; const advertLon = repeaterContact?.lon ?? null; @@ -93,7 +95,7 @@ export function NeighborsPane({ if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) { const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon); if (distKm != null) { - dist = formatDistance(distKm); + dist = formatDistance(distKm, distanceUnit); anyDist = true; } } @@ -111,7 +113,7 @@ export function NeighborsPane({ sorted: enriched, hasDistances: anyDist, }; - }, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]); + }, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]); return ( void; className?: string; }) { + const { distanceUnit, setDistanceUnit } = useDistanceUnit(); const [reopenLastConversation, setReopenLastConversation] = useState( getReopenLastConversationEnabled ); @@ -82,6 +89,31 @@ export function SettingsLocalSection({ + + Distance Units + { + const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number]; + setSavedDistanceUnit(nextUnit); + setDistanceUnit(nextUnit); + }} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + > + {DISTANCE_UNITS.map((unit) => ( + + {DISTANCE_UNIT_LABELS[unit]} + + ))} + + + Controls how distances are shown throughout the app. + + + + + void; +} + +const noop = () => {}; + +const DistanceUnitContext = createContext({ + distanceUnit: 'imperial', + setDistanceUnit: noop, +}); + +export function DistanceUnitProvider({ + distanceUnit, + setDistanceUnit, + children, +}: DistanceUnitContextValue & { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function useDistanceUnit() { + return useContext(DistanceUnitContext); +} diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index 0f7b4d8..5ec871e 100644 --- a/frontend/src/hooks/useAppShell.ts +++ b/frontend/src/hooks/useAppShell.ts @@ -1,6 +1,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react'; import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; +import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits'; import type { SettingsSection } from '../components/settings/settingsConstants'; import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash'; @@ -12,10 +13,12 @@ interface UseAppShellResult { showCracker: boolean; crackerRunning: boolean; localLabel: LocalLabel; + distanceUnit: DistanceUnit; setSettingsSection: (section: SettingsSection) => void; setSidebarOpen: (open: boolean) => void; setCrackerRunning: (running: boolean) => void; setLocalLabel: (label: LocalLabel) => void; + setDistanceUnit: (unit: DistanceUnit) => void; handleCloseSettingsView: () => void; handleToggleSettingsView: () => void; handleOpenNewMessage: () => void; @@ -34,6 +37,7 @@ export function useAppShell(): UseAppShellResult { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); + const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit); const previousHashRef = useRef(''); useEffect(() => { @@ -87,10 +91,12 @@ export function useAppShell(): UseAppShellResult { showCracker, crackerRunning, localLabel, + distanceUnit, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, + setDistanceUnit, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, diff --git a/frontend/src/test/distanceUnits.test.ts b/frontend/src/test/distanceUnits.test.ts new file mode 100644 index 0000000..9aeffb8 --- /dev/null +++ b/frontend/src/test/distanceUnits.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + DISTANCE_UNIT_KEY, + getSavedDistanceUnit, + setSavedDistanceUnit, +} from '../utils/distanceUnits'; + +describe('distanceUnits utilities', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults to imperial when unset', () => { + expect(getSavedDistanceUnit()).toBe('imperial'); + }); + + it('returns the stored unit when valid', () => { + localStorage.setItem(DISTANCE_UNIT_KEY, 'metric'); + expect(getSavedDistanceUnit()).toBe('metric'); + }); + + it('falls back to imperial for invalid stored values', () => { + localStorage.setItem(DISTANCE_UNIT_KEY, 'parsecs'); + expect(getSavedDistanceUnit()).toBe('imperial'); + }); + + it('stores the selected distance unit', () => { + setSavedDistanceUnit('smoots'); + expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots'); + }); +}); diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts index 9113692..32a32ea 100644 --- a/frontend/src/test/pathUtils.test.ts +++ b/frontend/src/test/pathUtils.test.ts @@ -685,22 +685,39 @@ describe('isValidLocation', () => { }); describe('formatDistance', () => { - it('formats distances under 1km in meters', () => { - expect(formatDistance(0.5)).toBe('500m'); - expect(formatDistance(0.123)).toBe('123m'); - expect(formatDistance(0.9999)).toBe('1000m'); + const formatInteger = (value: number) => value.toLocaleString(); + const formatOneDecimal = (value: number) => + value.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + + it('defaults to imperial formatting', () => { + expect(formatDistance(0.01)).toBe(`${formatInteger(33)}ft`); + expect(formatDistance(0.5)).toBe(`${formatOneDecimal(0.5 * 0.621371)}mi`); + expect(formatDistance(1)).toBe(`${formatOneDecimal(0.621371)}mi`); }); - it('formats distances at or above 1km with one decimal', () => { - expect(formatDistance(1)).toBe('1.0km'); - expect(formatDistance(1.5)).toBe('1.5km'); - expect(formatDistance(12.34)).toBe('12.3km'); - expect(formatDistance(100)).toBe('100.0km'); + it('formats metric distances in meters and kilometers', () => { + expect(formatDistance(0.5, 'metric')).toBe(`${formatInteger(500)}m`); + expect(formatDistance(0.123, 'metric')).toBe(`${formatInteger(123)}m`); + expect(formatDistance(0.9999, 'metric')).toBe(`${formatInteger(1000)}m`); + expect(formatDistance(1, 'metric')).toBe(`${formatOneDecimal(1)}km`); + expect(formatDistance(12.34, 'metric')).toBe(`${formatOneDecimal(12.34)}km`); }); - it('rounds meters to nearest integer', () => { - expect(formatDistance(0.4567)).toBe('457m'); - expect(formatDistance(0.001)).toBe('1m'); + it('formats smoot distances using 1.7018 meters per smoot', () => { + expect(formatDistance(0.0017018, 'smoots')).toBe(`${formatOneDecimal(1)} smoot`); + expect(formatDistance(0.001, 'smoots')).toBe(`${formatOneDecimal(0.6)} smoots`); + expect(formatDistance(1, 'smoots')).toBe(`${formatInteger(588)} smoots`); + }); + + it('applies locale separators to large values', () => { + expect(formatDistance(1.234, 'metric')).toBe(`${formatOneDecimal(1.234)}km`); + expect(formatDistance(1234, 'metric')).toBe(`${formatOneDecimal(1234)}km`); + expect(formatDistance(2.1, 'smoots')).toContain( + formatInteger(Math.round((2.1 * 1000) / 1.7018)) + ); }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 30f2b21..9bada6a 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -19,6 +19,7 @@ import { REOPEN_LAST_CONVERSATION_KEY, } from '../utils/lastViewedConversation'; import { api } from '../api'; +import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits'; const baseConfig: RadioConfig = { public_key: 'aa'.repeat(32), @@ -509,6 +510,18 @@ describe('SettingsModal', () => { expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull(); }); + it('defaults distance units to imperial and stores local changes', () => { + renderModal(); + openLocalSection(); + + const select = screen.getByLabelText('Distance Units'); + expect(select).toHaveValue('imperial'); + + fireEvent.change(select, { target: { value: 'smoots' } }); + + expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots'); + }); + it('purges decrypted raw packets via maintenance endpoint action', async () => { const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({ packets_deleted: 12, diff --git a/frontend/src/utils/distanceUnits.ts b/frontend/src/utils/distanceUnits.ts new file mode 100644 index 0000000..4d52c03 --- /dev/null +++ b/frontend/src/utils/distanceUnits.ts @@ -0,0 +1,32 @@ +export const DISTANCE_UNIT_KEY = 'remoteterm-distance-unit'; + +export const DISTANCE_UNITS = ['imperial', 'metric', 'smoots'] as const; + +export type DistanceUnit = (typeof DISTANCE_UNITS)[number]; + +export const DISTANCE_UNIT_LABELS: Record = { + imperial: 'Imperial', + metric: 'Metric', + smoots: 'Smoots', +}; + +function isDistanceUnit(value: unknown): value is DistanceUnit { + return typeof value === 'string' && DISTANCE_UNITS.includes(value as DistanceUnit); +} + +export function getSavedDistanceUnit(): DistanceUnit { + try { + const raw = localStorage.getItem(DISTANCE_UNIT_KEY); + return isDistanceUnit(raw) ? raw : 'imperial'; + } catch { + return 'imperial'; + } +} + +export function setSavedDistanceUnit(unit: DistanceUnit): void { + try { + localStorage.setItem(DISTANCE_UNIT_KEY, unit); + } catch { + // localStorage may be unavailable + } +} diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts index c98577b..547f559 100644 --- a/frontend/src/utils/pathUtils.ts +++ b/frontend/src/utils/pathUtils.ts @@ -1,4 +1,5 @@ import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types'; +import type { DistanceUnit } from './distanceUnits'; import { CONTACT_TYPE_REPEATER } from '../types'; const MAX_PATH_BYTES = 64; @@ -343,13 +344,35 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean } /** - * Format distance in human-readable form (m or km) + * Format distance in human-readable form using the selected display unit. */ -export function formatDistance(km: number): string { - if (km < 1) { - return `${Math.round(km * 1000)}m`; +export function formatDistance(km: number, unit: DistanceUnit = 'imperial'): string { + const formatInteger = (value: number) => value.toLocaleString(); + const formatOneDecimal = (value: number) => + value.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + + if (unit === 'metric') { + if (km < 1) { + return `${formatInteger(Math.round(km * 1000))}m`; + } + return `${formatOneDecimal(km)}km`; } - return `${km.toFixed(1)}km`; + + if (unit === 'smoots') { + const smoots = (km * 1000) / 1.7018; + const rounded = smoots < 10 ? Number(smoots.toFixed(1)) : Math.round(smoots); + const display = smoots < 10 ? formatOneDecimal(rounded) : formatInteger(rounded); + return `${display} ${rounded === 1 ? 'smoot' : 'smoots'}`; + } + + const miles = km * 0.621371; + if (miles < 0.1) { + return `${formatInteger(Math.round(km * 3280.839895))}ft`; + } + return `${formatOneDecimal(miles)}mi`; } /**
+ Controls how distances are shown throughout the app. +