mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -7,7 +7,7 @@ import Section from '../Settings/Section'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { MapView } from '../Map/MapView'
|
import { MapView } from '../Map/MapView'
|
||||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||||
import type { Place } from '../../types'
|
import type { DistanceUnit, Place } from '../../types'
|
||||||
|
|
||||||
const MAP_PRESETS = [
|
const MAP_PRESETS = [
|
||||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||||
@@ -19,6 +19,7 @@ const MAP_PRESETS = [
|
|||||||
|
|
||||||
type Defaults = {
|
type Defaults = {
|
||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
|
distance_unit?: DistanceUnit
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
default_currency?: string
|
default_currency?: string
|
||||||
@@ -212,6 +213,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</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 */}
|
{/* Time Format */}
|
||||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@@ -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'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ export async function calculateRoute(
|
|||||||
coordinates,
|
coordinates,
|
||||||
distance,
|
distance,
|
||||||
duration,
|
duration,
|
||||||
distanceText: formatDistance(distance),
|
distanceText: formatRouteDistance(distance),
|
||||||
durationText: formatDuration(duration),
|
durationText: formatDuration(duration),
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(drivingDuration),
|
drivingText: formatDuration(drivingDuration),
|
||||||
@@ -218,7 +220,7 @@ export async function calculateSegments(
|
|||||||
duration: leg.duration,
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
distanceText: formatDistance(leg.distance),
|
distanceText: formatRouteDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -265,7 +267,7 @@ export async function calculateRouteWithLegs(
|
|||||||
duration: leg.duration,
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
distanceText: formatDistance(leg.distance),
|
distanceText: formatRouteDistance(leg.distance),
|
||||||
durationText: formatDuration(leg.duration),
|
durationText: formatDuration(leg.duration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,11 +282,16 @@ export async function calculateRouteWithLegs(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDistance(meters: number): string {
|
function getDistanceUnit(): DistanceUnit {
|
||||||
if (meters < 1000) {
|
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 `${Math.round(meters)} m`
|
||||||
}
|
}
|
||||||
return `${(meters / 1000).toFixed(1)} km`
|
return formatDistance(meters / 1000, unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
|
|||||||
@@ -402,9 +402,9 @@ describe('DayDetailPanel', () => {
|
|||||||
await screen.findByText('20:15');
|
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, {
|
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(
|
server.use(
|
||||||
http.get('/api/weather/detailed', () =>
|
http.get('/api/weather/detailed', () =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
|||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
import { splitReservationDateTime } from '../../utils/formatters'
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
import { convertDistance } from '../../utils/units'
|
||||||
import { useDayDetail } from './useDayDetail'
|
import { useDayDetail } from './useDayDetail'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
@@ -68,6 +69,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const tripObj = useTripStore((s) => s.trip)
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
const canEditDays = can('day_edit', tripObj)
|
const canEditDays = can('day_edit', tripObj)
|
||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
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 is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
const fmtTime = (v) => {
|
const fmtTime = (v) => {
|
||||||
@@ -76,6 +78,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
return formatTime12(v, is12h)
|
return formatTime12(v, is12h)
|
||||||
}
|
}
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
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 collapsed = collapsedProp
|
||||||
const toggleCollapse = () => onToggleCollapse?.()
|
const toggleCollapse = () => onToggleCollapse?.()
|
||||||
const {
|
const {
|
||||||
@@ -172,7 +176,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<Chip icon={CloudRain} value={`${weather.precipitation_sum.toFixed(1)} mm`} />
|
<Chip icon={CloudRain} value={`${weather.precipitation_sum.toFixed(1)} mm`} />
|
||||||
)}
|
)}
|
||||||
{weather.wind_max != null && (
|
{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.sunrise && <Chip icon={Sunrise} value={weather.sunrise} />}
|
||||||
{weather.sunset && <Chip icon={Sunset} value={weather.sunset} />}
|
{weather.sunset && <Chip icon={Sunset} value={weather.sunset} />}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||||
|
import { formatDistance } from '../../utils/units'
|
||||||
|
|
||||||
const detailsCache = new Map()
|
const detailsCache = new Map()
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export default function PlaceInspector({
|
|||||||
const { t, locale, language } = useTranslation()
|
const { t, locale, language } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
|
||||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
const [filesExpanded, setFilesExpanded] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
@@ -274,7 +276,8 @@ export default function PlaceInspector({
|
|||||||
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
|
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
|
||||||
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
|
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
|
||||||
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
|
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
|
||||||
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
|
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
|
||||||
|
distanceUnit={distanceUnit} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -682,7 +685,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
|
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 (
|
return (
|
||||||
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
||||||
{openingHours && openingHours.length > 0 && (
|
{openingHours && openingHours.length > 0 && (
|
||||||
@@ -775,7 +778,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
|
|||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
||||||
<MapPin size={12} color="#3b82f6" />
|
<MapPin size={12} color="#3b82f6" />
|
||||||
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
{formatDistance(distKm, distanceUnit)}
|
||||||
</div>
|
</div>
|
||||||
{hasEle && (
|
{hasEle && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -150,6 +150,22 @@ describe('DisplaySettingsTab', () => {
|
|||||||
expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit');
|
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 () => {
|
it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { useToast } from '../shared/Toast'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import type { DistanceUnit } from '../../types'
|
||||||
|
|
||||||
export default function DisplaySettingsTab(): React.ReactElement {
|
export default function DisplaySettingsTab(): React.ReactElement {
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||||
|
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || 'metric')
|
||||||
const [langOpen, setLangOpen] = useState(false)
|
const [langOpen, setLangOpen] = useState(false)
|
||||||
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -28,6 +30,10 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
setTempUnit(settings.temperature_unit || 'celsius')
|
setTempUnit(settings.temperature_unit || 'celsius')
|
||||||
}, [settings.temperature_unit])
|
}, [settings.temperature_unit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDistanceUnit(settings.distance_unit || 'metric')
|
||||||
|
}, [settings.distance_unit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.display')} icon={Palette}>
|
<Section title={t('settings.display')} icon={Palette}>
|
||||||
{/* Display currency */}
|
{/* Display currency */}
|
||||||
@@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Time Format */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
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 { useAuthStore } from '../store/authStore';
|
||||||
import { usePermissionsStore } from '../store/permissionsStore';
|
import { usePermissionsStore } from '../store/permissionsStore';
|
||||||
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import DashboardPage from './DashboardPage';
|
import DashboardPage from './DashboardPage';
|
||||||
|
|
||||||
beforeEach(() => {
|
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', () => {
|
describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => {
|
||||||
it('renders without error when dark_mode is set to auto', async () => {
|
it('renders without error when dark_mode is set to auto', async () => {
|
||||||
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
|
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
|
||||||
const { useSettingsStore } = await import('../store/settingsStore');
|
|
||||||
seedStore(useSettingsStore, {
|
seedStore(useSettingsStore, {
|
||||||
settings: {
|
settings: {
|
||||||
map_tile_url: '',
|
map_tile_url: '',
|
||||||
@@ -812,6 +854,7 @@ describe('DashboardPage', () => {
|
|||||||
default_currency: 'USD',
|
default_currency: 'USD',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
temperature_unit: 'fahrenheit',
|
temperature_unit: 'fahrenheit',
|
||||||
|
distance_unit: 'metric',
|
||||||
time_format: '12h',
|
time_format: '12h',
|
||||||
show_place_description: false,
|
show_place_description: false,
|
||||||
blur_booking_codes: false,
|
blur_booking_codes: false,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
LayoutGrid, List, Ticket, X,
|
LayoutGrid, List, Ticket, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||||
|
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import '../styles/dashboard.css'
|
import '../styles/dashboard.css'
|
||||||
|
|
||||||
@@ -358,12 +359,26 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Atlas / stats row ────────────────────────────────────────────────────────
|
// ── 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 {
|
function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
|
||||||
const countries = stats?.countries || []
|
const countries = stats?.countries || []
|
||||||
const distanceKm = stats?.totalDistanceKm || 0
|
const distanceKm = stats?.totalDistanceKm || 0
|
||||||
const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm)
|
const distance = convertDistance(distanceKm, distanceUnit)
|
||||||
const equatorTimes = (distanceKm / 40075).toFixed(2)
|
const distanceText = formatCompactDistance(distance)
|
||||||
|
const equatorDistance = convertDistance(40075, distanceUnit)
|
||||||
|
const equatorTimes = (distance / equatorDistance).toFixed(2)
|
||||||
|
const distanceLabel = getDistanceUnitLabel(distanceUnit)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="atlas">
|
<section className="atlas">
|
||||||
@@ -401,7 +416,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
|
|||||||
|
|
||||||
<div className="atlas-card">
|
<div className="atlas-card">
|
||||||
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
|
<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>
|
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
|
||||||
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
|
<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" />
|
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
default_currency: 'USD',
|
default_currency: 'USD',
|
||||||
language: localStorage.getItem('app_language') || 'en',
|
language: localStorage.getItem('app_language') || 'en',
|
||||||
temperature_unit: 'fahrenheit',
|
temperature_unit: 'fahrenheit',
|
||||||
|
distance_unit: 'metric',
|
||||||
time_format: '12h',
|
time_format: '12h',
|
||||||
show_place_description: false,
|
show_place_description: false,
|
||||||
optimize_from_accommodation: true,
|
optimize_from_accommodation: true,
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export interface TripFile {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DistanceUnit = 'metric' | 'imperial'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
map_tile_url: string
|
map_tile_url: string
|
||||||
default_lat: number
|
default_lat: number
|
||||||
@@ -109,6 +111,7 @@ export interface Settings {
|
|||||||
default_currency: string
|
default_currency: string
|
||||||
language: string
|
language: string
|
||||||
temperature_unit: string
|
temperature_unit: string
|
||||||
|
distance_unit?: DistanceUnit
|
||||||
time_format: string
|
time_format: string
|
||||||
show_place_description: boolean
|
show_place_description: boolean
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user