Skip to content

iPhone: the second open is instant: sealed tiles and ring, asks by the data's age (WP5) - #47

Merged
frahlg merged 14 commits into
mainfrom
claude/ios-wp5-tiles
Oct 14, 2026
Merged

frahlg merged 14 commits into
mainfrom
claude/ios-wp5-tiles

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026

Copy link
Copy Markdown
Member

Why

Fredrik, 13 Oct: "Surely it should already have the data when you go back into the app a second time?"

On main the phone kept its history and live trace in memory only, and asked the box again for most things on every return. A redial reset every ask, so a return from the background read the chargers, the prefs, the plan and the prices again, whatever their age. After a kill, a node's live trace started empty and its history waited for the box.

This is WP5 of the polish release, the second step of the cache lane after WP4 (#35).

What changes

  • Asks keyed on the data's age (State/BoxAPI.swift). LiveAsk names what it wants by how old the data is: the five minutes for history, the quarter for the plan, the day and publish state for prices. An answer stands while that name holds. When someone starts looking again (a view appears, or the stream comes back), an answer older than its time to live no longer stands: a minute for the chargers, a quarter of an hour for the planner's prefs. Neither a view appearing nor a redial asks by itself, and no timer does. A view that stays open asks only when the name changes. Prices are asked again each quarter after 14:00 only while tomorrow's rates are late.
  • Reads in order (Protocol/Session.swift). Reads wait for the box's one API slot in ReadOrder: chargers, prefs, plan, status, then the rest. An idle slot waits one display frame for reads, so the order holds when they are asked in the same moment. Anything a person asked for never waits.
  • Redial at .inactive (FTWApp.swift, AppModel.glance()). Coming back, iOS passes through inactive before active; the new carrier dials there, so the handshake runs beside the app switch. A glance at Control Center or the app switcher keeps the session at 1 Hz; only the background closes it. Inactive still writes the cache, in case the app is closed from the switcher.
  • The live trace records from any live source (State/LiveTrace.swift, SiteModel). It recorded only the box's own stream, so through FTW Cloud a node's curve stayed empty. It now records whatever the screen reads, from the relay or FTW Cloud, and only fields whose own reading is live. Each point is stamped with when the box took it: the box's uptime placed by the smallest clock gap seen this connection, or FTW Cloud's send time by rule U. Each source has its own gap rule (SiteModel.readingGapMs): three and a half beats of the box's stream, or twice FTW Cloud's heartbeat and a margin, since the cloud sends on change. The node view's trace now takes it in place of NodeLive.sourceGapMs.
  • SiteModel.recent is gone. LiveTrace is the one copy of the last minutes.
  • Sealed ring-<site> (about 16 KB). The last five minutes of each ring, written with the home's other files: every 15 s while streaming, at inactive and at background. After a kill a node's curve opens with them before anything connects. Each restored point reaches the trace as LiveSample.seen false, so WP6's surface draws it in ink with no glow. LiveTrace.missed(nowMs:) gives the time the app missed, from the newest restored point to the first live one.
  • Sealed tiles-<site> (about 11.5 KB of closed tiles for 48 hours, and the tile still filling). After a kill a node's history draws in frame one. Past the newest value in the tile that was still filling, the steps read as on their way, never as a hole the box confirms.
  • History primed once. The first live frame asks for the window once, with no node open: the tile still filling first when older tiles are missing too, then the rest. After that the window is asked for again only when the five minutes turn while a node's view is open. Only closed tiles are offered to the box as held. The open tile stays on screen until its replacement lands, so the right edge never pulls back. Tiles that land within one display frame draw as one change. The grid widget's read waits for a load already on the wire instead of sending its own.
  • Prices after 14:00. The name the window was asked under stays once tomorrow's rates are here, so the answer that brings them is no reason to ask again.
  • Model files. A model adds its own sealed file with SiteModel.keepFile and reads it back with keptFile. Same key and seal as the snapshot and the home file; removing the phone erases them with the rest.
  • A count of request frames. Session logs each request frame under category asks, and SiteModel logs how many each stay in front cost when the app hides. The demo box lists every request frame for tests.
  • CLAUDE.md: the chargers are read when someone starts looking and the last read is over a minute old; a short rule for asks by the data's age.
  • SecondOpenTour (UI test, skipped unless asked for) opens the battery twice, kills the demo and opens it again straight into the battery.

Evidence

Tests. swift test on main f73c890 merged in: FTWShared 181 and FTWKit 319, all green. The app with its widgets and the watch app builds for the simulator. New in SecondOpenTests.swift:

  • a return within a minute sends no request; after two minutes only the chargers are read again (requestsThisVisit agrees);
  • a view opened again asks nothing while its answer stands, asks once when it is older than its time to live, and a view that stays open on a minute's clock asks nothing more;
  • reads wanted in the same moment reach the box as chargers, prefs, plan, status; a person's request goes first;
  • a return dials at inactive, and a glance from the front keeps the session and its rate (AppModelTests);
  • the trace records from FTW Cloud with the cloud's gap rule; box frames are stamped by uptime, so a frame 700 ms late lands one second after the one before; a device that stopped answering records nothing;
  • after a kill the trace opens with the last five minutes the app saw, every point restored and seen false, and the first live reading starts its own run;
  • runs survive the file;
  • history is primed once, newest tile first, and a burst of tiles draws as at most two changes, where without the gather it drew five;
  • reopening a node within the same five minutes sends no hist.query, across a return from the background;
  • after a kill history and the trace draw before anything connects, the twenty minutes since read as on their way, and the right edge never pulls back while the open tile comes;
  • the grid widget shares the priming: two hist.query, not three;
  • after 14:00 the price window is asked for once, and tomorrow's rates arriving are no reason to ask again.

I broke each rule on purpose: forgetting answers on a redial, an unordered queue, no person-first slot, no open-tile tail, no coalescing, no shared load, a price name that changes with its own answer. Each made its test fail.

Request frames per return from the background. Counted from the asks log and the demo box's list. Each column is a return after that long in the background.

Over the relay path, as a paired home does it (suspend closes the session, the return dials a new carrier), in a unit test against the demo box:

Build First stay 30 s 30 s 2 min 30 s
main 4 4 4 4 4
this branch 6 0 1 1 0

On main every return asked for the prefs, the plan, the prices and the chargers. Here only the chargers come back, once the last read is over a minute old. The first stay asks for history too (two hist.query, newest tile first), which main did only when a node opened.

In the demo app in the simulator (log show … category == "asks"), where the session stays open in the background:

Build First stay 30 s 30 s 2 min 30 s
main 4 2 2 2 2
this branch 6 0 0 1 0

LaunchClock. Debug build, simulator (iPhone 17 Pro, iOS 26.5), demo with -FTWDemoCache YES, 8 cold launches of each, run in turns, machine load average 13 to 25. Milliseconds after process start, medians (range):

Build First frame First content Live
main 561 (520–642) 562 (520–642) 1035 (955–1247)
this branch 547 (515–574) 548 (516–574) 1037 (995–1063)

No change beyond the noise. Reading both new files and building every model of the home takes about 2 ms in a Mac test, which is why.

Recording. The demo in the simulator (iPhone 17 Pro, iOS 26.5), SecondOpenTour.

The second open of the battery's Today, on this branch: the curve is in its final shape during the zoom and holds still.

Second open on this branch

Main has done this too since WP7 (#44): its new history surface starts settled. Before WP7 the curve grew back from a flat line on every open. One frame every 0.1 s from the second tap; main on top, this branch below:

Second open, frames

The difference this PR makes shows after a kill. The Now screen stayed up 90 s, the app went to the background and was killed, then opened straight into the battery. Left, main: the live trace is empty. Right, this branch: the minutes the app saw before, in ink with no glow, under ○ PER 14:33 and SPARAD PÅ TELEFONEN, then the live head after the gap.

After a kill, main left, this branch right

After a kill, frames

In the demo the box answers in a few milliseconds, so the history it fetches lands almost at once on main too. The unit tests prove the frame-one case without a box.

Full recordings: main short, 90 s on Now; this branch short, 90 s on Now.

Not in here

  • The RESUMED band and its tag. The data is there (LiveTrace.missed(nowMs:)); drawing it is a few lines in FTWCharts/LiveTrace.swift, which belongs to the chart lane. Until then the time the app missed shows as WP6's dotted baseline between the ink and the live line.
  • A device run against the home box: requests per return over the real relay, the time to live after a return, and Animation Hitches. The simulator cannot measure hitches.
  • At the switch from cached to live, the status line cross-fades two texts over each other (third picture above). That is WP8's.

Overlap

PR #27 (plan dial, draft) touches NowModels.swift; this changes only PriceModel there. The watch (#43) reads through the phone and uses none of what this changes.

🤖 Generated with Claude Code

frahlg and others added 14 commits October 14, 2026 07:36
Session counts each request frame it sends (history, plan, prices, API
reads) and logs it under category "asks". SiteModel logs how many a
stay in front cost when the app hides. The demo box lists every request
frame, so tests can count them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LiveAsk now names what it wants by how old the data is and keeps an
answer while that name holds. A view appearing or a redial asks again
only once the answer is older than its time to live: a minute for the
chargers, a quarter of an hour for the planner's prefs. A return from
the background within a minute sends no request.

Reads wait for the box's one API slot in order: chargers, prefs, plan,
status, the rest. An idle slot waits one display frame for reads, so
the order holds when they are asked in the same moment. A person's
request never waits.

Prices are asked again each quarter after 14:00 while tomorrow's rates
are late, instead of on every redial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS passes through inactive before active when the app comes back. The
new carrier now dials there, so the handshake runs beside the app
switch. A glance at Control Center or the app switcher keeps the session
at 1 Hz instead of dropping to 0.2 Hz; only the background closes it.
Inactive still writes what is on screen, in case the app is closed from
the switcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LiveTrace recorded only the box's own stream, so through FTW Cloud the
node's curve stayed empty. It now records whatever the screen reads, from
the relay or from FTW Cloud, and only fields whose own reading is live:
a device that stopped answering draws nothing.

Each point is stamped with when the box took it: the box's uptime placed
by the smallest clock gap seen this connection, or FTW Cloud's send time
by rule U. A frame a slow network held back lands where it was taken.

Each source has its own gap rule: three and a half beats of the box's
stream (1 Hz in front, 0.2 Hz hidden), and twice FTW Cloud's heartbeat
and a margin, since the cloud sends on change. Runs are decided when a
point arrives.

SiteModel.recent, a second copy of the last two minutes, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LiveTrace keeps the last five minutes of each ring in a sealed
ring-<site> file (about 16 KB), written with the home's other files:
every 15 s while streaming, at inactive and at background. A model
adds its own file with SiteModel.keepFile and reads it back with
keptFile.

After a kill the node's curve opens with those points before anything
connects. Each is marked restored: the live chart draws it in ink, with
no head dot, and a band covers the time the app missed, tagged RESUMED
and the time the first live reading came. Restored and live points
never share a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HistoryModel keeps its closed tiles and the tile still filling in a
sealed tiles-<site> file (about 11.5 KB for 48 hours). After a kill a
node's history draws in frame one. Past the newest value in the tile
that was still filling, the steps read as on their way, not as a hole
the box confirms.

The first live frame primes the window once, with no node open: the
tile still filling first when older tiles are missing too, then the
rest. After that the window is asked for again only when the five
minutes turn while a node's view is open, so reopening a node within the
same five minutes sends no hist.query.

Only closed tiles are offered to the box as held. The open tile stays on
screen until its replacement lands, so the right edge never pulls back.
Tiles that land within one display frame draw as one change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MovingPoints grew every chart from zero each time its view was made, so
opening a node a second time replayed the grow-in. What a chart already
has when it appears now starts settled; only what changes while it is on
screen moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid widget's quarter-hourly read asked for history itself, so the
first live frame sent the priming query and another beside it. A second
caller now waits for the load already on the wire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main brought WP3 (#38) and WP6 (#41). WP6 replaced the old live chart,
so the ink-and-band drawing this branch added to it goes. The node
view's trace now takes LiveTrace.samples, where a point kept from before
this launch is LiveSample.seen false, and the gap rule of the source in
use (SiteModel.readingGapMs) in place of NodeLive.sourceGapMs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opens the battery's Today twice, then kills the demo and opens it again
straight into the battery's live trace. Skipped unless
TEST_RUNNER_FTW_SECOND=1, like ChartTour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…after the kill

FTW_DWELL sets how long Now stays up before the first open, so the live
ring has more to keep when the demo is killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WP7 (#44) replaced the node view's Swift Charts with its own surfaces and
deleted ChartMotion.swift, so this branch's change to MovingPoints goes
with it. WP7's SlidingSurface already starts settled when it appears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After 14:00 the price window's name changed when the answer itself
brought tomorrow's rates, so the first stay sent price.get twice. The
name asked under now stays once tomorrow is here; a window still
without it is asked again the next quarter hour.

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

chatgpt-codex-connector Bot commented Oct 14, 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-14T13:14:39.497716Z 6f11ad5 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: 6f11ad5b45

ℹ️ 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 +238 to +240
public func glance() {
setVisible(true)
site?.persistNow()

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 Cancel the inactive reconnect when backgrounding

When a suspended app enters the switcher, FTWApp.swift routes .inactive through glance(), which clears suspended and launches the asynchronous connect() task. If the user returns to .background before that task reaches site.connect, suspend() closes the currently absent carrier but does not invalidate the pending connection; connect() checks only its generation and site identity, so it can subsequently attach a new carrier while the app is backgrounded, consuming a relay stream despite the background-close policy. Cancel or invalidate this reconnect in suspend(), or recheck background/visibility immediately before attaching the carrier.

Useful? React with 👍 / 👎.

Comment on lines +234 to +235
public func samples(_ node: EnergyNode, sinceMs: Double) -> [LiveSample] {
points(node.fid, sinceMs: sinceMs).map { LiveSample(t: $0.t, v: node.shown($0.v), seen: !$0.restored) }

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 Carry recorded run boundaries into chart samples

This conversion discards Point.run, even though recording and restoration now preserve source-specific gap boundaries in that field. NodeView consequently rebuilds all five minutes with one current site.readingGapMs; after switching from Cloud to relay, for example, older Cloud heartbeat intervals can be split using the relay's 3.5-second rule, while switching the other way can reconnect previously recorded gaps. Restored/live boundaries can likewise be joined when the elapsed time is short. Pass the stored run boundaries through to LiveSeries rather than recomputing them from a single current-source gap.

Useful? React with 👍 / 👎.

Comment on lines +170 to +171
if asking == name || failed == name || answered?.name == name { return }
start(name)

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 Serialize changed-name asks behind the in-flight ask

When want() changes while another name is still being fetched, this starts a second task immediately instead of waiting for the current ask to settle. Models such as LoadpointsModel and HistoryModel coalesce concurrent loads onto their existing reading/running task, so the second task merely awaits the old request and then records the new name as answered. A pull-to-refresh during a charger read, or a five-minute history boundary crossed during priming, can therefore send no request for the new state and suppress the real refresh until the name changes again.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit 02a269c into main Oct 14, 2026
1 check passed
@frahlg frahlg mentioned this pull request Oct 14, 2026
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