# RemindWall Active Phases Phase N1 (NFC scan reliability) completed 2026-07-03 → swept to PLAN_ARCHIVE.md. Context that outlives N1: screen-off findings from the scan audit are LIVE on current deploys (ScreenOffMonitor works on the iPads and the unsandboxed Mac) — a scan during the off-window renders its overlay on a dark panel, and scanning a late reminder at night triggers re-dim within 0-35s of the tap. N1.11's sounds cover the feedback gap; wake-on-scan sits in Backlog until T1 lands displayOn(). ## Phase T1 - Sandboxed Mac TestFlight via local DDC daemon Why this phase: TestFlight for Mac hard-rejects unsandboxed builds at upload (ITMS-90296, before any human review), and no exception entitlement gets a DDC path through the sandbox — every shipping DDC app (MonitorControl, Lunar, BetterDisplay) distributes outside the store for exactly this reason. So the DDC work moves OUT of the app into `ddcd/`, a small Rust daemon living as a new top-level crate in this repo; the sandboxed app talks to it over 127.0.0.1 (needs only `network.client`, which bus arrivals already requires). Every device stays on TestFlight's update pipeline, and the daemon rides launchd next to the other servers already on the kiosk box. NSUserUnixTask was the first candidate and IS workable (audit prototype proved runtime reachability from Catalyst and review-cleanliness), but it taxes every call with bash-watchdog + ObjC-runtime glue: no timeout/kill API on a hung task, execute-at-most-once instances that crash uncatchably on reuse, exit statuses collapsed into a localized string, parallel XPC execution colliding on the single-master I2C bus. The daemon turns all of that into ordinary server code (URLSession timeout, a mutex, HTTP statuses, KeepAlive). Review note: App Review's machines won't have the daemon, so the app must present fully functional without it — availability gating covers this. Honest cost of the TestFlight route on an always-on kiosk: builds EXPIRE after 90 days, and an expired build refuses to relaunch — a crash past day 90 with no fresh build shipped means a dead kiosk (the iPads already live on this treadmill, so it's a known cadence, but the Mac's KeepAlive relaunch agent turns "app crashed" into "app relaunched with an expired build" if the treadmill slips). Last-resort fallback: Developer ID + notarization (sandbox optional, but loses TestFlight updates). The daemon also unblocks TRUE monitor off/on — the original goal, tried three times before (git: IODisplayConnect via dlsym, dead API on Apple Silicon; in-process IOAVService, flagged by App Review; m1ddc spawn, luminance only). The new option: OS-level display sleep (`pmset displaysleepnow`) with an OS-level wake (IOPMAssertionDeclareUserActivity) — the panel enters real standby on signal loss and wakes on signal return, so the wake path never depends on a DDC command reaching a sleeping monitor. Ordering constraint either way: DDC is dead while the panel sleeps, so wake -> wait for the AV service -> reassert luminance. T1.2 probes the kiosk monitor to pick the mechanism. - [x] T1.1 - `ddcd/` Rust crate: axum bound to 127.0.0.1 (configurable port — the box also runs hotchkiss.io and friends), GET /health, GET /brightness, PUT /brightness (0.0-1.0); require a custom request header + reject CORS preflight (localhost is NOT a trust boundary on a box serving public traffic — this kills browser CSRF outright); m1ddc invocations behind a mutex with a per-call process timeout (~5s kill); unit tests against a fake m1ddc - [x] T1.2 - Hardware probe on the kiosk monitor (decides T1.3's mechanism): (a) `pmset displaysleepnow` + `caffeinate -u -t 2` wake — verify unprivileged, panel truly off, wake latency; (b) VCP D6 standby via m1ddc + DDC wake — does the monitor ACK while asleep; measure how long the AV service takes to return after wake either way - v1 run (2026-07-03, Hearthstone / LG HDR 4K over USB-C): route (b) UNAVAILABLE — brew m1ddc 1.2.0 has no `set power` (HEAD-only feature, probe falsely reported it passing); DDC READS corrupted (luminance -51 / max 62); test (a) visually unconfirmed. Probe v2 adds exit-code honesty, a read-stability check, a sleep-transition DDC timeline + captured eyeball observations — re-run 2026-07-03: B confirmed (visible off+on via SSH, unprivileged); reads 100% corrupted even with RemindWall quit -> Mac path goes WRITE-ONLY (T1.13); DDC ACKs throughout sleep so no wake-wait needed - [x] T1.13 - Restore-to-configured-level: ScreenOffMonitor's restore target must never come from a DDC read-back — the field's corrupted -51 read clamps to brightness 0 and the "restore" turns the screen OFF; reads become advisory/diagnostic only (live bug in today's deploy, likely why screen-off only works "mostly okay"). Shipped as the write-only state machine: isDimmed flips only on CONFIRMED write sequences, ticks re-issue the failed direction, saved level used only when a read succeeded (iOS), 1.0 fallback otherwise - [x] T1.3 - Display power endpoints: PUT /display on|off using the probe winner; wake sequence = assert user activity -> poll AV service back -> reassert luminance - [x] T1.4 - Daemon hardening: cache max luminance at startup (validate 1...1000, re-probe on failure), retry-with-backoff when the DCP AV service drops (panel sleep does this by design now), structured logs to stdout for launchd capture - [x] T1.5 - Kiosk install: LaunchAgent plist with KeepAlive (user session — the box is always on and unlocked, and IOPMAssertionDeclareUserActivity wants the GUI session; no LaunchDaemon/root needed) + a second KeepAlive agent relaunching RemindWall itself (no reboots means login items never re-fire; an app crash otherwise leaves the kiosk dead until someone notices) + install doc + `brew pin m1ddc` (stable v1.2.0 is the known-good; HEAD builds Apr 2025-Jun 2026 read max luminance from the wrong byte) - [x] T1.6 - ScreenControl rework: replace M1DDCBrightness/posix_spawn with a URLSession client (request timeout, Result-based errors — kill the `?? 1.0` failure masking that breaks restore), add displayOn/displayOff alongside brightness, os.Logger for postmortems; client tests against a stubbed daemon - [x] T1.7 - ScreenOffMonitor: off-window uses true display off on the Mac (brightness path stays for iOS), restore honors the wake ordering; in-flight guard (cancelInFlight on the work id) so a slow call can't stack ticks. Availability-consult dropped deliberately: ops THROW when the daemon's down and failed transitions self-retry next tick, so a per-tick /health round-trip adds nothing - [x] T1.8 - Availability rework: available = /health reachable, re-checked periodically (no forever-negative cache); fix the Settings caption (still says `brew install m1ddc`) - [x] T1.9 - Enable App Sandbox on the Mac target + companion entitlements (network.client, personal-information.photos-library, personal-information.calendars; smartcard already present — the old device.usb/disable-library-validation were libnfc-era, not needed) - [ ] T1.10 - Container migration check: CloudKit re-sync covers the DB; verify @Shared(.appStorage) settings survive or document the one-time kiosk reconfigure - Happens at first sandboxed launch on Hearthstone (testing night of 2026-07-03). Expect: album/schedule/calendar/bus-window need re-picking; trackees/reminders/stops return via CloudKit; bus API key may survive (iCloud Keychain) - [ ] T1.11 - Upload to TestFlight internal: processing passes clean (no ITMS-90296/90338), end-to-end kiosk test (scan, off-window true-off + late-reminder force-on, calendar, photos) - Upload + processing PASSED 2026-07-03 (both ITMS gates cleared - first sandboxed Mac build accepted). Kiosk e2e pending: reconfigure settings, bootstrap io.hotchkiss.remindwall-keepalive AFTER confirming launch, audio output = built-in speakers, watch /tmp/ddcd.log overnight (off-window true-off + late-reminder force-on) - [x] T1.12 - CI lane for `ddcd/` — Xcode Cloud only builds the Swift side, so cargo test + clippy need their own hook (GitHub Actions or a ci_scripts addition) ## Phase R1 - Soft-disable a trackee's reminders Why this phase: someone goes on a trip or pauses a med for a week, and today the only way to stop the dashboard nagging "Bob is late for meds" is to DELETE Bob — which throws away every reminder time and NFC-tag mapping, then you rebuild it all by hand when they're back. Add a per-trackee soft-disable that keeps the rows. Scope decision (chotchki, 2026-07-08): disable is a per-trackee flag (`Trackee.remindersEnabled`), not per-reminder-time — "someone's reminders" = the whole person. It gates the late-alert surface ONLY: a disabled trackee never appears in `AlertLoader.lateTrackeeNames`. The tag-scan path stays UNTOUCHED — scanning a disabled trackee's tag still credits the dose and shows the green "Thank you" (the scan is the "did they do it" surface, not the "nag" surface, and recording a real tap is never wrong). Hence the name `remindersEnabled`, not `isActive` — scans are not gated, only the reminders/nagging is. CloudKit note: `remindersEnabled` is a new column on the already-synced `trackees` table. Old CKRecords lack the field; the local `DEFAULT 1` + struct default cover the gap so a trackee synced from a pre-R1 device reads back enabled. No backfill needed. - [x] R1.1 - Schema: add `remindersEnabled: Bool = true` to `Trackee` (init param defaulted last so every existing `Trackee(id:name:)` site still compiles) + a `"Add remindersEnabled to trackees"` migration (`ALTER TABLE "trackees" ADD COLUMN "remindersEnabled" INTEGER NOT NULL DEFAULT 1`); DaoTests cover the default - [x] R1.2 - AlertLoader: drop late reminders whose trackee is disabled — intersect `lateTrackeeIds` with the enabled trackees before mapping to names; test proves a late-but-disabled trackee stays out of the alert - [x] R1.3 - TrackeeDetail: a "Reminders enabled" toggle at the top of the form that writes the flag through `defaultDatabase` (mirror RemindersFeature's `withErrorReporting`+write pattern); footer states scans still work; reducer test on the write + state update - [x] R1.4 - Trackees list: an explicit status badge per row — "Active" (green) / "Paused" (orange) capsule — so a paused trackee pops in a list of active ones without opening the detail - [ ] R1.5 - `swift test` green (288 passing, incl. 6 new: Dao default+toggle, AlertLoader single + per-trackee filter, disabled-tag-still-scans, TrackeeDetail disable + re-enable, new-trackee-defaults-enabled) — DONE; sweep R1 to PLAN_ARCHIVE.md after in-app confirmation on the next kiosk session (toggle flips the dashboard nag off; migration lands cleanly on the live CloudKit-synced DB). TestFlight build kicked off 2026-07-08. ## Phase S1 - Settings replication across devices Why this phase: the DB tables (trackees, reminders, monitored stops) ride CloudKit, but every `@Shared(.appStorage)` setting — album, calendar, screen-off schedule, bus window, bus toggle — is stranded in per-device UserDefaults. Every new device (and every container migration, see T1.10) means re-picking all five by hand. The fix has been sitting in the schema since the bus phase: the `Setting` key/value table (key, value, lastModified) is registered with SyncEngine and has ZERO readers or writers today — activate it. Two classes of setting: portable values (schedule, window, toggle) sync as raw strings; device-local identifiers do NOT travel (albumId is a PHAssetCollection localIdentifier, calendarId an EKCalendar identifier — both are per-device even when the underlying album/calendar is the same iCloud object), so those sync a portable descriptor (album title / calendar title+source) that each device resolves locally. Bus API key needs nothing — it already rides iCloud Keychain. - [x] S1.1 - `.syncedSetting(key)` shared key: custom SharedKey backed by the Setting table through defaultDatabase (read = fetchOne by key, write = upsert with lastModified = now, observation so a CloudKit-synced remote write updates live UI); last-writer-wins on lastModified; unit tests: round-trip, later-write-wins, external-write observation - [x] S1.2 - Migrate the portable trio (screenOffSchedule, busWindow, busAlertsEnabled) to `.syncedSetting`; one-time seed from the existing appStorage value when the Setting row is absent, so the configured kiosk migrates itself with nothing to re-pick - [x] S1.3 - Portable descriptors for the non-portable pair: alongside albumId sync the album title, alongside calendarId the calendar title+source; each device resolves descriptor -> local identifier at launch/pick and caches it; an unresolvable descriptor surfaces "needs re-pick" in Settings instead of a silently blank slideshow/calendar - [ ] S1.4 - swift test green + multi-device e2e: change the schedule on one iPad, watch it land on the second iPad and the Mac; fresh-install e2e confirms a new device picks up everything except the local album/calendar resolve ## Phase H1 - HomeKit low-battery alerts Why this phase: HomeKit accessories die silently — a sensor drops off the network and nobody notices until the automation it fed stops working. The wall dashboard is exactly where "front door lock at 15%" belongs. HomeKit ships on Catalyst 14+, but the kiosk Mac now runs sandboxed TestFlight builds (T1), and T1 taught us processing gates bite without warning — so the entitlement ride-along gets probed FIRST, not discovered at upload. - [x] H1.1 - Probe: HMHomeManager on kiosk Mac — ANSWERED 2026-08-01: TestFlight processing passed AND the sandboxed Catalyst build enumerates the full home (browser photographed live on the wall panel; doorbell reporting 0% while hardwired -> H1.7's ignore list). Verdict: the Mac renders battery chips, not iPads-only. (Capability gotcha for the record: had to land on com.RemindWall.RemindWallCloud, not com.remindwall.) - [x] H1.2 - `HomeKitAsync/` wrapper (mirrors CalendarAsync): @DependencyClient enumerating accessories exposing the battery service -> [BatteryStatus] (accessory name, room, level %, statusLowBattery flag); auth status surface; mock testValue - [x] H1.3 - Settings: master toggle + threshold % (alert = statusLowBattery set OR level below threshold), stored via S1's `.syncedSetting`; section mirrors the Bus Alerts template - [x] H1.4 - `BatteryAlertsFeature` on the dashboard: gentle poll (batteries move over days — 30 min, not seconds); provisional chip rendering, final placement lands in D1; reducer tests on threshold math + poll loop - [ ] H1.5 - swift test green + kiosk e2e (pull a battery or pick a genuinely-low sensor, watch the chip appear) - [x] H1.6 - Battery window + discovered-batteries browser (decided at TR1.4 review): battery stays its own alert with a synced `batteryWindow` gating when chips show; settings gains a browser listing every battery-service accessory found (name, room, level, low flag) with refresh — HomeKit battery reporting is non-standard, see what the house actually says before trusting thresholds - [x] H1.7 - Ignore specific battery devices (found in live testing 2026-08-01: a hardwired door reports 0% battery): synced ignore list keyed by accessory NAME (HomeKit uniqueIdentifiers are per-device, names are shared home data and unique per home); ignore/unignore from the browser rows, ignored accessories stay listed but dimmed and never chip - [x] H1.8 - Battery details move to a sub-screen (live feedback 2026-08-01: the browser's ~15 rows blow up the settings form): the Battery Alerts section shrinks to its header toggle + one summary row (threshold · window-or-always · ignored count) that opens the full details (threshold, window editor, browser) in a sheet, mirroring the Alerts Setup pattern; window summary text moves to AlertWindow in AppTypes so Watch rows and battery share it ## Phase D1 - Dashboard surface redesign (design first) Why this phase: the bottom edge is turning into a pile-up. Today: NowView top-left, UpNextView + BusArrivalsBar stacked at the bottom, AlertView overlaying everything, scan feedback on top of that — and H1 battery chips + TR1 traffic alerts are inbound with nowhere to go. Every widget got placed ad hoc when it landed; there is no priority model deciding what deserves the screen when several fire at once, on a display read from across the room. This phase is DESIGN FIRST — no implementation until the spec survives review. - [x] D1.1 - SPEC-DASHBOARD.md: inventory every surface current + planned (med overlay, calendar now/up-next, bus, battery, traffic, scan feedback); define priority tiers (emergency overlay / time-sensitive rail / ambient) and the interruption rules between them; gating rules (windows, screen-off interplay); type scale for across-the-room readability; max simultaneous cards + overflow behavior - [x] D1.2 - Mockups for 2-3 candidate layouts — REVIEW GATE with chotchki; nothing below this line starts until a candidate is picked - [x] D1.3 - Shared card model: features contribute typed cards (content, tier, gating), the dashboard renders a priority-ordered rail; unit tests on ordering + overflow - [x] D1.4 - Migrate calendar and bus surfaces onto the card model; med AlertView keeps its overlay-everything semantics (a missed dose still outranks the world). Battery has no surface to migrate yet — H1.4 lands directly on the model (AmbientChip) - [ ] D1.5 - swift test green + on-wall verification at viewing distance, iPad and Mac panel both - [x] D1.6 - NowView joins the D1 design language: ambient capsule, RailScale type, emoji-aware — old white banner retired (found on-wall at event start) ## Phase TR1 - Traffic / drive-time alerts Why this phase: same job as bus arrivals but for the car — "leaving for X now takes 40 min, that's 15 over normal". Data source decision up front: recommendation is MKDirections.calculateETA — traffic-aware expectedTravelTime, no API key to manage, no rate-limit contract to babysit (a TrafficAPIClient dependency keeps the door open for HERE/Google if MapKit's ETAs prove stale). MapKit returns no free-flow baseline, so "late" = ETA exceeds a user-entered normal duration by a threshold — same philosophy as the bus late-badge, set per route at config time. Origin is the house (fixed coordinate, configured once), so no CoreLocation permission dance. Renders through D1's card model — D1 lands first. ## Backlog - Wake the screen during scan overlays — post-T1 this is one ScreenControl.displayOn() call from TagScanLoader feedback (iPads: restore UIScreen.brightness); pairs with N1.11's beep so scans register even mid-wake - Harden slot subscription against a nil `slotNamed(_:)` (async `getSlot(withName:)` + rebuild on failure) — audit refuted the transient-nil trigger but the pipeline is one nil from dead-until-replug - `associatedTag` uniqueness constraint (schema migration) — N1.9 fixes the read side only - Sound policy for per-tap DB errors: `.error` is deliberately silent (a dead reader on the 30s backoff cycle must not buzz all night), so a DB-write failure in the dark gives no audio — the absent success ding is the only signal. Splitting infrastructure vs per-tap error cases would fix it; conscious tradeoff for now - Test seam for the decode path: N1.4's retry-while-validCard and N1.3's muteCard pipeline branch have no unit coverage — needs a protocol abstraction over TKSmartCardSlot/TKSmartCard to fake card behavior - [x] TR1.1 - Generalize the window type: BusWindow's weekday-mask + time-range encoding becomes shared `AlertWindow` in AppTypes (typealias keeps BusWindow call sites and the stored "busWindow" string valid); traffic gets its own window via `.syncedSetting` - [x] TR1.2 - `TrafficAPI/` target: @DependencyClient over MKDirections.calculateETA (origin, destination) -> ETA; typed errors; testValue stubs — mirrors TransitAPI minus the key store - [x] TR1.3 - `MonitoredRoute` @Table + migration + SyncEngine registration: label, destination lat/lon + display name, normalMinutes, sortOrder; DaoTests cover insert/fetch/delete - [x] TR1.4 - Unified transit-alerts settings BUILD — sketch APPROVED 2026-08-01 (per-watch windows + per-watch toggles, no master switch; battery separate, see H1.6): one Alerts watch-list where bus stops and driving routes are peers (each row = what/where/when + toggle), watch detail with the shared window editor, add-alert flow (type -> bus stop lookup / MKLocalSearch destination + normal-minutes), Setup subscreen holding API key + test connection + home origin - [x] TR1.5 - `TrafficAlertsFeature`: poll ~5 min inside the window, one card per route via D1's model, late styling when ETA > normal + threshold; reducer tests on window gating, late math and the error chip - [ ] TR1.6 - swift test green + live e2e during a real rush hour - [x] TR1.7 - Per-watch windows + toggles (decided at TR1.4 review): `window` (nullable AlertWindow, nil = default) and `enabled` columns on MonitoredStop AND MonitoredRoute; BusArrivals/TrafficAlerts gate each watch by its own window+toggle; seed stop windows/enabled from legacy busWindow/busAlertsEnabled (settings row or appStorage) so the kiosk upgrades itself; legacy per-mode keys retire with TR1.4's UI