diff --git a/frontend/src/__tests__/i18n.spec.ts b/frontend/src/__tests__/i18n.spec.ts new file mode 100644 index 00000000..34fc663e --- /dev/null +++ b/frontend/src/__tests__/i18n.spec.ts @@ -0,0 +1,18 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { nextTick } from 'vue' +import { i18n } from '@/i18n' + +describe('i18n', () => { + afterEach(() => { + i18n.global.locale.value = 'en' + }) + + it('names the interface language on the page', async () => { + expect(document.documentElement.lang).toBe('en') + + i18n.global.locale.value = 'nl' + await nextTick() + + expect(document.documentElement.lang).toBe('nl') + }) +}) diff --git a/frontend/src/assets/carDiagram.css b/frontend/src/assets/carDiagram.css index cb737f4a..edbe32b6 100644 --- a/frontend/src/assets/carDiagram.css +++ b/frontend/src/assets/carDiagram.css @@ -59,6 +59,7 @@ background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 1.25rem; } @@ -181,6 +182,7 @@ position: absolute; font-size: 0.68rem; font-weight: 600; + font-variant-numeric: tabular-nums; padding: 2px 5px; border-radius: 4px; background: var(--color-surface); diff --git a/frontend/src/assets/login.css b/frontend/src/assets/login.css index f100a9bf..f4ab85b6 100644 --- a/frontend/src/assets/login.css +++ b/frontend/src/assets/login.css @@ -15,6 +15,7 @@ background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 2rem 2rem 1.75rem; } diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 24ccd461..b40a54bd 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -25,6 +25,10 @@ --radius: 10px; --demo-banner-height: 2.2rem; + /* Dark cards stand off the page by their surface alone; light ones need a hint of depth, or a + white card on the pale page blends into it. */ + --shadow-card: none; + /* One size for every field, text box, native select and multiselect alike, so a form or a panel that mixes them lines up. */ --field-py: 0.375rem; @@ -50,6 +54,7 @@ --color-text-muted: #5a6a7e; --color-primary-light: #1d4ed8; --color-danger-text: #dc2626; + --shadow-card: 0 1px 2px rgb(16 24 40 / 5%), 0 1px 3px rgb(16 24 40 / 8%); --field-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 512'%3E%3Cpath fill='%235a6a7e' d='M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z'/%3E%3C/svg%3E"); } @@ -378,10 +383,11 @@ body, background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 0.5rem; display: flex; align-items: center; - gap: 1rem; + gap: 0.75rem; min-width: 0; } @@ -418,30 +424,38 @@ body, overflow: hidden; } +/* Label, value and note wrap rather than end in an ellipsis: a tile is narrow on a phone and in + the wide desktop grid alike, and a cut-off word has no tooltip to fall back on under a finger. + The grid stretches every tile in a row to the tallest, so the row stays even. A single word + too long for the tile (Dutch compounds, mostly) is hyphenated by the page's language, and + only ends in an ellipsis where the browser has no hyphenation for it. */ .status-card__label { font-size: 0.72rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; - white-space: nowrap; + text-wrap: balance; + hyphens: auto; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; } +/* Balanced, so "Off · 21.0 °C" breaks before the reading rather than leaving its unit alone. */ .status-card__value { font-size: 1.05rem; font-weight: 600; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; + font-variant-numeric: tabular-nums; + text-wrap: balance; + overflow-wrap: break-word; } .status-card__note { font-size: 0.78rem; color: var(--color-text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow-wrap: break-word; } .status-card__unit { @@ -466,7 +480,7 @@ body, } .status-card__chevron { - font-size: 0.65rem; + font-size: 0.8rem; color: var(--color-text-muted); margin-left: auto; flex-shrink: 0; @@ -866,6 +880,7 @@ body, background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 1.25rem; display: flex; flex-direction: column; @@ -2056,6 +2071,7 @@ textarea.form-control { padding: 0.25rem 0.75rem; color: var(--color-text-muted); font-size: 0.78rem; + font-variant-numeric: tabular-nums; overflow: hidden; white-space: nowrap; } diff --git a/frontend/src/assets/map.css b/frontend/src/assets/map.css index 89ba1fec..a1624cff 100644 --- a/frontend/src/assets/map.css +++ b/frontend/src/assets/map.css @@ -25,6 +25,7 @@ border-radius: var(--radius); overflow: hidden; border: 1px solid var(--color-border); + box-shadow: var(--shadow-card); } .trip-sidebar { @@ -33,6 +34,7 @@ background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 0 1rem 1rem; overflow-y: auto; display: flex; @@ -423,11 +425,13 @@ .trip-list__time { font-size: 0.85rem; + font-variant-numeric: tabular-nums; flex-shrink: 0; } .trip-list__meta { font-size: 0.75rem; + font-variant-numeric: tabular-nums; color: var(--color-text-muted); } diff --git a/frontend/src/assets/statistics.css b/frontend/src/assets/statistics.css index 65797893..2f6a9ed2 100644 --- a/frontend/src/assets/statistics.css +++ b/frontend/src/assets/statistics.css @@ -5,6 +5,7 @@ background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); + box-shadow: var(--shadow-card); padding: 1.25rem; overflow: visible; } diff --git a/frontend/src/assets/tripLog.css b/frontend/src/assets/tripLog.css index 3914913a..fdd57668 100644 --- a/frontend/src/assets/tripLog.css +++ b/frontend/src/assets/tripLog.css @@ -36,6 +36,7 @@ border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); + box-shadow: var(--shadow-card); } .trip-log-bulk__actions { @@ -67,6 +68,7 @@ border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); + box-shadow: var(--shadow-card); transition: opacity 0.15s; } @@ -78,6 +80,7 @@ grid-area: when; display: flex; flex-direction: column; + font-variant-numeric: tabular-nums; } .trip-log-item__date { @@ -110,6 +113,7 @@ flex-direction: column; align-items: flex-end; white-space: nowrap; + font-variant-numeric: tabular-nums; } .trip-log-item__km { @@ -146,12 +150,14 @@ --chip-color: var(--color-success); } -/* ── Phones ───────────────────────────────────────────────── */ +/* ── Tablets and phones ───────────────────────────────────── */ -@media (width <= 767px) { +/* Beside the sidebar, a tablet leaves the content column barely wider than a phone: four totals + in a row cut their labels short, and the route squeezed between date and distance cuts most + addresses down to a street name. */ +@media (width <= 1023px) { .trip-log-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.5rem; } .trip-log-item { @@ -162,7 +168,7 @@ grid-template-columns: minmax(0, 1fr) auto; } - /* Half a phone's width cuts most addresses short, so they wrap here instead. */ + /* With the route on a row of its own, a long address wraps rather than ending in an ellipsis. */ .trip-log-item__route { flex-wrap: wrap; row-gap: 0.1rem; @@ -171,6 +177,14 @@ .trip-log-item__place { white-space: normal; } +} + +/* ── Phones ───────────────────────────────────────────────── */ + +@media (width <= 767px) { + .trip-log-totals { + gap: 0.5rem; + } /* The three purposes share the row equally, and the notes take the next one. */ .trip-log-item__purpose { diff --git a/frontend/src/components/FindMyCarCard.vue b/frontend/src/components/FindMyCarCard.vue index ec489346..89e34d8d 100644 --- a/frontend/src/components/FindMyCarCard.vue +++ b/frontend/src/components/FindMyCarCard.vue @@ -32,7 +32,7 @@ async function request(target: boolean) {

