M8-T19: add scope-aware energy contract and cost UI

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 963e43e3e4
commit 5b9d60e80a
10 changed files with 532 additions and 51 deletions
+56 -11
View File
@@ -22,6 +22,7 @@ import {
Badge,
Group,
Paper,
SegmentedControl,
} from '@mantine/core'
import {
LineChart,
@@ -34,7 +35,8 @@ import {
ReferenceDot,
ResponsiveContainer,
} from 'recharts'
import { useEnergyPrices } from './hooks'
import { useQuery } from '@tanstack/react-query'
import apiClient from '../api/client'
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
const BUY_COLOR = '#2196f3'
@@ -46,6 +48,18 @@ const FALLBACK_SLOT_MS = 60 * 60 * 1000
/** How often the "current price" marker re-evaluates which slot is active. */
const NOW_TICK_MS = 30 * 1000
/** D11 thermal profile units. The API snapshot is Decimal strings, while the
* currency comes from its contract metadata. Keep this display-only: no rates
* are derived or prefilled in the browser. */
function thermalUnit(section: string, key: string, currency: string): string {
if (section === 'standing') return `${currency}/year`
if (key === 'heating') return `${currency}/GJ`
if (key === 'hot_water_heating' || key === 'hot_water' || key === 'hot_water_tax') {
return `${currency}/m³`
}
return currency
}
// ---------------------------------------------------------------------------
// Time range helpers
// ---------------------------------------------------------------------------
@@ -288,46 +302,59 @@ function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
// ---------------------------------------------------------------------------
export function TibberPrices() {
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
const start = getTodayStart()
const end = getTomorrowEnd()
const { data, isLoading, isError } = useQuery({
queryKey: ['energy-prices', scope, start, end],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/prices', { params: { query: { scope, start, end } } })
return res.data
},
})
const { data, isLoading, isError } = useEnergyPrices(start, end)
const selector = (
<SegmentedControl
value={scope}
onChange={(value) => setScope(value as 'electricity' | 'thermal')}
data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]}
data-testid="prices-scope-selector"
/>
)
if (isLoading) {
return (
<Center py="xl" data-testid="prices-loading">
<Loader />
</Center>
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Center py="xl" data-testid="prices-loading"><Loader /></Center></Stack>
)
}
if (isError) {
return (
<Alert color="red" data-testid="prices-error">
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Alert color="red" data-testid="prices-error">
Failed to load energy prices. Please refresh.
</Alert>
</Alert></Stack>
)
}
if (!data) {
return (
<Alert color="gray" data-testid="prices-no-data">
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Alert color="gray" data-testid="prices-no-data">
No pricing data available.
</Alert>
</Alert></Stack>
)
}
// No active contract
if (!data.kind) {
return (
<Paper withBorder p="md" data-testid="prices-no-contract">
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Paper withBorder p="md" data-testid="prices-no-contract">
<Stack gap="xs">
<Text fw={500}>No active contract</Text>
<Text size="sm" c="dimmed">
Activate an energy contract on the Contracts tab to see pricing data.
</Text>
</Stack>
</Paper>
</Paper></Stack>
)
}
@@ -337,6 +364,7 @@ export function TibberPrices() {
<Stack gap="lg" data-testid="tibber-prices">
<Group gap="sm" align="center">
<Text fw={500}>Energy Prices</Text>
{selector}
<Badge variant="outline" size="sm">
{data.kind}
</Badge>
@@ -364,6 +392,23 @@ export function TibberPrices() {
Manual tariff data not available.
</Alert>
)}
{scope === 'thermal' && data.values && (
<Paper withBorder p="md" data-testid="thermal-price-snapshot">
<Stack gap="xs">
<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>
))}
</Stack>
</Paper>
)}
</Stack>
)
}