Compare commits

..
4 Commits
Author SHA1 Message Date
tliu93 8180082f90 M8-R14: preserve zero DSMR sample interval
docker-image / build-and-push (push) Successful in 1m35s
frontend / frontend (push) Successful in 47s
pytest / test (push) Successful in 4m15s
2026-08-27 21:29:45 +02:00
tliu93 b472f91f19 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
2026-08-27 20:01:41 +02:00
tliu93 c9ce05d95a M8-R12: align and format thermal cost UI 2026-08-27 19:18:03 +02:00
tliu93 33ca3da593 M8-R11: make business timezone deterministic
frontend / frontend (push) Successful in 46s
pytest / test (push) Successful in 3m56s
2026-08-27 12:16:52 +02:00
15 changed files with 395 additions and 67 deletions
+1
View File
@@ -14,6 +14,7 @@ AUTH_BOOTSTRAP_PASSWORD=change-me
# Optional: runtime overrides. # Optional: runtime overrides.
# Leave these commented out to use the application's built-in defaults. # Leave these commented out to use the application's built-in defaults.
# TZ=Europe/Amsterdam
# APP_DEBUG= # APP_DEBUG=
# AUTH_SESSION_COOKIE_NAME= # AUTH_SESSION_COOKIE_NAME=
# AUTH_SESSION_TTL_HOURS= # AUTH_SESSION_TTL_HOURS=
+3 -1
View File
@@ -98,8 +98,10 @@ def _validate_field_value(kind: str, field: SourceConfigField, value: Any) -> No
_check_type(field, value) _check_type(field, value)
if field.name == "path" and not value.startswith("/dev/"): if field.name == "path" and not value.startswith("/dev/"):
raise SourceProfileError("warmtelink_serial config path must start with '/dev/'.") raise SourceProfileError("warmtelink_serial config path must start with '/dev/'.")
if field.name in {"broker_port", "sample_interval_s", "baudrate"} and value <= 0: if field.name in {"broker_port", "baudrate"} and value <= 0:
raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.") raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.")
if field.name == "sample_interval_s" and value < 0:
raise SourceProfileError("Config field 'sample_interval_s' must not be negative.")
if field.name == "data_bits" and value != 7: if field.name == "data_bits" and value != 7:
raise SourceProfileError("warmtelink_serial data_bits must be 7.") raise SourceProfileError("warmtelink_serial data_bits must be 7.")
if field.name == "parity" and value != "N": if field.name == "parity" and value != "N":
+4 -6
View File
@@ -19,8 +19,8 @@ Priority for resolving the local timezone
----------------------------------------- -----------------------------------------
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``. 1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling. Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``. 2. The DST-aware ``Europe/Amsterdam`` business timezone. This keeps local-day
This matches the behaviour callers already relied on implicitly. calculations deterministic when a deployment does not set ``TZ``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,7 +40,7 @@ def local_tz() -> "tzinfo":
Resolution order: Resolution order:
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set 1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling. ``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
2. System local timezone via ``datetime.now().astimezone().tzinfo``. 2. The DST-aware ``Europe/Amsterdam`` business timezone.
**Monkeypatch this function in tests** to get deterministic timezone **Monkeypatch this function in tests** to get deterministic timezone
behaviour regardless of CI host configuration:: behaviour regardless of CI host configuration::
@@ -51,9 +51,7 @@ def local_tz() -> "tzinfo":
tz_env = os.environ.get("TZ", "").strip() tz_env = os.environ.get("TZ", "").strip()
if tz_env: if tz_env:
return ZoneInfo(tz_env) return ZoneInfo(tz_env)
# System fallback — identical to the .astimezone() pattern already used return ZoneInfo("Europe/Amsterdam")
# in homeassistant_inbound.py and poo.py.
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
def to_local(dt: datetime) -> datetime: def to_local(dt: datetime) -> datetime:
+4
View File
@@ -6,6 +6,8 @@ services:
restart: "no" restart: "no"
init: true init: true
command: ["python", "-m", "scripts.run_migrations"] command: ["python", "-m", "scripts.run_migrations"]
environment:
TZ: "${TZ:-Europe/Amsterdam}"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
@@ -17,6 +19,8 @@ services:
user: "1000:1000" user: "1000:1000"
restart: unless-stopped restart: unless-stopped
init: true init: true
environment:
TZ: "${TZ:-Europe/Amsterdam}"
depends_on: depends_on:
migration: migration:
condition: service_completed_successfully condition: service_completed_successfully
+89 -2
View File
@@ -79,6 +79,23 @@ const THERMAL_SUMMARY = {
period_count: 4, degraded_count: 2, period_count: 4, degraded_count: 2,
fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' }, fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
} }
const ROUNDED_THERMAL_SUMMARY = {
...THERMAL_SUMMARY,
heating: '20.12344',
hot_water_heating: '8.20005',
hot_water: '1.99995',
hot_water_tax: '0.40000',
variable_subtotal: '7.0000',
fixed_subtotal: '0.00004',
all_in: '9.99995',
fixed_breakdown: {
heating_network: '100.000000000000000001',
metering: '0.0000',
delivery_set: '20.20000',
hot_water_network: '30.30004',
other: '40.40005',
},
}
const THERMAL_VALUES = { const THERMAL_VALUES = {
variable: { variable: {
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001', heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
@@ -95,6 +112,12 @@ const THERMAL_ROW = {
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES, cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
quality: 'unverifiable', degraded: false, degraded_reason: null, quality: 'unverifiable', degraded: false, degraded_reason: null,
} }
const ROUNDED_THERMAL_ROW = {
...THERMAL_ROW,
quantity: '1.23456',
cost: '0.12344',
cost_breakdown: { heating: '0.12345', hot_water: '0.10000' },
}
const THERMAL_ROW_OTHER_VERSION = { const THERMAL_ROW_OTHER_VERSION = {
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z', ...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z',
contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170', contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
@@ -233,6 +256,32 @@ describe('CostView', () => {
}) })
}) })
it('uses the same bottom-aligned toolbar structure in both scopes', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
renderWithProviders(<CostView />)
const electricityToolbar = screen.getByTestId('cost-toolbar')
expect(electricityToolbar).toContainElement(screen.getByTestId('costs-scope-selector'))
expect(electricityToolbar).toContainElement(screen.getByTestId('cost-range-control'))
expect(electricityToolbar).toContainElement(screen.getByTestId('cost-recompute-button'))
expect(screen.getByTestId('cost-recompute-button')).toHaveStyle({ marginLeft: 'auto' })
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-toolbar')).toBeInTheDocument())
const thermalToolbar = screen.getByTestId('thermal-cost-toolbar')
expect(thermalToolbar).toContainElement(screen.getByTestId('costs-scope-selector'))
expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-cost-range-control'))
expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-recompute-button'))
expect(screen.getByTestId('thermal-recompute-button')).toHaveStyle({ marginLeft: 'auto' })
})
it('calls recompute mutation when confirmed', async () => { it('calls recompute mutation when confirmed', async () => {
const user = userEvent.setup() const user = userEvent.setup()
@@ -349,8 +398,8 @@ describe('CostView', () => {
it.each([ it.each([
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'], ['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'],
['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'], ['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'],
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'], ['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'],
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'], ['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'],
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => { ])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
mockGet.mockImplementation((path: string) => { mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } }) if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
@@ -373,6 +422,44 @@ describe('CostView', () => {
} }
}) })
it('rounds ordinary thermal Decimal strings without changing the audit snapshot', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') {
return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
}
if (path === '/api/energy/meter-costs') {
return Promise.resolve({ data: { items: [ROUNDED_THERMAL_ROW], total: 1 } })
}
if (path === '/api/energy/meter-costs/summary') {
return Promise.resolve({ data: ROUNDED_THERMAL_SUMMARY })
}
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
renderWithProviders(<CostView />)
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Heating20.1234')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water heating8.2001')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot water2')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water tax0.4')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Variable subtotal7')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Fixed subtotal0')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('All-in total10')
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('heating_network 100')
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('other 40.4001')
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('1.2346')
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('0.1234 EUR')
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('heating 0.1235, hot_water 0.1')
await user.click(screen.getByTestId('thermal-cost-expand-0'))
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
})
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => { it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' } const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
+59 -17
View File
@@ -110,6 +110,49 @@ function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
type RangePreset = 'today' | 'month' | 'custom' type RangePreset = 'today' | 'month' | 'custom'
/** Format an API Decimal string without converting it through binary floating point. */
function formatDecimal(value: string): string {
const negative = value.startsWith('-')
const unsigned = negative ? value.slice(1) : value
const [rawWhole = '0', rawFraction = ''] = unsigned.split('.')
let whole = rawWhole.replace(/^0+(?=\d)/, '') || '0'
let fraction = rawFraction.slice(0, 4)
if (rawFraction.length > 4 && rawFraction[4] >= '5') {
const digits = '0123456789'
const fractionDigits = fraction.split('')
let carry = true
for (let index = fractionDigits.length - 1; index >= 0 && carry; index -= 1) {
const digit = fractionDigits[index]
if (digit === '9') {
fractionDigits[index] = '0'
} else {
fractionDigits[index] = digits[digits.indexOf(digit) + 1]
carry = false
}
}
fraction = fractionDigits.join('')
if (carry) {
const wholeDigits = whole.split('')
for (let index = wholeDigits.length - 1; index >= 0 && carry; index -= 1) {
const digit = wholeDigits[index]
if (digit === '9') {
wholeDigits[index] = '0'
} else {
wholeDigits[index] = digits[digits.indexOf(digit) + 1]
carry = false
}
}
whole = `${carry ? '1' : ''}${wholeDigits.join('')}`
}
}
fraction = fraction.replace(/0+$/, '')
const formatted = fraction ? `${whole}.${fraction}` : whole
return negative && formatted !== '0' ? `-${formatted}` : formatted
}
export function CostView() { export function CostView() {
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} /> if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} />
@@ -155,7 +198,7 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr
return ( return (
<Stack gap="lg" data-testid="cost-view"> <Stack gap="lg" data-testid="cost-view">
{/* Date range selector */} {/* Date range selector */}
<Group align="flex-start" gap="md" wrap="wrap"> <Group align="flex-end" gap="md" wrap="wrap" data-testid="cost-toolbar">
<ScopeSelector scope="electricity" onScopeChange={onScopeChange} /> <ScopeSelector scope="electricity" onScopeChange={onScopeChange} />
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -192,18 +235,17 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr
</Group> </Group>
)} )}
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end"> <Button
<Button variant="outline"
variant="outline" color="orange"
color="orange" size="sm"
size="sm" onClick={() => setShowRecomputeConfirm(true)}
onClick={() => setShowRecomputeConfirm(true)} loading={recomputeMutation.isPending}
loading={recomputeMutation.isPending} style={{ marginLeft: 'auto' }}
data-testid="cost-recompute-button" data-testid="cost-recompute-button"
> >
Recompute Recompute
</Button> </Button>
</Group>
</Group> </Group>
{/* Summary cards */} {/* Summary cards */}
@@ -483,16 +525,16 @@ function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricit
const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1 const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1
const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows) const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows)
return <Stack gap="lg" data-testid="thermal-cost-view"> return <Stack gap="lg" data-testid="thermal-cost-view">
<Group align="flex-start" gap="md" wrap="wrap"><ScopeSelector scope="thermal" onScopeChange={onScopeChange} /><Stack gap="xs"><Text size="sm" fw={500}>Date range</Text><SegmentedControl value={rangePreset} onChange={(value) => { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /></Stack>{rangePreset === 'custom' && <Group gap="sm" align="flex-end"><TextInput label="From" type="date" value={customStartStr} onChange={(event) => { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /><TextInput label="To" type="date" value={customEndStr} onChange={(event) => { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /></Group>}<Button variant="outline" color="orange" onClick={() => { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} data-testid="thermal-recompute-button">Recompute</Button></Group> <Group align="flex-end" gap="md" wrap="wrap" data-testid="thermal-cost-toolbar"><ScopeSelector scope="thermal" onScopeChange={onScopeChange} /><Stack gap="xs"><Text size="sm" fw={500}>Date range</Text><SegmentedControl value={rangePreset} onChange={(value) => { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /></Stack>{rangePreset === 'custom' && <Group gap="sm" align="flex-end"><TextInput label="From" type="date" value={customStartStr} onChange={(event) => { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /><TextInput label="To" type="date" value={customEndStr} onChange={(event) => { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /></Group>}<Button variant="outline" color="orange" onClick={() => { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} style={{ marginLeft: 'auto' }} data-testid="thermal-recompute-button">Recompute</Button></Group>
{(rows.isLoading || summary.isLoading) && <Center><Loader size="sm" /></Center>} {(rows.isLoading || summary.isLoading) && <Center><Loader size="sm" /></Center>}
{(rows.isError || summary.isError) && <Alert color="red">Failed to load thermal costs.</Alert>} {(rows.isError || summary.isError) && <Alert color="red">Failed to load thermal costs.</Alert>}
{recomputeError && <Alert color="red" data-testid="thermal-recompute-error">{recomputeError}</Alert>} {recomputeError && <Alert color="red" data-testid="thermal-recompute-error">{recomputeError}</Alert>}
{recomputeSuccess && <Alert color="green" data-testid="thermal-recompute-success">{recomputeSuccess}</Alert>} {recomputeSuccess && <Alert color="green" data-testid="thermal-recompute-success">{recomputeSuccess}</Alert>}
{summary.data && <Stack gap="xs" data-testid="thermal-cost-summary"><Title order={6}>{rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency})</Title><Text size="sm" data-testid="thermal-cost-range">{start ?? 'Select a start date'} {end ?? 'Select an end date'}</Text><SimpleGrid cols={{ base: 2, sm: 3 }}> {summary.data && <Stack gap="xs" data-testid="thermal-cost-summary"><Title order={6}>{rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency})</Title><Text size="sm" data-testid="thermal-cost-range">{start ?? 'Select a start date'} {end ?? 'Select an end date'}</Text><SimpleGrid cols={{ base: 2, sm: 3 }}>
<SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : summary.data.heating} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_heating} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_tax} /><SummaryCard label="Variable subtotal" value={summary.data.variable_subtotal} /><SummaryCard label="Fixed subtotal" value={summary.data.fixed_subtotal} /><SummaryCard label="All-in total" value={summary.data.all_in} /> <SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : formatDecimal(summary.data.heating)} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water_heating)} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water)} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water_tax)} /><SummaryCard label="Variable subtotal" value={formatDecimal(summary.data.variable_subtotal)} /><SummaryCard label="Fixed subtotal" value={formatDecimal(summary.data.fixed_subtotal)} /><SummaryCard label="All-in total" value={formatDecimal(summary.data.all_in)} />
</SimpleGrid>{missingCurrentMeters.length > 0 && <Alert color="yellow" data-testid="thermal-missing-current-meter">Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.</Alert>}<Text size="sm" data-testid="thermal-period-count">{summary.data.period_count} periods; {summary.data.degraded_count} degraded</Text>{summary.data.degraded_count > 0 && <Alert color="orange" data-testid="thermal-summary-degraded">Some totals include degraded periods. Expand a row to see its recorded reason.</Alert>}{fixed && <Text size="sm" data-testid="thermal-fixed-breakdown">Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')}</Text>}</Stack>} </SimpleGrid>{missingCurrentMeters.length > 0 && <Alert color="yellow" data-testid="thermal-missing-current-meter">Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.</Alert>}<Text size="sm" data-testid="thermal-period-count">{summary.data.period_count} periods; {summary.data.degraded_count} degraded</Text>{summary.data.degraded_count > 0 && <Alert color="orange" data-testid="thermal-summary-degraded">Some totals include degraded periods. Expand a row to see its recorded reason.</Alert>}{fixed && <Text size="sm" data-testid="thermal-fixed-breakdown">Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${formatDecimal(value)}`).join(' · ')}</Text>}</Stack>}
{rows.data?.items.length === 0 && <Alert color="gray" data-testid="thermal-costs-empty">No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.</Alert>} {rows.data?.items.length === 0 && <Alert color="gray" data-testid="thermal-costs-empty">No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.</Alert>}
{rows.data && <Stack gap="xs"><Text size="sm" c="dimmed" data-testid="thermal-ledger-count">Showing {shownStart}-{shownEnd} of {totalRows}</Text>{rows.data.items.length > 0 && <ScrollArea><Table striped withTableBorder data-testid="thermal-costs-table"><Table.Thead><Table.Tr><Table.Th>Time</Table.Th><Table.Th>Commodity</Table.Th><Table.Th>Quantity</Table.Th><Table.Th>Cost</Table.Th><Table.Th>Breakdown</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th></Table.Tr></Table.Thead><Table.Tbody>{rows.data.items.flatMap((item, index) => [<Table.Tr key={`${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-row-${index}`}><Table.Td>{formatLocalTime(item.period_start)}</Table.Td><Table.Td>{item.commodity}</Table.Td><Table.Td>{item.quantity}</Table.Td><Table.Td>{item.cost} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')}</Table.Td><Table.Td>{item.degraded ? <Badge color="orange" data-testid={`thermal-degraded-${index}`}>{item.degraded_reason ?? 'degraded'}</Badge> : 'normal'}</Table.Td><Table.Td><Button size="xs" variant="subtle" onClick={() => setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'}</Button></Table.Td></Table.Tr>, ...(expandedRows.has(index) ? [<Table.Tr key={`audit-${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-audit-${index}`}><Table.Td colSpan={7}><Text size="xs">Contract version: {item.contract_version_id ?? 'none'}</Text><Text size="xs">Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}</Text></Table.Td></Table.Tr>] : [])])}</Table.Tbody></Table></ScrollArea>}<Group justify="flex-end"><Button size="xs" variant="default" disabled={ledgerOffset === 0} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous</Button><Button size="xs" variant="default" disabled={ledgerOffset + (rows.data.items.length ?? 0) >= totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next</Button></Group></Stack>} {rows.data && <Stack gap="xs"><Text size="sm" c="dimmed" data-testid="thermal-ledger-count">Showing {shownStart}-{shownEnd} of {totalRows}</Text>{rows.data.items.length > 0 && <ScrollArea><Table striped withTableBorder data-testid="thermal-costs-table"><Table.Thead><Table.Tr><Table.Th>Time</Table.Th><Table.Th>Commodity</Table.Th><Table.Th>Quantity</Table.Th><Table.Th>Cost</Table.Th><Table.Th>Breakdown</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th></Table.Tr></Table.Thead><Table.Tbody>{rows.data.items.flatMap((item, index) => [<Table.Tr key={`${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-row-${index}`}><Table.Td>{formatLocalTime(item.period_start)}</Table.Td><Table.Td>{item.commodity}</Table.Td><Table.Td>{formatDecimal(item.quantity)}</Table.Td><Table.Td>{formatDecimal(item.cost)} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${formatDecimal(value)}`).join(', ')}</Table.Td><Table.Td>{item.degraded ? <Badge color="orange" data-testid={`thermal-degraded-${index}`}>{item.degraded_reason ?? 'degraded'}</Badge> : 'normal'}</Table.Td><Table.Td><Button size="xs" variant="subtle" onClick={() => setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'}</Button></Table.Td></Table.Tr>, ...(expandedRows.has(index) ? [<Table.Tr key={`audit-${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-audit-${index}`}><Table.Td colSpan={7}><Text size="xs">Contract version: {item.contract_version_id ?? 'none'}</Text><Text size="xs">Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}</Text></Table.Td></Table.Tr>] : [])])}</Table.Tbody></Table></ScrollArea>}<Group justify="flex-end"><Button size="xs" variant="default" disabled={ledgerOffset === 0} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous</Button><Button size="xs" variant="default" disabled={ledgerOffset + (rows.data.items.length ?? 0) >= totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next</Button></Group></Stack>}
{showConfirm && <Modal opened onClose={() => setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal"><Stack><Text>This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} {closedEnd}. Continue?</Text>{!recomputeAvailable && <Alert color="yellow">Select a range containing at least one closed UTC quarter.</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel</Button><Button color="orange" loading={recompute.isPending} disabled={!recomputeAvailable} onClick={async () => { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute</Button></Group></Stack></Modal>} {showConfirm && <Modal opened onClose={() => setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal"><Stack><Text>This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} {closedEnd}. Continue?</Text>{!recomputeAvailable && <Alert color="yellow">Select a range containing at least one closed UTC quarter.</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel</Button><Button color="orange" loading={recompute.isPending} disabled={!recomputeAvailable} onClick={async () => { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute</Button></Group></Stack></Modal>}
</Stack> </Stack>
} }
+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 }, { 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 />) renderWithProviders(<MeterManager />)
const closed = await screen.findByTestId('binding-timeline-binding-closed') 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') 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(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(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 () => { it('renders "Declare New Meter" button', async () => {
+42 -18
View File
@@ -167,6 +167,34 @@ function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): Meter
return target 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) // Declare meter form (modal)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -515,11 +543,12 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
interface MeterTableProps { interface MeterTableProps {
meters: MeterResponse[] meters: MeterResponse[]
sources: ReturnType<typeof useSources>
onEdit: (meter: MeterResponse) => void onEdit: (meter: MeterResponse) => void
onClose: (meter: MeterResponse) => void onClose: (meter: MeterResponse) => void
} }
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) { function MeterTable({ meters, sources, onEdit, onClose }: MeterTableProps) {
if (meters.length === 0) { if (meters.length === 0) {
return ( return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty"> <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>
<Table.Td> <Table.Td>
{meter.bindings?.length ? meter.bindings.map((binding) => ( {meter.bindings?.length ? meter.bindings.map((binding) => (
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}> <BindingTimeline key={binding.uuid} binding={binding} sources={sources} />
<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>
)) : <Text size="xs" c="dimmed">Unbound</Text>} )) : <Text size="xs" c="dimmed">Unbound</Text>}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group justify="flex-end" gap="xs"> <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 <Button
size="xs" size="xs"
variant="outline" variant="light"
onClick={() => onEdit(meter)} onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`} data-testid={`meter-edit-${meter.id}`}
> >
Edit Edit
</Button> </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> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -622,7 +645,7 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) { function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
const [opened, setOpened] = useState(false) 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 }) { 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 || const recoverySourceIsClosable = meter.ended_at === null ||
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime() parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
return <> return <>
<Button size="xs" variant="subtle" onClick={() => setUnbindOpened(true)}>Unbind</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>}
{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>} <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>} {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)} />} {unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(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() { export function MeterManager() {
const metersQuery = useMeters() const metersQuery = useMeters()
const sources = useSources()
const [showDeclareForm, setShowDeclareForm] = useState(false) const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null) const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
@@ -845,7 +869,7 @@ export function MeterManager() {
</Notification> </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 */} {/* Declare new meter */}
{showDeclareForm && ( {showDeclareForm && (
+23 -6
View File
@@ -245,7 +245,7 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900') 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 user = userEvent.setup()
const thermal = { const thermal = {
kind: 'district_heating', currency: 'EUR', points: [], tariff: null, kind: 'district_heating', currency: 'EUR', points: [], tariff: null,
@@ -273,15 +273,19 @@ describe('TibberPrices', () => {
await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument())
const snapshot = screen.getByTestId('thermal-price-snapshot') const snapshot = screen.getByTestId('thermal-price-snapshot')
const table = screen.getByTestId('thermal-price-table')
expect(snapshot).toHaveTextContent('Version 42') expect(snapshot).toHaveTextContent('Version 42')
expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z') 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('not a 15-minute market spot price')
expect(snapshot).toHaveTextContent('20.123456789123456789 EUR/GJ') expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
expect(snapshot).toHaveTextContent('8.200000000000000001 EUR/m³') 'Category', 'Charge', 'Rate', 'Unit',
expect(snapshot).toHaveTextContent('1.234567890123456789 EUR/m³') ])
expect(snapshot).toHaveTextContent('0.456789012345678901 EUR/m³') 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)) { 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({ expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({
params: { query: expect.objectContaining({ scope: 'thermal' }) }, params: { query: expect.objectContaining({ scope: 'thermal' }) },
@@ -292,6 +296,19 @@ describe('TibberPrices', () => {
expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument() 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 () => { it('marks the currently active price slot with a dot and a caption', async () => {
installChartSize() installChartSize()
+36 -7
View File
@@ -23,6 +23,7 @@ import {
Group, Group,
Paper, Paper,
SegmentedControl, SegmentedControl,
ScrollArea,
} from '@mantine/core' } from '@mantine/core'
import { import {
LineChart, LineChart,
@@ -60,6 +61,19 @@ function thermalUnit(section: string, key: string, currency: string): string {
return currency 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 // Time range helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -399,13 +413,28 @@ export function TibberPrices() {
<Text fw={500}>Thermal contract snapshot</Text> <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">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> <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]) => ( <ScrollArea type="auto">
<Text size="sm" key={section} data-testid={`thermal-price-section-${section}`}> <Table withTableBorder withColumnBorders data-testid="thermal-price-table">
{section}: {Object.entries(values).map(([key, value]) => <Table.Thead>
`${key} ${value} ${thermalUnit(section, key, currency)}`, <Table.Tr>
).join(', ')} <Table.Th>Category</Table.Th>
</Text> <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> </Stack>
</Paper> </Paper>
)} )}
+15
View File
@@ -72,6 +72,21 @@ def test_compose_uses_migration_job_before_app() -> None:
assert dev["services"]["app"]["build"] == "." assert dev["services"]["app"]["build"] == "."
def test_compose_defaults_business_timezone_and_dev_inherits_it() -> None:
"""Both production services receive an overridable Amsterdam timezone.
Compose merges service ``environment`` mappings, and the dev override does
not replace either mapping, so the base default applies to base+dev too.
"""
base = _read_yaml("docker-compose.yml")
dev = _read_yaml("docker-compose.dev.yml")
default_tz = "${TZ:-Europe/Amsterdam}"
for service_name in ("migration", "app"):
assert base["services"][service_name]["environment"]["TZ"] == default_tz
assert "TZ" not in dev["services"].get(service_name, {}).get("environment", {})
def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None: def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
"""Base Compose maps the configured device with only pyserial's required access. """Base Compose maps the configured device with only pyserial's required access.
+8 -3
View File
@@ -12,6 +12,8 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from sqlalchemy import create_engine, event, inspect, text from sqlalchemy import create_engine, event, inspect, text
from app.integrations.meter_sources import sanitize_source_config, validate_source_config
def _config(database_url: str) -> Config: def _config(database_url: str) -> Config:
config = Config("alembic_app.ini") config = Config("alembic_app.ini")
@@ -108,7 +110,7 @@ def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path
{"key": "DSMR_INGEST_ENABLED", "value": "true", "at": start}, {"key": "DSMR_INGEST_ENABLED", "value": "true", "at": start},
{"key": "DSMR_MQTT_TOPIC", "value": "historic/dsmr", "at": start}, {"key": "DSMR_MQTT_TOPIC", "value": "historic/dsmr", "at": start},
{"key": "DSMR_TARIFF_TOPIC", "value": "historic/tariff", "at": start}, {"key": "DSMR_TARIFF_TOPIC", "value": "historic/tariff", "at": start},
{"key": "DSMR_SAMPLE_INTERVAL_S", "value": "15", "at": start}, {"key": "DSMR_SAMPLE_INTERVAL_S", "value": "0", "at": start},
{"key": "MQTT_BROKER_HOST", "value": "mqtt.example.invalid", "at": start}, {"key": "MQTT_BROKER_HOST", "value": "mqtt.example.invalid", "at": start},
{"key": "MQTT_BROKER_PORT", "value": "1884", "at": start}, {"key": "MQTT_BROKER_PORT", "value": "1884", "at": start},
{"key": "MQTT_USERNAME", "value": "historic-user", "at": start}, {"key": "MQTT_USERNAME", "value": "historic-user", "at": start},
@@ -159,11 +161,14 @@ def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path
text("SELECT id, enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'") text("SELECT id, enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'")
).one() ).one()
assert source.enabled == 1 assert source.enabled == 1
assert json.loads(source.config) == { source_config = json.loads(source.config)
assert source_config == {
"broker_host": "mqtt.example.invalid", "broker_port": 1884, "broker_host": "mqtt.example.invalid", "broker_port": 1884,
"username": "historic-user", "password": "historic-password", "tls_enabled": True, "username": "historic-user", "password": "historic-password", "tls_enabled": True,
"topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 15, "topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 0,
} }
assert validate_source_config("dsmr_mqtt", source_config) == source_config
assert sanitize_source_config("dsmr_mqtt", source_config)["sample_interval_s"] == 0
assert connection.execute(text("SELECT value FROM app_config WHERE key = 'DSMR_MQTT_TOPIC'")).scalar_one() == "historic/dsmr" assert connection.execute(text("SELECT value FROM app_config WHERE key = 'DSMR_MQTT_TOPIC'")).scalar_one() == "historic/dsmr"
assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == config_before assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == config_before
assert connection.execute(text("SELECT group_concat(telegram_id) FROM dsmr_reading")).scalar_one() == "77,78,77" assert connection.execute(text("SELECT group_concat(telegram_id) FROM dsmr_reading")).scalar_one() == "77,78,77"
+8 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from unittest.mock import patch from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -139,7 +140,9 @@ def test_meter_cost_summary_empty_and_recompute_csrf_window_validation(meter_cos
).status_code == 422 ).status_code == 422
def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_client) -> None: def test_meter_cost_summary_returns_five_fixed_components_and_totals(
meter_cost_client, monkeypatch: pytest.MonkeyPatch
) -> None:
client, engine = meter_cost_client client, engine = meter_cost_client
now = datetime.now(UTC) now = datetime.now(UTC)
values = { values = {
@@ -166,8 +169,10 @@ def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_
db.add_all((heating, water)) db.add_all((heating, water))
db.commit() db.commit()
_login(client) _login(client)
with patch("app.api.routes.api.meter_costs.local_now", return_value=datetime(2026, 6, 25, 2, tzinfo=UTC)): monkeypatch.setattr("app.services.timezone.local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
response = client.get("/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z") response = client.get(
"/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z"
)
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8" assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8"
+26
View File
@@ -68,6 +68,32 @@ def test_secret_sanitize_and_mask_merge_keep_old_value():
assert merged["topic"] == "new/topic" assert merged["topic"] == "new/topic"
def test_dsmr_zero_interval_validates_sanitizes_and_merges_unchanged():
config = validate_source_config("dsmr_mqtt", {"sample_interval_s": 0})
assert config["sample_interval_s"] == 0
assert sanitize_source_config("dsmr_mqtt", config)["sample_interval_s"] == 0
assert merge_source_config("dsmr_mqtt", config, {"topic": "new/topic"})["sample_interval_s"] == 0
@pytest.mark.parametrize(
("kind", "config"),
[
("dsmr_mqtt", {"sample_interval_s": -1}),
("dsmr_mqtt", {"sample_interval_s": False}),
("dsmr_mqtt", {"broker_port": 0}),
("dsmr_mqtt", {"broker_port": -1}),
("dsmr_mqtt", {"broker_port": False}),
("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": 0}),
("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": -1}),
("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": False}),
],
)
def test_numeric_source_profile_constraints_still_reject_invalid_values(kind, config):
with pytest.raises(SourceProfileError):
validate_source_config(kind, config)
@pytest.fixture() @pytest.fixture()
def session(tmp_path): def session(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'source_services.db'}") engine = create_engine(f"sqlite:///{tmp_path / 'source_services.db'}")
+33 -1
View File
@@ -7,7 +7,7 @@ on any CI host timezone.
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytest import pytest
@@ -56,6 +56,38 @@ def test_local_tz_env_var_overrides(monkeypatch):
monkeypatch.delenv("TZ") monkeypatch.delenv("TZ")
@pytest.mark.parametrize("value", [None, "", " "])
def test_local_tz_defaults_to_dst_aware_amsterdam(monkeypatch, value: str | None):
"""Unset or blank TZ must use the deterministic Amsterdam business zone."""
if value is None:
monkeypatch.delenv("TZ", raising=False)
else:
monkeypatch.setenv("TZ", value)
tz = tz_mod.local_tz()
assert isinstance(tz, ZoneInfo)
assert tz.key == "Europe/Amsterdam"
winter = datetime(2026, 1, 15, 12, tzinfo=tz)
summer = datetime(2026, 7, 15, 12, tzinfo=tz)
assert winter.utcoffset().total_seconds() == 3600
assert summer.utcoffset().total_seconds() == 7200
def test_local_tz_explicit_utc_override(monkeypatch):
"""A non-empty TZ remains an operator-controlled override."""
monkeypatch.setenv("TZ", "UTC")
tz = tz_mod.local_tz()
assert isinstance(tz, ZoneInfo)
assert tz.key == "UTC"
def test_local_tz_invalid_explicit_override_fails_loudly(monkeypatch):
"""A non-empty invalid override must not silently become Amsterdam."""
monkeypatch.setenv("TZ", "Invalid/Timezone")
with pytest.raises(ZoneInfoNotFoundError):
tz_mod.local_tz()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# to_local() — conversion correctness # to_local() — conversion correctness
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------