Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions Where/WhereCore/Sources/BackupCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -52,9 +62,12 @@ public actor BackupCoordinator {
/// the export being torn down.
private var previousExportDirectory: URL?

init(store: any WhereStore, widgets: WidgetSnapshotPublisher) {
init(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I know I approved this plan but this seems like a leaky abstraction to me. What if instead of injecting all of these, we just had this take in an onImport closure that our builder would invoke these rebuilds and updates in?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Eg, that would let us DRY up this into one place and not duplicate it.)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on @kyleve's behalf.

Good call — done in 6fab57d. BackupCoordinator no longer takes the scanner/reminder/issue-alert reconcilers (or the widget publisher); it just takes a single onImport: @Sendable () async -> Void and calls it once after an import commits. The composition root wires that hook to the existing fan-out:

let backup = BackupCoordinator(
    store: store,
    onImport: { await journal.reconcileAfterDayChange() },
)

DayJournal.reconcileAfterDayChange() went from private to internal so it's reused rather than duplicated — an import now runs the exact same post-day-change reconcile a journal write does, defined in one place. The coordinator test now just asserts the hook fires once per import; the end-to-end badge recount stays covered by WhereServicesTests.

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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions Where/WhereCore/Sources/DayJournal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
8 changes: 7 additions & 1 deletion Where/WhereCore/Sources/WhereServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 37 additions & 26 deletions Where/WhereCore/Tests/BackupCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions Where/WhereCore/Tests/WhereServicesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading