This repository has been archived on 2026-09-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Calendarr-IOS/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md
T
ScarriffleandClaude Opus 5 f9bc1965a5 Add the watch app and its eight complications
The watch receives the phone's payload over WatchConnectivity and writes it
into its own App Group container through the same SnapshotStore, so there is
one wire format and one reader. The agenda, the detail screen and the filter
all read that; the complications read what the app last wrote, since an
extension cannot run a WCSession of its own.

Complication timelines land on event boundaries rather than a schedule, so the
next appointment flips in the exact minute it changes without spending refresh
budget.

CalendarrWatchShared holds what both watch targets need, attached to each the
way Shared/ is attached to the app and the widgets.

recommendations() is implemented because watchOS requires it — iOS has a
default, the watch does not, since the complication gallery has to be
populated. Found by building, not by reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:08:27 +02:00

14 KiB
Raw Blame History

Apple Watch app and watch-face complications

Date: 2026-09-11 Status: implemented — pending on-device verification

Goal

Put "what is my next appointment" on the wrist as watch-face complications, with a tap-through app that shows a plain agenda list and lets the user narrow which calendars count. Nothing more.

Non-goals

  • Creating or editing events on the watch. The snapshot is deliberately lossy — no notes, recurrence rules or permissions — which is enough to display a calendar and deliberately not enough to edit one. Editing would mean a second API client and is out of scope.
  • Running without the iPhone. The watch is a read-only consumer of the phone's snapshot. Direct server access stays available as a later option (the server is reachable over HTTPS) but is not built now.
  • Wear OS parity. A separate project with its own decision.

Platform floor

iOS 26 (unchanged) and watchOS 26. Nothing below that needs to work, so no availability branching.

Design rules

  • No emojis as icons, anywhere — SF Symbols only (Image(systemName:)). An emoji renders differently on every platform and font, and complications tint SF Symbols with the watch face's colour, which an emoji ignores.
  • Exactly one element per complication is marked widgetAccentable().
  • The watch app's background stays black rather than following snapshot.theme; the always-on display and OLED make that the right choice. snapshot.theme supplies accents only.

Architecture

iPhone                                     Apple Watch
──────────────────────────────────         ──────────────────────────────
CalendarStore.publishWidgetSnapshot()
  ├─ SnapshotStore.write()   (App Group iOS)
  ├─ WidgetTimelineNotifier.reload()
  └─ WatchSyncService.push() ──WCSession──▶ WatchSnapshotReceiver
                                              ├─ SnapshotStore.write()  (App Group watchOS)
                                              ├─ SharedSessionStore.write()
                                              └─ WidgetCenter.reloadAllTimelines()
BGAppRefreshTask (new)
  └─ refresh → publishWidgetSnapshot()      Watch app foreground
                                              └─ sendMessage ──▶ pull when reachable

App Groups are per-device: the phone's container is not the watch's. The watch therefore receives the snapshot over WatchConnectivity and writes it into its own App Group container using the same SnapshotStore from CalendarrKit. One format, one reader, no drift — the watch is exactly the read-only consumer the CalendarrKit README already describes.

CalendarrAppGroup.current is already correct for watchOS: the #if os(macOS) || targetEnvironment(macCatalyst) guard falls through to the unprefixed identifier, which is what watchOS requires.

Module changes

Module Change
CalendarrKit Add .watchOS(.v26) to platforms. No code change — the Foundation-only constraint pays off here.
CalendarrKit New Snapshot/WatchCoverage.swift: the transport trimming policy plus CalendarrSnapshot.trimmedForWatch(). Shared by sender and receiver so they cannot disagree about it.
iOS app New Services/WatchSyncService.swift — WCSession sender, called from publishWidgetSnapshot().
iOS app New Services/BackgroundRefresh.swift — BGAppRefreshTask that refreshes events, republishes the snapshot and pushes to the watch. Benefits the existing widgets too.
New target CalendarrWatch (watchOS app): receiver, agenda, event detail, calendar filter.
New target CalendarrWatchComplications (watchOS widget extension): the complications.

Transport

Trimming policy