{{ t('control.findMyCarConfirm') }}

diff --git a/frontend/src/components/LightsCard.vue b/frontend/src/components/LightsCard.vue index fa3bdfdb..648e95d6 100644 --- a/frontend/src/components/LightsCard.vue +++ b/frontend/src/components/LightsCard.vue @@ -34,7 +34,7 @@ const summary = computed((): string | null => { icon="lightbulb" :title="t('vehicle.lights.title')" :value="summary" - :variant="activeLights.length > 0 ? 'success' : 'danger'" + :variant="activeLights.length > 0 ? 'success' : undefined" >
{ - if (store.overdueItems.length > 0) return 'danger' - if (store.dueSoonItems.length > 0) return 'warning' - return 'success' +// The card names the worst state in the words the maintenance page uses, rather than a bare count +// of items needing attention; the tooltip lists which ones. +const state = computed(() => { + if (store.overdueItems.length > 0) return { variant: 'danger', status: 'overdue' } as const + if (store.dueSoonItems.length > 0) return { variant: 'warning', status: 'dueSoon' } as const + return { variant: 'success', status: 'ok' } as const }) -const value = computed(() => - store.attentionItems.length === 0 - ? t('maintenance.status.ok') - : String(store.attentionItems.length), -) - const subtitle = computed(() => { if (store.attentionItems.length === 0) return t('maintenance.dashboard.allOk') const names = store.attentionItems.slice(0, 2).map((i) => i.name) @@ -51,9 +47,9 @@ function goToPage() { diff --git a/frontend/src/components/__tests__/FindMyCarCard.spec.ts b/frontend/src/components/__tests__/FindMyCarCard.spec.ts new file mode 100644 index 00000000..9cbd1f64 --- /dev/null +++ b/frontend/src/components/__tests__/FindMyCarCard.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { ref } from 'vue' +import en from '@/locales/en.json' +import FindMyCarCard from '../FindMyCarCard.vue' + +vi.mock('@/composables/useVehicleCommand', () => ({ + useVehicleCommand: () => ({ + sending: ref(null), + lastResult: ref(null), + isPending: () => false, + send: vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(true), + }), +})) + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +describe('FindMyCarCard', () => { + it('reads Off while the horn is quiet, rather than a dash that looks like missing data', () => { + const card = mount(FindMyCarCard, { + props: { vin: 'FAKEVN00000000001' }, + global: { plugins: [i18n], stubs: { DetailModal: true, FontAwesomeIcon: true } }, + }).find('.status-card') + + expect(card.find('.status-card__value').text()).toBe('Off') + expect(card.classes()).not.toContain('status-card--warning') + }) +}) diff --git a/frontend/src/components/__tests__/LightsCard.spec.ts b/frontend/src/components/__tests__/LightsCard.spec.ts new file mode 100644 index 00000000..bdf6327f --- /dev/null +++ b/frontend/src/components/__tests__/LightsCard.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import LightsCard from '../LightsCard.vue' + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +function mountCard(props: Record = {}) { + return mount(LightsCard, { + props: { mainBeam: false, dippedBeam: false, side: false, ...props }, + global: { plugins: [i18n], stubs: { DetailModal: true, FontAwesomeIcon: true } }, + }) +} + +describe('LightsCard', () => { + it('shows lights that are off as a plain tile, not as a problem', () => { + const card = mountCard().find('.status-card') + + expect(card.find('.status-card__value').text()).toBe('Off') + expect(card.classes()).not.toContain('status-card--danger') + expect(card.classes()).not.toContain('status-card--success') + }) + + it('names the lights that are on', () => { + const card = mountCard({ dippedBeam: true, side: true }).find('.status-card') + + expect(card.find('.status-card__value').text()).toBe('Low beam · Sidelights') + expect(card.classes()).toContain('status-card--success') + }) +}) diff --git a/frontend/src/components/__tests__/MaintenanceSummaryCard.spec.ts b/frontend/src/components/__tests__/MaintenanceSummaryCard.spec.ts new file mode 100644 index 00000000..9e7fff7c --- /dev/null +++ b/frontend/src/components/__tests__/MaintenanceSummaryCard.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import type { MaintenanceItem } from '@/services/maintenanceApi' +import { maintenanceItem } from '@/services/__tests__/maintenanceFixtures' +import { useMaintenanceStore } from '@/stores/maintenance' +import MaintenanceSummaryCard from '../MaintenanceSummaryCard.vue' + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }) })) + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + +function cardWith(items: MaintenanceItem[]) { + useMaintenanceStore().items = items + return mount(MaintenanceSummaryCard, { + global: { plugins: [i18n], stubs: { FontAwesomeIcon: true } }, + }).find('.status-card') +} + +describe('MaintenanceSummaryCard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('names the worst state, overdue before due soon', () => { + const card = cardWith([ + maintenanceItem({ id: 1, dueStatus: 'dueSoon' }), + maintenanceItem({ id: 2, dueStatus: 'overdue' }), + ]) + + expect(card.find('.status-card__value').text()).toBe('Overdue') + expect(card.classes()).toContain('status-card--danger') + }) + + it('reads Due soon when nothing is overdue yet', () => { + const card = cardWith([maintenanceItem({ dueStatus: 'dueSoon' })]) + + expect(card.find('.status-card__value').text()).toBe('Due soon') + expect(card.classes()).toContain('status-card--warning') + }) + + it('reads OK when nothing needs attention', () => { + const card = cardWith([maintenanceItem(), maintenanceItem({ id: 2, dueStatus: 'unknown' })]) + + expect(card.find('.status-card__value').text()).toBe('OK') + expect(card.classes()).toContain('status-card--success') + }) +}) diff --git a/frontend/src/composables/useStatisticsCharts.ts b/frontend/src/composables/useStatisticsCharts.ts index f1f19fb5..226bc5d6 100644 --- a/frontend/src/composables/useStatisticsCharts.ts +++ b/frontend/src/composables/useStatisticsCharts.ts @@ -10,6 +10,8 @@ import { formatDate, numberLocale } from '@/utils/format' import { dailyAverages, dailyCounterTotals, + dayTickFormat, + historyDayDate, roundedRange, roundTo2, type HistoryDay, @@ -80,7 +82,14 @@ export function useStatisticsCharts( const isHybrid = computed(() => burnsFuel(vehicleType.value)) const hasData = computed(() => days.value.some((day) => day.points.length > 0)) - const labels = computed(() => days.value.map((d) => formatDate(new Date(`${d.key}T00:00:00`)))) + const dates = computed(() => days.value.map(historyDayDate)) + const labels = computed(() => dates.value.map((d) => formatDate(d))) + // The axis names each day briefly, since a month of full dates crowds a phone's chart; the + // tooltip keeps the full date from the labels. + const tickLabels = computed(() => { + const options = dayTickFormat(days.value) + return dates.value.map((d) => formatDate(d, options)) + }) const evChartData = computed(() => ({ labels: labels.value, @@ -157,7 +166,15 @@ export function useStatisticsCharts( animation: false as const, plugins: { legend: { display: legend } }, scales: { - x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 8 } }, + x: { + ticks: { + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 8, + // On a category axis a tick's value is the index of its label. + callback: (value: string | number) => tickLabels.value[Number(value)] ?? value, + }, + }, y, }, } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d3de78d9..36bcbca6 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -1,3 +1,4 @@ +import { watch } from 'vue' import { createI18n } from 'vue-i18n' import en from './locales/en.json' import nl from './locales/nl.json' @@ -12,3 +13,13 @@ export const i18n = createI18n({ fallbackLocale: 'en', messages: { en, nl }, }) + +// The page names its language, so a screen reader reads Dutch in a Dutch voice and the browser +// hyphenates a long word (a dashboard tile's label) by that language's rules. +watch( + i18n.global.locale, + (locale) => { + document.documentElement.lang = locale + }, + { immediate: true }, +) diff --git a/frontend/src/services/__tests__/maintenanceFixtures.ts b/frontend/src/services/__tests__/maintenanceFixtures.ts new file mode 100644 index 00000000..e6198977 --- /dev/null +++ b/frontend/src/services/__tests__/maintenanceFixtures.ts @@ -0,0 +1,25 @@ +import type { MaintenanceItem } from '@/services/maintenanceApi' + +/** + * An oil change due every 10,000 km and nothing logged yet, as the API returns it, shared by the + * maintenance specs. Not a spec itself, so importing it does not run anything twice. + */ +export function maintenanceItem(overrides: Partial = {}): MaintenanceItem { + return { + id: 1, + vehicleId: 1, + name: 'Oil change', + notes: null, + intervalKm: 10000, + intervalMonths: null, + lastServiceDate: null, + lastServiceOdometerKm: null, + dueStatus: 'ok', + nextDueDate: null, + nextDueOdometerKm: null, + daysRemaining: null, + kmRemaining: null, + createdAt: '2026-01-01', + ...overrides, + } +} diff --git a/frontend/src/stores/__tests__/maintenance.spec.ts b/frontend/src/stores/__tests__/maintenance.spec.ts index 4df48267..24bb9fd6 100644 --- a/frontend/src/stores/__tests__/maintenance.spec.ts +++ b/frontend/src/stores/__tests__/maintenance.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useMaintenanceStore } from '@/stores/maintenance' +import { maintenanceItem } from '@/services/__tests__/maintenanceFixtures' import type { MaintenanceItem, MaintenanceLogEntry, @@ -19,26 +20,6 @@ vi.mock('@/services/maintenanceApi', () => ({ }, })) -function item(overrides: Partial = {}): MaintenanceItem { - return { - id: 1, - vehicleId: 1, - name: 'Oil change', - notes: null, - intervalKm: 10000, - intervalMonths: null, - lastServiceDate: null, - lastServiceOdometerKm: null, - dueStatus: 'ok', - nextDueDate: null, - nextDueOdometerKm: null, - daysRemaining: null, - kmRemaining: null, - createdAt: '2026-01-01', - ...overrides, - } -} - describe('useMaintenanceStore', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -47,7 +28,7 @@ describe('useMaintenanceStore', () => { it('fetchItems populates items on success', async () => { const { maintenanceApi } = await import('@/services/maintenanceApi') - vi.mocked(maintenanceApi.list).mockResolvedValue([item()]) + vi.mocked(maintenanceApi.list).mockResolvedValue([maintenanceItem()]) const store = useMaintenanceStore() await store.fetchItems('VIN1') expect(store.items).toHaveLength(1) @@ -65,7 +46,9 @@ describe('useMaintenanceStore', () => { it('createItem appends the created item', async () => { const { maintenanceApi } = await import('@/services/maintenanceApi') - vi.mocked(maintenanceApi.create).mockResolvedValue(item({ id: 2, name: 'Tyre rotation' })) + vi.mocked(maintenanceApi.create).mockResolvedValue( + maintenanceItem({ id: 2, name: 'Tyre rotation' }), + ) const store = useMaintenanceStore() await store.createItem('VIN1', { name: 'Tyre rotation', intervalKm: 10000 }) expect(store.items).toHaveLength(1) @@ -74,11 +57,15 @@ describe('useMaintenanceStore', () => { it('updateItem replaces the matching item in place', async () => { const { maintenanceApi } = await import('@/services/maintenanceApi') - vi.mocked(maintenanceApi.list).mockResolvedValue([item({ id: 1, name: 'Oil change' })]) + vi.mocked(maintenanceApi.list).mockResolvedValue([ + maintenanceItem({ id: 1, name: 'Oil change' }), + ]) const store = useMaintenanceStore() await store.fetchItems('VIN1') - vi.mocked(maintenanceApi.update).mockResolvedValue(item({ id: 1, name: 'Oil change (5w30)' })) + vi.mocked(maintenanceApi.update).mockResolvedValue( + maintenanceItem({ id: 1, name: 'Oil change (5w30)' }), + ) await store.updateItem('VIN1', 1, { name: 'Oil change (5w30)' }) expect(store.items).toHaveLength(1) @@ -87,7 +74,7 @@ describe('useMaintenanceStore', () => { it('deleteItem removes the item and its cached log entries', async () => { const { maintenanceApi } = await import('@/services/maintenanceApi') - vi.mocked(maintenanceApi.list).mockResolvedValue([item({ id: 1 })]) + vi.mocked(maintenanceApi.list).mockResolvedValue([maintenanceItem({ id: 1 })]) const store = useMaintenanceStore() await store.fetchItems('VIN1') store.logEntries[1] = [ @@ -109,13 +96,15 @@ describe('useMaintenanceStore', () => { it('logService updates the item and prepends the log entry when history is loaded', async () => { const { maintenanceApi } = await import('@/services/maintenanceApi') - vi.mocked(maintenanceApi.list).mockResolvedValue([item({ id: 1, dueStatus: 'overdue' })]) + vi.mocked(maintenanceApi.list).mockResolvedValue([ + maintenanceItem({ id: 1, dueStatus: 'overdue' }), + ]) const store = useMaintenanceStore() await store.fetchItems('VIN1') store.logEntries[1] = [] vi.mocked(maintenanceApi.logService).mockResolvedValue({ - item: item({ id: 1, dueStatus: 'ok', lastServiceDate: '2026-07-10' }), + item: maintenanceItem({ id: 1, dueStatus: 'ok', lastServiceDate: '2026-07-10' }), logEntry: { id: 3, maintenanceItemId: 1, @@ -135,9 +124,9 @@ describe('useMaintenanceStore', () => { it('overdueItems and attentionItems order overdue before dueSoon', async () => { const { maintenanceApi } = await import('@/services/maintenanceApi') vi.mocked(maintenanceApi.list).mockResolvedValue([ - item({ id: 1, dueStatus: 'ok' }), - item({ id: 2, dueStatus: 'dueSoon' }), - item({ id: 3, dueStatus: 'overdue' }), + maintenanceItem({ id: 1, dueStatus: 'ok' }), + maintenanceItem({ id: 2, dueStatus: 'dueSoon' }), + maintenanceItem({ id: 3, dueStatus: 'overdue' }), ]) const store = useMaintenanceStore() await store.fetchItems('VIN1') diff --git a/frontend/src/utils/__tests__/statistics.spec.ts b/frontend/src/utils/__tests__/statistics.spec.ts index 7dfa0aeb..4228559b 100644 --- a/frontend/src/utils/__tests__/statistics.spec.ts +++ b/frontend/src/utils/__tests__/statistics.spec.ts @@ -7,8 +7,10 @@ import { batteryVoltageChange, dailyAverages, dailyCounterTotals, + dayTickFormat, hasSpeedReadings, historyByDay, + historyDayDate, parkingSpots, peakDriveHour, roundedRange, @@ -155,4 +157,18 @@ describe('daily history', () => { expect(roundedRange(1.5, 3.5)).toEqual({ min: 1.5, max: 3.5 }) expect(roundedRange(21.76, 50.76)).toEqual({ min: 20, max: 55 }) }) + + it('puts a day at its local midnight', () => { + expect(historyDayDate({ key: '2026-09-25', points: [] })).toEqual(new Date(2026, 8, 25)) + }) + + it('names the days on a chart axis without the year while the period stays in one', () => { + expect(dayTickFormat(historyByDay([], 30, now))).toEqual({ day: 'numeric', month: 'short' }) + }) + + it('adds the year to the axis once the period runs into another one', () => { + const days = historyByDay([], 30, new Date(2027, 0, 10, 12, 0)) + + expect(dayTickFormat(days)).toEqual({ day: 'numeric', month: 'short', year: 'numeric' }) + }) }) diff --git a/frontend/src/utils/statistics.ts b/frontend/src/utils/statistics.ts index 9a0abbf5..18273b05 100644 --- a/frontend/src/utils/statistics.ts +++ b/frontend/src/utils/statistics.ts @@ -115,6 +115,23 @@ export function historyByDay( return Array.from(buckets.entries()).map(([key, points]) => ({ key, points })) } +/** The day a history bucket stands for, at local midnight. */ +export function historyDayDate(day: HistoryDay): Date { + return new Date(`${day.key}T00:00:00`) +} + +/** + * How a chart's axis names the period's days: day and short month ("10 Sep"), with the year only + * when the period runs into another one, where two "3 Jan" ticks a year apart would read the same. + */ +export function dayTickFormat(days: readonly HistoryDay[]): Intl.DateTimeFormatOptions { + const firstYear = days[0]?.key.slice(0, 4) + const lastYear = days[days.length - 1]?.key.slice(0, 4) + return firstYear === lastYear + ? { day: 'numeric', month: 'short' } + : { day: 'numeric', month: 'short', year: 'numeric' } +} + function average(values: readonly (number | null)[]): number | null { const valid = values.filter((v): v is number => v !== null) if (!valid.length) return null