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',
+ },
},
})