WatchCoverage, mirroring the existing SnapshotCoverage:

  • daysBehind = 1
  • daysAhead = 14
  • maxEvents = 120

trimmedForWatch() must narrow coverageStart/coverageEnd along with the events. Carrying 14 days of events while still claiming a 42-day window would make the watch render an empty week that it has no information about — the exact failure the README warns against. If the maxEvents cap truncates before daysAhead is reached, coverageEnd is pulled back to the start of the day of the last retained event.

Trimmed payload is roughly 20–30 KB, far below the WatchConnectivity limit.

Three delivery paths

Each one alone leaves a gap, so all three ship:

  1. updateApplicationContext — the main path. Carries only the latest state, survives restarts, and has no queue to overflow. Delivered when the watch app next launches or wakes.
  2. transferCurrentComplicationUserInfo — sent only when the next event changed identity (first event after now, compared by id and start). This is the only path that wakes the watch app while it is not running, which is why it exists; its daily budget (~50) is why it is not used for every write. Additionally rate-limited to at most 4 per hour.
  3. sendMessage pull on watch app foreground — covers "I am looking at it right now and it is stale".

Payload is a property-list dictionary of Data blobs: snapshot, calendars, session, plus an integer schema matching CalendarrSnapshot.currentSchema. A receiver refuses a higher schema rather than guessing, same rule as the file format.

Only the watch app runs WCSession; a widget extension cannot. The extension always reads what the app last wrote.

Timeline strategy

This deliberately differs from the iOS provider, which emits 24 hourly entries (CalendarrTimelineProvider.swift). On the watch, entries are placed on event boundaries: now, plus every event start and end inside the next 24 hours, deduplicated and sorted, with hourly filler so at least 24 hours are covered, and capped at 100 entries. The complication then flips in the exact minute the next appointment changes, with no network call and no refresh budget spent.

Refresh policy is .atEnd. Data changes arrive by push; when the timeline simply runs out it regenerates from the same snapshot, which is cheap and correct.

Countdowns use Text(event.start, style: .timer), which updates live without consuming timeline entries and dims correctly on the always-on display.

Complication catalogue

Eight kinds. Families in brackets.

Kind Families Content
WatchNextEvent rectangular, inline Time, title, location
WatchCornerCountdown corner Value: live countdown. Curved label: "14:30 Zahnarzt"
WatchCornerTime corner Value: start time. Curved label: event title
WatchCountdown circular, inline Remaining time; circular draws a draining ring
WatchDate circular, inline Day number and month
WatchTodayCount circular, inline Number of events today
WatchNextTwo rectangular The next two events, time plus title
WatchDatePlusEvent rectangular Weekday and date plus the next event

WatchDate, WatchTodayCount and WatchCountdown are ports of the existing lock-screen widgets in LockScreenWidgetViews.swift, which already render accessoryCircular, accessoryRectangular and accessoryInline.

Which face shows what

A round Infograph-style face has no rectangular slot: it offers four corners and a circular sub-dial. The rectangular kinds therefore only appear on Modular-family faces. Since the user's face is round, accessoryCorner is the primary slot and gets the most attention; the rectangular kinds still ship because they are cheap and cover the other faces.

accessoryCorner

New and watchOS-only. The curved bezel text comes from .widgetLabel { Text(...) } — WidgetKit lays it along the edge, so no manual arc drawing.

The hard constraint: a corner holds one value plus the curved label. A second inner line runs into the curved text and must not be attempted. Both corner kinds therefore carry a single value and put the context in the label.

Two content variants ship rather than one, because they share the whole data path and differ only in formatting — the user picks on the watch face. A date-in-the-corner variant was considered and dropped: it carries no appointment information, which is the whole point of the complication.

watchOS tints complications monochrome using the watch face's colour, so per-calendar colours are not available in complications. Calendar colours appear in the app only.

