Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/current-price-interval.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions web/components/ftw-price-chart-current.test.mjs
Original file line number Diff line number Diff line change
@@ -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(/<span class="meta-label">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");
});
29 changes: 8 additions & 21 deletions web/components/ftw-price-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down