/**
* 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 (