diff --git a/README.md b/README.md index b3a4701a..685661fd 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - **Themed vector basemap** -- Every map is drawn from OpenStreetMap vector tiles by MapLibre GL, in a dark or light style that follows the interface theme (or in full colour, if you prefer) and with labels in the interface language. Served by [OpenFreeMap](https://openfreemap.org) without an API key, point it at your own tile server if you prefer, and it falls back to raster tiles where WebGL is unavailable. - **Single sign-on** -- Sign in through your own identity provider (Authentik, Authelia, Keycloak, Pocket ID, Google, and anything else speaking OpenID Connect), with optional auto-login and group or email based access restrictions. A built-in username/password login remains available for installs without a provider. See [`AUTHENTICATION.md`](documentation/AUTHENTICATION.md). - **Multi-language support** -- Interface available in English and Dutch, with locale resolved from query string, cookie, or browser preference. -- **Units** -- Kilometres or miles, Celsius or Fahrenheit, bar, psi or kPa for tyres, and L/100 km or miles per UK or US gallon for fuel, chosen under Settings > Units. Everything is stored in metric and converted only for display, so switching back and forth loses nothing; the trip log's CSV export follows the chosen distance unit. The homepage widget, Home Assistant and the `TYRE_PRESSURE_*_BAR` thresholds stay metric. +- **Region and units** -- Kilometres or miles, Celsius or Fahrenheit, bar, psi or kPa for tyres, L/100 km or miles per UK or US gallon for fuel, and a 24-hour or 12-hour clock. Each one follows the region the browser is in (taken from its time zone, so an English browser in the Netherlands still gets kilometres and 14:05), and can be set by hand under Settings > Region and units, as can the region itself, which also sets the date order. Everything is stored in metric and converted only for display, so switching back and forth loses nothing; the trip log's CSV export follows the chosen distance unit and clock. The homepage widget, Home Assistant and the `TYRE_PRESSURE_*_BAR` thresholds stay metric, and notifications use a 24-hour clock. - **Settings follow you** -- Theme, language, units, the dashboard layout and the map's layers and filters are saved to your account, so every phone, tablet and computer you sign in on looks the same. Each browser keeps a copy too, so pages open instantly and the login page keeps your theme and language. After upgrading, the first device to sign in brings its settings along. - **Self-hosted** -- Runs entirely on your own infrastructure via Docker (all-in-one container or Docker Compose). No cloud account or subscription required beyond the SAIC iSmart API. diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 263f03c4..438c7d6a 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -245,6 +245,17 @@ styled apart), never through a hand-written `km` or `bar`. A distance or tempera through `useMeasureField`, which returns the untouched metric value it loaded rather than a round-tripped approximation. +The region, the clock and every unit default to automatic. `utils/region.ts` detects the region +once per page load, from the browser's time zone first (`utils/timeZoneRegions.ts`, generated by +`frontend/scripts/gen-time-zone-regions.mjs` from ICU's CLDR data) and from its languages after +that, since an `en-US` browser in the Netherlands is common and its time zone is not. The store +resolves the settings into `effectiveRegion`, `effectiveHourCycle` and `effectiveUnits`, and hands +the region and clock to `utils/format.ts`. Every date and time on screen goes through that file +(`formatDate`, `formatTime`, `formatDateTime`, `formatTimeOfDay` for a stored `HH:mm`), never +through `toLocaleString` directly: dates are written in the interface language in the region's +order (`en-NL` reads "09/10/2026"), times on the chosen clock, and numbers stay in the interface +language wherever the browser is. + Dashboard cards are described once, in `frontend/src/cards/registry.ts`: each entry carries the card's icon, whether it is visible by default for a given drivetrain, and whether the current telemetry has anything to show. The card ids, the default layout and the "does this card have data" checks are all derived from that list, so a new card is one entry plus its markup in `DashboardCardContent.vue`. The map's point-of-interest layers work the same way: `composables/poiTileLayer.ts` holds the fetch-by-tile, cache and cluster logic, and charging stations, fuel stations, service areas and speed cameras are four configurations of it. Every map is Leaflet, and everything drawn on one (markers, clusters, routes, the heatmap) is a diff --git a/frontend/scripts/gen-time-zone-regions.mjs b/frontend/scripts/gen-time-zone-regions.mjs new file mode 100644 index 00000000..86e0906d --- /dev/null +++ b/frontend/scripts/gen-time-zone-regions.mjs @@ -0,0 +1,73 @@ +// Writes src/utils/timeZoneRegions.ts: every region's IANA time zones, from the CLDR data in +// Node's own ICU, so the browser can tell the region it is in from its time zone alone. +// Run with `node scripts/gen-time-zone-regions.mjs` after a Node upgrade brings a newer ICU. +import { writeFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUT = join(__dirname, '..', 'src', 'utils', 'timeZoneRegions.ts') + +// CLDR keeps the old spelling of a renamed zone (Asia/Calcutta), which Chromium and Safari report, +// while Firefox reports the current IANA name (Asia/Kolkata). These are the current names, each +// checked below to be one of the zones ICU already knows. +const IANA_RENAMES = [ + 'Africa/Asmara', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/Mendoza', + 'America/Atikokan', + 'America/Indiana/Indianapolis', + 'America/Kentucky/Louisville', + 'America/Nuuk', + 'Asia/Ho_Chi_Minh', + 'Asia/Kathmandu', + 'Asia/Kolkata', + 'Asia/Yangon', + 'Atlantic/Faroe', + 'Europe/Kyiv', + 'Pacific/Chuuk', + 'Pacific/Kanton', + 'Pacific/Pohnpei', +] + +const zonesByRegion = new Map() +const regionOfZone = new Map() +for (let a = 65; a <= 90; a++) { + for (let b = 65; b <= 90; b++) { + const region = String.fromCharCode(a, b) + // A retired code (DD, SU, UK) canonicalizes to the one that replaced it, which lists the + // same zones itself. + if (Intl.getCanonicalLocales(`und-${region}`)[0] !== `und-${region}`) continue + const zones = new Intl.Locale(`und-${region}`).getTimeZones?.() ?? [] + if (zones.length === 0) continue + zonesByRegion.set(region, [...zones]) + for (const zone of zones) regionOfZone.set(zone, region) + } +} + +for (const name of IANA_RENAMES) { + if (regionOfZone.has(name)) continue + const known = new Intl.DateTimeFormat('en', { timeZone: name }).resolvedOptions().timeZone + const region = regionOfZone.get(known) + if (!region) throw new Error(`${name} resolves to ${known}, which no region lists`) + zonesByRegion.get(region).push(name) +} + +const lines = [...zonesByRegion] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([region, zones]) => ` ${region}: '${zones.sort().join(' ')}',`) + +writeFileSync( + OUT, + `// Generated by scripts/gen-time-zone-regions.mjs from ICU ${process.versions.icu}. Do not edit by hand. + +/** Each region's IANA time zones, space separated, under both spellings of a renamed zone. */ +export const TIME_ZONE_REGIONS: Readonly> = { +${lines.join('\n')} +} +`, +) +console.log(`${zonesByRegion.size} regions written to ${OUT}`) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 245050bb..6d5846c9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -123,7 +123,7 @@ const isInitialLoading = computed(() => vehicleStore.loading && !vehicleStore.cu const lastFetched = computed(() => { const d = vehicleStore.lastUpdated if (!d) return null - return formatTime(d, { hour: '2-digit', minute: '2-digit' }) + return formatTime(d) }) const lastRecorded = computed(() => { diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 3e036700..dab826ee 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1181,7 +1181,7 @@ textarea.form-control { font-family: inherit; } -/* A time of day as selects (TimeOfDaySelect): hour and minute on a 24-hour clock. */ +/* A time of day as selects (TimeOfDaySelect): hour and minute, plus AM or PM on a 12-hour clock. */ .time-select { display: flex; align-items: center; @@ -1334,6 +1334,18 @@ textarea.form-control { flex-shrink: 0; } +/* A choice from a list goes under its label, at full width: its options are too long to sit + beside the label, in a modal as narrow as a phone. */ +.settings-toggle--select { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.settings-toggle__select { + width: 100%; +} + .settings-toggle__side-icon { display: flex; align-items: center; diff --git a/frontend/src/components/BatteryHeatingCard.vue b/frontend/src/components/BatteryHeatingCard.vue index 30d34f1a..3bafca81 100644 --- a/frontend/src/components/BatteryHeatingCard.vue +++ b/frontend/src/components/BatteryHeatingCard.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import ExpandableStatusCard from './ExpandableStatusCard.vue' import DetailListItem from './DetailListItem.vue' +import { formatTimeOfDay } from '@/utils/format' const { t } = useI18n() @@ -16,7 +17,7 @@ const summaryValue = computed((): string | null => { if (props.batteryHeating === null) return null const state = props.batteryHeating ? t('common.on') : t('common.off') if (props.scheduleMode && props.scheduleMode !== 'off' && props.scheduleStartTime) - return `${state} · ${props.scheduleStartTime}` + return `${state} · ${formatTimeOfDay(props.scheduleStartTime)}` return state }) @@ -58,7 +59,7 @@ const hasModal = computed(() => props.scheduleMode !== null || props.scheduleSta diff --git a/frontend/src/components/ChargingSessionCard.vue b/frontend/src/components/ChargingSessionCard.vue index 4f14f630..e70de70d 100644 --- a/frontend/src/components/ChargingSessionCard.vue +++ b/frontend/src/components/ChargingSessionCard.vue @@ -3,7 +3,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import ExpandableStatusCard from './ExpandableStatusCard.vue' import DetailListItem from './DetailListItem.vue' -import { formatDateTime, formatNumber } from '@/utils/format' +import { formatDateTime, formatNumber, formatTimeOfDay } from '@/utils/format' const { t } = useI18n() @@ -142,10 +142,14 @@ const lastEndFormatted = computed((): string | null => { diff --git a/frontend/src/components/MaintenanceItemDetailModal.vue b/frontend/src/components/MaintenanceItemDetailModal.vue index c3120980..703c1dba 100644 --- a/frontend/src/components/MaintenanceItemDetailModal.vue +++ b/frontend/src/components/MaintenanceItemDetailModal.vue @@ -6,6 +6,7 @@ import { useMaintenanceStore } from '@/stores/maintenance' import { useVehicleStore } from '@/stores/vehicle' import type { MaintenanceItem } from '@/services/maintenanceApi' import { formatIntervalSummary } from '@/utils/maintenance' +import { localDateKey } from '@/utils/dates' import { formatDate } from '@/utils/format' import { ODOMETER_FORMAT } from '@/utils/units' import { useUnits } from '@/composables/useUnits' @@ -42,7 +43,7 @@ watch( if (!isOpen || !props.item) return pendingDelete.value = false logError.value = null - performedAt.value = new Date().toISOString().slice(0, 10) + performedAt.value = localDateKey(new Date()) odometer.load(vehicleStore.currentStatus?.odometerKm ?? null) logNotes.value = '' store.fetchLog(props.vin, props.item.id) diff --git a/frontend/src/components/RegionalSettings.vue b/frontend/src/components/RegionalSettings.vue new file mode 100644 index 00000000..3cf38f4b --- /dev/null +++ b/frontend/src/components/RegionalSettings.vue @@ -0,0 +1,121 @@ + + + diff --git a/frontend/src/components/SettingsSelect.vue b/frontend/src/components/SettingsSelect.vue new file mode 100644 index 00000000..08dea435 --- /dev/null +++ b/frontend/src/components/SettingsSelect.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/TimeOfDaySelect.vue b/frontend/src/components/TimeOfDaySelect.vue index 8f15e318..4876b33a 100644 --- a/frontend/src/components/TimeOfDaySelect.vue +++ b/frontend/src/components/TimeOfDaySelect.vue @@ -1,10 +1,11 @@ - - diff --git a/frontend/src/components/__tests__/RegionalSettings.spec.ts b/frontend/src/components/__tests__/RegionalSettings.spec.ts new file mode 100644 index 00000000..109313a1 --- /dev/null +++ b/frontend/src/components/__tests__/RegionalSettings.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { createPinia, setActivePinia } from 'pinia' +import en from '@/locales/en.json' +import { useUiSettingsStore } from '@/stores/settingsUi' +import { setRegionalFormat } from '@/utils/format' +import RegionalSettings from '../RegionalSettings.vue' + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +// ICU puts a narrow no-break space before AM and PM. +const plain = (text: string) => text.replace(/\s/g, ' ') + +function mountSettings() { + return mount(RegionalSettings, { global: { plugins: [i18n] } }) +} + +function automaticLabel(wrapper: ReturnType, id: string) { + return plain(wrapper.find(`#${id} option[value="auto"]`).text()) +} + +describe('RegionalSettings', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + }) + + afterEach(() => setRegionalFormat({ region: null, hourCycle: 'h23' })) + + it('says what every automatic choice stands for in the detected region', () => { + const wrapper = mountSettings() + + expect(automaticLabel(wrapper, 'settings-region')).toBe('Automatic: Netherlands') + expect(automaticLabel(wrapper, 'settings-clock')).toBe('Automatic: 24-hour (14:05)') + expect(automaticLabel(wrapper, 'settings-unit-distance')).toBe( + 'Automatic: Kilometres (km, km/h)', + ) + expect(automaticLabel(wrapper, 'settings-unit-pressure')).toBe('Automatic: bar') + }) + + it('follows a chosen region in every choice left on automatic', async () => { + const wrapper = mountSettings() + await wrapper.find('#settings-region').setValue('US') + + expect(useUiSettingsStore().region).toBe('US') + expect(automaticLabel(wrapper, 'settings-clock')).toBe('Automatic: 12-hour (2:05 PM)') + expect(automaticLabel(wrapper, 'settings-unit-distance')).toBe('Automatic: Miles (mi, mph)') + expect(automaticLabel(wrapper, 'settings-unit-fuelConsumption')).toBe( + 'Automatic: mpg (US gallon)', + ) + }) + + it('stores a chosen clock and unit, and offers going back to automatic', async () => { + const wrapper = mountSettings() + const store = useUiSettingsStore() + + await wrapper.find('#settings-clock').setValue('h12') + await wrapper.find('#settings-unit-temperature').setValue('fahrenheit') + expect(store.clock).toBe('h12') + expect(store.units.temperature).toBe('fahrenheit') + + await wrapper.find('#settings-unit-temperature').setValue('auto') + expect(store.units.temperature).toBe('auto') + }) +}) diff --git a/frontend/src/components/__tests__/TimeOfDaySelect.spec.ts b/frontend/src/components/__tests__/TimeOfDaySelect.spec.ts index 8ce43265..d7388215 100644 --- a/frontend/src/components/__tests__/TimeOfDaySelect.spec.ts +++ b/frontend/src/components/__tests__/TimeOfDaySelect.spec.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { mount } from '@vue/test-utils' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' +import { setRegionalFormat } from '@/utils/format' import TimeOfDaySelect from '../TimeOfDaySelect.vue' const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) @@ -19,6 +20,8 @@ function lastValue(wrapper: ReturnType) { } describe('TimeOfDaySelect', () => { + afterEach(() => setRegionalFormat({ region: null, hourCycle: 'h23' })) + it('shows the time on a 24-hour clock, also in English', () => { const wrapper = mountSelect('19:05') const [hour, minute] = wrapper.findAll('select') @@ -44,6 +47,31 @@ describe('TimeOfDaySelect', () => { expect(lastValue(wrapper)).toEqual(['07:45']) }) + it('splits the hour into 12 and the half of the day on a 12-hour clock', () => { + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + const wrapper = mountSelect('19:05') + const [hour, , period] = wrapper.findAll('select') + + expect(wrapper.findAll('select')).toHaveLength(3) + expect((hour!.element as HTMLSelectElement).value).toBe('7') + expect(period!.find('option:checked').text()).toBe('PM') + const hours = hour!.findAll('option').map((o) => o.text()) + expect([hours[0], hours[11]]).toEqual(['12', '11']) + }) + + it('writes back a 12-hour time as HH:mm on the 24-hour clock', async () => { + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + const wrapper = mountSelect('19:05') + const [hour, , period] = wrapper.findAll('select') + + await hour!.setValue('12') + expect(lastValue(wrapper)).toEqual(['12:05']) + + await wrapper.setProps({ modelValue: '12:05' }) + await period!.setValue('false') + expect(lastValue(wrapper)).toEqual(['00:05']) + }) + it('offers minutes in steps of five, keeping a saved minute between them', () => { const [, minute] = mountSelect('07:32').findAll('select') diff --git a/frontend/src/components/__tests__/TripLogItem.spec.ts b/frontend/src/components/__tests__/TripLogItem.spec.ts index e925fbc7..d10521aa 100644 --- a/frontend/src/components/__tests__/TripLogItem.spec.ts +++ b/frontend/src/components/__tests__/TripLogItem.spec.ts @@ -22,7 +22,6 @@ function mountItem( return mount(TripLogItem, { props: { entry, - locale: 'en-US', placesShown: true, placesResolving: false, saving: false, diff --git a/frontend/src/components/settings/SettingsModal.vue b/frontend/src/components/settings/SettingsModal.vue index 1fb008ca..7a98dfce 100644 --- a/frontend/src/components/settings/SettingsModal.vue +++ b/frontend/src/components/settings/SettingsModal.vue @@ -6,7 +6,7 @@ import nlFlag from 'flag-icons/flags/4x3/nl.svg' import { useUiSettingsStore } from '@/stores/settingsUi' import DetailModal from '../DetailModal.vue' import SettingsToggle from '../SettingsToggle.vue' -import UnitSettings from '../UnitSettings.vue' +import RegionalSettings from '../RegionalSettings.vue' import SettingsSection from './SettingsSection.vue' import SideIconSwitch from './SideIconSwitch.vue' import CarColorPicker from './CarColorPicker.vue' @@ -97,7 +97,7 @@ const isNL = computed({ - + diff --git a/frontend/src/composables/useStatisticsCharts.ts b/frontend/src/composables/useStatisticsCharts.ts index 53120597..f1f19fb5 100644 --- a/frontend/src/composables/useStatisticsCharts.ts +++ b/frontend/src/composables/useStatisticsCharts.ts @@ -4,10 +4,9 @@ import type { ChartData, ChartOptions } from 'chart.js' import type { StatsChartType } from '@/components/StatsChartCard.vue' import type { TelemetryHistoryPoint } from '@/services/vehicleApi' import type { StatsChartId } from '@/stores/settingsShared' -import { useUiSettingsStore } from '@/stores/settingsUi' import type { VehicleType } from '@/stores/vehicle' import { energyUnit, litres } from '@/utils/energy' -import { formatDate, intlLocale } from '@/utils/format' +import { formatDate, numberLocale } from '@/utils/format' import { dailyAverages, dailyCounterTotals, @@ -76,7 +75,6 @@ export function useStatisticsCharts( ): Ref { const { t } = useI18n() const units = useUnits() - const uiSettings = useUiSettingsStore() const hasLargeEv = computed(() => mayPlugIn(vehicleType.value)) const isHybrid = computed(() => burnsFuel(vehicleType.value)) @@ -152,7 +150,7 @@ export function useStatisticsCharts( // and y-axis range. The locale makes axis and tooltip numbers read like the rest of the page. function chartOptions(aspectRatio: number, legend: boolean, y: { min: number; max?: number }) { return { - locale: intlLocale(uiSettings.locale), + locale: numberLocale(), responsive: true, maintainAspectRatio: true, aspectRatio, diff --git a/frontend/src/composables/useUnits.ts b/frontend/src/composables/useUnits.ts index 454efe49..1c16f6b8 100644 --- a/frontend/src/composables/useUnits.ts +++ b/frontend/src/composables/useUnits.ts @@ -4,13 +4,13 @@ import { useUiSettingsStore } from '@/stores/settingsUi' import { UnitFormatter } from '@/utils/units' /** - * The formatter for this browser's units. The preferences are copied in, rather than handed over - * as the store's reactive object, so this computed depends on every one of them and anything - * built on it follows a change. The language needs no such care: each symbol is translated when - * it is asked for, inside whatever computed or render is asking. + * The formatter for the units this browser shows: the chosen ones, and the region's for each one + * left on automatic. The store resolves them into a fresh object whenever any of them changes, so + * anything built on this computed follows. The language needs no such care: each symbol is + * translated when it is asked for, inside whatever computed or render is asking. */ export function useUnits() { const settings = useUiSettingsStore() const { t } = useI18n() - return computed(() => new UnitFormatter({ ...settings.units }, t)) + return computed(() => new UnitFormatter(settings.effectiveUnits, t)) } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index e8e69eb3..380638be 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -457,7 +457,15 @@ "title": "Notification types" }, "units": { - "title": "Units", + "title": "Region and units", + "auto": "Automatic: {value}", + "region": "Region", + "regionDesc": "Sets the date order, and the clock and units left on automatic", + "clock": "Clock", + "clockOptions": { + "h23": "24-hour ({example})", + "h12": "12-hour ({example})" + }, "distance": "Distance and speed", "distanceDesc": "Also energy use per distance", "temperature": "Temperature", @@ -528,7 +536,8 @@ "pageOf": "Page {n} of {total}", "retry": "Try again", "hour": "Hour", - "minute": "Minute" + "minute": "Minute", + "dayPeriod": "AM or PM" }, "demo": { "banner": "Demo mode - displaying sample data only", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index bcf859fb..dc4a18f8 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -457,7 +457,15 @@ "title": "Meldingstypen" }, "units": { - "title": "Eenheden", + "title": "Regio en eenheden", + "auto": "Automatisch: {value}", + "region": "Regio", + "regionDesc": "Bepaalt de datumnotatie, en de klok en eenheden die op automatisch staan", + "clock": "Klok", + "clockOptions": { + "h23": "24-uurs ({example})", + "h12": "12-uurs ({example})" + }, "distance": "Afstand en snelheid", "distanceDesc": "Ook energieverbruik per afstand", "temperature": "Temperatuur", @@ -528,7 +536,8 @@ "pageOf": "Pagina {n} van {total}", "retry": "Opnieuw proberen", "hour": "Uur", - "minute": "Minuut" + "minute": "Minuut", + "dayPeriod": "Ochtend of middag" }, "demo": { "banner": "Demo modus - alleen voorbeelddata wordt getoond", diff --git a/frontend/src/stores/__tests__/settings.spec.ts b/frontend/src/stores/__tests__/settings.spec.ts index 09dc2cd4..a9e6293f 100644 --- a/frontend/src/stores/__tests__/settings.spec.ts +++ b/frontend/src/stores/__tests__/settings.spec.ts @@ -6,7 +6,8 @@ import { ALL_CARD_IDS } from '@/cards/registry' import { useUiSettingsStore } from '@/stores/settingsUi' import { useDashboardSettingsStore } from '@/stores/settingsDashboard' import { useMapSettingsStore } from '@/stores/settingsMap' -import { METRIC_UNITS } from '@/utils/units' +import { formatTime, setRegionalFormat } from '@/utils/format' +import { AUTO_UNITS, METRIC_UNITS } from '@/utils/units' // Settings persistence is debounced (see createDebouncedSave in settingsShared.ts) so a burst of // ref changes coalesces into one localStorage write - advance fake timers past the debounce @@ -27,6 +28,7 @@ describe('useUiSettingsStore', () => { afterEach(() => { vi.useRealTimers() + setRegionalFormat({ region: null, hourCycle: 'h23' }) }) it('defaults to dark theme when matchMedia reports no light preference', () => { @@ -147,11 +149,67 @@ describe('useUiSettingsStore', () => { expect(store.notificationTypeExclusions).toEqual(['charging-complete']) }) - it('shows metric units until the browser asks for others', () => { + it('leaves the region, the clock and every unit on automatic until one is chosen', () => { localStorage.setItem(UI_KEY, JSON.stringify({ theme: 'light' })) + const store = useUiSettingsStore() + + expect(store.region).toBe('auto') + expect(store.clock).toBe('auto') + expect(store.units).toEqual(AUTO_UNITS) + }) + + it('keeps the units an install stored before automatic existed', () => { + localStorage.setItem(UI_KEY, JSON.stringify({ units: METRIC_UNITS })) expect(useUiSettingsStore().units).toEqual(METRIC_UNITS) }) + it('shows what the detected region uses for everything left on automatic', () => { + const store = useUiSettingsStore() + + expect(store.detectedRegion).toBe('NL') + expect(store.effectiveRegion).toBe('NL') + expect(store.effectiveHourCycle).toBe('h23') + expect(store.effectiveUnits).toEqual(METRIC_UNITS) + }) + + it('follows a chosen region, except where a clock or unit is chosen too', () => { + const store = useUiSettingsStore() + store.region = 'US' + + expect(store.effectiveHourCycle).toBe('h12') + expect(store.effectiveUnits).toEqual({ + distance: 'mi', + temperature: 'fahrenheit', + pressure: 'psi', + fuelConsumption: 'mpgUs', + }) + + store.clock = 'h23' + store.units.distance = 'km' + expect(store.effectiveHourCycle).toBe('h23') + expect(store.effectiveUnits.distance).toBe('km') + }) + + it('hands the region and the clock to every date and time at once', async () => { + const store = useUiSettingsStore() + const moment = new Date(2026, 8, 26, 14, 5) + store.region = 'US' + await nextTick() + expect(formatTime(moment).replace(/\s/g, ' ')).toBe('2:05 PM') + + store.clock = 'h23' + await nextTick() + expect(formatTime(moment)).toBe('14:05') + }) + + it('falls back to automatic for a region or clock it does not know', () => { + localStorage.setItem(UI_KEY, JSON.stringify({ region: 'XX', clock: 'h11' })) + const store = useUiSettingsStore() + + expect(store.region).toBe('auto') + expect(store.clock).toBe('auto') + }) + it('persists a unit change, and loads it back', async () => { const store = useUiSettingsStore() store.units.distance = 'mi' @@ -160,7 +218,7 @@ describe('useUiSettingsStore', () => { expect(JSON.parse(localStorage.getItem(UI_KEY)!).units.distance).toBe('mi') setActivePinia(createPinia()) - expect(useUiSettingsStore().units).toEqual({ ...METRIC_UNITS, distance: 'mi' }) + expect(useUiSettingsStore().units).toEqual({ ...AUTO_UNITS, distance: 'mi' }) }) it('falls back per unit, keeping the stored units it recognises', () => { @@ -170,9 +228,9 @@ describe('useUiSettingsStore', () => { ) expect(useUiSettingsStore().units).toEqual({ distance: 'mi', - temperature: 'celsius', + temperature: 'auto', pressure: 'psi', - fuelConsumption: 'l100km', + fuelConsumption: 'auto', }) }) }) diff --git a/frontend/src/stores/__tests__/settingsSync.spec.ts b/frontend/src/stores/__tests__/settingsSync.spec.ts index 53c75f10..b574f6a5 100644 --- a/frontend/src/stores/__tests__/settingsSync.spec.ts +++ b/frontend/src/stores/__tests__/settingsSync.spec.ts @@ -5,7 +5,7 @@ import { PULL_INTERVAL_MS, useSettingsSyncStore } from '@/stores/settingsSync' import { useUiSettingsStore } from '@/stores/settingsUi' import { useMapSettingsStore } from '@/stores/settingsMap' import { useDashboardSettingsStore } from '@/stores/settingsDashboard' -import { METRIC_UNITS } from '@/utils/units' +import { AUTO_UNITS } from '@/utils/units' vi.mock('@/services/settingsApi', () => ({ settingsApi: { @@ -111,7 +111,7 @@ describe('settings kept on the account', () => { await settle() expect(save).toHaveBeenCalledExactlyOnceWith('ui', { - units: { ...METRIC_UNITS, distance: 'mi' }, + units: { ...AUTO_UNITS, distance: 'mi' }, }) }) diff --git a/frontend/src/stores/settingsUi.ts b/frontend/src/stores/settingsUi.ts index fbd26c4b..3a86571e 100644 --- a/frontend/src/stores/settingsUi.ts +++ b/frontend/src/stores/settingsUi.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { reactive, toRefs, watch } from 'vue' +import { computed, reactive, toRefs, watch } from 'vue' import type { Theme, Locale, VehicleTypeOverride } from './settingsShared' import { osPreferredTheme, @@ -8,18 +8,33 @@ import { readLegacyBlob, persistSettings, } from './settingsShared' +import { setRegionalFormat } from '@/utils/format' import { + detectRegion, + isRegion, + regionHourCycle, + type HourCycle, + type Region, +} from '@/utils/region' +import { + AUTO_UNITS, DISTANCE_UNITS, FUEL_CONSUMPTION_UNITS, - METRIC_UNITS, PRESSURE_UNITS, + resolveUnits, TEMPERATURE_UNITS, - type UnitPreferences, + type UnitSettings, } from '@/utils/units' export type { Theme, Locale, VehicleTypeOverride, CarColorScheme } from './settingsShared' export { CAR_COLOR_SCHEMES } +/** The region dates, the clock and "auto" units follow: the detected one, or one chosen instead. */ +export type RegionSetting = 'auto' | Region +export type ClockSetting = 'auto' | HourCycle + +export const CLOCK_SETTINGS: readonly ClockSetting[] = ['auto', 'h23', 'h12'] + const STORAGE_KEY = 'garagestack-settings-ui' /** @@ -38,7 +53,9 @@ interface UiSettings { vehicleTypeOverride: VehicleTypeOverride filterDays: number notificationTypeExclusions: string[] - units: UnitPreferences + region: RegionSetting + clock: ClockSetting + units: UnitSettings } function defaultsFor(): UiSettings { @@ -52,9 +69,11 @@ function defaultsFor(): UiSettings { vehicleTypeOverride: 'auto', filterDays: DEFAULT_FILTER_DAYS, notificationTypeExclusions: [], - // Metric for everyone rather than guessed from the browser language: plenty of people run an - // English browser without driving in miles, and an update must not switch an install over. - units: { ...METRIC_UNITS }, + // Everything follows the region the browser is in, which its time zone tells far better than + // its language does. An install from before these settings keeps the units it stored. + region: 'auto', + clock: 'auto', + units: { ...AUTO_UNITS }, } } @@ -82,18 +101,30 @@ function positiveDays(value: unknown, fallback: number): number { : fallback } +function orAuto(allowed: readonly T[]): readonly ('auto' | T)[] { + return ['auto', ...allowed] +} + // Each unit is checked on its own, so one unknown value (a unit an older build offered, say) // falls back alone instead of resetting the others. -function parseUnits(raw: unknown, fallback: UnitPreferences): UnitPreferences { +function parseUnits(raw: unknown, fallback: UnitSettings): UnitSettings { const units = typeof raw === 'object' && raw !== null ? (raw as Record) : {} return { - distance: oneOf(units.distance, DISTANCE_UNITS, fallback.distance), - temperature: oneOf(units.temperature, TEMPERATURE_UNITS, fallback.temperature), - pressure: oneOf(units.pressure, PRESSURE_UNITS, fallback.pressure), - fuelConsumption: oneOf(units.fuelConsumption, FUEL_CONSUMPTION_UNITS, fallback.fuelConsumption), + distance: oneOf(units.distance, orAuto(DISTANCE_UNITS), fallback.distance), + temperature: oneOf(units.temperature, orAuto(TEMPERATURE_UNITS), fallback.temperature), + pressure: oneOf(units.pressure, orAuto(PRESSURE_UNITS), fallback.pressure), + fuelConsumption: oneOf( + units.fuelConsumption, + orAuto(FUEL_CONSUMPTION_UNITS), + fallback.fuelConsumption, + ), } } +function parseRegion(raw: unknown, fallback: RegionSetting): RegionSetting { + return raw === 'auto' || isRegion(raw) ? raw : fallback +} + function parseUiFields(parsed: Record, fallback: UiSettings): UiSettings { return { theme: oneOf(parsed.theme, THEMES, fallback.theme), @@ -113,6 +144,8 @@ function parseUiFields(parsed: Record, fallback: UiSettings): U notificationTypeExclusions: Array.isArray(parsed.notificationTypeExclusions) ? (parsed.notificationTypeExclusions as string[]) : fallback.notificationTypeExclusions, + region: parseRegion(parsed.region, fallback.region), + clock: oneOf(parsed.clock, CLOCK_SETTINGS, fallback.clock), units: parseUnits(parsed.units, fallback.units), } } @@ -138,6 +171,7 @@ function applyCarColors(id: string) { export const useUiSettingsStore = defineStore('settingsUi', () => { const settings = reactive(loadUiSettings()) + const detected = detectRegion() persistSettings({ storageKey: STORAGE_KEY, section: 'ui', @@ -159,5 +193,27 @@ export const useUiSettingsStore = defineStore('settingsUi', () => { ) watch(() => settings.carColorScheme, applyCarColors, { immediate: true }) - return toRefs(settings) + // Detected once per page load: what this device says, so "auto" can differ between devices + // while every choice made instead follows the account. + const detectedRegion = computed(() => detected) + const effectiveRegion = computed(() => (settings.region === 'auto' ? detected : settings.region)) + const effectiveHourCycle = computed(() => + settings.clock === 'auto' ? regionHourCycle(effectiveRegion.value) : settings.clock, + ) + const effectiveUnits = computed(() => resolveUnits(settings.units, effectiveRegion.value)) + + // Dates and times are written by plain functions (utils/format), which follow this at once. + watch( + [effectiveRegion, effectiveHourCycle], + ([region, hourCycle]) => setRegionalFormat({ region, hourCycle }), + { immediate: true }, + ) + + return { + ...toRefs(settings), + detectedRegion, + effectiveRegion, + effectiveHourCycle, + effectiveUnits, + } }) diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index bf733f2f..8b79e52b 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1,5 +1,13 @@ // Applied before every test file via vitest.config.ts setupFiles. // Defines browser APIs that jsdom does not implement. +import { vi } from 'vitest' + +// The region comes from the machine's time zone, which differs between a laptop and CI, so every +// test runs in the Netherlands. region.spec imports the real detection to test it. +vi.mock('@/utils/region', async (importOriginal) => ({ + ...(await importOriginal()), + detectRegion: () => 'NL', +})) Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/frontend/src/utils/__tests__/climateSchedule.spec.ts b/frontend/src/utils/__tests__/climateSchedule.spec.ts index ba8b691d..b9e7558f 100644 --- a/frontend/src/utils/__tests__/climateSchedule.spec.ts +++ b/frontend/src/utils/__tests__/climateSchedule.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { commandLabel, daysSummary, @@ -6,9 +6,9 @@ import { runLabel, runParts, settingsSummary, - startTimeLabel, weekdayName, } from '../climateSchedule' +import { setRegionalFormat } from '@/utils/format' import { METRIC_UNITS, UnitFormatter } from '@/utils/units' import { schedule } from '@/services/__tests__/climateScheduleFixtures' @@ -16,6 +16,11 @@ const t = (key: string, named?: Record) => named ? `${key}(${Object.values(named).join(',')})` : key const celsius = new UnitFormatter(METRIC_UNITS, (k) => (k === 'units.celsius' ? '°C' : k)) +// ICU puts a narrow no-break space before AM and PM. +const plain = (text: string) => text.replace(/\s/g, ' ') + +afterEach(() => setRegionalFormat({ region: null, hourCycle: 'h23' })) + describe('daysSummary', () => { it('names the common choices', () => { expect(daysSummary([], t)).toBe('climateSchedules.days.once') @@ -29,16 +34,11 @@ describe('daysSummary', () => { }) }) -describe('weekdayName and startTimeLabel', () => { +describe('weekdayName', () => { it('reads ISO weekdays from Monday', () => { expect(weekdayName(1)).toBe('Mon') expect(weekdayName(7, 'long')).toBe('Sunday') }) - - it('shows the start time on the interface clock', () => { - expect(startTimeLabel('07:30')).toBe('07:30 AM') - expect(startTimeLabel('18:05')).toBe('06:05 PM') - }) }) describe('runLabel', () => { @@ -46,19 +46,24 @@ describe('runLabel', () => { it('says today, tomorrow or yesterday for runs close by', () => { expect(runLabel(new Date(2026, 9, 9, 18, 0).toISOString(), now, t)).toBe( - 'climateSchedules.when.today(06:00 PM)', + 'climateSchedules.when.today(18:00)', ) expect(runLabel(new Date(2026, 9, 10, 7, 30).toISOString(), now, t)).toBe( - 'climateSchedules.when.tomorrow(07:30 AM)', + 'climateSchedules.when.tomorrow(07:30)', ) expect(runLabel(new Date(2026, 9, 8, 7, 30).toISOString(), now, t)).toBe( - 'climateSchedules.when.yesterday(07:30 AM)', + 'climateSchedules.when.yesterday(07:30)', ) }) it('names the weekday within the week, and the date beyond it', () => { - expect(runLabel(new Date(2026, 9, 12, 7, 30).toISOString(), now, t)).toBe('Monday 07:30 AM') - expect(runLabel(new Date(2026, 9, 20, 7, 30).toISOString(), now, t)).toBe('Oct 20 07:30 AM') + expect(runLabel(new Date(2026, 9, 12, 7, 30).toISOString(), now, t)).toBe('Monday 07:30') + expect(runLabel(new Date(2026, 9, 20, 7, 30).toISOString(), now, t)).toBe('Oct 20 07:30') + }) + + it('writes the date in the order of the region', () => { + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) + expect(runLabel(new Date(2026, 9, 20, 7, 30).toISOString(), now, t)).toBe('20 Oct 07:30') }) }) @@ -66,19 +71,25 @@ describe('runParts', () => { const now = new Date(2026, 9, 9, 12, 0) it('leaves the day out today, and names it otherwise', () => { + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) expect(runParts(new Date(2026, 9, 9, 18, 0).toISOString(), now)).toEqual({ - time: '6:00 PM', + time: '18:00', day: null, }) expect(runParts(new Date(2026, 9, 10, 7, 30).toISOString(), now)).toEqual({ - time: '7:30 AM', + time: '7:30', day: 'Sat', }) expect(runParts(new Date(2026, 9, 20, 7, 30).toISOString(), now)).toEqual({ - time: '7:30 AM', - day: 'Oct 20', + time: '7:30', + day: '20 Oct', }) }) + + it('shows the time on a 12-hour clock when that is the chosen one', () => { + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + expect(plain(runParts(new Date(2026, 9, 9, 18, 0).toISOString(), now).time)).toBe('6:00 PM') + }) }) describe('settingsSummary', () => { diff --git a/frontend/src/utils/__tests__/format.spec.ts b/frontend/src/utils/__tests__/format.spec.ts index 251bd1a7..32a1a1fa 100644 --- a/frontend/src/utils/__tests__/format.spec.ts +++ b/frontend/src/utils/__tests__/format.spec.ts @@ -1,10 +1,28 @@ import { describe, it, expect, afterEach } from 'vitest' import { i18n } from '@/i18n' -import { formatDate, formatNumber, formatTime } from '@/utils/format' +import { + activeHourCycle, + clockExample, + dayPeriodNames, + formatDate, + formatDateTime, + formatHour, + formatNumber, + formatTime, + formatTimeOfDay, + numberLocale, + setRegionalFormat, +} from '@/utils/format' + +// ICU puts a narrow no-break space before AM and PM. +const plain = (text: string) => text.replace(/\s/g, ' ') describe('format', () => { + const moment = new Date(2026, 8, 26, 14, 5) + afterEach(() => { i18n.global.locale.value = 'en' + setRegionalFormat({ region: null, hourCycle: 'h23' }) }) it('writes numbers the English way by default', () => { @@ -21,14 +39,82 @@ describe('format', () => { expect(formatNumber(24852.4, 0, true)).toBe('24.852') }) - it('writes dates and times in the interface language', () => { - const moment = new Date(2026, 8, 26, 14, 5) + it('keeps numbers in the interface language wherever the browser is', () => { + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) + + expect(formatNumber(12.345)).toBe('12.3') + expect(numberLocale()).toBe('en-US') + }) + it('writes dates in the interface language and times on a 24-hour clock by default', () => { expect(formatDate(moment, { month: 'long' })).toBe('September') - expect(formatTime(moment, { hour: '2-digit', minute: '2-digit' })).toBe('02:05 PM') + expect(formatTime(moment)).toBe('14:05') i18n.global.locale.value = 'nl' expect(formatDate(moment, { month: 'long' })).toBe('september') - expect(formatTime(moment, { hour: '2-digit', minute: '2-digit' })).toBe('14:05') + expect(formatTime(moment)).toBe('14:05') + }) + + it('writes the date in the order of the region', () => { + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) + expect(formatDate(moment)).toBe('26/09/2026') + expect(formatDateTime(moment)).toBe('26 Sept 2026, 14:05') + + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + expect(formatDate(moment)).toBe('9/26/2026') + + i18n.global.locale.value = 'nl' + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) + expect(formatDate(moment)).toBe('26-9-2026') + }) + + it('falls back to international English, or the language itself, for a pair Intl lacks', () => { + setRegionalFormat({ region: 'AQ', hourCycle: 'h23' }) + expect(formatDate(moment)).toBe('26/09/2026') + + i18n.global.locale.value = 'nl' + setRegionalFormat({ region: 'US', hourCycle: 'h23' }) + expect(formatDate(moment)).toBe('26-9-2026') + }) + + it('shows every time on a 12-hour clock without a leading zero when that is chosen', () => { + setRegionalFormat({ region: 'NL', hourCycle: 'h12' }) + + expect(activeHourCycle()).toBe('h12') + expect(plain(formatTime(new Date(2026, 8, 26, 9, 5)))).toBe('9:05 am') + expect(plain(formatDateTime(moment))).toBe('26 Sept 2026, 2:05 pm') + expect(plain(formatHour(7))).toBe('7:00 am') + }) + + it('writes the start of an hour on the chosen clock', () => { + expect(formatHour(7)).toBe('07:00') + expect(formatHour(17)).toBe('17:00') + }) + + it('puts a time of day kept as HH:mm on the chosen clock', () => { + expect(formatTimeOfDay('07:30')).toBe('07:30') + expect(formatTimeOfDay('18:05:00')).toBe('18:05') + + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + expect(plain(formatTimeOfDay('07:30'))).toBe('7:30 AM') + expect(plain(formatTimeOfDay('00:00'))).toBe('12:00 AM') + }) + + it('shows a time of day it cannot read as it came', () => { + expect(formatTimeOfDay('soon')).toBe('soon') + expect(formatTimeOfDay('25:00')).toBe('25:00') + }) + + it('shows an example of either clock, whichever one is chosen', () => { + expect(plain(clockExample('h12'))).toBe('2:05 PM') + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + expect(clockExample('h23')).toBe('14:05') + }) + + it('names the halves of the day in the interface language', () => { + expect(dayPeriodNames()).toEqual({ am: 'AM', pm: 'PM' }) + + i18n.global.locale.value = 'nl' + expect(dayPeriodNames()).toEqual({ am: 'a.m.', pm: 'p.m.' }) }) }) diff --git a/frontend/src/utils/__tests__/region.spec.ts b/frontend/src/utils/__tests__/region.spec.ts new file mode 100644 index 00000000..86f52c08 --- /dev/null +++ b/frontend/src/utils/__tests__/region.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest' +import { isRegion, REGIONS, regionHourCycle, regionName } from '@/utils/region' + +// test-setup pins detectRegion for every other test; this file tests the real one. +const { detectRegion } = await vi.importActual('@/utils/region') + +describe('detectRegion', () => { + it('goes by the time zone before the language, which is often a default', () => { + expect(detectRegion({ timeZone: 'Europe/Amsterdam', languages: ['en-US', 'en'] })).toBe('NL') + expect(detectRegion({ timeZone: 'Europe/London', languages: ['en-US'] })).toBe('GB') + expect(detectRegion({ timeZone: 'America/Chicago', languages: ['nl-NL'] })).toBe('US') + }) + + it('knows a renamed zone under both of its names', () => { + expect(detectRegion({ timeZone: 'Asia/Calcutta', languages: [] })).toBe('IN') + expect(detectRegion({ timeZone: 'Asia/Kolkata', languages: [] })).toBe('IN') + expect(detectRegion({ timeZone: 'Europe/Kyiv', languages: [] })).toBe('UA') + expect(detectRegion({ timeZone: 'America/Argentina/Buenos_Aires', languages: [] })).toBe('AR') + }) + + it('takes the first language that names a region when the zone says nothing', () => { + expect(detectRegion({ timeZone: 'UTC', languages: ['nl', 'en-GB', 'en-US'] })).toBe('GB') + expect(detectRegion({ timeZone: undefined, languages: ['de-AT'] })).toBe('AT') + }) + + it('falls back to where the first language is most spoken, and then to the United States', () => { + expect(detectRegion({ timeZone: 'Etc/GMT+2', languages: ['nl'] })).toBe('NL') + expect(detectRegion({ timeZone: undefined, languages: ['not a language tag'] })).toBe('US') + expect(detectRegion({ timeZone: undefined, languages: [] })).toBe('US') + }) +}) + +describe('regionHourCycle', () => { + it('reads the clock the region lives by', () => { + expect(regionHourCycle('NL')).toBe('h23') + expect(regionHourCycle('GB')).toBe('h23') + expect(regionHourCycle('DE')).toBe('h23') + expect(regionHourCycle('US')).toBe('h12') + expect(regionHourCycle('AU')).toBe('h12') + expect(regionHourCycle('IN')).toBe('h12') + }) +}) + +describe('regions', () => { + it('lists the current regions only, not codes that were retired', () => { + expect(REGIONS).toContain('NL') + expect(REGIONS).toContain('US') + expect(REGIONS).not.toContain('UK') + expect(REGIONS).not.toContain('SU') + }) + + it('recognises a region code and nothing else', () => { + expect(isRegion('NL')).toBe(true) + expect(isRegion('XX')).toBe(false) + expect(isRegion('constructor')).toBe(false) + expect(isRegion(undefined)).toBe(false) + }) + + it('names a region in the interface language', () => { + expect(regionName('NL', 'en')).toBe('Netherlands') + expect(regionName('NL', 'nl')).toBe('Nederland') + }) +}) diff --git a/frontend/src/utils/__tests__/statistics.spec.ts b/frontend/src/utils/__tests__/statistics.spec.ts index 4ce71f8d..7dfa0aeb 100644 --- a/frontend/src/utils/__tests__/statistics.spec.ts +++ b/frontend/src/utils/__tests__/statistics.spec.ts @@ -38,8 +38,8 @@ describe('trip statistics', () => { tripSummary({ startedAt }), ) - expect(peakDriveHour(trips)).toBe('17:00') - expect(peakDriveHour([tripSummary({ startedAt: at(7) })])).toBe('07:00') + expect(peakDriveHour(trips)).toBe(17) + expect(peakDriveHour([tripSummary({ startedAt: at(7) })])).toBe(7) }) it('counts a spot visited again once and skips trips without an end', () => { diff --git a/frontend/src/utils/__tests__/tripLogCsv.spec.ts b/frontend/src/utils/__tests__/tripLogCsv.spec.ts index 1f861990..ea6cadde 100644 --- a/frontend/src/utils/__tests__/tripLogCsv.spec.ts +++ b/frontend/src/utils/__tests__/tripLogCsv.spec.ts @@ -1,4 +1,6 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' +import { i18n } from '@/i18n' +import { setRegionalFormat } from '@/utils/format' import { deventer, logEntry, zwolle } from '@/services/__tests__/tripLogFixtures' import { tripLogCsv, tripLogFileName } from '../tripLogCsv' import { METRIC_UNITS, UnitFormatter } from '../units' @@ -15,6 +17,11 @@ function lines(csv: string): string[] { } describe('tripLogCsv', () => { + afterEach(() => { + i18n.global.locale.value = 'en' + setRegionalFormat({ region: null, hourCycle: 'h23' }) + }) + const entry = logEntry({ startedAt: new Date(2026, 8, 24, 14, 32).toISOString(), endedAt: new Date(2026, 8, 24, 15, 6).toISOString(), @@ -25,9 +32,8 @@ describe('tripLogCsv', () => { }) it('writes a header, then one line per trip in the columns a trip log asks for', () => { - const [header, row] = lines( - tripLogCsv([entry], { locale: 'en-US', t, units, placesShown: true }), - ) + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) + const [header, row] = lines(tripLogCsv([entry], { t, units, placesShown: true })) expect(header).toBe( [ @@ -44,14 +50,21 @@ describe('tripLogCsv', () => { ].join(','), ) expect(row).toBe( - '9/24/2026,02:32 PM,03:06 PM,"Grote Markt 1, 8011 PK Zwolle","Brink 2, 7411 BT Deventer",24010,24052.5,42.5,tripLog.purpose.business,Client visit', + '24/09/2026,14:32,15:06,"Grote Markt 1, 8011 PK Zwolle","Brink 2, 7411 BT Deventer",24010,24052.5,42.5,tripLog.purpose.business,Client visit', ) }) + it('writes the date and times as the region and clock have them', () => { + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + const [, row] = lines(tripLogCsv([entry], { t, units, placesShown: true })) + + // ICU puts a narrow no-break space before PM. + const cells = row!.split(',').map((cell) => cell.replace(/\s/g, ' ')) + expect(cells.slice(0, 3)).toEqual(['9/24/2026', '2:32 PM', '3:06 PM']) + }) + it('writes the distances in miles, and says so in the headers, for a log read in miles', () => { - const [header, row] = lines( - tripLogCsv([entry], { locale: 'en-US', t, units: miles, placesShown: true }), - ) + const [header, row] = lines(tripLogCsv([entry], { t, units: miles, placesShown: true })) expect(header!.split(',').slice(5, 8)).toEqual([ 'tripLog.csv.odometerStart(units.mi)', @@ -62,7 +75,9 @@ describe('tripLogCsv', () => { }) it('follows Dutch spreadsheet conventions in Dutch', () => { - const [, row] = lines(tripLogCsv([entry], { locale: 'nl-NL', t, units, placesShown: true })) + i18n.global.locale.value = 'nl' + setRegionalFormat({ region: 'NL', hourCycle: 'h23' }) + const [, row] = lines(tripLogCsv([entry], { t, units, placesShown: true })) expect(row).toBe( '24-9-2026;14:32;15:06;Grote Markt 1, 8011 PK Zwolle;Brink 2, 7411 BT Deventer;24010;24052,5;42,5;tripLog.purpose.business;Client visit', @@ -70,9 +85,9 @@ describe('tripLogCsv', () => { }) it('writes coordinates when place names are switched off, and a blank for no purpose', () => { + i18n.global.locale.value = 'nl' const [, row] = lines( tripLogCsv([{ ...entry, purpose: null, notes: null }], { - locale: 'nl-NL', t, units, placesShown: false, @@ -87,7 +102,6 @@ describe('tripLogCsv', () => { it('rounds the distance to a decimal', () => { const [, row] = lines( tripLogCsv([logEntry({ odometerStartKm: null, distanceKm: 12.3456 })], { - locale: 'en-US', t, units, placesShown: false, diff --git a/frontend/src/utils/__tests__/tripRows.spec.ts b/frontend/src/utils/__tests__/tripRows.spec.ts index aefcfc6d..7dbc5c89 100644 --- a/frontend/src/utils/__tests__/tripRows.spec.ts +++ b/frontend/src/utils/__tests__/tripRows.spec.ts @@ -1,4 +1,6 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' +import { i18n } from '@/i18n' +import { setRegionalFormat } from '@/utils/format' import { buildTripRow, formatTripDuration, type TripRowContext } from '../tripRows' import { trip as makeTrip } from '@/services/__tests__/tripFixtures' import { METRIC_UNITS, UnitFormatter } from '@/utils/units' @@ -21,7 +23,6 @@ function context(overrides: Partial = {}): TripRowContext { toCity: null, canResolve: true, resolving: false, - locale: 'en-US', t, units: new UnitFormatter(METRIC_UNITS, t), ...overrides, @@ -29,6 +30,11 @@ function context(overrides: Partial = {}): TripRowContext { } describe('buildTripRow', () => { + afterEach(() => { + i18n.global.locale.value = 'en' + setRegionalFormat({ region: null, hourCycle: 'h23' }) + }) + it('leads with the route once both cities are known', () => { const row = buildTripRow(trip(), context({ fromCity: 'Zwolle', toCity: 'Deventer' })) @@ -115,12 +121,20 @@ describe('buildTripRow', () => { expect(row.metaTitle).toContain(row.timeLabel) }) - it('formats the date and time in the given locale', () => { - const dutch = buildTripRow(trip(), context({ locale: 'nl-NL' })) - const english = buildTripRow(trip(), context({ locale: 'en-US' })) + it('formats the date in the interface language', () => { + const english = buildTripRow(trip(), context()) + i18n.global.locale.value = 'nl' + const dutch = buildTripRow(trip(), context()) expect(dutch.dateLabel).not.toBe(english.dateLabel) }) + + it('gives the time on the chosen clock', () => { + expect(buildTripRow(trip(), context()).timeLabel).toMatch(/^\d{2}:\d{2}$/) + + setRegionalFormat({ region: 'US', hourCycle: 'h12' }) + expect(buildTripRow(trip(), context()).timeLabel).toMatch(/^\d{1,2}:\d{2}\s[AP]M$/) + }) }) describe('formatTripDuration', () => { diff --git a/frontend/src/utils/__tests__/units.spec.ts b/frontend/src/utils/__tests__/units.spec.ts index fd444c1d..1efe2ea8 100644 --- a/frontend/src/utils/__tests__/units.spec.ts +++ b/frontend/src/utils/__tests__/units.spec.ts @@ -4,11 +4,14 @@ import en from '@/locales/en.json' import nl from '@/locales/nl.json' import { i18n as appI18n } from '@/i18n' import { + AUTO_UNITS, DISTANCE_UNITS, FUEL_CONSUMPTION_UNITS, METRIC_UNITS, ODOMETER_FORMAT, PRESSURE_UNITS, + regionUnits, + resolveUnits, TEMPERATURE_UNITS, UnitFormatter, type Quantity, @@ -154,3 +157,45 @@ describe('UnitFormatter', () => { expect(Object.keys(messages.settings.units.options).sort()).toEqual([...units].sort()) }) }) + +describe('regionUnits', () => { + it('is metric with bar on most of the map', () => { + expect(regionUnits('NL')).toEqual(METRIC_UNITS) + expect(regionUnits('DE')).toEqual(METRIC_UNITS) + }) + + it('counts in miles, Fahrenheit, psi and US gallons in the United States', () => { + expect(regionUnits('US')).toEqual({ + distance: 'mi', + temperature: 'fahrenheit', + pressure: 'psi', + fuelConsumption: 'mpgUs', + }) + }) + + it('counts in miles, psi and UK gallons but Celsius in the United Kingdom', () => { + expect(regionUnits('GB')).toEqual({ + distance: 'mi', + temperature: 'celsius', + pressure: 'psi', + fuelConsumption: 'mpgUk', + }) + }) + + it('pumps tyres in psi in Australia and in kPa in Japan, with everything else metric', () => { + expect(regionUnits('AU')).toEqual({ ...METRIC_UNITS, pressure: 'psi' }) + expect(regionUnits('JP')).toEqual({ ...METRIC_UNITS, pressure: 'kpa' }) + }) +}) + +describe('resolveUnits', () => { + it('takes each unit left on automatic from the region and keeps every chosen one', () => { + expect(resolveUnits(AUTO_UNITS, 'US')).toEqual(regionUnits('US')) + expect(resolveUnits({ ...AUTO_UNITS, distance: 'km', pressure: 'bar' }, 'US')).toEqual({ + distance: 'km', + temperature: 'fahrenheit', + pressure: 'bar', + fuelConsumption: 'mpgUs', + }) + }) +}) diff --git a/frontend/src/utils/climateSchedule.ts b/frontend/src/utils/climateSchedule.ts index 8945eee1..24400b29 100644 --- a/frontend/src/utils/climateSchedule.ts +++ b/frontend/src/utils/climateSchedule.ts @@ -12,8 +12,6 @@ export const WEEKDAYS: readonly IsoWeekday[] = [1, 2, 3, 4, 5, 6, 7] export const WORKWEEK: readonly IsoWeekday[] = [1, 2, 3, 4, 5] export const WEEKEND: readonly IsoWeekday[] = [6, 7] -const TIME_FORMAT: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' } - /** The name of an ISO weekday in the interface language. 1 January 2024 was a Monday. */ export function weekdayName(day: IsoWeekday, style: 'short' | 'long' = 'short'): string { return formatDate(new Date(Date.UTC(2024, 0, day)), { weekday: style, timeZone: 'UTC' }) @@ -35,12 +33,6 @@ export function daysSummary(days: readonly IsoWeekday[], t: Translate): string { .join(', ') } -/** A schedule's "HH:mm" start time on the interface's clock (12-hour in English). */ -export function startTimeLabel(startTime: string): string { - const [hours = 0, minutes = 0] = startTime.split(':').map(Number) - return formatTime(new Date(2024, 0, 1, hours, minutes), TIME_FORMAT) -} - function localDay(date: Date): number { return Math.round( new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 86_400_000, @@ -50,7 +42,7 @@ function localDay(date: Date): number { /** When a run is or was, from where the browser stands: "Today 07:30", "Tomorrow 07:30", "Monday 07:30" or a date. */ export function runLabel(iso: string, now: Date, t: Translate): string { const at = new Date(iso) - const time = formatTime(at, TIME_FORMAT) + const time = formatTime(at) const days = localDay(at) - localDay(now) if (days === 0) return t('climateSchedules.when.today', { time }) if (days === 1) return t('climateSchedules.when.tomorrow', { time }) diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts index 95e4ff09..72573dfa 100644 --- a/frontend/src/utils/dates.ts +++ b/frontend/src/utils/dates.ts @@ -1,5 +1,16 @@ const MS_PER_DAY = 86_400_000 +/** + * The local calendar day as "YYYY-MM-DD", which is also what a date input takes. Not + * toISOString(): that is the day in UTC, which in Europe is still yesterday just after midnight. + */ +export function localDateKey(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + /** * ISO timestamp for exactly `days` days before now. The "from" bound the dashboard and map use * for their trip queries. diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index e535073e..1c396403 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -1,18 +1,78 @@ +import { shallowRef } from 'vue' import type { Locale } from '@/stores/settingsShared' import { i18n } from '@/i18n' +import type { HourCycle, Region } from '@/utils/region' /** - * The Intl locale behind an interface language, for dates and times: the interface language - * names only a language, and Intl needs a region to pick a date order and a clock. + * The Intl locale behind an interface language when no region is known: the interface language + * names only a language, and Intl needs a region to pick a date order. */ -export function intlLocale(locale: Locale): string { +function languageLocale(locale: Locale): string { return locale === 'nl' ? 'nl-NL' : 'en-US' } // Read through the i18n instance's reactive locale, so a computed or template that formats a // value runs again when the interface language changes. -function activeLocale(): string { - return intlLocale(i18n.global.locale.value as Locale) +function activeLanguage(): Locale { + return i18n.global.locale.value as Locale +} + +/** + * The locale numbers are written in: the interface language's, so English reads "12.3" wherever + * the browser is. Charts and the CSV export take it too, so their numbers match the page. + */ +export function numberLocale(): string { + return languageLocale(activeLanguage()) +} + +/** The region and clock dates and times follow, as the UI settings store settles them. */ +export interface RegionalFormat { + region: Region | null + hourCycle: HourCycle +} + +// Until the UI settings store hands over its region and clock (and in tests that never create +// the store), the interface language's own date order on a 24-hour clock. +const regional = shallowRef({ region: null, hourCycle: 'h23' }) + +export function setRegionalFormat(format: RegionalFormat): void { + regional.value = format +} + +/** The clock every time on the page is shown on. */ +export function activeHourCycle(): HourCycle { + return regional.value.hourCycle +} + +const regionalLocales = new Map() + +/** + * The locale for dates and times: the interface language as written in the region, so English in + * the Netherlands reads "09/10/2026" and English in the United States "10/9/2026". A pair Intl + * has no conventions for falls back to international English, or to the language's own. + */ +function dateLocale(): string { + const language = activeLanguage() + const { region } = regional.value + if (region === null) return languageLocale(language) + const tag = `${language}-${region}` + let locale = regionalLocales.get(tag) + if (locale === undefined) { + const supported = new Intl.DateTimeFormat(tag).resolvedOptions().locale === tag + locale = supported ? tag : language === 'en' ? 'en-001' : languageLocale(language) + regionalLocales.set(tag, locale) + } + return locale +} + +/** The options on a clock, by default the chosen one, without a leading zero on 12 hours ("9:05 AM"). */ +function onClock( + options: Intl.DateTimeFormatOptions, + hourCycle: HourCycle = activeHourCycle(), +): Intl.DateTimeFormatOptions { + const withClock = { ...options, hourCycle } + if (hourCycle === 'h12' && options.hour === '2-digit') withClock.hour = 'numeric' + return withClock } /** @@ -20,7 +80,7 @@ function activeLocale(): string { * in Dutch. Thousands are grouped only when asked for, as on the odometer. */ export function formatNumber(value: number, decimals = 1, grouped = false): string { - return value.toLocaleString(activeLocale(), { + return value.toLocaleString(numberLocale(), { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: grouped, @@ -31,17 +91,62 @@ function toDate(value: string | Date): Date { return typeof value === 'string' ? new Date(value) : value } -/** A calendar date in the interface language. */ +const TIME: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' } +const DATE_TIME: Intl.DateTimeFormatOptions = { + day: 'numeric', + month: 'short', + year: 'numeric', + ...TIME, +} + +/** A calendar date in the interface language, in the region's date order. */ export function formatDate(value: string | Date, options?: Intl.DateTimeFormatOptions): string { - return toDate(value).toLocaleDateString(activeLocale(), options) + return toDate(value).toLocaleDateString(dateLocale(), options) +} + +/** A time of day on the chosen clock: "14:05" or "2:05 PM". */ +export function formatTime( + value: string | Date, + options: Intl.DateTimeFormatOptions = TIME, +): string { + return toDate(value).toLocaleTimeString(dateLocale(), onClock(options)) +} + +/** A date and a time of day, on the chosen clock: "9 Oct 2026, 14:05". */ +export function formatDateTime( + value: string | Date, + options: Intl.DateTimeFormatOptions = DATE_TIME, +): string { + return toDate(value).toLocaleString(dateLocale(), onClock(options)) +} + +/** The start of an hour of the day on the chosen clock: "14:00" or "2:00 PM". */ +export function formatHour(hour: number): string { + return formatTime(new Date(2024, 0, 1, hour)) +} + +/** + * A time of day kept as "HH:mm" (a climate schedule, or a schedule the gateway reports) on the + * chosen clock. Anything else is shown as it came, since the gateway's text is not ours to trust. + */ +export function formatTimeOfDay(hhmm: string): string { + const match = /^(\d{1,2}):(\d{2})/.exec(hhmm) + const hours = Number(match?.[1]) + const minutes = Number(match?.[2]) + if (!match || hours > 23 || minutes > 59) return hhmm + return formatTime(new Date(2024, 0, 1, hours, minutes)) } -/** A time of day in the interface language, with its own clock (24-hour in Dutch). */ -export function formatTime(value: string | Date, options?: Intl.DateTimeFormatOptions): string { - return toDate(value).toLocaleTimeString(activeLocale(), options) +/** An afternoon time on the given clock, whichever is chosen, for a settings choice to show: "14:05". */ +export function clockExample(hourCycle: HourCycle): string { + return new Date(2024, 0, 1, 14, 5).toLocaleTimeString(dateLocale(), onClock(TIME, hourCycle)) } -/** A date and time in the interface language. */ -export function formatDateTime(value: string | Date, options?: Intl.DateTimeFormatOptions): string { - return toDate(value).toLocaleString(activeLocale(), options) +/** What the 12-hour clock calls the morning and the afternoon in the interface language. */ +export function dayPeriodNames(): { am: string; pm: string } { + const format = new Intl.DateTimeFormat(dateLocale(), { hour: 'numeric', hourCycle: 'h12' }) + const name = (hour: number) => + format.formatToParts(new Date(2024, 0, 1, hour)).find((p) => p.type === 'dayPeriod')?.value ?? + '' + return { am: name(1), pm: name(13) } } diff --git a/frontend/src/utils/region.ts b/frontend/src/utils/region.ts new file mode 100644 index 00000000..1dcc772a --- /dev/null +++ b/frontend/src/utils/region.ts @@ -0,0 +1,98 @@ +import { TIME_ZONE_REGIONS } from '@/utils/timeZoneRegions' + +/** An ISO 3166 region code, such as "NL". */ +export type Region = string + +/** How a region reads the clock: 0 to 23, or 1 to 12 with AM and PM. */ +export type HourCycle = 'h23' | 'h12' + +/** Every region with a time zone of its own, which is every region a browser can be in. */ +export const REGIONS: readonly Region[] = Object.keys(TIME_ZONE_REGIONS) + +const KNOWN_REGIONS = new Set(REGIONS) + +export function isRegion(value: unknown): value is Region { + return typeof value === 'string' && KNOWN_REGIONS.has(value) +} + +/** What a browser tells about where it is: its time zone, and its languages, most preferred first. */ +export interface ClientSignals { + timeZone: string | undefined + languages: readonly string[] +} + +export function clientSignals(): ClientSignals { + let timeZone: string | undefined + try { + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + } catch { + timeZone = undefined + } + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + return { timeZone, languages } +} + +let regionOfZone: Map | null = null + +function regionOfTimeZone(zone: string): Region | null { + regionOfZone ??= new Map( + Object.entries(TIME_ZONE_REGIONS).flatMap(([region, zones]) => + zones.split(' ').map((z): [string, Region] => [z, region]), + ), + ) + return regionOfZone.get(zone) ?? null +} + +function parsedLocale(tag: string): Intl.Locale | null { + try { + return new Intl.Locale(tag) + } catch { + return null + } +} + +/** + * The region whose conventions this browser most likely lives by. The time zone comes first: the + * system sets it from where the device is, while the language is often left at a default such as + * en-US that says nothing about where its user drives. A region named in one of the languages + * comes next, then the region the first language is most spoken in. + */ +export function detectRegion(signals: ClientSignals = clientSignals()): Region { + const fromZone = signals.timeZone ? regionOfTimeZone(signals.timeZone) : null + if (fromZone) return fromZone + for (const tag of signals.languages) { + const region = parsedLocale(tag)?.region + if (isRegion(region)) return region + } + const likely = parsedLocale(signals.languages[0] ?? 'en')?.maximize().region + return isRegion(likely) ? likely : 'US' +} + +/** + * The clock a region reads, from the conventions of the language most spoken there: a 24-hour + * clock in Britain and the Netherlands, 12 hours in the United States and Australia. + */ +export function regionHourCycle(region: Region): HourCycle { + // maximize() adds a script ("en-Latn-GB"), which no set of conventions is filed under. + const language = parsedLocale(`und-${region}`)?.maximize().language ?? 'en' + const { hour12 } = new Intl.DateTimeFormat(`${language}-${region}`, { + hour: 'numeric', + }).resolvedOptions() + return hour12 ? 'h12' : 'h23' +} + +const displayNames = new Map() + +/** The region's name in the given language: "Netherlands", "Nederland". */ +export function regionName(region: Region, language: string): string { + try { + let names = displayNames.get(language) + if (!names) { + names = new Intl.DisplayNames([language], { type: 'region' }) + displayNames.set(language, names) + } + return names.of(region) ?? region + } catch { + return region + } +} diff --git a/frontend/src/utils/statistics.ts b/frontend/src/utils/statistics.ts index ea1fe0c9..9a0abbf5 100644 --- a/frontend/src/utils/statistics.ts +++ b/frontend/src/utils/statistics.ts @@ -1,4 +1,5 @@ import type { TelemetryHistoryPoint, TripSummary } from '@/services/vehicleApi' +import { localDateKey } from '@/utils/dates' import { dailyCounterTotal } from '@/utils/energy' /** @@ -22,8 +23,8 @@ export function averageTripKm(trips: readonly TripSummary[]): number | null { return total === null ? null : total / trips.length } -/** The local hour most trips started in, as "HH:00". On a tie, the hour an earlier trip started in wins. */ -export function peakDriveHour(trips: readonly TripSummary[]): string | null { +/** The local hour (0 to 23) most trips started in. On a tie, the hour an earlier trip started in wins. */ +export function peakDriveHour(trips: readonly TripSummary[]): number | null { if (trips.length === 0) return null const counts = new Map() for (const trip of trips) { @@ -38,7 +39,7 @@ export function peakDriveHour(trips: readonly TripSummary[]): string | null { bestCount = count } } - return `${String(bestHour).padStart(2, '0')}:00` + return bestHour } /** @@ -83,13 +84,6 @@ export interface HistoryDay { points: TelemetryHistoryPoint[] } -export function localDateKey(date: Date): string { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - return `${y}-${m}-${d}` -} - /** * The period's readings by local day, one entry for every day from `days` ago up to today even * when the car sent nothing that day, so a chart's x axis spans the whole period evenly. diff --git a/frontend/src/utils/timeZoneRegions.ts b/frontend/src/utils/timeZoneRegions.ts new file mode 100644 index 00000000..a1ac5b1c --- /dev/null +++ b/frontend/src/utils/timeZoneRegions.ts @@ -0,0 +1,252 @@ +// Generated by scripts/gen-time-zone-regions.mjs from ICU 78.3. Do not edit by hand. + +/** Each region's IANA time zones, space separated, under both spellings of a renamed zone. */ +export const TIME_ZONE_REGIONS: Readonly> = { + AD: 'Europe/Andorra', + AE: 'Asia/Dubai', + AF: 'Asia/Kabul', + AG: 'America/Antigua', + AI: 'America/Anguilla', + AL: 'Europe/Tirane', + AM: 'Asia/Yerevan', + AO: 'Africa/Luanda', + AQ: 'Antarctica/Casey Antarctica/Davis Antarctica/DumontDUrville Antarctica/Mawson Antarctica/McMurdo Antarctica/Palmer Antarctica/Rothera Antarctica/Syowa Antarctica/Troll Antarctica/Vostok', + AR: 'America/Argentina/Buenos_Aires America/Argentina/Catamarca America/Argentina/Cordoba America/Argentina/Jujuy America/Argentina/La_Rioja America/Argentina/Mendoza America/Argentina/Rio_Gallegos America/Argentina/Salta America/Argentina/San_Juan America/Argentina/San_Luis America/Argentina/Tucuman America/Argentina/Ushuaia America/Buenos_Aires America/Catamarca America/Cordoba America/Jujuy America/Mendoza', + AS: 'Pacific/Pago_Pago', + AT: 'Europe/Vienna', + AU: 'Antarctica/Macquarie Australia/Adelaide Australia/Brisbane Australia/Broken_Hill Australia/Darwin Australia/Eucla Australia/Hobart Australia/Lindeman Australia/Lord_Howe Australia/Melbourne Australia/Perth Australia/Sydney', + AW: 'America/Aruba', + AX: 'Europe/Mariehamn', + AZ: 'Asia/Baku', + BA: 'Europe/Sarajevo', + BB: 'America/Barbados', + BD: 'Asia/Dhaka', + BE: 'Europe/Brussels', + BF: 'Africa/Ouagadougou', + BG: 'Europe/Sofia', + BH: 'Asia/Bahrain', + BI: 'Africa/Bujumbura', + BJ: 'Africa/Porto-Novo', + BL: 'America/St_Barthelemy', + BM: 'Atlantic/Bermuda', + BN: 'Asia/Brunei', + BO: 'America/La_Paz', + BQ: 'America/Kralendijk', + BR: 'America/Araguaina America/Bahia America/Belem America/Boa_Vista America/Campo_Grande America/Cuiaba America/Eirunepe America/Fortaleza America/Maceio America/Manaus America/Noronha America/Porto_Velho America/Recife America/Rio_Branco America/Santarem America/Sao_Paulo', + BS: 'America/Nassau', + BT: 'Asia/Thimphu', + BW: 'Africa/Gaborone', + BY: 'Europe/Minsk', + BZ: 'America/Belize', + CA: 'America/Atikokan America/Blanc-Sablon America/Cambridge_Bay America/Coral_Harbour America/Creston America/Dawson America/Dawson_Creek America/Edmonton America/Fort_Nelson America/Glace_Bay America/Goose_Bay America/Halifax America/Inuvik America/Iqaluit America/Moncton America/Rankin_Inlet America/Regina America/Resolute America/St_Johns America/Swift_Current America/Toronto America/Vancouver America/Whitehorse America/Winnipeg', + CC: 'Indian/Cocos', + CD: 'Africa/Kinshasa Africa/Lubumbashi', + CF: 'Africa/Bangui', + CG: 'Africa/Brazzaville', + CH: 'Europe/Zurich', + CI: 'Africa/Abidjan', + CK: 'Pacific/Rarotonga', + CL: 'America/Coyhaique America/Punta_Arenas America/Santiago Pacific/Easter', + CM: 'Africa/Douala', + CN: 'Asia/Shanghai Asia/Urumqi', + CO: 'America/Bogota', + CR: 'America/Costa_Rica', + CU: 'America/Havana', + CV: 'Atlantic/Cape_Verde', + CW: 'America/Curacao', + CX: 'Indian/Christmas', + CY: 'Asia/Famagusta Asia/Nicosia', + CZ: 'Europe/Prague', + DE: 'Europe/Berlin Europe/Busingen', + DJ: 'Africa/Djibouti', + DK: 'Europe/Copenhagen', + DM: 'America/Dominica', + DO: 'America/Santo_Domingo', + DZ: 'Africa/Algiers', + EC: 'America/Guayaquil Pacific/Galapagos', + EE: 'Europe/Tallinn', + EG: 'Africa/Cairo', + EH: 'Africa/El_Aaiun', + ER: 'Africa/Asmara Africa/Asmera', + ES: 'Africa/Ceuta Atlantic/Canary Europe/Madrid', + ET: 'Africa/Addis_Ababa', + FI: 'Europe/Helsinki', + FJ: 'Pacific/Fiji', + FK: 'Atlantic/Stanley', + FM: 'Pacific/Chuuk Pacific/Kosrae Pacific/Pohnpei Pacific/Ponape Pacific/Truk', + FO: 'Atlantic/Faeroe Atlantic/Faroe', + FR: 'Europe/Paris', + GA: 'Africa/Libreville', + GB: 'Europe/London', + GD: 'America/Grenada', + GE: 'Asia/Tbilisi', + GF: 'America/Cayenne', + GG: 'Europe/Guernsey', + GH: 'Africa/Accra', + GI: 'Europe/Gibraltar', + GL: 'America/Danmarkshavn America/Godthab America/Nuuk America/Scoresbysund America/Thule', + GM: 'Africa/Banjul', + GN: 'Africa/Conakry', + GP: 'America/Guadeloupe', + GQ: 'Africa/Malabo', + GR: 'Europe/Athens', + GS: 'Atlantic/South_Georgia', + GT: 'America/Guatemala', + GU: 'Pacific/Guam', + GW: 'Africa/Bissau', + GY: 'America/Guyana', + HK: 'Asia/Hong_Kong', + HN: 'America/Tegucigalpa', + HR: 'Europe/Zagreb', + HT: 'America/Port-au-Prince', + HU: 'Europe/Budapest', + ID: 'Asia/Jakarta Asia/Jayapura Asia/Makassar Asia/Pontianak', + IE: 'Europe/Dublin', + IL: 'Asia/Jerusalem', + IM: 'Europe/Isle_of_Man', + IN: 'Asia/Calcutta Asia/Kolkata', + IO: 'Indian/Chagos', + IQ: 'Asia/Baghdad', + IR: 'Asia/Tehran', + IS: 'Atlantic/Reykjavik', + IT: 'Europe/Rome', + JE: 'Europe/Jersey', + JM: 'America/Jamaica', + JO: 'Asia/Amman', + JP: 'Asia/Tokyo', + KE: 'Africa/Nairobi', + KG: 'Asia/Bishkek', + KH: 'Asia/Phnom_Penh', + KI: 'Pacific/Enderbury Pacific/Kanton Pacific/Kiritimati Pacific/Tarawa', + KM: 'Indian/Comoro', + KN: 'America/St_Kitts', + KP: 'Asia/Pyongyang', + KR: 'Asia/Seoul', + KW: 'Asia/Kuwait', + KY: 'America/Cayman', + KZ: 'Asia/Almaty Asia/Aqtau Asia/Aqtobe Asia/Atyrau Asia/Oral Asia/Qostanay Asia/Qyzylorda', + LA: 'Asia/Vientiane', + LB: 'Asia/Beirut', + LC: 'America/St_Lucia', + LI: 'Europe/Vaduz', + LK: 'Asia/Colombo', + LR: 'Africa/Monrovia', + LS: 'Africa/Maseru', + LT: 'Europe/Vilnius', + LU: 'Europe/Luxembourg', + LV: 'Europe/Riga', + LY: 'Africa/Tripoli', + MA: 'Africa/Casablanca', + MC: 'Europe/Monaco', + MD: 'Europe/Chisinau', + ME: 'Europe/Podgorica', + MF: 'America/Marigot', + MG: 'Indian/Antananarivo', + MH: 'Pacific/Kwajalein Pacific/Majuro', + MK: 'Europe/Skopje', + ML: 'Africa/Bamako', + MM: 'Asia/Rangoon Asia/Yangon', + MN: 'Asia/Hovd Asia/Ulaanbaatar', + MO: 'Asia/Macau', + MP: 'Pacific/Saipan', + MQ: 'America/Martinique', + MR: 'Africa/Nouakchott', + MS: 'America/Montserrat', + MT: 'Europe/Malta', + MU: 'Indian/Mauritius', + MV: 'Indian/Maldives', + MW: 'Africa/Blantyre', + MX: 'America/Bahia_Banderas America/Cancun America/Chihuahua America/Ciudad_Juarez America/Hermosillo America/Matamoros America/Mazatlan America/Merida America/Mexico_City America/Monterrey America/Ojinaga America/Tijuana', + MY: 'Asia/Kuala_Lumpur Asia/Kuching', + MZ: 'Africa/Maputo', + NA: 'Africa/Windhoek', + NC: 'Pacific/Noumea', + NE: 'Africa/Niamey', + NF: 'Pacific/Norfolk', + NG: 'Africa/Lagos', + NI: 'America/Managua', + NL: 'Europe/Amsterdam', + NO: 'Europe/Oslo', + NP: 'Asia/Kathmandu Asia/Katmandu', + NR: 'Pacific/Nauru', + NU: 'Pacific/Niue', + NZ: 'Pacific/Auckland Pacific/Chatham', + OM: 'Asia/Muscat', + PA: 'America/Panama', + PE: 'America/Lima', + PF: 'Pacific/Gambier Pacific/Marquesas Pacific/Tahiti', + PG: 'Pacific/Bougainville Pacific/Port_Moresby', + PH: 'Asia/Manila', + PK: 'Asia/Karachi', + PL: 'Europe/Warsaw', + PM: 'America/Miquelon', + PN: 'Pacific/Pitcairn', + PR: 'America/Puerto_Rico', + PS: 'Asia/Gaza Asia/Hebron', + PT: 'Atlantic/Azores Atlantic/Madeira Europe/Lisbon', + PW: 'Pacific/Palau', + PY: 'America/Asuncion', + QA: 'Asia/Qatar', + RE: 'Indian/Reunion', + RO: 'Europe/Bucharest', + RS: 'Europe/Belgrade', + RU: 'Asia/Anadyr Asia/Barnaul Asia/Chita Asia/Irkutsk Asia/Kamchatka Asia/Khandyga Asia/Krasnoyarsk Asia/Magadan Asia/Novokuznetsk Asia/Novosibirsk Asia/Omsk Asia/Sakhalin Asia/Srednekolymsk Asia/Tomsk Asia/Ust-Nera Asia/Vladivostok Asia/Yakutsk Asia/Yekaterinburg Europe/Astrakhan Europe/Kaliningrad Europe/Kirov Europe/Moscow Europe/Samara Europe/Saratov Europe/Ulyanovsk Europe/Volgograd', + RW: 'Africa/Kigali', + SA: 'Asia/Riyadh', + SB: 'Pacific/Guadalcanal', + SC: 'Indian/Mahe', + SD: 'Africa/Khartoum', + SE: 'Europe/Stockholm', + SG: 'Asia/Singapore', + SH: 'Atlantic/St_Helena', + SI: 'Europe/Ljubljana', + SJ: 'Arctic/Longyearbyen', + SK: 'Europe/Bratislava', + SL: 'Africa/Freetown', + SM: 'Europe/San_Marino', + SN: 'Africa/Dakar', + SO: 'Africa/Mogadishu', + SR: 'America/Paramaribo', + SS: 'Africa/Juba', + ST: 'Africa/Sao_Tome', + SV: 'America/El_Salvador', + SX: 'America/Lower_Princes', + SY: 'Asia/Damascus', + SZ: 'Africa/Mbabane', + TC: 'America/Grand_Turk', + TD: 'Africa/Ndjamena', + TF: 'Indian/Kerguelen', + TG: 'Africa/Lome', + TH: 'Asia/Bangkok', + TJ: 'Asia/Dushanbe', + TK: 'Pacific/Fakaofo', + TL: 'Asia/Dili', + TM: 'Asia/Ashgabat', + TN: 'Africa/Tunis', + TO: 'Pacific/Tongatapu', + TR: 'Europe/Istanbul', + TT: 'America/Port_of_Spain', + TV: 'Pacific/Funafuti', + TW: 'Asia/Taipei', + TZ: 'Africa/Dar_es_Salaam', + UA: 'Europe/Kiev Europe/Kyiv Europe/Simferopol', + UG: 'Africa/Kampala', + UM: 'Pacific/Midway Pacific/Wake', + US: 'America/Adak America/Anchorage America/Boise America/Chicago America/Denver America/Detroit America/Indiana/Indianapolis America/Indiana/Knox America/Indiana/Marengo America/Indiana/Petersburg America/Indiana/Tell_City America/Indiana/Vevay America/Indiana/Vincennes America/Indiana/Winamac America/Indianapolis America/Juneau America/Kentucky/Louisville America/Kentucky/Monticello America/Los_Angeles America/Louisville America/Menominee America/Metlakatla America/New_York America/Nome America/North_Dakota/Beulah America/North_Dakota/Center America/North_Dakota/New_Salem America/Phoenix America/Sitka America/Yakutat Pacific/Honolulu', + UY: 'America/Montevideo', + UZ: 'Asia/Samarkand Asia/Tashkent', + VA: 'Europe/Vatican', + VC: 'America/St_Vincent', + VE: 'America/Caracas', + VG: 'America/Tortola', + VI: 'America/St_Thomas', + VN: 'Asia/Ho_Chi_Minh Asia/Saigon', + VU: 'Pacific/Efate', + WF: 'Pacific/Wallis', + WS: 'Pacific/Apia', + YE: 'Asia/Aden', + YT: 'Indian/Mayotte', + ZA: 'Africa/Johannesburg', + ZM: 'Africa/Lusaka', + ZW: 'Africa/Harare', +} diff --git a/frontend/src/utils/tripLogCsv.ts b/frontend/src/utils/tripLogCsv.ts index 6271f211..2939daa0 100644 --- a/frontend/src/utils/tripLogCsv.ts +++ b/frontend/src/utils/tripLogCsv.ts @@ -1,5 +1,6 @@ import type { TripLogEntry } from '@/services/tripLogApi' import { csvFormatFor, toCsv, type CsvCell } from '@/utils/csv' +import { formatDate, formatTime, numberLocale } from '@/utils/format' import { endLabel, loggedDistanceKm, periodKey, type TripLogPeriod } from '@/utils/tripLog' import type { UnitFormatter } from '@/utils/units' @@ -8,7 +9,6 @@ import type { UnitFormatter } from '@/utils/units' type Translate = (key: string, named?: Record) => string export interface TripLogCsvContext { - locale: string t: Translate /** The distances are written in the unit the log is read in, and the headers say which. */ units: UnitFormatter @@ -23,9 +23,7 @@ export interface TripLogCsvContext { * purpose rather than a guess. */ export function tripLogCsv(entries: readonly TripLogEntry[], ctx: TripLogCsvContext): string { - const { locale, t, units, placesShown } = ctx - const time = (iso: string) => - new Date(iso).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) + const { t, units, placesShown } = ctx // A number rather than text, so a spreadsheet can add the column up. const distance = (km: number | null) => km === null ? null : Math.round(units.convert('distance', km) * 10) / 10 @@ -45,9 +43,9 @@ export function tripLogCsv(entries: readonly TripLogEntry[], ctx: TripLogCsvCont ] const rows = entries.map((entry): CsvCell[] => [ - new Date(entry.startedAt).toLocaleDateString(locale), - time(entry.startedAt), - time(entry.endedAt), + formatDate(entry.startedAt), + formatTime(entry.startedAt), + formatTime(entry.endedAt), endLabel(placesShown ? entry.startPlace : null, entry.startLatitude, entry.startLongitude), endLabel(placesShown ? entry.endPlace : null, entry.endLatitude, entry.endLongitude), distance(entry.odometerStartKm), @@ -57,7 +55,7 @@ export function tripLogCsv(entries: readonly TripLogEntry[], ctx: TripLogCsvCont entry.notes, ]) - return toCsv([header, ...rows], csvFormatFor(locale)) + return toCsv([header, ...rows], csvFormatFor(numberLocale())) } /** "trip-log-2026-09.csv": sorts by period in a folder, whatever the interface language. */ diff --git a/frontend/src/utils/tripRows.ts b/frontend/src/utils/tripRows.ts index c1c4d271..511b79c6 100644 --- a/frontend/src/utils/tripRows.ts +++ b/frontend/src/utils/tripRows.ts @@ -1,4 +1,5 @@ import type { Trip } from '@/services/vehicleApi' +import { formatDate, formatTime } from '@/utils/format' import type { UnitFormatter } from '@/utils/units' // `t` is injected rather than obtained via useI18n() so these stay plain, directly testable @@ -35,7 +36,6 @@ export interface TripRowContext { canResolve: boolean /** True while lookups are in flight, which is what a placeholder means rather than a fallback. */ resolving: boolean - locale: string t: Translate units: UnitFormatter } @@ -53,13 +53,12 @@ export function formatTripDuration(startedAt: string, endedAt: string, t: Transl * conditionals. */ export function buildTripRow(trip: Trip, ctx: TripRowContext): TripRow { - const { fromCity, toCity, canResolve, resolving, locale, t, units, inProgress = false } = ctx + const { fromCity, toCity, canResolve, resolving, t, units, inProgress = false } = ctx const destination = inProgress ? null : toCity - const startedAt = new Date(trip.startedAt) - const dateLabel = startedAt.toLocaleDateString(locale) - const dateShort = startedAt.toLocaleDateString(locale, { day: 'numeric', month: 'short' }) - const timeLabel = startedAt.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) + const dateLabel = formatDate(trip.startedAt) + const dateShort = formatDate(trip.startedAt, { day: 'numeric', month: 'short' }) + const timeLabel = formatTime(trip.startedAt) const distance = units.format('distance', trip.distanceKm) const duration = formatTripDuration(trip.startedAt, trip.endedAt, t) diff --git a/frontend/src/utils/units.ts b/frontend/src/utils/units.ts index f6ebe315..c7beb3a5 100644 --- a/frontend/src/utils/units.ts +++ b/frontend/src/utils/units.ts @@ -1,9 +1,11 @@ import { formatNumber } from '@/utils/format' +import type { Region } from '@/utils/region' // The API speaks metric and nothing else: kilometres, bar, degrees Celsius, litres. The interface // converts at the last moment, when a value is shown or typed, so a preference never reaches the -// database and switching it back and forth loses nothing. This file holds the conversions and the -// unit each preference implies; composables/useUnits binds them to the settings and the language. +// database and switching it back and forth loses nothing. This file holds the conversions, the +// unit each preference implies and the units each region uses; composables/useUnits binds them +// to the settings and the language. export type DistanceUnit = 'km' | 'mi' export type TemperatureUnit = 'celsius' | 'fahrenheit' @@ -29,6 +31,58 @@ export const METRIC_UNITS: Readonly = { fuelConsumption: 'l100km', } +/** The units as chosen in the settings: each one a unit, or "auto" for the one the region uses. */ +export type UnitSettings = { [K in keyof UnitPreferences]: UnitPreferences[K] | 'auto' } + +export const AUTO_UNITS: Readonly = { + distance: 'auto', + temperature: 'auto', + pressure: 'auto', + fuelConsumption: 'auto', +} + +// The United States and the territories that sign their roads the same way. +const US_ROADS: readonly Region[] = ['US', 'AS', 'GU', 'MP', 'UM', 'VI'] +// The United Kingdom and the Crown Dependencies, which share its road signs and its gallon. +const UK_ROADS: readonly Region[] = ['GB', 'GG', 'IM', 'JE'] + +const MILE_REGIONS = new Set([...US_ROADS, ...UK_ROADS]) +// Where the weather forecast is in Fahrenheit (CLDR): Puerto Rico signs its roads in kilometres. +const FAHRENHEIT_REGIONS = new Set([...US_ROADS, 'BS', 'BZ', 'KY', 'PR', 'PW']) +// What a forecourt air pump reads, rather than the door sticker: psi in these, kPa in Japan, and +// bar everywhere else. +const PSI_REGIONS = new Set([...US_ROADS, ...UK_ROADS, 'AU', 'CA', 'IN', 'NZ']) +const KPA_REGIONS = new Set(['JP']) + +/** The units drivers in a region use, which an "auto" setting stands for. */ +export function regionUnits(region: Region): UnitPreferences { + return { + distance: MILE_REGIONS.has(region) ? 'mi' : 'km', + temperature: FAHRENHEIT_REGIONS.has(region) ? 'fahrenheit' : 'celsius', + pressure: PSI_REGIONS.has(region) ? 'psi' : KPA_REGIONS.has(region) ? 'kpa' : 'bar', + fuelConsumption: US_ROADS.includes(region) + ? 'mpgUs' + : UK_ROADS.includes(region) + ? 'mpgUk' + : 'l100km', + } +} + +/** The units to show: each chosen one as it is, each "auto" one as the region has it. */ +export function resolveUnits(settings: Readonly, region: Region): UnitPreferences { + const regional = regionUnits(region) + function chosen(key: K): UnitPreferences[K] { + const value = settings[key] + return value === 'auto' ? regional[key] : (value as UnitPreferences[K]) + } + return { + distance: chosen('distance'), + temperature: chosen('temperature'), + pressure: chosen('pressure'), + fuelConsumption: chosen('fuelConsumption'), + } +} + /** * Something the API measures, each always in one metric unit: kilometres, km/h, Wh/km, km per * percent of the tank, bar, degrees Celsius, L/100 km and litres respectively. diff --git a/frontend/src/views/ClimateSchedulesView.vue b/frontend/src/views/ClimateSchedulesView.vue index 217c9411..25b16487 100644 --- a/frontend/src/views/ClimateSchedulesView.vue +++ b/frontend/src/views/ClimateSchedulesView.vue @@ -7,14 +7,9 @@ import { useClimateSchedulesStore } from '@/stores/climateSchedules' import StatusCard from '@/components/StatusCard.vue' import ClimateScheduleFormModal from '@/components/ClimateScheduleFormModal.vue' import type { ClimateSchedule } from '@/services/climateScheduleApi' -import { - daysSummary, - needsAttention, - runLabel, - settingsSummary, - startTimeLabel, -} from '@/utils/climateSchedule' +import { daysSummary, needsAttention, runLabel, settingsSummary } from '@/utils/climateSchedule' import { CLIMATE_MODE_ICONS } from '@/utils/climate' +import { formatTimeOfDay } from '@/utils/format' import { useUnits } from '@/composables/useUnits' import { useErrorMessage } from '@/composables/useErrorMessage' @@ -94,7 +89,7 @@ function variant(schedule: ClimateSchedule): 'info' | 'warning' | undefined { :key="schedule.id" :icon="CLIMATE_MODE_ICONS[schedule.mode]" :label="label(schedule)" - :value="startTimeLabel(schedule.startTime)" + :value="formatTimeOfDay(schedule.startTime)" :note="note(schedule)" :variant="variant(schedule)" clickable diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 4d0292a8..52a47ba0 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -29,7 +29,6 @@ import '@/assets/map.css' import type { Trip } from '@/services/vehicleApi' import { speedLimitSummary } from '@/utils/speedLimits' import { daysAgoIso } from '@/utils/dates' -import { intlLocale } from '@/utils/format' import { isPhoneViewport } from '@/utils/viewport' import { useUnits } from '@/composables/useUnits' import { burnsFuel, plugsIn } from '@/utils/vehicleType' @@ -54,7 +53,6 @@ const vehicleType = computed(() => store.effectiveVehicleType) // never gets counted against a row this one has no way to reach. const carTakesFuel = computed(() => burnsFuel(vehicleType.value)) const carTakesCharge = computed(() => plugsIn(vehicleType.value)) -const displayLocale = computed(() => intlLocale(uiSettingsStore.locale)) const selectedTripIndex = ref(null) const { speedOverlayEnabled, speedLimitOverlayEnabled } = storeToRefs(settingsStore) const { filterDays: dateRangeDays } = storeToRefs(uiSettingsStore) @@ -269,7 +267,6 @@ const displayRows = computed(() => inProgress: realIdx === activeTripIndex.value, canResolve: start != null && end != null && placeNamesEnabled.value, resolving: placesResolving.value, - locale: displayLocale.value, t, units: units.value, }), diff --git a/frontend/src/views/StatisticsView.vue b/frontend/src/views/StatisticsView.vue index 929144c4..aeb24775 100644 --- a/frontend/src/views/StatisticsView.vue +++ b/frontend/src/views/StatisticsView.vue @@ -24,7 +24,7 @@ import SkeletonChart from '@/components/SkeletonChart.vue' import StatusCard from '@/components/StatusCard.vue' import StatsChartCard from '@/components/StatsChartCard.vue' import type EditableCardGridComponent from '@/components/EditableCardGrid.vue' -import { formatNumber } from '@/utils/format' +import { formatHour, formatNumber } from '@/utils/format' import { startOfLocalDayDaysAgoIso } from '@/utils/dates' import { averageMovingSpeedKmh, @@ -121,7 +121,10 @@ const averageTripKm = computed(() => averageTripDistanceKm(store.tripSummaries)) const climateUsagePct = computed(() => aggregateStats.value?.climateUsagePct ?? null) -const peakDriveHour = computed(() => peakTripHour(store.tripSummaries)) +const peakDriveHour = computed(() => { + const hour = peakTripHour(store.tripSummaries) + return hour === null ? null : formatHour(hour) +}) // Shared with the parking-locations modal below - parkingLocations is just this list's count. const parkingCoordinates = computed(() => parkingSpots(store.tripSummaries)) diff --git a/frontend/src/views/TripLogView.vue b/frontend/src/views/TripLogView.vue index 91770dd5..c4668a59 100644 --- a/frontend/src/views/TripLogView.vue +++ b/frontend/src/views/TripLogView.vue @@ -9,7 +9,7 @@ import StatusCard from '@/components/StatusCard.vue' import TripLogItem from '@/components/TripLogItem.vue' import { TRIP_PURPOSES, type TripPurpose } from '@/services/tripLogApi' import { downloadCsv } from '@/utils/download' -import { intlLocale } from '@/utils/format' +import { formatDate } from '@/utils/format' import { PURPOSE_ICONS, PURPOSE_KEYS, type TripLogPeriod } from '@/utils/tripLog' import { tripLogCsv, tripLogFileName } from '@/utils/tripLogCsv' import { useUnits } from '@/composables/useUnits' @@ -23,7 +23,6 @@ const units = useUnits() const errorMessage = useErrorMessage() const vin = computed(() => vehicleStore.activeVin) -const displayLocale = computed(() => intlLocale(uiSettings.locale)) // The current month to start with: the one a driver is most likely filling in. const today = new Date() @@ -40,7 +39,7 @@ const years = computed(() => const months = computed(() => Array.from({ length: 12 }, (_, m) => ({ value: m, - label: new Date(2000, m, 1).toLocaleDateString(displayLocale.value, { month: 'long' }), + label: formatDate(new Date(2000, m, 1), { month: 'long' }), })), ) @@ -87,7 +86,6 @@ function classifyAll(purpose: TripPurpose) { function exportCsv() { const csv = tripLogCsv(store.entries, { - locale: displayLocale.value, t, units: units.value, placesShown: placesShown.value, @@ -179,7 +177,6 @@ function exportCsv() { v-for="entry in newestFirst" :key="entry.id" :entry="entry" - :locale="displayLocale" :places-shown="placesShown" :places-resolving="store.placesResolving" :saving="store.saving.has(entry.id)"