Compare commits
3
Commits
d07a083e03
..
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b405aea88b | ||
|
|
bfc7aa3031 | ||
|
|
2f63b9630c |
@@ -79,6 +79,23 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
|
|||||||
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
|
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
|
||||||
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
|
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
|
||||||
|
|
||||||
|
#### API 契约同步:`openapi/` 与 `schema.d.ts` 是**两步**(v1.4.0 后教训)
|
||||||
|
|
||||||
|
**只跑 `export_openapi.py` 不够。** 前端的 `frontend/src/api/schema.d.ts` 是由 `openapi/openapi.json` 二次生成的,CI(`.github/workflows/frontend.yml` 的 *Check codegen is in sync*)会重跑 codegen 并 `git diff --exit-code src/api/schema.d.ts`。漏了第二步 → 本地闸门全绿、远端 CI 红。真出过:`d07a083` 改了 `/api/energy/prices` 的 docstring 并同步了 `openapi.json`,但没重跑 codegen,只差一行注释就把 CI 挂了。
|
||||||
|
|
||||||
|
所以**只要动了路由 / Pydantic schema / 路由 docstring**(docstring 也会进 OpenAPI description!),两步都要跑,两个产物都要入库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 后端契约
|
||||||
|
python scripts/export_openapi.py && git diff --exit-code openapi/
|
||||||
|
# 2) 前端类型(在 frontend/ 下)
|
||||||
|
npm run codegen && git diff --exit-code src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 判据:`git diff --exit-code openapi/` 有输出 → **必然**还要跑一次 `npm run codegen`。
|
||||||
|
- 反过来也成立:`schema.d.ts` 不要手改,它是生成物。
|
||||||
|
- Reviewer 审"动了路由 / schema / 路由 docstring"类任务时,把**这两个产物是否都已重新生成并入库**当作 acceptance 的一部分。
|
||||||
|
|
||||||
### 构建上下文完整性(M1 Dockerfile 教训)
|
### 构建上下文完整性(M1 Dockerfile 教训)
|
||||||
|
|
||||||
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile` 的 `COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
|
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile` 的 `COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
|
||||||
|
|||||||
Vendored
+1
-1
@@ -261,7 +261,7 @@ export interface paths {
|
|||||||
*
|
*
|
||||||
* Response ``points`` carries per-slot:
|
* Response ``points`` carries per-slot:
|
||||||
* - ``buy = total`` (Tibber all-inclusive price)
|
* - ``buy = total`` (Tibber all-inclusive price)
|
||||||
* - ``sell = total − energy_tax − sell_adjust`` (from active version values)
|
* - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)
|
||||||
* - ``level`` (Tibber price level, may be null)
|
* - ``level`` (Tibber price level, may be null)
|
||||||
*
|
*
|
||||||
* ``tariff`` is null.
|
* ``tariff`` is null.
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
* 2. Empty state (no active contract / no kind).
|
* 2. Empty state (no active contract / no kind).
|
||||||
* 3. Renders tibber chart when tibber kind data is available.
|
* 3. Renders tibber chart when tibber kind data is available.
|
||||||
* 4. Shows tariff table for manual kind.
|
* 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
|
|||||||
// Import component
|
// 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
|
// Tests
|
||||||
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
|
|||||||
|
|
||||||
describe('TibberPrices', () => {
|
describe('TibberPrices', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
restoreChartSize()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders loading state initially', () => {
|
it('renders loading state initially', () => {
|
||||||
mockGet.mockImplementation(() => new Promise(() => {}))
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
|
|||||||
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
||||||
expect(screen.getByTestId('tariff-sell-dal')).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(<TibberPrices />)
|
||||||
|
|
||||||
|
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(<TibberPrices />)
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* TibberPrices — price curve visualization.
|
* TibberPrices — price curve visualization.
|
||||||
*
|
*
|
||||||
* - Fetches today + tomorrow price range using useEnergyPrices.
|
* - 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).
|
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
|
||||||
* - Handles: no active contract, empty data, loading, error.
|
* - Handles: no active contract, empty data, loading, error.
|
||||||
*
|
*
|
||||||
* Recharts imports are isolated to this file only.
|
* Recharts imports are isolated to this file only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -29,10 +31,20 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
ReferenceDot,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useEnergyPrices } from './hooks'
|
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
|
// Time range helpers
|
||||||
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
|
|||||||
return d.toISOString()
|
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
|
// Tibber chart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface TibberChartProps {
|
interface TibberChartProps {
|
||||||
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
|
points: PricePoint[]
|
||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TibberChart({ points, currency }: TibberChartProps) {
|
function TibberChart({ points, currency }: TibberChartProps) {
|
||||||
const data = points.map((p) => ({
|
const data = useMemo(() => buildChartRows(points), [points])
|
||||||
time: formatLocalTime(p.starts_at),
|
const nowMs = useNowMs()
|
||||||
buy: p.buy,
|
const activeIndex = findActiveSlotIndex(data, nowMs)
|
||||||
sell: p.sell,
|
const activeRow = activeIndex == null ? null : data[activeIndex]
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" data-testid="tibber-chart">
|
<Stack gap="xs" data-testid="tibber-chart">
|
||||||
<Title order={6} c="dimmed">
|
<Group gap="xs" justify="space-between" align="baseline">
|
||||||
Price curve ({currency})
|
<Title order={6} c="dimmed">
|
||||||
</Title>
|
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}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="ts"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
|
tickFormatter={(v: string) => formatLocalTime(v)}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
|||||||
formatter={(val: any) =>
|
formatter={(val: any) =>
|
||||||
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
||||||
}
|
}
|
||||||
|
labelFormatter={(label) =>
|
||||||
|
typeof label === 'string'
|
||||||
|
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
|
||||||
|
: label
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="buy"
|
dataKey="buy"
|
||||||
stroke="#2196f3"
|
stroke={BUY_COLOR}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Buy"
|
name="Buy"
|
||||||
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="sell"
|
dataKey="sell"
|
||||||
stroke="#4caf50"
|
stroke={SELL_COLOR}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Sell"
|
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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user