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
2 changes: 1 addition & 1 deletion Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ one it belongs to rather than to a god-object:
`InstallationRecordingContextStoring` keeps the persistence adapter outside
the domain value.
- **`WherePreferences`** — persisted user intent (onboarding,
reminder / summary schedules, Locations-card GPS-dot and annual-forecast visibility) plus the
reminder / summary schedules, Locations-card GPS-dot and estimated-time/planning visibility) plus the
year-keyed Location-card counts and Codable recording-warning generation used for presentation
continuity, behind a `KeyValueStore`. `RecordingConfigurationWarningCondition` evaluates the live
device authority, recording choice, and authorization tuple in Core. The store has no
Expand Down
10 changes: 6 additions & 4 deletions Where/WhereCore/Sources/Forecasting/LocationForecast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ public struct LocationForecast: Hashable, Sendable {
let baselineRate = Double(yearToDateDays) / Double(elapsedDays)
let tomorrow = today.adding(days: 1)

let matchingStay = plannedStay.flatMap { stay in
stay.region == region && stay.through >= today ? stay : nil
let activeStay = plannedStay.flatMap { stay in
stay.through >= tomorrow ? stay : nil
}
let plannedEnd = matchingStay.map { min($0.through, lastDay) }
let plannedDays = plannedEnd.map { tomorrow.days(through: $0).count } ?? 0
let plannedEnd = activeStay.map { min($0.through, lastDay) }
let plannedDays = activeStay?.region == region
? plannedEnd.map { tomorrow.days(through: $0).count } ?? 0
: 0
let projectionStart = plannedEnd?.adding(days: 1) ?? tomorrow
let remainingDays = projectionStart.days(through: lastDay).count
let projectedRemainingDays = baselineRate * Double(remainingDays)
Expand Down
7 changes: 4 additions & 3 deletions Where/WhereCore/Sources/Preferences/WherePreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ public final class WherePreferences {
set { store.set(newValue, forKey: Keys.showsRecordedLocationDots.rawValue) }
}

/// Whether the annual-estimate summary appears on the Locations tab.
/// Whether annual estimates, stay planning, and their projections appear.
/// Defaults to `true` so an existing or fresh install sees the feature until
/// the user explicitly turns it off.
public var showsLocationForecastsOnLocationsTab: Bool {
/// the user explicitly turns it off. The defaults key retains its original
/// Locations-only name so an existing choice survives the broader meaning.
public var showsEstimatedTimeAndPlanning: Bool {
get {
store.object(forKey: Keys.showsLocationForecastsOnLocationsTab.rawValue) as? Bool
?? true
Expand Down
41 changes: 35 additions & 6 deletions Where/WhereCore/Tests/LocationForecastTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,25 +102,54 @@ struct LocationForecastTests {
#expect(forecast.estimatedTotalDays == 274)
}

@Test func anotherRegionsStayDoesNotChangeTheEstimate() throws {
let baseline = try #require(LocationForecast.estimate(
@Test func anotherRegionsStayReservesItsDaysFromTheProjection() throws {
let withCaliforniaStay = try #require(LocationForecast.estimate(
region: .newYork,
report: Self.report(),
asOf: Self.date(7, 1),
calendar: Self.calendar,
plannedStay: nil,
plannedStay: PlannedStay(
region: .california,
through: CalendarDay(year: 2026, month: 8, day: 1),
),
))
let withCaliforniaStay = try #require(LocationForecast.estimate(

#expect(withCaliforniaStay.plannedDays == 0)
#expect(withCaliforniaStay.projectedRemainingDays == 76)
#expect(withCaliforniaStay.estimatedTotalDays == 167)
}

@Test func anotherRegionsCrossYearStayLeavesOnlyRecordedDays() throws {
let forecast = try #require(LocationForecast.estimate(
region: .newYork,
report: Self.report(),
asOf: Self.date(7, 1),
calendar: Self.calendar,
plannedStay: PlannedStay(
region: .california,
through: CalendarDay(year: 2026, month: 8, day: 1),
through: CalendarDay(year: 2027, month: 2, day: 1),
),
))

#expect(forecast.plannedDays == 0)
#expect(forecast.projectedRemainingDays == 0)
#expect(forecast.estimatedTotalDays == 91)
}

@Test func stayEndingTodayDoesNotReserveFutureDays() throws {
let forecast = try #require(LocationForecast.estimate(
region: .newYork,
report: Self.report(),
asOf: Self.date(7, 1),
calendar: Self.calendar,
plannedStay: PlannedStay(
region: .california,
through: CalendarDay(year: 2026, month: 7, day: 1),
),
))

#expect(withCaliforniaStay == baseline)
#expect(forecast.plannedDays == 0)
#expect(forecast.projectedRemainingDays == 91.5)
#expect(forecast.estimatedTotalDays == 183)
}
}
17 changes: 14 additions & 3 deletions Where/WhereCore/Tests/WherePreferencesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct WherePreferencesTests {

#expect(preferences.hasOnboarded == false)
#expect(preferences.showsRecordedLocationDots)
#expect(preferences.showsLocationForecastsOnLocationsTab)
#expect(preferences.showsEstimatedTimeAndPlanning)
#expect(preferences.remindersEnabled)
#expect(preferences.reminderTime == .defaultEvening)
#expect(preferences.summaryEnabled)
Expand All @@ -38,6 +38,17 @@ struct WherePreferencesTests {
#expect(preferences.lastSeenLocationDayCounts(in: 2026) == counts2026)
}

@Test func estimatedTimeUsesTheLegacyLocationsVisibilityKey() {
let store = InMemoryKeyValueStore()
store.set(false, forKey: "where.showsLocationForecastsOnLocationsTab")
let preferences = WherePreferences(store: store)

#expect(preferences.showsEstimatedTimeAndPlanning == false)

preferences.showsEstimatedTimeAndPlanning = true
#expect(store.bool(forKey: "where.showsLocationForecastsOnLocationsTab"))
}

@Test func recordingWarningRegistrationRoundTrips() {
let preferences = preferences()
var registration = RecordingConfigurationWarningRegistration()
Expand All @@ -53,7 +64,7 @@ struct WherePreferencesTests {
let preferences = preferences()
preferences.hasOnboarded = true
preferences.showsRecordedLocationDots = false
preferences.showsLocationForecastsOnLocationsTab = false
preferences.showsEstimatedTimeAndPlanning = false
preferences.remindersEnabled = false
preferences.reminderTime = ReminderTime(hour: 9, minute: 15)
preferences.summaryEnabled = false
Expand All @@ -70,7 +81,7 @@ struct WherePreferencesTests {

#expect(preferences.hasOnboarded == false)
#expect(preferences.showsRecordedLocationDots)
#expect(preferences.showsLocationForecastsOnLocationsTab)
#expect(preferences.showsEstimatedTimeAndPlanning)
#expect(preferences.remindersEnabled)
#expect(preferences.reminderTime == .defaultEvening)
#expect(preferences.summaryEnabled)
Expand Down
2 changes: 2 additions & 0 deletions Where/WhereUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Layering, localization, preview, and testing conventions live in the feature
`YearReportDetails`.
- Keep planned-stay persistence and forecast math in WhereCore; `LocationForecastModel` only mirrors
the active register and orchestrates intents for the Locations, calendar, and timeline surfaces.
- Hide every forecast and planned-stay visualization behind
`YearReportModel.showsEstimatedTimeAndPlanning`; persist Off only after clearing the synced plan.
- Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`,
typewriter reveals) must consult the shared `@MotionIsStatic` helper
([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift))
Expand Down
12 changes: 8 additions & 4 deletions Where/WhereUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
widget family on miniature Home Screen and Lock Screen surfaces. A Share &
Evidence walkthrough also reveals the system Share-sheet extension and links
into the saved attachment archive. Insights & Accuracy introduces the
automatic issue detectors without running them merely to render the gallery.
automatic issue detectors without running them merely to render the gallery,
while Estimated Time & Planning explains the live annual projection with a
worked pace-and-plan calculation, planned stays, and why overlapping travel
days do not sum neatly to one year.
These galleries use a shared marketing header, quiet patterned backdrop, and
staged entrance that resolves immediately for Reduce Motion and snapshot
capture. Once the selected report has 14 recorded days, the Siri, Spotlight,
Expand Down Expand Up @@ -295,9 +298,10 @@ stroke; its containing Liquid Glass surface owns the subtle outer border so
direct and production rendering do not diverge.

After at least three months of the current year, Locations can reveal a collapsible annual estimate
from the recorded pace; Settings > Appearance can disable it entirely. A focused region calendar
places the estimate after the current month and renders “I’ll be here through…” future days with a
continuous hatched band, distinct from recorded presence.
from the recorded pace and plan a stay through one of its displayed regions. A focused region
calendar places the estimate after the current month and renders planned future days with a
continuous hatched band, distinct from recorded presence. Settings > Appearance disables every
estimate and planning visualization only after clearing the synced plan succeeds.

DEBUG builds include Card Designer Studio under Settings → Appearance. It
edits a versioned, persisted draft of the regular, compact, and shared card
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SnapshotKitTesting
import Testing
@testable import WhereUI

@MainActor
struct EstimatedTimeFeaturesViewSnapshotTests {
@Test func snapshots() async {
await assertSnapshots(of: EstimatedTimeFeaturesView.self)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading