Skip to content

Apple Watch: Now, the plan dial on the Crown, the car, the price and complications (W1) - #43

Merged
frahlg merged 3 commits into
mainfrom
claude/watch-w1
Oct 14, 2026
Merged

frahlg merged 3 commits into
mainfrom
claude/watch-w1

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026

Copy link
Copy Markdown
Member

The first FTW app for Apple Watch (W1 in the watch plan). Fredrik asked on 14 Oct 2026 for "the plan, control and so on" on the wrist.

Now, the plan, the plan with the Crown turned, the car's receipt, the price, a watch face

What the watch does

  • Now in one glance. A figure glows in its kind's hue only while the phone passes on live frames, and only for 4 s after the last one. Anything else is ink with the minute it was read (○ PER 11:13). The status line uses the phone's words (● LIVE · FTW STYR).
  • The plan on a 24-hour dial, 00 at the bottom and 12 at the top. Each quarter is a tick: its length is the price in eight levels, its colour what the battery does (cyan stores, ink gives, ink3 waits). The car's charging is a dotted violet arc. The Digital Crown steps the hand a quarter hour at a time, with a .selection tick per quarter and a light tap at now, at the cheapest hours' edges and at the car's goal. The middle shows that quarter's time and price, and the line under it says what is planned. A tap brings the hand back to now. Quarters with no price yet are stubs, never a level.
  • The car: level as a 10-cell meter, state, goal, next charging window and its age. Charge now, Pause and Back to the plan.
  • The price: price now, the next hours as block bars, when it is cheapest, when it was fetched.
  • The mode: the box's modes, with a change sent the same way as the car's commands.
  • Complications: the dial as accessoryCircular (the price ring, the battery's action, the hand at now, and the battery's level in the middle while it is under 15 min old), accessoryRectangular (what happens next, block bars, PLANERAD 11:00), accessoryCorner (price now, "billigast nu") and accessoryInline (price now). A RelevanceConfiguration card, "Billigt nu", rises in the Smart Stack 15 min before each day's cheapest two hours left and stays until they end.

Each command asks once more (the yes has handGestureShortcut(.primaryAction)), then shows SKICKAT ▸ MOTTAGET ▸ BEKRÄFTAT. Every mark after "sending" comes from the box's own answers as the phone saw them. The watch can only say that it is sending, that the phone was out of reach, or that no answer came within 45 s. It can never mark CONFIRMED itself, and a test holds that.

How it reaches the box

watchOS gives an ordinary app no WebSocket and no Network connection (TN3135), so in W1 the watch goes through the iPhone:

  • After each write of the widgets' cache, WatchBridge hands the watch the same records with updateApplicationContext: prices, cars, plan, Now, the modes and the last receipt. Each keeps where it came from and when. They travel as JSON packed with LZFSE; the demo's context is about 2 KB.
  • When tomorrow's prices arrive, it also calls transferCurrentComplicationUserInfo.
  • While the watch app is on screen, the phone holds its session (with the same runner the intents use) and sends Now's frames as they land. The prices, chargers, plan and mode models count the watch as a screen that shows them.
  • A command arrives with sendMessageData. The phone checks the home and the time, then runs it exactly as the car view or the mode sheet would, and sends each step back.
  • FTW Cloud never controls.

Built to stand alone

