From 0bd87c83c9f5b5239b82abc836cafc02b44543fa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 13:20:33 -0700 Subject: [PATCH 1/3] Reconcile badge/issue count after a backup import BackupCoordinator.importBackup only republished the widget snapshot after committing, so the app-icon (home-screen) badge and the issues-to-resolve notification kept their pre-import values until an unrelated reconcile trigger. Wire the scanner + reminder + issue-alert reconcilers into BackupCoordinator and, after an import commits, run the same post-write fan-out DayJournal uses (scanner invalidate -> reminders/issueAlerts reconcile -> widget publish). Plan steps: wire-coordinator, wire-services. --- .../WhereCore/Sources/BackupCoordinator.swift | 31 ++++++++++++++++++- Where/WhereCore/Sources/WhereServices.swift | 8 ++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/Sources/BackupCoordinator.swift b/Where/WhereCore/Sources/BackupCoordinator.swift index 887b8f75..ebae1b10 100644 --- a/Where/WhereCore/Sources/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/BackupCoordinator.swift @@ -42,6 +42,14 @@ public actor BackupCoordinator { private let store: any WhereStore private let backupService = BackupService() private let widgets: WidgetSnapshotPublisher + /// Shared scanner behind the badge/notification issue count. Dropped inline + /// after an import commits so the reconciles below recount from fresh data + /// rather than racing the scanner's async store-change invalidation. + private let issueScanner: DataIssueScanner + /// App-icon badge (missing-day backlog + unresolved-issue count). + private let reminders: ReminderReconciler + /// "Issues to resolve" notification. + private let issueAlerts: DataIssueAlertReconciler private static let logger = WhereLog.channel(.backupService) /// Staging directory of the most recent export. Each archive lands in its @@ -52,9 +60,18 @@ public actor BackupCoordinator { /// the export being torn down. private var previousExportDirectory: URL? - init(store: any WhereStore, widgets: WidgetSnapshotPublisher) { + init( + store: any WhereStore, + widgets: WidgetSnapshotPublisher, + issueScanner: DataIssueScanner, + reminders: ReminderReconciler, + issueAlerts: DataIssueAlertReconciler, + ) { self.store = store self.widgets = widgets + self.issueScanner = issueScanner + self.reminders = reminders + self.issueAlerts = issueAlerts } /// Serialize the entire store (all four tables plus evidence blobs) to a @@ -166,6 +183,18 @@ public actor BackupCoordinator { report() } } + // An import rewrites day data, so reconcile exactly like a `DayJournal` + // day change: drop the scanner cache inline (so the reconciles below + // recount fresh rather than racing its async store-change + // invalidation), then reconcile the app-icon badge + issues + // notification off the new count and republish the widget snapshot. + // These headless reconcilers don't observe `store.changes()`, so + // without this an import leaves the home-screen badge and the issues + // alert stuck at their pre-import values. Keep this fan-out in sync with + // `DayJournal.reconcileAfterDayChange()`. + await issueScanner.invalidate() + await reminders.reconcile() + await issueAlerts.reconcile() await widgets.publish() return ImportSummary( diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index b9baa085..3be94aa5 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -155,7 +155,13 @@ public struct WhereServices: Sendable { issueScanner: resolution, widgets: widgets, ) - let backup = BackupCoordinator(store: store, widgets: widgets) + let backup = BackupCoordinator( + store: store, + widgets: widgets, + issueScanner: resolution, + reminders: reminders, + issueAlerts: issueAlerts, + ) let recentActivity = RecentActivitySummarizer( store: store, attributor: attributor, From 107817e24d3db95186b09b71b0485c4b8e835676 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 13:20:38 -0700 Subject: [PATCH 2/3] Guard badge refresh after a backup import Add a BackupCoordinator regression test and a WhereServices end-to-end test proving a replace import recomputes the app-icon badge off the imported data instead of leaving the stale pre-import count. Plan steps: test-coordinator, test-e2e. --- .../Tests/BackupCoordinatorTests.swift | 146 +++++++++++++++++- .../WhereCore/Tests/WhereServicesTests.swift | 46 ++++++ 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 0e6ce3e9..4a5cd5b6 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -10,6 +10,9 @@ struct BackupCoordinatorTests { let coordinator: BackupCoordinator let store: SwiftDataStore let widgets: SpyRefresher + let reminders: ReminderReconciler + let reminderScheduler: SpyReminderScheduler + let issueAlertScheduler: SpyIssueAlertScheduler } private actor SpyRefresher: WidgetTimelineRefreshing { @@ -19,7 +22,50 @@ struct BackupCoordinatorTests { } } - private static func makeHarness() throws -> Harness { + /// Records the app-icon badge reconciles so a test can assert an import + /// recomputes the badge off the imported data. + private actor SpyReminderScheduler: LoggingReminderScheduling { + private(set) var reconcileCount = 0 + private(set) var lastBadgeCount: Int? + func requestAuthorization() async -> Bool { + true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile( + badgeCount: Int, + scheduleDays _: [Date], + reminderTime _: ReminderTime, + enabled _: Bool, + ) async { + reconcileCount += 1 + lastBadgeCount = badgeCount + } + } + + /// Records the issue-alert reconciles so a test can assert an import + /// refreshes the "issues to resolve" notification. + private actor SpyIssueAlertScheduler: DataIssueAlertScheduling { + private(set) var reconcileCount = 0 + func requestAuthorization() async -> Bool { + true + } + + func isAuthorized() async -> Bool { + true + } + + func reconcile(enabled _: Bool, time _: ReminderTime, body _: String) async { + reconcileCount += 1 + } + } + + private static func makeHarness( + now: Date = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00"), + ) throws -> Harness { let store = try SwiftDataStore.inMemory() let aggregator = DayAggregator( calendar: WhereCoreTestSupport.calendar(), @@ -34,11 +80,47 @@ struct BackupCoordinatorTests { ), widgetRefresher: refresher, attributor: .shared, - calendar: WhereCoreTestSupport.calendar(), - now: { Date() }, + calendar: aggregator.calendar, + now: { now }, + ) + let reports = ReportReader(store: store, aggregator: aggregator, attributor: .shared) + let scanner = DataIssueScanner( + reportReader: reports, + attributor: .shared, + calendar: aggregator.calendar, + now: { now }, + storeChanges: store.changes(), + ) + let reminderScheduler = SpyReminderScheduler() + let reminders = ReminderReconciler( + scheduler: reminderScheduler, + reportReader: reports, + issueScanner: scanner, + calendar: aggregator.calendar, + now: { now }, + ) + let issueAlertScheduler = SpyIssueAlertScheduler() + let issueAlerts = DataIssueAlertReconciler( + scheduler: issueAlertScheduler, + scanner: scanner, + calendar: aggregator.calendar, + now: { now }, + ) + let coordinator = BackupCoordinator( + store: store, + widgets: widgets, + issueScanner: scanner, + reminders: reminders, + issueAlerts: issueAlerts, + ) + return Harness( + coordinator: coordinator, + store: store, + widgets: refresher, + reminders: reminders, + reminderScheduler: reminderScheduler, + issueAlertScheduler: issueAlertScheduler, ) - let coordinator = BackupCoordinator(store: store, widgets: widgets) - return Harness(coordinator: coordinator, store: store, widgets: refresher) } private static let evidence = Evidence( @@ -146,6 +228,60 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) } + /// Regression guard: an import rewrites day data, so it must reconcile the + /// app-icon badge and the issues-to-resolve notification off the fresh data + /// rather than leaving them at their pre-import values (the "home-screen + /// badge stuck at 157 after replace import" bug). The headless reconcilers + /// don't observe `store.changes()`, so the import has to drive them. + @Test func replaceImportReconcilesTheBadgeOffTheImportedData() async throws { + // Frozen at Jan 5, so the backlog window is just Jan 1–4. + let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") + + // Source covers the whole backlog window, so it exports a fully-logged + // start of year (no missing days, no unresolved issues). + let source = try Self.makeHarness(now: now) + try await source.store.perform { + for iso in [ + "2026-01-01T12:00:00-08:00", + "2026-01-02T12:00:00-08:00", + "2026-01-03T12:00:00-08:00", + "2026-01-04T12:00:00-08:00", + ] { + try await source.store.setManualDay(DayPresence( + date: WhereCoreTestSupport.iso(iso), + in: WhereCoreTestSupport.calendar(), + regions: [.california], + )) + } + } + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + // Destination enables reminders + issue alerts while empty, so its badge + // starts at the fully-missing-year count (Jan 1–4 backlog + one + // missing-days range). + let destination = try Self.makeHarness(now: now) + await destination.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: true, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) + let emptyBadge = await destination.reminderScheduler.lastBadgeCount + let remindersBeforeImport = await destination.reminderScheduler.reconcileCount + let issueAlertsBeforeImport = await destination.issueAlertScheduler.reconcileCount + + _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) + + // Both headless reconcilers ran on the import. + #expect(await destination.reminderScheduler.reconcileCount > remindersBeforeImport) + #expect(await destination.issueAlertScheduler.reconcileCount > issueAlertsBeforeImport) + // The badge was recomputed off the imported data — backlog + one missing + // range while empty, dropping to zero once Jan 1–4 are all logged. + #expect(emptyBadge == 5) + #expect(await destination.reminderScheduler.lastBadgeCount == 0) + } + /// The coordinator owns the export staging directory's lifecycle: starting a /// new export purges the previous one, so at most one archive sits on disk. @Test func exportPurgesThePreviousExportDirectory() async throws { diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index ee63a522..0d4d8efe 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -620,6 +620,52 @@ struct WhereServicesTests { #expect(try await destinationStore.allManualDays() == sourceStore.allManualDays()) } + /// End-to-end guard for the "home-screen badge stuck after a replace import" + /// bug: importing new data must reconcile the app-icon badge off the fresh + /// data through the assembled services, not leave the pre-import count. + @Test func backupReplaceImportRefreshesTheAppIconBadge() async throws { + // Frozen at Jan 5, so the backlog window is just Jan 1–4. + let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") + + // Source logs the whole backlog window, exporting a fully-covered start + // of year. + let (source, _, _) = try Self.makeReminderServices( + now: now, + scheduler: SpyReminderScheduler(), + ) + try await source.journal.addManualDays( + from: WhereCoreTestSupport.iso("2026-01-01T12:00:00-08:00"), + through: WhereCoreTestSupport.iso("2026-01-04T12:00:00-08:00"), + regions: [.california], + audit: nil, + ) + let url = try await source.backup.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + // Destination enables reminders + issue alerts while empty, so its badge + // starts at the fully-missing-year count (Jan 1–4 backlog + one + // missing-days range). + let spy = SpyReminderScheduler() + let (destination, _, _) = try Self.makeReminderServices(now: now, scheduler: spy) + await destination.reminders.configure( + enabled: true, + time: .defaultEvening, + issueAlertsEnabled: true, + driftThresholdMeters: Double(DriftThreshold.default.rawValue), + ) + let emptyBadge = await spy.lastBadgeCount + let reconcilesBeforeImport = await spy.reconcileCount + + _ = try await destination.backup.importBackup(from: url, strategy: .replace) + + // The import reconciled the badge off the imported data instead of + // leaving the stale empty-store count: Jan 1–4 are now logged with no + // unresolved issues, so the badge drops from 5 to 0. + #expect(await spy.reconcileCount > reconcilesBeforeImport) + #expect(emptyBadge == 5) + #expect(await spy.lastBadgeCount == 0) + } + @Test func clearAll_removesEveryTable() async throws { let store = try SwiftDataStore.inMemory() let seedSample = sample(at: "2026-03-15T12:00:00-07:00") From 6fab57df0cc00de2415b21b8788618973c4ed012 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 14:10:38 -0700 Subject: [PATCH 3/3] Take an onImport hook instead of injecting the reconcilers Address review: injecting the scanner + reminder + issue-alert reconcilers into BackupCoordinator was a leaky abstraction and duplicated DayJournal's post-day-change fan-out. Instead the coordinator takes a single onImport closure and the composition root points it at journal.reconcileAfterDayChange(), so the invalidate/reconcile/publish fan-out stays defined in one place. The coordinator now depends only on the store + the hook. --- .../WhereCore/Sources/BackupCoordinator.swift | 53 ++--- Where/WhereCore/Sources/DayJournal.swift | 5 +- Where/WhereCore/Sources/WhereServices.swift | 8 +- .../Tests/BackupCoordinatorTests.swift | 181 +++--------------- 4 files changed, 56 insertions(+), 191 deletions(-) diff --git a/Where/WhereCore/Sources/BackupCoordinator.swift b/Where/WhereCore/Sources/BackupCoordinator.swift index ebae1b10..6e26d431 100644 --- a/Where/WhereCore/Sources/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/BackupCoordinator.swift @@ -1,8 +1,14 @@ import Foundation import LogKit -/// Owns backup export/import over the `BackupService` and the store, publishing -/// the widget snapshot after an import lands new data. +/// Owns backup export/import over the `BackupService` and the store, running a +/// caller-supplied `onImport` hook after an import lands new data. +/// +/// An import rewrites day data, so the same badge / notification / widget +/// reconcile a `DayJournal` day change runs has to follow it. Rather than reach +/// into all those collaborators (a leaky abstraction), the coordinator takes one +/// `onImport` closure and the composition root points it at the shared fan-out — +/// so the reconcile stays defined in a single place. /// /// Public so its `ImportStrategy` / `ImportSummary` types stay nameable from the /// UI directly through `WhereServices.backup`; construction stays in-module via @@ -41,15 +47,11 @@ public actor BackupCoordinator { private let store: any WhereStore private let backupService = BackupService() - private let widgets: WidgetSnapshotPublisher - /// Shared scanner behind the badge/notification issue count. Dropped inline - /// after an import commits so the reconciles below recount from fresh data - /// rather than racing the scanner's async store-change invalidation. - private let issueScanner: DataIssueScanner - /// App-icon badge (missing-day backlog + unresolved-issue count). - private let reminders: ReminderReconciler - /// "Issues to resolve" notification. - private let issueAlerts: DataIssueAlertReconciler + /// Invoked once after an import successfully commits. The composition root + /// wires it to the same post-day-change reconcile a journal write runs + /// (drop the issue-scan cache, reconcile the app-icon badge + issues + /// notification, republish the widget snapshot). + private let onImport: @Sendable () async -> Void private static let logger = WhereLog.channel(.backupService) /// Staging directory of the most recent export. Each archive lands in its @@ -62,16 +64,10 @@ public actor BackupCoordinator { init( store: any WhereStore, - widgets: WidgetSnapshotPublisher, - issueScanner: DataIssueScanner, - reminders: ReminderReconciler, - issueAlerts: DataIssueAlertReconciler, + onImport: @escaping @Sendable () async -> Void, ) { self.store = store - self.widgets = widgets - self.issueScanner = issueScanner - self.reminders = reminders - self.issueAlerts = issueAlerts + self.onImport = onImport } /// Serialize the entire store (all four tables plus evidence blobs) to a @@ -183,19 +179,12 @@ public actor BackupCoordinator { report() } } - // An import rewrites day data, so reconcile exactly like a `DayJournal` - // day change: drop the scanner cache inline (so the reconciles below - // recount fresh rather than racing its async store-change - // invalidation), then reconcile the app-icon badge + issues - // notification off the new count and republish the widget snapshot. - // These headless reconcilers don't observe `store.changes()`, so - // without this an import leaves the home-screen badge and the issues - // alert stuck at their pre-import values. Keep this fan-out in sync with - // `DayJournal.reconcileAfterDayChange()`. - await issueScanner.invalidate() - await reminders.reconcile() - await issueAlerts.reconcile() - await widgets.publish() + // An import rewrites day data, so the badge / notification / widget + // reconcile a day change runs has to follow it — these headless + // reconcilers don't observe `store.changes()`, so without this the + // home-screen badge and the issues alert stay stuck at their pre-import + // values. The composition root supplies the shared fan-out. + await onImport() return ImportSummary( sampleCount: archive.samples.count, diff --git a/Where/WhereCore/Sources/DayJournal.swift b/Where/WhereCore/Sources/DayJournal.swift index a0034929..b92363e3 100644 --- a/Where/WhereCore/Sources/DayJournal.swift +++ b/Where/WhereCore/Sources/DayJournal.swift @@ -58,8 +58,9 @@ public actor DayJournal { /// Full reconcile after a change to persisted day data (manual overlays, /// clears): recount issues / badge / notification, then republish the widget /// snapshot. Every day-mutating write funnels through here so the fan-out - /// stays in one place. - private func reconcileAfterDayChange() async { + /// stays in one place — including the backup import, which the composition + /// root points at this method via `BackupCoordinator`'s `onImport` hook. + func reconcileAfterDayChange() async { await reconcileIssueState() await widgets.publish() } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 3be94aa5..64f902ce 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -155,12 +155,12 @@ public struct WhereServices: Sendable { issueScanner: resolution, widgets: widgets, ) + // An import changes day data, so it reuses the journal's post-day-change + // reconcile (scanner invalidate + badge/notification reconcile + widget + // publish) rather than duplicating that fan-out. let backup = BackupCoordinator( store: store, - widgets: widgets, - issueScanner: resolution, - reminders: reminders, - issueAlerts: issueAlerts, + onImport: { await journal.reconcileAfterDayChange() }, ) let recentActivity = RecentActivitySummarizer( store: store, diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 4a5cd5b6..0c9d333d 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -3,124 +3,33 @@ import RegionKit import Testing @testable import WhereCore -/// Covers export/import round-trips and the post-import widget publish the -/// controller delegates to `BackupCoordinator`. +/// Covers export/import round-trips and the post-import `onImport` hook the +/// coordinator invokes once new data lands. struct BackupCoordinatorTests { private struct Harness { let coordinator: BackupCoordinator let store: SwiftDataStore - let widgets: SpyRefresher - let reminders: ReminderReconciler - let reminderScheduler: SpyReminderScheduler - let issueAlertScheduler: SpyIssueAlertScheduler + let onImport: HookSpy } - private actor SpyRefresher: WidgetTimelineRefreshing { - private(set) var publishCount = 0 - func publish(_: WidgetSnapshot) async { - publishCount += 1 + /// Records how many times the coordinator invoked its `onImport` hook, so a + /// test can assert an import triggers the (composition-root-supplied) + /// badge / notification / widget reconcile exactly once. + private actor HookSpy { + private(set) var count = 0 + func run() { + count += 1 } } - /// Records the app-icon badge reconciles so a test can assert an import - /// recomputes the badge off the imported data. - private actor SpyReminderScheduler: LoggingReminderScheduling { - private(set) var reconcileCount = 0 - private(set) var lastBadgeCount: Int? - func requestAuthorization() async -> Bool { - true - } - - func isAuthorized() async -> Bool { - true - } - - func reconcile( - badgeCount: Int, - scheduleDays _: [Date], - reminderTime _: ReminderTime, - enabled _: Bool, - ) async { - reconcileCount += 1 - lastBadgeCount = badgeCount - } - } - - /// Records the issue-alert reconciles so a test can assert an import - /// refreshes the "issues to resolve" notification. - private actor SpyIssueAlertScheduler: DataIssueAlertScheduling { - private(set) var reconcileCount = 0 - func requestAuthorization() async -> Bool { - true - } - - func isAuthorized() async -> Bool { - true - } - - func reconcile(enabled _: Bool, time _: ReminderTime, body _: String) async { - reconcileCount += 1 - } - } - - private static func makeHarness( - now: Date = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00"), - ) throws -> Harness { + private static func makeHarness() throws -> Harness { let store = try SwiftDataStore.inMemory() - let aggregator = DayAggregator( - calendar: WhereCoreTestSupport.calendar(), - timeZone: WhereCoreTestSupport.pacific, - ) - let refresher = SpyRefresher() - let widgets = WidgetSnapshotPublisher( - widgetReader: WidgetDataReader( - store: store, - aggregator: aggregator, - attributor: .shared, - ), - widgetRefresher: refresher, - attributor: .shared, - calendar: aggregator.calendar, - now: { now }, - ) - let reports = ReportReader(store: store, aggregator: aggregator, attributor: .shared) - let scanner = DataIssueScanner( - reportReader: reports, - attributor: .shared, - calendar: aggregator.calendar, - now: { now }, - storeChanges: store.changes(), - ) - let reminderScheduler = SpyReminderScheduler() - let reminders = ReminderReconciler( - scheduler: reminderScheduler, - reportReader: reports, - issueScanner: scanner, - calendar: aggregator.calendar, - now: { now }, - ) - let issueAlertScheduler = SpyIssueAlertScheduler() - let issueAlerts = DataIssueAlertReconciler( - scheduler: issueAlertScheduler, - scanner: scanner, - calendar: aggregator.calendar, - now: { now }, - ) + let hook = HookSpy() let coordinator = BackupCoordinator( store: store, - widgets: widgets, - issueScanner: scanner, - reminders: reminders, - issueAlerts: issueAlerts, - ) - return Harness( - coordinator: coordinator, - store: store, - widgets: refresher, - reminders: reminders, - reminderScheduler: reminderScheduler, - issueAlertScheduler: issueAlertScheduler, + onImport: { await hook.run() }, ) + return Harness(coordinator: coordinator, store: store, onImport: hook) } private static let evidence = Evidence( @@ -176,8 +85,8 @@ struct BackupCoordinatorTests { .allDismissedIssues()) #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) #expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob) - // An import that lands new data republishes the widget snapshot. - #expect(await destination.widgets.publishCount == 1) + // An import that lands new data runs the post-import hook once. + #expect(await destination.onImport.count == 1) } @Test func mergeImportKeepsPreexistingRows() async throws { @@ -228,58 +137,24 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) } - /// Regression guard: an import rewrites day data, so it must reconcile the - /// app-icon badge and the issues-to-resolve notification off the fresh data - /// rather than leaving them at their pre-import values (the "home-screen - /// badge stuck at 157 after replace import" bug). The headless reconcilers - /// don't observe `store.changes()`, so the import has to drive them. - @Test func replaceImportReconcilesTheBadgeOffTheImportedData() async throws { - // Frozen at Jan 5, so the backlog window is just Jan 1–4. - let now = WhereCoreTestSupport.iso("2026-01-05T09:00:00-08:00") - - // Source covers the whole backlog window, so it exports a fully-logged - // start of year (no missing days, no unresolved issues). - let source = try Self.makeHarness(now: now) - try await source.store.perform { - for iso in [ - "2026-01-01T12:00:00-08:00", - "2026-01-02T12:00:00-08:00", - "2026-01-03T12:00:00-08:00", - "2026-01-04T12:00:00-08:00", - ] { - try await source.store.setManualDay(DayPresence( - date: WhereCoreTestSupport.iso(iso), - in: WhereCoreTestSupport.calendar(), - regions: [.california], - )) - } - } + /// Regression guard: an import rewrites day data, so the coordinator must + /// invoke its `onImport` hook once new data lands — the composition root + /// wires that hook to the badge / notification / widget reconcile, so + /// skipping it leaves the home-screen badge and issues alert stuck at their + /// pre-import values (the "badge stuck at 157 after replace import" bug). + /// The end-to-end badge recount is asserted in `WhereServicesTests`. + @Test func replaceImportInvokesTheOnImportHook() async throws { + let source = try Self.makeHarness() + try await Self.seed(source.store) let url = try await source.coordinator.exportBackup() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - // Destination enables reminders + issue alerts while empty, so its badge - // starts at the fully-missing-year count (Jan 1–4 backlog + one - // missing-days range). - let destination = try Self.makeHarness(now: now) - await destination.reminders.configure( - enabled: true, - time: .defaultEvening, - issueAlertsEnabled: true, - driftThresholdMeters: Double(DriftThreshold.default.rawValue), - ) - let emptyBadge = await destination.reminderScheduler.lastBadgeCount - let remindersBeforeImport = await destination.reminderScheduler.reconcileCount - let issueAlertsBeforeImport = await destination.issueAlertScheduler.reconcileCount + let destination = try Self.makeHarness() + #expect(await destination.onImport.count == 0) _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) - // Both headless reconcilers ran on the import. - #expect(await destination.reminderScheduler.reconcileCount > remindersBeforeImport) - #expect(await destination.issueAlertScheduler.reconcileCount > issueAlertsBeforeImport) - // The badge was recomputed off the imported data — backlog + one missing - // range while empty, dropping to zero once Jan 1–4 are all logged. - #expect(emptyBadge == 5) - #expect(await destination.reminderScheduler.lastBadgeCount == 0) + #expect(await destination.onImport.count == 1) } /// The coordinator owns the export staging directory's lifecycle: starting a