diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx index d83d6f8..16e10c4 100644 --- a/frontend/src/energy/MeterManager.test.tsx +++ b/frontend/src/energy/MeterManager.test.tsx @@ -258,6 +258,95 @@ describe('MeterManager — declare new meter', () => { }) }) +describe('MeterManager — edit meter date initialisation', () => { + beforeEach(() => vi.clearAllMocks()) + + it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => { + // ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight). + // The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so + // the local date is deterministically '2024-01-15' on any CI runner. + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement + expect(dateInput.value).toBe('2024-01-15') + }) + + it('initialises date input from started_at (naive, no tz marker)', async () => { + // A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z' + // so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date + // equals the UTC date exactly. + const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' } + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement + expect(dateInput.value).toBe('2024-03-20') + }) + + it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => { + // The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00' + // suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date. + // The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex + // and handles explicit offsets properly. + const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' } + // UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15' + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement + // Must not be empty (which would indicate Invalid Date from the old buggy path) + expect(dateInput.value).not.toBe('') + expect(dateInput.value).toBe('2024-01-15') + }) + + it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => { + // Open the edit form and immediately submit without changing any fields except label. + // The date should be considered unchanged → no started_at in the PATCH body. + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + // Change only label; leave date untouched + const labelInput = screen.getByTestId('edit-meter-label') + await user.clear(labelInput) + await user.type(labelInput, 'New label') + + await user.click(screen.getByTestId('edit-meter-submit')) + + await waitFor(() => { + expect(mockPatch).toHaveBeenCalled() + }) + + const patchBody = mockPatch.mock.calls[0][1].body + expect(patchBody).not.toHaveProperty('started_at') + }) +}) + describe('MeterManager — edit meter', () => { beforeEach(() => vi.clearAllMocks()) diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx index 4479cd3..2dfa954 100644 --- a/frontend/src/energy/MeterManager.tsx +++ b/frontend/src/energy/MeterManager.tsx @@ -38,7 +38,7 @@ import { type MeterReason, } from './hooks' import { ApiError } from '../api/client' -import { formatLocalDate } from '../utils/datetime' +import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime' // --------------------------------------------------------------------------- // Helpers @@ -60,6 +60,19 @@ function toLocalMidnightNaive(dateStr: string): string { return `${dateStr}T00:00:00` } +/** + * Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone. + * Used to populate fields. + * Returns '' if the Date is invalid. + */ +function toLocalDateInputString(d: Date): string { + if (isNaN(d.getTime())) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + // --------------------------------------------------------------------------- // Declare meter form (modal) // --------------------------------------------------------------------------- @@ -204,19 +217,12 @@ interface EditMeterFormProps { function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) { const [label, setLabel] = useState(meter.label) const [note, setNote] = useState(meter.note ?? '') - // Convert UTC started_at to a local date string for the . - // We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz. - const initialDateStr = (() => { - const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/) - ? meter.started_at + 'Z' - : meter.started_at - const d = new Date(iso) - if (isNaN(d.getTime())) return '' - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - return `${y}-${m}-${day}` - })() + // Convert started_at to a local date string for the . + // parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC, + // matching the backend's storage convention), Z-suffixed strings, and strings with + // explicit offsets like +02:00. We then extract the local-timezone date components + // so the displayed date matches the local wall-clock date of the meter start. + const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at)) const [dateStr, setDateStr] = useState(initialDateStr) const [error, setError] = useState(null) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6e3789c..87dd1ef 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -20,5 +20,13 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'], + env: { + // Lock the test timezone to UTC so that date-formatting assertions + // (which rely on toLocalDateInputString / getFullYear etc.) produce + // deterministic results regardless of the CI runner's local timezone. + // All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD + // values in MeterManager.test.tsx are correct under UTC. + TZ: 'UTC', + }, }, })