feat(plan): draw the price day, and make every ask heal itself - #13
Conversation
Two things, together because the first forced the second. Prices. The box now serves price.get, so the Plan screen draws the day above the timeline using the box's own ftw-price-chart, vendored byte for byte and fed by method rather than left to fetch. Lazy-loaded: 10.7 kB gzip against 17 kB of headroom, on a screen most opens never reach, so it is a chunk of its own and CI now fails if a static import pulls it into the entry bundle. Both columns read the same units table, so the chart's "148.0 öre" and the row beneath it are the same number rather than two a hundred apart with one of them unlabelled. Healing. The plan, a history window and a price day are all asked for rather than streamed, and all three had the same fault: nothing ever asked again. A carrier drop settles every request in flight as a failure at once, the phase returns to streaming seconds later with the box answering happily, and the screen keeps a sentence promising something it will never do. Quieter failures never move the phase at all — a bulk answer lost on the relay, the eight-second deadline against a busy box, E_BOOTING in the minutes after an update — so no reconnect was ever coming to rescue those. askWhenLive holds that rule once. An ask runs when the session is live, again whenever it comes back, and again after one that failed, backing off rather than polling. A rejected promise is what says "ask again": nothing else can tell, because a failure the caller swallows looks exactly like an answer. The rule is only worth having if nothing goes round it, and twice it was built and applied to one caller. Both of the ones left out were the ones a user reaches by tapping — a history range, and the replan chased after a mode change — so both now change the question instead of fetching for themselves. Sharper edges, each with a test that fails when its fix is reverted: Session#detach settled one of four pending maps while its comment claimed all of them, and it was not called on an ordinary drop at all: the Noise carrier reconnects inside itself, so connect() runs once at mount. Settling now happens where the carrier actually goes away. A window stays drawn through an ask that failed — today's prices are still today's, and taking away a block someone is reading costs them something. It is cleared at local midnight, because past that the bars are yesterday's and the chart would go on heading them "today". The chart is fed on every tick, not only when the window changes: `fed` removed the poll that used to re-render it, and the NOW marker and the "now" figure both come from the clock at render time. Without a tick it labelled a price up to fourteen hours old as now, on a healthy box with a perfect connection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
ftw-webapp | 08b40da | Aug 06 2026, 12:43 AM |
The three day-boundary tests advanced fake time by up to sixteen hours, which with a one-second ticker is nearly sixty thousand timer callbacks each. Fine on this laptop, over the five-second limit on a CI runner — the suite passed here and failed there. Nothing in these tests is about the hours in between, so the clock is set forward and then advanced far enough for the view's own tick to see it. All three still fail when their fix is reverted, checked by reverting each in turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
ftw-webapp/src/lib/state/ask.svelte.ts
Lines 107 to 110 in 3c8cfb8
Every rejected ask is retried indefinitely, even when the protocol explicitly marks the error non-retryable. For example, an older/floor box answering plan.get with E_UNKNOWN_OP, or a session receiving E_SCOPE_DENIED, will be contacted every 15 minutes for the lifetime of the view while the UI continues claiming it is “Still trying.” Preserve the request error's detail.retryable distinction and only schedule this backoff for transient failures.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let curIdx = visible.findIndex(it => { | ||
| const start = (it.tsMs || 0); | ||
| const end = start + 60 * 60 * 1000; | ||
| return now >= start && now < end; |
There was a problem hiding this comment.
Use each slot's duration for the current-price statistic
For quarter-hour settlement data, this hard-coded one-hour end makes findIndex select the first slot of the hour for the entire hour. For example, at 09:50 the header's prominent “now” value comes from 09:00–09:15 rather than 09:45–10:00, even though the chart marker correctly uses lenMin; calculate the end from it.lenMin so both parts identify the same current price.
Useful? React with 👍 / 👎.
| * midnight-to-now-plus-48h reaches seventy-two hours by late evening — past | ||
| * what quarter-hour settlement fits, so the market would quietly end early. | ||
| */ | ||
| const PRICE_HORIZON_MS = 48 * 3_600_000 |
There was a problem hiding this comment.
End the price window at the second calendar midnight
In timezones with daylight-saving transitions, 48 elapsed hours from local midnight is not two calendar days. In Europe/Stockholm, the spring transition makes this request end at 01:00 on the third day, so even fully published today-and-tomorrow data is marked stale and reported as unpublished; the autumn transition ends at 23:00 and omits tomorrow's final hour. Compute toMs by advancing the local calendar date by two days instead of adding a fixed duration.
Useful? React with 👍 / 👎.
Two things, in one PR because the first forced the second.
Needs srcfl/ftw#836 — the box half. The vendored files here are byte-identical to that branch and their provenance headers cite its commit.
Prices
The Plan screen draws the day above the timeline, using the box's own
ftw-price-chartvendored byte for byte and fed by method rather than left to fetch. The app has no HTTP origin, so an unfed chart would request/api/pricesevery five minutes forever and get a 404 every time.Lazy-loaded: 10.7 kB gzip against ~17 kB of headroom, on a screen most opens never reach. CI now fails if a static import pulls it into the entry bundle — the byte budget alone would not have caught it.
Both columns read the same units table, so the chart's
148.0 öreand the timeline row beneath it are the same number. They used to be a hundred apart, with the row unlabelled, and the "to import" legend told the reader they were the same money — which made it worse rather than better.Every ask heals itself
The plan, a history window and a price day are all asked for rather than streamed, and all three had the same fault: nothing ever asked again.
A carrier drop settles every request in flight as a failure at once. The phase returns to
streamingseconds later with the box answering happily, and the screen keeps a sentence promising something it will never do — with no reconnect button anywhere in this app, because healing is meant to be its job.The quieter failures never move the phase at all: a bulk answer lost on the relay, the eight-second deadline against a busy box,
E_BOOTINGin the minutes after an update. No reconnect was ever coming to rescue those.askWhenLivenow holds that rule in one place. A rejected promise is what says "ask again" — nothing else can tell, because a failure the caller swallows looks exactly like an answer.The rule is only worth having if nothing goes round it, and twice it was built and applied to a single caller. Both callers left out were the ones a user reaches by tapping — a history range, and the replan chased after a mode change. Tapping "7 d" and losing that answer used to leave 24 h of readings drawn under a pressed 7 d button, which is worse than an empty chart: it is a wrong label on real data.
Sharper edges
Each has a test that fails when its fix is reverted — checked by reverting, not by assuming.
Session#detachsettled one of four pending maps while its comment claimed all of them — and it was never called on an ordinary drop at all. The Noise carrier reconnects inside itself, soconnect()runs once, at mount. Settling now happens where the carrier actually goes away.fedremoved the poll that used to re-render it, and both the NOW marker and the "now" figure come from the clock at render time — without a tick it labelled a price up to fourteen hours old as now, on a healthy box with a perfect connection.bootingleft the phase there with nothing sending another hello. Not reachable against today's box, but it goes live the day the box feeds boot progress — which is when it restarts after an update, the commonest drop there is.Testing notes
vitest.config.ts, with a control that fails if the pin is removed. The simulator-clock guard was previously inert on CI: GitHub runners are UTC, andgetHours()only differs fromgetUTCHours()where the zone does.tests/vendored.test.tsrecords a digest per vendored file, covering the body so a header re-stamp is not an edit. One vendored file had already drifted from upstream; nothing was checking.🤖 Generated with Claude Code