Watch app

  • Agenda — a scrolling list grouped by day, day headers, one row per event with a leading bar in the event's colour, time range or all-day label, and title. Covers the trimmed window; days outside snapshot.covers(date) are not rendered as empty but greyed with a note.
  • Event detail — title, time, location, calendar name. Read-only.
  • Calendar filter — one row per calendar from readCalendars(), rendered as a Button with an SF Symbol checkbox (checkmark.square.fill / square) plus the calendar's colour dot. Deliberately not a watchOS Toggle: its switch reads as a state indicator you wait on rather than a control you tap.

The background stays black rather than following snapshot.theme, because the always-on display and OLED make black the right choice on the watch. snapshot.theme is used for accents only.

Calendar filter semantics

Stored in the watch's App Group UserDefaults under watchHiddenCalendarKeys: [String], so the widget extension reads the same value and one toggle affects every complication at once.

The snapshot already excludes hidden and banished calendars, so the watch filter narrows further and never contradicts the phone. CalendarSelectionIntent is ported so a complication can override the app-level filter, but the in-app toggles are the primary path: intent configuration is awkward to operate on the watch face itself.

Error and empty states

SnapshotReadResult already covers most of them. One case is new.

State Display
.neverWritten / nothing received yet "Open Calendarr on your iPhone once"
.loggedOut "Sign in on your iPhone" — never show the previous events
.incompatible "Update the watch app"
outside covers(date) Greyed with a note, never rendered as "nothing scheduled"
Snapshot present but visibly old Show writtenAt as "as of …" rather than implying freshness

The last one has no iPhone equivalent: the phone writes its own cache, so it is never stale in a way the reader cannot see. The watch can hold a snapshot from days ago because the phone was unreachable, and must say so.

iOS background refresh

  • Register BGAppRefreshTaskRequest with identifier com.scarriffleservices.calendarr.ios.refresh in CalendarrApp, rescheduling after every run with earliestBeginDate 30 minutes out.
  • Info.plist needs BGTaskSchedulerPermittedIdentifiers and the fetch background mode.
  • The task fetches the coverage window, calls publishWidgetSnapshot(), and lets the existing call chain reload widgets and push to the watch. It must finish well inside the ~30 s budget and set an expiration handler.

iOS decides when this runs; frequency follows how the user actually opens the app. This improves freshness, it does not guarantee it.

Build, signing and distribution

  • Bundle identifiers: com.scarriffleservices.calendarr.ios.watchkitapp and com.scarriffleservices.calendarr.ios.watchkitapp.complications. The .watchkitapp suffix is required for a companion watch app; WKCompanionAppBundleIdentifier points at the iOS app.
  • Both new targets need com.apple.security.application-groups set to group.com.scarriffleservices.calendarr, unprefixed.
  • No keychain access group on the watch: there is no token there to protect.
  • Register both bundle IDs and enable the App Group for them in the developer portal.
  • App Store Connect: a watch app is not a new app record, it is a new target in the existing one — a normal version submission and a normal review. Watch screenshots are additionally required.
  • The server needs no change at all.

Risks

  1. Xcode does not currently support the test device. Xcode 26.6 ships the watchOS 26.5 SDK; the target watch runs watchOS 27 beta. Installing builds on it needs an Xcode with watchOS 27 support. Code can be written and built against watchOS 26 in the meantime, but on-device verification is blocked until that Xcode is installed. This is the first thing to resolve.
  2. WatchConnectivity background delivery is opportunistic and cannot be verified on this machine — only on a real paired device.
  3. Complication push budget. Path 2 is deliberately rate-limited; if pushes are exhausted, freshness silently degrades to path 1. The stale-snapshot display state is what keeps that honest rather than wrong.
  4. First run is a chicken-and-egg. The watch app can be installed before the phone has ever pushed. The empty state must name the fix rather than look broken.
  5. App review may ask for demo credentials, since the app needs a self-hosted server to show anything.

Verification

Per the project's working rules, this machine builds but does not run apps:

xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch \
  -sdk watchsimulator CODE_SIGNING_ALLOWED=NO build
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build   # CalendarrKit
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test

Behaviour, layout and complication refresh are verified by the user on the device. Nothing about the look is asserted here.

Later, not now

  • Direct HTTPS fetch from the watch, for freshness independent of the phone.
  • Creating events on the watch.
  • Wear OS equivalent.