feat: add distance unit (metric/imperial) display setting

Mirrors the existing temperature_unit pattern. Adds distance_unit to Settings,
a Display settings control, admin default, and a formatDistance helper applied
at distance render sites. Backward compatible (default metric). Closes #1300.
This commit is contained in:
Matt Van Horn
2026-06-25 17:07:14 -07:00
committed by Maurice
parent b1145e7e0a
commit 94dca8cad7
12 changed files with 187 additions and 19 deletions
@@ -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 {
))}
</OptionRow>
{/* Distance */}
<OptionRow label={<>Distance <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
+14 -7
View File
@@ -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 {
@@ -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', () =>
@@ -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
<Chip icon={CloudRain} value={`${weather.precipitation_sum.toFixed(1)} mm`} />
)}
{weather.wind_max != null && (
<Chip icon={Wind} value={isFahrenheit ? `${Math.round(weather.wind_max * 0.621371)} mph` : `${Math.round(weather.wind_max)} km/h`} />
<Chip icon={Wind} value={formatWindSpeed(weather.wind_max)} />
)}
{weather.sunrise && <Chip icon={Sunrise} value={weather.sunrise} />}
{weather.sunset && <Chip icon={Sunset} value={weather.sunset} />}
@@ -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({
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} />
</div>
@@ -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 (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
@@ -775,7 +778,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
{formatDistance(distKm, distanceUnit)}
</div>
{hasEle && (
<>
@@ -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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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);
@@ -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<string>(settings.temperature_unit || 'celsius')
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || 'metric')
const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(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 (
<Section title={t('settings.display')} icon={Palette}>
{/* Display currency */}
@@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Distance */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">Distance</label>
<div className="flex gap-3">
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<button
key={opt.value}
onClick={async () => {
setDistanceUnit(opt.value)
try { await updateSetting('distance_unit', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: distanceUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: distanceUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Time Format */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
+45 -2
View File
@@ -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(<DashboardPage />);
await waitFor(() => {
expect(distanceValue('10 km')).toBeInTheDocument();
});
});
it('renders imperial atlas distance as miles', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
render(<DashboardPage />);
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,
+18 -3
View File
@@ -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 (
<section className="atlas">
@@ -401,7 +416,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
<div className="value mono">{distanceText} <span className="unit">{t('dashboard.atlas.kmUnit')}</span></div>
<div className="value mono">{distanceText} <span className="unit">{distanceLabel}</span></div>
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
+1
View File
@@ -30,6 +30,7 @@ export const useSettingsStore = create<SettingsState>((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,
+3
View File
@@ -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
+22
View File
@@ -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}`
}