From 3c8cfb8327b6b0f35dc2662a8d309c94b494c13f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 6 Aug 2026 02:39:54 +0200 Subject: [PATCH 1/2] feat(plan): draw the price day, and make every ask heal itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, together because the first forced the second. Prices. The box now serves price.get, so the Plan screen draws the day above the timeline using the box's own ftw-price-chart, vendored byte for byte and fed by method rather than left to fetch. Lazy-loaded: 10.7 kB gzip against 17 kB of headroom, on a screen most opens never reach, so it is a chunk of its own and CI now fails if a static import pulls it into the entry bundle. Both columns read the same units table, so the chart's "148.0 öre" and the row beneath it are the same number rather than two a hundred apart with one of them unlabelled. Healing. The plan, a history window and a price day are all asked for rather than streamed, and all three had the same fault: nothing ever asked again. A carrier drop settles every request in flight as a failure at once, the phase returns to streaming seconds later with the box answering happily, and the screen keeps a sentence promising something it will never do. Quieter failures never move the phase at all — a bulk answer lost on the relay, the eight-second deadline against a busy box, E_BOOTING in the minutes after an update — so no reconnect was ever coming to rescue those. askWhenLive holds that rule once. An ask runs when the session is live, again whenever it comes back, and again after one that failed, backing off rather than polling. A rejected promise is what says "ask again": nothing else can tell, because a failure the caller swallows looks exactly like an answer. The rule is only worth having if nothing goes round it, and twice it was built and applied to one caller. Both of the ones left out were the ones a user reaches by tapping — a history range, and the replan chased after a mode change — so both now change the question instead of fetching for themselves. Sharper edges, each with a test that fails when its fix is reverted: Session#detach settled one of four pending maps while its comment claimed all of them, and it was not called on an ordinary drop at all: the Noise carrier reconnects inside itself, so connect() runs once at mount. Settling now happens where the carrier actually goes away. A window stays drawn through an ask that failed — today's prices are still today's, and taking away a block someone is reading costs them something. It is cleared at local midnight, because past that the bars are yesterday's and the chart would go on heading them "today". The chart is fed on every tick, not only when the window changes: `fed` removed the poll that used to re-render it, and the NOW marker and the "now" figure both come from the clock at render time. Without a tick it labelled a price up to fourteen hours old as now, on a healthy box with a perfect connection. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 27 + docs/protocol.md | 43 +- src/lib/carrier/loopback.ts | 59 +- src/lib/dev/simulated-site.ts | 15 +- src/lib/format/plan.test.ts | 30 +- src/lib/format/plan.ts | 17 +- src/lib/protocol/messages.ts | 58 ++ src/lib/protocol/session.ts | 152 ++- src/lib/sim/box.ts | 99 +- src/lib/sim/planner.ts | 42 +- src/lib/state/age.test.ts | 70 +- src/lib/state/ask.svelte.ts | Bin 0 -> 4530 bytes src/lib/state/history.svelte.ts | 24 +- src/lib/state/plan.svelte.ts | 35 +- src/lib/state/price.test.ts | 131 +++ src/lib/state/price.ts | 69 ++ src/lib/state/site.svelte.ts | 7 +- src/styles/tokens.css | 3 + src/vendor/ftw/api-fetch.js | 7 + src/vendor/ftw/digests.json | 10 + src/vendor/ftw/ftw-price-chart.d.ts | 24 + src/vendor/ftw/ftw-price-chart.js | 1341 +++++++++++++++++++++++++++ src/vendor/ftw/price-math.js | 58 ++ src/vendor/ftw/price-strip.js | 68 ++ src/vendor/ftw/price-summary.js | 123 +++ src/vendor/ftw/price-units.d.ts | 41 + src/vendor/ftw/price-units.js | 119 +++ src/views/History.svelte | 13 +- src/views/History.svelte.test.ts | 201 ++++ src/views/Plan.svelte | 239 ++++- src/views/Plan.svelte.test.ts | 795 ++++++++++++++++ tests/price-chart-fed.test.ts | 112 +++ tests/price-e2e.test.ts | 201 ++++ tests/price-stale-contract.test.ts | 83 ++ tests/relay-blindness.test.ts | 79 +- tests/session-e2e.test.ts | 226 ++++- tests/vendored.test.ts | 98 ++ vitest.config.ts | 20 + 38 files changed, 4658 insertions(+), 81 deletions(-) create mode 100644 src/lib/state/ask.svelte.ts create mode 100644 src/lib/state/price.test.ts create mode 100644 src/lib/state/price.ts create mode 100644 src/vendor/ftw/api-fetch.js create mode 100644 src/vendor/ftw/digests.json create mode 100644 src/vendor/ftw/ftw-price-chart.d.ts create mode 100644 src/vendor/ftw/ftw-price-chart.js create mode 100644 src/vendor/ftw/price-math.js create mode 100644 src/vendor/ftw/price-strip.js create mode 100644 src/vendor/ftw/price-summary.js create mode 100644 src/vendor/ftw/price-units.d.ts create mode 100644 src/vendor/ftw/price-units.js create mode 100644 src/views/History.svelte.test.ts create mode 100644 src/views/Plan.svelte.test.ts create mode 100644 tests/price-chart-fed.test.ts create mode 100644 tests/price-e2e.test.ts create mode 100644 tests/price-stale-contract.test.ts create mode 100644 tests/vendored.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec9da1f..efe8fe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,12 @@ concurrency: jobs: verify: runs-on: ubuntu-latest + + # No TZ set here on purpose. Local time is not decoration in this app — + # the price window starts at local midnight, the publish hour is local, + # and the day the chart draws is the local one — so the suite pins its own + # zone in vitest.config.ts and runs the same on a UTC runner as on a + # laptop. Setting one here would only hide it if the pin were removed. steps: - uses: actions/checkout@v5 @@ -85,3 +91,24 @@ jobs: exit 1 fi echo "decoder is its own chunk: $(gzip -c dist/assets/jsQR-*.js | wc -c | tr -d ' ') bytes gzip, fetched on demand" + + # The price chart is 10.4 kB gzip against about 15 kB of headroom, so a + # static import would pass the budget above while spending most of what + # is left — on a screen many opens never reach. The comment in + # Plan.svelte says it is fetched on demand; this is what makes that + # comment true rather than a hope. + - name: Price chart stays out of the launch bundle + run: | + if ! ls dist/assets/ftw-price-chart-*.js >/dev/null 2>&1; then + echo "no separate chart chunk — it was inlined somewhere" + exit 1 + fi + # The component's own prose, not ours. Grepping for the element name + # would match the tag in Plan.svelte's template, which lives in the + # entry bundle by design and says nothing about where the component + # ended up. + if grep -l "Cheapest 2 h" dist/assets/index-*.js >/dev/null 2>&1; then + echo "the chart reached the entry bundle; keep the import dynamic" + exit 1 + fi + echo "chart is its own chunk: $(gzip -c dist/assets/ftw-price-chart-*.js | wc -c | tr -d ' ') bytes gzip, fetched on demand" diff --git a/docs/protocol.md b/docs/protocol.md index c50047e..73bc8ee 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -62,7 +62,7 @@ junk keys. ## Messages -Fifteen types in v1. +Nineteen types in v1. | Type | Direction | Purpose | |---|---|---| @@ -72,6 +72,8 @@ Fifteen types in v1. | `delta` | B→C | Changed fields by id | | `tick` | B→C | Nothing changed; keeps the cadence constant | | `hist.query` / `hist.chunk` / `hist.end` | | Time window and resolution | +| `plan.get` / `plan` | | What the box intends to do, slot by slot | +| `price.get` / `price` | | What electricity costs across a window | | `cmd` / `cmd.ack` / `cmd.result` | | Intent, receipt, and observed outcome | | `event` | B→C | Something worth surfacing happened | | `error` | B→C | Stable code with machine-readable args | @@ -140,6 +142,45 @@ Chunks are column-packed int32 little-endian inside CBOR byte strings, so the client gets an `Int32Array` without parsing. `INT32_MIN` marks a missing sample — distinct from zero, which is a real reading. +## Prices + +Gated on the `price.spot` capability: absent means the app draws no price view +rather than an empty one. + +`price.get {fromMs, toMs}` is answered with slots carrying `spotMinor` and +`totalMinor` — integer minor units per kWh, öre or cents. **Money never +crosses as a float.** The box rounds once and nothing rounds again, because a +second rounding is how two screens start disagreeing about what 18.7 öre is. + +`totalMinor` is what the household actually pays, tariff and tax included, and +the box computes it because the box holds that configuration. An app that +multiplied spot by its own guess would put a different number under the same +hour than the box's own dashboard does. + +Times are wall clock, unlike every age in this protocol: prices are about +hours a person plans around rather than about the box. + +`stale` means the answer does not cover the window asked for. That is three +shapes, not one: it begins after the start, it has a hole in the middle, or it +stops short of the end. The box judges all three against the window it was +asked for and sets the one flag for any of them; a slot that starts at or +before `fromMs` covers the head, because that is the slot running at `fromMs` +and it is the price right now. + +Tomorrow's rates publish in the afternoon, so a window asked for at breakfast +genuinely ends early, and the box also drops the far end rather than failing an +encode that will not fit a bulk bucket. One failed midday fetch is the second +shape — a store holding 00:00–06:00 and 12:00–24:00. A box that first heard +from the market at breakfast is the third: 06:00–24:00 of a day the app asked +for from midnight, every slot joining the last. A tail-only reading calls the +last two a covered day. + +The flag cannot say which shape it is, and the app does not need it to: the app +holds the slots, so it reads the missing hours off them. They are different +sentences to the reader — a day missing its own morning is not a day waiting +for tomorrow — and drawing either as a market that simply went quiet is "never +fake live" with prices in it. + ## Commands Intent and execution are separate, and the gap between them is where safety diff --git a/src/lib/carrier/loopback.ts b/src/lib/carrier/loopback.ts index 1bc668b..2269eb1 100644 --- a/src/lib/carrier/loopback.ts +++ b/src/lib/carrier/loopback.ts @@ -36,14 +36,8 @@ export class LoopbackCarrier extends CarrierBase implements Carrier { this.#latencyMs = opts.latencyMs ?? 120 this.kind = opts.kind ?? 'relay' - this.#unsubscribe = box.onFrame((frame) => { - this.#defer(() => this.emitFrame(frame)) - }) - - this.#defer(() => { - this.#status = { phase: 'open', sinceMs: Date.now() } - this.emitStatus(this.#status) - }) + this.#listen() + this.#open() } get rttMs(): number | null { @@ -62,15 +56,60 @@ export class LoopbackCarrier extends CarrierBase implements Carrier { close(reason = 'closed by client'): void { if (this.#status.phase === 'closed') return + this.#cut(reason, false) + this.clearHandlers() + } + + /** + * Lose the wire without tearing the carrier down. + * + * This is how a connection goes away in the field, and it is a different + * event from `close()`. Something drops the socket; the carrier reports + * `closed` with `retryable` set, keeps its handlers, and comes back on its + * own — `RelayCarrier.#onClose` in one line. `close()` is the app shutting + * the whole thing down on purpose, and nothing comes back from it. + * + * Frames in flight are lost, as they are over a real socket, and `send` + * drops whatever is handed to it while the wire is down. + */ + drop(reason = 'wire dropped'): void { + if (this.#status.phase === 'closed') return + this.#cut(reason, true) + } + + /** The wire comes back. The session re-handshakes over it, as in the field. */ + restore(): void { + if (this.#status.phase !== 'closed') return + // A carrier the app closed has no handlers left to talk to, so there is + // nothing here to bring back. + if (!this.#status.retryable) return + + this.#listen() + this.#open() + } + + #listen(): void { + this.#unsubscribe = this.#box.onFrame((frame) => { + this.#defer(() => this.emitFrame(frame)) + }) + } + + #open(): void { + this.#defer(() => { + this.#status = { phase: 'open', sinceMs: Date.now() } + this.emitStatus(this.#status) + }) + } + + #cut(reason: string, retryable: boolean): void { this.#unsubscribe?.() this.#unsubscribe = null for (const t of this.#timers) clearTimeout(t) this.#timers.clear() - this.#status = { phase: 'closed', reason, retryable: false } + this.#status = { phase: 'closed', reason, retryable } this.emitStatus(this.#status) - this.clearHandlers() } #defer(fn: () => void): void { diff --git a/src/lib/dev/simulated-site.ts b/src/lib/dev/simulated-site.ts index be02bb8..a0cc007 100644 --- a/src/lib/dev/simulated-site.ts +++ b/src/lib/dev/simulated-site.ts @@ -18,6 +18,16 @@ export interface SimHandle { stop: () => void /** Force a fault and watch the UI respond. */ fault: (patch: Partial) => void + /** + * Lose the wire the way a socket loses it, and bring it back. + * + * The failure state that cannot be reached with a fault switch: the box is + * fine, the connection is not. Everything asked for rather than streamed — + * the plan, a history window, the price day — fails the moment this is + * called, and healing is what happens on the way back. + */ + drop: () => void + restore: () => void } declare global { @@ -30,7 +40,8 @@ export function attachSimulatedSite(store: SiteStore): SimHandle { // Roughly what the relay costs in production. Zero latency would hide every // pending state the UI is supposed to handle. - store.connect(new LoopbackCarrier(box, { latencyMs: 120 })) + const carrier = new LoopbackCarrier(box, { latencyMs: 120 }) + store.connect(carrier) // The box defends an import ceiling; the Now view explains that. store.ceilingW = 11_000 @@ -43,6 +54,8 @@ export function attachSimulatedSite(store: SiteStore): SimHandle { fault: (patch) => { box.faults = { ...box.faults, ...patch } }, + drop: () => carrier.drop('dropped from the console'), + restore: () => carrier.restore(), } globalThis.ftwSim = handle diff --git a/src/lib/format/plan.test.ts b/src/lib/format/plan.test.ts index b879d7a..6ca4d07 100644 --- a/src/lib/format/plan.test.ts +++ b/src/lib/format/plan.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { planHeadline, slotAction, reasonText, formatPrice, modeLabel, modeHelp } from './plan' +import { formatPrice as boxPrice, unitLabel } from '$vendor/ftw/price-units.js' import type { Plan, PlanSlot, PlanReason, ModeInfo } from '$lib/protocol/messages' /** @@ -170,13 +171,32 @@ describe('mode wording comes from the box', () => { }) describe('formatPrice', () => { - it('renders minor units as currency', () => { - expect(formatPrice(80)).toBe('0.80') - expect(formatPrice(145)).toBe('1.45') + it('is the number the chart puts on the same hour', () => { + // The chart directly above the timeline renders every price through the + // box's table, so that table is the reference here rather than a number + // written out by hand: this column and that chart have to be the same + // money in the same unit, or the screen asks its reader to divide by a + // hundred to compare two lines of it. + // + // Both scales, because they are the interesting difference between + // currencies: öre and cent are quoted in the minor unit, koruna in the + // major one, and only the table knows which is which. + const cases = [ + [144, 'SEK'], + [80, 'SEK'], + [17, 'EUR'], + [400, 'CZK'], + ] as const + + for (const [minor, currency] of cases) { + expect(`${formatPrice(minor, currency)} ${unitLabel(currency)}`).toBe( + boxPrice(minor, currency) + ) + } }) it('returns null rather than a fake price', () => { - expect(formatPrice(null)).toBeNull() - expect(formatPrice(NaN)).toBeNull() + expect(formatPrice(null, 'SEK')).toBeNull() + expect(formatPrice(NaN, 'SEK')).toBeNull() }) }) diff --git a/src/lib/format/plan.ts b/src/lib/format/plan.ts index cf99bb1..4ccd209 100644 --- a/src/lib/format/plan.ts +++ b/src/lib/format/plan.ts @@ -9,6 +9,7 @@ */ import type { Plan, PlanSlot, PlanReason, SiteMode, ModeInfo } from '$lib/protocol/messages' +import { toDisplay, unitFor } from '$vendor/ftw/price-units.js' import { formatPower } from './power' /** @@ -149,8 +150,18 @@ function inWords(ms: number): string { return 'later today' } -/** Price in whole currency units per kWh, for display beside a slot. */ -export function formatPrice(minor: number | null): string | null { +/** + * A slot's price, for display beside it — in the chart's unit, to the chart's + * precision. + * + * The chart directly above the timeline prices the same hours, and it reads + * this table for every number it draws. Anything else here puts two numbers + * for 21:00 one above the other in units a hundred apart: 144.0 öre on the + * chart, 1.44 on the timeline, and a reader left to work out that they are + * the same money. The unit is named once above the column rather than on + * every row, which is what `unitPerKwh` is for. + */ +export function formatPrice(minor: number | null, currency: string): string | null { if (minor === null || !Number.isFinite(minor)) return null - return (minor / 100).toFixed(2) + return toDisplay(minor, currency).toFixed(unitFor(currency).decimals) } diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index 31ee8ad..1c1d53d 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -234,6 +234,62 @@ export interface Plan { ceilingW: number | null } +// -------------------------------------------------------------------------- +// Prices +// -------------------------------------------------------------------------- + +/** + * The window of prices to ask for. + * + * Wall clock, not box uptime. Prices are about hours a person plans around, + * and every other age in this protocol is measured against uptime precisely + * because it is about the box rather than about the day. + */ +export interface PriceQuery { + fromMs: number + toMs: number +} + +/** + * One settlement slot's price, in minor units per kWh. + * + * Integers — öre, cents — because a price is money, and money in a float is a + * rounding argument waiting to happen. `spotMinor` is the raw market price; + * `totalMinor` is what the household actually pays, tariff and tax included. + * The box computes the total because the box holds the configuration. + */ +export interface PriceSlot { + startMs: number + /** Slot length. An hour or a quarter of one, depending on the market. */ + durationMs: number + spotMinor: number + totalMinor: number +} + +export interface Prices { + /** Bidding zone, and what the minor units are. Without them 45 is a guess. */ + zone: string + currency: string + slots: PriceSlot[] + /** + * The answer does not cover the window asked for. + * + * Three shapes, not one: it begins after the start, it has a hole in the + * middle, or it stops short of the end. Tomorrow's rates publish in the + * afternoon, so a window asked for at breakfast genuinely ends early; one + * failed midday fetch on the box leaves a day holding 00:00-06:00 and + * 12:00-24:00; a box that first heard from the market at breakfast holds + * 06:00-24:00 of a day the app asked for from midnight. Saying so beats + * drawing a cliff the market did not have. + * + * Which shape it is has to be read off `slots` — see `hasHole` in + * `$lib/state/price`, which covers the first two. A day missing its own + * morning is not a day waiting for tomorrow, and the flag cannot tell them + * apart. + */ + stale: boolean +} + // -------------------------------------------------------------------------- // Commands // -------------------------------------------------------------------------- @@ -343,6 +399,7 @@ export type ServerMessage = | { t: 'tick'; b: Tick } | { t: 'hist.chunk'; id: number; b: HistChunk } | { t: 'hist.end'; id: number; b: HistEnd } + | { t: 'price'; id: number; b: Prices } | { t: 'cmd.ack'; b: CmdAck } | { t: 'cmd.result'; b: CmdResult } | { t: 'event'; b: EventMsg } @@ -354,4 +411,5 @@ export type ClientMessage = | { t: 'sub'; b: Sub } | { t: 'plan.get'; id: number } | { t: 'hist.query'; id: number; b: HistQuery } + | { t: 'price.get'; id: number; b: PriceQuery } | { t: 'cmd'; b: Cmd } diff --git a/src/lib/protocol/session.ts b/src/lib/protocol/session.ts index 9217ab0..b78fa7d 100644 --- a/src/lib/protocol/session.ts +++ b/src/lib/protocol/session.ts @@ -27,6 +27,8 @@ import { type HistChunk, type HistEnd, type Plan, + type PriceQuery, + type Prices, type ModeInfo, type Guard, type CmdAck, @@ -137,6 +139,29 @@ interface PendingPlan { timer: ReturnType } +/** A price window is one bulk message too, so it keeps the plan's deadline. */ +export const PRICE_TIMEOUT_MS = 8_000 + +interface PendingPrices { + resolve: (prices: Prices) => void + reject: (err: Error) => void + timer: ReturnType +} + +/** + * How long to wait before asking a box that is still starting again. + * + * A box coming back from an update answers hello with mode 'booting' and + * refuses a subscription until it is up. It does not announce being ready, and + * the carrier stays open throughout, so no reconnect is coming to ask again — + * without a timer of the session's own the app sits on the starting screen for + * as long as it is open, and reloading is never the fix here. + * + * Five seconds. A VACUUM runs for minutes, so this is nowhere near a poll, and + * it is close enough that the progress on the starting screen keeps moving. + */ +export const BOOT_RETRY_MS = 5_000 + /** * How long an intent stays valid, in ms of box uptime. * @@ -205,6 +230,14 @@ export class HistoryError extends Error { } } +/** Thrown when the box answers a price request with a stable error code. */ +export class PriceError extends Error { + constructor(readonly detail: ErrorMsg) { + super(detail.code) + this.name = 'PriceError' + } +} + export class Session { #state: SessionState = EMPTY #carrier: Carrier | null = null @@ -216,7 +249,10 @@ export class Session { #nextRequestId = 1 #pendingHistory = new Map() #pendingPlan = new Map() + #pendingPrices = new Map() #pendingCmd = new Map() + /** Set only while the box says it is starting. See BOOT_RETRY_MS. */ + #bootRetry: ReturnType | undefined constructor(opts: SessionOptions) { this.#opts = opts @@ -261,6 +297,18 @@ export class Session { // Losing the carrier does not clear the readings. They are still // true, just older — and the freshness band says so. this.#patch({ phase: 'failed', carrier: 'none' }) + // A box that cannot be reached is not one to ask how far along it + // is. The reopen above sends a fresh hello anyway. + clearTimeout(this.#bootRetry) + // But it does end every request in flight. This is the ordinary way + // a carrier goes away — the wire drops and the carrier reconnects + // inside itself, keeping its handlers — so it never reaches + // #detach. Settling has to happen here or a view waits out the + // request's own deadline against a reply that cannot arrive. + // #detach is deliberately not called: it closes the carrier and + // drops the handlers, and the carrier has to stay attached to come + // back. + this.#settlePending() } }) ) @@ -353,6 +401,30 @@ export class Session { }) } + /** + * Ask the box what electricity costs across a window. + * + * Bulk lane, for the same reason the plan is: two days of quarter-hour + * slots is far past lane 0's fixed bucket, and none of it belongs in the + * constant-cadence stream. + */ + prices(query: PriceQuery): Promise { + if (!this.#carrier) return Promise.reject(new Error('no carrier')) + + const id = this.#nextRequestId + this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1 + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pendingPrices.delete(id) + reject(new Error('price request timed out')) + }, PRICE_TIMEOUT_MS) + + this.#pendingPrices.set(id, { resolve, reject, timer }) + this.#sendBulk({ t: 'price.get', id, b: query }) + }) + } + /** * Express an intent and follow it to its outcome. * @@ -416,6 +488,9 @@ export class Session { } #sendHello(): void { + // Any hello supersedes a retry waiting to send one — a reconnect asks the + // same question, and two hellos in flight would earn two answers. + clearTimeout(this.#bootRetry) this.#send({ t: 'hello', b: { @@ -461,6 +536,9 @@ export class Session { case 'plan': this.#onPlan(envelope.b as Plan, envelope.id) break + case 'price': + this.#settlePrices(envelope.id, envelope.b as Prices) + break case 'cmd.ack': this.#onCmdAck(envelope.b as CmdAck) break @@ -493,9 +571,14 @@ export class Session { }) if (b.mode === 'booting') { - // Nothing to subscribe to yet. The box will accept one once it is up; - // the caller retries rather than the session spinning silently. + // Nothing to subscribe to yet — the box refuses one until it is up, and + // it does not say when that is. So ask again on our own timer: the + // carrier stays open the whole time the box is starting, so no status + // change is coming to do it for us, and a phase parked here is the + // starting screen for as long as the app is open. Each answer also + // refreshes the boot progress the screen is showing. this.#patch({ phase: 'booting' }) + this.#bootRetry = setTimeout(() => this.#sendHello(), BOOT_RETRY_MS) return } @@ -577,6 +660,22 @@ export class Session { } } + /** + * Prices settle the request and nothing else. + * + * Unlike a plan, which the box pushes unasked after a mode change and which + * therefore lives on session state, a price window only ever arrives as an + * answer. Keeping a copy on state would be a second place for the view to + * read the same thing from, and the two would eventually disagree. + */ + #settlePrices(id: number | undefined, prices: Prices): void { + const pending = this.#pendingPrices.get(id ?? -1) + if (!pending) return + this.#pendingPrices.delete(id!) + clearTimeout(pending.timer) + pending.resolve(prices) + } + #onCmdAck(b: CmdAck): void { const pending = this.#pendingCmd.get(b.cmdId) if (!pending) return @@ -621,6 +720,14 @@ export class Session { plan.reject(new PlanError(b)) return } + + const prices = this.#pendingPrices.get(id) + if (prices) { + this.#pendingPrices.delete(id) + clearTimeout(prices.timer) + prices.reject(new PriceError(b)) + return + } } this.#patch({ lastError: b }) @@ -652,18 +759,45 @@ export class Session { } #detach(): void { + clearTimeout(this.#bootRetry) for (const u of this.#unsub) u() this.#unsub = [] this.#carrier?.close() this.#carrier = null + this.#settlePending() + } - // A carrier that went away will never answer. Settling every pending - // request now is what keeps a view from waiting on a reply that cannot - // arrive — there is no "reload" button to rescue it. - for (const [id, pending] of this.#pendingHistory) { - clearTimeout(pending.timer) - pending.reject(new Error('carrier closed')) - this.#pendingHistory.delete(id) + /** + * End everything waiting on a carrier that will not answer. + * + * Separate from #detach because the two ways a carrier goes away need + * different things done to the carrier and the same thing done to the + * requests. #detach tears the carrier down; an ordinary drop leaves it + * attached to reconnect. Either way a view must stop waiting on a reply + * that cannot arrive — there is no "reload" button to rescue it. Every map, + * not just the first: one left behind is a promise that settles minutes + * later, on its own timer, against a view that has long since moved on. + */ + #settlePending(): void { + for (const map of [this.#pendingHistory, this.#pendingPlan, this.#pendingPrices]) { + for (const [id, pending] of map) { + clearTimeout(pending.timer) + pending.reject(new Error('carrier closed')) + map.delete(id) + } + } + + // A command is settled the way its own deadlines would settle it, because + // the two cases are different answers. One the box never acknowledged did + // not reach it, and is never replayed silently. One it did acknowledge may + // well have been carried out, so "that didn't reach your box" would be a + // lie — and "accepted, never confirmed" is exactly what unconfirmed says. + for (const [cmdId, pending] of this.#pendingCmd) { + clearTimeout(pending.ackTimer) + clearTimeout(pending.confirmTimer) + this.#pendingCmd.delete(cmdId) + if (pending.acked) pending.resolve({ cmdId, state: 'unconfirmed' }) + else pending.reject(new CommandError('E_NO_ACK', "That didn't reach your box. Try again.")) } } diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index 572b6ca..33e9db3 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -27,12 +27,15 @@ import { type HistQuery, type HistChunk, type HistEnd, + type PriceQuery, + type PriceSlot, + type Prices, type SiteMode, type ModeInfo, OP_SET_MODE, isRetryable, } from '$lib/protocol/messages' -import { buildPlan } from './planner' +import { buildPlan, priceAt, importTotalMinor } from './planner' import { RESOLUTIONS, DEFAULT_MAX_POINTS, @@ -215,8 +218,21 @@ const CAPS = [ 'cmd.readback', 'der.battery', 'plan.dispatch', + 'price.spot', ] +/** + * The hour tomorrow's rates publish. + * + * Day-ahead markets clear in the early afternoon, so for most of a day a + * window reaching into tomorrow genuinely ends early — which is the case + * `stale` exists to express, and the one a real box spends every morning in. + */ +const PRICE_PUBLISH_HOUR = 14 + +/** Settlement slot the sim publishes. Hourly, as the Nordic day-ahead was. */ +const HOUR_MS = 3_600_000 + export interface SimBoxOptions { house?: Partial faults?: Partial @@ -302,6 +318,9 @@ export class SimBox { case 'hist.query': this.#onHistQuery(id, b as HistQuery) break + case 'price.get': + this.#onPriceGet(id, b as PriceQuery) + break default: // Unknown types are answered, never fatal — that is what lets a newer // app talk to an older box. @@ -412,6 +431,19 @@ export class SimBox { this.#bucket = sub.bucket this.#subscribed = true + this.#sendSnapshot() + } + + /** + * Send the whole state, and reset what the delta stream is measured against. + * + * On subscribe, and again whenever `controlRev` moves. The real box does the + * second one too: without it the app holds a revision the box has left + * behind, and every command after the first is refused as a conflict with a + * change the app itself made. That looked like an app bug on the dev screen + * for exactly as long as nobody tapped a mode twice. + */ + #sendSnapshot(): void { this.#lastSent.clear() this.#lastSourcesJson = JSON.stringify(this.#sources()) @@ -479,6 +511,7 @@ export class SimBox { const lease = { leaseId: `lease-${cmd.cmdId.slice(0, 8)}`, expiresAtMs: this.uptimeMs + 900_000 } this.#idempotency.set(cmd.cmdId, lease) this.#controlRev += 1 + if (this.#subscribed) this.#sendSnapshot() this.#send( { lane: LANE_CONTROL, flags: 0, envelope: { t: 'cmd.ack', b: { cmdId: cmd.cmdId, ...lease } } }, @@ -549,6 +582,70 @@ export class SimBox { ) } + /** + * Answer a price window with the slots the market has actually published. + * + * The curve is the planner's, so the price on the chart and the price on + * the timeline below it are the same number for the same hour. The total is + * computed here because the box is where the tariff and the tax live. + */ + #onPriceGet(id: number | undefined, q: PriceQuery): void { + if (typeof id !== 'number') return + + if (this.faults.booting) { + this.#error('E_BOOTING', isRetryable('E_BOOTING'), { etaMs: 90_000 }, id) + return + } + + const nowMs = this.#now() + const dayStart = new Date(nowMs).setHours(0, 0, 0, 0) + const publishedTo = + dayStart + (new Date(nowMs).getHours() >= PRICE_PUBLISH_HOUR ? 2 : 1) * DAY_MS + + const slots: PriceSlot[] = [] + // Aligned to the local hour, not the UTC one. Every window this is asked + // for starts at local midnight, and where the zone is offset by half an + // hour — Kolkata, Chatham — a UTC-hour floor puts every slot thirty + // minutes off the local day. The chart's midnight would then not be the + // day boundary the rest of the view is drawn against, and a window that + // reaches its end would report itself short. + const first = new Date(q.fromMs).setMinutes(0, 0, 0) + for (let at = first; at + HOUR_MS <= Math.min(q.toMs, publishedTo); at += HOUR_MS) { + // Local hours, the same clock the day boundary and the publish hour + // above are read in. Mixing the two shifted the sim's peaks off the + // local hours the rest of the view is drawn in. + const spot = priceAt(new Date(at).getHours()) + slots.push({ + startMs: at, + durationMs: HOUR_MS, + spotMinor: spot, + totalMinor: importTotalMinor(spot), + }) + } + + const last = slots[slots.length - 1] + const prices: Prices = { + zone: 'SE4', + currency: 'SEK', + slots, + // An answer that reaches the end of the window is complete; anything + // short of it says so, and an empty store is the shortest answer there is. + // + // The box also sets this for a window that begins after the start and for + // a hole in the middle — three shapes, one flag, see docs/protocol.md. + // The tail is the whole test here only because this loop can produce + // neither of the others: it floors to the hour at or before `fromMs` and + // walks one unbroken hour at a time. Read the contract, not this line, + // for what `stale` means. + stale: last === undefined || last.startMs + last.durationMs < q.toMs, + } + + this.#send( + { lane: LANE_BULK, flags: 0, envelope: { t: 'price', id, b: prices } }, + bulkBucketFor(16000) ?? 16384 + ) + } + /** The mode the site is running in. Read by the planner. */ get mode(): SiteMode { return this.#mode diff --git a/src/lib/sim/planner.ts b/src/lib/sim/planner.ts index be49817..f6c21b8 100644 --- a/src/lib/sim/planner.ts +++ b/src/lib/sim/planner.ts @@ -21,14 +21,39 @@ const HORIZON_SLOTS = 96 // a day ahead * * Two peaks and a night trough, which is what a Nordic day looks like and * what makes a plan interesting: there is something to shift. + * + * Exported because the price window the box serves has to be the same curve + * the plan was built from. Two curves would put one price on the timeline and + * another on the chart above it, for the same hour. */ -function priceAt(hourOfDay: number): number { +export function priceAt(hourOfDay: number): number { const morning = 90 * Math.exp(-((hourOfDay - 7.5) ** 2) / 3) const evening = 130 * Math.exp(-((hourOfDay - 18) ** 2) / 4) const base = 45 + 12 * Math.sin((hourOfDay / 24) * 2 * Math.PI) return Math.round(base + morning + evening) } +/** + * What the sim's grid operator charges, and the tax on both. + * + * On this side because the box is the only side that knows them: it is why + * the wire carries a total per slot at all. + */ +const SIM_GRID_TARIFF_MINOR = 70 +const SIM_VAT_PCT = 25 + +/** + * What a kilowatt-hour costs to import: spot plus tariff, taxed. + * + * One function because two callers need it — the price window and the plan's + * `priceMinor`, which the box documents as the import price and not the spot. + * Two arithmetics would put a different number on the timeline than on the + * chart directly above it, for the same hour, on the same screen. + */ +export function importTotalMinor(spotMinor: number): number { + return Math.round((spotMinor + SIM_GRID_TARIFF_MINOR) * (1 + SIM_VAT_PCT / 100)) +} + export interface PlanInput { house: HouseConfig mode: SiteMode @@ -53,8 +78,15 @@ export function buildPlan(input: PlanInput): Plan { for (let i = 0; i < HORIZON_SLOTS; i++) { const at = start + i * SLOT_MS - const hour = new Date(at).getUTCHours() + new Date(at).getUTCMinutes() / 60 - const price = priceAt(hour) + // Whole hours, though the plan's slots are quarters of one: a market + // price is flat across its settlement slot, and pricing 09:15 a little + // above 09:00 would put four different numbers on the plan under one + // bar of the price chart. + // + // Local hours, because every hour on this screen is a local one. Reading + // the curve in UTC put the sim's peaks an hour or two off the labels + // beside them, which makes the dev screen a wrong thing to review against. + const price = priceAt(new Date(at).getHours()) const reading = sample(house, at, soc, ceilingW) // pv_w is never positive, so the house's net demand is load + pv and a @@ -124,7 +156,9 @@ export function buildPlan(input: PlanInput): Plan { durationMs: SLOT_MS, batteryW: Math.round(batteryW), gridW: Math.round(gridW), - priceMinor: price, + // The import price, which is what the box puts here — the decisions + // above are taken on spot, but spot is not what a slot costs. + priceMinor: importTotalMinor(price), reason, }) } diff --git a/src/lib/state/age.test.ts b/src/lib/state/age.test.ts index 00ff455..7476b22 100644 --- a/src/lib/state/age.test.ts +++ b/src/lib/state/age.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import 'fake-indexeddb/auto' import { Session } from '$lib/protocol/session' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' import type { SourceState } from '$lib/protocol/types' /* Age is a claim, and a wrong one is the one thing this app must never make. @@ -41,6 +44,19 @@ describe('ageOf across a box restart', () => { }) }) +/* The phone in the tunnel. + * + * The wall-clock term only exists for a stream that has stopped, so the whole + * clause is dead unless a frame has arrived and then stopped arriving — and + * unless the app's own second-hand is running, which it only is after + * `start()`. An earlier version of this test built a store and never started + * one, so both assertions were the "nothing has ever arrived" zero and cutting + * the wall-clock term out of `sinceLastFrameMs` left it green. + * + * So the whole path is real here: the store starts the way it starts on a cold + * open, connects over the loopback to a simulator, streams, and then the wire + * goes. + */ describe('the age keeps moving when the stream stops', () => { beforeEach(() => { vi.useFakeTimers() @@ -48,6 +64,22 @@ describe('the age keeps moving when the stream stops', () => { }) afterEach(() => vi.useRealTimers()) + /** fake-indexeddb runs its transactions on timers. Keep the clock moving. */ + async function pump(ms = 5, times = 40) { + for (let i = 0; i < times; i++) await vi.advanceTimersByTimeAsync(ms) + } + + /** + * Let the zero-latency loopback deliver, barely moving the clock. + * + * A millisecond a step rather than the pump's five, because everything below + * is measured from the last frame: the less time passes between it arriving + * and the wire being cut, the closer the silence is to the round number. + */ + async function flush(times = 20) { + for (let i = 0; i < times; i++) await vi.advanceTimersByTimeAsync(1) + } + it('adds wall clock once the frames stop arriving', async () => { const { SiteStore } = await import('./site.svelte') const site = new SiteStore('test') @@ -59,11 +91,47 @@ describe('the age keeps moving when the stream stops', () => { // Nothing has ever arrived, so there is no silence to report yet. expect(site.sinceLastFrameMs).toBe(0) + // A cold open. This is also where the app's one-second clock is started, + // and nothing below moves without it. + const started = site.start('test') + await pump() + await started + + const box = new SimBox({ now: () => Date.now() }) + const carrier = new LoopbackCarrier(box, { latencyMs: 0 }) + site.connect(carrier) + await flush() + box.tick() + await flush() + + expect(site.session.phase).toBe('streaming') + // Live, and a reading whose age the box itself vouches for. + expect(site.sinceLastFrameMs).toBe(0) + expect(Number.isNaN(site.ageMs)).toBe(false) + const liveAgeMs = site.ageMs + + carrier.drop('into a tunnel') + // Two beats of a 1 Hz stream is not silence; reporting it would make a // healthy view flicker between "now" and "1s ago". - vi.advanceTimersByTime(2_000) + await vi.advanceTimersByTimeAsync(2_000) expect(site.sinceLastFrameMs).toBe(0) + // Past that it is silence. The box's uptime stopped with the last frame, + // so this term is the only thing left that can move — without it the band + // would still read "readings 0s ago" an hour into the tunnel, which is the + // one lie this app must never tell. + // + // Within a tick of a minute rather than exactly it: the app reads the + // clock on its own second-hand rather than on every access, so the answer + // is as coarse as the display it feeds and no coarser. + await vi.advanceTimersByTimeAsync(58_000) + expect(site.sinceLastFrameMs).toBeGreaterThan(59_000) + expect(site.sinceLastFrameMs).toBeLessThanOrEqual(60_000) + + // And it is added to the reading's own age, not swapped for it. + expect(site.ageMs).toBe(liveAgeMs + site.sinceLastFrameMs) + site.destroy() }) }) diff --git a/src/lib/state/ask.svelte.ts b/src/lib/state/ask.svelte.ts new file mode 100644 index 0000000000000000000000000000000000000000..88b3d6597b8de10c020ce42a8890c789da5c874f GIT binary patch literal 4530 zcmZ{o(QX^b5k>RNujl{f24vUlL zKJ4O&PgXq&oKsx5t3*{)1}+e_bh+i^w&`BC1#rl@CWF>s$EgURTs^rpcVs3|T_aDJ z=|~qE53u}G{(=hrO(~13g0D!8nyaQ9k(a!8(D~H8`S`=Tch_%!{>Op*+d2+Ch4yQT zDQ7;qp{*#bg^&~Hk64o`#BnR?1$akv`wa+=Y@W(2eB^NF>mEdDwnS9r+YtL&qz3?R z0ItX`REt7CYoyi2cW?k2-`4lA#>-b1ddLp=a+!9jdg&N-=HbhsJw*rNJ$1WHz1moz zXx9-nE0eW+h!xctTFO{hp+Lt=`dw1R??axL@MCBykxWKh3x7vm5tPMiy*#mAZ5@N} zzFDQV24~y?DDtCpjHh|4dRJY&R{Mf!(SmGk-0WRduh%3Jdpl-foXuwSwoTyknK1rN z<9BKA9^9HnqE+uh(}(AiNxyFcb|30Kd_c9r!ESMudA69$XJ;===zavECYAE4TBJwu z;8VU+(T!GxF5(JL2^i8Aj!$V@dh#Msvfs^>+OlcVu5{aNr)s1L*lXxuR#h67s2gek zR^&F^-deLtk>$Z;6$7D(f}9#Bx5Q|-3s~eCiIIN`SxxZU0H0_h6(G7z8oCQeIds;Q z(<8YT!BO+nGzWLI5La6oP=As|l8%Xn%s7J*I=Aua=~U8~^dN>$!hlFh%{eD&43RmL z96Sdl{R{C~hNedFDnW8KL*YR0{hRCe|N40K!CksPoqs$(KY#N4Z*SiIL_e3`zx^}0 z|D_mB<$O(npg;3Y?KWu*c{a2a8A>I_A=1~39SVNTv6Rzn6uXJUSE@c$@MZ@SZd_!1 zl^I&Jd>O0?(=;qyZtW*aD=FoHDN;e-Af46Tt~242aMI;dWz0*zW7htaGDRl#(TToI zju!4F4o!38wgCYx$lG|Z3ac8zywDflX~dnZFD7 z^Ek;v!_c-tw1VJ$I1G_#3DDguk}Eo&ch-sUASE`Q@X4=w)TXKBUs|f6Gi5T}0Z^s; zzRDYSE zC#7d{j*550xh(cm3ReTYM2i^$m8%A=DWZCdkRz^CVQ5dM{P!-Px~sl$RV?WS`jvrN zSEg-1R4iaaQqyeZUkL0vRF$yzh5Tbrh}8Mik&vc@2?pu4itcO{KAD)Whqx*|knetB zN&AToJ()QtnZ7s*_$iOnXcz8e?k-;ma;5UciSPQ6aHrq?X{%NEeu#GwlwZx}7w$gQ zmHUic6Ck_#>Efvmlt!*b3xR)?c{T~r_*`m%hQ0^4E?zTazSpEdUP1Dj-+=!WK@F6C z%|24iILC-_zt}7mq?Ft^IdnGOsKdg&);<~0t4KCgJ z06fX3bnzgLBHh4w*eD}yVz#?Rq?pf|xh)&Muy5%r%PYV32 z2EL66pBN}}vwK$fFITI}hJ3t%fJ_N6&rVhi$8$>|$N!T)lQDWlonhaZ%R9 zKVDuwS<1hB8Qt<+#hY7&<>2fL$O)f48k4Dk$EKkK@PZ;81WBS6*y*u`spIA<5wvK` zofU0-DJg_58|_Ygq^^?>xyedAbe13HF7F>{y%sN? zY@w8o#eM^CwI!kvM`!9mi!iZd#=%=hjcXTM+j=T|zOV`yF0z*_3mVcx6K`#e zKD+}GxpE`!L#kY5G!pG`t9QCCBL=cFuPP{9u}z=}EZ#_RXZsP`SQ|zJ4a`2 pQWQngu|pGF@!uZ{f;<_Yyl~&0j^pIGzfR{KEJ}TQn9m+&{{#I4*fszF literal 0 HcmV?d00001 diff --git a/src/lib/state/history.svelte.ts b/src/lib/state/history.svelte.ts index 76da5d5..b415213 100644 --- a/src/lib/state/history.svelte.ts +++ b/src/lib/state/history.svelte.ts @@ -88,18 +88,27 @@ export class HistoryStore { return frame.startMs + this.cursor * frame.stepMs } + /** + * Choose what the chart covers. + * + * Only sets the range, because the range is the question `askWhenLive` asks + * under: changing it is already what fetches, and what heals a tap whose + * answer never comes. Fetching here as well would send the same window + * twice for one tap — a second bulk round trip that `#token` then throws + * away, on the screen where the wire is busiest. + */ select(range: RangeKey): void { - if (range === this.range && this.frame) return this.range = range - void this.load() } /** * Fill the chart: cache first, box second. * - * Never rejects. A history request that fails leaves whatever was cached on - * screen with a line saying it is not current, because that is more useful - * than an empty chart and an apology. + * A window the box could not serve leaves whatever was cached on screen + * with a line saying it is not current, because that is more useful than an + * empty chart and an apology — and then rejects, because the caller that + * heals this has no other way to tell an answer from a failure the store + * swallowed. */ async load(): Promise { const token = ++this.#token @@ -157,10 +166,13 @@ export class HistoryStore { show() if (siteId) void pruneTiles(siteId, toMs - RESOLUTIONS[end.resActual].retentionMs) - } catch { + } catch (err) { + // A reply for a range the user has already moved off is not this + // range's news, and it is not a reason to ask for this one again. if (token !== this.#token) return // What happens now, not what broke inside. The cached chart stays up. this.error = tiles.size > 0 ? 'Not up to date — your box is out of reach' : 'No history yet' + throw err } finally { if (token === this.#token) { this.loading = false diff --git a/src/lib/state/plan.svelte.ts b/src/lib/state/plan.svelte.ts index e965ad9..923383d 100644 --- a/src/lib/state/plan.svelte.ts +++ b/src/lib/state/plan.svelte.ts @@ -28,6 +28,16 @@ export class PlanStore { #timer: ReturnType | null = null plan = $state(null) + + /** + * Bumped when something other than the session wants the plan again. + * + * Part of the name `askWhenLive` asks under, so a replan chased after a + * mode change is the same rule as every other ask: one place decides when + * to ask again, and one place heals an ask that failed. + */ + want = $state(0) + loading = $state(false) /** Set when the box could not answer. A sentence, never a code. */ problem = $state(null) @@ -93,21 +103,34 @@ export class PlanStore { async #followPlan(): Promise { const before = this.plan?.rev for (let attempt = 0; attempt < 10; attempt++) { - await this.load() - if (this.plan?.rev !== before) return + // Asks by changing the question rather than fetching here. A fetch of + // its own would be a second one outside `askWhenLive`, and when these + // thirty seconds were up a failed one would leave the screen saying it + // was still trying while nothing was. + this.want += 1 await new Promise((r) => setTimeout(r, 3_000)) + if (this.plan?.rev !== before) return } } + /** + * Fetch the plan, and say so when the box could not send one. + * + * Rejects on failure as well as saying it, because the caller that heals + * this — `askWhenLive` — has no other way to tell an answer from a failure + * the store swallowed. The view keeps whatever plan it had either way. + */ async load(): Promise { this.loading = true this.problem = null try { this.plan = await this.#site.plan() - } catch { - // A plan the box could not send is not a broken app. The view keeps - // whatever it had and says the one useful thing. - this.problem = "Couldn't reach your box for the plan. It'll load when it's back." + } catch (err) { + // A plan the box could not send is not a broken app. What happens now + // is that the app asks again on its own, so that is what it says — the + // sentence is true because askWhenLive makes it true. + this.problem = "Couldn't get the plan from your box. Still trying." + throw err } finally { this.loading = false } diff --git a/src/lib/state/price.test.ts b/src/lib/state/price.test.ts new file mode 100644 index 0000000..a6bb694 --- /dev/null +++ b/src/lib/state/price.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { chartPrices, hasHole } from './price' +import type { Prices } from '$lib/protocol/messages' + +// The mapping between the wire's price window and the vendored chart. The +// component itself is the box's file and is not under test here — what is +// under test is every rename and unit change the app makes before speaking +// to it, because a silent one of those puts the right shape on screen with +// the wrong numbers in it. + +const WIRE: Prices = { + zone: 'SE4', + currency: 'SEK', + stale: false, + slots: [ + { startMs: 1_800_000_000_000, durationMs: 3_600_000, spotMinor: 17, totalMinor: 109 }, + { startMs: 1_800_003_600_000, durationMs: 900_000, spotMinor: -4, totalMinor: 82 }, + ], +} + +describe('chartPrices', () => { + it('renames every field the component reads', () => { + const slot = chartPrices(WIRE).slots[0]! + + expect(slot.tsMs).toBe(1_800_000_000_000) + expect(slot.spot).toBe(17) + expect(slot.total).toBe(109) + }) + + it('converts slot length from milliseconds to minutes', () => { + const slots = chartPrices(WIRE).slots + + expect(slots[0]!.lenMin).toBe(60) + // Quarter-hour settlement, which is what the day-ahead market moved to. + expect(slots[1]!.lenMin).toBe(15) + }) + + it('keeps the slot end where the box put it', () => { + // The component finds a slot's end as tsMs + lenMin × 60 000. Anything + // lossy in the conversion above moves that end, and with it the NOW + // marker and every tick label. + for (const [i, slot] of chartPrices(WIRE).slots.entries()) { + const wire = WIRE.slots[i]! + expect(slot.tsMs + slot.lenMin * 60_000).toBe(wire.startMs + wire.durationMs) + } + }) + + it('carries the total across rather than recomputing it', () => { + // The box holds the grid tariff and the VAT rate. Spot × 1.25 here would + // read 21 öre for the slot the box's own dashboard prices at 109. + const totals = chartPrices(WIRE).slots.map((s) => s.total) + expect(totals).toEqual([109, 82]) + }) + + it('keeps a negative spot negative', () => { + // Priced to be taken. The chart draws those bars below the zero line and + // colours them differently, which it cannot do if the sign is lost. + expect(chartPrices(WIRE).slots[1]!.spot).toBe(-4) + }) + + it('passes the labels and the staleness through untouched', () => { + expect(chartPrices(WIRE).zone).toBe('SE4') + expect(chartPrices(WIRE).currency).toBe('SEK') + expect(chartPrices(WIRE).stale).toBe(false) + // Tomorrow's rates have not published. Saying so is the difference + // between a short window and a market that stopped. + expect(chartPrices({ ...WIRE, stale: true }).stale).toBe(true) + }) + + it('maps an empty window to an empty window', () => { + expect(chartPrices({ ...WIRE, slots: [] }).slots).toEqual([]) + }) +}) + +/* Telling the shapes of a short answer apart. + * + * The box sets one flag over all three — a window that begins after its start, + * one with a hole in the middle, one that stops short of the end — so this is + * the only thing that can keep the view from saying "tomorrow isn't published" + * over a day missing its morning. + */ +describe('hasHole', () => { + /** Where the window that produced WIRE was asked to start. */ + const ASKED_FROM = 1_800_000_000_000 + + it('does not call a slot that joins the last one a hole', () => { + // The mixed case on purpose: an hour followed by a quarter of one, meeting + // exactly. Comparing starts rather than ends would read this as a gap. + expect(hasHole(chartPrices(WIRE).slots, ASKED_FROM)).toBe(false) + }) + + it('finds an hour nobody sent', () => { + const withGap = chartPrices({ + ...WIRE, + slots: [ + { startMs: 1_800_000_000_000, durationMs: 3_600_000, spotMinor: 17, totalMinor: 109 }, + // 3 600 000 ms later would join. This starts an hour after that. + { startMs: 1_800_007_200_000, durationMs: 3_600_000, spotMinor: 21, totalMinor: 113 }, + ], + }) + expect(hasHole(withGap.slots, ASKED_FROM)).toBe(true) + }) + + it('finds a window that never started', () => { + // The shape a box sends when its store begins mid-day: eighteen hourly + // slots from 06:00 answering a request for the whole day. Every slot joins + // the last, so looking only between them sees a flawless day — and the + // chart, which lays bars out by index, draws one. + const lateStart = chartPrices({ + ...WIRE, + slots: [ + { startMs: ASKED_FROM + 6 * 3_600_000, durationMs: 3_600_000, spotMinor: 17, totalMinor: 109 }, + { startMs: ASKED_FROM + 7 * 3_600_000, durationMs: 3_600_000, spotMinor: 21, totalMinor: 113 }, + ], + }) + expect(hasHole(lateStart.slots, ASKED_FROM)).toBe(true) + }) + + it('does not invent a hole before a window that starts where it was asked to', () => { + const onTime = chartPrices({ ...WIRE, slots: [WIRE.slots[0]!] }) + expect(hasHole(onTime.slots, ASKED_FROM)).toBe(false) + // The box is free to answer with more than was asked for. + expect(hasHole(onTime.slots, ASKED_FROM + 60_000)).toBe(false) + }) + + it('has no hole in a window of none', () => { + // Nothing at all is a chart that draws nothing, and a notice about + // missing hours under an empty rectangle explains the wrong thing. + expect(hasHole([], ASKED_FROM)).toBe(false) + }) +}) diff --git a/src/lib/state/price.ts b/src/lib/state/price.ts new file mode 100644 index 0000000..987bc1f --- /dev/null +++ b/src/lib/state/price.ts @@ -0,0 +1,69 @@ +/* From a price window on the wire to the price chart's own vocabulary. + * + * The box's dashboard feeds this component from /api/prices, where a slot is + * {slot_ts_ms, slot_len_min, spot_ore_kwh}; this is the same mapping fed from + * the session instead. Two names change and one unit does, and that is the + * whole of it — the component is the box's file and is not touched here. + * + * The total is carried across rather than recomputed. The box holds the grid + * tariff and the VAT rate and applies them once; an app that multiplied spot + * by its own guess would put a different number under the same hour than the + * box's own dashboard does, which is the fault this component already had + * once and was fixed for. + */ + +import type { Prices } from '$lib/protocol/messages' +import type { FtwPriceChartWindow, FtwPriceChartSlot } from '$vendor/ftw/ftw-price-chart.js' + +const MINUTE_MS = 60_000 + +/** + * Build the component's window from the wire's. + * + * Slot length crosses as milliseconds and the component counts in minutes, so + * this divides — exactly, since the component multiplies by the same number + * to find where a slot ends. Rounding here would move the end of a slot. + */ +export function chartPrices(wire: Prices): FtwPriceChartWindow { + return { + zone: wire.zone, + currency: wire.currency, + stale: wire.stale, + slots: wire.slots.map((s) => ({ + tsMs: s.startMs, + lenMin: s.durationMs / MINUTE_MS, + spot: s.spotMinor, + total: s.totalMinor, + })), + } +} + +/** + * Whether the window misses hours it should have, rather than ending early. + * + * The box sets `stale` for either — its rule is that the answer does not + * cover the window asked for, and all three shapes of short answer fall under + * it: one failed midday fetch leaves a store holding 00:00-06:00 and + * 12:00-24:00, and a store that first heard from the market at breakfast + * holds 06:00-24:00 of a day asked for from midnight. Those two are missing + * hours; a window that merely stops early is waiting for tomorrow. They need + * different sentences and the flag cannot carry both, so the app reads it off + * the slots it already holds. + * + * `fromMs` is where the window was asked to start, and it is what makes a + * missing head visible at all. A store that begins at 06:00 answers a request + * for the whole day with eighteen slots that join each other perfectly, so + * looking only between slots sees a complete day — and the chart lays bars + * out by index and closes the gap visually, which puts the NOW marker on the + * wrong bar with nothing on the screen saying so. + */ +export function hasHole(slots: readonly FtwPriceChartSlot[], fromMs: number): boolean { + if (slots.length === 0) return false + if (slots[0]!.tsMs > fromMs) return true + + for (let i = 1; i < slots.length; i++) { + const prev = slots[i - 1]! + if (prev.tsMs + prev.lenMin * MINUTE_MS < slots[i]!.tsMs) return true + } + return false +} diff --git a/src/lib/state/site.svelte.ts b/src/lib/state/site.svelte.ts index 9935877..d0baec3 100644 --- a/src/lib/state/site.svelte.ts +++ b/src/lib/state/site.svelte.ts @@ -12,7 +12,7 @@ */ import { Session, type SessionState } from '$lib/protocol/session' -import type { Plan, CmdResult, Guard } from '$lib/protocol/messages' +import type { Plan, PriceQuery, Prices, CmdResult, Guard } from '$lib/protocol/messages' import type { HistQuery, HistChunk, HistEnd } from '$lib/protocol/messages' import type { Carrier } from '$lib/carrier/carrier' import { explain, FID, type Explanation } from '$lib/format/explanation' @@ -120,6 +120,11 @@ export class SiteStore { return this.#session.plan() } + /** What electricity costs across a window. */ + prices(query: PriceQuery): Promise { + return this.#session.prices(query) + } + /** * Express an intent and wait for what actually happened. * diff --git a/src/styles/tokens.css b/src/styles/tokens.css index caa0d75..1475901 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -66,6 +66,9 @@ --ink-raised: var(--surface-raised); --ink-elevated: var(--surface-elevated); --amber: var(--energy-generation); + /* The price chart's "they pay you to take it" colour, and its midnight + rule. The box aliases it to amber too — see theme.css. */ + --yellow: var(--amber); --red-e: var(--energy-import); --green-e: var(--energy-export); --cyan: var(--energy-storage); diff --git a/src/vendor/ftw/api-fetch.js b/src/vendor/ftw/api-fetch.js new file mode 100644 index 0000000..ef40d62 --- /dev/null +++ b/src/vendor/ftw/api-fetch.js @@ -0,0 +1,7 @@ +// Vendored from srcfl/ftw web/components/api-fetch.js at da6a1018. +// Do not edit here — change it upstream and re-copy. The app and the +// box's own dashboard render this exact file; that is the point. +// Shared local API accessor for web components. +export function apiFetch(path, opts) { + return fetch(path, opts); +} diff --git a/src/vendor/ftw/digests.json b/src/vendor/ftw/digests.json new file mode 100644 index 0000000..95be6aa --- /dev/null +++ b/src/vendor/ftw/digests.json @@ -0,0 +1,10 @@ +{ + "api-fetch.js": "ae8d2c3a8508d0b20c4afb599cf41f1cf5a5bba55deb6f496a530c233ea69934", + "ftw-element.js": "7241b586582187cef2d76d3ee11bbc596c4f428cea3a5c8c84227e113aa3c1a0", + "ftw-energy-flow.js": "8b59561b4c8d50fb7df1c457f6d22fe5d5b5a13d050d1f5834217fb68f08363d", + "ftw-price-chart.js": "fe38335b54fdf96c423d5d0c284acfa634327b107d3554af2189a7bd554c0c0d", + "price-math.js": "bc6b104259f76d799c0a9d7f1d8a780d897cb7dc0d7a3e16a6572fb9c626d508", + "price-strip.js": "413b2946c311e910c9a052a8407a3a403ed6caca0e210307c6f3e951896c2b9b", + "price-summary.js": "d84cafce59e0cd44939522ba59c0c89979d54cfb414b2c4431a96230e611cd33", + "price-units.js": "996d82b9d1c52cae499dffc4eb6ca929c035c667c3c886eb8711d2c1c329a73b" +} diff --git a/src/vendor/ftw/ftw-price-chart.d.ts b/src/vendor/ftw/ftw-price-chart.d.ts new file mode 100644 index 0000000..d710567 --- /dev/null +++ b/src/vendor/ftw/ftw-price-chart.d.ts @@ -0,0 +1,24 @@ +/* Types for the vendored component — the surface the app actually uses. + * The implementation is the box's own file, untouched; see the header there. + */ + +/** One slot in the component's own vocabulary. Minor units per kWh. */ +export interface FtwPriceChartSlot { + tsMs: number + lenMin: number + spot: number + /** With tariff and tax added. Absent means the component computes it. */ + total?: number +} + +export interface FtwPriceChartWindow { + zone: string + currency: string + slots: FtwPriceChartSlot[] + /** The prices stop short of the window asked for. */ + stale: boolean +} + +export interface FtwPriceChartElement extends HTMLElement { + setPrices(window: FtwPriceChartWindow): void +} diff --git a/src/vendor/ftw/ftw-price-chart.js b/src/vendor/ftw/ftw-price-chart.js new file mode 100644 index 0000000..ceb496b --- /dev/null +++ b/src/vendor/ftw/ftw-price-chart.js @@ -0,0 +1,1341 @@ +// Vendored from srcfl/ftw web/components/ftw-price-chart.js at f7f475cb. +// Do not edit here — change it upstream and re-copy. The app and the +// box's own dashboard render this exact file; that is the point. +// — full-width bar chart of known electricity prices +// (next 48 h or so), with a toggle between the consumer total (default) +// and raw spot, and a hover tooltip per slot. Peaks + lows are marked. +// Self-fetching: hits /api/prices and /api/config on connect, polls +// /api/prices every 5 min after that. +// +// Inputs (none — autonomous). The component renders its own header +// (label + price-mode toggle) and the SVG chart underneath. +// +// Unless it is told otherwise: the `fed` attribute turns both the fetch +// and the poll off and hands the data question to the caller, which then +// pushes windows in with setPrices(). The FTW app draws this chart over +// its encrypted session to the box and has no HTTP origin at all, so a +// request from here would be a 404 every five minutes for the life of the +// page. Without the attribute nothing about this file changes. +// +// Data shape from /api/prices: +// { zone: "SE4", enabled: true, items: [ +// { slot_ts_ms, slot_len_min, spot_ore_kwh, total_ore_kwh, ... } +// ] } +// +// The consumer total is (spot + grid tariff) × (1 + VAT/100) — the same +// formula as prices.Applier on the Go side and the plan chart's tooltip. +// Grid tariff and VAT come from /api/config (price.grid_tariff_ore_kwh, +// price.vat_percent); we recompute rather than read the stored +// total_ore_kwh so a tariff change in settings applies to already-fetched +// slots too. Missing config falls back to 0 tariff / 25 % VAT. +// +// The default used to be spot × 1.25 labelled "incl. VAT", which left the +// grid tariff out — on a 70 öre/kWh tariff that reported 21 öre for a slot +// the plan chart (correctly) priced at 109 öre. + +import { FtwElement } from "./ftw-element.js"; +import { apiFetch } from "./api-fetch.js"; +import { + buildCompactPriceView, + formatPriceSlotLabel, +} from "./price-summary.js"; +import { bestBlock, consumerTotalOre, priceParts } from "./price-math.js"; +import { setActiveCurrency, toDisplay, unitFor } from "./price-units.js"; +import { buildPriceStrip } from "./price-strip.js"; + +class FtwPriceChart extends FtwElement { + static styles = ` + :host { + display: block; + font-family: var(--sans); + color: var(--fg); + } + .head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + gap: 12px; + flex-wrap: wrap; + } + .label { + font-family: var(--mono); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--fg-muted); + } + .meta { + font-family: var(--mono); + font-size: 11px; + color: var(--fg-dim); + } + .meta-stats { + display: flex; + gap: 0.9rem; + row-gap: 0.35rem; + flex-wrap: wrap; + margin-top: 0.15rem; + /* line-height 1 keeps each "now / low / high / avg" item tight + vertically so when the row wraps onto two lines on a phone the + line-spacing comes from row-gap, not from per-item baseline + leading (which otherwise stacked the wrapped row too far down). */ + line-height: 1; + } + .meta-stats .meta-label { + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + margin-right: 0.18em; + } + .meta-stats span { white-space: nowrap; } + .toggle { + position: relative; + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + border: 1px solid var(--line); + border-radius: 999px; + background: var(--ink-sunken); + padding: 2px; + isolation: isolate; + } + .toggle::before { + content: ''; + position: absolute; + top: 2px; bottom: 2px; + left: 2px; + width: calc(50% - 2px); + background: var(--accent-e); + border-radius: 999px; + transform: translateX(0); + transition: transform 240ms cubic-bezier(0.4, 0, 0.2, 1); + z-index: 0; + } + .toggle[data-price-mode="spot"]::before { + transform: translateX(100%); + } + /* Horizon pill is a 3-position selector (Today / +Tomorrow / Tomorrow); + slider width is 1/3 of the inner area instead of the default 1/2. */ + .toggle[data-horizon]::before { + width: calc((100% - 4px) / 3); + } + .toggle[data-horizon="all"]::before { transform: translateX(100%); } + .toggle[data-horizon="tomorrow"]::before { transform: translateX(200%); } + .toggles { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + } + .toggle button { + position: relative; + z-index: 1; + background: transparent; + border: 0; + color: var(--fg-dim); + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 4px 14px; + cursor: pointer; + transition: color 220ms ease; + } + .toggle button.active { color: #0a0a0a; } + .toggle button:not(.active):hover { color: var(--fg); } + + .chart-wrap { + position: relative; + } + svg.chart { + width: 100%; + display: block; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + /* Allow normal vertical page scroll when the touch starts on the + chart. Horizontal gestures are reserved for scrubbing — we win + them by calling preventDefault on touchmove once long-press + has fired. */ + touch-action: pan-y; + } + .scrub-cursor { + pointer-events: none; + transition: opacity 80ms ease; + } + .empty { + color: var(--fg-muted); + font-size: 0.85rem; + padding: 24px 8px; + text-align: center; + } + /* Tooltip — absolutely positioned, follows the cursor's slot. */ + .tip { + position: absolute; + pointer-events: none; + background: var(--ink-raised); + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + font-family: var(--mono); + font-size: 12px; + color: var(--fg); + transform: translate(-50%, -110%); + white-space: nowrap; + opacity: 0; + transition: opacity 80ms; + z-index: 5; + } + .tip.visible { opacity: 1; } + .tip-time { + color: var(--fg-dim); + margin-bottom: 2px; + } + .tip-price { + font-size: 14px; + font-weight: 600; + } + .tip-price.peak { color: var(--red-e); } + .tip-price.low { color: var(--green-e); } + /* Component breakdown under the total, so the tooltip answers "why is + it that much?" without a trip to the plan page. */ + .tip-parts { + color: var(--fg-dim); + font-size: 10px; + margin-top: 3px; + } + + .compact-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 14px; + margin-bottom: 14px; + } + .compact-kicker { + margin-bottom: 3px; + color: var(--accent-e); + font-family: var(--mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + } + .compact-title { + color: var(--fg); + font-family: var(--sans); + font-size: 16px; + font-weight: 700; + letter-spacing: -0.015em; + } + .compact-link, + .compact-setup { + color: var(--accent-e); + font-family: var(--mono); + font-size: 10px; + font-weight: 650; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; + } + .compact-link { + padding-top: 3px; + white-space: nowrap; + } + .compact-link:hover, + .compact-setup:hover { + color: var(--fg); + } + .compact-link:focus-visible, + .compact-setup:focus-visible { + outline: 2px solid var(--accent-e); + outline-offset: 4px; + border-radius: 2px; + } + /* The compact card is a container, so its layout follows its own width + rather than the window's. On Overview it sits in a column that can be + far narrower than the viewport — a @media breakpoint never fired there + and the headline ran straight through the cheapest-window column. */ + :host([compact]) { container-type: inline-size; } + + .compact-summary { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(130px, 0.8fr); + align-items: end; + gap: 18px; + } + .compact-current { + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + column-gap: 7px; + } + .compact-value { + color: var(--fg); + font-family: var(--mono); + /* cqi, not vw: the number scales with the card it lives in. At 5vw a + 420px card on a wide screen rendered it at the 3.15rem ceiling and + it overflowed its column. */ + font-size: clamp(1.9rem, 13cqi, 3.15rem); + font-weight: 750; + font-variant-numeric: tabular-nums; + letter-spacing: -0.065em; + line-height: 0.95; + min-width: 0; + } + .compact-unit { + color: var(--fg-dim); + font-family: var(--mono); + font-size: 11px; + white-space: nowrap; + } + .compact-meta { + grid-column: 1 / -1; + margin-top: 6px; + color: var(--fg-muted); + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.04em; + } + .compact-low { + display: flex; + min-width: 0; + flex-direction: column; + padding-left: 16px; + border-left: 1px solid var(--line); + } + .compact-low > span { + color: var(--fg-label); + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + } + .compact-low strong { + margin-top: 3px; + color: var(--green-e); + font-family: var(--mono); + font-size: 1rem; + font-variant-numeric: tabular-nums; + } + /* Wraps rather than truncating: "Tomorrow 11:00–13:00" cut to + "Tomorrow 11:00–13…" loses the end of the window, which is the half + that says how long you have. */ + .compact-low small { + margin-top: 1px; + color: var(--fg-muted); + font-family: var(--mono); + font-size: 10px; + line-height: 1.35; + } + + /* Below ~400px of card the two columns stack: the headline keeps its + own line and the cheapest window sits under a rule instead of beside + one. */ + @container (max-width: 400px) { + .compact-summary { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + } + .compact-low { + padding-left: 0; + padding-top: 10px; + border-left: 0; + border-top: 1px solid var(--line); + } + } + @container (max-width: 300px) { + .compact-head { flex-wrap: wrap; } + } + .compact-profile { + display: block; + width: 100%; + height: 58px; + margin-top: 16px; + overflow: visible; + } + /* Bars rise from zero like the full chart's, so both views teach one + reading: height is price. The dotted line is the average ahead — a + bar's top is read against it, and colour only reinforces what the + geometry already says, so the strip survives greyscale. */ + .compact-profile rect { opacity: 0.85; } + .compact-profile rect.is-dear { fill: var(--red-e); } + .compact-profile rect.is-cheap { fill: var(--green-e); } + .compact-profile rect.is-flat { fill: var(--fg-muted); } + .compact-profile rect.is-negative { fill: var(--yellow); } + .compact-profile rect.is-current { + fill: var(--accent-e); + opacity: 1; + } + /* Dotted like the full chart's mean line — one visual word for + "average" across both views. */ + .compact-profile line { + stroke: var(--fg-muted); + stroke-width: 1.5; + stroke-linecap: round; + stroke-dasharray: 0.01 6; + opacity: 0.9; + } + .compact-profile-note { + margin-top: 4px; + color: var(--fg-muted); + font-family: var(--mono); + font-size: 10px; + } + .compact-stale, + .compact-profile-empty { + display: block; + margin-top: 8px; + color: var(--fg-muted); + font-family: var(--mono); + font-size: 10px; + } + .compact-stale { + color: var(--amber); + } + .compact-empty { + display: flex; + min-height: 126px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 9px; + color: var(--fg-muted); + } + .compact-empty strong { + color: var(--fg-dim); + font-family: var(--mono); + font-size: 15px; + } + + /* Phone layout — the JS picks a taller viewBox H on small screens + so bars get more vertical room WITHOUT vertically stretching + text (which made labels like "NOW" look horizontally squeezed + relative to their height). The SVG's intrinsic ratio handles + sizing — no CSS aspect-ratio needed. The tooltip is pinned + above the bars so it never covers the data being read. */ + @media (max-width: 600px) { + .chart-wrap { padding-top: 40px; } + .tip { + transform: translate(-50%, 0); + transition: opacity 80ms, left 120ms cubic-bezier(.4, 0, .2, 1); + } + /* Compact-card sizing lives in the @container blocks above — it has + to follow the card, not the window. Only the profile height stays + here, as a phone-ergonomics call rather than a fit one. */ + .compact-profile { + height: 44px; + margin-top: 10px; + } + } + `; + + static get observedAttributes() { + return ["compact"]; + } + + constructor() { + super(); + this._data = null; // { zone, items: [{tsMs, ore}], min, max, vatPct } + this._priceState = "loading"; + this._totalOn = readTotalPref(); // consumer total vs raw spot, persisted + this._horizon = readHorizonPref(); // "today" or "all", persisted + this._refreshTimer = null; + this._hover = null; // { idx, x, y } during hover + this._vatPct = 25; // fallback; overwritten from /api/config + this._gridTariff = 0; // minor units/kWh excl. VAT; from /api/config + this._currency = "SEK"; // what those minor units are; from /api/prices + this._geom = null; // { padL, plotW, n, W } — set in _renderChart + this._isTouching = false; // suppresses synthesized mouse events after touch + } + + attributeChangedCallback() { + this.update(); + } + + connectedCallback() { + super.connectedCallback(); + if (!this.hasAttribute("fed")) { + this._loadConfig(); + this._loadPrices(); + this._refreshTimer = setInterval(() => this._loadPrices(), 5 * 60 * 1000); + } + // Re-render when the viewport crosses the small-screen breakpoint + // — render() picks a different viewBox H per side, so a rotation + // or window-resize over the 600 px line needs a redraw. + if (typeof window !== "undefined" && window.matchMedia) { + this._mql = window.matchMedia("(max-width: 600px)"); + this._mqlListener = () => this.update(); + if (this._mql.addEventListener) this._mql.addEventListener("change", this._mqlListener); + else if (this._mql.addListener) this._mql.addListener(this._mqlListener); + } + this._modeSyncListener = (event) => { + const next = event && event.detail && event.detail.totalOn; + if (typeof next !== "boolean" || next === this._totalOn) return; + this._totalOn = next; + this.update(); + }; + window.addEventListener("ftw-price-mode-change", this._modeSyncListener); + } + + disconnectedCallback() { + if (this._refreshTimer) { + clearInterval(this._refreshTimer); + this._refreshTimer = null; + } + if (this._mql && this._mqlListener) { + if (this._mql.removeEventListener) this._mql.removeEventListener("change", this._mqlListener); + else if (this._mql.removeListener) this._mql.removeListener(this._mqlListener); + this._mql = null; + this._mqlListener = null; + } + if (this._modeSyncListener) { + window.removeEventListener("ftw-price-mode-change", this._modeSyncListener); + this._modeSyncListener = null; + } + } + + async _loadConfig() { + try { + const r = await apiFetch("/api/config"); + const j = await r.json(); + const p = (j && j.price) || {}; + let changed = false; + if (typeof p.vat_percent === "number" && p.vat_percent > 0) { + this._vatPct = p.vat_percent; + changed = true; + } + if (typeof p.grid_tariff_ore_kwh === "number" && p.grid_tariff_ore_kwh >= 0) { + this._gridTariff = p.grid_tariff_ore_kwh; + changed = true; + } + if (changed) this.update(); + } catch (e) { /* ignore — fallback 0 tariff / 25 % VAT is fine */ } + } + + async _loadPrices() { + try { + // since_ms = local midnight today so past slots stay visible (the + // chart should read like a calendar, not a sliding window). The + // API's default lookback is only 1 h, which dropped the morning + // off as the day progressed. + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + const since = midnight.getTime(); + const until = Date.now() + 48 * 3600_000; + const r = await apiFetch(`/api/prices?since_ms=${since}&until_ms=${until}`); + if (r.ok === false) throw new Error(`Price request failed: ${r.status}`); + const j = await r.json(); + if (j && j.enabled === false) { + this._data = null; + this._priceState = "unconfigured"; + } else if (!j || !Array.isArray(j.items)) { + throw new Error("Price response did not include items"); + } else { + // We keep only spot and derive the total from live config, so the + // toggle is a view over one number rather than two independently + // aged ones. See the file header for why. + const items = j.items.map((it) => ({ + tsMs: Number(it.slot_ts_ms) || 0, + lenMin: Number(it.slot_len_min) || 60, + spot: Number(it.spot_ore_kwh) || 0, + })).sort((a, b) => a.tsMs - b.tsMs); + this._data = { zone: j.zone || "", items }; + // The response says which currency the stored minor units are in; + // it decides the label, not the arithmetic. Sharing it saves the + // views that show a price without fetching one their own request. + if (j.currency) this._currency = setActiveCurrency(j.currency); + this._priceState = "ready"; + } + this.update(); + } catch (e) { + this._priceState = this._data ? "stale" : "error"; + this.update(); + } + } + + // Hand the chart a window of prices instead of it fetching one. Pairs + // with the `fed` attribute — without it the next poll overwrites this. + // + // slots are { tsMs, lenMin, spot, total }, spot and total in minor units + // per kWh: the component's own vocabulary, so nothing about the caller's + // transport reaches in here. `total` is optional and is what the slot + // costs to import with tariff and tax added. Supply it when whoever + // holds those numbers is the same place the prices came from; leave it + // out and the Total toggle computes it from /api/config as before. + // + // `stale` is the caller saying "our numbers stop here", which is the + // same sentence the fetch path's own stale state carries. + setPrices({ zone = "", currency = "", slots = [], stale = false } = {}) { + const items = (Array.isArray(slots) ? slots : []) + .filter((s) => s && Number.isFinite(s.tsMs)) + .map((s) => ({ + tsMs: s.tsMs, + lenMin: Number(s.lenMin) > 0 ? Number(s.lenMin) : 60, + spot: Number(s.spot) || 0, + total: s.total, + })) + .sort((a, b) => a.tsMs - b.tsMs); + this._data = { zone, items }; + if (currency) this._currency = setActiveCurrency(currency); + this._priceState = stale ? "stale" : "ready"; + this.update(); + } + + // Resolved minor units/kWh per slot for the active toggle. "Total" is + // what the slot actually costs to import: (spot + grid tariff) × (1 + VAT/100). + // A slot that arrived with its own total is believed rather than recomputed: + // whoever fed it holds the tariff and the VAT, and a fed instance never + // fetched either — it would quietly answer with a 0 öre grid tariff. + _priceFor(item) { + if (!this._totalOn) return item.spot; + if (Number.isFinite(item.total)) return item.total; + return consumerTotalOre(item.spot, this._gridTariff, this._vatPct); + } + + // Breakdown of the consumer total for one slot, in minor units/kWh. + _partsFor(item) { + return priceParts(item.spot, this._gridTariff, this._vatPct); + } + + // Name what the numbers include. "incl. VAT" alone was read as "this is + // what I pay", which it wasn't while the grid tariff sat outside it — and + // a fed total is added up wherever the prices came from, so this instance + // can name the whole but never its parts. + _totalLabel(items) { + if (!this._totalOn) return "spot only"; + if ((items || []).some((it) => Number.isFinite(it.total))) return "total to import"; + return this._gridTariff > 0 ? "incl. grid tariff + VAT" : "incl. VAT"; + } + + render() { + if (this.hasAttribute("compact")) return this._renderCompact(); + const data = this._data; + const hasTomorrow = data ? itemsIncludeTomorrow(data.items) : false; + // No tomorrow data → no choice to make, so the toggle is hidden + // and the effective horizon is forced to "today" regardless of + // the stored preference. The stored value is kept untouched so the + // user's choice re-applies once tomorrow's prices publish. + const effectiveHorizon = hasTomorrow ? this._horizon : "today"; + const modeLabel = this._totalLabel(data && data.items); + const horizonLabel = + effectiveHorizon === "today" ? "today" : + effectiveHorizon === "tomorrow" ? "tomorrow" : + "today + tomorrow"; + const horizonToggleHtml = hasTomorrow ? ` +
+ + + +
` : ""; + // Filter first so the stats row reflects the active horizon. + let visible = []; + if (data) { + if (effectiveHorizon === "today") visible = filterToday(data.items); + else if (effectiveHorizon === "tomorrow") visible = filterTomorrow(data.items); + else visible = data.items; + } + // 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. + 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; + }); + 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 lo = Math.min(...prices); + const hi = Math.max(...prices); + const avg = prices.reduce((a, b) => a + b, 0) / prices.length; + const unit = unitFor(this._currency); + const fmt = v => (v == null ? "—" : toDisplay(v, this._currency).toFixed(unit.decimals) + " " + unit.label); + statsHtml = ` +
+ now ${fmt(cur)} + low ${fmt(lo)} + high ${fmt(hi)} + avg ${fmt(avg)} +
+ `; + } + const head = ` +
+
+
Electricity prices
+
${data ? `${escapeXml(data.zone)} · ${modeLabel} · ${horizonLabel}` : "—"}
+ ${statsHtml} +
+
+
+ + +
${horizonToggleHtml} +
+
+ `; + if (!data || !data.items.length) { + return head + `
No price data available.
`; + } + if (!visible.length) { + const which = effectiveHorizon === "tomorrow" ? "tomorrow" : "today"; + return head + `
No price data for ${which}.
`; + } + return head + this._renderChart({ ...data, items: visible }); + } + + _renderCompact() { + const data = this._data; + const view = buildCompactPriceView({ + state: this._priceState, + items: data && data.items, + now: Date.now(), + totalOn: this._totalOn, + gridTariffOre: this._gridTariff, + vatPercent: this._vatPct, + }); + const head = ` +
+
+
Market now
+
Electricity price
+
+ Full view +
+ `; + if (view.kind !== "ready") { + const settings = view.kind === "unconfigured" + ? `Open price settings` + : ""; + return `${head} +
+ ${escapeXml(view.message)} + ${settings} +
+ `; + } + + const summary = view.summary; + const unit = unitFor(this._currency); + const formatOre = (value) => { + if (!Number.isFinite(value)) return "—"; + const shown = toDisplay(value, this._currency); + const digits = Math.abs(shown) >= 100 ? 0 : unit.decimals; + return shown.toFixed(digits); + }; + const currentValue = summary.current ? formatOre(summary.current.ore) : "—"; + // The cheapest contiguous 2 h ahead, not the cheapest single slot: a + // dishwasher cycle or an EV top-up is the unit these decisions come in, + // and a lone 15-minute trough is not something you can run anything in. + const cheapBlock = bestBlock( + summary.upcoming, + summary.upcoming.map((it) => it.ore), + 2, + "min", + ); + const lowValue = cheapBlock ? formatOre(cheapBlock.mean) : "—"; + const lowTime = cheapBlock + ? `${formatPriceSlotLabel(cheapBlock.startMs)}–${fmtClock(cheapBlock.endMs)}` + : "No 2 h window published"; + const vatLabel = this._totalLabel(data && data.items); + const stale = view.stale + ? `Last update failed` + : ""; + const accessible = summary.current + ? `Current electricity price ${currentValue} ${unit.label} per kilowatt-hour. Cheapest two hours ${lowValue} ${unit.label} at ${lowTime}.` + : `No current electricity price slot. Cheapest two hours ${lowValue} ${unit.label} at ${lowTime}.`; + + return `${head} +
+
+ ${currentValue} + ${unit.perKwh} + ${escapeXml(data.zone || "—")} · ${vatLabel} +
+
+ Cheapest 2 h + ${lowValue} ${unit.label} + ${escapeXml(lowTime)} +
+
+ ${this._renderCompactProfile(summary)} + ${stale} + `; + } + + _renderCompactProfile(summary) { + // The strip covers what is still ahead, the same slots the cheapest-2h + // search runs over. Drawing "today" instead put the strip and the window + // on different days — by evening the profile was nearly spent while the + // headline pointed at tomorrow. + const strip = buildPriceStrip(summary.upcoming, { + currentTsMs: summary.current ? summary.current.tsMs : null, + }); + if (!strip) { + return `
No prices published ahead yet.
`; + } + const unit = unitFor(this._currency); + const mean = roundOre(toDisplay(strip.mean, this._currency)); + const bars = strip.bars.map((b) => { + const cls = [`is-${b.tone}`, b.current ? "is-current" : ""].filter(Boolean).join(" "); + return ``; + }).join(""); + return ` + + ${bars} + + +
dotted line: average ahead, ${mean} ${unit.label}
+ `; + } + + _renderChart(data) { + // Compute prices, min/max, and the indices of the lowest + + // highest slots for the marker overlays. + const items = data.items; + const n = items.length; + const prices = items.map((it) => this._priceFor(it)); + let lo = 0, hi = 0; + for (let i = 1; i < n; i++) { + if (prices[i] < prices[lo]) lo = i; + if (prices[i] > prices[hi]) hi = i; + } + const minP = prices[lo]; + const maxP = prices[hi]; + const meanP = prices.reduce((a, p) => a + p, 0) / n; + + // SVG geometry. Width = 100 % via viewBox. Height of the viewBox + // is doubled on phones so bars get more vertical room — bumping + // the box AT THE VIEWBOX level (not via a mismatched CSS + // aspect-ratio + preserveAspectRatio="none") keeps text scaled + // uniformly. preserveAspectRatio="none" is harmless when the box + // and viewBox match. + const W = 1000; + const small = typeof window !== "undefined" && window.matchMedia && + window.matchMedia("(max-width: 600px)").matches; + const H = small ? 720 : 240; + // Wider left padding so the y-axis öre labels have breathing + // room between the SVG edge and the plot's first bar (was 36 → + // labels rendered too close to the card's left border). + // Phones get bigger fonts AND more padding so the larger labels + // stay inside the SVG box and below the NOW pill clears its top. + // +4 px left padding so 3-digit öre prices (e.g. "234 ö") clear the + // SVG edge — the label is text-anchored "end" at `pad.l - 4` and + // extends left from there, so a tighter pad.l clipped large prices. + const pad = small + ? { t: 26, r: 16, b: 40, l: 84 } + : { t: 16, r: 16, b: 28, l: 60 }; + // Phone sizes bumped per operator request (2026-05): axis labels + // were readable but the NOW marker felt thin and crowded against + // the bars. +50 % on axes, +33 % on NOW + thicker stroke so the + // current hour reads at-a-glance from across a room. + const fsAxis = small ? 27 : 10; // y-axis price + x-axis time + const fsNow = small ? 24 : 10; // NOW label + const fsMark = small ? 26 : 11; // peak/low ▼▲ glyphs + const nowStrokeW = small ? 3 : 1.5; + // Tick density drops from every 3 h to every 6 h on phones so the + // bigger labels don't overlap each other across a 48 h chart. + const tickStepMs = (small ? 6 : 3) * 3600_000; + const tickLabelDy = small ? 26 : 16; + const plotW = W - pad.l - pad.r; + const plotH = H - pad.t - pad.b; + const barW = plotW / n; + // Geometry stash for hit-testing from raw clientX during touch + // scrubbing — touchmove targets stay anchored to the touchstart + // element, so we can't lean on data-idx like the mouse path does. + this._geom = { padL: pad.l, plotW, n, W }; + // Y scale: include 0 so a negative-spot day still renders, and + // pad the top so the peak's marker doesn't kiss the edge. + const yMin = Math.min(0, minP); + const yMax = Math.max(maxP * 1.08, 1); + const yToPx = (v) => pad.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH; + const zeroY = yToPx(0); + const meanY = yToPx(meanP); + + // "Now" vertical line — falls inside one of the slots if any. + const now = Date.now(); + let nowIdx = -1; + for (let i = 0; i < n; i++) { + const start = items[i].tsMs; + const end = start + items[i].lenMin * 60_000; + if (now >= start && now < end) { nowIdx = i; break; } + } + + // Bars — colour by relative price (cheaper = green, expensive = + // red, mid = neutral). Using the per-slot deviation from the mean + // keeps the colour discipline meaningful even on flat-price days. + const bars = items.map((it, i) => { + const x = pad.l + i * barW; + const p = prices[i]; + const y = yToPx(p); + // Negative-price slots draw downward from zero; the rect's top + // is the zero line and its height extends to the price's y. + // Positive slots draw the conventional way (top = price y, down + // to zero). Either way, height is the absolute distance. + const top = p < 0 ? zeroY : y; + const h = Math.max(1, Math.abs(zeroY - y)); + const dev = (p - meanP) / Math.max(1, maxP - minP); + // Negative slots are flagged yellow regardless of where they + // land in the lo/hi ranking — "they pay you to take it" reads + // as a different category, not just "the cheapest hour today". + const fill = p < 0 ? "var(--yellow)" + : i === lo ? "var(--green-e)" + : i === hi ? "var(--red-e)" + : (p < meanP ? `color-mix(in srgb, var(--green-e) ${Math.round(40 - dev * 40)}%, transparent)` + : `color-mix(in srgb, var(--red-e) ${Math.round(40 + dev * 40)}%, transparent)`); + const stroke = (i === lo || i === hi) ? "currentColor" : "none"; + return ``; + }).join(""); + + // Mean reference line — true dotted (round caps + zero-length + // dashes spaced by 6 px) so it reads as "average over the known + // price period" rather than a regular dashed grid line. Sits + // above the bars but below the markers and tooltip. + const meanLine = ``; + + // X-axis time ticks — every 3 hours so 48 h reads as ~16 evenly + // spaced labels, not the 8-label sparse grid we had before. + // Operators kept asking "what hour is this?" mid-chart. + const xTicks = []; + if (n > 0) { + const startT = items[0].tsMs; + const endT = items[n - 1].tsMs + items[n - 1].lenMin * 60_000; + for (let t = ceilTo(startT, tickStepMs); t < endT; t += tickStepMs) { + const frac = (t - startT) / (endT - startT); + const x = pad.l + frac * plotW; + xTicks.push(` + + ${fmtClock(t)} + `); + } + } + // Y-axis labels — min / mean / max. + const axisUnit = unitFor(this._currency); + const axisTick = (v) => roundOre(toDisplay(v, this._currency)) + " " + axisUnit.axis; + const yLabels = [ + { y: yToPx(yMax), text: axisTick(yMax) }, + { y: meanY, text: axisTick(meanP) }, + { y: yToPx(yMin), text: axisTick(yMin) }, + ].map((l) => `${l.text}`).join(""); + + // "Now" marker — vertical line plus a "now" pill. + let nowMarker = ""; + if (nowIdx >= 0) { + const x = pad.l + (nowIdx + 0.5) * barW; + nowMarker = ` + + NOW + `; + } + + // Tomorrow boundary — yellow vertical line at midnight when the + // chart spans across the day boundary, with a rotated "TOMORROW" + // label hugging it. Only renders when the data actually crosses + // 00:00 (so single-day "Today only" views don't get a stray line + // at the right edge). + let dayBoundary = ""; + if (n > 0) { + const tomorrow = new Date(); + tomorrow.setHours(0, 0, 0, 0); + tomorrow.setDate(tomorrow.getDate() + 1); + const midnightMs = tomorrow.getTime(); + const startT = items[0].tsMs; + const endT = items[n - 1].tsMs + items[n - 1].lenMin * 60_000; + if (midnightMs > startT && midnightMs < endT) { + const frac = (midnightMs - startT) / (endT - startT); + const x = pad.l + frac * plotW; + const tx = x + 4; + const ty = pad.t + 6; + dayBoundary = ` + + TOMORROW + `; + } + } + + // Peak / low markers — small triangles above their bars. + const markBar = (idx, color, glyph) => { + const x = pad.l + (idx + 0.5) * barW; + const p = prices[idx]; + // For negative-priced slots the bar extends downward from zero, + // so anchoring the marker at the price's y would bury it inside + // the bar. Pin to just above the zero line instead so the + // glyph still reads as a pointer at the column. + const baseY = p < 0 ? zeroY : yToPx(p); + const y = baseY - 6; + return `${glyph}`; + }; + + // Hit-target overlay — invisible rects sized to bar width that + // cover the FULL plot height so hover is forgiving even when a + // bar is short (cheap slots). + const hits = items.map((_, i) => { + const x = pad.l + i * barW; + return ``; + }).join(""); + + return ` +
+ + ${meanLine} + ${bars} + ${dayBoundary} + ${nowMarker} + ${markBar(lo, "var(--green-e)", "▼")} + ${markBar(hi, "var(--red-e)", "▲")} + ${xTicks.join("")} + ${yLabels} + + ${hits} + +
+
+
+ +
+
+ `; + } + + afterRender() { + const root = this.shadowRoot; + const modeToggle = root.querySelector(".toggle[data-price-mode]"); + if (modeToggle) { + modeToggle.querySelectorAll("button[data-price-mode]").forEach((b) => { + b.addEventListener("click", () => { + const next = b.dataset.priceMode === "total"; + if (next === this._totalOn) return; + this._totalOn = next; + writeTotalPref(next); + // Overview renders a second instance in compact mode; this keeps + // the two from disagreeing about which price is on screen. + window.dispatchEvent(new CustomEvent("ftw-price-mode-change", { + detail: { totalOn: next }, + })); + this.update(); + }); + }); + } + const horizonToggle = root.querySelector(".toggle[data-horizon]"); + if (horizonToggle) { + horizonToggle.querySelectorAll("button[data-horizon]").forEach((b) => { + b.addEventListener("click", () => { + const next = b.dataset.horizon; + if (next === this._horizon) return; + this._horizon = next; + writeHorizonPref(next); + this.update(); + }); + }); + } + // Tooltip wiring — listen on the SVG and route by data-idx. + const svg = root.querySelector("svg.chart"); + const tip = root.querySelector("[data-tip]"); + if (!svg || !tip || !this._data) return; + const onMouseMove = (e) => { + if (this._isTouching) return; // touch path owns the tooltip + const target = e.target.closest("[data-idx]"); + if (!target) { this._hideTip(); return; } + const i = Number(target.dataset.idx); + if (!Number.isFinite(i)) { this._hideTip(); return; } + const rect = svg.getBoundingClientRect(); + this._showTipAt(i, e.clientX - rect.left, e.clientY - rect.top); + }; + svg.addEventListener("mousemove", onMouseMove); + svg.addEventListener("mouseleave", () => { if (!this._isTouching) this._hideTip(); }); + + // Touch — long-press to enter scrub mode, then drag horizontally + // to walk the tooltip across slots. The 250 ms threshold lets a + // regular vertical swipe-to-scroll pass through unmolested; if + // the finger moves >10 px before the timer fires, we cancel + // (gesture is a scroll, not a press). + let pressTimer = null; + let scrubbing = false; + let startX = 0, startY = 0; + const SCRUB_DELAY_MS = 250; + const SCRUB_TOLERANCE_PX = 10; + + const cancelPress = () => { + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + }; + const enterScrub = () => { + pressTimer = null; + scrubbing = true; + if (navigator.vibrate) { try { navigator.vibrate(8); } catch (_) {} } + const idx = this._idxFromClientX(startX); + if (idx >= 0) { + const rect = svg.getBoundingClientRect(); + this._showTipAt(idx, startX - rect.left, startY - rect.top); + } + }; + const endTouch = () => { + cancelPress(); + if (scrubbing) { + scrubbing = false; + this._hideTip(); + } + // Defer clearing _isTouching past the synthesized mouse events + // that fire after touchend on iOS/Android — without this the + // tooltip flashes back open as the page settles. + setTimeout(() => { this._isTouching = false; }, 400); + }; + + svg.addEventListener("touchstart", (e) => { + if (e.touches.length !== 1) { cancelPress(); return; } + const t = e.touches[0]; + startX = t.clientX; + startY = t.clientY; + this._isTouching = true; + cancelPress(); + pressTimer = setTimeout(enterScrub, SCRUB_DELAY_MS); + }, { passive: true }); + + svg.addEventListener("touchmove", (e) => { + if (e.touches.length !== 1) return; + const t = e.touches[0]; + if (!scrubbing) { + if (Math.hypot(t.clientX - startX, t.clientY - startY) > SCRUB_TOLERANCE_PX) { + cancelPress(); + } + return; + } + // In scrub mode — block page scroll and walk the tooltip. + e.preventDefault(); + const idx = this._idxFromClientX(t.clientX); + if (idx < 0) return; + const rect = svg.getBoundingClientRect(); + this._showTipAt(idx, t.clientX - rect.left, t.clientY - rect.top); + }, { passive: false }); + + svg.addEventListener("touchend", endTouch); + svg.addEventListener("touchcancel", endTouch); + } + + _idxFromClientX(clientX) { + const svg = this.shadowRoot.querySelector("svg.chart"); + if (!svg || !this._geom) return -1; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0) return -1; + const vbX = ((clientX - rect.left) / rect.width) * this._geom.W; + const barW = this._geom.plotW / this._geom.n; + const i = Math.floor((vbX - this._geom.padL) / barW); + if (i < 0 || i >= this._geom.n) return -1; + return i; + } + + _showTipAt(idx, localX, localY) { + const tip = this.shadowRoot.querySelector("[data-tip]"); + const item = this._data && this._data.items[idx]; + if (!tip || !item) return; + const price = this._priceFor(item); + const tEnd = item.tsMs + item.lenMin * 60_000; + tip.querySelector("[data-tip-time]").textContent = + `${fmtClock(item.tsMs)}–${fmtClock(tEnd)}`; + const priceEl = tip.querySelector("[data-tip-price]"); + const shown = (v) => roundOre(toDisplay(v, this._currency)); + priceEl.textContent = `${shown(price)} ${unitFor(this._currency).label}`; + // Breakdown line — only in Total mode, and only when there is + // something beyond spot to break out. A fed total arrives already added + // up and cannot be taken apart here; printing "grid 0 + VAT 25 %" over + // it would invent a breakdown rather than show one. + const partsEl = tip.querySelector("[data-tip-parts]"); + if (partsEl) { + const showParts = this._totalOn && !Number.isFinite(item.total) && + (this._gridTariff > 0 || this._vatPct > 0); + if (showParts) { + const p = this._partsFor(item); + partsEl.textContent = + `spot ${shown(p.spot)} + grid ${shown(p.grid)} + VAT ${shown(p.vat)}`; + partsEl.hidden = false; + } else { + partsEl.hidden = true; + } + } + // Annotate peak/low per the same indices used in render. + const items = this._data.items; + const prices = items.map((it) => this._priceFor(it)); + let lo = 0, hi = 0; + for (let i = 1; i < items.length; i++) { + if (prices[i] < prices[lo]) lo = i; + if (prices[i] > prices[hi]) hi = i; + } + priceEl.classList.toggle("peak", idx === hi); + priceEl.classList.toggle("low", idx === lo); + // On small screens the tooltip is pinned above the bars and tracks + // slot centre, not finger Y — keeps the readout clear of the data + // it's reading. Desktop keeps the cursor-follow behaviour. + const smallScreen = typeof window !== "undefined" && + window.matchMedia && window.matchMedia("(max-width: 600px)").matches; + if (smallScreen && this._geom) { + const svg = this.shadowRoot.querySelector("svg.chart"); + const svgRect = svg ? svg.getBoundingClientRect() : null; + const slotVbX = this._geom.padL + (idx + 0.5) * (this._geom.plotW / this._geom.n); + const slotPxX = svgRect && svgRect.width + ? (slotVbX / this._geom.W) * svgRect.width + : localX; + // Clamp so the tooltip never runs off the chart's left/right edge. + const halfW = ((tip.getBoundingClientRect().width) || 120) / 2; + const wrapW = svgRect ? svgRect.width : 1000; + const clampedX = Math.max(halfW + 4, Math.min(wrapW - halfW - 4, slotPxX)); + tip.style.left = clampedX + "px"; + tip.style.top = "0px"; + } else { + tip.style.left = localX + "px"; + tip.style.top = localY + "px"; + } + tip.classList.add("visible"); + // Vertical scrub cursor — pin it to the slot centre so the eye + // can confirm which column the tooltip is reading from. + const cursor = this.shadowRoot.querySelector("svg .scrub-cursor"); + if (cursor && this._geom) { + const slotX = this._geom.padL + (idx + 0.5) * (this._geom.plotW / this._geom.n); + cursor.setAttribute("x1", slotX); + cursor.setAttribute("x2", slotX); + cursor.setAttribute("opacity", "0.5"); + } + } + + _hideTip() { + const tip = this.shadowRoot.querySelector("[data-tip]"); + if (tip) tip.classList.remove("visible"); + const cursor = this.shadowRoot.querySelector("svg .scrub-cursor"); + if (cursor) cursor.setAttribute("opacity", "0"); + } +} + +// "Show more than raw spot" — the stored preference under the old +// vatOn key means the same thing, so it carries over instead of +// resetting everyone to the default. +const TOTAL_PREF_KEY = "ftw.priceChart.totalOn"; +const LEGACY_PREF_KEY = "ftw.priceChart.vatOn"; +function readTotalPref() { + try { + for (const key of [TOTAL_PREF_KEY, LEGACY_PREF_KEY]) { + const v = localStorage.getItem(key); + if (v === "0" || v === "false") return false; + if (v === "1" || v === "true") return true; + } + } catch (_) { /* private mode / disabled storage — fall through */ } + return true; +} +function writeTotalPref(on) { + try { localStorage.setItem(TOTAL_PREF_KEY, on ? "1" : "0"); } catch (_) {} +} + +const HORIZON_PREF_KEY = "ftw.priceChart.horizon"; +function readHorizonPref() { + try { + const v = localStorage.getItem(HORIZON_PREF_KEY); + if (v === "today" || v === "all" || v === "tomorrow") return v; + } catch (_) {} + return "all"; +} +function writeHorizonPref(h) { + try { localStorage.setItem(HORIZON_PREF_KEY, h); } catch (_) {} +} + +// Filter to slots whose start time falls inside today's calendar day +// (local timezone). Used for the "Today only" toggle position. +function filterToday(items) { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const t0 = start.getTime(), t1 = end.getTime(); + return items.filter((it) => it.tsMs >= t0 && it.tsMs < t1); +} + +// Filter to slots whose start time falls inside tomorrow's calendar +// day (local timezone). Used for the "Tomorrow only" toggle position. +function filterTomorrow(items) { + const start = new Date(); + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() + 1); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const t0 = start.getTime(), t1 = end.getTime(); + return items.filter((it) => it.tsMs >= t0 && it.tsMs < t1); +} + +// True if any slot starts on tomorrow's calendar day (local timezone) — +// drives whether the today/tomorrow toggle is meaningful at all. +function itemsIncludeTomorrow(items) { + if (!items || !items.length) return false; + const start = new Date(); + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() + 1); + const t0 = start.getTime(); + return items.some((it) => it.tsMs >= t0); +} + +function fmtClock(tsMs) { + const d = new Date(tsMs); + return d.getHours().toString().padStart(2, "0") + ":" + + d.getMinutes().toString().padStart(2, "0"); +} + +// Significant-figure rounding for a display-unit value: three digits when +// the number is large, two decimals when it's small. Works the same for 234 +// öre and for 2.34 Kč, which is why it takes the already-scaled value. +function roundOre(v) { + if (Math.abs(v) >= 100) return v.toFixed(0); + if (Math.abs(v) >= 10) return v.toFixed(1); + return v.toFixed(2); +} + +function ceilTo(t, step) { + return Math.ceil(t / step) * step; +} + +function escapeXml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" + }[c])); +} + +customElements.define("ftw-price-chart", FtwPriceChart); diff --git a/src/vendor/ftw/price-math.js b/src/vendor/ftw/price-math.js new file mode 100644 index 0000000..1fb4f5e --- /dev/null +++ b/src/vendor/ftw/price-math.js @@ -0,0 +1,58 @@ +// Vendored from srcfl/ftw web/components/price-math.js at da6a1018. +// Do not edit here — change it upstream and re-copy. The app and the +// box's own dashboard render this exact file; that is the point. +// Consumer price arithmetic, shared by the price components so no two +// surfaces can drift apart on what a slot costs. Mirrors +// prices.Applier in go/internal/prices — change both together. +// +// Pure functions, no DOM: the tests import this module directly. + +// What the slot actually costs to import, in öre/kWh: +// (spot + grid tariff) × (1 + VAT/100). +// +// Leaving the grid tariff out is what made the Overview price card read +// 21 öre for a slot the plan chart priced at 109. +export function consumerTotalOre(spotOre, gridTariffOre, vatPct) { + return (spotOre + (gridTariffOre || 0)) * (1 + (vatPct || 0) / 100); +} + +// The three components of that total, for a breakdown display. +export function priceParts(spotOre, gridTariffOre, vatPct) { + const grid = gridTariffOre || 0; + return { + spot: spotOre, + grid, + vat: Math.max(0, (spotOre + grid) * ((vatPct || 0) / 100)), + }; +} + +// Cheapest ("min") or dearest ("max") contiguous run of at least `hours`, +// returned as { mean, startMs, endMs } — or null when no run that long +// exists. +// +// Runs are measured by wall-clock duration rather than slot count: a +// window can hold both 15- and 60-minute slots (NordPool moved to +// quarterly PTUs, and stored history straddles the change), so counting +// slots would silently return a 30-minute "2 hour" block. Any gap in the +// series ends a run — a block has to be contiguous to be usable. +// +// `items` must be sorted by tsMs, and `totals[i]` is item `i`'s price. +export function bestBlock(items, totals, hours, mode) { + const needMs = hours * 3600_000; + let out = null; + for (let i = 0; i < items.length; i++) { + let spanMs = 0, sum = 0, cnt = 0; + for (let j = i; j < items.length && spanMs < needMs; j++) { + if (j > i && items[j].tsMs !== items[j - 1].tsMs + items[j - 1].lenMin * 60_000) break; + spanMs += items[j].lenMin * 60_000; + sum += totals[j]; + cnt++; + } + if (spanMs < needMs || !cnt) continue; + const mean = sum / cnt; + if (!out || (mode === "min" ? mean < out.mean : mean > out.mean)) { + out = { mean, startMs: items[i].tsMs, endMs: items[i].tsMs + spanMs }; + } + } + return out; +} diff --git a/src/vendor/ftw/price-strip.js b/src/vendor/ftw/price-strip.js new file mode 100644 index 0000000..dc5f6f0 --- /dev/null +++ b/src/vendor/ftw/price-strip.js @@ -0,0 +1,68 @@ +// Vendored from srcfl/ftw web/components/price-strip.js at da6a1018. +// Do not edit here — change it upstream and re-copy. The app and the +// box's own dashboard render this exact file; that is the point. +// Geometry for the compact price strip: each slot's price drawn as a bar +// from zero, a miniature of the full chart one tap away. +// +// It used to draw each slot's deviation from the window's mean instead, +// so the fixed grid tariff could not flatten the shape. That traded away +// the one convention every bar chart teaches — height is amount. The +// cheapest morning drew the TALLEST bars (hanging below the line), so the +// strip and the full chart read the same day in opposite directions. Bars +// now rise from zero exactly like the full chart's, and the mean survives +// as a reference line at its own height rather than as the baseline. +// +// Colour never carries the message alone (the theme's green and red sit +// ΔE 2.4 apart under deuteranopia, against a threshold of 8): height is +// the price, the mean line is the cheap/dear reference a bar's top is +// read against, and colour only reinforces that — the strip still works +// in greyscale. +// +// Pure geometry, no DOM: the caller renders and the tests import it. + +// Returns null when there is nothing to draw, else +// { zeroY, meanY, mean, bars: [{x, y, w, h, tone, current}], W, H }. +// +// `tone` is "dear"/"cheap" by side of the mean, "flat" within 5 % of the +// price span (a slot that is neither, and shouldn't be pushed onto one +// side of the palette), and "negative" below zero — priced to be taken, +// a different category from "cheapest today", as on the full chart. +export function buildPriceStrip(items, { width = 360, height = 58, currentTsMs = null } = {}) { + const slots = (Array.isArray(items) ? items : []).filter( + (it) => it && Number.isFinite(it.ore) && Number.isFinite(it.tsMs), + ); + if (slots.length < 2) return null; + + const ores = slots.map((it) => it.ore); + const mean = ores.reduce((a, v) => a + v, 0) / slots.length; + const hi = Math.max(...ores); + const lo = Math.min(...ores); + // Zero stays in view — that is the point of the strip — and a + // negative-spot slot extends the scale below it, hanging under the + // baseline. The 1-öre floor keeps an all-zero window drawable. + const top = Math.max(hi, 1); + const bottom = Math.min(lo, 0); + const pad = 2; + const plotH = height - pad * 2; + const yFor = (v) => pad + ((top - v) / (top - bottom)) * plotH; + const zeroY = yFor(0); + const meanY = yFor(mean); + const slotW = width / slots.length; + const flatBand = Math.max(hi - lo, 1) * 0.05; + + const bars = slots.map((it, i) => { + const h = Math.max(1.5, Math.abs(yFor(it.ore) - zeroY)); + return { + x: i * slotW + 0.5, + y: it.ore < 0 ? zeroY : zeroY - h, + w: Math.max(0.6, slotW - 1), + h, + tone: it.ore < 0 ? "negative" + : Math.abs(it.ore - mean) < flatBand ? "flat" + : it.ore > mean ? "dear" : "cheap", + current: currentTsMs != null && it.tsMs === currentTsMs, + }; + }); + + return { zeroY, meanY, mean, bars, W: width, H: height }; +} diff --git a/src/vendor/ftw/price-summary.js b/src/vendor/ftw/price-summary.js new file mode 100644 index 0000000..bb5f083 --- /dev/null +++ b/src/vendor/ftw/price-summary.js @@ -0,0 +1,123 @@ +// Vendored from srcfl/ftw web/components/price-summary.js at f7f475cb. +// Do not edit here — change it upstream and re-copy. The app and the +// box's own dashboard render this exact file; that is the point. +import { consumerTotalOre } from "./price-math.js"; + +// Re-exported so the compact card can search for a usable window without +// importing two price modules. +export { bestBlock } from "./price-math.js"; + +// `totalOn` picks the consumer total — (spot + grid tariff) × (1 + VAT/100), +// the same arithmetic as prices.Applier and the plan chart — over raw spot. +// Applying VAT to bare spot here reported 21 öre for a slot that costs 109 +// on a 70 öre/kWh tariff, which is the number this summary leads with. +export function buildPriceSummary(items, { + now = Date.now(), + totalOn = true, + gridTariffOre = 0, + vatPercent = 25, +} = {}) { + // A slot that carries its own total is believed rather than recomputed. + // Whoever fed the chart from outside holds the tariff and the VAT where the + // prices came from; recomputing from the defaults here would put a different + // number under the same slot on the compact card than on the full chart. + // + // The test is Number.isFinite(item.total), the same expression the chart's + // own _priceFor uses. Coercing first read `total: null` as a supplied 0 and + // `"109"` as a supplied 109, so the card and the chart disagreed about what + // counts as fed — which is the one divergence a fed chart exists to prevent. + const resolve = (item) => { + const spot = Number(item && item.spot) || 0; + if (!totalOn) return spot; + return Number.isFinite(item && item.total) + ? item.total + : consumerTotalOre(spot, gridTariffOre, vatPercent); + }; + const normalized = (Array.isArray(items) ? items : []) + .map((item) => ({ + tsMs: Number(item && item.tsMs), + lenMin: Number(item && item.lenMin) || 60, + ore: resolve(item), + })) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + + const current = normalized.find((item) => ( + now >= item.tsMs && now < item.tsMs + item.lenMin * 60_000 + )) || null; + + const nextLow = normalized + .filter((item) => item.tsMs > now) + .reduce((lowest, item) => ( + !lowest || item.ore < lowest.ore ? item : lowest + ), null); + + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayEnd.getDate() + 1); + const today = normalized.filter((item) => ( + item.tsMs >= dayStart.getTime() && item.tsMs < dayEnd.getTime() + )); + const todayPrices = today.map((item) => item.ore); + + // Everything still ahead, including the slot in progress — what a window + // search has to work from. `today` stops at midnight, so on an evening it + // holds too little to find a usable block in. + const upcoming = normalized.filter( + (item) => item.tsMs + item.lenMin * 60_000 > now, + ); + + return { + current, + nextLow, + upcoming, + today, + minOre: todayPrices.length ? Math.min(...todayPrices) : null, + maxOre: todayPrices.length ? Math.max(...todayPrices) : null, + }; +} + +export function buildCompactPriceView({ + state = "loading", + items = null, + now = Date.now(), + totalOn = true, + gridTariffOre = 0, + vatPercent = 25, +} = {}) { + if (!Array.isArray(items)) { + if (state === "unconfigured") { + return { kind: "unconfigured", message: "Price unavailable" }; + } + if (state === "error") { + return { kind: "error", message: "Prices unavailable" }; + } + return { kind: "loading", message: "Loading prices…" }; + } + if (items.length === 0) { + return { kind: "empty", message: "No prices published for today." }; + } + return { + kind: "ready", + stale: state === "stale", + summary: buildPriceSummary(items, { now, totalOn, gridTariffOre, vatPercent }), + }; +} + +export function formatPriceSlotLabel(tsMs, now = Date.now()) { + const slot = new Date(tsMs); + const today = new Date(now); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const dayKey = (date) => ( + `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` + ); + const prefix = dayKey(slot) === dayKey(today) + ? "Today" + : dayKey(slot) === dayKey(tomorrow) + ? "Tomorrow" + : slot.toLocaleDateString(undefined, { weekday: "short" }); + const clock = `${String(slot.getHours()).padStart(2, "0")}:${String(slot.getMinutes()).padStart(2, "0")}`; + return `${prefix} ${clock}`; +} diff --git a/src/vendor/ftw/price-units.d.ts b/src/vendor/ftw/price-units.d.ts new file mode 100644 index 0000000..8605c11 --- /dev/null +++ b/src/vendor/ftw/price-units.d.ts @@ -0,0 +1,41 @@ +/* Types for the vendored units table — the surface the app actually uses. + * The implementation is the box's own file, untouched; see the header there. + * + * What a price is quoted in is the box's vocabulary, not this app's: öre for + * SEK, cent for EUR, and the two currencies that are quoted in the major unit + * instead. Naming any of that here would be hand-writing a name shared with + * the box, so the table comes across with the component that reads it. + */ + +export interface FtwPriceUnit { + /** The unit on its own: "öre", "cent", "Kč". */ + label: string + /** The unit per kWh: "öre/kWh", "cent/kWh". */ + perKwh: string + /** What a stored minor unit is multiplied by for display. */ + scale: number + /** How many decimals this currency is worth showing. */ + decimals: number +} + +export function unitFor(currency: string): FtwPriceUnit +export function unitLabel(currency: string): string +export function unitPerKwh(currency: string): string +export function toDisplay(minorPerKwh: number, currency: string): number + +/** + * A stored minor-unit value as text with its unit: "144.0 öre". + * + * The whole rendering, unit and all, which is what the chart draws. Used as + * the reference the timeline's own column is tested against — a number this + * app writes and a number the box writes have to agree. + */ +export function formatPrice(minorPerKwh: number, currency: string, decimals?: number): string + +/** + * The currency of the last price window anyone read, "SEK" until one lands. + * + * For the surfaces that show a price without ever fetching one — the plan + * timeline here, the plan tooltip and the diagnose page on the box. + */ +export function activeCurrency(): string diff --git a/src/vendor/ftw/price-units.js b/src/vendor/ftw/price-units.js new file mode 100644 index 0000000..dabddc4 --- /dev/null +++ b/src/vendor/ftw/price-units.js @@ -0,0 +1,119 @@ +// Vendored from srcfl/ftw web/components/price-units.js at da6a1018. +// Do not edit here — change it upstream and re-copy. The app and the +// box's own dashboard render this exact file; that is the point. +// What to call the number on a price. Every price the API returns is in +// minor units per kWh — öre for SEK, cent for EUR, øre for NOK — because +// go/internal/prices stores spot × 100 whatever the currency. Only the +// label and the sensible number of decimals differ, so they live here +// rather than being spelled "öre" in ten places. +// +// Pure data + pure functions: components import it, and it also lands on +// window.FTWUnits for the classic scripts (app.js, diagnose.js, +// loadpoints.js, the settings tabs) that can't import. + +// scale is what a stored minor unit is multiplied by for display. 1 keeps +// minor units (öre, cent); 0.01 shows the major unit instead, which is how +// koruna, forint and leu are actually quoted — 4 Kč/kWh, not 400 haléř. +// +// axis is the cramped form for chart tick labels, where a three-digit price +// and its unit share about six characters. +const UNITS = { + SEK: { label: "öre", perKwh: "öre/kWh", axis: "ö", scale: 1, decimals: 1 }, + NOK: { label: "øre", perKwh: "øre/kWh", axis: "ø", scale: 1, decimals: 1 }, + DKK: { label: "øre", perKwh: "øre/kWh", axis: "ø", scale: 1, decimals: 1 }, + EUR: { label: "cent", perKwh: "cent/kWh", axis: "c", scale: 1, decimals: 1 }, + PLN: { label: "gr", perKwh: "gr/kWh", axis: "gr", scale: 1, decimals: 1 }, + CHF: { label: "Rp.", perKwh: "Rp./kWh", axis: "Rp", scale: 1, decimals: 1 }, + CZK: { label: "Kč", perKwh: "Kč/kWh", axis: "Kč", scale: 0.01, decimals: 2 }, + HUF: { label: "Ft", perKwh: "Ft/kWh", axis: "Ft", scale: 0.01, decimals: 1 }, + RON: { label: "lei", perKwh: "lei/kWh", axis: "lei", scale: 0.01, decimals: 2 }, +}; + +// A currency with no entry above is shown in its major unit under its ISO +// code — never wrong, just less familiar than "öre". +function fallback(code) { + const c = String(code || "").toUpperCase(); + return { label: c, perKwh: c + "/kWh", axis: c, scale: 0.01, decimals: 3 }; +} + +// unitFor returns { label, perKwh, scale, decimals } for a currency code. +// An empty or unknown code falls back to SEK, which is what installs +// predating the currency setting are in. +export function unitFor(currency) { + const code = String(currency || "SEK").toUpperCase(); + return UNITS[code] || fallback(code); +} + +// The unit on its own: "öre", "cent", "Kč". +export function unitLabel(currency) { + return unitFor(currency).label; +} + +// The unit per kWh: "öre/kWh", "cent/kWh". +export function unitPerKwh(currency) { + return unitFor(currency).perKwh; +} + +// A stored minor-unit value in display units, unrounded. +export function toDisplay(minorPerKwh, currency) { + return (minorPerKwh || 0) * unitFor(currency).scale; +} + +// A stored minor-unit value as text with its unit: "17.4 öre". +// decimals overrides the currency's own default when a surface wants +// whole numbers. +export function formatPrice(minorPerKwh, currency, decimals) { + const u = unitFor(currency); + const d = decimals == null ? u.decimals : decimals; + return toDisplay(minorPerKwh, currency).toFixed(d) + " " + u.label; +} + +// Same, but with the /kWh suffix: "17.4 öre/kWh". +export function formatPricePerKwh(minorPerKwh, currency, decimals) { + const u = unitFor(currency); + const d = decimals == null ? u.decimals : decimals; + return toDisplay(minorPerKwh, currency).toFixed(d) + " " + u.perKwh; +} + +// ---- The install's own currency ---- +// +// Surfaces that show a price but never fetch one — the plan tooltip in +// app.js, the diagnose timeline, the loadpoint schedule — read it from +// here instead of each asking the API. Whoever reads /api/prices sets it; +// bootstrapCurrency covers a page that opens straight onto one of those +// views. Until it resolves the answer is SEK, which is what every install +// predating the currency setting is in. +let active = "SEK"; + +export function activeCurrency() { + return active; +} + +export function setActiveCurrency(code) { + if (code) active = String(code).toUpperCase(); + return active; +} + +// Asks the price API for the currency alone — an empty time window, so the +// answer carries no price rows. Failure leaves the SEK default in place. +export function bootstrapCurrency(fetchImpl) { + const f = fetchImpl || (typeof fetch === "function" ? fetch : null); + if (!f) return Promise.resolve(active); + return f("/api/prices?since_ms=0&until_ms=0") + .then((r) => r.json()) + .then((j) => setActiveCurrency(j && j.currency)) + .catch(() => active); +} + +if (typeof window !== "undefined") { + window.FTWUnits = { + unitFor, + unitLabel, + unitPerKwh, + toDisplay, + formatPrice, + formatPricePerKwh, + activeCurrency, + setActiveCurrency, + }; +} diff --git a/src/views/History.svelte b/src/views/History.svelte index a44c1a7..48ffb36 100644 --- a/src/views/History.svelte +++ b/src/views/History.svelte @@ -12,12 +12,13 @@ Loaded on demand, so none of this sits on the path to the first frame. -->
@@ -125,14 +291,56 @@ {/if}
+ +{#if prices} +
+ + {#await import('$vendor/ftw/ftw-price-chart.js') then _module} + + {/await} + + {#if priceHole} +

Some hours are missing their price.

+ {:else if prices.stale} +

Tomorrow's rates aren't published yet.

+ {/if} +
+{/if} + {#if slots.length > 0}
-

Next 12 hours

+
+

Next 12 hours

+ + to import, {unitPerKwh(currency)} +
    {#each slots as s (s.startMs)} {@const action = slotAction(s)} {@const p = formatPower(s.batteryW)} - {@const price = formatPrice(s.priceMinor)} + {@const price = formatPrice(s.priceMinor, currency)}
  1. = s.startMs && nowMs < s.startMs + s.durationMs}> {time(s.startMs)} {reasonText(s.reason)} - {#if price}{price}{/if} + {#if price}{price}{/if}
  2. {/each}
@@ -169,7 +377,8 @@ } .problem, - .status { + .status, + .short { font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; @@ -185,6 +394,7 @@ } .modes, + .prices, .timeline { padding: 0 var(--space-4) var(--space-6); } @@ -193,6 +403,18 @@ margin-bottom: var(--space-3); } + .timeline-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + } + + .legend { + font-size: 11px; + color: var(--fg-muted); + } + .choices { display: flex; flex-direction: column; @@ -324,13 +546,6 @@ white-space: nowrap; } - .price::after { - content: '/kWh'; - font-size: 9px; - opacity: 0.7; - margin-left: 0.15em; - } - .loading { padding: var(--space-5) var(--space-4); color: var(--fg-muted); diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts new file mode 100644 index 0000000..f16c8a0 --- /dev/null +++ b/src/views/Plan.svelte.test.ts @@ -0,0 +1,795 @@ +/* What the Plan view does about prices, mounted rather than read. + * + * The mapping has its own tests and the component is the box's own file. What + * had nothing at all was the wiring between them: the capability gate, the + * `fed` attribute, the notice when a window stops early, and the guard that + * keeps a late failure from taking down a chart that is already drawn. All of + * those are silent when they break — an app that lost `fed` would quietly + * request /api/prices from an origin that has none, every five minutes for + * the life of the page. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render } from '@testing-library/svelte' +import Plan from './Plan.svelte' +import { SiteStore } from '$lib/state/site.svelte' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' +import type { Prices } from '$lib/protocol/messages' +import type { FtwPriceChartElement } from '$vendor/ftw/ftw-price-chart.js' + +const HOUR_MS = 3_600_000 + +/** Mid-morning, so tomorrow's rates have not published yet. */ +const MORNING = new Date(2026, 6, 15, 9, 0, 0).getTime() + +function chart(): Element | null { + return document.querySelector('ftw-price-chart') +} + +describe('the Plan view, against the simulator', () => { + let fetched: ReturnType + + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(MORNING) + fetched = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no origin')) + }) + + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + async function mount() { + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(new SimBox({ now: () => MORNING }), { latencyMs: 0 })) + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(chart()).not.toBeNull(), { timeout: 2_000 }) + return site + } + + it('feeds the chart rather than letting it fetch', async () => { + await mount() + + // Without this attribute the component fetches /api/prices on a timer. + // The app has no HTTP origin at all: every request is a 404. + expect(chart()!.hasAttribute('fed')).toBe(true) + expect(fetched).not.toHaveBeenCalled() + }) + + it('says tomorrow is not published yet when the window stops early', async () => { + await mount() + + // The component keeps its own stale state for the compact card, which + // this screen never renders — so without the app saying it, a market that + // ends at midnight ends with no notice at all. Not an error: the + // day-ahead market clears in the afternoon, so this is every morning. + await vi.waitFor(() => + expect(document.body.textContent).toMatch(/tomorrow's rates aren't published yet/i) + ) + }) + + it('names the money the timeline column carries, and its unit', async () => { + await mount() + + // The chart above prices the same hours. Two numbers for 21:00, one above + // the other, with nothing saying which is which, is the failure this + // screen exists to avoid — and naming the money without naming the unit + // is what made a hundredfold gap between them read as agreement. + await vi.waitFor(() => expect(document.body.textContent).toMatch(/to import, öre\/kWh/i)) + }) + + it('prices the timeline in the unit the chart is drawn in', async () => { + // The gap this closes: the chart's header read "NOW 144.0 öre" while the + // row for the same hour read "1.44", with no currency named on it at all. + // Both are the same money — which is exactly what makes two numbers a + // hundred apart worse than one number with no label. + const site = new SiteStore('test') + + // The real answer, kept as it goes past: what the chart draws is what the + // box sent, so it is the only honest thing to compare a row against. + let fed: Prices | undefined + const answer = site.prices.bind(site) + vi.spyOn(site, 'prices').mockImplementation(async (q) => (fed = await answer(q))) + + site.connect(new LoopbackCarrier(new SimBox({ now: () => MORNING }), { latencyMs: 0 })) + render(Plan, { props: { site } }) + + const current = () => document.querySelector('section.timeline li.now') + await vi.waitFor(() => expect(chart()).not.toBeNull()) + await vi.waitFor(() => expect(current()).not.toBeNull()) + + // Found by name, not by position: a new column would otherwise make this + // read a different cell and go on passing. The plan and the price window + // are built from one curve on the box, so for the hour happening now they + // are the same number and on screen they must be too. + const cell = current()!.querySelector('.slot-price') + expect(cell, 'the row has no price cell').not.toBeNull() + const shown = Number(cell!.textContent) + const hour = fed!.slots.find((s) => MORNING >= s.startMs && MORNING < s.startMs + s.durationMs) + expect(hour, 'the window the chart was fed has no slot for this hour').toBeDefined() + expect(shown).toBe(hour!.totalMinor) + }) + + it('takes the price window away when the box stops offering prices', async () => { + // A feed removed, a driver pulled, a box that came back speaking less + // than it did. Nothing asks any more once the capability is gone, and the + // last window it ever sent used to sit there until local midnight moved + // the day out from under it — at which point the chart draws its empty + // state, which reads as the market having gone quiet. + const box = new SimBox({ now: () => MORNING }) + const carrier = new LoopbackCarrier(box, { latencyMs: 0 }) + const site = new SiteStore('test') + site.connect(carrier) + render(Plan, { props: { site } }) + + await vi.waitFor(() => expect(chart()).not.toBeNull()) + + // The box comes back as one that only speaks the floor protocol, whose + // capability list is status.core and nothing else. A restart is what it + // takes to reach: capabilities are settled at the handshake. + box.faults = { ...box.faults, maxProto: 0 } + carrier.drop('box restarted') + carrier.restore() + + await vi.waitFor(() => expect(site.session.caps.has('price.spot')).toBe(false)) + await vi.waitFor(() => expect(chart(), 'a window nothing is asking about any more').toBeNull()) + }) +}) + +/* What the chart is allowed to keep saying, and for how long. + * + * A drawn window survives an ask that failed, because today's prices are still + * today's and a lost answer is no reason to take a block someone is reading + * off the screen. That is a trade against "never fake live", and it only holds + * while the two do not actually conflict — which is until local midnight, and + * until the chart's own idea of "now" goes stale. + * + * Both halves are invisible when they break. The chart renders its NOW marker + * and its "now" figure from the clock at render time, and `fed` took away the + * five-minute poll that used to re-render it, so a marker frozen at nine in + * the morning looks exactly like a marker. + */ +describe('a price window as the day moves under it', () => { + afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + /** Real box, real wire, and a clock the test drives. */ + async function drawn() { + vi.useFakeTimers() + vi.setSystemTime(MORNING) + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + const asked = vi.spyOn(site, 'prices') + site.connect(new LoopbackCarrier(box, { latencyMs: 20 })) + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(chart()).not.toBeNull(), { timeout: 2_000 }) + await vi.advanceTimersByTimeAsync(100) + return { box, site, asked } + } + + it('stays drawn through an ask that failed, while it is still today', async () => { + const { box, asked } = await drawn() + const first = asked.mock.calls.length + + // Nothing the box sends arrives. The session never notices, so this is the + // quiet failure. Six hours takes the clock past the publication hour, + // which is what earns a fresh ask without leaving the day — so there is a + // real failure here and not merely nothing happening. + box.faults = { ...box.faults, frameLossRate: 1 } + await vi.advanceTimersByTimeAsync(6 * 3_600_000) + + expect(asked.mock.calls.length, 'no second ask was made, so nothing failed').toBeGreaterThan( + first + ) + expect(new Date().getDate(), 'the clock left the day, which is a different case').toBe(15) + expect(chart(), 'a lost answer took away a window that was still correct').not.toBeNull() + }) + + it('is gone once the day it covers is yesterday', async () => { + const { box } = await drawn() + + // Past midnight with the box still unreachable. The bars are yesterday's + // now, and the chart heads them "today" and calls the last of them "now". + box.faults = { ...box.faults, frameLossRate: 1 } + await vi.advanceTimersByTimeAsync(16 * 3_600_000) + + expect(new Date().getDate(), 'the clock did not reach the next day').toBe(16) + expect(chart(), "yesterday's prices were still on screen, headed today").toBeNull() + }) + + it('moves its idea of now while the same window stays up', async () => { + // No failure anywhere: a box answering everything, one window, and time + // passing. The chart is fed by method, so nothing re-renders it unless + // this view does. + const { asked } = await drawn() + const fedAt = (): number => (asked.mock.calls.length, Date.now()) + + const early = chart()!.shadowRoot?.textContent ?? chart()!.textContent ?? '' + const startedAt = fedAt() + + await vi.advanceTimersByTimeAsync(4 * 3_600_000) + expect(Date.now() - startedAt).toBe(4 * 3_600_000) + + const later = chart()!.shadowRoot?.textContent ?? chart()!.textContent ?? '' + expect(later, 'the chart drew the same hour as now, four hours later').not.toBe(early) + }) +}) + +/* A plan the box never answers, with the wire still up. + * + * The other half of the healing, and the half that had none. A carrier that + * drops is the loud failure: the phase moves, and everything asked for is + * asked for again on the way back. Every quieter one leaves the phase exactly + * where it is — a bulk answer lost on the relay, the eight-second deadline + * against a box busy replanning, E_BOOTING for the minutes after an update — + * and one of those used to be terminal for this screen. The sentence stayed + * up until the tab was closed, promising a load that nothing would ever make. + * + * Frame loss rather than a fault switch, because it is the failure that + * cannot be told apart from the box being slow, and because it leaves the + * session untouched: still streaming, still counting the box as live. + */ +describe('a plan the box could not answer', () => { + const rows = () => document.querySelectorAll('section.timeline li').length + + afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('is asked for again on its own, without the session ever moving', async () => { + vi.useFakeTimers() + vi.setSystemTime(MORNING) + + const box = new SimBox({ now: () => Date.now() }) + const carrier = new LoopbackCarrier(box, { latencyMs: 20 }) + const site = new SiteStore('test') + const asked = vi.spyOn(site, 'plan') + site.connect(carrier) + + render(Plan, { props: { site } }) + + // Streaming, with the first ask on the wire and not yet answered. + for (let i = 0; i < 50 && asked.mock.calls.length === 0; i++) { + await vi.advanceTimersByTimeAsync(5) + } + expect(asked).toHaveBeenCalledTimes(1) + + // Nothing the box sends arrives from here on. The request is already on + // its way, so what is lost is the answer to it. + box.faults = { ...box.faults, frameLossRate: 1 } + await vi.advanceTimersByTimeAsync(9_000) + + // The whole difficulty in one line: as far as the session is concerned + // nothing has happened, so nothing about the connection is coming back to + // trigger a fresh ask. + expect(site.session.phase).toBe('streaming') + expect(rows(), 'a plan arrived through a wire dropping every frame').toBe(0) + expect(document.body.textContent).toMatch(/couldn't get the plan from your box/i) + + box.faults = { ...box.faults, frameLossRate: 0 } + await vi.advanceTimersByTimeAsync(31_000) + + expect(asked.mock.calls.length, 'nothing ever asked again').toBeGreaterThan(1) + expect(rows(), 'the timeline stayed empty against a box that was answering').toBeGreaterThan(0) + // And the sentence goes with it. It said the app would keep trying; this + // is the line that makes that a description rather than a hope. + expect(document.body.textContent).not.toMatch(/couldn't get the plan/i) + }) + + it('is asked for again after a mode change whose replan never arrived', async () => { + // The replan chased after a mode change used to be fetched by the store + // itself, outside the healing rule. Its ten attempts are spent in thirty + // seconds; if every one of them was lost, nothing was left to ask again — + // and the screen kept saying it was still trying while nothing was. + vi.useFakeTimers() + vi.setSystemTime(MORNING) + + const box = new SimBox({ now: () => Date.now() }) + const carrier = new LoopbackCarrier(box, { latencyMs: 20 }) + const site = new SiteStore('test') + const asked = vi.spyOn(site, 'plan') + site.connect(carrier) + + render(Plan, { props: { site } }) + + await vi.waitFor(() => expect(rows()).toBeGreaterThan(0), { timeout: 2_000 }) + const beforeChange = asked.mock.calls.length + + // The command itself lands. The wire is cut once the replan it triggers is + // already travelling, so what goes missing is the answer to it and every + // answer after — the mode really did change, and the plan for it never + // arrives. + const mode = [...document.querySelectorAll('button.choice')].find( + (b) => b.getAttribute('aria-checked') === 'false' && !(b as HTMLButtonElement).disabled + ) as HTMLButtonElement + expect(mode, 'no mode to switch to, so nothing under test happened').toBeTruthy() + mode.click() + + for (let i = 0; i < 300 && asked.mock.calls.length === beforeChange; i++) { + await vi.advanceTimersByTimeAsync(2) + } + expect(asked.mock.calls.length, 'the mode change asked for no replan at all').toBeGreaterThan( + beforeChange + ) + box.faults = { ...box.faults, frameLossRate: 1 } + + // Long enough for all ten attempts to be spent and give up. + await vi.advanceTimersByTimeAsync(200_000) + + expect(site.session.phase, 'the session moved, so this is not the case under test').toBe( + 'streaming' + ) + expect(document.body.textContent).toMatch(/couldn't get the plan from your box/i) + const afterGivingUp = asked.mock.calls.length + + box.faults = { ...box.faults, frameLossRate: 0 } + await vi.advanceTimersByTimeAsync(16 * 60_000) + + expect( + asked.mock.calls.length, + 'the replan gave up for good against a box that was answering' + ).toBeGreaterThan(afterGivingUp) + expect(document.body.textContent).not.toMatch(/couldn't get the plan/i) + }) +}) + +/* The plan and the price window, over a wire that goes away and comes back. + * + * Everything here is real — a Session, a SimBox, and the loopback carrier + * dropping the way a socket drops in the field, keeping its handlers and + * returning on its own. Nothing is stubbed but the count of asks. + * + * The fault this covers was invisible to every other test in the tree: a drop + * settles the request as a failure at once, the carrier comes back, the phase + * returns to 'streaming', the box answers everything else — and nothing asked + * again. The screen kept a sentence promising it would load, for as long as + * the view stayed open, with no reconnect button anywhere in this app because + * healing is meant to be automatic. + * + * Both asks in the one test because both leave on the same mount and die in + * the same drop, and because a carrier can only be cut once. The price heal + * had been covered only by tests that set `session.phase` to 'failed' and back + * by hand — a path no user takes, and one that passes just as happily against + * a heal wired to nothing. + */ +describe('a plan and a price window the wire cut short', () => { + const rows = () => document.querySelectorAll('section.timeline li').length + + afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('arrive on their own once the carrier is back', async () => { + vi.useFakeTimers() + vi.setSystemTime(MORNING) + + const box = new SimBox({ now: () => Date.now() }) + // Enough latency that the wire can be cut while the ask is still on it, + // which is the whole case: at zero the answer is home before anything can + // go wrong. + const carrier = new LoopbackCarrier(box, { latencyMs: 20 }) + const site = new SiteStore('test') + const asked = vi.spyOn(site, 'plan') + // Counted, not replaced: the box on the other end advertises price.spot + // and answers price.get, and it is the real answer that draws the chart. + const askedPrices = vi.spyOn(site, 'prices') + site.connect(carrier) + + render(Plan, { props: { site } }) + + // Streaming, and the first asks on the wire but not yet answered. + for (let i = 0; i < 50 && asked.mock.calls.length === 0; i++) { + await vi.advanceTimersByTimeAsync(5) + } + expect(asked).toHaveBeenCalledTimes(1) + expect(askedPrices).toHaveBeenCalledTimes(1) + + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(50) + + expect(rows(), 'the plan somehow survived the drop').toBe(0) + expect(document.body.textContent).toMatch(/couldn't get the plan from your box/i) + // The price ask died with the wire too. Nothing was ever drawn, so there + // is no chart — which is what makes one appearing below mean something. + expect(chart(), 'the price window somehow survived the drop').toBeNull() + + carrier.restore() + await vi.advanceTimersByTimeAsync(500) + + expect(site.session.phase).toBe('streaming') + expect(asked.mock.calls.length, 'nothing asked again once the box was back').toBeGreaterThan(1) + expect(rows(), 'the timeline stayed empty against a box that was answering').toBeGreaterThan(0) + expect(document.body.textContent).not.toMatch(/couldn't get the plan/i) + + expect( + askedPrices.mock.calls.length, + 'nothing asked for prices again once the box was back' + ).toBeGreaterThan(1) + expect(chart(), 'the chart stayed away against a box that was answering').not.toBeNull() + }) +}) + +/* When the view asks, and for what. + * + * The store is real; only the one call under test is replaced, so the two + * requests can be settled in the order a carrier blip settles them and the + * clock can be moved without a box in the way. + */ +describe('when the Plan view asks for prices', () => { + afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + /** A store that believes it is streaming from a box that has prices. */ + function streamingStore() { + const site = new SiteStore('test') + site.session = { + ...site.session, + phase: 'streaming' as const, + caps: new Set(['price.spot']), + } + return site + } + + function deferred() { + let settle!: (p: Prices) => void + let fail!: (e: Error) => void + const promise = new Promise((res, rej) => { + settle = res + fail = rej + }) + return { promise, settle, fail } + } + + const WINDOW: Prices = { + zone: 'SE4', + currency: 'SEK', + stale: false, + slots: [ + { startMs: MORNING, durationMs: HOUR_MS, spotMinor: 40, totalMinor: 137 }, + { startMs: MORNING + HOUR_MS, durationMs: HOUR_MS, spotMinor: 17, totalMinor: 109 }, + ], + } + + it('asks nothing of a box that does not advertise prices', async () => { + const site = new SiteStore('test') + site.session = { ...site.session, phase: 'streaming', caps: new Set(['status.core']) } + const asked = vi.spyOn(site, 'prices') + + render(Plan, { props: { site } }) + await new Promise((r) => setTimeout(r, 20)) + + // An empty chart would claim the market went quiet rather than that this + // house has no price feed. + expect(asked).not.toHaveBeenCalled() + expect(chart()).toBeNull() + }) + + it('keeps the window inside one bulk frame, however late in the day it is', async () => { + // Nine in the evening. Local midnight to now plus forty-eight hours is + // seventy-two hours, which is 288 quarter-hour slots — past the box's + // wall of roughly 270, so the market truncates and sets stale. + vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 15, 21, 0, 0).getTime()) + const site = streamingStore() + const asked = vi.spyOn(site, 'prices').mockReturnValue(deferred().promise) + + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(asked).toHaveBeenCalledTimes(1)) + + const query = asked.mock.calls[0]![0] + expect(query.toMs - query.fromMs).toBe(48 * HOUR_MS) + expect(new Date(query.fromMs).getHours()).toBe(0) + }) + + it('asks again when the local day turns over', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 15, 23, 59, 30)) + const site = streamingStore() + const asked = vi.spyOn(site, 'prices').mockReturnValue(deferred().promise) + + render(Plan, { props: { site } }) + await vi.advanceTimersByTimeAsync(0) + expect(asked).toHaveBeenCalledTimes(1) + + // Past midnight the window on screen is yesterday's, with the NOW marker + // off the end of it. + vi.setSystemTime(new Date(2026, 6, 16, 0, 0, 30)) + await vi.advanceTimersByTimeAsync(30_000) + + expect(asked).toHaveBeenCalledTimes(2) + expect(new Date(asked.mock.calls[1]![0].fromMs).getDate()).toBe(16) + }) + + it("asks again once tomorrow's rates have published", async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 15, 13, 30, 0)) + const site = streamingStore() + const asked = vi.spyOn(site, 'prices').mockReturnValue(deferred().promise) + + render(Plan, { props: { site } }) + await vi.advanceTimersByTimeAsync(0) + expect(asked).toHaveBeenCalledTimes(1) + + // A phone left on the counter all morning is the case this is for: on a + // LAN carrier the phase may not change for days. + vi.setSystemTime(new Date(2026, 6, 15, 14, 30, 0)) + await vi.advanceTimersByTimeAsync(30_000) + + expect(asked).toHaveBeenCalledTimes(2) + }) + + it('does not poll in between', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 15, 15, 0, 0)) + const site = streamingStore() + const asked = vi.spyOn(site, 'prices').mockReturnValue(deferred().promise) + + render(Plan, { props: { site } }) + await vi.advanceTimersByTimeAsync(0) + + // Six hours inside the same day, all of them after publication. Rates + // move once a day; a round trip every thirty seconds learns nothing. + for (let i = 0; i < 12; i++) { + vi.setSystemTime(new Date(2026, 6, 15, 15, 0, 0).getTime() + (i + 1) * 1_800_000) + await vi.advanceTimersByTimeAsync(30_000) + } + + expect(asked).toHaveBeenCalledTimes(1) + }) + + it('asks again after a failed ask, and backs off rather than polling', async () => { + // E_UNAVAILABLE is marked retryable in the contract, and an eight-second + // timeout against a busy box is the same shape. Neither changes the phase, + // so before this the view sat on a blank chart until local midnight — + // ten hours of nothing against a box that would have answered. + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 15, 8, 0, 0)) + const site = streamingStore() + const asked = vi.spyOn(site, 'prices').mockRejectedValue(new Error('E_UNAVAILABLE')) + + render(Plan, { props: { site } }) + await vi.advanceTimersByTimeAsync(0) + expect(asked).toHaveBeenCalledTimes(1) + + // Not at once, and not on the 30-second clock the view already ticks on. + await vi.advanceTimersByTimeAsync(25_000) + expect(asked, 'retried before the backoff was up').toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10_000) + expect(asked, 'never asked again while the carrier stayed up').toHaveBeenCalledTimes(2) + + // The second wait is longer than the first. A fixed timer here would be a + // request every thirty seconds at a box that is already struggling. + await vi.advanceTimersByTimeAsync(40_000) + expect(asked, 'the wait did not grow').toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(25_000) + expect(asked).toHaveBeenCalledTimes(3) + + // Five hours of a box that never answers. A 30-second poll would be 600. + await vi.advanceTimersByTimeAsync(5 * HOUR_MS) + expect(asked.mock.calls.length, 'the retry became a poll').toBeLessThan(30) + + // And when the box does answer, the chart arrives without anything else + // having to happen. This is what the ceiling is for: five hours of + // doubling with nothing to stop it puts the next ask hours out, so prices + // that land at noon would not be believed until the evening. + const answered = asked.mock.calls.length + asked.mockResolvedValue(WINDOW) + await vi.advanceTimersByTimeAsync(20 * 60_000) + expect(asked.mock.calls.length, 'the backoff grew without a ceiling').toBeGreaterThan(answered) + await vi.waitFor(() => expect(chart()).not.toBeNull()) + }) + + it('drops a retry the reconnect has already made redundant', async () => { + // A failure schedules a wait. The carrier blinks five seconds later, the + // ask that follows succeeds — and the wait is still running, against a + // question that has been answered. Harmless, because the generation guard + // throws the answer away, but it is a bulk round trip spent for nothing + // and it is not one of the moments the doc above says earns an ask. + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 15, 8, 0, 0)) + const site = streamingStore() + const streaming = site.session + + const asked = vi + .spyOn(site, 'prices') + .mockRejectedValueOnce(new Error('E_UNAVAILABLE')) + .mockResolvedValue(WINDOW) + + render(Plan, { props: { site } }) + await vi.advanceTimersByTimeAsync(0) + expect(asked).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5_000) + site.session = { ...streaming, phase: 'failed' } + await Promise.resolve() + site.session = streaming + await vi.waitFor(() => expect(asked).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(chart()).not.toBeNull()) + + // Well past when the orphaned wait would have come due. + await vi.advanceTimersByTimeAsync(60_000) + expect(asked, 'a retry ran for an ask that had already been answered').toHaveBeenCalledTimes(2) + }) + + it('does not let the older failure clear the newer chart', async () => { + const site = streamingStore() + const streaming = site.session + + const first = deferred() + const second = deferred() + const asked = vi + .spyOn(site, 'prices') + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(asked).toHaveBeenCalledTimes(1)) + + // The carrier blinks and comes back. The first request is still in + // flight — nothing settled it. + site.session = { ...streaming, phase: 'failed' } + await Promise.resolve() + site.session = streaming + await vi.waitFor(() => expect(asked).toHaveBeenCalledTimes(2)) + + second.settle(WINDOW) + await vi.waitFor(() => expect(chart()).not.toBeNull()) + + // Eight seconds after the blip, the first request gives up. + first.fail(new Error('price request timed out')) + await vi.waitFor(() => expect(chart()).not.toBeNull()) + await new Promise((r) => setTimeout(r, 20)) + + expect(chart(), 'a superseded failure removed a good chart').not.toBeNull() + }) + + it('does not let the older answer redraw over the newer one', async () => { + // The other half of the same guard, and the half that survives a carrier + // blink at 23:59:50: the superseded request answers, correctly, with + // yesterday's window — a few seconds after the 00:00:10 request drew + // today's. Without the generation check on the way in, the older window + // wins because it landed last, and the chart is a day behind with no sign + // that anything went wrong. + const site = streamingStore() + const streaming = site.session + + const first = deferred() + const second = deferred() + const asked = vi + .spyOn(site, 'prices') + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(asked).toHaveBeenCalledTimes(1)) + + site.session = { ...streaming, phase: 'failed' } + await Promise.resolve() + site.session = streaming + await vi.waitFor(() => expect(asked).toHaveBeenCalledTimes(2)) + + // Today's window draws. + second.settle(WINDOW) + await vi.waitFor(() => expect(chart()).not.toBeNull()) + + // Everything the chart is fed from here on. A window is only ever on + // screen because it was fed, so this is what "stays on screen" means. + const fed = vi.spyOn(chart() as FtwPriceChartElement, 'setPrices') + + first.settle({ + ...WINDOW, + slots: WINDOW.slots.map((s) => ({ ...s, startMs: s.startMs - 24 * HOUR_MS })), + }) + await new Promise((r) => setTimeout(r, 20)) + + expect(fed, 'a superseded answer redrew the chart').not.toHaveBeenCalled() + expect(chart()).not.toBeNull() + }) +}) + +/* What the notice under the chart is allowed to claim. + * + * The box sets `stale` for any answer that does not cover the window asked + * for — one that begins after the start, one with a hole in the middle, and + * one that ends early. Only the app holds the slots, so only the app can tell + * those apart, and they are not the same sentence: a day missing its own + * morning is not a day waiting for tomorrow. + */ +describe('the notice under the price chart', () => { + const MIDNIGHT = new Date(2026, 6, 15, 0, 0, 0).getTime() + + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + /** An hourly window over `hours`, skipping any hour in `missing`. */ + function window(hours: number, missing: readonly number[] = []): Prices { + const slots = [] + for (let h = 0; h < hours; h++) { + if (missing.includes(h)) continue + slots.push({ + startMs: MIDNIGHT + h * HOUR_MS, + durationMs: HOUR_MS, + spotMinor: 20 + (h % 7), + totalMinor: 120 + (h % 7), + }) + } + // Set by the box for every shape below, which is the whole difficulty. + return { zone: 'SE4', currency: 'SEK', stale: true, slots } + } + + async function show(prices: Prices) { + vi.spyOn(Date, 'now').mockReturnValue(MORNING) + const site = new SiteStore('test') + site.session = { + ...site.session, + phase: 'streaming' as const, + caps: new Set(['price.spot']), + } + vi.spyOn(site, 'prices').mockResolvedValue(prices) + + render(Plan, { props: { site } }) + // The chart and the notice are the same block, so one arriving means the + // other has had its chance. + await vi.waitFor(() => expect(chart()).not.toBeNull()) + } + + it('does not blame tomorrow for a hole in today', async () => { + // Reaches the end of the 48 h window asked for, and has nothing for + // 06:00–12:00 today — one failed midday fetch on the box. Tomorrow is + // published and on the chart, so saying it is not is simply false. + await show(window(48, [6, 7, 8, 9, 10, 11])) + + expect(document.body.textContent).not.toMatch(/tomorrow's rates aren't published yet/i) + expect(document.body.textContent).toMatch(/some hours are missing their price/i) + }) + + it('still says tomorrow is not published when the window merely stops short', async () => { + // The everyday morning case: contiguous from midnight, ending where the + // market's published day does. + await show(window(24)) + + expect(document.body.textContent).toMatch(/tomorrow's rates aren't published yet/i) + expect(document.body.textContent).not.toMatch(/some hours are missing their price/i) + }) + + it('says nothing at all about a window that covers what was asked for', async () => { + await show({ ...window(48), stale: false }) + + expect(document.body.textContent).not.toMatch(/tomorrow's rates aren't published yet/i) + expect(document.body.textContent).not.toMatch(/some hours are missing their price/i) + }) + + it('does not blame tomorrow for a morning that never arrived', async () => { + // The window asked for starts at local midnight; this one starts at 06:00 + // — a box whose store begins mid-day. Every slot in it joins the last, so + // there is no gap to find between them, and the day looks whole. + await show(window(48, [0, 1, 2, 3, 4, 5])) + + expect(document.body.textContent).toMatch(/some hours are missing their price/i) + expect(document.body.textContent).not.toMatch(/tomorrow's rates aren't published yet/i) + }) + + it('says the hours are missing even when the box called the window complete', async () => { + // Reproduced against the box: eighteen hourly slots covering 06:00–24:00 + // of a request for 00:00–24:00, answered stale:false. The chart lays bars + // out by index and closes the gap visually, so six missing hours draw as + // a complete day with the NOW marker on the wrong bar. This sentence is + // the only thing on the screen that can say otherwise. + await show({ ...window(24, [0, 1, 2, 3, 4, 5]), stale: false }) + + expect(document.body.textContent).toMatch(/some hours are missing their price/i) + }) +}) diff --git a/tests/price-chart-fed.test.ts b/tests/price-chart-fed.test.ts new file mode 100644 index 0000000..cec3be7 --- /dev/null +++ b/tests/price-chart-fed.test.ts @@ -0,0 +1,112 @@ +/* The seam between the wire and the box's own price chart. + * + * The component is vendored byte-for-byte and is not under test here. What is + * under test is what a fed instance does with what the mapping hands it: a + * mapping that lost the total would draw a chart of the right shape with the + * wrong money in it, and every mount below sets `fed` because that is the + * mode being exercised. + * + * That the *view* sets the attribute is a different claim, and one this file + * cannot make — it belongs in src/views/Plan.svelte.test.ts, where the view + * itself is mounted. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { chartPrices } from '$lib/state/price' +import type { Prices } from '$lib/protocol/messages' +import type { FtwPriceChartElement } from '$vendor/ftw/ftw-price-chart.js' + +const HOUR_MS = 3_600_000 + +/** Slots have to land on today's calendar day: the chart filters by it. */ +function todayAt(hour: number): number { + const d = new Date() + d.setHours(hour, 0, 0, 0) + return d.getTime() +} + +const WIRE: Prices = { + zone: 'SE4', + currency: 'SEK', + stale: false, + slots: [ + { startMs: todayAt(1), durationMs: HOUR_MS, spotMinor: 17, totalMinor: 109 }, + { startMs: todayAt(2), durationMs: HOUR_MS, spotMinor: 40, totalMinor: 137 }, + { startMs: todayAt(3), durationMs: HOUR_MS, spotMinor: -4, totalMinor: 82 }, + ], +} + +async function mount(): Promise { + await import('$vendor/ftw/ftw-price-chart.js') + const el = document.createElement('ftw-price-chart') as FtwPriceChartElement + el.setAttribute('fed', '') + document.body.appendChild(el) + return el +} + +describe('the price chart, fed from the wire', () => { + let fetched: ReturnType + + beforeEach(() => { + fetched = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no origin')) + }) + + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('asks no origin for anything', async () => { + const el = await mount() + el.setPrices(chartPrices(WIRE)) + + // The app reaches its box over an encrypted session and has no HTTP + // origin at all. A request here is a 404 and a broken promise. + expect(fetched).not.toHaveBeenCalled() + }) + + it('draws one bar per slot it was given', async () => { + const el = await mount() + el.setPrices(chartPrices(WIRE)) + + // Bars plus the invisible hit targets over them, one pair per slot. + expect(el.shadowRoot!.querySelectorAll('rect[data-idx]')).toHaveLength(WIRE.slots.length * 2) + }) + + it('shows the box’s total, not one it worked out itself', async () => { + const el = await mount() + el.setPrices(chartPrices(WIRE)) + + // Spot × 1.25 would read 50 öre high and 21 low. The box applied a grid + // tariff this instance never fetched, and the wire carried the answer. + const stats = el.shadowRoot!.querySelector('.meta-stats')!.textContent! + expect(stats).toContain('137.0 öre') + expect(stats).toContain('82.0 öre') + }) + + it('names the zone and currency the box sent', async () => { + const el = await mount() + el.setPrices(chartPrices(WIRE)) + + const meta = el.shadowRoot!.querySelector('.meta')!.textContent! + expect(meta).toContain('SE4') + // Never "incl. VAT" over a total that also carries the grid tariff — + // that claim is the fault this component was fixed for once already. + expect(meta).not.toContain('incl. VAT') + }) + + it('replaces the window rather than appending to it', async () => { + const el = await mount() + el.setPrices(chartPrices(WIRE)) + el.setPrices(chartPrices({ ...WIRE, slots: WIRE.slots.slice(0, 1) })) + + expect(el.shadowRoot!.querySelectorAll('rect[data-idx]')).toHaveLength(2) + }) + + it('says so rather than drawing an empty market', async () => { + const el = await mount() + el.setPrices(chartPrices({ ...WIRE, slots: [] })) + + expect(el.shadowRoot!.querySelector('.empty')).not.toBeNull() + }) +}) diff --git a/tests/price-e2e.test.ts b/tests/price-e2e.test.ts new file mode 100644 index 0000000..19e8f14 --- /dev/null +++ b/tests/price-e2e.test.ts @@ -0,0 +1,201 @@ +/* Prices, end to end against the simulator. + * + * The app has no HTTP origin, so what a kilowatt-hour costs reaches it the + * same way everything else does: a request on the bulk lane, answered with a + * request id. These run the real exchange rather than a stub agreeing with + * itself. + */ + +import { describe, it, expect } from 'vitest' +import { Session, PriceError } from '$lib/protocol/session' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' + +const HOUR_MS = 3_600_000 + +/** Mid-morning, so tomorrow's rates have not published yet. */ +const MORNING = new Date(2026, 6, 15, 9, 0, 0).getTime() + +function connect(box: SimBox) { + const session = new Session({ build: 'test' }) + session.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + return session +} + +async function settle(times = 30) { + for (let i = 0; i < times; i++) await new Promise((r) => setTimeout(r, 2)) +} + +function today(atMs: number) { + return new Date(atMs).setHours(0, 0, 0, 0) +} + +describe('prices on the wire', () => { + it('is being run in the timezone the suite pins', () => { + // The control, and it comes first because without it the rest of this + // file is decoration. Every claim below about a local hour — the day + // boundary, the publish hour, where the evening peak lands — is written + // as getHours(), and getHours() and getUTCHours() return the same number + // wherever the offset is zero. Under TZ=UTC this whole file passes with + // the simulator reading the curve in UTC, which is the bug it was written + // to catch. + // + // So the zone is pinned in vitest.config.ts rather than left to the + // machine, and this is what says the pin is in effect. The offset rather + // than the name: ICU still answers 'Asia/Calcutta' for it on some builds, + // and the offset is what the assertions below actually depend on anyway. + // + // -330 is +05:30, and the half hour is the point. A day boundary computed + // by flooring to a UTC hour lands inside the local day here and nowhere in + // Europe, so a whole-hour zone would hide it. Written out rather than + // imported from the config: a control that reads the value it is checking + // agrees with whatever it finds, which is the same nothing this test + // exists to stop. + expect(new Date(MORNING).getTimezoneOffset()).toBe(-330) + }) + + it('answers a window with labelled slots', async () => { + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 48 * HOUR_MS }) + + expect(prices.slots.length).toBeGreaterThan(0) + // Without these, 45 is a number and not a price. + expect(prices.zone).not.toBe('') + expect(prices.currency).toBe('SEK') + }) + + it('carries money as integer minor units', async () => { + // A price is money, and money in a float is a rounding argument waiting + // to happen. The box rounds once, here, and nothing rounds again. + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 48 * HOUR_MS }) + for (const slot of prices.slots) { + expect(Number.isInteger(slot.spotMinor)).toBe(true) + expect(Number.isInteger(slot.totalMinor)).toBe(true) + } + }) + + it('costs more to import than the market charges, because the box adds the rest', async () => { + // The app must never compute this itself: only the box holds the grid + // tariff and the VAT rate. + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 48 * HOUR_MS }) + for (const slot of prices.slots) { + expect(slot.totalMinor).toBeGreaterThan(slot.spotMinor) + } + }) + + it('is self-consistent: slots are contiguous and aligned', async () => { + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 48 * HOUR_MS }) + for (let i = 1; i < prices.slots.length; i++) { + const prev = prices.slots[i - 1]! + // A gap would draw as a market that stopped for an hour. + expect(prices.slots[i]!.startMs).toBe(prev.startMs + prev.durationMs) + } + }) + + it('says so when the answer stops short of the window asked for', async () => { + // Tomorrow's rates publish in the afternoon. A morning window that reaches + // into tomorrow genuinely ends early, and "our numbers stop here" is a + // different sentence from "the market went quiet". + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 48 * HOUR_MS }) + const last = prices.slots[prices.slots.length - 1]! + + expect(prices.stale).toBe(true) + expect(last.startMs + last.durationMs).toBeLessThan(MORNING + 48 * HOUR_MS) + }) + + it('is complete when the window ends inside what has published', async () => { + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 6 * HOUR_MS }) + + expect(prices.stale).toBe(false) + }) + + it('agrees with the plan about what an hour costs', async () => { + // Two numbers would put one price on the chart and another on the timeline + // directly below it, for the same hour, on the same screen. The plan's + // priceMinor is the *import* price, which is what the box puts there — + // comparing it against spot would agree about the wrong thing. + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const [prices, plan] = await Promise.all([ + session.prices({ fromMs: today(MORNING), toMs: MORNING + 12 * HOUR_MS }), + session.plan(), + ]) + + let compared = 0 + for (const slot of plan.slots) { + const covering = prices.slots.find( + (p) => p.startMs <= slot.startMs && slot.startMs < p.startMs + p.durationMs + ) + if (!covering) continue + expect(slot.priceMinor).toBe(covering.totalMinor) + compared++ + } + expect(compared).toBeGreaterThan(0) + }) + + it('puts the evening peak on the local evening', async () => { + // The day boundary and the publish hour are read in local time. Reading + // the curve in UTC as well as them shifted the sim's peaks one or two + // hours off the local hours the rest of the view is drawn in, which makes + // the dev screen a slightly wrong thing to review against. + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ + fromMs: today(MORNING), + toMs: today(MORNING) + 24 * HOUR_MS, + }) + const dearest = prices.slots.reduce((a, b) => (b.spotMinor > a.spotMinor ? b : a)) + + expect(new Date(dearest.startMs).getHours()).toBe(18) + }) + + it('refuses to price while the box is still booting', async () => { + const box = new SimBox({ now: () => MORNING, faults: { booting: true } }) + const session = connect(box) + await settle() + + // Scoped to the request, so one chart the box cannot draw does not raise + // the session-wide error banner over the whole app. + await expect(session.prices({ fromMs: today(MORNING), toMs: MORNING })).rejects.toBeInstanceOf( + PriceError + ) + expect(session.state.lastError).toBeNull() + }) + + it('offers prices only when the box says it has them', async () => { + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + // The capability is what the Plan view gates the whole chart on. From + // contract/registry.yaml. + expect(session.state.caps.has('price.spot')).toBe(true) + }) +}) diff --git a/tests/price-stale-contract.test.ts b/tests/price-stale-contract.test.ts new file mode 100644 index 0000000..5019193 --- /dev/null +++ b/tests/price-stale-contract.test.ts @@ -0,0 +1,83 @@ +/* The wire contract for `stale` is prose, and this is what keeps it true. + * + * One flag covers three shapes of short answer, and the two places a future + * implementer reads first are a docstring and a document — neither of which + * the compiler, the type system or any other test in this tree can contradict. + * Both spent a release describing two shapes after the box had begun setting + * the flag for a third, while the app one directory away already read all + * three off the slots. A claim outrunning the code is the defect this whole + * area keeps producing, so the claim gets a test. + * + * Not a spelling test. What is pinned is the count and the shape that was + * missing, and both of those have to move when the box's rule moves. Reword + * either passage as freely as you like; say something different about how many + * shapes there are and this goes red. + * + * The box's own rule is `priceFrom` in go/internal/appproto/price.go: stale + * unless the answer spans the window end to end with no hole in it — a head + * that begins after `fromMs`, a gap between slots, and a tail that stops + * before `toMs` are all the same flag. + */ + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** From the project root, which is where vitest runs. */ +const root = (p: string) => join(process.cwd(), p) + +/** The `stale` docstring, from its opening line to the field it documents. */ +function typeDoc(): string { + const src = readFileSync(root('src/lib/protocol/messages.ts'), 'utf8') + const from = src.indexOf('The answer does not cover the window asked for.') + const to = src.indexOf('stale: boolean', from) + expect(from, 'the stale docstring has been renamed or removed').toBeGreaterThan(-1) + expect(to, 'the stale field is no longer below its own docstring').toBeGreaterThan(from) + return src.slice(from, to) +} + +/** The Prices section of the protocol document. */ +function protocolDoc(): string { + const src = readFileSync(root('docs/protocol.md'), 'utf8') + const from = src.indexOf('\n## Prices\n') + expect(from, 'the protocol document no longer has a Prices section').toBeGreaterThan(-1) + const to = src.indexOf('\n## ', from + 1) + return src.slice(from, to === -1 ? undefined : to) +} + +describe('what the two written contracts say `stale` covers', () => { + const sources: [string, () => string][] = [ + ['the Prices type', typeDoc], + ['docs/protocol.md', protocolDoc], + ] + + // Both passages are hard-wrapped, and a comment block puts ` * ` in the + // middle of them, so a phrase can arrive with anything at all between its + // words. Matching on a literal space would go red for a re-wrap. + const phrase = (words: string) => new RegExp(words.split(' ').join('\\s+(?:\\*\\s+)?'), 'i') + + for (const [name, read] of sources) { + it(`${name} counts three shapes, not two`, () => { + const text = read() + + // The old contract, word for word. It survived a round of review by + // being true of the box that existed when it was written. + expect(text, 'still describing the contract the box replaced').not.toMatch( + phrase('two shapes') + ) + expect(text).toMatch(phrase('three shapes')) + }) + + it(`${name} names a window that begins after its start`, () => { + const text = read() + + // The shape the box added, and the only one of the three that a reader + // watching the tail alone would call a covered day. A store that first + // heard from the market at breakfast answers a request from midnight + // with slots that join each other perfectly — so the app draws six + // missing hours as a market that simply went quiet, and the NOW marker + // sits on the wrong bar with nothing on screen saying so. + expect(text).toMatch(phrase('begins after')) + }) + } +}) diff --git a/tests/relay-blindness.test.ts b/tests/relay-blindness.test.ts index b436263..3e545c2 100644 --- a/tests/relay-blindness.test.ts +++ b/tests/relay-blindness.test.ts @@ -74,18 +74,33 @@ function findString(haystack: Uint8Array, needle: string): boolean { return false } +/** + * The shortest needle worth searching for, in bytes. + * + * The dump is around 36 kB, so any given three-byte sequence turns up in it + * about twice in a thousand runs by coincidence — and with a handful of + * readings searched that is a failure every few hundred runs against a relay + * that leaked nothing. This file ran at three bytes and did exactly that. A + * test people learn to re-run protects nothing, so the floor is four, where + * the same sum is about one run in thirty thousand. + */ +const MIN_NEEDLE_BYTES = 4 + /** * How a reading could appear on this wire. * - * The CBOR form first, because that is what a leak would actually look like — - * a type byte and then the number, which is both precise and specific enough - * to mean something. Then the raw widths, in case a future carrier packs - * readings some other way. + * The raw widths, in case a future carrier packs readings some other way: + * four bytes for an int32 and eight for a float64, both long enough that + * finding one means something. The CBOR form is offered too and survives the + * floor only for values past 65 535, which is where CBOR stops spending three + * bytes on an integer. * - * Needles under three bytes are dropped rather than searched. Any given - * two-byte sequence turns up in thirty kilobytes of ciphertext about half the - * time, so including them would make this test fail on coincidence instead of - * on a leak, and a test that cries wolf gets deleted. + * Dropping the short CBOR forms costs this test nothing. A reading can only + * be CBOR on this wire by sitting in an envelope, and an envelope carries its + * type and its field names — 'snap', 'delta', 'grid_w', 'battery_soc' — every + * one of which is in KNOWN_STRINGS above and searched for regardless of how + * long the numbers beside it are. A CBOR leak is caught by its words before + * it is caught by its digits. */ function encodingsOf(value: number): Uint8Array[] { const out: Uint8Array[] = [cborEncode(value)] @@ -98,7 +113,7 @@ function encodingsOf(value: number): Uint8Array[] { f64.setFloat64(0, value, little) out.push(new Uint8Array(f64.buffer)) } - return out.filter((n) => n.length >= 3) + return out.filter((n) => n.length >= MIN_NEEDLE_BYTES) } function findBytes(haystack: Uint8Array, needle: Uint8Array): boolean { @@ -159,6 +174,29 @@ describe('the relay cannot read what it carries', () => { await relay.stop() }) + it('hunts only for needles long enough to mean something', () => { + // Checked rather than argued in a comment, because the argument is what + // went wrong: the prose made the case against two-byte needles and the + // code stopped one byte short, so every reading a house actually produces + // was searched for as three bytes. 620 — a state of charge — is the one + // that fired, on a run where nothing leaked at all. + // Four is written out here rather than read from MIN_NEEDLE_BYTES on + // purpose: checking a filter against the filter's own threshold passes + // whatever the threshold is, which is the same nothing this test was + // added to stop. + for (const value of [300, 620, 1555, 65_535, -3_456, 1_000_000]) { + for (const needle of encodingsOf(value)) { + expect( + needle.length, + `${value} is hunted for as ${needle.length} bytes` + ).toBeGreaterThanOrEqual(4) + } + // And the floor must not empty the quiver: a reading with nothing left + // to search for is a reading this test has stopped covering. + expect(encodingsOf(value).length, `nothing left to search for ${value}`).toBeGreaterThan(0) + } + }) + it('dumps everything it saw and gives nothing away', async () => { const pair = await sealedPair(relay.url, SECRET) @@ -183,12 +221,23 @@ describe('the relay cannot read what it carries', () => { new TextEncoder().encode(JSON.stringify(inspection)), ]) - // The control. If the detector cannot catch plaintext, its silence on - // ciphertext means nothing at all. - const plain = concat([ - encodeFrame({ lane: 0, flags: 0, envelope: { t: 'snap', b: { fields: readings } } }, 4096), - ]) - expect(leaks(plain, readings).length).toBeGreaterThan(0) + // The control, and it is two claims rather than one. "Something was + // found" was satisfied by the words alone, so the half of the detector + // that hunts for numbers was never proven — which is how its needles came + // to be a byte too short without anything noticing. + const plain = encodeFrame( + { lane: 0, flags: 0, envelope: { t: 'snap', b: { fields: readings } } }, + 4096 + ) + expect(leaks(plain, readings).some((f) => f.startsWith('string:'))).toBe(true) + + // Readings packed as raw int32 — the shape the byte needles exist for, + // and the one no envelope would announce with a name. + const packed = new DataView(new ArrayBuffer(readings.length * 4)) + readings.forEach((v, i) => packed.setInt32(i * 4, v, true)) + expect( + leaks(new Uint8Array(packed.buffer), readings).some((f) => f.startsWith('reading:')) + ).toBe(true) expect(leaks(dump, readings)).toEqual([]) expect(inspection.framesRouted).toBeGreaterThan(40) diff --git a/tests/session-e2e.test.ts b/tests/session-e2e.test.ts index 928aefe..a9ccc03 100644 --- a/tests/session-e2e.test.ts +++ b/tests/session-e2e.test.ts @@ -10,7 +10,7 @@ import { Session } from '$lib/protocol/session' import { LoopbackCarrier } from '$lib/carrier/loopback' import { SimBox } from '$lib/sim/box' import { decodeFrame } from '$lib/protocol/frame' -import { PROTO_FLOOR } from '$lib/protocol/messages' +import { PROTO_FLOOR, OP_SET_MODE } from '$lib/protocol/messages' const BUILD = 'test' @@ -217,7 +217,7 @@ describe('degradation instead of failure', () => { await settle() const readings = new Map(session.state.fields) - carrier.close('network gone') + carrier.drop('network gone') await settle() // The values are still true, only older. Blanking them would throw away @@ -225,6 +225,228 @@ describe('degradation instead of failure', () => { expect(session.state.fields).toEqual(readings) expect(session.state.carrier).toBe('none') }) + + it('comes back on its own from a box that was still starting', async () => { + // The drop this is about is the commonest one there is: the box restarts + // after an update. The wire returns in seconds, the box answers hello with + // mode 'booting' while it tidies its database, and refuses a subscription. + // + // Nothing else in the app would ever ask again. The carrier is up, so no + // status change is coming; a box that finishes starting does not announce + // it. Before this the phase parked on 'booting' and stayed there — the + // starting screen for as long as the app was open, every view frozen on + // what it held before the drop, and only a reload out of it. + const box = new SimBox({ now: () => Date.now() }) + const carrier = new LoopbackCarrier(box, { latencyMs: 0 }) + const session = new Session({ build: BUILD }) + + // What the app puts on the wire. A subscription is the thing a box that + // has restarted has lost and the app has to send again, so counting them + // is what says the app healed rather than the simulator being generous. + const sent = vi.spyOn(carrier, 'send') + const count = (t: string) => + sent.mock.calls.filter((c) => decodeFrame(c[0] as Uint8Array).envelope.t === t).length + + session.connect(carrier) + await settle() + expect(session.state.phase).toBe('streaming') + expect(count('sub')).toBe(1) + + box.faults.booting = true + carrier.drop('box restarting after an update') + await settle() + carrier.restore() + await settle() + + // The reconnect handshake happened and got 'booting' back. + expect(session.state.phase).toBe('booting') + expect(session.state.boot?.phase).toBe('vacuum') + expect(count('hello')).toBe(2) + expect(count('sub')).toBe(1) + + // The VACUUM finishes. Nothing on the wire says so. + box.faults.booting = false + await vi.advanceTimersByTimeAsync(30_000) + + expect(session.state.phase).toBe('streaming') + expect(count('sub'), 'never subscribed again to a box that was ready').toBe(2) + expect(session.state.fields.get(2)).toBeTypeOf('number') + + // And asked at a pace a box mid-VACUUM can carry. Thirty seconds of + // starting is half a minute of a Pi with its disk busy. + expect(count('hello'), 'the retry is a poll').toBeLessThan(12) + }) +}) + +/* Every request, not just the first map anyone remembered to write. + * + * A request left pending across a drop settles minutes later on its own + * deadline, against a view that has moved on — and a late failure that lands + * after a good answer takes the good answer down with it. The clock is never + * advanced in these: settling has to happen because the carrier went, not + * because a timer eventually fired. + * + * The wire is cut here, not the session, and it is cut the way a wire is + * actually cut in the field: `drop()` reports a retryable close and keeps the + * carrier's handlers, so the same carrier can come back — which is what + * RelayCarrier does inside itself, and what nothing above it ever learns + * about. An earlier round drove session.close() instead, a path the app takes + * only when it is shutting the whole thing down, and passed for months while + * every real drop left its requests hanging. `close()` on the carrier is no + * better: it clears the handlers, so a session that tore itself down on a + * drop would look identical to one that recovered. The last test in here is + * the one that can tell those apart. + */ +describe('a carrier that goes away settles everything waiting on it', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FIXED_NOW) + }) + afterEach(() => vi.useRealTimers()) + + /** A session and the wire under it, so the wire can be cut on its own. */ + function connectOverWire(box: SimBox) { + const carrier = new LoopbackCarrier(box, { latencyMs: 0 }) + const session = new Session({ build: BUILD }) + session.connect(carrier) + return { session, carrier } + } + + /** Records how a promise settled without ever waiting on it. */ + function watch(promise: Promise): () => string { + let outcome = 'still waiting' + void promise.then( + () => (outcome = 'answered'), + (err: Error) => (outcome = err.message) + ) + return () => outcome + } + + it('rejects a price window at once rather than eight seconds later', async () => { + const box = new SimBox({ now: () => Date.now() }) + const { session, carrier } = connectOverWire(box) + await settle() + + const outcome = watch(session.prices({ fromMs: FIXED_NOW, toMs: FIXED_NOW + 3_600_000 })) + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(0) + + // The phase moving is not enough on its own: a chart waiting on this + // promise draws nothing until it settles, whatever the phase says. + expect(session.state.phase).toBe('failed') + expect(outcome()).toBe('carrier closed') + }) + + it('rejects a plan at once rather than eight seconds later', async () => { + const box = new SimBox({ now: () => Date.now() }) + const { session, carrier } = connectOverWire(box) + await settle() + + const outcome = watch(session.plan()) + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(0) + + expect(outcome()).toBe('carrier closed') + }) + + it('rejects a history window at once rather than twenty seconds later', async () => { + const box = new SimBox({ now: () => Date.now() }) + const { session, carrier } = connectOverWire(box) + await settle() + + const outcome = watch( + session.history( + { fromMs: FIXED_NOW - 3_600_000, toMs: FIXED_NOW, res: '5m', series: ['grid_w'] }, + () => {} + ) + ) + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(0) + + expect(outcome()).toBe('carrier closed') + }) + + it('stops an unacknowledged command waiting, because it is never replayed', async () => { + const box = new SimBox({ now: () => Date.now() }) + const { session, carrier } = connectOverWire(box) + await settle() + + const outcome = watch(session.command(OP_SET_MODE, { mode: 'idle' }).promise) + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(0) + + // The intent never reached the box, and a queued command is never sent + // later — so the view has to stop waiting on it now, not in five seconds. + expect(outcome()).toBe('E_NO_ACK') + }) + + it('calls an acknowledged command unconfirmed rather than claiming it never arrived', async () => { + // Acks, then the hardware never reports back — the case the confirm + // deadline exists for. + const box = new SimBox({ now: () => Date.now(), faults: { neverConfirm: true } }) + const { session, carrier } = connectOverWire(box) + await settle() + + const handle = session.command(OP_SET_MODE, { mode: 'idle' }) + let state = 'still waiting' + void handle.promise.then( + (r) => (state = r.state), + (err: Error) => (state = err.message) + ) + // Let the ack come back, but cut the wire well before the confirm deadline. + await vi.advanceTimersByTimeAsync(50) + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(0) + + // The box took it. Saying "that didn't reach your box" here would be the + // one thing this protocol exists to prevent: a confident answer that is + // not true. + expect(state).toBe('unconfirmed') + }) + + it('is talking to the box again when the wire comes back', async () => { + // The other half of a drop, and the half none of the tests above can see: + // the carrier stays attached and returns. Settling the requests must not + // cost the session its handlers — a session that detached here would look + // exactly like this one until the wire came back, and then stay dead for + // as long as the app was open, with no reconnect button to rescue it. + const box = new SimBox({ now: () => Date.now() }) + const { session, carrier } = connectOverWire(box) + await settle() + expect(session.state.phase).toBe('streaming') + + const cut = watch(session.plan()) + carrier.drop('wire died') + await vi.advanceTimersByTimeAsync(0) + expect(cut()).toBe('carrier closed') + expect(session.state.phase).toBe('failed') + + carrier.restore() + await settle() + + // Re-handshaked over the same carrier, and the box is answering again. + expect(session.state.phase).toBe('streaming') + expect(session.state.carrier).toBe('relay') + + const after = watch(session.plan()) + await settle() + expect(after()).toBe('answered') + }) + + it('does the same when the app closes the session itself', async () => { + // The other way in — a teardown rather than a drop. Both ends of the + // split reach the same settling, and this is what says the tear-down end + // is still wired to it. + const box = new SimBox({ now: () => Date.now() }) + const { session } = connectOverWire(box) + await settle() + + const outcome = watch(session.prices({ fromMs: FIXED_NOW, toMs: FIXED_NOW + 3_600_000 })) + session.close() + await vi.advanceTimersByTimeAsync(0) + + expect(outcome()).toBe('carrier closed') + }) }) describe('revocation is immediate and fail-closed', () => { diff --git a/tests/vendored.test.ts b/tests/vendored.test.ts new file mode 100644 index 0000000..9336a59 --- /dev/null +++ b/tests/vendored.test.ts @@ -0,0 +1,98 @@ +/* The vendored components are copies, and this is what keeps them copies. + * + * Every file under src/vendor/ftw carries a header saying to change it + * upstream and re-copy, never here. Nothing enforced that, and one file had + * already drifted: price-summary.js kept a coercion the box had since fixed, + * so it read a slot's `total: null` as a supplied 0 where the box's own + * dashboard recomputed it. Inert in this app today — the compact card is + * never rendered here — and exactly the divergence the vendoring exists to + * prevent. + * + * A recorded digest per file, because the box repo is not checked out beside + * this one in CI and there is nothing to diff against. The digest covers the + * body only, everything past the provenance header, so re-stamping that + * header with a commit hash when the box side lands is not mistaken for + * someone editing the file. + * + * When a vendored file legitimately changes: change it in the box, copy it + * here, keep the header, and re-record with + * RECORD_VENDOR_DIGESTS=1 npx vitest run tests/vendored.test.ts + * That is deliberately a separate act. A check that re-records itself on the + * way past would agree with an edit made here just as readily as with a + * re-copy, which is the whole thing this is here to tell apart. + */ + +import { describe, it, expect } from 'vitest' +import { createHash } from 'node:crypto' +import { readFileSync, writeFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +// From the project root, which is where vitest runs. import.meta.url is not +// a file URL here — the test is transformed and served like any other module. +const DIR = join(process.cwd(), 'src/vendor/ftw') +const RECORD = join(DIR, 'digests.json') + +/** Lines of provenance every vendored file opens with. Not part of the copy. */ +const HEADER_LINES = 3 + +const PROVENANCE = /^\/\/ Vendored from srcfl\/ftw web\/components\/(.+) at (.+)\.$/ + +/** The `.js` files are the box's. The `.d.ts` beside them are this app's. */ +function vendored(): string[] { + return readdirSync(DIR) + .filter((name) => name.endsWith('.js')) + .sort() +} + +function linesOf(name: string): string[] { + return readFileSync(join(DIR, name), 'utf8').split('\n') +} + +function bodyOf(name: string): string { + return linesOf(name).slice(HEADER_LINES).join('\n') +} + +function digests(): Record { + const out: Record = {} + for (const name of vendored()) { + out[name] = createHash('sha256').update(bodyOf(name)).digest('hex') + } + return out +} + +if (process.env.RECORD_VENDOR_DIGESTS) { + writeFileSync(RECORD, `${JSON.stringify(digests(), null, 2)}\n`) +} + +const recorded = JSON.parse(readFileSync(RECORD, 'utf8')) as Record + +describe('the vendored components', () => { + it('each say where they came from', () => { + for (const name of vendored()) { + const [first, ...rest] = linesOf(name) + const cites = PROVENANCE.exec(first ?? '') + + expect(cites, `${name} has no provenance line`).not.toBeNull() + // A file that cites another file's name is a copy that was pasted over + // the wrong one, and the digest below would not notice. + expect(cites![1], `${name} cites ${cites![1]} upstream`).toBe(name) + expect(rest.slice(0, HEADER_LINES - 1).join(' ')).toMatch(/do not edit here/i) + } + }) + + it('are the record of what was copied, file for file', () => { + // A file added or removed without its digest being recorded is a file + // nothing below is checking. + expect(Object.keys(digests())).toEqual(Object.keys(recorded)) + }) + + it('have not been edited in place', () => { + for (const [name, digest] of Object.entries(digests())) { + expect( + digest, + `${name} differs from what was vendored. Change it in the box and ` + + 're-copy, then re-record — see the top of this file.' + ).toBe(recorded[name]) + } + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 409dba4..f6ecb93 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,24 @@ import { defineConfig, mergeConfig } from 'vitest/config' import viteConfig from './vite.config.ts' +/** + * The timezone the whole suite runs in, wherever it is run. + * + * Local time is not decoration in this app: the price window starts at local + * midnight, the publish hour is local, and the day the chart draws is the + * local one. Under TZ=UTC getHours() and getUTCHours() return the same number, + * so every assertion that says "local" passes just as well against code + * reading UTC — and a suite that is only honest on a machine that happens to + * sit at an offset is a suite people learn to ignore when it goes red. + * + * A half-hour offset rather than a whole-hour one, because a day boundary + * computed by flooring to a UTC hour lands inside the local day at +05:30 and + * nowhere in Europe. No daylight saving either, so the same instant is the + * same local hour in January as in July. The control at the top of + * tests/price-e2e.test.ts fails if this ever goes away. + */ +const TZ = 'Asia/Kolkata' + // Kept separate from vite.config.ts so the build config stays typed against // Vite alone — a `test` key there fails svelte-check. // @@ -21,6 +39,7 @@ export default mergeConfig( test: { name: 'unit', environment: 'jsdom', + env: { TZ }, include: ['tests/**/*.test.ts', 'src/**/*.test.ts', 'relay/**/*.test.ts'], exclude: ['**/node_modules/**', '**/*.svelte.test.ts'], globals: false, @@ -31,6 +50,7 @@ export default mergeConfig( test: { name: 'components', environment: 'jsdom', + env: { TZ }, include: ['src/**/*.svelte.test.ts'], globals: false, }, From 08b40da75162c317065435865b1ca24c1318b8a4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 6 Aug 2026 02:42:48 +0200 Subject: [PATCH 2/2] test(plan): move the clock instead of walking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three day-boundary tests advanced fake time by up to sixteen hours, which with a one-second ticker is nearly sixty thousand timer callbacks each. Fine on this laptop, over the five-second limit on a CI runner — the suite passed here and failed there. Nothing in these tests is about the hours in between, so the clock is set forward and then advanced far enough for the view's own tick to see it. All three still fail when their fix is reverted, checked by reverting each in turn. Co-Authored-By: Claude Opus 5 --- src/views/Plan.svelte.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts index f16c8a0..7460a9a 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -180,7 +180,10 @@ describe('a price window as the day moves under it', () => { // which is what earns a fresh ask without leaving the day — so there is a // real failure here and not merely nothing happening. box.faults = { ...box.faults, frameLossRate: 1 } - await vi.advanceTimersByTimeAsync(6 * 3_600_000) + // The clock is moved rather than walked: sixteen thousand one-second ticks + // prove nothing this test is about, and cost more than the CI runner has. + vi.setSystemTime(MORNING + 6 * 3_600_000) + await vi.advanceTimersByTimeAsync(60_000) expect(asked.mock.calls.length, 'no second ask was made, so nothing failed').toBeGreaterThan( first @@ -195,7 +198,8 @@ describe('a price window as the day moves under it', () => { // Past midnight with the box still unreachable. The bars are yesterday's // now, and the chart heads them "today" and calls the last of them "now". box.faults = { ...box.faults, frameLossRate: 1 } - await vi.advanceTimersByTimeAsync(16 * 3_600_000) + vi.setSystemTime(MORNING + 16 * 3_600_000) + await vi.advanceTimersByTimeAsync(60_000) expect(new Date().getDate(), 'the clock did not reach the next day').toBe(16) expect(chart(), "yesterday's prices were still on screen, headed today").toBeNull() @@ -211,8 +215,9 @@ describe('a price window as the day moves under it', () => { const early = chart()!.shadowRoot?.textContent ?? chart()!.textContent ?? '' const startedAt = fedAt() - await vi.advanceTimersByTimeAsync(4 * 3_600_000) - expect(Date.now() - startedAt).toBe(4 * 3_600_000) + vi.setSystemTime(MORNING + 4 * 3_600_000) + await vi.advanceTimersByTimeAsync(60_000) + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4 * 3_600_000) const later = chart()!.shadowRoot?.textContent ?? chart()!.textContent ?? '' expect(later, 'the chart drew the same hour as now, four hours later').not.toBe(early)