diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index d984991b..7ea45f1a 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -7,7 +7,7 @@ import Section from '../Settings/Section' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' -import type { Place } from '../../types' +import type { DistanceUnit, Place } from '../../types' const MAP_PRESETS = [ { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, @@ -19,6 +19,7 @@ const MAP_PRESETS = [ type Defaults = { temperature_unit?: string + distance_unit?: DistanceUnit dark_mode?: string | boolean time_format?: string default_currency?: string @@ -212,6 +213,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement { ))} + {/* Distance */} + Distance }> + {([ + { value: 'metric', label: 'km Metric' }, + { value: 'imperial', label: 'mi Imperial' }, + ] as const).map(opt => ( + save({ distance_unit: opt.value })} + > + {opt.label} + + ))} + + {/* Time Format */} {t('settings.timeFormat')} }> {([ diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index af86ac01..1c104f30 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -1,4 +1,6 @@ -import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types' +import { useSettingsStore } from '../../store/settingsStore' +import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types' +import { formatDistance } from '../../utils/units' const OSRM_BASE = 'https://router.project-osrm.org/route/v1' @@ -60,7 +62,7 @@ export async function calculateRoute( coordinates, distance, duration, - distanceText: formatDistance(distance), + distanceText: formatRouteDistance(distance), durationText: formatDuration(duration), walkingText: formatDuration(walkingDuration), drivingText: formatDuration(drivingDuration), @@ -218,7 +220,7 @@ export async function calculateSegments( duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), - distanceText: formatDistance(leg.distance), + distanceText: formatRouteDistance(leg.distance), } }) } @@ -265,7 +267,7 @@ export async function calculateRouteWithLegs( duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), - distanceText: formatDistance(leg.distance), + distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), } } @@ -280,11 +282,16 @@ export async function calculateRouteWithLegs( return result } -function formatDistance(meters: number): string { - if (meters < 1000) { +function getDistanceUnit(): DistanceUnit { + return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric' +} + +function formatRouteDistance(meters: number): string { + const unit = getDistanceUnit() + if (unit === 'metric' && meters < 1000) { return `${Math.round(meters)} m` } - return `${(meters / 1000).toFixed(1)} km` + return formatDistance(meters / 1000, unit) } function formatDuration(seconds: number): string { diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index a70fd9d5..1db078e5 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -402,9 +402,9 @@ describe('DayDetailPanel', () => { await screen.findByText('20:15'); }); - it('FE-PLANNER-DAYDETAIL-027: weather chips show Fahrenheit wind speed', async () => { + it('FE-PLANNER-DAYDETAIL-027: weather chips show imperial wind speed', async () => { seedStore(useSettingsStore, { - settings: { time_format: '24h', temperature_unit: 'fahrenheit', blur_booking_codes: false }, + settings: { time_format: '24h', temperature_unit: 'celsius', distance_unit: 'imperial', blur_booking_codes: false }, }); server.use( http.get('/api/weather/detailed', () => diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index c7dad60d..bda5cdab 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -14,6 +14,7 @@ import { getLocaleForLanguage, useTranslation } from '../../i18n' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import { isDayInAccommodationRange } from '../../utils/dayOrder' import { splitReservationDateTime } from '../../utils/formatters' +import { convertDistance } from '../../utils/units' import { useDayDetail } from './useDayDetail' const WEATHER_ICON_MAP = { @@ -68,6 +69,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const tripObj = useTripStore((s) => s.trip) const canEditDays = can('day_edit', tripObj) const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric' const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) const fmtTime = (v) => { @@ -76,6 +78,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri return formatTime12(v, is12h) } const unit = isFahrenheit ? '°F' : '°C' + const formatWindSpeed = (kmh: number) => + distanceUnit === 'imperial' ? `${Math.round(convertDistance(kmh, distanceUnit))} mph` : `${Math.round(kmh)} km/h` const collapsed = collapsedProp const toggleCollapse = () => onToggleCollapse?.() const { @@ -172,7 +176,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri )} {weather.wind_max != null && ( - + )} {weather.sunrise && } {weather.sunset && } diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 30c93fc6..f41d8e7b 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -12,6 +12,7 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import { splitReservationDateTime, formatTime } from '../../utils/formatters' +import { formatDistance } from '../../utils/units' const detailsCache = new Map() @@ -122,6 +123,7 @@ export default function PlaceInspector({ const { t, locale, language } = useTranslation() const toast = useToast() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric' const [hoursExpanded, setHoursExpanded] = useState(false) const [filesExpanded, setFilesExpanded] = useState(false) const [isUploading, setIsUploading] = useState(false) @@ -274,7 +276,8 @@ export default function PlaceInspector({ + fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} + distanceUnit={distanceUnit} /> @@ -682,7 +685,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi } function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place, - placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) { + placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit }: any) { return (
0 ? 'sm:grid-cols-2' : ''} gap-2`}> {openingHours && openingHours.length > 0 && ( @@ -775,7 +778,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
- {distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`} + {formatDistance(distKm, distanceUnit)}
{hasEle && ( <> diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx index 51f1b80b..235d14ad 100644 --- a/client/src/components/Settings/DisplaySettingsTab.test.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx @@ -150,6 +150,22 @@ describe('DisplaySettingsTab', () => { expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit'); }); + it('FE-COMP-DISPLAY-028: metric distance button is active by default', () => { + seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } }); + render(); + const metricBtn = screen.getByText('km Metric').closest('button')!; + expect(metricBtn.style.border).toContain('var(--text-primary)'); + }); + + it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => { + const user = userEvent.setup(); + const updateSetting = vi.fn().mockResolvedValue(undefined); + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }), updateSetting }); + render(); + await user.click(screen.getByText('mi Imperial')); + expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial'); + }); + it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx index d5cb40ed..c777366c 100644 --- a/client/src/components/Settings/DisplaySettingsTab.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.tsx @@ -6,12 +6,14 @@ import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' import Section from './Section' +import type { DistanceUnit } from '../../types' export default function DisplaySettingsTab(): React.ReactElement { const { settings, updateSetting } = useSettingsStore() const { t } = useTranslation() const toast = useToast() const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius') + const [distanceUnit, setDistanceUnit] = useState(settings.distance_unit || 'metric') const [langOpen, setLangOpen] = useState(false) const langDropdownRef = useRef(null) @@ -28,6 +30,10 @@ export default function DisplaySettingsTab(): React.ReactElement { setTempUnit(settings.temperature_unit || 'celsius') }, [settings.temperature_unit]) + useEffect(() => { + setDistanceUnit(settings.distance_unit || 'metric') + }, [settings.distance_unit]) + return (
{/* Display currency */} @@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
+ {/* Distance */} +
+ +
+ {([ + { value: 'metric', label: 'km Metric' }, + { value: 'imperial', label: 'mi Imperial' }, + ] as const).map(opt => ( + + ))} +
+
+ {/* Time Format */}
diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 73a5e36f..74f278c3 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -4,9 +4,10 @@ import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; -import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories'; +import { buildUser, buildAdmin, buildTrip, buildSettings } from '../../tests/helpers/factories'; import { useAuthStore } from '../store/authStore'; import { usePermissionsStore } from '../store/permissionsStore'; +import { useSettingsStore } from '../store/settingsStore'; import DashboardPage from './DashboardPage'; beforeEach(() => { @@ -798,10 +799,51 @@ describe('DashboardPage', () => { }); }); + describe('FE-PAGE-DASH-033: Atlas distance respects distance unit setting', () => { + const distanceValue = (text: string) => + screen.getByText((_, element) => + element?.classList.contains('value') === true && + element.textContent?.replace(/\s+/g, ' ').trim() === text + ); + + beforeEach(() => { + server.use( + http.get('/api/auth/travel-stats', () => + HttpResponse.json({ + totalTrips: 1, + totalDays: 1, + totalPlaces: 1, + totalDistanceKm: 10, + countries: [], + }) + ), + ); + }); + + it('renders metric atlas distance as kilometers', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) }); + + render(); + + await waitFor(() => { + expect(distanceValue('10 km')).toBeInTheDocument(); + }); + }); + + it('renders imperial atlas distance as miles', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) }); + + render(); + + await waitFor(() => { + expect(distanceValue('6.2 mi')).toBeInTheDocument(); + }); + }); + }); + describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => { it('renders without error when dark_mode is set to auto', async () => { // Seed settings with dark_mode = 'auto' to exercise the matchMedia branch - const { useSettingsStore } = await import('../store/settingsStore'); seedStore(useSettingsStore, { settings: { map_tile_url: '', @@ -812,6 +854,7 @@ describe('DashboardPage', () => { default_currency: 'USD', language: 'en', temperature_unit: 'fahrenheit', + distance_unit: 'metric', time_format: '12h', show_place_description: false, blur_booking_codes: false, diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 110ee9a9..c9cb0d53 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -19,6 +19,7 @@ import { LayoutGrid, List, Ticket, X, } from 'lucide-react' import { formatTime, splitReservationDateTime } from '../utils/formatters' +import { convertDistance, getDistanceUnitLabel } from '../utils/units' import { useSettingsStore } from '../store/settingsStore' import '../styles/dashboard.css' @@ -358,12 +359,26 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch } // ── Atlas / stats row ──────────────────────────────────────────────────────── +function formatCompactDistance(value: number): string { + const safeValue = Number.isFinite(value) ? Math.max(0, value) : 0 + if (safeValue >= 1000) { + return `${(safeValue / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })}k` + } + const rounded = Math.round(safeValue * 10) / 10 + if (safeValue > 0 && rounded === 0) return '<0.1' + return rounded.toLocaleString(undefined, { maximumFractionDigits: 1 }) +} + function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement { const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric' const countries = stats?.countries || [] const distanceKm = stats?.totalDistanceKm || 0 - const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm) - const equatorTimes = (distanceKm / 40075).toFixed(2) + const distance = convertDistance(distanceKm, distanceUnit) + const distanceText = formatCompactDistance(distance) + const equatorDistance = convertDistance(40075, distanceUnit) + const equatorTimes = (distance / equatorDistance).toFixed(2) + const distanceLabel = getDistanceUnitLabel(distanceUnit) return (
@@ -401,7 +416,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{t('dashboard.atlas.distanceFlown')}
-
{distanceText} {t('dashboard.atlas.kmUnit')}
+
{distanceText} {distanceLabel}
{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}
diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index b8182811..632ddca7 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -30,6 +30,7 @@ export const useSettingsStore = create((set, get) => ({ default_currency: 'USD', language: localStorage.getItem('app_language') || 'en', temperature_unit: 'fahrenheit', + distance_unit: 'metric', time_format: '12h', show_place_description: false, optimize_from_accommodation: true, diff --git a/client/src/types.ts b/client/src/types.ts index 4c0191a7..2486dedd 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -100,6 +100,8 @@ export interface TripFile { url: string } +export type DistanceUnit = 'metric' | 'imperial' + export interface Settings { map_tile_url: string default_lat: number @@ -109,6 +111,7 @@ export interface Settings { default_currency: string language: string temperature_unit: string + distance_unit?: DistanceUnit time_format: string show_place_description: boolean blur_booking_codes?: boolean diff --git a/client/src/utils/units.ts b/client/src/utils/units.ts new file mode 100644 index 00000000..2b823dc1 --- /dev/null +++ b/client/src/utils/units.ts @@ -0,0 +1,22 @@ +import type { DistanceUnit } from '../types' + +const KM_TO_MI = 0.621371 + +export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' { + return unit === 'imperial' ? 'mi' : 'km' +} + +export function convertDistance(km: number, unit: DistanceUnit): number { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0 + return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm +} + +export function formatDistance(km: number, unit: DistanceUnit): string { + const value = convertDistance(km, unit) + const label = getDistanceUnitLabel(unit) + const rounded = Math.round(value * 10) / 10 + const text = value > 0 && rounded === 0 + ? '<0.1' + : rounded.toLocaleString(undefined, { maximumFractionDigits: 1 }) + return `${text} ${label}` +}