diff --git a/Where/WhereCore/Sources/BackupCoordinator.swift b/Where/WhereCore/Sources/BackupCoordinator.swift index 887b8f75..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,7 +47,11 @@ public actor BackupCoordinator { private let store: any WhereStore private let backupService = BackupService() - private let widgets: WidgetSnapshotPublisher + /// 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 @@ -52,9 +62,12 @@ public actor BackupCoordinator { /// the export being torn down. private var previousExportDirectory: URL? - init(store: any WhereStore, widgets: WidgetSnapshotPublisher) { + init( + store: any WhereStore, + onImport: @escaping @Sendable () async -> Void, + ) { self.store = store - self.widgets = widgets + self.onImport = onImport } /// Serialize the entire store (all four tables plus evidence blobs) to a @@ -166,7 +179,12 @@ public actor BackupCoordinator { report() } } - 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 b9baa085..64f902ce 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) + // 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, + onImport: { await journal.reconcileAfterDayChange() }, + ) let recentActivity = RecentActivitySummarizer( store: store, attributor: attributor, diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 0e6ce3e9..0c9d333d 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -3,42 +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 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 } } private static func makeHarness() throws -> Harness { let store = try SwiftDataStore.inMemory() - let aggregator = DayAggregator( - calendar: WhereCoreTestSupport.calendar(), - timeZone: WhereCoreTestSupport.pacific, + let hook = HookSpy() + let coordinator = BackupCoordinator( + store: store, + onImport: { await hook.run() }, ) - let refresher = SpyRefresher() - let widgets = WidgetSnapshotPublisher( - widgetReader: WidgetDataReader( - store: store, - aggregator: aggregator, - attributor: .shared, - ), - widgetRefresher: refresher, - attributor: .shared, - calendar: WhereCoreTestSupport.calendar(), - now: { Date() }, - ) - let coordinator = BackupCoordinator(store: store, widgets: widgets) - return Harness(coordinator: coordinator, store: store, widgets: refresher) + return Harness(coordinator: coordinator, store: store, onImport: hook) } private static let evidence = Evidence( @@ -94,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 { @@ -146,6 +137,26 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) } + /// 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()) } + + let destination = try Self.makeHarness() + #expect(await destination.onImport.count == 0) + + _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) + + #expect(await destination.onImport.count == 1) + } + /// 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")