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
90 changes: 90 additions & 0 deletions Where/WhereCore/Sources/DataIssueAlertReconciler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Foundation
import LogKit

/// Owns the "you have issues to resolve" notification intent and the
/// reconciliation that keeps that notification in sync with the current year's
/// unresolved data-issue count. Mirrors `DailySummaryReconciler`: the schedule
/// re-runs on launch/foreground and after every committed write, so the alert
/// appears once issues exist and clears the moment the last one is resolved or
/// dismissed.
public actor DataIssueAlertReconciler {
private let scheduler: any DataIssueAlertScheduling
private let scanner: DataIssueScanner
private let calendar: Calendar
private let now: @Sendable () -> Date

private var config = Configuration()

private struct Configuration {
var enabled = false
var time: ReminderTime = .defaultEvening
var driftThresholdMeters = Double(DriftThreshold.default.rawValue)
}

private static let logger = WhereLog.channel(.dataIssueAlertReconciler)

init(
scheduler: any DataIssueAlertScheduling,
scanner: DataIssueScanner,
calendar: Calendar,
now: @escaping @Sendable () -> Date,
) {
self.scheduler = scheduler
self.scanner = scanner
self.calendar = calendar
self.now = now
}

/// Set the user's issue-alert intent (enabled + fire time + the drift
/// threshold the scan runs at), request notification permission when
/// enabling, then reconcile. Safe to call on every launch and whenever the
/// user changes the setting.
public func configure(enabled: Bool, time: ReminderTime, driftThresholdMeters: Double) async {
config = Configuration(
enabled: enabled,
time: time,
driftThresholdMeters: driftThresholdMeters,
)
if enabled {
_ = await scheduler.requestAuthorization()
}
await reconcile()
}

/// Recompute the current-year unresolved-issue count and push it to the
/// scheduler: schedule the alert while issues remain (and the user has it
/// enabled), clear it otherwise.
func reconcile() async {
guard config.enabled else {
await scheduler.reconcile(enabled: false, time: config.time, body: "")
return
}

let year = calendar.component(.year, from: now())
do {
let count = try await scanner.currentIssueCount(
year: year,
driftThresholdMeters: config.driftThresholdMeters,
)
await scheduler.reconcile(
enabled: count > 0,
time: config.time,
body: Self.body(count: count),
)
} catch {
Self.logger.error(
"Failed to reconcile issue alerts: \(error.localizedDescription)",
)
}
}

/// The alert body, pluralized on the issue count (e.g. "1 issue to resolve"
/// / "3 issues to resolve").
private static func body(count: Int) -> String {
String(
localized: "dataIssues.notification.body",
defaultValue: "\(count) issues to resolve",
bundle: .module,
)
}
}
25 changes: 25 additions & 0 deletions Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ public actor DataIssueScanner {
return sorted
}

/// Count of unresolved issues for `year`, for headless callers (the app-icon
/// badge, the issue-alert notification) that don't have the UI's
/// `RegionRanking` on hand. Derives `primaryRegions` through the shared
/// `Region.primaryRegions` helper — the *same* definition `RegionRanking`
/// builds the Primary/Elsewhere split from, so the "primary" rule lives in
/// one place and this count can't disagree with what the Resolve tab shows
/// (no ranking logic is duplicated here). This reads the report once to rank
/// regions and `issues(...)` reads it again on a cache miss, so callers that
/// already hold a report (the hot badge path in `ReminderReconciler`) should
/// call `issues(...)` with `Region.primaryRegions(...)` directly to avoid the
/// second read; this convenience is for the cold notification path.
public func currentIssueCount(
year: Int,
driftThresholdMeters: Double,
force: Bool = false,
) async throws -> Int {
let report = try await reportReader.yearReport(for: year)
return try await issues(
year: year,
primaryRegions: Region.primaryRegions(in: report.totals),
driftThresholdMeters: driftThresholdMeters,
force: force,
).count
}

