diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
index e5c38bc..b45072b 100644
--- a/frontend/src/api/schema.d.ts
+++ b/frontend/src/api/schema.d.ts
@@ -559,6 +559,84 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/energy/meters": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Energy Meters
+ * @description List all meter epochs in ascending ``started_at`` order.
+ *
+ * Returns the full historical sequence of meter installations across all
+ * commodities. The active meter (``ended_at=null``) appears last because it
+ * has the latest ``started_at``.
+ */
+ get: operations["list_energy_meters_api_energy_meters_get"];
+ put?: never;
+ /**
+ * Declare Energy Meter
+ * @description Declare a new meter epoch (swap, home move, or initial declaration).
+ *
+ * Closes the current active meter for the given commodity at ``started_at``
+ * and opens a new active meter. If no active meter exists, the new meter is
+ * simply created without closing anything.
+ *
+ * **Validation**: ``started_at`` must be **≥** the current active meter's
+ * own ``started_at`` (no chronological backdate below the active epoch's
+ * start). Equal timestamps are allowed (replaces the current meter at the
+ * same logical moment). Violation → 422.
+ *
+ * **Retroactive recompute**: if ``started_at`` is in the past, billing
+ * records from that point forward are re-judged via ``recompute_range`` to
+ * reflect the new meter attribution. The response includes the count of
+ * recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
+ * — the recompute is transparent; callers should re-fetch costs if needed).
+ */
+ post: operations["declare_energy_meter_api_energy_meters_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/meters/{meter_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Patch Energy Meter
+ * @description Partially update a meter epoch: rename, edit note, or correct started_at.
+ *
+ * - ``label``: updates the human-readable label.
+ * - ``note``: updates the free-form note.
+ * - ``started_at``: **retroactive correction** — shifts this meter's start
+ * boundary. The service layer maintains timeline continuity by also
+ * updating the preceding meter's ``ended_at``. Validation:
+ * * Must be strictly after the previous meter's own ``started_at``.
+ * * Must be strictly before this meter's ``ended_at`` (if closed).
+ * Violation → 422.
+ *
+ * **Retroactive recompute when ``started_at`` changes**: billing records in
+ * the window ``[min(old, new), now)`` are re-judged to reflect the corrected
+ * meter attribution.
+ *
+ * Not found → 404.
+ */
+ patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"];
+ trace?: never;
+ };
"/api/expose": {
parameters: {
query?: never;
@@ -1569,6 +1647,114 @@ export interface components {
*/
sell_normal: number;
};
+ /**
+ * MeterDeclareRequest
+ * @description Request body for POST /api/energy/meters.
+ *
+ * Declares a new meter epoch (swap, home move, or initial declaration). The
+ * service layer closes the current active meter for the given commodity at
+ * ``started_at`` and opens a new one.
+ *
+ * ``started_at`` follows the Principle-A localisation convention: a
+ * timezone-naive value is interpreted as the **server's local wall-clock time**
+ * (e.g. CEST midnight → stored as UTC the night before); a timezone-aware
+ * value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
+ * every meter declaration must carry an explicit start timestamp.
+ *
+ * ``commodity`` defaults to ``"electricity"``; the field is available for
+ * future use with ``gas`` or ``heating``.
+ */
+ MeterDeclareRequest: {
+ /** Label */
+ label: string;
+ /**
+ * Started At
+ * Format: date-time
+ * @description UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration).
+ */
+ started_at: string;
+ /** @description Why this epoch was created. One of: initial, meter_swap, home_move, other. */
+ reason: components["schemas"]["MeterReason"];
+ /** Note */
+ note?: string | null;
+ /**
+ * Commodity
+ * @description Energy commodity this meter measures. Defaults to 'electricity'.
+ * @default electricity
+ */
+ commodity: string;
+ };
+ /**
+ * MeterListResponse
+ * @description Response schema for GET /api/energy/meters.
+ *
+ * Meters are returned in ascending ``started_at`` order so the caller sees
+ * the historical installation sequence.
+ */
+ MeterListResponse: {
+ /** Items */
+ items: components["schemas"]["MeterResponse"][];
+ /** Total */
+ total: number;
+ };
+ /**
+ * MeterPatchRequest
+ * @description Request body for PATCH /api/energy/meters/{id}.
+ *
+ * All fields are optional. Only non-``None`` values are applied.
+ *
+ * Updating ``started_at`` is a **retroactive correction**: the service layer
+ * maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
+ * and the API layer triggers ``recompute_range`` over the affected window so
+ * that billing attribution is re-judged.
+ */
+ MeterPatchRequest: {
+ /** Label */
+ label?: string | null;
+ /** Note */
+ note?: string | null;
+ /**
+ * Started At
+ * @description Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window.
+ */
+ started_at?: string | null;
+ };
+ /**
+ * MeterReason
+ * @description Allowed values for the meter epoch creation reason.
+ * @enum {string}
+ */
+ MeterReason: "initial" | "meter_swap" | "home_move" | "other";
+ /**
+ * MeterResponse
+ * @description Response schema for a single Meter epoch row.
+ *
+ * ``ended_at`` is ``null`` for the currently active meter.
+ */
+ MeterResponse: {
+ /** Id */
+ id: number;
+ /** Label */
+ label: string;
+ /** Commodity */
+ commodity: string;
+ /**
+ * Started At
+ * Format: date-time
+ */
+ started_at: string;
+ /** Ended At */
+ ended_at: string | null;
+ /** Reason */
+ reason: string;
+ /** Note */
+ note: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
/**
* MetricInfo
* @description Metadata for a single measurable quantity in a device's profile.
@@ -3007,6 +3193,98 @@ export interface operations {
};
};
};
+ list_energy_meters_api_energy_meters_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MeterListResponse"];
+ };
+ };
+ };
+ };
+ declare_energy_meter_api_energy_meters_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["MeterDeclareRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MeterResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ patch_energy_meter_api_energy_meters__meter_id__patch: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path: {
+ meter_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["MeterPatchRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MeterResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_expose_api_expose_get: {
parameters: {
query?: never;
diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx
new file mode 100644
index 0000000..d83d6f8
--- /dev/null
+++ b/frontend/src/energy/MeterManager.test.tsx
@@ -0,0 +1,357 @@
+/**
+ * Tests for MeterManager component.
+ *
+ * Coverage:
+ * 1. Loading state rendering.
+ * 2. Error state rendering.
+ * 3. Empty state when no meters exist.
+ * 4. Meter timeline list rendering (label, dates, active badge, reason).
+ * 5. "Declare New Meter" button opens form modal.
+ * 6. Declare meter — form submit calls POST /api/energy/meters.
+ * 7. Declare meter — 422 (倒挂) error is displayed.
+ * 8. Edit button opens edit form modal.
+ * 9. Edit meter — saves label/note via PATCH /api/energy/meters/{meter_id}.
+ * 10. Edit meter — retroactive started_at triggers recompute notice.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders } from '../test-utils'
+import { MeterManager } from './MeterManager'
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockPatch = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ GET: (...args: unknown[]) => mockGet(...args),
+ POST: (...args: unknown[]) => mockPost(...args),
+ PATCH: (...args: unknown[]) => mockPatch(...args),
+ DELETE: vi.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const ACTIVE_METER = {
+ id: 1,
+ label: 'Initial 2G meter',
+ commodity: 'electricity',
+ started_at: '2024-01-15T00:00:00Z',
+ ended_at: null,
+ reason: 'initial',
+ note: null,
+ created_at: '2024-01-15T00:00:00Z',
+}
+
+const CLOSED_METER = {
+ id: 2,
+ label: 'Old 4G meter',
+ commodity: 'electricity',
+ started_at: '2023-06-01T00:00:00Z',
+ ended_at: '2024-01-15T00:00:00Z',
+ reason: 'meter_swap',
+ note: 'Replaced by grid company',
+ created_at: '2023-06-01T00:00:00Z',
+}
+
+const METERS_RESPONSE = {
+ items: [CLOSED_METER, ACTIVE_METER],
+ total: 2,
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('MeterManager — loading / error / empty states', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('renders loading state initially', () => {
+ mockGet.mockImplementation(() => new Promise(() => {}))
+
+ renderWithProviders()
+
+ expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
+ })
+
+ it('renders error state when GET fails', async () => {
+ mockGet.mockRejectedValue(new Error('Network error'))
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('meters-load-error')).toBeInTheDocument()
+ })
+ })
+
+ it('renders empty state when no meters exist', async () => {
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('meters-empty')).toBeInTheDocument()
+ })
+ })
+})
+
+describe('MeterManager — meter list', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('renders meter timeline with label, dates, status badge, reason', async () => {
+ mockGet.mockResolvedValue({ data: METERS_RESPONSE })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('meters-table')).toBeInTheDocument()
+ })
+
+ // Labels
+ expect(screen.getByText('Initial 2G meter')).toBeInTheDocument()
+ expect(screen.getByText('Old 4G meter')).toBeInTheDocument()
+
+ // Status badges
+ expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active')
+ expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed')
+
+ // Reason badges
+ expect(screen.getByText('initial')).toBeInTheDocument()
+ expect(screen.getByText('meter_swap')).toBeInTheDocument()
+ })
+
+ it('renders "Declare New Meter" button', async () => {
+ mockGet.mockResolvedValue({ data: METERS_RESPONSE })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()
+ })
+ })
+})
+
+describe('MeterManager — declare new meter', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('opens declare modal when "Declare New Meter" is clicked', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
+
+ await user.click(screen.getByTestId('meter-declare-button'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()
+ })
+ expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()
+ })
+
+ it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+ mockPost.mockResolvedValue({ data: ACTIVE_METER })
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
+ await user.click(screen.getByTestId('meter-declare-button'))
+
+ await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
+
+ // Fill form
+ await user.type(screen.getByTestId('meter-label'), 'New meter label')
+ await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
+
+ // Select reason via the combobox (Mantine Select renders a combobox)
+ await user.click(screen.getByTestId('meter-reason'))
+ await waitFor(() => screen.getByText('Initial installation'))
+ await user.click(screen.getByText('Initial installation'))
+
+ await user.click(screen.getByTestId('declare-meter-submit'))
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith(
+ '/api/energy/meters',
+ expect.objectContaining({
+ body: expect.objectContaining({
+ label: 'New meter label',
+ // FU10 local-midnight naive convention: no Z suffix
+ started_at: '2026-01-01T00:00:00',
+ reason: 'initial',
+ commodity: 'electricity',
+ }),
+ }),
+ )
+ })
+ })
+
+ it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
+
+ const { ApiError } = await import('../api/client')
+ mockPost.mockRejectedValue(
+ new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }),
+ )
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
+ await user.click(screen.getByTestId('meter-declare-button'))
+ await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
+
+ await user.type(screen.getByTestId('meter-label'), 'Bad meter')
+ await user.type(screen.getByTestId('meter-started-at'), '2020-01-01')
+ await user.click(screen.getByTestId('meter-reason'))
+ await waitFor(() => screen.getByText('Meter swap (same address)'))
+ await user.click(screen.getByText('Meter swap (same address)'))
+
+ await user.click(screen.getByTestId('declare-meter-submit'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument()
+ })
+ expect(screen.getByTestId('declare-meter-error').textContent).toContain(
+ 'started_at must be ≥',
+ )
+ })
+
+ it('cancel button closes the declare modal', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
+ await user.click(screen.getByTestId('meter-declare-button'))
+ await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument())
+
+ await user.click(screen.getByTestId('declare-meter-cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument()
+ })
+ })
+})
+
+describe('MeterManager — edit meter', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('opens edit modal when Edit button is clicked', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: METERS_RESPONSE })
+
+ 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-modal')).toBeInTheDocument()
+ })
+ expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()
+ })
+
+ it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
+ mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } })
+
+ 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())
+
+ // Clear label and type new one
+ const labelInput = screen.getByTestId('edit-meter-label')
+ await user.clear(labelInput)
+ await user.type(labelInput, 'Renamed meter')
+
+ await user.click(screen.getByTestId('edit-meter-submit'))
+
+ await waitFor(() => {
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/api/energy/meters/{meter_id}',
+ expect.objectContaining({
+ params: { path: { meter_id: ACTIVE_METER.id } },
+ body: expect.objectContaining({ label: 'Renamed meter' }),
+ }),
+ )
+ })
+ })
+
+ it('shows recompute notice when started_at is changed (retroactive correction)', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
+ mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } })
+
+ 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 the date field
+ const dateInput = screen.getByTestId('edit-meter-started-at')
+ await user.clear(dateInput)
+ await user.type(dateInput, '2024-02-01')
+
+ await user.click(screen.getByTestId('edit-meter-submit'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument()
+ })
+ })
+
+ it('displays error when PATCH fails', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
+
+ const { ApiError } = await import('../api/client')
+ mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' }))
+
+ 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 label so there's something to patch
+ const labelInput = screen.getByTestId('edit-meter-label')
+ await user.clear(labelInput)
+ await user.type(labelInput, 'Different label')
+
+ await user.click(screen.getByTestId('edit-meter-submit'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument()
+ })
+ expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
+ })
+})
diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx
new file mode 100644
index 0000000..4479cd3
--- /dev/null
+++ b/frontend/src/energy/MeterManager.tsx
@@ -0,0 +1,497 @@
+/**
+ * MeterManager — electricity meter timeline UI.
+ *
+ * Features:
+ * - Table of meter epochs: label / interval (started_at → ended_at or "active") /
+ * active badge / reason.
+ * - "Declare New Meter" button: form with label + date (started_at) + reason +
+ * optional note. Sends local-midnight naive datetime per FU10 convention.
+ * - Edit modal: update label, note, or correct started_at (retroactive).
+ * - Retroactive feedback: if started_at is changed, a success notice mentions
+ * that affected billing periods have been recomputed.
+ * - Loading / error / empty states.
+ */
+
+import { useState } from 'react'
+import {
+ Table,
+ Button,
+ Group,
+ Text,
+ Loader,
+ Center,
+ Alert,
+ Stack,
+ Badge,
+ ScrollArea,
+ Modal,
+ TextInput,
+ Textarea,
+ Select,
+ Notification,
+} from '@mantine/core'
+import {
+ useMeters,
+ useDeclareMeter,
+ useUpdateMeter,
+ type MeterResponse,
+ type MeterReason,
+} from './hooks'
+import { ApiError } from '../api/client'
+import { formatLocalDate } from '../utils/datetime'
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const REASON_OPTIONS: { value: MeterReason; label: string }[] = [
+ { value: 'initial', label: 'Initial installation' },
+ { value: 'meter_swap', label: 'Meter swap (same address)' },
+ { value: 'home_move', label: 'Home / address move' },
+ { value: 'other', label: 'Other' },
+]
+
+/**
+ * Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime
+ * string (no Z suffix) following the FU10 / ContractForm convention.
+ * The backend interprets naive datetimes as server local wall-clock time.
+ */
+function toLocalMidnightNaive(dateStr: string): string {
+ return `${dateStr}T00:00:00`
+}
+
+// ---------------------------------------------------------------------------
+// Declare meter form (modal)
+// ---------------------------------------------------------------------------
+
+interface DeclareMeterFormProps {
+ onClose: () => void
+ onSaved: () => void
+}
+
+function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
+ const [label, setLabel] = useState('')
+ const [dateStr, setDateStr] = useState('')
+ const [reason, setReason] = useState(null)
+ const [note, setNote] = useState('')
+ const [error, setError] = useState(null)
+
+ const declareMutation = useDeclareMeter()
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+
+ if (!label.trim()) {
+ setError('Label is required.')
+ return
+ }
+ if (!dateStr) {
+ setError('Start date is required.')
+ return
+ }
+ if (!reason) {
+ setError('Reason is required.')
+ return
+ }
+
+ try {
+ await declareMutation.mutateAsync({
+ label: label.trim(),
+ started_at: toLocalMidnightNaive(dateStr),
+ reason: reason as MeterReason,
+ note: note.trim() || undefined,
+ commodity: 'electricity',
+ })
+ onSaved()
+ onClose()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ const detail = (err.body as { detail?: string } | null)?.detail
+ setError(detail ?? `Error ${err.status}: failed to declare meter.`)
+ } else {
+ setError('Failed to declare meter. Please try again.')
+ }
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Edit meter form (modal)
+// ---------------------------------------------------------------------------
+
+interface EditMeterFormProps {
+ meter: MeterResponse
+ onClose: () => void
+ onSaved: (retroactive: boolean) => void
+}
+
+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}`
+ })()
+ const [dateStr, setDateStr] = useState(initialDateStr)
+ const [error, setError] = useState(null)
+
+ const updateMutation = useUpdateMeter()
+
+ // Detect if the user changed started_at (retroactive correction).
+ const startedAtChanged = dateStr !== initialDateStr
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+
+ const patchBody: { label?: string | null; note?: string | null; started_at?: string | null } = {}
+ if (label.trim() !== meter.label) patchBody.label = label.trim()
+ const noteVal = note.trim() || null
+ if (noteVal !== meter.note) patchBody.note = noteVal
+ if (startedAtChanged && dateStr) {
+ patchBody.started_at = toLocalMidnightNaive(dateStr)
+ }
+
+ if (Object.keys(patchBody).length === 0) {
+ onClose()
+ return
+ }
+
+ try {
+ await updateMutation.mutateAsync({ id: meter.id, body: patchBody })
+ onSaved(startedAtChanged)
+ onClose()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ const detail = (err.body as { detail?: string } | null)?.detail
+ setError(detail ?? `Error ${err.status}: failed to update meter.`)
+ } else {
+ setError('Failed to update meter. Please try again.')
+ }
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Meter timeline table
+// ---------------------------------------------------------------------------
+
+interface MeterTableProps {
+ meters: MeterResponse[]
+ onEdit: (meter: MeterResponse) => void
+}
+
+function MeterTable({ meters, onEdit }: MeterTableProps) {
+ if (meters.length === 0) {
+ return (
+
+ No meters declared yet. Click "Declare New Meter" to add one.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Label
+ Commodity
+ From
+ To
+ Status
+ Reason
+ Actions
+
+
+
+ {meters.map((meter) => {
+ const isActive = meter.ended_at === null
+ return (
+
+
+
+ {meter.label}
+
+
+
+
+ {meter.commodity}
+
+
+
+
+ {formatLocalDate(meter.started_at)}
+
+
+
+
+ {meter.ended_at ? formatLocalDate(meter.ended_at) : '—'}
+
+
+
+
+ {isActive ? 'active' : 'closed'}
+
+
+
+
+ {meter.reason}
+
+
+
+
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// MeterManager — top-level
+// ---------------------------------------------------------------------------
+
+export function MeterManager() {
+ const metersQuery = useMeters()
+
+ const [showDeclareForm, setShowDeclareForm] = useState(false)
+ const [editMeter, setEditMeter] = useState(null)
+ const [recomputeNotice, setRecomputeNotice] = useState(false)
+
+ // ---------------------------------------------------------------------------
+ // Render states
+ // ---------------------------------------------------------------------------
+
+ if (metersQuery.isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (metersQuery.isError || !metersQuery.data) {
+ return (
+
+ Failed to load meters. Please refresh.
+
+ )
+ }
+
+ const meters = metersQuery.data.items
+
+ return (
+
+
+ Electricity Meters
+
+
+
+ {recomputeNotice && (
+ setRecomputeNotice(false)}
+ data-testid="meter-recompute-notice"
+ >
+ The start date was corrected. Affected billing periods have been
+ re-judged and cost attributions updated.
+
+ )}
+
+ setEditMeter(m)} />
+
+ {/* Declare new meter */}
+ {showDeclareForm && (
+ setShowDeclareForm(false)}
+ onSaved={() => setShowDeclareForm(false)}
+ />
+ )}
+
+ {/* Edit existing meter */}
+ {editMeter && (
+ setEditMeter(null)}
+ onSaved={(retroactive) => {
+ setEditMeter(null)
+ if (retroactive) setRecomputeNotice(true)
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/energy/hooks.ts b/frontend/src/energy/hooks.ts
index 90502d5..2bfe0a3 100644
--- a/frontend/src/energy/hooks.ts
+++ b/frontend/src/energy/hooks.ts
@@ -214,6 +214,12 @@ export function useMetrics(uuid: string) {
// ===========================================================================
// Re-exported energy types for consumers
+export type MeterResponse = components['schemas']['MeterResponse']
+export type MeterListResponse = components['schemas']['MeterListResponse']
+export type MeterDeclareRequest = components['schemas']['MeterDeclareRequest']
+export type MeterPatchRequest = components['schemas']['MeterPatchRequest']
+export type MeterReason = components['schemas']['MeterReason']
+
export type ContractResponse = components['schemas']['ContractResponse']
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
@@ -434,6 +440,66 @@ export function useRecomputeCosts() {
})
}
+// ===========================================================================
+// Meter hooks — typed TanStack Query wrappers for /api/energy/meters.
+//
+// Query-key conventions:
+// ['energy-meters'] — meter list
+// ===========================================================================
+
+// ---------------------------------------------------------------------------
+// Query: list all meter epochs
+// ---------------------------------------------------------------------------
+
+export function useMeters() {
+ return useQuery({
+ queryKey: ['energy-meters'],
+ queryFn: async () => {
+ const res = await apiClient.GET('/api/energy/meters')
+ return res.data
+ },
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Mutation: declare a new meter epoch (swap / home move / initial)
+// ---------------------------------------------------------------------------
+
+export function useDeclareMeter() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (body: MeterDeclareRequest) =>
+ apiClient.POST('/api/energy/meters', { body }),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['energy-meters'] })
+ // Invalidate cost-related queries: a new meter may trigger recompute server-side.
+ void qc.invalidateQueries({ queryKey: ['energy-costs'] })
+ void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
+ },
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Mutation: update (PATCH) a meter epoch (label / note / started_at)
+// ---------------------------------------------------------------------------
+
+export function useUpdateMeter() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, body }: { id: number; body: MeterPatchRequest }) =>
+ apiClient.PATCH('/api/energy/meters/{meter_id}', {
+ params: { path: { meter_id: id } },
+ body,
+ }),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['energy-meters'] })
+ // Retroactive started_at correction triggers recompute server-side.
+ void qc.invalidateQueries({ queryKey: ['energy-costs'] })
+ void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
+ },
+ })
+}
+
// ---------------------------------------------------------------------------
// Query: time-range readings for a device (window + limit — never full-table)
// ---------------------------------------------------------------------------
diff --git a/frontend/src/pages/EnergyPage.test.tsx b/frontend/src/pages/EnergyPage.test.tsx
index 0e0c518..fbaa1f9 100644
--- a/frontend/src/pages/EnergyPage.test.tsx
+++ b/frontend/src/pages/EnergyPage.test.tsx
@@ -381,6 +381,44 @@ describe('EnergyPage — test-read', () => {
})
})
+describe('EnergyPage — meters tab', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupDefaultMocks()
+ })
+
+ it('renders Meters tab in the tab list', async () => {
+ renderEnergy()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tab-meters')).toBeInTheDocument()
+ })
+ expect(screen.getByTestId('tab-meters').textContent).toContain('Meters')
+ })
+
+ it('renders meters panel when Meters tab is clicked', async () => {
+ mockGet.mockImplementation((path: string) => {
+ if (path === '/api/modbus/devices') {
+ return Promise.resolve({ data: { items: [DEVICE], total: 1 } })
+ }
+ if (path === '/api/energy/meters') {
+ return Promise.resolve({ data: { items: [], total: 0 } })
+ }
+ return Promise.resolve({ data: null })
+ })
+
+ renderEnergy()
+
+ await waitFor(() => expect(screen.getByTestId('tab-meters')).toBeInTheDocument())
+
+ fireEvent.click(screen.getByTestId('tab-meters'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('panel-meters')).toBeInTheDocument()
+ })
+ })
+})
+
describe('EnergyPage — auto-refresh switch', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx
index fbaafb3..f5f4fbe 100644
--- a/frontend/src/pages/EnergyPage.tsx
+++ b/frontend/src/pages/EnergyPage.tsx
@@ -42,6 +42,7 @@ import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMe
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager'
+import { MeterManager } from '../energy/MeterManager'
import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView'
import { DsmrPanel } from '../energy/DsmrPanel'
@@ -661,6 +662,9 @@ export function EnergyPage() {
Devices
+
+ Meters
+
Contracts
@@ -679,6 +683,10 @@ export function EnergyPage() {
+
+
+
+