diff --git a/frontend/src/energy/TibberPrices.test.tsx b/frontend/src/energy/TibberPrices.test.tsx
index da73443..13c371e 100644
--- a/frontend/src/energy/TibberPrices.test.tsx
+++ b/frontend/src/energy/TibberPrices.test.tsx
@@ -6,10 +6,14 @@
* 2. Empty state (no active contract / no kind).
* 3. Renders tibber chart when tibber kind data is available.
* 4. Shows tariff table for manual kind.
+ * 5. Marks the currently active price slot (dot + caption).
+ * 6. Hovering past midnight resolves tomorrow's slot, not today's (regression).
+ * 7. buildChartRows: unique X keys across midnight.
+ * 8. findActiveSlotIndex: which slot is currently active.
*/
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { screen, waitFor } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
// ---------------------------------------------------------------------------
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
// Import component
// ---------------------------------------------------------------------------
-import { TibberPrices } from './TibberPrices'
+import { TibberPrices, buildChartRows, findActiveSlotIndex } from './TibberPrices'
+
+// ---------------------------------------------------------------------------
+// Chart size harness
+//
+// jsdom reports every element as 0x0, so Recharts renders an empty plot and no
+// pointer interaction is possible. These helpers hand the chart a fixed size:
+// - the ResponsiveContainer gets 800x300 from its bounding rect + a ResizeObserver
+// that reports the same size,
+// - the chart wrapper reports 800x260 (the height the component asks for), which
+// is what Recharts uses to translate clientX/clientY into chart coordinates,
+// - everything else stays 0x0 so the legend does not eat the whole plot area.
+// ---------------------------------------------------------------------------
+
+const CHART_W = 800
+const CONTAINER_H = 300
+const CHART_H = 260
+
+function fakeRect(width: number, height: number): DOMRect {
+ return {
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: width,
+ bottom: height,
+ width,
+ height,
+ toJSON: () => {},
+ } as DOMRect
+}
+
+const originalResizeObserver = globalThis.ResizeObserver
+const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'offsetWidth',
+)
+const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'offsetHeight',
+)
+
+function installChartSize() {
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
+ if (this.classList.contains('recharts-responsive-container')) {
+ return fakeRect(CHART_W, CONTAINER_H)
+ }
+ if (this.classList.contains('recharts-wrapper')) return fakeRect(CHART_W, CHART_H)
+ return fakeRect(0, 0)
+ })
+
+ // Recharts divides rect size by offset size to undo CSS transform scaling;
+ // matching them keeps the scale factor at 1.
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
+ configurable: true,
+ value: CHART_W,
+ })
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
+ configurable: true,
+ value: CHART_H,
+ })
+
+ globalThis.ResizeObserver = class implements ResizeObserver {
+ private readonly cb: ResizeObserverCallback
+ constructor(cb: ResizeObserverCallback) {
+ this.cb = cb
+ }
+ observe() {
+ this.cb(
+ [{ contentRect: { width: CHART_W, height: CONTAINER_H } } as ResizeObserverEntry],
+ this,
+ )
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+}
+
+function restoreChartSize() {
+ globalThis.ResizeObserver = originalResizeObserver
+ if (offsetWidthDescriptor) {
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthDescriptor)
+ }
+ if (offsetHeightDescriptor) {
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightDescriptor)
+ }
+}
+
+/** Hourly price points, one per hour starting at `startUtc`, with unique prices. */
+function hourlyPoints(startUtc: number, count: number) {
+ return Array.from({ length: count }, (_, i) => ({
+ starts_at: new Date(startUtc + i * 3600_000).toISOString(),
+ buy: 0.1 + i / 1000,
+ sell: 0.05 + i / 1000,
+ level: 'NORMAL',
+ }))
+}
+
+function tooltipText(): string {
+ return document.querySelector('.recharts-tooltip-wrapper')?.textContent ?? ''
+}
// ---------------------------------------------------------------------------
// Tests
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
describe('TibberPrices', () => {
beforeEach(() => vi.clearAllMocks())
+ afterEach(() => {
+ vi.restoreAllMocks()
+ restoreChartSize()
+ })
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
})
+
+ it('marks the currently active price slot with a dot and a caption', async () => {
+ installChartSize()
+
+ const SLOT_MS = 15 * 60 * 1000
+ // Start of the quarter-hour slot that contains "now".
+ const currentSlot = Math.floor(Date.now() / SLOT_MS) * SLOT_MS
+
+ mockGet.mockResolvedValue({
+ data: {
+ kind: 'tibber',
+ currency: 'EUR',
+ points: [
+ { starts_at: new Date(currentSlot - SLOT_MS).toISOString(), buy: 0.11, sell: 0.05 },
+ { starts_at: new Date(currentSlot).toISOString(), buy: 0.2431, sell: 0.1102 },
+ { starts_at: new Date(currentSlot + SLOT_MS).toISOString(), buy: 0.31, sell: 0.15 },
+ ],
+ tariff: null,
+ },
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tibber-current-price')).toBeInTheDocument()
+ })
+
+ const marker = screen.getByTestId('tibber-current-price')
+ expect(marker).toHaveTextContent('0.2431')
+ expect(marker).toHaveTextContent('0.1102')
+
+ // One dot on the buy line, one on the sell line — visible without hovering.
+ await waitFor(() => {
+ expect(document.querySelectorAll('.recharts-reference-dot')).toHaveLength(2)
+ })
+ })
+
+ it('resolves the hovered slot past midnight to tomorrow, not today', async () => {
+ installChartSize()
+
+ // 26 hourly points starting at 2020-01-01T00:00Z, so "00:00" and "01:00"
+ // each appear twice. Fixed past dates keep the "now" marker out of range.
+ const points = hourlyPoints(Date.UTC(2020, 0, 1), 26)
+
+ mockGet.mockResolvedValue({
+ data: { kind: 'tibber', currency: 'EUR', points, tariff: null },
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('tibber-chart')).toBeInTheDocument())
+ expect(screen.queryByTestId('tibber-current-price')).not.toBeInTheDocument()
+
+ const wrapper = document.querySelector('.recharts-wrapper')
+ expect(wrapper).not.toBeNull()
+
+ // Right edge of the plot area = the last slot (day 2, 01:00, buy 0.1250).
+ fireEvent.mouseMove(wrapper!, { clientX: 770, clientY: CHART_H / 2 })
+
+ await waitFor(() => expect(tooltipText()).toContain('0.1250'))
+
+ // Label carries the date, so day 2 is distinguishable from day 1.
+ expect(tooltipText()).toContain('1/2/2020')
+ expect(tooltipText()).toContain('0.0750')
+
+ // The active dots must sit on the hovered point (right half of the plot).
+ // The bug put them on day 1's identically-labelled slot near the left edge.
+ const dots = Array.from(document.querySelectorAll('.recharts-active-dot circle'))
+ expect(dots).toHaveLength(2)
+ for (const dot of dots) {
+ expect(Number(dot.getAttribute('cx'))).toBeGreaterThan(CHART_W / 2)
+ }
+ })
+})
+
+// ---------------------------------------------------------------------------
+// buildChartRows
+// ---------------------------------------------------------------------------
+
+describe('buildChartRows', () => {
+ it('keeps X-axis keys unique across midnight', () => {
+ // Same local time-of-day on two consecutive days: as "HH:mm" labels these
+ // collided, which made Recharts resolve the hovered point to the first match
+ // (today) instead of the hovered one (tomorrow).
+ const rows = buildChartRows([
+ { starts_at: '2026-07-26T22:00:00Z', buy: 0.1, sell: 0.05 },
+ { starts_at: '2026-07-27T22:00:00Z', buy: 0.2, sell: 0.06 },
+ ])
+
+ expect(rows).toHaveLength(2)
+ expect(new Set(rows.map((r) => r.ts)).size).toBe(2)
+ })
+
+ it('sorts rows by slot start and parses naive timestamps as UTC', () => {
+ const rows = buildChartRows([
+ { starts_at: '2026-07-27T02:00:00', buy: 0.3, sell: 0.07 },
+ { starts_at: '2026-07-27T01:00:00Z', buy: 0.2, sell: 0.06 },
+ { starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
+ ])
+
+ expect(rows.map((r) => r.buy)).toEqual([0.1, 0.2, 0.3])
+ expect(rows.map((r) => r.ts)).toEqual([
+ '2026-07-27T00:00:00.000Z',
+ '2026-07-27T01:00:00.000Z',
+ '2026-07-27T02:00:00.000Z',
+ ])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// findActiveSlotIndex
+// ---------------------------------------------------------------------------
+
+describe('findActiveSlotIndex', () => {
+ const rows = buildChartRows([
+ { starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
+ { starts_at: '2026-07-27T00:15:00Z', buy: 0.2, sell: 0.06 },
+ { starts_at: '2026-07-27T00:30:00Z', buy: 0.3, sell: 0.07 },
+ ])
+
+ const at = (iso: string) => new Date(iso).getTime()
+
+ it('returns the slot containing now', () => {
+ expect(findActiveSlotIndex(rows, at('2026-07-27T00:20:00Z'))).toBe(1)
+ })
+
+ it('returns the slot at its exact start boundary', () => {
+ expect(findActiveSlotIndex(rows, at('2026-07-27T00:15:00Z'))).toBe(1)
+ })
+
+ it('returns null before the first slot', () => {
+ expect(findActiveSlotIndex(rows, at('2026-07-26T23:59:00Z'))).toBeNull()
+ })
+
+ it('stays on the last slot until its inferred end, then returns null', () => {
+ expect(findActiveSlotIndex(rows, at('2026-07-27T00:44:00Z'))).toBe(2)
+ expect(findActiveSlotIndex(rows, at('2026-07-27T00:45:00Z'))).toBeNull()
+ })
+
+ it('returns null for empty data', () => {
+ expect(findActiveSlotIndex([], Date.now())).toBeNull()
+ })
})
diff --git a/frontend/src/energy/TibberPrices.tsx b/frontend/src/energy/TibberPrices.tsx
index 143a009..1964eb8 100644
--- a/frontend/src/energy/TibberPrices.tsx
+++ b/frontend/src/energy/TibberPrices.tsx
@@ -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 (
-
- Price curve ({currency})
-
+
+
+ Price curve ({currency})
+
+ {activeRow && (
+
+ Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '}
+ {activeRow.sell.toFixed(4)}
+
+ )}
+
formatLocalTime(v)}
/>
[`${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 && (
+
+ )}