M7-T06: add meter management UI (timeline, declare swap, edit) to Energy view

This commit is contained in:
2026-06-25 17:01:54 +02:00
parent 839faaf95b
commit 33f5de0591
6 changed files with 1244 additions and 0 deletions
+278
View File
@@ -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;
+357
View File
@@ -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(<MeterManager />)
expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
})
it('renders error state when GET fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderWithProviders(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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(<MeterManager />)
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')
})
})
+497
View File
@@ -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<string | null>(null)
const [note, setNote] = useState('')
const [error, setError] = useState<string | null>(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 (
<Modal
opened
onClose={onClose}
title="Declare New Meter"
size="md"
data-testid="declare-meter-modal"
>
<form onSubmit={handleSubmit} data-testid="declare-meter-form">
<Stack gap="sm">
<TextInput
label="Label"
description={'Human-readable identifier, e.g. "2G meter @ Dorpsstraat 1"'}
required
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
data-testid="meter-label"
/>
<TextInput
label="Start date"
description="Date in YYYY-MM-DD format (interpreted as local midnight)"
type="date"
required
value={dateStr}
onChange={(e) => setDateStr(e.currentTarget.value)}
data-testid="meter-started-at"
/>
<Select
label="Reason"
required
data={REASON_OPTIONS}
value={reason}
onChange={setReason}
data-testid="meter-reason"
/>
<Textarea
label="Note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
data-testid="meter-note"
/>
{error && (
<Alert color="red" data-testid="declare-meter-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="declare-meter-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={declareMutation.isPending}
data-testid="declare-meter-submit"
>
Declare Meter
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
// ---------------------------------------------------------------------------
// 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 <input type="date">.
// 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<string | null>(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 (
<Modal
opened
onClose={onClose}
title={`Edit Meter — ${meter.label}`}
size="md"
data-testid="edit-meter-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-meter-form">
<Stack gap="sm">
<TextInput
label="Label"
required
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
data-testid="edit-meter-label"
/>
<TextInput
label="Start date"
description="Retroactive correction: shifts the epoch boundary and re-judges billing periods"
type="date"
value={dateStr}
onChange={(e) => setDateStr(e.currentTarget.value)}
data-testid="edit-meter-started-at"
/>
<Textarea
label="Note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
data-testid="edit-meter-note"
/>
{error && (
<Alert color="red" data-testid="edit-meter-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="edit-meter-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-meter-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Meter timeline table
// ---------------------------------------------------------------------------
interface MeterTableProps {
meters: MeterResponse[]
onEdit: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit }: MeterTableProps) {
if (meters.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
No meters declared yet. Click "Declare New Meter" to add one.
</Text>
)
}
return (
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="meters-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Commodity</Table.Th>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{meters.map((meter) => {
const isActive = meter.ended_at === null
return (
<Table.Tr key={meter.id} data-testid={`meter-row-${meter.id}`}>
<Table.Td>
<Text fw={500} size="sm">
{meter.label}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{meter.commodity}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatLocalDate(meter.started_at)}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{meter.ended_at ? formatLocalDate(meter.ended_at) : '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge
color={isActive ? 'green' : 'gray'}
variant="light"
size="sm"
data-testid={`meter-status-${meter.id}`}
>
{isActive ? 'active' : 'closed'}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm" color="blue">
{meter.reason}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`}
>
Edit
</Button>
</Group>
</Table.Td>
</Table.Tr>
)
})}
</Table.Tbody>
</Table>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
export function MeterManager() {
const metersQuery = useMeters()
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
const [recomputeNotice, setRecomputeNotice] = useState(false)
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (metersQuery.isLoading) {
return (
<Center py="xl" data-testid="meters-loading">
<Loader />
</Center>
)
}
if (metersQuery.isError || !metersQuery.data) {
return (
<Alert color="red" data-testid="meters-load-error">
Failed to load meters. Please refresh.
</Alert>
)
}
const meters = metersQuery.data.items
return (
<Stack gap="lg" data-testid="meter-manager">
<Group justify="space-between" align="center">
<Text fw={500}>Electricity Meters</Text>
<Button
onClick={() => setShowDeclareForm(true)}
data-testid="meter-declare-button"
>
Declare New Meter
</Button>
</Group>
{recomputeNotice && (
<Notification
color="teal"
title="Billing periods recomputed"
onClose={() => setRecomputeNotice(false)}
data-testid="meter-recompute-notice"
>
The start date was corrected. Affected billing periods have been
re-judged and cost attributions updated.
</Notification>
)}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
<DeclareMeterForm
onClose={() => setShowDeclareForm(false)}
onSaved={() => setShowDeclareForm(false)}
/>
)}
{/* Edit existing meter */}
{editMeter && (
<EditMeterForm
meter={editMeter}
onClose={() => setEditMeter(null)}
onSaved={(retroactive) => {
setEditMeter(null)
if (retroactive) setRecomputeNotice(true)
}}
/>
)}
</Stack>
)
}
+66
View File
@@ -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)
// ---------------------------------------------------------------------------
+38
View File
@@ -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()
+8
View File
@@ -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() {
<Tabs.Tab value="devices" data-testid="tab-devices">
Devices
</Tabs.Tab>
<Tabs.Tab value="meters" data-testid="tab-meters">
Meters
</Tabs.Tab>
<Tabs.Tab value="contracts" data-testid="tab-contracts">
Contracts
</Tabs.Tab>
@@ -679,6 +683,10 @@ export function EnergyPage() {
<DevicesTab />
</Tabs.Panel>
<Tabs.Panel value="meters" data-testid="panel-meters">
<MeterManager />
</Tabs.Panel>
<Tabs.Panel value="contracts" data-testid="panel-contracts">
<ContractManager />
</Tabs.Panel>