Skip to content

iPhone: a Grid today widget, with today's energy over the grid, its cost and the day as a chart - #30

Merged
frahlg merged 4 commits into
mainfrom
claude/ios-grid-today
Oct 13, 2026
Merged

frahlg merged 4 commits into
mainfrom
claude/ios-grid-today

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member

A user asked Fredrik for a widget like HomeWizard's "Strömtotal": today's energy from the grid, today's cost and a day chart. This PR takes that pattern into FTW's own look and words. It is called "Grid today" in English and "Nätet i dag" in Swedish.

What the widget shows

  • Top left: net energy today, import minus export, in kWh with one decimal and the language's decimal mark. An arrow gives the direction: ↓ when the house took more than it gave, ↑ when it gave more. Under it a quiet line always holds both sides, "in 21,8 · ut 4,0 kWh", so net never reads as the bill.
  • Top right: the cost today, actual_cost_ore / 100 from the box: import cost less export revenue, priced per quarter hour. A day that earned money shows a minus sign in the export colour. Below 0.95 of the day priced, the cost carries "delvis" / "partial". A day the box had no prices for says "inga priser" / "no prices" instead of a zero.
  • The chart: today's grid power on a fixed 00–24 axis. Import is a bar per quarter hour; export is a .monotone curve with a soft fill below zero. The rest of the day stays empty, and a hole in the data stays a break. The y-axis shows 0 and at most one round value each way, and drops a label when its side is too thin to hold it.
  • The time of the data: "kl 18:11" / "at 18:11". After local midnight, until the app has read the new day, the widget goes grey and says "Dagens siffror har inte kommit än · öppna FTW", with the time of the last figures.
  • The grid symbol is bolt.fill, the grid's own symbol on Now (NodeLook), so one thing keeps one look. It is in the iOS 26.5 runtime's name_availability.plist (since 2019), and a test checks it and both arrows with NSImage(systemSymbolName:).
  • Families: systemMedium (the main one), systemSmall (the figures and a small chart) and accessoryRectangular for the lock screen (net, cost, both sides and the time). VoiceOver reads one sentence: "17,8 kWh netto från nätet i dag. in 21,8 · ut 4,0 kWh. kostnad 5,08 kr. kl 18:11".

Colour: the requester asked for purple bars, but purple (Theme.mobility) means the car in FTW, so the bars use importing and the curve exporting, both set in one place (FTWWidgets/GridToday/GridColors.swift), and a test holds them to ftw.importing and ftw.exporting in the asset catalog.

The widget follows the system like FTW's other widgets, with dark as the reference. On tinted and clear home screens it draws in one colour: the bars solid, the curve and its fill fainter, and zero tells them apart (widgetRenderingMode, widgetAccentable).

Where the numbers come from

Only the box's numbers. The app computes no cost.

  • Totals and cost: GET /api/savings/daily?days=1: day, import_wh, export_wh, actual_cost_ore, priced_coverage_pct. The app may already read it (SimulatedBox's route table).
  • Chart: the grid_w history the grid's node view reads (HistoryModel, five-minute tiles), folded into quarter hours. A quarter is the mean over the part the box has a record for; a quarter with no record has no value.
  • The day follows the box's time zone (session.box?.tz), and the chart's day is the day the box named in its answer.
  • Freshness: GridTodayShare (FTWKit) reads once a quarter hour while the app is on screen, and again when the household's day turns. Hidden, it asks nothing. Each read writes one GridTodayEntry record (grid-today-<site>) through SiteModel.write, as the price and plan records go, and the app wakes the widgets. The record carries carrier, srcState (the grid meter's, at the read) and the time of the read, and reads back as cache.
  • The widget's timeline has two entries: now, and the next local midnight.

The demo

  • The demo box now answers savings/daily with Core's fields. Today comes from the same five-minute buckets its history serves, priced per quarter hour (import at the price, export at the spot share), so the totals and the chart add up. A test holds them within 3 %.
  • -FTWDemoWidgets YES (Debug only) lets the demo leave its records for the widgets under the home "demo". Without it the demo still writes nothing; a test checks that too. This needed one flag on SiteModel.init (demoShares).
  • -FTWDemoSunny YES gives a clear summer's day, which sells more than it buys. -FTWDemoPartial YES leaves a fifth of the day unpriced.

Screenshots

From the demo on an iPhone 17 Pro simulator, iOS 26.5, in Swedish. Each picture holds the medium widget over the small one. "Bought" is an October evening; "Sold" is the same evening with -FTWDemoSunny YES.

