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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
43 changes: 42 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ junk keys.

## Messages

Fifteen types in v1.
Nineteen types in v1.

| Type | Direction | Purpose |
|---|---|---|
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
59 changes: 49 additions & 10 deletions src/lib/carrier/loopback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion src/lib/dev/simulated-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export interface SimHandle {
stop: () => void
/** Force a fault and watch the UI respond. */
fault: (patch: Partial<SimFaults>) => 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 {
Expand All @@ -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
Expand All @@ -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
Expand Down
30 changes: 25 additions & 5 deletions src/lib/format/plan.test.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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()
})
})
17 changes: 14 additions & 3 deletions src/lib/format/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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)
}
58 changes: 58 additions & 0 deletions src/lib/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// --------------------------------------------------------------------------
Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Loading