Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions frontend/scripts/gen-time-zone-regions.mjs
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
${lines.join('\n')}
}
`,
)
console.log(`${zonesByRegion.size} regions written to ${OUT}`)
2 changes: 1 addition & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/BatteryHeatingCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
})

Expand Down Expand Up @@ -58,7 +59,7 @@ const hasModal = computed(() => props.scheduleMode !== null || props.scheduleSta
<DetailListItem
v-if="scheduleStartTime !== null"
icon="clock"
:value="scheduleStartTime"
:value="formatTimeOfDay(scheduleStartTime)"
:label="t('vehicle.batteryHeating.startTime')"
/>
</div>
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/ChargingSessionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -142,10 +142,14 @@ const lastEndFormatted = computed((): string | null => {
<template #value>
<span class="badge badge-info">{{ chargingScheduleMode }}</span>
<template v-if="chargingScheduleStartTime">
<span class="detail-list__item-value">{{ chargingScheduleStartTime }}</span>
<span class="detail-list__item-value">{{
formatTimeOfDay(chargingScheduleStartTime)
}}</span>
<template v-if="chargingScheduleEndTime">
<span class="detail-list__item-sep">-</span>
<span class="detail-list__item-value">{{ chargingScheduleEndTime }}</span>
<span class="detail-list__item-value">{{
formatTimeOfDay(chargingScheduleEndTime)
}}</span>
</template>
</template>
</template>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/MaintenanceItemDetailModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
121 changes: 121 additions & 0 deletions frontend/src/components/RegionalSettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { CLOCK_SETTINGS, useUiSettingsStore, type RegionSetting } from '@/stores/settingsUi'
import { useVehicleStore } from '@/stores/vehicle'
import { clockExample } from '@/utils/format'
import { REGIONS, regionHourCycle, regionName, type HourCycle } from '@/utils/region'
import { mayBurnFuel } from '@/utils/vehicleType'
import SettingsSelect, { type SettingsSelectOption } from './SettingsSelect.vue'
import {
DISTANCE_UNITS,
FUEL_CONSUMPTION_UNITS,
PRESSURE_UNITS,
regionUnits,
TEMPERATURE_UNITS,
type UnitSettings,
} from '@/utils/units'

const { t, locale } = useI18n()
const settings = useUiSettingsStore()
const vehicleStore = useVehicleStore()

// Every "Automatic" option names what it currently stands for, so a wrong guess is plain to see.
const automatic = (value: string) => t('settings.units.auto', { value })

const regionOptions = computed((): SettingsSelectOption<RegionSetting>[] => {
const named = REGIONS.map((region) => ({
value: region,
label: regionName(region, locale.value),
}))
named.sort((a, b) => a.label.localeCompare(b.label, locale.value))
return [
{ value: 'auto', label: automatic(regionName(settings.detectedRegion, locale.value)) },
...named,
]
})

const clockLabel = (hourCycle: HourCycle) =>
t(`settings.units.clockOptions.${hourCycle}`, { example: clockExample(hourCycle) })

const clockOptions = computed(() =>
CLOCK_SETTINGS.map((clock) => ({
value: clock,
label:
clock === 'auto'
? automatic(clockLabel(regionHourCycle(settings.effectiveRegion)))
: clockLabel(clock),
})),
)

interface UnitChoice {
key: keyof UnitSettings
options: SettingsSelectOption<string>[]
desc?: string
}

function unitChoice(key: keyof UnitSettings, units: readonly string[], desc?: string): UnitChoice {
const unitLabel = (unit: string) => t(`settings.units.options.${unit}`)
const regional = regionUnits(settings.effectiveRegion)[key]
return {
key,
desc,
options: [
{ value: 'auto', label: automatic(unitLabel(regional)) },
...units.map((unit) => ({ value: unit, label: unitLabel(unit) })),
],
}
}

// Fuel consumption means nothing to a car that burns none. While the drivetrain is still unknown
// it is offered, as the dashboard offers its fuel cards then too.
const offersFuelConsumption = computed(() => mayBurnFuel(vehicleStore.effectiveVehicleType))

const unitChoices = computed((): UnitChoice[] => [
unitChoice('distance', DISTANCE_UNITS, t('settings.units.distanceDesc')),
unitChoice('temperature', TEMPERATURE_UNITS),
unitChoice('pressure', PRESSURE_UNITS),
...(offersFuelConsumption.value
? [
unitChoice(
'fuelConsumption',
FUEL_CONSUMPTION_UNITS,
t('settings.units.fuelConsumptionDesc'),
),
]
: []),
])

// Each select offers its own unit's values only, so what it hands back belongs to its key.
function chooseUnit(key: keyof UnitSettings, value: string) {
;(settings.units as Record<keyof UnitSettings, string>)[key] = value
}
</script>

<template>
<div class="settings-toggles">
<SettingsSelect
id="settings-region"
v-model="settings.region"
:label="t('settings.units.region')"
:desc="t('settings.units.regionDesc')"
:options="regionOptions"
/>
<SettingsSelect
id="settings-clock"
v-model="settings.clock"
:label="t('settings.units.clock')"
:options="clockOptions"
/>
<SettingsSelect
v-for="choice in unitChoices"
:id="`settings-unit-${choice.key}`"
:key="choice.key"
:model-value="settings.units[choice.key]"
:label="t(`settings.units.${choice.key}`)"
:desc="choice.desc"
:options="choice.options"
@update:model-value="(value: string) => chooseUnit(choice.key, value)"
/>
</div>
</template>
35 changes: 35 additions & 0 deletions frontend/src/components/SettingsSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts" generic="T extends string">
import SettingsToggle from './SettingsToggle.vue'

export interface SettingsSelectOption<V extends string> {
value: V
label: string
}

/** A settings row whose control is a choice from a list rather than a switch. */
defineProps<{
id: string
label: string
desc?: string
options: readonly SettingsSelectOption<T>[]
}>()

const model = defineModel<T>({ required: true })
</script>

<template>
<SettingsToggle class="settings-toggle--select" :label="label" :desc="desc">
<template #control>
<select
:id="id"
v-model="model"
class="form-select form-select-sm settings-toggle__select"
:aria-label="label"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</template>
</SettingsToggle>
</template>
Loading
Loading