diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift index 8548aa3d0..47029b9a2 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift @@ -56,7 +56,7 @@ preferences: scope.preferences, now: now, ) - await report.activate() + await report.activate(trigger: .initialAppearance) return WhereFlyoverWorld( scope: scope, diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index e880233ad..058c88b06 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -74,6 +74,19 @@ public enum LaunchStepID: String, Sendable { /// skipped node can't leave a hole in the data flow. @MainActor public enum WhereLaunch { + /// Which scene callback observed the app entering the foreground. + enum ForegroundTrigger { + case initialAppearance + case sceneBecameActive + + var logValue: String { + switch self { + case .initialAppearance: "initial-appearance" + case .sceneBecameActive: "scene-became-active" + } + } + } + private static let logger = WhereLog.root(WhereLaunchLog.self) /// Start the built-in ambient sources (network path, thermal state, low @@ -133,6 +146,60 @@ public enum WhereLaunch { return runner } + /// Promote the launch for a visible scene and record what the runner had + /// completed before that scene arrived. A prior `.ready` phase is the direct + /// signature of a process that finished its headless drive before foregrounding. + static func enterForeground( + _ runner: LifecycleRunner, + trigger: ForegroundTrigger, + ) async { + let event = foregroundEnteredEvent(for: runner, trigger: trigger) + await runner.enterForeground() + logger { event } + } + + /// Snapshot the pre-promotion runner state before `enterForeground()` can + /// replace both values. Kept separate so its diagnostic contract is testable + /// without sharing the process-global log pipeline. + static func foregroundEnteredEvent( + for runner: LifecycleRunner, + trigger: ForegroundTrigger, + ) -> WhereLaunchLog { + .foregroundEntered( + trigger: trigger.logValue, + previousReason: diagnosticName(for: runner.reason), + previousPhase: diagnosticName(for: runner.phase), + ) + } + + private static func diagnosticName(for reason: LifecycleReason) -> String { + switch reason { + case .userForeground: + "user-foreground" + case let .background(cause): + switch cause { + case .location: "background-location" + case .remoteNotification: "background-remote-notification" + case .backgroundTask: "background-task" + case .other: "background-other" + } + case .undetermined: + "undetermined" + } + } + + private static func diagnosticName( + for phase: LifecycleRunner.Phase, + ) -> String { + switch phase { + case .launching: "launching" + case .running: "running" + case .awaitingGate: "awaiting-gate" + case .failed: "failed" + case .ready: "ready" + } + } + /// The typed launch plan. The trunk mirrors the imperative /// `WhereSession.start()` order (a parity test guards this); the only /// insertions are the `onboarding` gate at its head and the diff --git a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift index 91ca9ca26..6f0005dc7 100644 --- a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift @@ -2,7 +2,7 @@ import PeriscopeCore /// Structured events for the app launch sequence (`WhereLaunch` / /// `WhereBootstrap`), including the process-global log-store bootstrap. -enum WhereLaunchLog: LogEvent { +enum WhereLaunchLog: LogEvent, Equatable { /// Names the launch spans — one budgeted span per measured launch or /// teardown step (see `MeasuredStep`), plus the two log-store chores the /// bootstrap runs off the critical path. @@ -30,6 +30,9 @@ enum WhereLaunchLog: LogEvent { } case runnerCreated(reason: String) + /// A scene asked the runner to enter the foreground. The previous phase says + /// whether a headless drive had already reached `.ready` before UI appeared. + case foregroundEntered(trigger: String, previousReason: String, previousPhase: String) case servicesAssembled /// Assembling the service layer (store open + `WhereServices.make`) failed; /// the `resolve-scope` step surfaces it and the launch parks in `.failed`. @@ -60,7 +63,8 @@ enum WhereLaunchLog: LogEvent { var level: LogLevel { switch self { - case .runnerCreated, .servicesAssembled, .loggingStoreReady, .historyPruned: + case .runnerCreated, .foregroundEntered, .servicesAssembled, .loggingStoreReady, + .historyPruned: .info // The store is still usable when pruning fails (degraded-but-handled), // unlike an outright open failure. A detached-step failure is the @@ -77,6 +81,9 @@ enum WhereLaunchLog: LogEvent { switch self { case let .runnerCreated(reason): "Lifecycle runner created (reason: \(reason))" + case let .foregroundEntered(trigger, previousReason, previousPhase): + "Entered foreground (trigger: \(trigger), previous reason: \(previousReason)," + + " previous phase: \(previousPhase))" case .servicesAssembled: "WhereServices assembled" case let .servicesAssemblyFailed(description): diff --git a/Where/WhereUI/Sources/Logging/YearReportModelLog.swift b/Where/WhereUI/Sources/Logging/YearReportModelLog.swift index 5c21f5856..e3781b2fe 100644 --- a/Where/WhereUI/Sources/Logging/YearReportModelLog.swift +++ b/Where/WhereUI/Sources/Logging/YearReportModelLog.swift @@ -4,7 +4,7 @@ import WhereCore /// Structured events for `YearReportModel`. The affected year rides on /// `externalID`. A successful load is `.info`; read failures that leave a /// degraded UI state are `.warning`. -enum YearReportModelLog: LogEvent { +enum YearReportModelLog: LogEvent, Equatable { /// Names the model's timed span. /// /// Only the composed pass is timed: the report read, the evidence-day fetch, @@ -18,6 +18,7 @@ enum YearReportModelLog: LogEvent { case sceneRefresh } + case activationStarted(year: Int, trigger: String, isFirstActivation: Bool, hadReport: Bool) case selectedYear(year: Int) case reportLoaded(year: Int, dayCount: Int) case reportLoadFailed(year: Int, description: String) @@ -32,7 +33,7 @@ enum YearReportModelLog: LogEvent { var level: LogLevel { switch self { - case .selectedYear, .reportLoaded: .info + case .activationStarted, .selectedYear, .reportLoaded: .info case .reportLoadFailed, .evidenceDayKeysLoadFailed, .dataIssueScanFailed, .clearYearFailed, .locationsLoadFailed, .dayLocationsLoadFailed, .representativeCoordinatesLoadFailed: @@ -42,6 +43,10 @@ enum YearReportModelLog: LogEvent { var message: String { switch self { + case let .activationStarted(year, trigger, isFirstActivation, hadReport): + "Year report activation started for \(year)" + + " (trigger: \(trigger), first: \(isFirstActivation)," + + " had report: \(hadReport))" case let .selectedYear(year): "Selected year \(year)" case let .reportLoaded(year, dayCount): @@ -65,7 +70,8 @@ enum YearReportModelLog: LogEvent { var externalID: String? { switch self { - case let .selectedYear(year), let .reportLoaded(year, _), + case let .activationStarted(year, _, _, _), let .selectedYear(year), + let .reportLoaded(year, _), let .reportLoadFailed(year, _), let .evidenceDayKeysLoadFailed(year, _), let .clearYearFailed(year, _), let .locationsLoadFailed(_, year, _), let .representativeCoordinatesLoadFailed(year, _): diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 8061f58de..325637caf 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -68,11 +68,11 @@ struct MainTabs: View { // Subscribe + pull once the scene is on screen, and again whenever it // returns to the foreground; cancel the subscription on background so a // backgrounded scene drives no refreshes. - .task { await report.activate() } + .task { await report.activate(trigger: .initialAppearance) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: - Task { await report.activate() } + Task { await report.activate(trigger: .foregroundReturn) } case .background: report.deactivate() case .inactive: diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 37156e844..634df1ce2 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -13,7 +13,7 @@ import WhereCore /// logged-in lifetime — a `YearReportModel` is created by `MainTabs` only once the /// real UI is on screen (the launch's `.ready` state) and torn down with it. It /// owns the store's data-change subscription, started on scene `.active` -/// (`activate()`) and cancelled on background (`deactivate()`), so a headless +/// (`activate(trigger:)`) and cancelled on background (`deactivate()`), so a headless /// background relaunch never drives a `refresh()` no UI consumes. /// /// `services` / `preferences` / `now` / `calendar` are exposed so the @@ -23,6 +23,21 @@ import WhereCore @MainActor @Observable public final class YearReportModel { + /// Why the scene is activating its report model. Kept typed at the API + /// boundary while the corresponding persisted log payload uses a stable + /// explicit string. + public enum ActivationTrigger: Sendable { + case initialAppearance + case foregroundReturn + + var logValue: String { + switch self { + case .initialAppearance: "initial-appearance" + case .foregroundReturn: "foreground-return" + } + } + } + /// Where the current year's data is in its load lifecycle. public enum LoadState: Equatable { case idle @@ -132,6 +147,10 @@ public final class YearReportModel { /// the main actor, and `deinit` runs with no other live references. @ObservationIgnored private nonisolated(unsafe) var dataChangeTask: Task? + /// Distinguishes a newly constructed scene model's first pull from later + /// foreground refreshes in diagnostics. Main-actor isolated with the model. + private var hasActivated = false + private static let logger = WhereLog.session(YearReportModelLog.self) /// Observed mirror of `preferences.driftThresholdMeters`, which isn't itself @@ -262,14 +281,30 @@ public final class YearReportModel { /// Start observing committed writes and pull fresh data. Called by `MainTabs` /// when the scene becomes active. Safe to call repeatedly — the subscription /// is set up at most once until `deactivate()`. - public func activate() async { + public func activate(trigger: ActivationTrigger) async { + let event = activationStartedEvent(trigger: trigger) + Self.logger { event } observeDataChanges() await refreshAll(forceDataIssueCount: false) } + /// Snapshot the facts that exist before an activation changes presentation + /// state. Split from emission so the diagnostic contract is deterministic to + /// test without attaching a suite to the process-global logging facade. + func activationStartedEvent(trigger: ActivationTrigger) -> YearReportModelLog { + let event = YearReportModelLog.activationStarted( + year: selectedYear, + trigger: trigger.logValue, + isFirstActivation: !hasActivated, + hadReport: report != nil, + ) + hasActivated = true + return event + } + /// Stop observing committed writes. Called by `MainTabs` when the scene goes /// to the background, so a backgrounded scene drives no refreshes; the next - /// `activate()` re-subscribes and pulls (covering the background→foreground + /// `activate(trigger:)` re-subscribes and pulls (covering the background→foreground /// gap). public func deactivate() { dataChangeTask?.cancel() diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 11d651848..1e2722dfe 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -219,14 +219,14 @@ public struct RootView: View { // entire (possibly slow) headless drive instead of the splash. .task { if scenePhase == .active { - await launcher.enterForeground() + await WhereLaunch.enterForeground(launcher, trigger: .initialAppearance) } await launcher.run() } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } Task { - await launcher.enterForeground() + await WhereLaunch.enterForeground(launcher, trigger: .sceneBecameActive) await model.session?.appBecameActive() } } diff --git a/Where/WhereUI/Tests/PrimaryRegionLocationsTests.swift b/Where/WhereUI/Tests/PrimaryRegionLocationsTests.swift index 29f115891..072ed6c79 100644 --- a/Where/WhereUI/Tests/PrimaryRegionLocationsTests.swift +++ b/Where/WhereUI/Tests/PrimaryRegionLocationsTests.swift @@ -68,7 +68,7 @@ struct PrimaryRegionLocationsTests { ) try await services.journal.ingest(first) - await report.activate() + await report.activate(trigger: .initialAppearance) defer { report.deactivate() } let initialReport = try #require(report.report) let initialLocations = try #require(report.primaryRegionLocations) diff --git a/Where/WhereUI/Tests/WhereLaunchLogTests.swift b/Where/WhereUI/Tests/WhereLaunchLogTests.swift new file mode 100644 index 000000000..52635cc85 --- /dev/null +++ b/Where/WhereUI/Tests/WhereLaunchLogTests.swift @@ -0,0 +1,20 @@ +import PeriscopeCore +import Testing +@testable import WhereUI + +/// Pins the foreground diagnostic whose prior phase distinguishes a completed +/// headless drive from a launch still progressing when its scene appeared. +struct WhereLaunchLogTests { + @Test func foregroundEntryRecordsThePriorRunnerState() { + let event = WhereLaunchLog.foregroundEntered( + trigger: "scene-became-active", + previousReason: "undetermined", + previousPhase: "ready", + ) + + #expect(event.level == .info) + #expect(event.message == "Entered foreground (trigger: scene-became-active," + + " previous reason: undetermined," + + " previous phase: ready)") + } +} diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 25c2c3414..395fbf677 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -218,11 +218,21 @@ struct WhereLaunchTests { #expect(launcher.reason.buildsNoViewTree) #expect(model.session?.isTracking == true) #expect(try await store.allSamples().isEmpty) + #expect( + WhereLaunch.foregroundEnteredEvent( + for: launcher, + trigger: .sceneBecameActive, + ) == .foregroundEntered( + trigger: "scene-became-active", + previousReason: "undetermined", + previousPhase: "ready", + ), + ) // A scene activates → promote. The re-drive skips the already-completed // background steps and runs the now-applicable foreground-only // capture-today, which logs today's fix. - await launcher.enterForeground() + await WhereLaunch.enterForeground(launcher, trigger: .sceneBecameActive) #expect(launcher.phase.isReady) #expect(launcher.reason == .userForeground) try await waitUntilAsync { await (try? store.allSamples().count) == 1 } diff --git a/Where/WhereUI/Tests/YearReportModelLogTests.swift b/Where/WhereUI/Tests/YearReportModelLogTests.swift new file mode 100644 index 000000000..dd03d0a8e --- /dev/null +++ b/Where/WhereUI/Tests/YearReportModelLogTests.swift @@ -0,0 +1,22 @@ +import PeriscopeCore +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +/// Pins the structured activation diagnostic used to explain a transient +/// Locations loading screen after the fact. +struct YearReportModelLogTests { + @Test func activationRecordsItsTriggerAndPriorReportState() { + let event = YearReportModelLog.activationStarted( + year: 2026, + trigger: "foreground-return", + isFirstActivation: false, + hadReport: true, + ) + + #expect(event.level == .info) + #expect(event.externalID == WhereStoreID.year(2026)) + #expect(event.message == "Year report activation started for 2026" + + " (trigger: foreground-return, first: false, had report: true)") + } +} diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index 3cf55983b..e1bcbc872 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -62,6 +62,41 @@ struct YearReportModelTests { #expect(reloaded.showsRecordedLocationDots == false) } + @Test func activationDiagnosticsDistinguishFreshAndRetainedReports() throws { + let services = try makeServices() + let fresh = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + ) + + #expect(fresh.activationStartedEvent(trigger: .initialAppearance) == .activationStarted( + year: 2026, + trigger: "initial-appearance", + isFirstActivation: true, + hadReport: false, + )) + #expect(fresh.activationStartedEvent(trigger: .foregroundReturn) == .activationStarted( + year: 2026, + trigger: "foreground-return", + isFirstActivation: false, + hadReport: false, + )) + + let preloaded = YearReportModel( + services: services, + details: Self.details(YearReport(year: 2026, days: [], totals: [:])), + selectedYear: 2026, + preferences: makePreferences(), + ) + #expect(preloaded.activationStartedEvent(trigger: .initialAppearance) == .activationStarted( + year: 2026, + trigger: "initial-appearance", + isFirstActivation: true, + hadReport: true, + )) + } + // MARK: - Year load / stale fetches / save errors @Test func staleYearFetchDoesNotOverwriteNewerSelection() async throws { @@ -424,7 +459,7 @@ struct YearReportModelTests { preferences: makePreferences(), now: { now }, ) - await report.activate() + await report.activate(trigger: .initialAppearance) #expect(report.report?.days.count == 1) #expect(report.loadState == .loaded) @@ -458,7 +493,7 @@ struct YearReportModelTests { preferences: makePreferences(), now: { now }, ) - await report.activate() + await report.activate(trigger: .initialAppearance) #expect(report.report?.days.count == 0) report.deactivate() @@ -468,7 +503,7 @@ struct YearReportModelTests { preferences: makePreferences(), now: { now }, ) - await probe.activate() + await probe.activate(trigger: .initialAppearance) try await services.journal.addManualDay( date: date(year: 2026, month: 1, day: 1), @@ -501,7 +536,7 @@ struct YearReportModelTests { preferences: makePreferences(), now: { now }, ) - await report.activate() + await report.activate(trigger: .initialAppearance) #expect(report.report?.days.count == 0) report.deactivate() @@ -514,7 +549,7 @@ struct YearReportModelTests { #expect(report.report?.days.count == 0) // Foregrounding re-subscribes and pulls the gap. - await report.activate() + await report.activate(trigger: .foregroundReturn) #expect(report.report?.days.count == 1) } @@ -535,7 +570,7 @@ struct YearReportModelTests { preferences: makePreferences(), ) - await report.activate() + await report.activate(trigger: .initialAppearance) #expect(report.evidenceDayKeys == [Self.cday(2026, 3, 4)]) } @@ -581,7 +616,7 @@ struct YearReportModelTests { selectedYear: 2026, preferences: makePreferences(), ) - await report.activate() + await report.activate(trigger: .initialAppearance) #expect(report.evidenceDayKeys == [Self.cday(2026, 5, 2)]) await report.select(year: 2025)