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 && ( + + )}