fix(prices): keep hover marker on the hovered slot past midnight
The price chart keyed its X axis on formatLocalTime() "HH:mm" labels, which repeat across a today+tomorrow range. Recharts resolves axis tooltips by value (combineTooltipPayload -> findEntryInArray), so hovering a slot after midnight matched today's identically labelled point: the tooltip showed today's prices and the active dot jumped back to today's position instead of following the cursor. Key the axis on the ISO instant instead (buildChartRows, sorted by slot start) and format down to HH:mm in the tick formatter; the tooltip label now carries the date so today and tomorrow are distinguishable. Also mark the price slot currently in effect by default: findActiveSlotIndex() locates the slot containing now, rendered as a ReferenceDot on the buy and sell lines plus a caption, re-evaluated every 30s. Regression test drives a real mousemove over a sized chart in jsdom and asserts the resolved slot and active-dot position.
This commit is contained in:
@@ -2,13 +2,15 @@
|
||||
* TibberPrices — price curve visualization.
|
||||
*
|
||||
* - Fetches today + tomorrow price range using useEnergyPrices.
|
||||
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
|
||||
* - 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,
|
||||
@@ -29,10 +31,20 @@ import {
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ReferenceDot,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useEnergyPrices } from './hooks'
|
||||
import { formatLocalTime } from '../utils/datetime'
|
||||
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
|
||||
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
|
||||
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: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
|
||||
points: PricePoint[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
function TibberChart({ points, currency }: TibberChartProps) {
|
||||
const data = points.map((p) => ({
|
||||
time: formatLocalTime(p.starts_at),
|
||||
buy: p.buy,
|
||||
sell: p.sell,
|
||||
}))
|
||||
const data = useMemo(() => buildChartRows(points), [points])
|
||||
const nowMs = useNowMs()
|
||||
const activeIndex = findActiveSlotIndex(data, nowMs)
|
||||
const activeRow = activeIndex == null ? null : data[activeIndex]
|
||||
|
||||
return (
|
||||
<Stack gap="xs" data-testid="tibber-chart">
|
||||
<Title order={6} c="dimmed">
|
||||
Price curve ({currency})
|
||||
</Title>
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
dataKey="ts"
|
||||
tick={{ fontSize: 10 }}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={(v: string) => formatLocalTime(v)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
||||
formatter={(val: any) =>
|
||||
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
||||
}
|
||||
labelFormatter={(label) =>
|
||||
typeof label === 'string'
|
||||
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
|
||||
: label
|
||||
}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="buy"
|
||||
stroke="#2196f3"
|
||||
stroke={BUY_COLOR}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
name="Buy"
|
||||
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="sell"
|
||||
stroke="#4caf50"
|
||||
stroke={SELL_COLOR}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
name="Sell"
|
||||
/>
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user