Skip to content

iPhone plan dial: today and tomorrow round the sun's path, in the plan view and as a widget - #27

Closed
frahlg wants to merge 4 commits into
mainfrom
ios-plan-dial
Closed

frahlg wants to merge 4 commits into
mainfrom
ios-plan-dial

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

FTW's plan as a round dial, from Fredrik's film (srcfl/sourceful-films, product/ftw-confirmed), in the app's own tokens. It is the plan view's default, where a finger dragged round it scrubs the plan, with the 12-hour lanes one tap away. It is also a new widget for the home screen, StandBy and the lock screen. Now keeps its straight plan and never scrolls.

Based on main after #17, #20 (with #18, #19 and #21), #25 (0.3.0, build 100 uploaded) and #26. For the release after 0.3.0; a draft until then.

plan view, light plan view, dark a finger round the dial

The rules

Fredrik's, from 13 Oct 2026, now under "Plan dial" in CLAUDE.md (Design references):

  1. The day runs like the sun's path: midnight at the bottom, noon at the top, up on the left and down on the right. It never reads as a 12-hour clock.
  2. One bar per quarter hour, as tall as the price. Cyan (Theme.storage) stores, white (Theme.fg) uses, Theme.fgMuted waits. A Theme.mobility dot marks a quarter when the car charges. No gold for the battery.
  3. In the middle: the battery as a ring, then the time and the price in mono figures.
  4. A hand marks now; what has passed dims.
  5. Tomorrow is the outer turn, with bars only once the box has tomorrow's prices. Until then it stays empty under "Tomorrow's prices come around 13:00" (after 13:00: "are not in yet"). No bar without a price. While prices are on their way, the gap rule: a faint band and "fetching…".
  6. The sky band on the rim comes from the box's own solar forecast (day where the PV forecast is above zero). Without the forecast there is no band.
  7. A dark glass behind the dial, so it reads on any background.

What changed

