Skip to content

iPhone: open on the last truth, from a sealed home file (WP4) - #35

Merged
frahlg merged 1 commit into
mainfrom
claude/ios-wp4-cache
Oct 14, 2026
Merged

frahlg merged 1 commit into
mainfrom
claude/ios-wp4-cache

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member

Why

On 0.3.0 Fredrik wrote: "It feels too blinky when it loads data from scratch; it needs to be softer." And: "Surely it should already have the data when you go back into the app a second time?"

Only the six Now readings reached the disk. A cold start drew a grey copy of Now, then filled it in one part at a time: "No plan from your box yet", then the plan, then the car's windows, then the price.

This is WP4 of the polish release, the first step of the cache work. WP5 (sealed history tiles and the live ring, asks keyed on the data's age, redial at .inactive) comes next and is not in here.

What changes

  • Stamped<T> (State/Stamped.swift): a value, when it was true, and where it came from: cache, relay or cloud. One rule in taking(_:): the same value again changes nothing; the box confirming what the cache said moves only the origin and the age; an older copy never replaces what the box said.
  • The sealed home file (State/HomeCache.swift, home-<site>): what hello said (caps, modes, the box's id, build and zone, role, scopes), the planner's preferences, the plan, the forecast, the price window, the chargers with goals and windows, and Now's nodes in order. Same key and seal as the readings. Written on the same triggers (every 15 s while streaming, on inactive, on background), and only when it differs. Each model adds its own part with site.keep. Removing the phone erases it with the rest.
  • Hello from the cache, controls inert. Session.restore(_:hello:) applies the kept hello and the readings in one change and leaves heardFromBox false. ModeModel.mayControl draws a control from the kept caps; canControl also needs heardFromBox. The mode button on Now, Change in a node view, the mode row in the plan view, and the car view's level, goal and Plan · Charge now · Pause bar are drawn in place and do nothing until the box has spoken.
  • The plan keeps its age ("Planned 19:48") until the box answers. "No plan" shows only after an answer. With nothing kept and no answer yet, the line is a placeholder at its final size; the card keeps two lines either way. PlanShare.moment(nowMs:) decides this for Now and the plan view.
  • App Group read-back. With no home file (the first launch after this update), plan- and prices- are read back from the App Group, as car- already was.
  • Prices stay over midnight. Yesterday's window holds today's prices; it stays until the new one lands.
  • The car keeps its last charger watts with the read's age, never 0 W from nowhere (NowFlow.readings(site:loadpoints:)). The car comes off the house figure only when both are as old, so an old read never changes a live figure.
  • FTW Cloud's prices and chargers reach PriceModel and LoadpointsModel as Stamped with origin .cloud. The relay's answer replaces them only if it differs.
  • An equal arrival changes nothing. Plan, prices, prefs, forecast and chargers all go through taking. The plan widget still gets the new time the box confirmed its plan (PlanModel.received).
  • Cached ages. Cached readings now age against the uptime they were taken on. Before, hello replaced the uptime and the age of every cached reading moved in the moment between hello and the first frame: down from 29 s to 20 s in the demo, and about double on a real box. This was in 0.3.1 too.
  • Demo. The simulated box now plans once a quarter and sends the same plan until then, as Core does (appproto.Plan.UptimeMs is when the plan was made). Debug only: -FTWDemoCache YES lets the demo keep its sealed cache, so a cold start can be recorded and timed.

Evidence

Tests. swift test: FTWShared 105 and FTWKit 286, all green. New in HomeCacheTests.swift:

  • after a kill, the plan, prices, forecast, mode label, node set and car watts exist before hello, dated and from the cache;
  • the kept caps draw the controls, and they stay inert through an open carrier and the handshake until hello_ok;
  • the box sending plan, prices and prefs again, the same, fires no observation change;
  • prices stay over midnight while the box streams and has not answered for the new day;
  • the App Group records are read back when there is no home file;
  • with nothing kept, the plan waits, and "No plan" never shows before an answer;
  • the home file is written only when it says something new;
  • hello does not move the age of cached readings;
  • FTW Cloud's prices and chargers land as .cloud.

I removed the inert gate, the equal-arrival rule, the midnight rule and the cached clock one at a time; each made its test fail.

NowFitsTests on an iPhone SE (3rd generation), iOS 26.5: 8 of 8 pass, so Now still fits with the plan card's two kept lines.

Recording. A cold start of the demo in the simulator (iPhone 17 Pro, iOS 26.5). Left: main. Right: this branch with -FTWDemoCache YES.

Cold start, main on the left, this branch on the right

Full recordings: main, this branch, side by side.

One frame every 0.1 s from the first frame of the app. Top, main: blank, then a grey placeholder for four frames, then the home with "No plan from your box yet" for three, then the plan without the car, then the plan with the car and the price. Bottom, this branch: the whole screen from the cache in the first frame, then one change to live.

Frames, main on top, this branch below

The cached first frame beside the live one:

Cached first frame and live frame

LaunchClock. Debug build, simulator, 10 cold launches of each, run in turns, machine load average 20 to 70. Milliseconds after process start, medians:

Build First frame First content Live
main, demo 570 1072 1010
this branch, demo with its cache 702 702 1216
this branch, demo without cache 560 1058 1000
this branch, cache flag but cache empty (6 runs) 536 1040 980

Content now comes in the first frame, 370 ms sooner. That frame comes 130 ms later because it draws the whole of Now instead of a placeholder: reading and decoding the home file takes 0.4 ms (Mac test), and the empty-cache row shows the keychain read costs nothing that shows. With its cache the demo goes live about 200 ms later. Its box runs in the same process, so the handshake waits for that first full frame; a real box waits on the network. This needs a measurement on a phone against the home box.

Not in here

  • At the switch to live, the nodes lose their "15 s ago" line and shrink, the status row cross-fades two sentences over each other, and the subtitle says "Starting the demo" over a cached home. Kept rows and the status line are WP8 and WP9.
  • A kill-and-relaunch recording on Fredrik's phone against the home box, and the time to live there.
  • WP5.
  • CLAUDE.md: the rules this adds (open on the kept home, controls inert until hello) can go in with WP2's update, which rewrites the same section.

Overlap

PR #27 (plan dial, draft) touches PlanView, PlanShare, PriceModel and SimulatedHistory. When it rebases: build the plan view's moment with models.planShare.moment(nowMs:) instead of PlanGlance.live(entry, nowMs:), and keep PriceModel.window as the stored value (prices and fromMs now read from it).

🤖 Generated with Claude Code

A cold start drew a grey copy of Now and filled it in one part at a
time: only the six readings reached the disk. The app now keeps the rest
of what a screen shows in a sealed home-<site> file, and every screen
opens on it in its final shape, dated, until the box answers.

- Stamped<T>: a value with when it was true and where it came from
  (cache, relay, cloud). The same value again changes nothing.
- home-<site>: what hello said, the planner's preferences, the plan, the
  forecast, the prices, the chargers with their goals and windows, and
  Now's nodes. Written on the snapshot's triggers, only when it differs.
- Session.restore applies the kept hello with the readings in one change,
  and leaves heardFromBox false. Controls are drawn from the kept caps and
  role, and stay inert until the box has spoken.
- The plan keeps its age until the box answers; "No plan" only after an
  answer. Before any answer with nothing kept, the line is a placeholder.
- plan- and prices- are read back from the App Group when the home file
  has none. Prices are kept over midnight.
- The car keeps its last charger watts, with their age; an old read never
  changes the live home figure.
- FTW Cloud's prices and chargers reach the models as stamped values.
- Cached readings age against the uptime they were taken on, so hello no
  longer moves their age.
- The demo plans once a quarter, as Core does, and -FTWDemoCache YES (Debug)
  lets it keep its cache, to record and time a cold start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T18:10:47.800766Z 679e4e1 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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

Here are some automated review suggestions for this pull request.

Reviewed commit: 679e4e16d4

ℹ️ 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 +111 to +116
if let kept = site.kept?.chargers, kept.asOfMs >= shared?.record.readAtMs ?? 0 {
stamp = Stamped(kept.value.points, asOfMs: kept.asOfMs, origin: .cache)
windows = kept.value.windows
readAtMs = kept.asOfMs
} else if let read = shared {
stamp = Stamped(read.value.cars.map { Self.loadpoint($0, readAtMs: read.record.readAtMs) }, asOfMs: read.record.readAtMs, origin: .cache)

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 Merge newer widget readings into the sealed charger cache

When the App Group CarEntry is newer than kept.chargers—for example, after a Cloud loadpoint update lands between sealed-cache writes—this branch discards the sealed Loadpoint objects and their cached schedule, plan windows, and other charger details. CarEntry contains only the reduced widget fields reconstructed by loadpoint(_:readAtMs:), so the next cold launch loses the goal and charging plan until the box API responds. Preserve the sealed details and overlay the newer dynamic widget fields instead of choosing the widget record wholesale.

Useful? React with 👍 / 👎.

Comment on lines +156 to +158
guard let taken = Stamped.taking(Stamped(next, asOfMs: cloud.asOfMs, origin: .cloud), over: stamp) else { return }
stamp = taken
readAtMs = taken.asOfMs

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 Carry Cloud freshness into charger stamps

When a Cloud loadpoint message is accepted here, its CloudView grade is discarded, and stale later considers the resulting .cloud stamp live whenever the unrelated relay session is streaming. If the Cloud copy subsequently ages or an heartbeat marks it behind while relay telemetry remains connected, the car can continue showing old power/SOC as “Live” and may use that power to split the live house load until another /api/loadpoints read occurs. Retain the Cloud loadpoint grade or otherwise age .cloud stamps independently of the relay phase.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit 5861f06 into main Oct 14, 2026
1 check passed
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