2026-06-23 23:32:39 +02:00
|
|
|
/**
|
|
|
|
|
* TibberPrices — price curve visualization.
|
|
|
|
|
*
|
|
|
|
|
* - Fetches today + tomorrow price range using useEnergyPrices.
|
2026-07-27 18:29:08 +02:00
|
|
|
* - For tibber kind: Recharts LineChart showing buy/sell prices over time,
|
|
|
|
|
* with the currently active price slot marked by a dot.
|
2026-06-23 23:32:39 +02:00
|
|
|
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
|
|
|
|
|
* - Handles: no active contract, empty data, loading, error.
|
|
|
|
|
*
|
|
|
|
|
* Recharts imports are isolated to this file only.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-07-27 18:29:08 +02:00
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
2026-06-23 23:32:39 +02:00
|
|
|
import {
|
|
|
|
|
Stack,
|
|
|
|
|
Text,
|
|
|
|
|
Loader,
|
|
|
|
|
Center,
|
|
|
|
|
Alert,
|
|
|
|
|
Table,
|
|
|
|
|
Title,
|
|
|
|
|
Badge,
|
|
|
|
|
Group,
|
|
|
|
|
Paper,
|
|
|
|
|
} from '@mantine/core'
|
|
|
|
|
import {
|
|
|
|
|
LineChart,
|
|
|
|
|
Line,
|
|
|
|
|
XAxis,
|
|
|
|
|
YAxis,
|
|
|
|
|
CartesianGrid,
|
|
|
|
|
Tooltip,
|
|
|
|
|
Legend,
|
2026-07-27 18:29:08 +02:00
|
|
|
ReferenceDot,
|
2026-06-23 23:32:39 +02:00
|
|
|
ResponsiveContainer,
|
|
|
|
|
} from 'recharts'
|
|
|
|
|
import { useEnergyPrices } from './hooks'
|
2026-07-27 18:29:08 +02:00
|
|
|
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
|
|
|
|
|
|
|
|
|
|
const BUY_COLOR = '#2196f3'
|
|
|
|
|
const SELL_COLOR = '#4caf50'
|
|
|
|
|
|
|
|
|
|
/** Slot length assumed for the very last point, when no next point bounds it. */
|
|
|
|
|
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
|
2026-06-23 23:32:39 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Time range helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function getTodayStart(): string {
|
|
|
|
|
const d = new Date()
|
|
|
|
|
d.setUTCHours(0, 0, 0, 0)
|
|
|
|
|
return d.toISOString()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTomorrowEnd(): string {
|
|
|
|
|
const d = new Date()
|
|
|
|
|
d.setUTCHours(0, 0, 0, 0)
|
|
|
|
|
d.setUTCDate(d.getUTCDate() + 2)
|
|
|
|
|
return d.toISOString()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 18:29:08 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Chart data helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export interface PricePoint {
|
|
|
|
|
starts_at: string
|
|
|
|
|
buy: number
|
|
|
|
|
sell: number
|
|
|
|
|
level?: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ChartRow {
|
|
|
|
|
/**
|
|
|
|
|
* X-axis category key — the full instant, NOT a "HH:mm" label.
|
|
|
|
|
*
|
|
|
|
|
* Must be unique per slot: Recharts resolves the hovered point by *value*
|
|
|
|
|
* (findEntryInArray on the axis dataKey), so a repeated key makes the tooltip
|
|
|
|
|
* and the active dot snap back to the first match. With "HH:mm" labels, every
|
|
|
|
|
* time of day appears twice in a today+tomorrow range, which pinned the dot on
|
|
|
|
|
* today once the cursor passed midnight. Formatting to HH:mm happens in the
|
|
|
|
|
* tick / tooltip formatters instead.
|
|
|
|
|
*/
|
|
|
|
|
ts: string
|
|
|
|
|
/** Slot start as epoch ms; NaN when starts_at is unparseable. */
|
|
|
|
|
tsMs: number
|
|
|
|
|
buy: number
|
|
|
|
|
sell: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Map API price points to chart rows with unique X keys, sorted by slot start. */
|
|
|
|
|
export function buildChartRows(points: PricePoint[]): ChartRow[] {
|
|
|
|
|
return points
|
|
|
|
|
.map((p) => {
|
|
|
|
|
const d = parseBackendTimestamp(p.starts_at)
|
|
|
|
|
const tsMs = d.getTime()
|
|
|
|
|
return {
|
|
|
|
|
ts: Number.isFinite(tsMs) ? d.toISOString() : p.starts_at,
|
|
|
|
|
tsMs,
|
|
|
|
|
buy: p.buy,
|
|
|
|
|
sell: p.sell,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
// Unparseable timestamps sort last so the ascending scan below can stop early.
|
|
|
|
|
if (!Number.isFinite(a.tsMs)) return Number.isFinite(b.tsMs) ? 1 : 0
|
|
|
|
|
if (!Number.isFinite(b.tsMs)) return -1
|
|
|
|
|
return a.tsMs - b.tsMs
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Index of the row whose slot contains `nowMs`, or null when now is outside the
|
|
|
|
|
* fetched range. A slot ends where the next one starts; the last row has no next
|
|
|
|
|
* slot, so it falls back to the series spacing (quarter-hourly for Tibber).
|
|
|
|
|
*/
|
|
|
|
|
export function findActiveSlotIndex(rows: ChartRow[], nowMs: number): number | null {
|
|
|
|
|
let idx = -1
|
|
|
|
|
for (let i = 0; i < rows.length; i += 1) {
|
|
|
|
|
if (!Number.isFinite(rows[i].tsMs) || rows[i].tsMs > nowMs) break
|
|
|
|
|
idx = i
|
|
|
|
|
}
|
|
|
|
|
if (idx < 0) return null
|
|
|
|
|
|
|
|
|
|
const spacing = rows.length > 1 ? rows[1].tsMs - rows[0].tsMs : NaN
|
|
|
|
|
const slotMs = Number.isFinite(spacing) && spacing > 0 ? spacing : FALLBACK_SLOT_MS
|
|
|
|
|
const slotEnd = idx + 1 < rows.length ? rows[idx + 1].tsMs : rows[idx].tsMs + slotMs
|
|
|
|
|
return nowMs < slotEnd ? idx : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Ticking clock so the active-slot marker follows slot boundaries while open. */
|
|
|
|
|
function useNowMs(intervalMs = NOW_TICK_MS): number {
|
|
|
|
|
const [now, setNow] = useState(() => Date.now())
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const id = setInterval(() => setNow(Date.now()), intervalMs)
|
|
|
|
|
return () => clearInterval(id)
|
|
|
|
|
}, [intervalMs])
|
|
|
|
|
return now
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tibber chart
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface TibberChartProps {
|
2026-07-27 18:29:08 +02:00
|
|
|
points: PricePoint[]
|
2026-06-23 23:32:39 +02:00
|
|
|
currency: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TibberChart({ points, currency }: TibberChartProps) {
|
2026-07-27 18:29:08 +02:00
|
|
|
const data = useMemo(() => buildChartRows(points), [points])
|
|
|
|
|
const nowMs = useNowMs()
|
|
|
|
|
const activeIndex = findActiveSlotIndex(data, nowMs)
|
|
|
|
|
const activeRow = activeIndex == null ? null : data[activeIndex]
|
2026-06-23 23:32:39 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Stack gap="xs" data-testid="tibber-chart">
|
2026-07-27 18:29:08 +02:00
|
|
|
<Group gap="xs" justify="space-between" align="baseline">
|
|
|
|
|
<Title order={6} c="dimmed">
|
|
|
|
|
Price curve ({currency})
|
|
|
|
|
</Title>
|
|
|
|
|
{activeRow && (
|
|
|
|
|
<Text size="xs" c="dimmed" data-testid="tibber-current-price">
|
|
|
|
|
Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '}
|
|
|
|
|
{activeRow.sell.toFixed(4)}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
2026-06-23 23:32:39 +02:00
|
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
|
|
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
|
|
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
|
|
|
<XAxis
|
2026-07-27 18:29:08 +02:00
|
|
|
dataKey="ts"
|
2026-06-23 23:32:39 +02:00
|
|
|
tick={{ fontSize: 10 }}
|
|
|
|
|
interval="preserveStartEnd"
|
2026-07-27 18:29:08 +02:00
|
|
|
tickFormatter={(v: string) => formatLocalTime(v)}
|
2026-06-23 23:32:39 +02:00
|
|
|
/>
|
|
|
|
|
<YAxis
|
|
|
|
|
tick={{ fontSize: 10 }}
|
|
|
|
|
tickFormatter={(v: number) => v.toFixed(3)}
|
|
|
|
|
/>
|
|
|
|
|
<Tooltip
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
formatter={(val: any) =>
|
|
|
|
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
|
|
|
|
}
|
2026-07-27 18:29:08 +02:00
|
|
|
labelFormatter={(label) =>
|
|
|
|
|
typeof label === 'string'
|
|
|
|
|
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
|
|
|
|
|
: label
|
|
|
|
|
}
|
2026-06-23 23:32:39 +02:00
|
|
|
/>
|
|
|
|
|
<Legend />
|
|
|
|
|
<Line
|
|
|
|
|
type="monotone"
|
|
|
|
|
dataKey="buy"
|
2026-07-27 18:29:08 +02:00
|
|
|
stroke={BUY_COLOR}
|
2026-06-23 23:32:39 +02:00
|
|
|
dot={false}
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
name="Buy"
|
|
|
|
|
/>
|
|
|
|
|
<Line
|
|
|
|
|
type="monotone"
|
|
|
|
|
dataKey="sell"
|
2026-07-27 18:29:08 +02:00
|
|
|
stroke={SELL_COLOR}
|
2026-06-23 23:32:39 +02:00
|
|
|
dot={false}
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
name="Sell"
|
|
|
|
|
/>
|
2026-07-27 18:29:08 +02:00
|
|
|
{/* Currently active price slot, marked by default (no hover needed). */}
|
|
|
|
|
{activeRow && (
|
|
|
|
|
<ReferenceDot
|
|
|
|
|
x={activeRow.ts}
|
|
|
|
|
y={activeRow.buy}
|
|
|
|
|
r={4}
|
|
|
|
|
fill={BUY_COLOR}
|
|
|
|
|
stroke="#fff"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{activeRow && (
|
|
|
|
|
<ReferenceDot
|
|
|
|
|
x={activeRow.ts}
|
|
|
|
|
y={activeRow.sell}
|
|
|
|
|
r={4}
|
|
|
|
|
fill={SELL_COLOR}
|
|
|
|
|
stroke="#fff"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-06-23 23:32:39 +02:00
|
|
|
</LineChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Manual tariff table
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface ManualTariffTableProps {
|
|
|
|
|
tariff: {
|
|
|
|
|
buy_dal: number
|
|
|
|
|
buy_normal: number
|
|
|
|
|
sell_dal: number
|
|
|
|
|
sell_normal: number
|
|
|
|
|
}
|
|
|
|
|
currency: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
|
|
|
|
|
return (
|
|
|
|
|
<Stack gap="xs" data-testid="manual-tariff-table">
|
|
|
|
|
<Title order={6} c="dimmed">
|
|
|
|
|
Fixed tariff ({currency}/kWh)
|
|
|
|
|
</Title>
|
|
|
|
|
<Table withTableBorder withColumnBorders>
|
|
|
|
|
<Table.Thead>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Th>Tariff</Table.Th>
|
|
|
|
|
<Table.Th>Buy</Table.Th>
|
|
|
|
|
<Table.Th>Sell</Table.Th>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Thead>
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Td>Normal (peak)</Table.Td>
|
|
|
|
|
<Table.Td data-testid="tariff-buy-normal">{tariff.buy_normal.toFixed(4)}</Table.Td>
|
|
|
|
|
<Table.Td data-testid="tariff-sell-normal">{tariff.sell_normal.toFixed(4)}</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Td>Dal (off-peak)</Table.Td>
|
|
|
|
|
<Table.Td data-testid="tariff-buy-dal">{tariff.buy_dal.toFixed(4)}</Table.Td>
|
|
|
|
|
<Table.Td data-testid="tariff-sell-dal">{tariff.sell_dal.toFixed(4)}</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// TibberPrices — main component
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function TibberPrices() {
|
|
|
|
|
const start = getTodayStart()
|
|
|
|
|
const end = getTomorrowEnd()
|
|
|
|
|
|
|
|
|
|
const { data, isLoading, isError } = useEnergyPrices(start, end)
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<Center py="xl" data-testid="prices-loading">
|
|
|
|
|
<Loader />
|
|
|
|
|
</Center>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isError) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert color="red" data-testid="prices-error">
|
|
|
|
|
Failed to load energy prices. Please refresh.
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert color="gray" data-testid="prices-no-data">
|
|
|
|
|
No pricing data available.
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No active contract
|
|
|
|
|
if (!data.kind) {
|
|
|
|
|
return (
|
|
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currency = data.currency
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Stack gap="lg" data-testid="tibber-prices">
|
|
|
|
|
<Group gap="sm" align="center">
|
|
|
|
|
<Text fw={500}>Energy Prices</Text>
|
|
|
|
|
<Badge variant="outline" size="sm">
|
|
|
|
|
{data.kind}
|
|
|
|
|
</Badge>
|
|
|
|
|
<Text size="xs" c="dimmed">
|
|
|
|
|
{currency}
|
|
|
|
|
</Text>
|
|
|
|
|
</Group>
|
|
|
|
|
|
|
|
|
|
{data.kind === 'tibber' && data.points && data.points.length > 0 && (
|
|
|
|
|
<TibberChart points={data.points} currency={currency} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{data.kind === 'tibber' && (!data.points || data.points.length === 0) && (
|
|
|
|
|
<Alert color="yellow" data-testid="tibber-no-prices">
|
|
|
|
|
No Tibber price points available for the selected time range. Check Tibber configuration.
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{data.kind === 'manual' && data.tariff && (
|
|
|
|
|
<ManualTariffTable tariff={data.tariff} currency={currency} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{data.kind === 'manual' && !data.tariff && (
|
|
|
|
|
<Alert color="yellow" data-testid="manual-no-tariff">
|
|
|
|
|
Manual tariff data not available.
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|