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 0000000..88b3d65 Binary files /dev/null and b/src/lib/state/ask.svelte.ts differ 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..7460a9a --- /dev/null +++ b/src/views/Plan.svelte.test.ts @@ -0,0 +1,800 @@ +/* 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 } + // 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 + ) + 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 } + 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() + }) + + 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() + + 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) + }) +}) + +/* 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, },