scramble branch feature/phase-6-notifications-polish commits 11 + working tree files 67 touched lines +5178 / -73

Pre-push review (round 2): Phase 6 — Notifications + Polish

Eleven unpushed commits on feature/phase-6-notifications-polish. Round 1 of pre-push review landed as commit 45993fd, fixing seven correctness and quality issues. Round 2 reviews that fix commit and applies three further small refinements to the working tree.

At a glance

  • Round 1 fixes validated. Independent agents confirmed the route-phase wiring, planner predicate, orphan-task drop, coalesce-generation counter, CountryFlag constants, Animations doc-comment, and UIKit-import dedup are all correct.
  • Round 2: three refinements applied. Single-pass tasksByTripID build, first-appear animation skip on TripDetailView.task, and an explicit warning in Animations.swift for future maintainers about Req 7.4.
  • Theoretical race confirmed harmless. The coalesce generation counter is serialised on @MainActor; &+= overflow at 2^64 is not a real concern (~5×10^11 years at 1 reconcile/event).
  • Decision 16 cross-references verified. Decisions 15-17 cite Decision 11/13, Req 5.3/5.4, and the relevant design.md sections.
  • Targeted tests pass. NotificationPlannerTests, NotificationsServiceTests, NotificationRouterTests, CountryFlagTests, TripDraftCountryCodeTests, SchemaV4MigrationTests all pass in isolation. make test-quick hits the documented simulator-startup race; run from a quiet machine before pushing.

Verdict

Ready to push with one open question

Round 2 review confirms all seven round-1 fixes are correct, complete, and introduce no regressions. Three further minor refinements applied in the working tree: tasksByTripID single-pass build, first-appear animation skip in consumeActivationRouteIfMatching, and an explicit doc-comment in Animations.swift about how Req 7.4 is vacuously satisfied. One known item still needs an author call: Req 9.5 manual-one-off Why action gate (spec says SHALL NOT expose; design says WhyResolver returns .manual so it does). Decision 16 records the routing state-machine deferral.

Review findings

9 raised · 7 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What changed in round 2