Fredrik wants the watch to work fully on its own on LTE later. So W1 already:

  • sets WKRunsIndependentlyOfCompanionApp;
  • opens on its own cache (the last context, in the watch's App Group) before any path speaks;
  • makes its own X25519 key in its Keychain (kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly); nothing uses it yet;
  • makes its own cmdId (UUIDv7). The phone sends the box that id, so when the relay exchange (ADR 0009) exists, a command tried both ways runs once;
  • reads and sends through two seams, ReadPath and CommandPath. PhonePath is the only path now. CloudReadPath and DirectPath come after the design docs for standalone reads; none of their network code is here.

Outside the watch targets

  • FTWKit/Package.swift: FTWShared builds for watchOS 26.
  • FTWShared/Watch/: the wire (WatchContext, WatchAsk, WatchNews, WatchReceipt), the dial's face on the WP1 engine (SlotGrid, ZoneClock), the watch's words and their Swedish.
  • FTWKit/State/WatchShare.swift: the records from the models, and the command runner with its receipts.
  • Session.command, LoadpointsModel (Charge now, Pause, Back to the plan, perform) and ModeModel.set take an optional cmdId. Every other caller is unchanged.
  • FTW/Watch/WatchBridge.swift, two lines in FTWApp.swift, and IntentRunner.hold, so the watch and the intents share one count before the session closes.
  • Theme.xcassets: watchOS compiles no dark appearance, and a watch is always dark. Each colour set gains a watch variant equal to its dark one. ThemeTests holds them equal and now also scans the watch folders for colours in code.
  • The dial's lock-on uses LockOn.brackets from iPhone: the node view streams, the live trace as its hero (WP6) #41. LiveSeries does not fit Now's one figure per node, so the watch does not use it.
  • CI: the iPhone app embeds the watch app, so the existing build step builds it too. The release check reads the watch app's version as well. RELEASING.md lists the new bundle IDs.
  • CLAUDE.md: the watch's scope, where its code lives, and the colour rule.

Proof

  • swift test: FTWShared 172 and FTWKit 304 pass on the Mac. In the watchOS 27 simulator, FTWShared's 171 pass; the one that draws with AppKit builds only on the Mac.
  • New tests: the wire packs and unpacks; the watch never marks what only the box can; only a fresh live frame glows; the dial's angles, levels, stubs and landmarks; the cheapest hours left; tomorrow's prices wake the complication once; every word has Swedish; and against the simulated box, Charge now, Pause, Back to the plan and a mode change from the watch reach the box with the watch's cmdId and confirm in order.
  • xcodebuild builds the iPhone app with the watch app and its complications inside, and the FTWWatch scheme alone.
  • Run in an iPhone 17 Pro and Apple Watch Series 12 (46 mm) simulator pair, watchOS 27, Swedish, with the phone running the demo. Everything below came through WatchConnectivity, not a fixture. Charge now from the watch took 1 801 and 1 895 ms from tap to the box's result; a mode change 1 864 ms.
Now, live Now, live Now, the mode Now, the mode Now, phone away Phone off: the cache, in ink
Plan The plan now Plan, Crown turned Crown at 20:15 Price The price
Car The car Ask The question Receipt Confirmed by the box
No answer Phone off: no answer Modes The modes Mode receipt A mode, confirmed
Face Inline, the cheap card, circular Corner Corner on Infograph Rectangular Rectangular in the Smart Stack

Recordings, from the same pair:

The Crown recording predates the switch to LockOn's brackets; the still at 20:15 shows them.

W0 in the simulator

Check Result
The iPhone's Charge now Control on the watch Not testable. The side button does not open Control Center in the watch simulator.
The Live Activity's .small view in the watch's Smart Stack Charge now started the Live Activity on the phone; the watch's Smart Stack did not show it. The simulator does not pass it on.
Notification actions on the watch Not tried. Notifications reach the watch only from a phone on a real pair.

Needs a real Apple Watch

  • The W0 checks above, and whether .requiresAuthentication on the phone's Control blocks a tap on the watch while the phone is locked.
  • "iPhone not reachable": the simulator reports the phone as reachable even when it is shut down. With the phone off, a command's send never fails there, and the 45 s rule ends it with "no answer". The grey buttons need a real pair.
  • How long iOS lets the phone run when the watch wakes it, and p50/p95 over 20 runs of Charge now with the phone locked in a pocket.
  • Whether the complication gets tomorrow's prices by 13:30 without the watch app being opened.
  • The haptics, the Crown's feel (about 44 quarters per turn in the simulator), and Always On (nothing glows while the wrist is down).
  • The watch key with a passcode set, and that it goes when the passcode is removed.
  • The UI test's Crown turns timed out on a later run ("Timed out while synthesizing device event"); the recording above is from a run where they worked.

Choices to look at

  • Colours. The watch variant sits in the same colour sets as the dark one, rather than a second palette. The alternative is to resolve colours by hand on the watch.
  • Cheap card. It points at the cheapest two hours left in each day that stand out (under 85 % of the day's mean, the plan glance's rule), so today's card follows the price page's "billigast 11–13".
  • Inline carries the price alone. The plan's sentence and "billigast nu" do not fit the narrowest faces, and the system cuts them.
  • No sun arc on the watch dial yet. PlanEntry on main has no forecast; iPhone plan dial: today and tomorrow round the sun's path, in the plan view and as a widget #27's daylight is not merged. When WP11's dial lands in FTWShared, the watch should draw that face and drop its own.
  • Bundle IDs. energy.ftw.app.watchkitapp and energy.ftw.app.watchkitapp.widgets need the App Group on team L66M368PZ6. The release archive uses -allowProvisioningUpdates, which should register them. A local archive with the same flag does too.

Not merged and not for merge yet.

🤖 Generated with Claude Code

…complications (W1)

A watch app and its complications, reading and commanding through the
iPhone. watchOS gives an ordinary app no WebSocket (TN3135), so the
phone hands the watch the records its widgets read after each cache
write, sends Now's frames while the watch looks, and runs each command
over its own session. FTW Cloud never controls.

- FTWWatch: Now (lit only while a live frame is fresh, ink with its
  time otherwise), the plan dial with the Crown stepping by quarter
  hour, the car with Charge now, Pause and Back to the plan, the price,
  and the mode. Each command shows SENT ▸ ACCEPTED ▸ CONFIRMED from the
  box's answers.
- FTWWatchWidgets: the dial as accessoryCircular, plus rectangular,
  corner and inline, and a RelevanceConfiguration card for the
  cheapest hours.
- Built to stand alone: its own cache, its own key, its own cmdId, and
  two seams (ReadPath, CommandPath). PhonePath is the only path today.
- Phone side: WatchBridge in the app, WatchShare in FTWKit, the wire in
  FTWShared/Watch. Session.command takes the watch's cmdId.
- Each colour set gains a watch variant equal to its dark one, since
  watchOS compiles no dark appearance; ThemeTests holds them equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 2 commits October 14, 2026 14:21
…, the chart small and growing (WP7) (#44)

* iPhone: history and ahead on the engine, the chart small and growing (WP7)

Today and 48 h draw from HistoryPicture: a fixed day with 30-minute
slots, 48 h with 60-minute slots, each slot the mean of the box's
five-minute means through Steffen, with its band. One stable scale for
the chosen series. Holes on their way are hatched with a dashed
estimate; holes with no record are a dotted baseline. AS OF marks old
data. A tile that lands moves each slot to its new mean.

Ahead draws from AheadPicture: plan quarters as ghost bars, forecasts
as a dashed line, the price strip under the same slots.

Fredrik's note 16: the chart is compact and a tap zooms it to the
whole screen. Chips add or take away series, kept per viewer per node;
the last one stays and a hidden series changes nothing the others say.
The scale re-fits as one spring.

Size caps for the largest text move to where each view is placed, so
@ScaledMetric sees them; this fixes the price and node heroes too.

NodeCharts, ChartMotion and ChartScrub are gone.

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

* iPhone: every power trace in the live trace's material (WP7)

Fredrik on 0.3.5: the live view feels right, so every other chart should
feel and look the same. Live, Today and 48 h, small and enlarged, now
draw through one place, TraceInk: the same band, body, 2.5 pt line, and
glow only while live (on Today, only the slot still filling). Live uses
ChartAxes and NodeChartLook like the rest.

SlidingSurface gives history and Ahead Live's motion: a change for the
same slots slides over 0.25 s, a new scale or a series coming or going
springs over 0.35 s, with the same ease. AheadPicture.blended moves a
new plan or forecast slot by slot.

Under a finger the hero follows on Today and 48 h, as on Live, and the
line under the chart says the energy so far or between, from the same
functions. The dot takes its side's colour, the span tag gives the
average, and nothing dims behind the finger. History's landmarks are the
high, the low, now and each end of a run of data, as on Live.

The debug finger reads the newest track; HistoryTour starts on Live.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 14, 2026 12:23
@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-14T12:26:59.246812Z fa9a233 Draft marked ready
ℹ️ 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: fa9a2330d4

ℹ️ 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 +99 to +102
if nowArrivedAtMs == nil || (ctx.now?.readAtMs ?? 0) > (now?.readAtMs ?? 0) {
now = ctx.now
nowArrivedAtMs = nil
relight()

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 Reset live readings when the home changes

When a context for a different home arrives while nowArrivedAtMs is non-nil, this comparison can retain the previous home's now record whenever the new home has no reading yet or an older timestamp. Because nowArrivedAtMs is not cleared when the glow expires, the Now page can display another home's readings indefinitely after switching homes; detect a home change and replace the transient reading regardless of timestamps.

Useful? React with 👍 / 👎.

Comment on lines +27 to +29
let site = reader.latestSiteID(PlanEntry.self) ?? reader.latestSiteID(PriceEntry.self)
guard let site else { return (nil, nil, nil) }
return (reader.read(PlanEntry.self, siteId: site), reader.read(PriceEntry.self, siteId: site), reader.read(WatchNow.self, siteId: site))

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 Scope complication reads to the active home

After switching homes, WatchCache.save leaves the previous home's per-site files in the App Group. If the new context has prices or Now but no plan yet, this unconditional preference for the latest PlanEntry selects the old home's site and all three complication records are then read from that site, potentially showing the previous household indefinitely. Persist or read the active context's home identifier instead of inferring it from whichever plan file exists.

Useful? React with 👍 / 👎.

Comment on lines +163 to +166
private func receipt(_ r: WatchReceipt) {
let target = Self.target(r.op)
guard let mine = receipts[target], let next = mine.taking(r) else { return }
receipts[target] = next

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 Restore the cached receipt without requiring in-memory state

On a cold launch, receipts is empty, so the final receipt carried in WatchContext reaches this guard without a matching in-memory tapped receipt and is discarded. This defeats the context's stated purpose of showing how a command ended when the watch slept or was terminated before the result arrived; a trusted context receipt needs to seed the target when no local receipt exists.

Useful? React with 👍 / 👎.

Comment on lines +63 to +66
let prices = Kept.read().prices
let nowMs = Date.now.timeIntervalSince1970 * 1000
let window = WatchGlance.cheapWindows(prices?.value, nowMs: nowMs).first { $0.startMs == configuration.startMs }
let fetched = prices.map { PriceGlance.moment($0, atMs: nowMs).fetchedText }

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 Keep the configured cheap window for its full interval

During an active cheap window, recomputing cheapWindows with the current time removes elapsed slots, so after the first slot ends the best block starts later and no longer matches configuration.startMs. The entry then becomes nil and the Smart Stack card is redacted for the remainder of the relevance interval even though the configuration also carries the original end time; reconstruct or look up the configured interval without filtering away its start.

Useful? React with 👍 / 👎.

Comment on lines +108 to +110
private func keep(_ ctx: WatchContext) {
guard let cache else { return }
Task { await cache.save(ctx) }

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 context cache writes

Every received context starts an independent task, while WatchCache.save suspends between several record writes. Rapid contexts can therefore interleave, allowing an older task to write its records or final watch-context file after a newer task, so complications or the next launch regress to stale state. Queue these saves or discard writes older than the latest accepted context.

Useful? React with 👍 / 👎.

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