/// Drop the cache so the next `issues(...)` recomputes regardless of throttle.
public func invalidate() {
cache = nil
Expand Down
29 changes: 29 additions & 0 deletions Where/WhereCore/Sources/DayJournal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ public actor DayJournal {
private let store: any WhereStore
private let aggregator: DayAggregator
private let reminders: ReminderReconciler
private let issueAlerts: DataIssueAlertReconciler
/// Shared scanner behind the badge/notification issue count. Dropped inline
/// after each committed write so the reconciles below recount from fresh
/// data rather than racing the scanner's async store-change invalidation.
private let issueScanner: DataIssueScanner
private let widgets: WidgetSnapshotPublisher

private static let logger = WhereLog.channel(.dayJournal)
Expand All @@ -22,11 +27,15 @@ public actor DayJournal {
store: any WhereStore,
aggregator: DayAggregator,
reminders: ReminderReconciler,
issueAlerts: DataIssueAlertReconciler,
issueScanner: DataIssueScanner,
widgets: WidgetSnapshotPublisher,
) {
self.store = store
self.aggregator = aggregator
self.reminders = reminders
self.issueAlerts = issueAlerts
self.issueScanner = issueScanner
self.widgets = widgets
}

Expand Down Expand Up @@ -68,7 +77,9 @@ public actor DayJournal {
let key = aggregator.calendar.startOfDay(for: date)
let presence = DayPresence(date: key, regions: regions, audit: audit)
try await store.perform { try await store.setManualDay(presence) }
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
await widgets.publish()
Self.logger.info(
"Added manual day \(Self.dayLogLabel(key, calendar: aggregator.calendar)) with \(regions.count) region(s)",
Expand All @@ -88,7 +99,9 @@ public actor DayJournal {
let key = aggregator.calendar.startOfDay(for: date)
let presence = DayPresence(date: key, regions: regions, isAuthoritative: true, audit: audit)
try await store.perform { try await store.setManualDay(presence) }
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
await widgets.publish()
Self.logger.info(
"Overrode day \(Self.dayLogLabel(key, calendar: aggregator.calendar)) with \(regions.count) region(s)",
Expand All @@ -102,7 +115,9 @@ public actor DayJournal {
public func clearManualDay(date: Date) async throws {
let key = aggregator.calendar.startOfDay(for: date)
try await store.perform { try await store.clearManualDay(key) }
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
await widgets.publish()
Self.logger.info(
"Cleared manual overlay for day \(Self.dayLogLabel(key, calendar: aggregator.calendar))",
Expand Down Expand Up @@ -135,7 +150,9 @@ public actor DayJournal {
)
}
}
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
await widgets.publish()
Self.logger.info(
"Backfilled \(dayKeys.count) manual day(s) with \(regions.count) region(s)",
Expand All @@ -147,7 +164,9 @@ public actor DayJournal {
public func clearYear(_ year: Int) async throws {
let interval = aggregator.yearInterval(year: year)
try await store.perform { try await store.clear(in: interval) }
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
await widgets.publish()
Self.logger.info("Cleared year \(year)")
}
Expand All @@ -159,7 +178,9 @@ public actor DayJournal {
/// store immediately rather than relying on a later launch step.
public func eraseAllData() async throws {
try await store.perform { try await store.clearAll() }
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
await widgets.publish()
Self.logger.info("Erased all store data")
}
Expand All @@ -183,10 +204,18 @@ public actor DayJournal {

public func dismissIssue(key: String) async throws {
try await store.perform { try await store.setIssueDismissed(true, key: key) }
// Dismissing removes the issue from the unresolved count, so the badge
// and the "issues to resolve" notification both have to recount.
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
}

public func restoreIssue(key: String) async throws {
try await store.perform { try await store.setIssueDismissed(false, key: key) }
await issueScanner.invalidate()
await reminders.reconcile()
await issueAlerts.reconcile()
}

private static func dayLogLabel(_ day: Date, calendar: Calendar) -> String {
Expand Down
18 changes: 18 additions & 0 deletions Where/WhereCore/Sources/Region+Ordering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,22 @@ extension Region {
return lhsOrder < rhsOrder
}
}

/// The "primary" regions for a year's `totals`: the top `count` regions by
/// day count, with `.other` excluded (it's a catch-all bucket, never a
/// headline place). The single definition of "primary", shared by the
/// Primary/Elsewhere split (`RegionRanking`) and the headless data-issue scan
/// that drives the badge and notification — so the count the badge shows
/// can't disagree with what the Resolve tab would compute.
public static func primaryRegions(in totals: [Region: Int], count: Int = 2) -> [Region] {
rankedByDayCount(
totals.filter { $0.value > 0 },
days: { $0.value },
region: { $0.key },
)
.map(\.key)
.filter { $0 != .other }
.prefix(max(0, count))
.map(\.self)
}
}
Loading
Loading