M8-R13: simplify prices and meter controls
frontend / frontend (push) Successful in 53s
pytest / test (push) Successful in 4m34s
docker-image / build-and-push (push) Successful in 1m49s

This commit is contained in:
2026-08-27 20:01:41 +02:00
parent c9ce05d95a
commit b472f91f19
4 changed files with 145 additions and 34 deletions
+44 -3
View File
@@ -209,14 +209,55 @@ describe('MeterManager — meter list', () => {
{ uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
],
}
mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } })
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meterWithBindings], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [
{ uuid: 'source-old', name: 'Old source' }, { uuid: 'source-new', name: 'New source' },
], total: 2 } })
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [
{ uuid: 'channel-old', label: 'Old channel', unit: 'kWh' }, { uuid: 'channel-new', label: 'New channel', unit: 'kWh' },
], total: 2 } })
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
const closed = await screen.findByTestId('binding-timeline-binding-closed')
await waitFor(() => expect(closed).toHaveTextContent('Old source → Old channel'))
const active = screen.getByTestId('binding-timeline-binding-active')
expect(closed).toHaveTextContent('source-old → channel-old')
expect(closed).toHaveTextContent('Old source → Old channel')
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
expect(active).toHaveTextContent('source-new → channel-new')
expect(active).toHaveTextContent('New source → New channel')
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
expect(closed).not.toHaveTextContent('source-old')
expect(active).not.toHaveTextContent('channel-new')
})
it('uses UUID-free honest binding fallbacks while source details load or fail', async () => {
const meter = { ...ACTIVE_METER, bindings: [{ uuid: 'binding-visible-id-only', source_uuid: 'private-source-id', source_channel_uuid: 'private-channel-id', started_at: '2025-01-01T00:00:00Z', ended_at: null }] }
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meter], total: 1 } })
if (path === '/api/energy/sources') return new Promise(() => {})
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
const timeline = await screen.findByTestId('binding-timeline-binding-visible-id-only')
expect(timeline).toHaveTextContent('Loading source details… → Channel details unavailable')
expect(timeline).not.toHaveTextContent('private-source-id')
expect(timeline).not.toHaveTextContent('private-channel-id')
})
it('keeps meter row actions in the shared light, xs order', async () => {
const binding = { uuid: 'ordered-binding', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2024-01-15T00:00:00Z', ended_at: null }
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{ ...ACTIVE_METER, bindings: [binding] }], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }], total: 1 } })
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [{ uuid: 'channel-1', label: 'Total import', unit: 'kWh' }], total: 1 } })
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
const row = await screen.findByTestId(`meter-row-${ACTIVE_METER.id}`)
const buttons = Array.from(row.querySelectorAll('button'))
expect(buttons.map((button) => button.textContent)).toEqual(['Edit', 'Transfer source', 'Unbind', 'Close meter'])
expect(buttons).toHaveLength(4)
})
it('renders "Declare New Meter" button', async () => {
+42 -18
View File
@@ -167,6 +167,34 @@ function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): Meter
return target
}
function BindingTimeline({ binding, sources }: {
binding: NonNullable<MeterResponse['bindings']>[number]
sources: ReturnType<typeof useSources>
}) {
const source = sources.data?.items.find((item) => item.uuid === binding.source_uuid)
const channels = useSourceChannels(source?.uuid ?? null)
const channel = channels.data?.items.find((item) => item.uuid === binding.source_channel_uuid)
let endpoint = 'Source details unavailable'
if (sources.isLoading) endpoint = 'Loading source details…'
else if (!sources.isError && source) endpoint = source.name
else if (!sources.isError) endpoint = 'Source unavailable'
let channelName = 'Channel details unavailable'
if (source && channels.isLoading) channelName = 'Loading channel details…'
else if (source && !channels.isError && channel) channelName = channel.label
else if (source && !channels.isError) channelName = 'Channel unavailable'
return (
<Stack gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{endpoint} {channelName}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
)
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
@@ -515,11 +543,12 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
interface MeterTableProps {
meters: MeterResponse[]
sources: ReturnType<typeof useSources>
onEdit: (meter: MeterResponse) => void
onClose: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function MeterTable({ meters, sources, onEdit, onClose }: MeterTableProps) {
if (meters.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
@@ -585,30 +614,24 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
</Table.Td>
<Table.Td>
{meter.bindings?.length ? meter.bindings.map((binding) => (
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{binding.source_uuid} {binding.source_channel_uuid}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
<BindingTimeline key={binding.uuid} binding={binding} sources={sources} />
)) : <Text size="xs" c="dimmed">Unbound</Text>}
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
))}
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
<Button
size="xs"
variant="outline"
variant="light"
onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`}
>
Edit
</Button>
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
))}
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
</Group>
</Table.Td>
</Table.Tr>
@@ -622,7 +645,7 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
const [opened, setOpened] = useState(false)
return <>{<Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
return <>{<Button size="xs" variant="light" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
}
function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) {
@@ -676,8 +699,8 @@ function BindingActions({ meter, binding, meters }: { meter: MeterResponse; bind
const recoverySourceIsClosable = meter.ended_at === null ||
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
return <>
<Button size="xs" variant="subtle" onClick={() => setUnbindOpened(true)}>Unbind</Button>
{meter.ended_at === null ? <Button size="xs" variant="subtle" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
{meter.ended_at === null ? <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
<Button size="xs" variant="light" onClick={() => setUnbindOpened(true)}>Unbind</Button>
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>}
{unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />}
@@ -793,6 +816,7 @@ function CloseMeterModal({ meter, onClose }: { meter: MeterResponse; onClose: ()
export function MeterManager() {
const metersQuery = useMeters()
const sources = useSources()
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
@@ -845,7 +869,7 @@ export function MeterManager() {
</Notification>
)}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
<MeterTable meters={meters} sources={sources} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
+23 -6
View File
@@ -245,7 +245,7 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
})
it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot with units', async () => {
it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot as a table', async () => {
const user = userEvent.setup()
const thermal = {
kind: 'district_heating', currency: 'EUR', points: [], tariff: null,
@@ -273,15 +273,19 @@ describe('TibberPrices', () => {
await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument())
const snapshot = screen.getByTestId('thermal-price-snapshot')
const table = screen.getByTestId('thermal-price-table')
expect(snapshot).toHaveTextContent('Version 42')
expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z')
expect(snapshot).toHaveTextContent('not a 15-minute market spot price')
expect(snapshot).toHaveTextContent('20.123456789123456789 EUR/GJ')
expect(snapshot).toHaveTextContent('8.200000000000000001 EUR/m³')
expect(snapshot).toHaveTextContent('1.234567890123456789 EUR/m³')
expect(snapshot).toHaveTextContent('0.456789012345678901 EUR/m³')
expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
'Category', 'Charge', 'Rate', 'Unit',
])
expect(table.querySelectorAll('tbody tr')).toHaveLength(9)
expect(screen.getByTestId('thermal-price-row-variable-heating')).toHaveTextContent('VariableHeating20.123456789123456789EUR/GJ')
expect(screen.getByTestId('thermal-price-row-variable-hot_water_heating')).toHaveTextContent('Hot Water Heating8.200000000000000001EUR/m³')
expect(screen.getByTestId('thermal-price-row-standing-heating_network')).toHaveTextContent('StandingHeating Network100.000000000000000001EUR/year')
for (const value of Object.values(thermal.values.standing)) {
expect(snapshot).toHaveTextContent(`${value} EUR/year`)
expect(table).toHaveTextContent(value)
}
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({
params: { query: expect.objectContaining({ scope: 'thermal' }) },
@@ -292,6 +296,19 @@ describe('TibberPrices', () => {
expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument()
})
it('keeps unknown thermal snapshot fields with safe human-readable labels', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) => Promise.resolve({
data: options?.params?.query?.scope === 'thermal'
? { kind: 'district_heating', currency: 'EUR', values: { future_fee: { experimental_charge: '1.000000000000000001' } } }
: { kind: 'manual', currency: 'EUR', points: [], tariff: { buy_dal: 0.1, buy_normal: 0.2, sell_dal: 0.03, sell_normal: 0.04 } },
}))
renderWithProviders(<TibberPrices />)
await user.click(await screen.findByText('Thermal'))
const row = await screen.findByTestId('thermal-price-row-future_fee-experimental_charge')
expect(row).toHaveTextContent('Future FeeExperimental Charge1.000000000000000001EUR')
})
it('marks the currently active price slot with a dot and a caption', async () => {
installChartSize()
+36 -7
View File
@@ -23,6 +23,7 @@ import {
Group,
Paper,
SegmentedControl,
ScrollArea,
} from '@mantine/core'
import {
LineChart,
@@ -60,6 +61,19 @@ function thermalUnit(section: string, key: string, currency: string): string {
return currency
}
function humanizeThermalField(value: string): string {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function thermalSections(values: Record<string, Record<string, string>>): string[] {
const preferred = ['variable', 'standing']
return [...preferred.filter((section) => section in values), ...Object.keys(values)
.filter((section) => !preferred.includes(section))
.sort()]
}
// ---------------------------------------------------------------------------
// Time range helpers
// ---------------------------------------------------------------------------
@@ -399,13 +413,28 @@ export function TibberPrices() {
<Text fw={500}>Thermal contract snapshot</Text>
<Text size="sm">Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'}</Text>
<Text size="sm" c="dimmed">This is a contract snapshot, not a 15-minute market spot price.</Text>
{Object.entries(data.values).map(([section, values]) => (
<Text size="sm" key={section} data-testid={`thermal-price-section-${section}`}>
{section}: {Object.entries(values).map(([key, value]) =>
`${key} ${value} ${thermalUnit(section, key, currency)}`,
).join(', ')}
</Text>
))}
<ScrollArea type="auto">
<Table withTableBorder withColumnBorders data-testid="thermal-price-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Category</Table.Th>
<Table.Th>Charge</Table.Th>
<Table.Th>Rate</Table.Th>
<Table.Th>Unit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{thermalSections(data.values!).flatMap((section) => Object.entries(data.values![section]).map(([key, rate]) => (
<Table.Tr key={`${section}-${key}`} data-testid={`thermal-price-row-${section}-${key}`}>
<Table.Td>{humanizeThermalField(section)}</Table.Td>
<Table.Td>{humanizeThermalField(key)}</Table.Td>
<Table.Td>{rate}</Table.Td>
<Table.Td>{thermalUnit(section, key, currency)}</Table.Td>
</Table.Tr>
)))}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
</Paper>
)}