diff --git a/.changeset/current-price-interval.md b/.changeset/current-price-interval.md new file mode 100644 index 00000000..ef8806c7 --- /dev/null +++ b/.changeset/current-price-interval.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show the price for the current interval in the Energy chart. Respect quarter-hour prices and leave NOW unavailable when viewing tomorrow or a gap in prices. diff --git a/web/components/ftw-price-chart-current.test.mjs b/web/components/ftw-price-chart-current.test.mjs new file mode 100644 index 00000000..b8e43493 --- /dev/null +++ b/web/components/ftw-price-chart-current.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; + +// Exercise the real header renderer without a DOM or network. The SVG body +// is outside these assertions; the slot selection and price display are not. +let PriceChart; +globalThis.HTMLElement = class {}; +globalThis.customElements = { define(name, component) { + if (name === "ftw-price-chart") PriceChart = component; +} }; +await import("./ftw-price-chart.js"); +delete globalThis.HTMLElement; +delete globalThis.customElements; + +function at(day, hour, minute = 0) { + return new Date(2026, 8, 7 + day, hour, minute).getTime(); +} + +function render(items, horizon = "today", totalOn = false) { + const chart = Object.assign(Object.create(PriceChart.prototype), { + _data: { zone: "SE4", items }, + _horizon: horizon, + _totalOn: totalOn, + _gridTariff: 70, + _vatPct: 25, + _currency: "SEK", + hasAttribute: () => false, + _renderChart: () => "", + }); + return chart.render(); +} + +function nowValue(html) { + const match = html.match(/now<\/span> ([^<]*)<\/span>/); + assert.ok(match, "header must state whether a current price is available"); + return match[1]; +} + +it("shows the current quarter, including the exact interval boundary", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: at(0, 18, 15) }); + const items = [ + { tsMs: at(0, 18), lenMin: 15, spot: 100 }, + { tsMs: at(0, 18, 15), lenMin: 15, spot: 200 }, + { tsMs: at(0, 18, 30), lenMin: 15, spot: 300 }, + ]; + assert.equal(nowValue(render(items)), "200.0 öre"); +}); + +it("does not label tomorrow's first price as now", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: at(0, 18, 25) }); + const items = [ + { tsMs: at(0, 18, 15), lenMin: 15, spot: 200 }, + { tsMs: at(1, 0), lenMin: 15, spot: 20 }, + ]; + assert.equal(nowValue(render(items, "tomorrow")), "—"); +}); + +it("leaves now unavailable in a gap or after the last interval", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: at(0, 18, 25) }); + const past = { tsMs: at(0, 18), lenMin: 15, spot: 100 }; + const future = { tsMs: at(0, 18, 30), lenMin: 15, spot: 300 }; + assert.equal(nowValue(render([past, future])), "—"); + assert.equal(nowValue(render([past])), "—"); +}); + +it("keeps hourly slots, tariff, VAT and supplied totals consistent", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: at(0, 18, 25) }); + const hourly = { tsMs: at(0, 18), lenMin: 60, spot: 20 }; + assert.equal(nowValue(render([hourly], "today", true)), "112.5 öre"); + assert.equal(nowValue(render([{ ...hourly, total: 109.3 }], "today", true)), "109.3 öre"); + assert.equal(nowValue(render([{ ...hourly, total: 109.3 }])), "20.0 öre"); +}); diff --git a/web/components/ftw-price-chart.js b/web/components/ftw-price-chart.js index cbf67262..4719b7de 100644 --- a/web/components/ftw-price-chart.js +++ b/web/components/ftw-price-chart.js @@ -34,6 +34,7 @@ import { FtwElement } from "./ftw-element.js"; import { apiFetch } from "./api-fetch.js"; import { buildCompactPriceView, + buildPriceSummary, formatPriceSlotLabel, } from "./price-summary.js"; import { bestBlock, consumerTotalOre, priceParts } from "./price-math.js"; @@ -637,31 +638,17 @@ class FtwPriceChart extends FtwElement { } // Compute stats over the visible window using the resolved öre/kWh for // whichever toggle is active, so the numbers in the subtitle line up - // exactly with what the chart bars are showing. `current` is the slot - // covering wall-clock - // now if it's in the window, else the nearest. Empty horizon → no - // stats row, falls through to the existing "no data" message. + // exactly with what the chart bars are showing. Only an interval that + // contains now has a current price. A future window or gap has none. let statsHtml = ""; if (visible.length > 0) { const prices = visible.map(it => this._priceFor(it)); - const now = Date.now(); - let curIdx = visible.findIndex(it => { - const start = (it.tsMs || 0); - const end = start + 60 * 60 * 1000; - return now >= start && now < end; + const { current } = buildPriceSummary(visible, { + totalOn: this._totalOn, + gridTariffOre: this._gridTariff, + vatPercent: this._vatPct, }); - if (curIdx < 0) { - // Nearest by absolute time delta (used when "Tomorrow" tab is - // active and the wall clock is still in today, etc.). - let best = -1, bestD = Infinity; - for (let i = 0; i < visible.length; i++) { - const start = new Date(visible[i].starts_at || visible[i].ts || 0).getTime(); - const d = Math.abs(start - now); - if (d < bestD) { bestD = d; best = i; } - } - curIdx = best; - } - const cur = curIdx >= 0 ? prices[curIdx] : null; + const cur = current ? current.ore : null; const lo = Math.min(...prices); const hi = Math.max(...prices); const avg = prices.reduce((a, b) => a + b, 0) / prices.length;