Skip to content

feat(plan): draw the price day, and make every ask heal itself - #13

Merged
frahlg merged 2 commits into
mainfrom
prices-and-self-healing-asks
Aug 6, 2026
Merged

feat(plan): draw the price day, and make every ask heal itself#13
frahlg merged 2 commits into
mainfrom
prices-and-self-healing-asks

Conversation

@frahlg

@frahlg frahlg commented Aug 6, 2026

Copy link
Copy Markdown
Member

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-chart vendored 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/prices every 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 öre and 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 streaming seconds 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_BOOTING in the minutes after an update. No reconnect was ever coming to rescue those.

askWhenLive now 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#detach settled 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, so connect() runs once, at mount. Settling now happens where the carrier actually goes away.
  • A drawn window survives a failed ask, because today's prices are still today's. It is cleared at local midnight, because past that the bars are yesterday's and the chart would go on heading them "today" and calling yesterday's last slot "now".
  • The chart is fed on every tick, not only when the window changes. fed removed 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.
  • A booting box no longer parks forever. A reconnect to a box answering booting left 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

  • The timezone is pinned in 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, and getHours() only differs from getUTCHours() where the zone does.
  • tests/vendored.test.ts records 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.
  • Seven adversarial review rounds ran over this work. Several rounds' fixes were themselves wrong — reported in the commit trail rather than quietly corrected.

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

() => {
if (mine !== gen) return
timer = setTimeout(() => (tries += 1), waitMs)
waitMs = Math.min(waitMs * 2, RETRY_CEILING_MS)

P2 Badge Stop retrying permanent request errors

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".

Comment on lines +651 to +654
let curIdx = visible.findIndex(it => {
const start = (it.tsMs || 0);
const end = start + 60 * 60 * 1000;
return now >= start && now < end;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/views/Plan.svelte
* 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@frahlg
frahlg merged commit e7cbaa0 into main Aug 6, 2026
2 checks passed
@frahlg
frahlg deleted the prices-and-self-healing-asks branch August 6, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant