/** * TibberPrices — price curve visualization. * * - Fetches today + tomorrow price range using useEnergyPrices. * - For tibber kind: Recharts LineChart showing buy/sell prices over time, * with the currently active price slot marked by a dot. * - 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. */ import { useEffect, useMemo, useState } from 'react' import { Stack, Text, Loader, Center, Alert, Table, Title, Badge, Group, Paper, } from '@mantine/core' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceDot, ResponsiveContainer, } from 'recharts' import { useEnergyPrices } from './hooks' 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 // --------------------------------------------------------------------------- // 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() } // --------------------------------------------------------------------------- // 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 } // --------------------------------------------------------------------------- // Tibber chart // --------------------------------------------------------------------------- interface TibberChartProps { points: PricePoint[] currency: string } function TibberChart({ points, currency }: TibberChartProps) { const data = useMemo(() => buildChartRows(points), [points]) const nowMs = useNowMs() const activeIndex = findActiveSlotIndex(data, nowMs) const activeRow = activeIndex == null ? null : data[activeIndex] return ( Price curve ({currency}) {activeRow && ( Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '} {activeRow.sell.toFixed(4)} )} formatLocalTime(v)} /> v.toFixed(3)} /> [`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined] } labelFormatter={(label) => typeof label === 'string' ? `${formatLocalDate(label)} ${formatLocalTime(label)}` : label } /> {/* Currently active price slot, marked by default (no hover needed). */} {activeRow && ( )} {activeRow && ( )} ) } // --------------------------------------------------------------------------- // 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 ( Fixed tariff ({currency}/kWh) Tariff Buy Sell Normal (peak) {tariff.buy_normal.toFixed(4)} {tariff.sell_normal.toFixed(4)} Dal (off-peak) {tariff.buy_dal.toFixed(4)} {tariff.sell_dal.toFixed(4)}
) } // --------------------------------------------------------------------------- // TibberPrices — main component // --------------------------------------------------------------------------- export function TibberPrices() { const start = getTodayStart() const end = getTomorrowEnd() const { data, isLoading, isError } = useEnergyPrices(start, end) if (isLoading) { return (
) } if (isError) { return ( Failed to load energy prices. Please refresh. ) } if (!data) { return ( No pricing data available. ) } // No active contract if (!data.kind) { return ( No active contract Activate an energy contract on the Contracts tab to see pricing data. ) } const currency = data.currency return ( Energy Prices {data.kind} {currency} {data.kind === 'tibber' && data.points && data.points.length > 0 && ( )} {data.kind === 'tibber' && (!data.points || data.points.length === 0) && ( No Tibber price points available for the selected time range. Check Tibber configuration. )} {data.kind === 'manual' && data.tariff && ( )} {data.kind === 'manual' && !data.tariff && ( Manual tariff data not available. )} ) }