Where What
FTWShared/PlanDial.swift The geometry, one quarter per bar from today's midnight to tomorrow's end (DST days have 92 or 100), the battery's action per quarter, the car's quarters, the sky, the finger's turn across midnight, VoiceOver's hour stops, the centre's figures. Worked out once per plan, from a PlanEntry alone, so app, widget and later the Watch agree.
FTWCharts/PlanDialView.swift The drawing, built by the app and the widget. Canvas layers keyed by what they show, with a stable id per quarter: bars redraw when the plan or the quarter changes, the hand by the minute, and the mark and the middle when PlanDialSelection (a finger, VoiceOver, later the Crown) moves. New bars grow in with the charts' spring (response 0.55, damping 0.72, capped stagger); nothing moves under Reduce Motion or in Low Power Mode. ChartMotion's spring moved to FTWCharts/ChartSpring.swift so both use it.
FTW/UI/PlanDialCard.swift, PlanView.swift The dial as the plan view's default, with a switch to the lanes above it. Drag round the spiral: .selection per quarter (at most 25 a second), .impact(weight: .light) at now and at the cheapest window's edges. The line under it says what is planned there ("i morgon 02:30–02:45 batteriet väntar · bilen laddar · 30,0 öre/kWh") and springs back on release. VoiceOver: one adjustable element, "Plan dial", an hour a step, reading time, plan, price and, at now, the battery.
FTWWidgets/Plan/PlanDialWidget.swift Widget energy.ftw.plan-dial: systemSmall (home screen, StandBy) and accessoryCircular (today's quarters as ticks, the hand, the battery as a ring round its level). Entries every quarter hour for 12 hours. Grey with "Plan från 14:42" once the plan is an hour old; monochrome when tinted or at night.
PlanEntry, PlanShare Two optional fields, daylight (the forecast's sunny runs and its reach) and batteryLevel (read live, rewritten at most every 5 minutes once it moved). Old records still read (tested). The widget's record now holds today and tomorrow instead of an hour back to a day ahead.
PriceModel fetching, so a price gap on its way is told from a missing one.
Demo Plans as far as its prices reach: to midnight, and to the end of tomorrow after 13:00, as Core does.

Fredrik's answers

  1. The dial is the plan view's default, and the lanes are one tap away. A segmented control at the top of the plan view, Tavla | Banor (Dial | Lanes), switches between them. Each phone keeps its viewer's choice (@AppStorage("FTWPlanStyle")). With the lanes, the event rows go back to the lanes' colours.
  2. The dial is a widget of its own, energy.ftw.plan-dial, beside the Flighty-style plan widget.
  3. The sky shows the next 24 hours. Each place on the rim shows the next time that hour comes round. The plan view reads the forecast (GET /api/mpc/plan) as the sun's and the home's views do: when it opens, then every quarter hour while open.
  4. Now's lanes keep gold for the battery giving power. On the dial, gold stays the sun's.

plan view with the lanes, light plan view with the lanes, dark

Also as built: the past has prices but no plan (the box's plan starts now), so today's earlier quarters are grey price bars, dimmed. The glass is a dark disc of plain paint, not Liquid Glass.

Scrub cost

On Fredrik's iPhone the plan's scrub lagged in 0.3.0, so the dial's path was profiled and rebuilt:

  • A drag event does only arithmetic and one lookup in a table of the day's 192 quarters (Face.index(atProgress:)). It writes nothing a view reads: the finger's place sits in an object no view observes.
  • A change of quarter moves one @Observable value (PlanDialSelection). Only the views that read it redraw: the mark (a small Canvas), the middle's figures, the line under the dial, and a tiny view that holds the haptics. The card, the face, the bars and the hand stay as they are.
  • Haptics fire only on a change of quarter: .selection at most every 40 ms, .impact(weight: .light) at now and at the cheapest window's edges.

Measured with Instruments (Time Profiler + Points of Interest) on the iOS 26.5 simulator on this Mac, Debug build (the demo's launch flags are Debug only). PlanDialTour drove four slow drags round the dial with a real XCTest touch. Temporary signposts in each layer counted redraws; they are not in the code. The two dial drag and dial quarter signposts stay (Points of Interest), so anyone can repeat this.

Per scrub #27 as first opened (677544e) This commit
Drag handler per event, median / p95 / max 108 / 184 / 342 µs 17 / 53 / 314 µs
Views redrawn per change of quarter card, face, mark, middle, line mark, middle, line, haptics
Face re-evaluated per change of quarter 1.03 0.03 (the clock only)
Bars redrawn while the finger was down 0 2 in 12 s, at the clock's quarter, not the finger's
Mark's Canvas per redraw, median 25 µs 18 µs
SwiftUI update per change of quarter 1.6 ms 1.2 ms
App's main thread while the finger was down, without XCTest's own queries 6.5 % (966 ms in 14.9 s) 5.4 % (650 ms in 12.1 s)
App's main thread per change of quarter, commit and the plan view's 1 Hz refresh included 4.6 ms 4.1 ms

An in-between version showed the trap: with .equatable() on the bars and the hand, SwiftUI still re-ran both on every change of quarter, without asking ==, and the bars' Canvas took 0.5 ms (p95 1.1 ms) each time. The selection object removes the dependence on .equatable() altogether.

Not measured here: Animation Hitches (Instruments has no Hitches on the simulator) and the haptic engine. Both need a phone. The same tools can measure the 0.3.0 chart scrub; that is not in this PR.

Evidence

  • swift test in appleApp/FTWKit: 103 tests in FTWShared and 267 in FTWKit pass. New: PlanDialTests (14: the sun's path, quarters, DST, actions, car, empty outer turn, the finger past midnight, reach, hour stops and steps, cheapest edges, sky, centre, old records, the finger's lookup on the nights the clocks change), a readout test in English and Swedish, the widget record test, and PlanDialCard.swift in the Swedish-words test.
  • NowFitsTests: 8 of 8 on iPhone SE (3rd generation) and on iPhone 17 Pro Max, iOS 26.5, on this commit.
  • PlanDialTests (UI), 2 of 2: the dial is one element named "Plan dial" that starts at now with price and battery (iOS XCTest cannot swipe an adjustable element, so the hour steps are tested in FTWShared, Face.step); and the lanes are one tap away, stay after a new launch, and the dial comes back with one tap.
  • The app and the widget build for the simulator without new warnings.

Plan view and scrub (iPhone 17 Pro, iOS 26.5, Swedish demo)

scrub, light: tomorrow 03:45 scrub, dark: tomorrow 02:30

Recordings of the scrub, one touch per drag (FTWUITests/PlanDialTour): light, dark.

Widget on the home screen

Light, dark, without tomorrow's prices, and an hour-old plan:

widget, light widget, dark widget without tomorrow's prices widget, old plan

Lock screen circle

lock screen, light lock screen, dark circle, light circle, dark

StandBy: rendered, not captured

The iOS Simulator cannot enter StandBy. These are the widget's own view drawn on the Mac as StandBy shows it: on black, without the widget background, scaled up; at night in one red.

StandBy by day, rendered StandBy at night, rendered

Not proven yet

  • StandBy on a device.
  • Haptics and an Animation Hitches measurement need a device.
  • The widgets get a new plan only while the app runs, as before.
  • The Watch and the Digital Crown come later. The view takes the quarter to mark from whoever holds it, so the Crown can drive it.

Retake the pictures

  • Scrub: TEST_RUNNER_FTW_DIAL=1 xcodebuild test … -only-testing:FTWUITests/PlanDialTour, with xcrun simctl io <sim> recordVideo, or with xcrun xctrace record --template 'Time Profiler' --instrument 'Points of Interest' --attach <pid> for the scrub cost.
  • The lanes: launch with -FTWPlanStyle lanes.
  • Home screen: place energy.ftw.plan-dial in IconState.plist before boot, write a plan-<site> record into the App Group, then TEST_RUNNER_FTW_WIDGETS=home … WidgetTour.
  • Lock screen: TEST_RUNNER_FTW_LOCK=1 TEST_RUNNER_FTW_LOCK_PAGE=2 … LockScreenTour places the circle; TEST_RUNNER_FTW_LOCK=shot photographs it.

🤖 Generated with Claude Code

The plan as a round dial, from Fredrik's film (srcfl/sourceful-films,
product/ftw-confirmed), in the app's own tokens:

- FTWShared/PlanDial: the sun's path (midnight at the bottom, noon at the
  top, up on the left), a quarter per bar from today's midnight to the end
  of tomorrow, the battery's action per quarter, the car's quarters, the
  sky from the box's own solar forecast, the finger's turn across midnight
  and VoiceOver's hour stops. Worked out once per plan; tested.
- FTWCharts/PlanDialView: the drawing, shared by the app and the widgets
  (later the Watch). Canvas layers with a stable id per quarter: the bars
  redraw when the plan or the quarter changes, the hand by the minute, the
  mark as the finger moves. New bars grow in with the charts' spring;
  still under Reduce Motion and in Low Power Mode. Its own dark glass.
- Plan view: the dial at the top in place of the 12-hour lanes. A finger
  dragged round it scrubs the plan, .selection per quarter, .impact(light)
  at now and the cheapest window's edges; the line under it says what is
  planned there. VoiceOver: one adjustable element, an hour a step.
- Widget energy.ftw.plan-dial: systemSmall (home screen and StandBy) and
  accessoryCircular (lock screen: today's ticks, the hand, the battery).
- PlanEntry carries the forecast's daylight and a live battery level, both
  optional (old records still read); the widget's record now holds today
  and tomorrow. PriceModel says when it is fetching.
- The demo plans as far as its prices reach, so tomorrow fills at 13:00.
- CLAUDE.md: "Plan dial" under Design references.

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-13T14:59:41.717747Z d5033d6 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: d5033d69d4

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

guard !face.tomorrowPriced, face.quarters.contains(where: { $0.turn == 0 && $0.priceMinor != nil }) else { return nil }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = face.timeZone
let early = calendar.component(.hour, from: Date(timeIntervalSince1970: nowMs / 1000)) < 13

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 Align the price refresh with the 13:00 message

When a price-consuming view remains active across 13:00, this switches to “Tomorrow's prices are not in yet,” but PriceModel.wanted does not change from pending to published until its existing publishHour = 14. Since LiveAsk does not repeat an unchanged request, no new price request is made during that hour, so the dial can claim prices are late while it has not attempted to fetch them. Use the same publication boundary for both the note and the refresh key.

Useful? React with 👍 / 👎.

Comment on lines +210 to +211
let middle = calendar.date(byAdding: .day, value: 1, to: today).map(ms) ?? start + 86_400_000
let end = calendar.date(byAdding: .day, value: 2, to: today).map(ms) ?? middle + 86_400_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 Fetch the full calendar span on fall-back days

When today or tomorrow contains a fall-back DST transition, this calendar-derived end is 49 actual hours after start, but PriceModel.load() still requests only its fixed 48-hour horizon from midnight. The resulting face therefore has no price bars for the final local hour even though tomorrowPriced is true, so it suppresses the missing-price note as well. The price request needs to extend through this same calendar boundary rather than assuming two days always equal 48 hours.

Useful? React with 👍 / 👎.

frahlg and others added 2 commits October 13, 2026 17:22
The runner could not type-check a nested ternary in the test's price
fixture in time. A small function and written-out types take it well
under 80 ms here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as draft October 13, 2026 15:29
…mark

Fredrik's answers on #27:

- The plan view opens on the dial; a segmented switch at the top (Tavla |
  Banor) brings back the 12-hour lanes, and the phone keeps the viewer's
  choice (@AppStorage "FTWPlanStyle"). With the lanes, the event rows use
  the lanes' colours again.
- On the dial gold stays the sun's; Now's lanes keep gold for discharge.

The scrub, profiled in Instruments:

- A drag event is arithmetic and one lookup in a table of the two days'
  192 quarters (Face.index(atProgress:), was a scan), and writes nothing a
  view reads.
- A change of quarter moves one @observable PlanDialSelection, read only
  by the mark, the middle, the line under the dial and a small view that
  holds the haptics. The card, the face, the bars and the hand stay put.
  With .equatable() alone SwiftUI still re-ran the bars and the hand.
- The sky and the hand are worked out in their own layers, so a redraw of
  the face does not pay for them.
- Two Points of Interest signposts stay: "dial drag" and "dial quarter".

Tests: the finger's lookup on the nights the clocks change; a UI test
that the lanes are one tap away and stay after a new launch.

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

frahlg commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

Replaced by #53 (WP11): the plan dial as one turn for the next 24 hours, on the new palette and the same face as the Apple Watch dial from #43, with its own widget. #53 keeps this PR's scrub path, widget kind, PlanEntry.daylight and the dial/lanes switch, and drops the two turns, the battery ring and the glass disc, as decided in the design review.

@frahlg frahlg closed this 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