diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx
index a8704c5..de38fee 100644
--- a/frontend/src/energy/MeterManager.test.tsx
+++ b/frontend/src/energy/MeterManager.test.tsx
@@ -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()
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()
+ 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()
+ 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 () => {
diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx
index 7f93519..6a45a00 100644
--- a/frontend/src/energy/MeterManager.tsx
+++ b/frontend/src/energy/MeterManager.tsx
@@ -167,6 +167,34 @@ function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): Meter
return target
}
+function BindingTimeline({ binding, sources }: {
+ binding: NonNullable[number]
+ sources: ReturnType
+}) {
+ 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 (
+
+ {endpoint} → {channelName}
+
+ [{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
+ {' '}({binding.ended_at ? 'closed' : 'active'})
+
+
+ )
+}
+
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
@@ -515,11 +543,12 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
interface MeterTableProps {
meters: MeterResponse[]
+ sources: ReturnType
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 (
@@ -585,30 +614,24 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
{meter.bindings?.length ? meter.bindings.map((binding) => (
-
- {binding.source_uuid} → {binding.source_channel_uuid}
-
- [{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
- {' '}({binding.ended_at ? 'closed' : 'active'})
-
-
+
)) : Unbound}
- {meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
-
- ))}
- {isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && }
- {isActive && }
+ {meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
+
+ ))}
+ {isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && }
+ {isActive && }
@@ -622,7 +645,7 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
const [opened, setOpened] = useState(false)
- return <>{}{opened && setOpened(false)} />}>
+ return <>{}{opened && 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 <>
-
- {meter.ended_at === null ? : recoveryTarget && recoverySourceIsClosable && }
+ {meter.ended_at === null ? : recoveryTarget && recoverySourceIsClosable && }
+
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && Cannot recover: the source binding starts at or after this Meter ended.}
{unbindOpened && setUnbindOpened(false)} />}
{transferOpened && 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(null)
@@ -845,7 +869,7 @@ export function MeterManager() {
)}
- setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
+ setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
diff --git a/frontend/src/energy/TibberPrices.test.tsx b/frontend/src/energy/TibberPrices.test.tsx
index 6e3e202..a3c8bf9 100644
--- a/frontend/src/energy/TibberPrices.test.tsx
+++ b/frontend/src/energy/TibberPrices.test.tsx
@@ -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()
+ 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()
diff --git a/frontend/src/energy/TibberPrices.tsx b/frontend/src/energy/TibberPrices.tsx
index fd3f78f..010e23e 100644
--- a/frontend/src/energy/TibberPrices.tsx
+++ b/frontend/src/energy/TibberPrices.tsx
@@ -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[] {
+ 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() {
Thermal contract snapshot
Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'}
This is a contract snapshot, not a 15-minute market spot price.
- {Object.entries(data.values).map(([section, values]) => (
-
- {section}: {Object.entries(values).map(([key, value]) =>
- `${key} ${value} ${thermalUnit(section, key, currency)}`,
- ).join(', ')}
-
- ))}
+
+
+
+
+ Category
+ Charge
+ Rate
+ Unit
+
+
+
+ {thermalSections(data.values!).flatMap((section) => Object.entries(data.values![section]).map(([key, rate]) => (
+
+ {humanizeThermalField(section)}
+ {humanizeThermalField(key)}
+ {rate}
+ {thermalUnit(section, key, currency)}
+
+ )))}
+
+
+
)}