From c2e80b3384056dbf0a92936ccb0a7b164c47c6e3 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:19:45 +0200 Subject: [PATCH 1/3] fix(ui): tile text wraps instead of cutting off, firmer cards in light mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard, statistics and trip log tiles cut their labels and values short with an ellipsis ("CLIMATE CONT...", "71% · Act..."), which no tooltip can rescue on a phone. Labels now wrap to two lines and values wrap balanced, so a reading keeps its unit. A single word too long for the tile is hyphenated by the page's language and only falls back to an ellipsis where the browser lacks hyphenation for it. The tile's inner gap drops from 1rem to 0.75rem to give the text room next to a larger chevron. The page now carries the interface language in , which the hyphenation needs and screen readers use; it was always "en". On tablets the trip log stacks each trip as on phones, so the route gets a row of its own and the totals sit two to a row. Readings use tabular figures, and light mode gives cards a faint shadow (--shadow-card) so white cards stand off the pale page. Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/i18n.spec.ts | 18 +++++++++++++++ frontend/src/assets/carDiagram.css | 2 ++ frontend/src/assets/login.css | 1 + frontend/src/assets/main.css | 34 +++++++++++++++++++++-------- frontend/src/assets/map.css | 4 ++++ frontend/src/assets/statistics.css | 1 + frontend/src/assets/tripLog.css | 22 +++++++++++++++---- frontend/src/i18n.ts | 11 ++++++++++ 8 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 frontend/src/__tests__/i18n.spec.ts 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/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 }, +) From dda8ecb6ede5bf79077139142a915f4bca23083b Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:19:45 +0200 Subject: [PATCH 2/3] fix(dashboard): tiles say what they mean The lights tile turned red whenever the lights were off, which is how a parked car should be; it is now neutral, and green while lights are on. Find my car read "-" when idle, which looked like missing data; it reads Off. The maintenance tile showed a bare count; it now names the worst state (Overdue, Due soon, OK) as the maintenance page does, with the items in its tooltip as before. The maintenance item fixture moves to services/__tests__ so the store and card specs share it. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/FindMyCarCard.vue | 2 +- frontend/src/components/LightsCard.vue | 2 +- .../src/components/MaintenanceSummaryCard.vue | 20 +++----- .../__tests__/FindMyCarCard.spec.ts | 29 +++++++++++ .../components/__tests__/LightsCard.spec.ts | 31 ++++++++++++ .../__tests__/MaintenanceSummaryCard.spec.ts | 50 +++++++++++++++++++ .../services/__tests__/maintenanceFixtures.ts | 25 ++++++++++ .../src/stores/__tests__/maintenance.spec.ts | 49 +++++++----------- 8 files changed, 164 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/__tests__/FindMyCarCard.spec.ts create mode 100644 frontend/src/components/__tests__/LightsCard.spec.ts create mode 100644 frontend/src/components/__tests__/MaintenanceSummaryCard.spec.ts create mode 100644 frontend/src/services/__tests__/maintenanceFixtures.ts 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/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') From 8ac72ef3feaffeb1c7bae84e8c22c35ba8127336 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:19:45 +0200 Subject: [PATCH 3/3] fix(statistics): short day names on the chart axes A month of full dates (10/09/2026) crowded the x axis on a phone. The ticks now read "10 Sep", with the year only when the period runs into another one. Tooltips keep the full date. Co-Authored-By: Claude Opus 5.5 --- .../src/composables/useStatisticsCharts.ts | 21 +++++++++++++++++-- .../src/utils/__tests__/statistics.spec.ts | 16 ++++++++++++++ frontend/src/utils/statistics.ts | 17 +++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) 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/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