Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
preferences: scope.preferences,
now: now,
)
await report.activate()
await report.activate(trigger: .initialAppearance)

return WhereFlyoverWorld(
scope: scope,
Expand Down
67 changes: 67 additions & 0 deletions Where/WhereUI/Sources/Launch/WhereLaunch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<WhereSession>,
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<WhereSession>,
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<WhereSession>.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
Expand Down
11 changes: 9 additions & 2 deletions Where/WhereUI/Sources/Logging/WhereLaunchLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
12 changes: 9 additions & 3 deletions Where/WhereUI/Sources/Logging/YearReportModelLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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):
Expand All @@ -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, _):
Expand Down
4 changes: 2 additions & 2 deletions Where/WhereUI/Sources/MainTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 38 additions & 3 deletions Where/WhereUI/Sources/Model/YearReportModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Void, Never>?

/// 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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions Where/WhereUI/Sources/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
2 changes: 1 addition & 1 deletion Where/WhereUI/Tests/PrimaryRegionLocationsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions Where/WhereUI/Tests/WhereLaunchLogTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
12 changes: 11 additions & 1 deletion Where/WhereUI/Tests/WhereLaunchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
22 changes: 22 additions & 0 deletions Where/WhereUI/Tests/YearReportModelLogTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Loading
Loading