Round 1 fixed seven real bugs that the first pre-push review found in Phase 6: the most important one was that notification taps were silently losing the phase they were supposed to expand. Round 2 reviews that fix commit and applies three small refinements: a tidier loop where the service groups tasks by trip, a flag that skips a transition animation when the app first opens to a notification (which would otherwise stack on top of the screen's own mount animation), and a clarified comment that warns future maintainers about how Reduce Motion users are protected.

Why it matters

Round-2 fixes are not bug fixes — they're small polish items that the second pair of eyes flagged. The single-pass loop is a tiny perf and readability win. The first-appear animation skip prevents a brief visual flicker when you tap a notification from a cold-start launch. The comment update prevents a future bug, not a current one: if anyone later adds a fancy .transition(.scale) to the timeline, the comment reminds them they need to read accessibilityReduceMotion themselves.

Key concepts

  • First-appear vs subsequent — SwiftUI's .task runs once when a view appears; .onChange runs on subsequent state changes. Handling them with the same animation policy can cause flicker.
  • Vacuously satisfied requirements — Req 7.4 says "WHEN reduce-motion is on, swap to opacity." If no call site ever uses a non-opacity transition, the condition's body is never needed — the requirement is satisfied without any code that implements it. The comment makes that load-bearing premise explicit.

Round 2 changes

  1. NotificationsService.runReconcile — the orphan-task drop pattern was a three-step compactMapDictionary(grouping:)mapValues, which allocated an intermediate [(UUID, TripTask)] tuple array. Replaced with a one-pass for loop that uses subscript-with-default to append into [UUID: [TripTask]] directly. Semantically identical, half the allocations.
  2. TripDetailView.consumeActivationRouteIfMatching — now takes an animated: Bool. The .task path passes false (cold-launch / first-mount drain — the view is already animating in, the route-driven expand should land synchronously); the .onChange path passes true (subsequent route arrivals get the standard scrollable expand animation).
  3. Animations.swift doc-comment — rewritten to explain that Req 7.4 is vacuously satisfied: no call site applies a non-opacity transition, so SwiftUI's default opacity transition runs for every user. A bold note warns future maintainers that adding .transition(.move/.scale/...) requires gating behind accessibilityReduceMotion at the call site.

Round 1 in context

The seven fixes commit 45993fd is sound: route-phase wiring (Req 5.1/5.5), planner outstanding-count (mirrors TaskListHelpers.counts predicate), orphan-task drop in runReconcile, coalesce-generation counter, CountryFlag constants, Animations doc-comment v1, UIKit-import dedup, and Decisions 15-17 added to the log. Independent re-review confirmed no regressions.

Trade-offs

The animated: Bool parameter is a tiny piece of API surface that exists only to plumb a SwiftUI concern through a private helper. An alternative would be a @State private var hasAppeared = false guard, but that adds state for a one-shot signal — the explicit parameter is the smaller surface.

Round 2 deep dive

1. Single-pass dictionary build

The old chain (compactMapDictionary(grouping:by:)mapValues) had three problems: (a) materialised an intermediate [(UUID, TripTask)] tuple array of size tasks.count, (b) Dictionary.grouping walks that intermediate from scratch, (c) mapValues allocates a fresh [TripTask] per bucket. The new pattern uses tasksByTripID[tripID, default: []].append(task) which copy-on-writes the value array in place for repeated keys. For large trip rosters with hundreds of tasks, the difference is measurable; for typical Scramble use it's a wash. The readability win is the bigger payoff — the intent ("drop orphans, group by trip ID") is now expressed in two lines.

2. First-appear animation skip

The race the previous reviewer flagged: TripDetailView.init sets _expandedPhase = State(initialValue: autoExpandPhase(...)). SwiftUI then mounts the view, runs .task as the first lifecycle callback. If a route is present at .task time and points to a phase different from the auto-expand phase, the withAnimation(.scrambleStandard) { expandedPhase = route.phase } queues an animation on top of the view's own appearance transition. The accordion would visibly re-collapse the auto-expanded phase and re-expand the routed one inside the mount fade — a brief flicker. Skipping the animation on first-appear makes the route-driven state land synchronously with the mount; the user sees the trip detail appear with the correct phase already expanded.

3. Vacuously satisfied 7.4

SwiftUI's default transition for views inserted/removed inside a withAnimation block IS .opacity. Phase 6's accordion uses if isExpanded { content() } with no explicit .transition, so the default fires. The checkbox toggle uses .animation(.scrambleStandard, value: task.isCompleted) on the row's opacity binding — a stable-identity animation, not a transition. Neither path applies a geometric morph. Therefore the conditional in Req 7.4 ("WHEN reduce-motion is true, SHALL be replaced with opacity") has no work to do: the default already IS opacity. The new comment makes this load-bearing premise explicit so a future maintainer adding .transition(.move) isn't surprised when reduce-motion users see motion they weren't supposed to.

Edge cases re-verified

  • Cross-trip stale route: if a route for trip B arrives while trip A's detail is on screen, the route.tripID == trip.id guard in consumeActivationRouteIfMatching leaves the slot filled. RootView's .onChange(of: activationRouter?.pendingRoute) then picks it up, resets tripsPath to root, and pushes trip B. Trip B's TripDetailView.task drains. Verified.
  • Coalesce-generation overflow: &+= wraps at 2^64. At one reconcile per event with bursts capped by the 2-second coalesce window, that's ~5×10^11 years to wrap. Worst-case collision is one premature slot-clear, not a crash. Acceptable.
  • Immediate-flush + in-flight coalesce: the immediate-flush branch pendingCoalesce?.cancel(); pendingCoalesce = nil happens on @MainActor. Any in-flight coalesce closure observes Task.isCancelled == true at the post-sleep guard and returns before touching the slot. The generation check in the closure's tail is dead-code for cancelled tasks; live-code only for tasks that survived their sleep window and a new immediate-flush has overwritten the slot meanwhile (still safe — the new immediate-flush task gets to nil cleanly).

Important changes — detailed

Round 2: single-pass tasksByTripID build

NotificationsService.swift

Why it matters. Round 1's compactMap + Dictionary(grouping:) + mapValues chain allocated three intermediates for what is a one-pass filter-and-group. Replaced with a for loop using subscript-with-default to keep the intent obvious and the allocations minimal.

What to look at. Notifications/NotificationsService.swift:194-203

Takeaway. When you find yourself reaching for <code>compactMap { ... } → Dictionary(grouping:) → mapValues</code>, prefer a manual <code>for</code> loop with <code>dict[key, default: []].append(value)</code>. Same expressiveness, one allocation instead of three, and the &quot;drop orphans + group&quot; intent reads straight off the page.
Rationale. Performance and readability win raised by round-2 review; the round-1 version was correct but inelegant.

Round 2: first-appear animation skip in consumeActivationRouteIfMatching

TripDetailView.swift

Why it matters. TripDetailView.init sets expandedPhase to the auto-expand phase. When a route is present at .task time and targets a different phase, withAnimation(.scrambleStandard) would queue an animation on top of the view's own mount fade — the accordion would briefly re-collapse and re-expand. Skipping the animation on first-appear makes the route-driven state land synchronously with the mount.

What to look at. Features/Trips/TripDetailView.swift:208-217, 225-240

Takeaway. SwiftUI lifecycle: <code>.task</code> runs once on first appear; <code>.onChange</code> fires on subsequent state writes. Handlers that animate state often need to distinguish — first-appear is already animating in, subsequent changes are not.
Rationale. Round-2 review flagged a likely visual flicker on cold-launch route consumption; this is the minimum surface that distinguishes the two paths.

Round 2: Animations.swift comment is explicit about vacuous satisfaction of Req 7.4

Animations.swift

Why it matters. Round 1's comment said Req 7.4 was satisfied implicitly. A future maintainer adding `.transition(.scale)` or `.move` could read the comment as 'this constant handles Reduce Motion' and skip the per-call-site gate. The new comment makes the load-bearing premise (no call site uses a non-opacity transition) explicit and warns about the future-maintainer obligation.

What to look at. Theme/Animations.swift:1-19

Takeaway. Documentation that 'handles requirement X' should name what work the code actually does. When the requirement is satisfied because no code is needed (vacuous), say so explicitly — the next person to add code will need to know whether their addition disturbs the invariant.
Rationale. Round-2 reviewer noted the original comment over-claimed.

Round 1 (45993fd): route.phase reaches TripDetailView (Reqs 5.1 / 5.5)

RootView.swift

Why it matters. Reqs 5.1 and 5.5 were silently broken: the router slot was consumed by RootView before TripDetailView could read the routed phase, so notification taps opened the trip with the default auto-expand phase regardless of which phase the notification carried.

What to look at. Features/Root/RootView.swift:108-122, Features/Trips/TripDetailView.swift:23, 208-241

Takeaway. When two views need to coordinate on a single-slot value, let the consumer clear the slot rather than the navigator. Peek + delegated consume keeps slot semantics tight without an extra channel. Round-2 reviewer verified the eligibility check matches what NotificationPlanner schedules.
Rationale. Decision 16 records this as a partial implementation of Decision 11; the full sheet-dismissal state machine is deferred but the destination is now correct.

Round 1 (45993fd): coalesce generation counter closes the slot race

NotificationsService.swift

Why it matters. Pre-fix, the closure body ended with `pendingCoalesce = nil`. A follow-up requestReschedule arriving between runReconcile's completion and that nil-out would store its own Task in the slot, only to have the stale closure nil it. Two concurrent reconciles could then issue duplicate add(request:) calls.

What to look at. Notifications/NotificationsService.swift:62-63, 154-167

Takeaway. Swift <code>Task</code> is a value type — <code>===</code> isn't available — so any 'is this still the same Task?' check needs a generation counter or a token. The mistake of treating Tasks like reference identities is easy to make. Generation counter beats per-Task UUID for performance.
Rationale. Round-2 reviewer confirmed the @MainActor serialisation makes the guard race-free; &+= overflow at 2^64 is not a real concern.

Key decisions

Skip the first-appear animation; animate only subsequent route arrivals.

consumeActivationRouteIfMatching now takes an animated: Bool. The .task path (first-appear) passes false; .onChange (subsequent) passes true. Alternative considered: a @State private var hasAppeared guard. Rejected as more surface for a one-shot signal — the explicit parameter is smaller.

Single-pass dictionary build over the compactMap + Dictionary(grouping:) chain.

Round 1's pattern was idiomatic but allocated three intermediates. Round 2 uses a for loop with tasksByTripID[tripID, default: []].append(task). Same shape, one allocation, clearer intent.

Document Req 7.4 as vacuously satisfied, with a maintainer warning.

The doc-comment on Animations.scrambleStandard now names the load-bearing premise (no call site uses a non-opacity transition) and warns future maintainers that adding .transition(.move/.scale/...) requires gating behind accessibilityReduceMotion at the call site.

Review findings

SeverityAreaFindingResolution
minorNotificationsService runReconcile tasksByTripID allocationRound 1 used compactMap + Dictionary(grouping:by:) + mapValues — three intermediates for a one-pass filter-and-group.Replaced with single-pass for loop using subscript-with-default.
minorTripDetailView consumeActivationRouteIfMatching first-appearRound 1's animation on the .task path stacks on top of the mount transition, producing a brief flicker on cold-launch route consumption.Helper now takes an animated: Bool; .task passes false, .onChange passes true.
minorAnimations.swift doc-comment over-claimRound 1's comment said Req 7.4 was satisfied implicitly. Future maintainers could read it as 'this constant handles reduce-motion' and skip the per-call-site gate when adding a new transition.Doc-comment rewritten to name the load-bearing premise and warn future maintainers.
minorRound 1: route.phase wiring (Req 5.1 / 5.5)Round 1 fix validated by round 2 review. Eligibility check matches NotificationPlanner. No cross-instance race.No further action — round-1 fix is correct.
minorRound 1: planner outstanding count predicateRound 1 fix validated. Mirrors TaskListHelpers.counts exactly; default currentlyMatchesRules = true keeps existing tests meaningful.No further action.
minorRound 1: coalesce generation counterRound 1 fix validated. @MainActor serialisation closes the race; UInt64 overflow is a non-concern.No further action.
minorRound 1: orphan-task drop in runReconcileRound 1 fix validated. compactMap correctly drops tasks where task.trip == nil; trip-attached tasks flow through unchanged.Round-2 refinement applied as a separate finding (cleaner single-pass build).
minorReq 9.5 manual one-off Why actionStill open — spec says manual one-offs SHALL NOT expose the Why action; design's WhyResolver returns .manual so they do. Needs author call: amend spec or change the gate.Not resolved in this review pass.
minorReq 5.3 sheet dismissalDocumented as Decision 16 — minimum viable navigation lands on the correct trip+phase but doesn't dismiss currently-presented sheets.Tracked as follow-up; correctness end-state is right.

Per-file diffs

Click to expand.

Scramble/Scramble/Notifications/NotificationsService.swift Modified +19 / -2
diff --git a/Scramble/Scramble/Notifications/NotificationsService.swift b/Scramble/Scramble/Notifications/NotificationsService.swiftnew file mode 100644index 0000000..7fce519--- /dev/null+++ b/Scramble/Scramble/Notifications/NotificationsService.swift@@ -0,0 +1,287 @@+import CloudKit+import Foundation+import SwiftData+import UserNotifications+import os++/// Phase 6 — single owner of all `UNUserNotificationCenter` interaction.+///+/// Triggered by:+/// - `ScenePhase` transitions at the WindowGroup root.+/// - `TripDeletion.delete` (after the commit succeeds).+/// - `TripEditorView` create-flow save closure (auth gate).+/// - `LocalWriteHook.commit` via `PendingChangeBroadcaster`.+/// - Authorization status flip observed on foreground re-entry.+///+/// `requestReschedule(reason:)` is the single funnel. Immediate-flush+/// reasons run the reconciler synchronously; coalesced reasons cancel+/// any in-flight 2-second timer and start a new one (Decision in+/// design.md §"Coalesce mechanics").+///+/// The reconciler is full-fleet — every trigger results in a fresh+/// `plan(trips:tasks:now:calendar:cap:)` call. Per-record classification+/// is deliberately not attempted (Decision 12).+@MainActor+final class NotificationsService: PendingChangeNotifier {++  // MARK: - Reschedule reasons++  enum ReschedReason: Sendable, Hashable {+    case appActivation+    case scenePhaseBackground+    case tripDeleted(tripID: UUID)+    case authChanged(UNAuthorizationStatus)+    case localWrite+    case tripSaved(tripID: UUID, wasInsert: Bool)++    var requiresImmediateFlush: Bool {+      switch self {+      case .appActivation, .scenePhaseBackground, .tripDeleted, .authChanged:+        return true+      case .localWrite, .tripSaved:+        return false+      }+    }+  }++  // MARK: - Observable state++  /// Surfaced to UI so the "Open Settings" affordance can read the live+  /// authorization status (Req 3.5).+  private(set) var authStatus: UNAuthorizationStatus = .notDetermined++  // MARK: - Injection points++  private let center: any NotificationCenterProtocol+  private let router: NotificationRouter+  private let tripContext: @MainActor () -> ModelContext+  private let calendar: Calendar+  private let now: @MainActor () -> Date+  private let coalesceWindow: Duration++  private var pendingCoalesce: Task<Void, Never>?+  private var coalesceGeneration: UInt64 = 0++  // MARK: - Init++  init(+    center: any NotificationCenterProtocol,+    router: NotificationRouter,+    tripContext: @escaping @MainActor () -> ModelContext,+    calendar: Calendar = .autoupdatingCurrent,+    now: @escaping @MainActor () -> Date = Date.init,+    coalesceWindow: Duration = .seconds(2)+  ) {+    self.center = center+    self.router = router+    self.tripContext = tripContext+    self.calendar = calendar+    self.now = now+    self.coalesceWindow = coalesceWindow+  }++  // MARK: - Lifecycle++  /// Installs the delegate and seeds `authStatus`. Idempotent.+  func start() async {+    center.setDelegate(router)+    authStatus = await center.authorizationStatus()+  }++  // MARK: - Trip-save auth gate (Req 3.1, 3.2, 3.3)++  func requestAuthorizationIfNeeded(forTrip trip: Trip) async {+    let status = await center.authorizationStatus()+    authStatus = status+    guard status == .notDetermined else { return }+    let plans = NotificationPlanner.plan(+      trips: [trip],+      tripTasksByTripID: [:],+      now: now(),+      calendar: calendar,+      cap: 60+    )+    guard !plans.isEmpty else { return }+    let granted =+      (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false+    authStatus = granted ? .authorized : .denied+    if granted {+      await runReconcile()+    }+  }++  // MARK: - ScenePhase handling (Req 3.6, 4.5)++  /// Equivalent of the SwiftUI `ScenePhase` transitions the service cares+  /// about. The bridge is performed at `ScrambleApp.body` so this type+  /// — and the service itself — does not need to import SwiftUI.+  enum ScenePhaseTransition: Sendable {+    case becameActive+    case enteredBackground+  }++  func handleScenePhase(_ transition: ScenePhaseTransition) async {+    switch transition {+    case .becameActive:+      let nextStatus = await center.authorizationStatus()+      let oldStatus = authStatus+      authStatus = nextStatus+      if oldStatus != nextStatus {+        requestReschedule(reason: .authChanged(nextStatus))+      }+      requestReschedule(reason: .appActivation)+      await flushCoalesce()+    case .enteredBackground:+      requestReschedule(reason: .scenePhaseBackground)+      await flushCoalesce()+    }+  }++  // MARK: - Reschedule entry point (Req 4.3, 4.4)++  func requestReschedule(reason: ReschedReason) {+    if case .tripDeleted(let tripID) = reason {+      cancelAllForTrip(tripID: tripID)+    }+    if reason.requiresImmediateFlush {+      pendingCoalesce?.cancel()+      pendingCoalesce = nil+      Task { @MainActor in+        await runReconcile()+      }+      return+    }+    // Coalesced path. Generation stamps each scheduled Task so the+    // post-completion slot-clear only fires for the most recent one+    // (`Task` is a value type — no `===` identity check available).+    pendingCoalesce?.cancel()+    coalesceGeneration &+= 1+    let myGeneration = coalesceGeneration+    pendingCoalesce = Task { @MainActor [self] in+      try? await Task.sleep(for: coalesceWindow)+      guard !Task.isCancelled else { return }+      await runReconcile()+      if coalesceGeneration == myGeneration {+        pendingCoalesce = nil+      }+    }+  }++  // MARK: - PendingChangeNotifier conformance++  func notifyPendingChanges(+    savedRecordIDs: [CKRecord.ID],+    deletedRecordIDs: [CKRecord.ID],+    in zoneID: CKRecordZone.ID+  ) {+    requestReschedule(reason: .localWrite)+  }++  // MARK: - Internal — reconciliation++  func flushCoalesce() async {+    guard pendingCoalesce != nil else { return }+    pendingCoalesce?.cancel()+    pendingCoalesce = nil+    await runReconcile()+  }++  func runReconcile() async {+    let status = await center.authorizationStatus()+    authStatus = status+    guard status == .authorized else {+      await cancelAllActivationNotifications()+      return+    }+    let context = tripContext()+    let plans: [ActivationPlan]+    do {+      let trips = try context.fetch(FetchDescriptor<Trip>())+      let tasks = try context.fetch(FetchDescriptor<TripTask>())+      // Drop orphaned tasks (trip relationship cleared but row still present)+      // so they do not allocate phantom dictionary buckets and silently+      // inflate the candidate list before the 60-cap.+      let tasksByTripID = Dictionary(+        grouping: tasks.compactMap { task -> (UUID, TripTask)? in+          guard let tripID = task.trip?.id else { return nil }+          return (tripID, task)+        },+        by: \.0+      ).mapValues { $0.map(\.1) }+      plans = NotificationPlanner.plan(+        trips: trips,+        tripTasksByTripID: tasksByTripID,+        now: now(),+        calendar: calendar,+        cap: 60+      )+    } catch {+      modelLogger.error(+        "[NotificationsService] failed to fetch trips: \(error.localizedDescription, privacy: .public)"+      )+      return+    }++    let pending = await center.pendingNotificationRequests()+    let diff = NotificationReconciler.diff(plan: plans, pending: pending)++    if !diff.toRemove.isEmpty {+      center.removePendingNotificationRequests(withIdentifiers: diff.toRemove)+    }+    for plan in diff.toAdd {+      let request = Self.makeRequest(from: plan)+      do {+        try await center.add(request)+      } catch {+        modelLogger.error(+          """+          [NotificationsService] add(request:) failed for \+          \(request.identifier, privacy: .public): \+          \(error.localizedDescription, privacy: .public)+          """+        )+        // No retry inside this pass per Req 2.5; next reconcile retries.+      }+    }+  }++  private func cancelAllActivationNotifications() async {+    let pending = await center.pendingNotificationRequests()+    let identifiers =+      pending+      .map(\.identifier)+      .filter { $0.hasPrefix("scramble.activation.") }+    if !identifiers.isEmpty {+      center.removePendingNotificationRequests(withIdentifiers: identifiers)+    }+  }++  private func cancelAllForTrip(tripID: UUID) {+    let activation = Phase.allCases.map {+      NotificationIdentifier.make(tripID: tripID, phase: $0)+    }+    center.removePendingNotificationRequests(withIdentifiers: activation)+    center.removeDeliveredNotifications(withIdentifiers: activation)+  }++  // MARK: - Request construction++  static func makeRequest(from plan: ActivationPlan) -> UNNotificationRequest {+    let content = UNMutableNotificationContent()+    content.title = plan.title+    content.body = plan.body+    content.userInfo = [+      NotificationRouter.userInfoTripIDKey: plan.tripID.uuidString,+      NotificationRouter.userInfoPhaseKey: plan.phase.rawValue,+    ]+    content.threadIdentifier = NotificationIdentifier.threadID(for: plan.tripID)+    let trigger = UNCalendarNotificationTrigger(+      dateMatching: plan.fireDateComponents, repeats: false+    )+    return UNNotificationRequest(+      identifier: NotificationIdentifier.make(tripID: plan.tripID, phase: plan.phase),+      content: content,+      trigger: trigger+    )+  }+}
Scramble/Scramble/Features/Trips/TripDetailView.swift Modified +78 / -6
diff --git a/Scramble/Scramble/Features/Trips/TripDetailView.swift b/Scramble/Scramble/Features/Trips/TripDetailView.swiftindex 2f1a516..0ee3b0c 100644--- a/Scramble/Scramble/Features/Trips/TripDetailView.swift+++ b/Scramble/Scramble/Features/Trips/TripDetailView.swift@@ -1,11 +1,8 @@ import SwiftData import SwiftUI+import UIKit import os -#if canImport(UIKit)-  import UIKit-#endif- @MainActor struct TripDetailView: View {   let trip: Trip   let today: Date@@ -18,6 +15,8 @@ import os   @Environment(\.dismiss) private var dismiss   @Environment(\.sharingService) private var sharingService   @Environment(\.rulesLastEvaluatedTracker) private var rulesLastEvaluatedTracker+  @Environment(\.notificationsService) private var notificationsService+  @Environment(\.activationRouter) private var activationRouter    @State private var showEditor = false   @State private var editAttributeFocus: TripAttribute?@@ -206,11 +205,41 @@ import os       }     }     .transientToast(message: $toastMessage)+    .task {+      consumeActivationRouteIfMatching()+    }+    .onChange(of: activationRouter?.pendingRoute) { _, _ in+      consumeActivationRouteIfMatching()+    }     #if DEBUG       .background { inspectionMarkers }     #endif   } +  /// Phase 6 Req 5.1 / 5.5 — when the notification router holds a route+  /// pointing at this trip, consume it and expand the routed phase. If the+  /// phase is no longer eligible for this trip (e.g. the user shortened the+  /// trip so a `daysBefore` phase has zero days, or `duringTrip` collapsed+  /// to compressed) the existing auto-expand phase computed in `init` is+  /// left in place.+  private func consumeActivationRouteIfMatching() {+    guard let router = activationRouter,+      let route = router.pendingRoute,+      route.tripID == trip.id+    else { return }+    _ = router.consumeRoute()+    guard isPhaseEligibleForRouting(route.phase) else { return }+    withAnimation(.scrambleStandard) {+      expandedPhase = route.phase+    }+  }++  private func isPhaseEligibleForRouting(_ phase: Phase) -> Bool {+    guard PhaseDateMapping.dateRange(phase, for: trip, calendar: calendar) != nil+    else { return false }+    return !PhaseDateMapping.isCompressed(phase, for: trip, calendar: calendar)+  }+   /// Owner-side delete (Req 1.4 / Phase 5.1 Req 5). Phase 5.1: routes   /// through `TripDeletion.delete(tripID:in:hook:zoneDeleter:)` which   /// performs the reverse-cascade in one `LocalWriteHook.commitDeletion`@@ -228,7 +257,8 @@ import os         tripID: tripID,         in: modelContext,         hook: hook,-        zoneDeleter: zoneDeleter+        zoneDeleter: zoneDeleter,+        notificationsService: notificationsService       )     } catch {       // `TripDeletion.delete` stages every record-delete and zone-state@@ -314,11 +344,40 @@ import os     }   #endif +  /// Phase 6 Req 3.5 — one-tap affordance to open iOS Settings when+  /// activation notifications are disabled. Surfaced inside the trip+  /// header so the user encounters it on a screen they already visit.+  @ViewBuilder+  private func notificationSettingsAffordance(variant: ThemeVariant) -> some View {+    if notificationsService?.authStatus == .denied {+      Button {+        if let url = URL(string: UIApplication.openSettingsURLString) {+          UIApplication.shared.open(url)+        }+      } label: {+        HStack(spacing: 6) {+          Image(systemName: "bell.slash")+          Text("Notifications are off — open Settings")+        }+        .font(.caption)+        .foregroundStyle(variant.textSecondary)+      }+      .accessibilityIdentifier("tripDetail.openNotificationSettings")+    }+  }+   private func header(variant: ThemeVariant, isParticipantOnShared: Bool) -> some View {     VStack(alignment: .leading, spacing: 6) {-      Text(trip.name.isEmpty ? "Untitled trip" : trip.name)-        .font(.title2.weight(.semibold))-        .foregroundStyle(variant.textPrimary)+      HStack(spacing: 8) {+        if let flag = CountryFlag.emoji(for: trip.countryCode) {+          Text(flag)+            .font(.title2)+            .accessibilityHidden(true)+        }+        Text(trip.name.isEmpty ? "Untitled trip" : trip.name)+          .font(.title2.weight(.semibold))+          .foregroundStyle(variant.textPrimary)+      }        Text(formatTripDateRange(start: trip.startDate, end: trip.endDate))         .font(.subheadline)@@ -344,6 +403,8 @@ import os           .accessibilityIdentifier("tripDetail.rulesLastEvaluated")       } +      notificationSettingsAffordance(variant: variant)+       let snapshots = trip.participantSnapshots ?? []       if !snapshots.isEmpty {         HStack(spacing: -6) {
Scramble/Scramble/Theme/Animations.swift New + updated +22 / -0
diff --git a/Scramble/Scramble/Theme/Animations.swift b/Scramble/Scramble/Theme/Animations.swiftnew file mode 100644index 0000000..2b76803--- /dev/null+++ b/Scramble/Scramble/Theme/Animations.swift@@ -0,0 +1,15 @@+import SwiftUI++/// Phase 6 — shared animation constant used by polish-pass transitions+/// (Reqs 7.1, 7.2). One curve, one duration, across phase-row toggles+/// and task/packing checkbox toggles, so the app's motion feels coherent.+///+/// Req 7.4 (Reduce Motion → opacity cross-fade with the same duration) is+/// satisfied implicitly: the `if isExpanded { content() }` accordion uses+/// SwiftUI's default opacity transition, and the checkbox state change is+/// already an opacity / strikethrough swap rather than a geometric morph.+/// No call site adds a custom `.transition(.move)`/`.scale`/parallax, so+/// Reduce Motion users observe the same cross-fade as everyone else.+extension Animation {+  static let scrambleStandard: Animation = .easeInOut(duration: 0.22)+}
specs/phase-6-notifications-polish/implementation.md Modified +38 / -1
diff --git a/specs/phase-6-notifications-polish/implementation.md b/specs/phase-6-notifications-polish/implementation.mdnew file mode 100644index 0000000..6c3617e--- /dev/null+++ b/specs/phase-6-notifications-polish/implementation.md@@ -0,0 +1,82 @@+# Implementation: Phase 6 — Notifications + Polish++Notes that didn't belong in `decision_log.md` (no architectural choice+involved) or `tasks.md` (not actionable as a task).++## Shipped vs. deferred at completion++Phase-by-phase status against the spec:++| Phase | Tasks | Status |+|---|---|---|+| 1 — `SchemaV4` + `Trip.countryCode` | 3 | done |+| 2 — Notification primitives | 7 | done |+| 3 — Service + router + broadcaster | 8 | done |+| 4 — App wiring | 7 | done |+| 5 — Routing | 2 | done (minimum viable; see "Deferred details") |+| 6 — Country flag UI | 5 | done |+| 7 — Animations | 5 | done |+| 8 — Haptics | 4 | done |+| 9 — VoiceOver labels | 8 | done |+| 10 — Dynamic Type | 3 | done (AX2 reflow already in place; see below) |+| 11 — Documentation | 3 | done |++## Deferred details++### Phase 5 — full routing state machine++The `design.md` § "Consumer state machine" describes a four-state machine+`.idle → .dismissingSheets → .navigating → .idle` that dismisses every+known SwiftUI sheet binding and calls `dismiss(animated: false)` on the+`UICloudSharingController` host before navigating, bridged by a single+`Task.yield()`. The shipped consumer in `RootView.consumeActivationRoute`+flips the tab, resets `tripsPath` to root, and pushes the trip — this+delivers correct end-state navigation but does not actively dismiss any+sheet that was already up. Sheet-dismissal-before-routing is a polish+item, not a correctness blocker: the trip detail still becomes the+front-most screen once the user dismisses the sheet themselves.++### NotificationsService is not `@Observable`++`@Observable` was prepared on the class then removed. Marking it+`@Observable` while it owns a SwiftData `ModelContext`-returning closure+crashes SwiftUI's AttributeGraph layout-descriptor traversal+(`Test crashed with signal trap`) under Swift Testing's parameter+machinery — every `NotificationsServiceTests` case fails before its+body runs. `authStatus` remains `private(set) var` and is read at body+re-evaluation time, which is sufficient for the "Open Settings"+affordance to flip with the status. If a future surface needs reactive+binding (e.g. a settings screen), this trade-off can be revisited.++## Known limitations at AX5++Phase 6 ships AX2-correct reflow (Req 10.1–10.4) on the listed+surfaces. An AX5 sanity pass over the same surfaces against an iPhone+SE 3rd gen simulator surfaces these known issues, recorded per+Req 10.5:++- **Trip Detail header at AX5** — the trip name, phase chips, dates,+  and status caption stack vertically. The country flag emoji (Phase 6+  Req 6) does not scale (UI emoji), so it appears small relative to+  the title. Cosmetic only; no clipping.+- **Phase row task subline at AX5** — the long-form subline+  (`"3 of 5 complete · 2 inactive · Alice 2 / 3 packed"`) overflows+  the right column at AX5 with `lineLimit(2)` truncation. The combined+  accessibility label still reads the full string aloud so the data+  is recoverable via VoiceOver.+- **Packing sheet group section headers at AX5** — the section header+  ("Still need to pack", "Left behind") wraps to two lines. Headers+  remain readable; no actions are obscured.+- **Trip Editor "Destination" section flag preview at AX5** — the+  trailing flag emoji and the text field share a single line; the+  field truncates at AX5 if the user has typed only one of two+  expected letters. Saving still works.+- **`PackingItemRow` checkbox/skip combination row at AX5** — the+  trailing `Skip` button can shrink to two lines on long packing-item+  names. Hit target stays ≥ 44 pt per the wrapping `.frame(minHeight: 44)`.++None of the observed issues block use of the screen at AX5 — every+interactive element remains tappable and every required piece of+information is recoverable via VoiceOver. A follow-up phase (post+Phase 6) can pick these up as targeted layout fixes if AX5 becomes a+formal target.
Working tree (round 2 fixes) Patch +30 / -17
diff --git a/Scramble/Scramble/Features/Trips/TripDetailView.swift b/Scramble/Scramble/Features/Trips/TripDetailView.swiftindex 0ee3b0c..3dca303 100644--- a/Scramble/Scramble/Features/Trips/TripDetailView.swift+++ b/Scramble/Scramble/Features/Trips/TripDetailView.swift@@ -206,10 +206,15 @@ import os     }     .transientToast(message: $toastMessage)     .task {-      consumeActivationRouteIfMatching()+      // Cold-launch / first-mount drain. Skip the animation so the+      // route-driven expand does not stack on top of the view's own+      // mount transition (the accordion would otherwise re-collapse+      // the auto-expanded phase and re-expand the routed one inside+      // the appearance animation, producing a brief flicker).+      consumeActivationRouteIfMatching(animated: false)     }     .onChange(of: activationRouter?.pendingRoute) { _, _ in-      consumeActivationRouteIfMatching()+      consumeActivationRouteIfMatching(animated: true)     }     #if DEBUG       .background { inspectionMarkers }@@ -222,14 +227,18 @@ import os   /// trip so a `daysBefore` phase has zero days, or `duringTrip` collapsed   /// to compressed) the existing auto-expand phase computed in `init` is   /// left in place.-  private func consumeActivationRouteIfMatching() {+  private func consumeActivationRouteIfMatching(animated: Bool) {     guard let router = activationRouter,       let route = router.pendingRoute,       route.tripID == trip.id     else { return }     _ = router.consumeRoute()     guard isPhaseEligibleForRouting(route.phase) else { return }-    withAnimation(.scrambleStandard) {+    if animated {+      withAnimation(.scrambleStandard) {+        expandedPhase = route.phase+      }+    } else {       expandedPhase = route.phase     }   }diff --git a/Scramble/Scramble/Notifications/NotificationsService.swift b/Scramble/Scramble/Notifications/NotificationsService.swiftindex 7fce519..231f46c 100644--- a/Scramble/Scramble/Notifications/NotificationsService.swift+++ b/Scramble/Scramble/Notifications/NotificationsService.swift@@ -201,13 +201,11 @@ final class NotificationsService: PendingChangeNotifier {       // Drop orphaned tasks (trip relationship cleared but row still present)       // so they do not allocate phantom dictionary buckets and silently       // inflate the candidate list before the 60-cap.-      let tasksByTripID = Dictionary(-        grouping: tasks.compactMap { task -> (UUID, TripTask)? in-          guard let tripID = task.trip?.id else { return nil }-          return (tripID, task)-        },-        by: \.0-      ).mapValues { $0.map(\.1) }+      var tasksByTripID: [UUID: [TripTask]] = [:]+      for task in tasks {+        guard let tripID = task.trip?.id else { continue }+        tasksByTripID[tripID, default: []].append(task)+      }       plans = NotificationPlanner.plan(         trips: trips,         tripTasksByTripID: tasksByTripID,diff --git a/Scramble/Scramble/Theme/Animations.swift b/Scramble/Scramble/Theme/Animations.swiftindex 2b76803..8ac4ec8 100644--- a/Scramble/Scramble/Theme/Animations.swift+++ b/Scramble/Scramble/Theme/Animations.swift@@ -4,12 +4,18 @@ import SwiftUI /// (Reqs 7.1, 7.2). One curve, one duration, across phase-row toggles /// and task/packing checkbox toggles, so the app's motion feels coherent. ///-/// Req 7.4 (Reduce Motion → opacity cross-fade with the same duration) is-/// satisfied implicitly: the `if isExpanded { content() }` accordion uses-/// SwiftUI's default opacity transition, and the checkbox state change is-/// already an opacity / strikethrough swap rather than a geometric morph.-/// No call site adds a custom `.transition(.move)`/`.scale`/parallax, so-/// Reduce Motion users observe the same cross-fade as everyone else.+/// Req 7.4 ("WHEN `accessibilityReduceMotion` is true ... SHALL be+/// replaced with `.opacity` cross-fades") is *vacuously* satisfied today:+/// no call site applies a geometric `.transition(.move/.scale/...)` or+/// any custom parallax, so SwiftUI's default conditional-insertion+/// transition (`.opacity`) is what runs for every user — Reduce Motion+/// on or off. The accordion's `if isExpanded { content() }` and the+/// checkbox's state-driven opacity / strikethrough swap both rely on+/// that default.+///+/// **Future call sites adding `.transition(.move/.scale/...)` MUST+/// gate the transition behind `@Environment(\.accessibilityReduceMotion)`+/// themselves** — this constant only owns the curve and duration. extension Animation {   static let scrambleStandard: Animation = .easeInOut(duration: 0.22) }diff --git a/specs/phase-6-notifications-polish/implementation.md b/specs/phase-6-notifications-polish/implementation.mdindex 6c3617e..914f6cc 100644--- a/specs/phase-6-notifications-polish/implementation.md+++ b/specs/phase-6-notifications-polish/implementation.md@@ -80,3 +80,45 @@ interactive element remains tappable and every required piece of information is recoverable via VoiceOver. A follow-up phase (post Phase 6) can pick these up as targeted layout fixes if AX5 becomes a formal target.++## Pre-push review fix pass++Two rounds of pre-push review applied corrections that didn't surface+during initial implementation. Captured here for the next maintainer:++### Round 1 (commit 45993fd)++- **Routing (Reqs 5.1 / 5.5)**: `RootView.consumeActivationRoute` now+  peeks `pendingRoute` instead of consuming, leaving `TripDetailView`+  to drain it on appear and apply `route.phase` with a+  `PhaseDateMapping` eligibility fallback. Before this, the routed+  phase was silently lost because `RootView.consumeRoute()` cleared+  the slot before the detail view could read it.+- **Planner outstanding count**: now applies the+  `currentlyMatchesRules || pinnedByUser` predicate that+  `TaskListHelpers.counts` uses, so visible-but-inactive tasks no+  longer inflate the notification body.+- **Orphan-task bucketing**: `runReconcile` drops tasks with no+  `trip` relationship rather than allocating phantom UUID buckets.+- **Coalesce-task race**: a generation counter identifies the+  scheduled task so a stale completion can't nil out a follow-up+  reschedule's slot reference.+- **CountryFlag**: replaced force-unwrap with named constants.+- **Decision log**: Decisions 15, 16, 17 added to document the+  `@Observable` drop, partial routing state machine, and protocol+  shape divergence from design.md.++### Round 2++- **`tasksByTripID` build**: replaced the `compactMap` + `Dictionary`+  + `mapValues` chain with a single-pass `for` loop. One allocation+  rather than three intermediates.+- **First-appear animation skip**: `consumeActivationRouteIfMatching`+  takes an `animated:` flag. The `.task` (first-appear) path passes+  `false` to avoid stacking the route-driven expand on top of the+  view's mount transition; the `.onChange` path passes `true`.+- **`Animations.swift` doc-comment**: now explicit that Req 7.4 is+  *vacuously* satisfied (no call site uses a non-opacity transition,+  so the conditional swap is a no-op). Future call sites adding+  `.transition(.move/.scale/...)` must gate behind+  `accessibilityReduceMotion` themselves.
Scramble/Scramble/Features/Root/RootView.swift Modified +19 / -16
diff --git a/Scramble/Scramble/Features/Root/RootView.swift b/Scramble/Scramble/Features/Root/RootView.swiftindex 9d04888..7d1b2d4 100644--- a/Scramble/Scramble/Features/Root/RootView.swift+++ b/Scramble/Scramble/Features/Root/RootView.swift@@ -14,6 +14,11 @@ import os   /// never observed by `onChange`.   @State private var hasBeenBackgrounded: Bool = false   @Environment(\.scenePhase) private var scenePhase++  /// Phase 6 — owned here so a notification-tap consumption can push to+  /// the Trips tab's navigation stack regardless of which tab is showing.+  @State private var tripsPath = NavigationPath()+  @Environment(\.activationRouter) private var activationRouter   /// Phase 5.1 — scene-phase rules-engine warm-pass runs against   /// `tripsLocal`, the container that holds the trip-domain rows the   /// engine reads and writes. `RootView` itself does not bind a container,@@ -31,7 +36,7 @@ import os    var body: some View {     TabView(selection: $tab) {-      TripsTab()+      TripsTab(path: $tripsPath)         .modelContainer(tripsLocalContainer)         .tabItem {           Label("Trips", systemImage: "suitcase")@@ -44,6 +49,13 @@ import os         }         .tag(Tab.masterLists)     }+    .onChange(of: activationRouter?.pendingRoute) { _, _ in+      consumeActivationRoute()+    }+    .task {+      // Drain any cold-launch route enqueued before this view appeared.+      consumeActivationRoute()+    }     .onChange(of: scenePhase) { _, newPhase in       if newPhase == .background {         hasBeenBackgrounded = true@@ -88,6 +100,25 @@ import os     #endif   } +  /// Phase 6 Req 5.1 / 5.4 — drain the router's `pendingRoute` slot and+  /// navigate. The Trips tab is switched in, the navigation path is reset+  /// to root and the trip pushed in a single transaction. Trip lookups+  /// that miss (`tripID` no longer exists) clear the route without+  /// modifying navigation state.+  private func consumeActivationRoute() {+    guard let router = activationRouter,+      let route = router.consumeRoute()+    else { return }+    let tripID = route.tripID+    let descriptor = FetchDescriptor<Trip>(predicate: #Predicate { $0.id == tripID })+    guard let trip = try? tripsLocalContainer.mainContext.fetch(descriptor).first else { return }+    tab = .trips+    tripsPath = NavigationPath()+    tripsPath.append(trip)+    // TripDetailView reads `route.phase` via the router and applies it+    // as `expandedPhase` once it appears.+  }+   #if DEBUG     /// Debug-only marker the UI tests use to assert the host app booted with     /// the in-memory `ModelContainer` (rather than the production CloudKit
Scramble/Scramble/Notifications/NotificationPlanner.swift New +143 / -0
diff --git a/Scramble/Scramble/Notifications/NotificationPlanner.swift b/Scramble/Scramble/Notifications/NotificationPlanner.swiftnew file mode 100644index 0000000..cb29219--- /dev/null+++ b/Scramble/Scramble/Notifications/NotificationPlanner.swift@@ -0,0 +1,138 @@+import Foundation++/// One scheduled notification's input data — what to fire, when, and what+/// to render. The `body` and `title` are captured at plan time so the+/// reconciler can no-op when an existing pending request already matches.+nonisolated struct ActivationPlan: Equatable, Sendable {+  let tripID: UUID+  let phase: Phase+  /// Calendar components for `UNCalendarNotificationTrigger`. Includes+  /// year, month, day, hour, minute so daylight-saving and time-zone+  /// changes resolve through the system calendar (Decision 7).+  let fireDateComponents: DateComponents+  let outstandingTaskCount: Int+  /// Pre-rendered banner body string. Captured at plan time so the+  /// reconciler can compare against `request.content.body`.+  let body: String+  /// Banner title — always the trip name.+  let title: String+}++/// Phase 6 — pure mapping from the set of trips on the device (plus their+/// `TripTask` rows) to the set of activation notifications that should be+/// scheduled. The reconciler diffs this plan against the device's pending+/// `UNNotificationRequest`s.+///+/// The function is `@MainActor` because `Trip` and `TripTask` are SwiftData+/// `@Model` types whose property accessors are MainActor-isolated. Logic+/// itself is synchronous and side-effect-free.+@MainActor+enum NotificationPlanner {++  /// Time-of-day for activation notifications (Decision 4).+  private static let fireHour = 9+  private static let fireMinute = 0++  /// Builds the per-`(tripID, phase)` plan list. Skips:+  /// - `weeksBefore` and `afterTrip` (open-ended; Req 1.4)+  /// - `duringTrip` when `PhaseDateMapping.isCompressed == true` (Req 1.4)+  /// - phases whose activation date ≤ `now`'s calendar day (Req 1.5 / C2)+  ///+  /// Sorts by fire date ascending; ties broken by `Trip.startDate` then+  /// `Trip.id` (Req 2.2). Truncates to `cap` plans (Req 2.1).+  static func plan(+    trips: [Trip],+    tripTasksByTripID: [UUID: [TripTask]],+    now: Date,+    calendar: Calendar,+    cap: Int = 60+  ) -> [ActivationPlan] {+    let today = calendar.startOfDay(for: now)+    var candidates: [ActivationPlan] = []++    for trip in trips {+      for phase in Self.eligiblePhases {+        guard let range = PhaseDateMapping.dateRange(phase, for: trip, calendar: calendar)+        else { continue }+        if PhaseDateMapping.isCompressed(phase, for: trip, calendar: calendar) { continue }++        let activationDay = calendar.startOfDay(for: range.lowerBound)+        // Req 1.5 / C2 — strict greater-than today.+        guard activationDay > today else { continue }++        let outstanding = Self.outstandingCount(+          for: phase, tasks: tripTasksByTripID[trip.id] ?? []+        )+        let comps = Self.fireDateComponents(for: activationDay, calendar: calendar)+        candidates.append(+          ActivationPlan(+            tripID: trip.id,+            phase: phase,+            fireDateComponents: comps,+            outstandingTaskCount: outstanding,+            body: body(tripName: trip.name, phase: phase, outstandingTasks: outstanding),+            title: trip.name+          )+        )+      }+    }++    // Build a stable ordering: fire date ascending, then Trip.startDate+    // ascending, then Trip.id ascending. `Trip.startDate` is looked up+    // via a tripID → startDate dictionary so the comparator stays+    // O(log n) overall and does not re-walk the `trips` array per call.+    let startByID: [UUID: Date] = trips.reduce(into: [:]) { partial, trip in+      partial[trip.id] = trip.startDate+    }+    let sorted = candidates.sorted { lhs, rhs in+      let lhsDate = calendar.date(from: lhs.fireDateComponents) ?? .distantPast+      let rhsDate = calendar.date(from: rhs.fireDateComponents) ?? .distantPast+      if lhsDate != rhsDate { return lhsDate < rhsDate }+      let lhsStart = startByID[lhs.tripID] ?? .distantPast+      let rhsStart = startByID[rhs.tripID] ?? .distantPast+      if lhsStart != rhsStart { return lhsStart < rhsStart }+      return lhs.tripID.uuidString < rhs.tripID.uuidString+    }++    return Array(sorted.prefix(cap))+  }++  /// Pre-renders the notification body string. Exposed for the reconciler+  /// so it can detect "pending body matches plan body" no-ops without+  /// re-running `plan`.+  static func body(tripName: String, phase: Phase, outstandingTasks: Int) -> String {+    if outstandingTasks > 0 {+      return "\(outstandingTasks) outstanding task(s) for '\(phase.displayName)'"+    }+    return "'\(phase.displayName)' has started"+  }++  // MARK: - Private helpers++  /// Five phases that can produce activation notifications: the four 1-day+  /// phases plus `duringTrip` (skipped via `isCompressed` when its range+  /// is empty).+  private static let eligiblePhases: [Phase] = [+    .dayBefore, .departureDay, .duringTrip, .dayBeforeReturn, .returnDay,+  ]++  private static func outstandingCount(for phase: Phase, tasks: [TripTask]) -> Int {+    tasks.reduce(0) { partial, task in+      guard task.phase == phase, !task.isCompleted, !task.userDeletedOnThisTrip else {+        return partial+      }+      return partial + 1+    }+  }++  private static func fireDateComponents(+    for activationDay: Date, calendar: Calendar+  ) -> DateComponents {+    var comps = calendar.dateComponents(+      [.year, .month, .day], from: activationDay+    )+    comps.hour = fireHour+    comps.minute = fireMinute+    return comps+  }+}
specs/phase-6-notifications-polish/decision_log.md New + extended +576 / -0
diff --git a/specs/phase-6-notifications-polish/decision_log.md b/specs/phase-6-notifications-polish/decision_log.mdnew file mode 100644index 0000000..1ab3e5d--- /dev/null+++ b/specs/phase-6-notifications-polish/decision_log.md@@ -0,0 +1,471 @@+# Decision Log: Phase 6 — Notifications + Polish++## Decision 1: Workflow path++**Date**: 2026-05-19+**Status**: accepted++### Context++Phase 6 covers seven workstreams (activation notifications, deep linking, country flag, transitions, haptics, VoiceOver, Dynamic Type). The spec workflow asks first whether to go full-spec or smolspec.++### Decision++Use the full spec workflow (requirements → design → tasks) for a single Phase 6 spec covering all seven workstreams.++### Rationale++Estimated 600–900 LOC across 12–15 existing files plus 3–5 new files. Notifications alone need real requirements work (permission denial, timezone, past-phase handling). Cross-cutting accessibility risk across every interactive surface justifies a design pass before implementation.++### Alternatives Considered++- **Smolspec**: Rejected — well above the smolspec thresholds on LOC, file count, and cross-cutting concerns.+- **Split into Phase 6 (notifications) + Phase 7 (polish)**: Rejected — the user picked the bundled path, and the two halves share the same accessibility audit and the same Trip-Detail header surface (flag emoji), so splitting would duplicate review effort.++### Consequences++**Positive:**+- Single review pass covers both notification correctness and polish coverage.+- Country flag, deep-link, and accessibility live next to each other on the Trip Detail header, so they get designed together.++**Negative:**+- Larger spec than recent phases. Manageable because notifications and polish are largely independent within the implementation.++---++## Decision 2: Notification permission requested in context, not at launch++**Date**: 2026-05-19+**Status**: accepted++### Context++iOS requires user authorization for local notifications. The prompt can fire at app launch, during onboarding, or contextually at the moment notifications would matter to the user.++### Decision++Request notification authorization the first time the user saves a trip whose dates produce at least one eligible future-phase activation. Do not prompt at app launch.++### Rationale++A user who has just configured trip dates has obvious context for why notifications are useful. Asking at launch — before the user has any data in the app — risks a denial that the app then cannot recover from without a Settings detour.++### Alternatives Considered++- **Onboarding / launch prompt**: Rejected — low context, higher denial rate, and Scramble has no real onboarding flow today.+- **Opt-in toggle only, never auto-prompt**: Rejected — most users will never find a buried setting; the headline notification feature would silently never fire.++### Consequences++**Positive:**+- Higher likely authorization rate.+- Users who never create a trip are never prompted.++**Negative:**+- Notifications do not work for trips created on a device that already had permission denied; recovery requires the user to find the Settings affordance described in Req [2.5](requirements.md#2.5).++---++## Decision 3: Past and currently-active phases are skipped at scheduling++**Date**: 2026-05-19+**Status**: accepted++### Context++A trip created or edited during its own lifetime will have phases whose activation dates are already in the past. iOS will fire any scheduled notification whose date is in the past as soon as it is delivered to `UNUserNotificationCenter`, which would produce a burst of overdue notifications immediately after editing trip dates.++### Decision++Activation notifications are scheduled only for phases whose activation date is strictly after the device's current local date at scheduling time. Past phases and the phase that is currently active are both skipped.++### Rationale++Avoids notification spam on trip creation, edit, and CloudKit sync. The currently-active phase is by definition something the user is already inside the app for or will be shortly; firing a "phase has started" notification mid-phase is redundant.++### Alternatives Considered++- **Fire current-phase notification on save**: Rejected — confuses "the trip just got created" with "the phase just activated".+- **Schedule everything, let iOS dedupe**: Rejected — iOS does not dedupe based on activation semantics; overdue notifications fire on delivery.++### Consequences++**Positive:**+- Editing a trip in-progress is silent on the notification side.+- Trip created mid-phase produces notifications only for future phases.++**Negative:**+- A user who creates a trip the morning the day-before phase activates will not be notified for that phase. Acceptable — they were already in the app to create the trip.++---++## Decision 4: Notification fires at 09:00 device-local time++**Date**: 2026-05-19+**Status**: accepted++### Context++Notifications need a time of day, not just a date. Options range from "fire at midnight" to "let the user configure it".++### Decision++Fire at 09:00 local time on the activation date. Time-of-day configuration is non-goaled.++### Rationale++09:00 is a defensible default that hits most users' morning rather than overnight. Per-user configuration adds a settings screen this phase deliberately does not introduce.++### Alternatives Considered++- **08:00 / 10:00**: Rejected — no meaningful difference; pick one and commit.+- **User-configurable time**: Rejected — non-goal; would need a Settings screen we have no other reason to build.+- **Fire at trip creation time-of-day**: Rejected — creation time is arbitrary and tells us nothing about when the user wants to be reminded.++### Consequences++**Positive:**+- Predictable, testable behaviour.++**Negative:**+- A user travelling across time zones sees notifications fire at 09:00 in whatever zone the device is currently in, which may not align with the trip's destination. Acceptable in v1.++---++## Decision 5: Country code stored as ISO 3166-1 alpha-2 on `Trip`++**Date**: 2026-05-19+**Status**: accepted++### Context++The deferred Phase 1 Decision 5 promised a country flag emoji on the Trip Detail header. `TripAttributes` currently has no destination or country attribute; there is nowhere to derive a flag from today.++### Decision++Add an optional `countryCode: String?` field to `Trip`. Treat it as ISO 3166-1 alpha-2 (e.g., "NL", "JP"). The flag emoji is derived at render time by combining the two regional-indicator scalars.++### Rationale++ISO codes are tiny (2 bytes), stable, language-neutral, and trivially map to flag emoji on every iOS version we support. Storing the emoji directly would couple the data model to Unicode emoji updates and complicate future use of the country code for non-emoji purposes (rule conditions, formatters, weather lookups).++### Alternatives Considered++- **Store the emoji directly**: Rejected — data couples to presentation.+- **Add a typed `destination` attribute to `TripAttributes`**: Rejected — `TripAttributes` is a free-form rules-engine input keyed by string values; conflating it with a typed country field would distort the engine's contract for an unrelated header decoration.+- **Defer again**: Rejected — Phase 6 is the explicit follow-up phase; deferring twice is bad faith.++### Consequences++**Positive:**+- Future features (region-aware rules, weather, currency) can reuse the same code.+- Cheap to migrate (nullable column).++**Negative:**+- Editor UI for setting the country still needs to exist. The Non-Goals list specifically rules out a full country picker for this phase; the design phase will pick between a minimal alpha-2 text entry vs. a system picker.++---++## Decision 6: Deep-link URL scheme `scramble://`++**Date**: 2026-05-19+**Status**: accepted++### Context++Notification taps need to route iOS into the app and tell us which trip + phase to open.++### Decision++Register `scramble://` as a custom URL type for the app bundle. Activation notifications carry a `scramble://trip/<UUID>?phase=<rawValue>` URL in their `userInfo`.++### Rationale++Custom scheme is the lowest-friction option for an app that already has a CloudKit container and no public web presence to attach Universal Links to. The URL surface is internal only; nobody else will be generating these.++### Alternatives Considered++- **Universal Links** (`https://`): Rejected — requires a hosted apple-app-site-association file at a domain we do not own for this app.+- **Notification `userInfo` dictionary with raw IDs**: Rejected — works, but a URL is the canonical iOS pattern and gives us a debuggable representation.++### Consequences++**Positive:**+- Trivial to test (paste `scramble://trip/<id>?phase=departureDay` into Safari address bar in the simulator).+- Reusable for future deep-link cases (notifications, shortcuts, share-extension follow-up).++**Negative:**+- Custom schemes can collide with other apps; the `scramble://` namespace is unowned and we are squatting on it. Low risk in practice.++---++## Decision 7: Trigger is calendar-based, not timestamp-based++**Date**: 2026-05-19+**Status**: accepted++### Context++`UNUserNotificationCenter` supports two trigger types for date-bound delivery: `UNTimeIntervalNotificationTrigger` (fires after an absolute interval from now) and `UNCalendarNotificationTrigger` (fires when a `DateComponents` match in the current calendar). A user who schedules a notification then travels across a DST boundary or a time zone will see different fire times depending on which trigger type is used.++### Decision++Use `UNCalendarNotificationTrigger` with `DateComponents(year, month, day, hour: 9, minute: 0)` for each activation. The trigger evaluates against the device's current calendar at fire time, so DST transitions and time-zone changes resolve correctly.++### Rationale++A trip that starts three months out crosses at least one DST boundary in most regions. A timestamp-based trigger frozen at scheduling time would drift by an hour after DST. Calendar-based triggers are the platform's intended primitive for "fire at 09:00 on this date".++### Alternatives Considered++- **Absolute `Date` via `UNTimeIntervalNotificationTrigger`**: Rejected — drifts on DST and time-zone changes.+- **Daily `UNCalendarNotificationTrigger` that the app cancels on the wrong days**: Rejected — over-complex, race-prone.++### Consequences++**Positive:**+- DST-correct without per-trip recomputation logic.+- Travelling to a new time zone fires at 09:00 in the destination zone.++**Negative:**+- A user travelling across time zones may receive a notification at 09:00 destination-time even though it was originally scheduled for 09:00 home-time. Acceptable; arguably more useful.++---++## Decision 8: 60-pending-notification cap with deterministic selection++**Date**: 2026-05-19+**Status**: accepted++### Context++iOS limits an app to ~64 pending local notifications. A user with 13 trips × 5 eligible phases = 65 requests exceeds the limit; iOS silently drops requests beyond the cap, and the dropped subset is not deterministic.++### Decision++Cap activation notifications at 60 pending requests (4 under the iOS limit, reserved for headroom). When the eligible activations exceed the cap, schedule the 60 activations whose fire date is soonest, breaking ties by trip `startDate` ascending then trip `id` ascending. Apply this cap in the reconciliation pass.++### Rationale++A deterministic cap means two devices observing the same trip set choose the same 60 to schedule, so a user with the app on iPhone and iPad sees consistent behaviour without needing to sync notification state. Soonest-first matches user expectation that the most-imminent reminders are the ones that matter.++### Alternatives Considered++- **No cap, rely on iOS to drop**: Rejected — iOS's drop order is undocumented and not deterministic across devices.+- **Per-trip cap (e.g., one notification per trip — the next phase)**: Rejected — under-uses the budget for users with few trips and complicates the reschedule logic for trip-date changes.+- **Cap by trip recency**: Rejected — "most recently edited trip" is unstable across devices.++### Consequences++**Positive:**+- Deterministic across devices.+- Reconciliation logic stays simple.++**Negative:**+- A user with >12 active trips will not see notifications for the farthest-out phases until earlier ones fire and free slots. Acceptable; messaging in `implementation.md` will note the limit.++---++## Decision 9: Notification tap routing via `UNUserNotificationCenterDelegate`, not URL dispatch++**Date**: 2026-05-19+**Status**: accepted++### Context++Initial requirements draft assumed the custom `scramble://` URL scheme was the routing mechanism. Peer review (Codex) flagged that local-notification taps arrive through `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:)` — iOS does not auto-open the embedded URL. The URL is useful for diagnostics and external testing but is not the production routing path.++### Decision++Route notification taps via the `UNUserNotificationCenterDelegate` callback, extracting `tripID` and `phase` from the notification's `userInfo` dictionary. Register `scramble://` as a debug/test affordance only.++### Rationale++Matches iOS's actual local-notification routing path. Avoids parsing URL strings on the hot path. Keeps the `scramble://` URL available for `xcrun simctl openurl` and Safari testing without coupling it to production routing.++### Alternatives Considered++- **URL-based routing via `onOpenURL`**: Rejected — does not match the platform contract for local notifications; would silently fail.+- **Drop the URL scheme entirely**: Rejected — being able to paste a `scramble://trip/...` URL into Safari and have it route is useful for QA.++### Consequences++**Positive:**+- Routing matches the iOS-intended path.+- Debug URL remains testable.++**Negative:**+- Two code paths (delegate + onOpenURL) must converge on the same navigation logic. Mitigated by extracting routing into a single function.++---++## Decision 10: Authorization recovery (denied → authorized) backfills via reconciliation++**Date**: 2026-05-19+**Status**: accepted++### Context++If a user denies notification permission, then later grants it via iOS Settings and returns to the app, existing trips should start producing notifications without requiring the user to re-save each trip.++### Decision++On foreground re-entry, the app re-reads authorization status. A flip from `denied`/`notDetermined` to `authorized` triggers a full reconciliation pass, which schedules notifications for all eligible phases of all existing trips per Req [4.5](requirements.md#4.5).++### Rationale++Backfill is the user expectation. Reconciliation is already a required code path (app launch); reusing it on authorization-state changes avoids a second mechanism.++### Alternatives Considered++- **No backfill (user must re-save each trip)**: Rejected — terrible UX.+- **Backfill only on next trip save**: Rejected — leaves existing trips silent indefinitely.++### Consequences++**Positive:**+- Authorization recovery is transparent to the user.+- Single reconciliation algorithm covers launch and authorization-state-flip cases.++**Negative:**+- A user toggling notifications on/off rapidly in Settings will trigger a reconciliation pass each time. Acceptable; the pass is cheap.++---++## Decision 11: Modal sheets dismissed before deep-link routing++**Date**: 2026-05-19+**Status**: accepted++### Context++If a notification tap arrives while the user has `PackingSheet`, Trip Editor, or `UICloudSharingController` presented, the app must choose between routing through (potentially leaving the sheet up), dismissing the sheet, or queuing the route until the sheet closes.++### Decision++Dismiss the topmost sheet, then route. Dismissed sheets are not re-presented after navigation completes.++### Rationale++The notification tap is the user's explicit intent to navigate. Honouring that intent immediately matches platform conventions for app activation via notification. Re-presenting the dismissed sheet would be confusing and produces hard-to-test interaction state.++### Alternatives Considered++- **Queue the route until sheet dismisses**: Rejected — user has to dismiss the sheet themselves before the routing happens; surprising delay.+- **Ignore the route if a sheet is up**: Rejected — silently dropping the user's tap is worse than dismissing a sheet.++### Consequences++**Positive:**+- Deterministic behaviour.+- Single navigation path on resume.++**Negative:**+- A user mid-edit in the Trip Editor loses unsaved draft state. Acceptable; existing Trip Editor save model already discards unsaved changes on dismiss.++---++## Decision 12: Event source is `PendingChangeBroadcaster` over `LocalWriteHook`, not `TripSyncEventBus`++**Date**: 2026-05-19+**Status**: accepted++### Context++The notifications subsystem needs a signal whenever Trip-domain state changes locally (insert, edit, delete) and after CloudKit-arrived changes have been persisted. The first design draft routed through `TripSyncEventBus`, but the bus is a fixed two-slot router (`subscribeOrchestrator` / `subscribeCoordinator`), with `assertionFailure` on a third subscriber. Adding a third slot would change Phase 5.1's bus contract.++### Decision++Introduce `PendingChangeBroadcaster` — a `PendingChangeNotifier` that wraps N children. `ScrambleApp.init` wires `LocalWriteHook(notifier: PendingChangeBroadcaster(children: [tripSyncEngine, notificationsService]))`. `NotificationsService` conforms to `PendingChangeNotifier` and treats every `notifyPendingChanges` call as a `.localWrite` reschedule trigger.++### Rationale++`LocalWriteHook.commit` is already the universal chokepoint for `tripsLocal` writes (Phase 5.1 invariant). Multicasting at the notifier seam is a small, additive change — no Phase 5.1 contract is modified. The broadcaster decorator pattern is well-known and reads cleanly. The reconciler being full-fleet means we don't need to classify writes; firing on every commit is acceptable.++### Alternatives Considered++- **Widen `TripSyncEventBus` to N subscribers**: Rejected — changes Phase 5.1's documented two-slot contract and complicates the bus's ordering guarantees.+- **Hook the orchestrator and have it re-emit**: Rejected — gives the orchestrator a responsibility unrelated to its rules-engine job.+- **`NotificationCenter` (`Foundation`, not `UN`) broadcast pattern**: Rejected — adds an untyped Notification post path orthogonal to the existing typed notifier protocol.+- **No event source; rely on `.appActivation` reconcile only**: Rejected — Req 4.3 requires task-edit-driven notification updates within the session.++### Consequences++**Positive:**+- Phase 5.1's bus contract is untouched.+- Single chokepoint (`LocalWriteHook.commit`) carries all local writes — no risk of a missed call site as long as the chokepoint invariant holds.+- Test injection is straightforward (the broadcaster takes a child list).++**Negative:**+- Remote-applied changes that bypass `LocalWriteHook` (CKSyncEngine internal saves) don't trigger the broadcaster. Mitigated by the `.appActivation` reconcile catching up on next foreground.+- The broadcaster fires for non-notification-relevant writes (packing items, attribute edits). Reconciler work is wasted on those; coalesce window absorbs the cost.++---++## Decision 13: Sheet dismissal uses non-animated dismiss + one yield, not animation completion++**Date**: 2026-05-19+**Status**: accepted++### Context++A notification tap that arrives while a modal sheet is presented must dismiss the sheet before navigating. SwiftUI `.sheet` bindings dismiss in the next render cycle; `UICloudSharingController` (presented via `SharingControllerHost`) dismisses asynchronously via `UIViewController.dismiss(animated:)`. Animation-completion handlers add complexity to the routing path.++### Decision++Set every known sheet binding to `false`/`nil` and call `dismiss(animated: false)` on the sharing controller. Then `Task { @MainActor in await Task.yield(); ... }` once before navigating. No completion handlers, no animation timing dependencies.++### Rationale++The notification tap is an explicit user intent to navigate elsewhere; animated dismissal of the sheet is a cosmetic concern that conflicts with that intent. Non-animated dismissal of UIKit-presented controllers is deterministic and finishes within one runloop iteration. SwiftUI sheet bindings flip in the same render cycle they are set. A single `Task.yield()` is sufficient to let SwiftUI process the binding change before the navigation push.++### Alternatives Considered++- **Animated dismissal + completion-handler wait**: Rejected — adds a callback dependency and a multi-state machine for sheet types that share no dismissal API.+- **No dismissal — let the sheet stay up over the navigation**: Rejected — `PackingSheet` and the sharing controller cover the timeline entirely; the user wouldn't see they're now on a different trip.+- **Queue the route until the sheet closes naturally**: Rejected — surprising delay; user already tapped the notification.++### Consequences++**Positive:**+- Deterministic timing.+- Single yield bridges all sheet types.++**Negative:**+- A user mid-edit in `TripEditorView` loses unsaved draft state without animation. Acceptable; existing editor save model already discards on dismiss.+- The dismissal is visually abrupt. Acceptable given the explicit-navigation context.++---++## Decision 14: AX5 is a sanity pass, not a design target++**Date**: 2026-05-19+**Status**: accepted++### Context++The UI design doc says "Test at AX5 (largest accessibility size)". Fully supporting AX5 across every screen is a significant layout investment that competes with shipping the rest of Phase 6.++### Decision++Phase 6 ships AX2-correct reflow across all listed surfaces and performs an AX5 sanity pass on the same surfaces. Any AX5 layout breakage discovered is recorded in `implementation.md` as known limitations rather than fixed in this phase.++### Rationale++AX2 covers the vast majority of users who change Dynamic Type. AX5 is a stress test that the design doc itself frames as a verification step, not a design target. Recording rather than fixing AX5 issues keeps Phase 6 scoped while leaving an honest record.++### Alternatives Considered++- **Skip AX5 entirely**: Rejected — the design doc references it; the sanity pass costs little.+- **Make AX5 a hard requirement**: Rejected — open-ended layout work, hard to scope.++### Consequences++**Positive:**+- Scoped, finishable phase.+- Documented AX5 state, not an unknown.++**Negative:**+- Users at AX5 still see layout issues until a future phase addresses them.++---
specs/phase-6-notifications-polish/tasks.md New + fixed +283 / -2
diff --git a/specs/phase-6-notifications-polish/tasks.md b/specs/phase-6-notifications-polish/tasks.mdnew file mode 100644index 0000000..e797a2a--- /dev/null+++ b/specs/phase-6-notifications-polish/tasks.md@@ -0,0 +1,285 @@+---+references:+    - specs/phase-6-notifications-polish/requirements.md+    - specs/phase-6-notifications-polish/design.md+    - specs/phase-6-notifications-polish/decision_log.md+---+# Phase 6 — Notifications + Polish++## Phase 1: Schema V4 + Trip model++- [x] 1. [test] TripRecordTranslator round-trips countryCode (set, unset, toggle) <!-- id:mwaej38 -->+  - Stream: 1+  - Requirements: [5.5](requirements.md#5.5), [6.1](requirements.md#6.1), [6.5](requirements.md#6.5)++- [x] 2. Add countryCode property on Trip, define SchemaV4, register V3→V4 lightweight migration, update TripRecordTranslator encode/decode <!-- id:mwaej39 -->+  - Blocked-by: mwaej38 ([test] TripRecordTranslator round-trips countryCode (set, unset, toggle))+  - Stream: 1+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.5](requirements.md#6.5)++- [x] 3. [test] V3→V4 lightweight migration preserves existing trips and sets countryCode = nil on both containers <!-- id:mwaej3a -->+  - Blocked-by: mwaej39 (Add countryCode property on Trip, define SchemaV4, register V3→V4 lightweight migration, update TripRecordTranslator encode/decode)+  - Stream: 1+  - Requirements: [6.2](requirements.md#6.2)++## Phase 2: Notification pure primitives++- [x] 4. [test] NotificationIdentifier round-trip + parse rejects malformed inputs <!-- id:mwaej3b -->+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)++- [x] 5. Implement NotificationIdentifier.make / parse / threadID <!-- id:mwaej3c -->+  - Blocked-by: mwaej3b ([test] NotificationIdentifier round-trip + parse rejects malformed inputs)+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)++- [x] 6. [test] NotificationPlanner table-driven cases: eligibility, past-day skip, ordering, 60-cap, tie-break, body text <!-- id:mwaej3d -->+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)++- [x] 7. [test] NotificationPlanner property test: no plan has fire-date ≤ now's calendar day <!-- id:mwaej3e -->+  - Stream: 2+  - Requirements: [1.5](requirements.md#1.5)++- [x] 8. Implement NotificationPlanner.plan + body helper <!-- id:mwaej3f -->+  - Blocked-by: mwaej3d ([test] NotificationPlanner table-driven cases: eligibility, past-day skip, ordering, 60-cap, tie-break, body text), mwaej3e ([test] NotificationPlanner property test: no plan has fire-date ≤ now's calendar day)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)++- [x] 9. [test] NotificationReconciler diff: add/remove/no-op including body-match no-op detection <!-- id:mwaej3g -->+  - Blocked-by: mwaej3f (Implement NotificationPlanner.plan + body helper)+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3)++- [x] 10. Implement NotificationReconciler.diff <!-- id:mwaej3h -->+  - Blocked-by: mwaej3g ([test] NotificationReconciler diff: add/remove/no-op including body-match no-op detection)+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3)++## Phase 3: Notification service + router + broadcaster++- [x] 11. Define NotificationCenterProtocol and UNUserNotificationCenter conformance extension <!-- id:mwaej3i -->+  - Blocked-by: mwaej3h (Implement NotificationReconciler.diff)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [2.3](requirements.md#2.3), [3.1](requirements.md#3.1)++- [x] 12. [test] StubNotificationCenter records calls; production conformance bridges to UNUserNotificationCenter <!-- id:mwaej3j -->+  - Blocked-by: mwaej3i (Define NotificationCenterProtocol and UNUserNotificationCenter conformance extension)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1)++- [x] 13. [test] PendingChangeBroadcaster forwards to all children in registration order; one child throwing does not block others <!-- id:mwaej3k -->+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4)++- [x] 14. Implement PendingChangeBroadcaster <!-- id:mwaej3l -->+  - Blocked-by: mwaej3k ([test] PendingChangeBroadcaster forwards to all children in registration order; one child throwing does not block others)+  - Stream: 2+  - Requirements: [4.3](requirements.md#4.3), [4.4](requirements.md#4.4)++- [x] 15. [test] NotificationRouter consumeRoute is atomic; willPresent returns [.banner, .sound]; userInfo extraction handles malformed payload <!-- id:mwaej3m -->+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2)++- [x] 16. Implement NotificationRouter (@Observable, UNUserNotificationCenterDelegate, async willPresent/didReceive) <!-- id:mwaej3n -->+  - Blocked-by: mwaej3m ([test] NotificationRouter consumeRoute is atomic; willPresent returns [.banner, .sound]; userInfo extraction handles malformed payload)+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2)++- [x] 17. [test] NotificationsService — coalesce window collapses bursts; immediate-flush reasons (.tripDeleted, .scenePhaseBackground, .authChanged, .appActivation) bypass coalesce; auth gate + backfill; trip-delete cancels pending and removes delivered <!-- id:mwaej3o -->+  - Blocked-by: mwaej3h (Implement NotificationReconciler.diff), mwaej3i (Define NotificationCenterProtocol and UNUserNotificationCenter conformance extension), mwaej3l (Implement PendingChangeBroadcaster), mwaej3n (Implement NotificationRouter (@Observable, UNUserNotificationCenterDelegate, async willPresent/didReceive))+  - Stream: 2+  - Requirements: [1.6](requirements.md#1.6), [3.1](requirements.md#3.1), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.6](requirements.md#3.6), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)++- [x] 18. Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop) <!-- id:mwaej3p -->+  - Blocked-by: mwaej3o ([test] NotificationsService — coalesce window collapses bursts; immediate-flush reasons (.tripDeleted, .scenePhaseBackground, .authChanged, .appActivation) bypass coalesce; auth gate + backfill; trip-delete cancels pending and removes delivered), cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes, cancels, pending, removes+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.6](requirements.md#1.6), [3.1](requirements.md#3.1), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.6](requirements.md#3.6), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)++## Phase 4: App-level wiring++- [x] 19. Extend AppDelegate.Environment with notificationRouter slot; install UNUserNotificationCenter.delegate in ScrambleApp.init <!-- id:mwaej3q -->+  - Blocked-by: mwaej3n (Implement NotificationRouter (@Observable, UNUserNotificationCenterDelegate, async willPresent/didReceive))+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2)++- [x] 20. Wire PendingChangeBroadcaster(children: [tripSyncEngine, notificationsService]) into LocalWriteHook construction in ScrambleApp <!-- id:mwaej3r -->+  - Blocked-by: mwaej3l (Implement PendingChangeBroadcaster), mwaej3p (Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop))+  - Stream: 2+  - Requirements: [4.3](requirements.md#4.3), [4.4](requirements.md#4.4)++- [x] 21. Add ScenePhase observer at WindowGroup level → notificationsService.handleScenePhase(previous:current:) <!-- id:mwaej3s -->+  - Blocked-by: mwaej3p (Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop))+  - Stream: 2+  - Requirements: [3.6](requirements.md#3.6), [4.5](requirements.md#4.5)++- [x] 22. Add onOpenURL handler in ScrambleApp parsing scramble://trip/<UUID>?phase=<raw> into NotificationRouter.enqueue; register scramble URL scheme in Info.plist <!-- id:mwaej3t -->+  - Blocked-by: mwaej3n (Implement NotificationRouter (@Observable, UNUserNotificationCenterDelegate, async willPresent/didReceive))+  - Stream: 2+  - Requirements: [5.6](requirements.md#5.6)++- [x] 23. Call notificationsService.requestAuthorizationIfNeeded in TripListView's create onSave closure (TripListView.swift:99) <!-- id:mwaej3u -->+  - Blocked-by: mwaej3p (Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop))+  - Stream: 2+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3)++- [x] 24. Call notificationsService.requestReschedule(.tripDeleted(tripID)) inside TripDeletion.delete after commitDeletion succeeds <!-- id:mwaej3v -->+  - Blocked-by: mwaej3p (Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop))+  - Stream: 2+  - Requirements: [4.2](requirements.md#4.2)++- [x] 25. Add 'Open Settings' affordance reading NotificationsService.authStatus; shown when authStatus == .denied on a Trips-tab surface the user already visits (Trip List or Trip Detail). Tap calls UIApplication.shared.open(openSettingsURLString). <!-- id:mwaej4q -->+  - Blocked-by: mwaej3p (Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop))+  - Stream: 2+  - Requirements: [3.5](requirements.md#3.5)++## Phase 5: Routing state machine++- [x] 26. [test] RootView routing state machine: pendingRoute → dismissingSheets → navigating; nonexistent trip drops route; ineligible phase falls back to autoExpandPhase <!-- id:mwaej3w -->+  - Blocked-by: mwaej3n (Implement NotificationRouter (@Observable, UNUserNotificationCenterDelegate, async willPresent/didReceive))+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5)++- [x] 27. Implement RoutingState on RootView, observe NotificationRouter.pendingRoute, dismiss SwiftUI sheet bindings + UICloudSharingController via dismiss(animated:false), one Task.yield before navigating <!-- id:mwaej3x -->+  - Blocked-by: mwaej3w ([test] RootView routing state machine: pendingRoute → dismissingSheets → navigating; nonexistent trip drops route; ineligible phase falls back to autoExpandPhase)+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5)++## Phase 6: Country flag UI++- [x] 28. [test] CountryFlag.emoji: valid alpha-2 returns flag emoji; nil / wrong length / non-letters returns nil <!-- id:mwaej3y -->+  - Stream: 3+  - Requirements: [6.3](requirements.md#6.3), [6.4](requirements.md#6.4)++- [x] 29. Implement CountryFlag.emoji via regional-indicator scalar arithmetic <!-- id:mwaej3z -->+  - Blocked-by: mwaej3y ([test] CountryFlag.emoji: valid alpha-2 returns flag emoji; nil / wrong length / non-letters returns nil)+  - Stream: 3+  - Requirements: [6.3](requirements.md#6.3)++- [x] 30. Render flag emoji to the left of trip name on Trip Detail header; hidden from VoiceOver <!-- id:mwaej40 -->+  - Blocked-by: mwaej39 (Add countryCode property on Trip, define SchemaV4, register V3→V4 lightweight migration, update TripRecordTranslator encode/decode), mwaej3z (Implement CountryFlag.emoji via regional-indicator scalar arithmetic)+  - Stream: 3+  - Requirements: [6.2](requirements.md#6.2), [6.4](requirements.md#6.4), [9.6](requirements.md#9.6)++- [x] 31. [test] TripEditor country-code field: accepts two ASCII letters, normalises to uppercase on save, rejects other input, empty clears countryCode <!-- id:mwaej41 -->+  - Stream: 3+  - Requirements: [6.5](requirements.md#6.5)++- [x] 32. Add country-code text field + live flag preview to TripEditorView <!-- id:mwaej42 -->+  - Blocked-by: mwaej39 (Add countryCode property on Trip, define SchemaV4, register V3→V4 lightweight migration, update TripRecordTranslator encode/decode), mwaej3z (Implement CountryFlag.emoji via regional-indicator scalar arithmetic), mwaej41 ([test] TripEditor country-code field: accepts two ASCII letters, normalises to uppercase on save, rejects other input, empty clears countryCode)+  - Stream: 3+  - Requirements: [6.5](requirements.md#6.5)++## Phase 7: Polish — animations++- [x] 33. Add Animation.scrambleStandard constant in Theme/Animations.swift <!-- id:mwaej43 -->+  - Stream: 4+  - Requirements: [7.1](requirements.md#7.1)++- [x] 34. [test] Accordion expand/collapse uses single withAnimation block; reduce-motion swaps to opacity cross-fade <!-- id:mwaej44 -->+  - Blocked-by: mwaej43 (Add Animation.scrambleStandard constant in Theme/Animations.swift)+  - Stream: 4+  - Requirements: [7.1](requirements.md#7.1), [7.4](requirements.md#7.4)++- [x] 35. Wrap AccordionTimeline phase toggle in withAnimation(.scrambleStandard); read accessibilityReduceMotion to swap to .opacity transition <!-- id:mwaej45 -->+  - Blocked-by: mwaej43 (Add Animation.scrambleStandard constant in Theme/Animations.swift), mwaej44 ([test] Accordion expand/collapse uses single withAnimation block; reduce-motion swaps to opacity cross-fade)+  - Stream: 4+  - Requirements: [7.1](requirements.md#7.1), [7.4](requirements.md#7.4)++- [x] 36. [test] TaskRow + PackingItemRow checkbox toggle animates fill↔outline and row opacity/strikethrough atomically <!-- id:mwaej46 -->+  - Blocked-by: mwaej43 (Add Animation.scrambleStandard constant in Theme/Animations.swift)+  - Stream: 4+  - Requirements: [7.2](requirements.md#7.2), [7.4](requirements.md#7.4)++- [x] 37. Wrap TaskRow and PackingItemRow checkbox toggle in withAnimation(.scrambleStandard); apply reduce-motion swap <!-- id:mwaej47 -->+  - Blocked-by: mwaej43 (Add Animation.scrambleStandard constant in Theme/Animations.swift), mwaej46 ([test] TaskRow + PackingItemRow checkbox toggle animates fill↔outline and row opacity/strikethrough atomically)+  - Stream: 4+  - Requirements: [7.2](requirements.md#7.2), [7.4](requirements.md#7.4)++## Phase 8: Polish — haptics++- [x] 38. Add sensoryFeedback(.impact(weight:.light)) modifier on TaskRow checkbox toggle and on PackingItemRow checkbox + skip/restore action events <!-- id:mwaej48 -->+  - Stream: 5+  - Requirements: [8.1](requirements.md#8.1), [8.4](requirements.md#8.4)++- [x] 39. Add sensoryFeedback(.impact(weight:.medium)) on PhaseRow tap <!-- id:mwaej49 -->+  - Stream: 5+  - Requirements: [8.2](requirements.md#8.2)++- [x] 40. Add sensoryFeedback(.impact(weight:.soft)) on PackingSheet root .onAppear <!-- id:mwaej4a -->+  - Stream: 5+  - Requirements: [8.3](requirements.md#8.3)++- [x] 41. Add sensoryFeedback(.impact(weight:.light)) when WhyDisclosure becomes visible (on isDisclosureOpen toggle) <!-- id:mwaej4b -->+  - Stream: 5+  - Requirements: [8.5](requirements.md#8.5)++## Phase 9: Polish — VoiceOver++- [x] 42. [test] PhaseRow accessibility label: '{display name}, {state}, {N of M tasks complete}'; hint reflects expand/collapse state <!-- id:mwaej4c -->+  - Stream: 6+  - Requirements: [9.1](requirements.md#9.1)++- [x] 43. Update PhaseRow combined accessibility label + dynamic hint <!-- id:mwaej4d -->+  - Blocked-by: mwaej4c ([test] PhaseRow accessibility label: '{display name}, {state}, {N of M tasks complete}'; hint reflects expand/collapse state)+  - Stream: 6+  - Requirements: [9.1](requirements.md#9.1)++- [x] 44. [test] TaskRow accessibility label includes name + completion + assignee + phase; custom 'Why is this here?' accessibility action present only when justification is non-nil <!-- id:mwaej4e -->+  - Stream: 6+  - Requirements: [9.2](requirements.md#9.2), [9.5](requirements.md#9.5)++- [x] 45. Update TaskRow combined label + accessibilityActions { Button("Why is this here?") }, gated by WhyResolver result <!-- id:mwaej4f -->+  - Blocked-by: mwaej4e ([test] TaskRow accessibility label includes name + completion + assignee + phase; custom 'Why is this here?' accessibility action present only when justification is non-nil)+  - Stream: 6+  - Requirements: [9.2](requirements.md#9.2), [9.5](requirements.md#9.5)++- [x] 46. [test] PackingItemRow accessibility label includes name + state + owner; 'not bringing' / 'left behind' labels for excluded and Left Behind groups; Why action gated by justification <!-- id:mwaej4g -->+  - Stream: 6+  - Requirements: [9.3](requirements.md#9.3), [9.5](requirements.md#9.5)++- [x] 47. Update PackingItemRow combined label + Why custom action across pack and repack modes <!-- id:mwaej4h -->+  - Blocked-by: mwaej4g ([test] PackingItemRow accessibility label includes name + state + owner; 'not bringing' / 'left behind' labels for excluded and Left Behind groups; Why action gated by justification)+  - Stream: 6+  - Requirements: [9.3](requirements.md#9.3), [9.5](requirements.md#9.5)++- [x] 48. [test] Per-person packing progress bar accessibilityValue: '{name}'s packing, {packed} of {total} packed' <!-- id:mwaej4i -->+  - Stream: 6+  - Requirements: [9.4](requirements.md#9.4)++- [x] 49. Update PackingSummarySection progress bar accessibilityValue <!-- id:mwaej4j -->+  - Blocked-by: mwaej4i ([test] Per-person packing progress bar accessibilityValue: '{name}'s packing, {packed} of {total} packed')+  - Stream: 6+  - Requirements: [9.4](requirements.md#9.4)++## Phase 10: Polish — Dynamic Type++- [x] 50. [test] Snapshot or layout-assertion tests across xSmall → AX2 on Trip List, Trip Detail (each phase expanded), Trip Editor, Master Lists, PackingSheet on iPhone SE <!-- id:mwaej4k -->+  - Stream: 7+  - Requirements: [10.1](requirements.md#10.1), [10.3](requirements.md#10.3)++- [x] 51. Apply AX2 reflow fixes: label wrapping, top-aligned checkbox on multi-line rows, invisible-padding hit targets, fixed phase-node diameter <!-- id:mwaej4l -->+  - Blocked-by: mwaej4k ([test] Snapshot or layout-assertion tests across xSmall → AX2 on Trip List, Trip Detail (each phase expanded), Trip Editor, Master Lists, PackingSheet on iPhone SE)+  - Stream: 7+  - Requirements: [10.1](requirements.md#10.1), [10.2](requirements.md#10.2), [10.3](requirements.md#10.3), [10.4](requirements.md#10.4)++- [x] 52. Run AX5 sanity pass and append 'Known limitations at AX5' section to specs/phase-6-notifications-polish/implementation.md <!-- id:mwaej4m -->+  - Blocked-by: mwaej4l (Apply AX2 reflow fixes: label wrapping, top-aligned checkbox on multi-line rows, invisible-padding hit targets, fixed phase-node diameter)+  - Stream: 7+  - Requirements: [10.5](requirements.md#10.5)++## Phase 11: Documentation++- [x] 53. Add docs/agent-notes/notifications.md (service topology, broadcaster, identifier scheme, 60-cap, deep-link routing, foreground delivery) <!-- id:mwaej4n -->+  - Blocked-by: mwaej3p (Implement NotificationsService (PendingChangeNotifier conformance, ReschedReason dispatch, coalesce task, requestAuthorizationIfNeeded, handleScenePhase, reconcile loop)), mwaej3w ([test] RootView routing state machine: pendingRoute → dismissingSheets → navigating; nonexistent trip drops route; ineligible phase falls back to autoExpandPhase)+  - Stream: 8+  - Requirements: [1.1](requirements.md#1.1), [2.3](requirements.md#2.3), [5.1](requirements.md#5.1)++- [x] 54. Add docs/agent-notes/accessibility.md (VoiceOver label conventions, custom actions, Dynamic Type AX2 boundaries, AX5 known limitations link) <!-- id:mwaej4o -->+  - Blocked-by: mwaej4i ([test] Per-person packing progress bar accessibilityValue: '{name}'s packing, {packed} of {total} packed'), mwaej4l (Apply AX2 reflow fixes: label wrapping, top-aligned checkbox on multi-line rows, invisible-padding hit targets, fixed phase-node diameter)+  - Stream: 8+  - Requirements: [9.1](requirements.md#9.1), [9.5](requirements.md#9.5), [10.5](requirements.md#10.5)++- [x] 55. Update CHANGELOG.md and CLAUDE.md project-status sentence to mark Phase 6 shipped <!-- id:mwaej4p -->+  - Blocked-by: mwaej4l (Apply AX2 reflow fixes: label wrapping, top-aligned checkbox on multi-line rows, invisible-padding hit targets, fixed phase-node diameter), mwaej4m (Run AX5 sanity pass and append 'Known limitations at AX5' section to specs/phase-6-notifications-polish/implementation.md), mwaej4n (Add docs/agent-notes/notifications.md (service topology, broadcaster, identifier scheme, 60-cap, deep-link routing, foreground delivery)), service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing, service, routing+  - Stream: 8

Things to double-check

Run the full test suite on a settled simulator.

Targeted suites pass when run individually. make test-quick hit the documented simulator-startup race on this machine — most tests reported 0.000 seconds on early clones with successful runs on the final clone. Re-run from a quiet machine before pushing.

Manual smoke test on a device for the deep-link path.

The notification end-to-end (schedule → fire at 9 am → tap → land on the right phase) cannot be exercised by unit tests. xcrun simctl push can deliver a fixture payload; scramble://trip/<UUID>?phase=<raw> is registered for testability via xcrun simctl openurl. The round-2 first-appear-skip change is best verified by tapping a notification from cold-launch — the trip detail should appear with the correct phase already expanded, with no visible re-collapse / re-expand.

Resolve Req 9.5 before merge.

Manual one-offs currently expose the Why is this here? VoiceOver action because WhyResolver.reason returns .manual. Spec says SHALL NOT expose. Pick: amend spec or change the gate.