Bought more (↓) Sold more (↑)
Light import-light export-light
Dark import-dark export-dark
Tinted import-tinted export-tinted
Clear import-clear export-clear
Lock screen import-lock export-lock

The lock screen always draws widgets in one colour, so it has no light, dark or tinted version of its own. The "delvis" mark, dark: partial-dark

How they were taken: the widgets went onto the home screen through IconState.plist before boot, and the app ran once with -FTWDemo YES -FTWDemoWidgets YES (and -FTWDemoSunny YES) before each picture. FTWUITests/GridWidgetTour picks the icon style (Förval, Mörkt, Klart, Tonat), places the widget on the lock screen and takes the pictures. It is skipped unless asked for (TEST_RUNNER_FTW_GRID=…) and drives SpringBoard with Swedish labels. I also checked the largest text size, where the small widget's time moved to its own line so a long cost can no longer cut it short, and the grey new-day state, by dating the record a day back.

Tests

swift test in appleApp/FTWKit: 379 tests in 54 suites, green (105 in FTWSharedTests, 274 in FTWKitTests), 23 of them new:

  • GridGlanceTests (16): decimals in sv and en, the arrow and a figure with no sign, the negative cost and a minus that rounds to nothing, the partial and no-prices marks, the day boundary (yesterday after midnight, a 25-hour day's axis), bars and the curve with a hole, two runs sharing a zero, the quiet y-axis, quarters from history steps, the snapshot round trip, every word in Swedish, and the SF Symbols.
  • GridTodayShareTests (7): the demo fills every part of the widget and its totals match its chart, a sunny day sells more than it buys, a partly priced day, the demo alone writes nothing, one read per quarter hour and none while hidden, the box's row as it gave it, and the colours against the asset catalog.

The app and the widget extension build for the iOS Simulator (xcodebuild build -project FTW.xcodeproj -scheme FTW -destination 'generic/platform=iOS Simulator'). Now and NowFitsTests are unchanged.

Coordination

Not in this PR

  • The widget gets new figures only while the app is open, as the plan widget does. The 13:05 background task could read them too; that is in FTWApp.swift and a later change.
  • A box with no price zone answers savings/daily with no days. The app then writes nothing, and the widget asks the reader to open FTW.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 13, 2026 18:01
Today's net energy over the grid with its direction, what was bought
and sold, today's cost from the box, and the day as a chart on a fixed
00-24 axis. Small, medium and the lock screen's rectangle.

- The totals and the cost are the box's own: GET /api/savings/daily?days=1,
  actual_cost_ore / 100. The app computes no cost.
- The chart is the grid_w history the grid's node view reads, a quarter
  hour at a time. A hole stays a break.
- GridTodayShare reads once a quarter hour while the app is on screen and
  writes one App Group record, as the price and plan widgets do.
- After local midnight the widget says today's figures have not come yet.
- The demo serves Core's savings fields from its own buckets.
  -FTWDemoWidgets YES lets a Debug demo leave its records for the widgets;
  -FTWDemoSunny YES gives a day that sells more than it buys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long cost cut the time short ("kl 18..."). The time says how old the
figures are, so it is never truncated.

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-13T16:27:36.375764Z 73cbe78 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: 73cbe78227

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

exportWh: totals.exportWh,
costMinor: totals.costMinor,
pricedCoverage: totals.pricedCoverage,
currency: prices.currency,

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 Wait for a known currency before caching the cost

When a non-SEK home opens, this load runs concurrently with PriceModel.load(), while PriceModel.currency falls back to "SEK" until that separate request completes (NowModels.swift). If the savings/history requests finish first—or the price request temporarily fails—the widget persists a correctly valued cost with the wrong kr unit, and it is not corrected until a later quarter-hour refresh. Use currency supplied by the savings response or defer/rewrite the record once the price model has an authoritative currency.

Useful? React with 👍 / 👎.


func load() async throws {
let readAt = site.nowMs
let wire = try await site.callBox(.get, "/api/savings/daily", query: ["days": "1"])

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 Prevent older grid loads from overwriting newer ones

If a request remains in flight across a quarter boundary—especially midnight—LiveAsk can start the newly wanted request without cancelling the previous one, but load() has no generation check. The older request can therefore finish last and overwrite the newer record; at midnight that leaves yesterday's entry as the latest cache and the widget grey until the next quarter-hour. Discard results whose request generation is no longer current before calling shareGridToday.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit d68ecc8 into main Oct 13, 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