From 609559c9d75a8a0b4feb44fba1befd051724e32f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 17:35:16 -0700 Subject: [PATCH 01/20] Add annual location time forecasts --- Where/Tools/upgrade-backup.rb | 6 +- Where/WhereCore/AGENTS.md | 4 + Where/WhereCore/README.md | 5 + .../Sources/Backup/BackupArchive.swift | 10 +- .../Sources/Backup/BackupCoordinator.swift | 11 +- .../Sources/Backup/BackupService.swift | 2 + .../Forecasting/LocationForecast.swift | 68 ++++++++++ .../Sources/Forecasting/PlannedStay.swift | 15 +++ .../Forecasting/PlannedStayCoordinator.swift | 49 +++++++ .../Forecasting/PlannedStayRecord.swift | 23 ++++ .../Sources/Persistence/SwiftDataStore.swift | 78 +++++++++++ .../Sources/Persistence/WhereStore.swift | 22 +++ Where/WhereCore/Sources/WhereServices.swift | 9 ++ .../Tests/BackupCoordinatorTests.swift | 11 ++ .../WhereCore/Tests/BackupServiceTests.swift | 12 +- .../Tests/LocationForecastTests.swift | 126 ++++++++++++++++++ .../Tests/PlannedStayCoordinatorTests.swift | 80 +++++++++++ .../Tests/PlannedStayRecordTests.swift | 22 +++ Where/WhereUI/AGENTS.md | 2 +- Where/WhereUI/README.md | 3 +- ...endarContent.FocusedPlannedStay_iPhone.png | 3 + ...Content.FocusedPlannedStay_iPhone_dark.png | 3 + .../calendarContent.Focused_iPhone.png | 4 +- .../calendarContent.Focused_iPhone_dark.png | 4 +- .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_accessibility.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_accessibility.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.PlannedStay_iPhone.png | 3 + .../locations.PlannedStay_iPhone_dark.png | 3 + .../Forecasting/LocationForecastPanel.swift | 97 ++++++++++++++ .../Forecasting/PlannedStayEditor.swift | 99 ++++++++++++++ .../Logging/LocationForecastModelLog.swift | 24 ++++ .../Sources/Model/LocationForecastModel.swift | 105 +++++++++++++++ .../Sources/Model/YearReportModel.swift | 5 + .../Sources/Preview/PreviewSupport.swift | 10 ++ .../Sources/Primary/CalendarContentView.swift | 32 +++++ .../Sources/Primary/LocationsView.swift | 19 +++ .../Sources/Resources/Localizable.xcstrings | 102 ++++++++++++++ .../WhereUI/Sources/Shared/WhereFormat.swift | 20 +++ .../Sources/Shared/WhereStylesheet.swift | 21 +++ .../Tests/LocationForecastModelTests.swift | 103 ++++++++++++++ Where/WhereUI/Tests/Support/TestStore.swift | 20 +++ Where/WhereUI/Tests/WhereFormatTests.swift | 13 ++ .../WhereUI/Tests/WhereStylesheetTests.swift | 8 ++ 51 files changed, 1263 insertions(+), 33 deletions(-) create mode 100644 Where/WhereCore/Sources/Forecasting/LocationForecast.swift create mode 100644 Where/WhereCore/Sources/Forecasting/PlannedStay.swift create mode 100644 Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift create mode 100644 Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift create mode 100644 Where/WhereCore/Tests/LocationForecastTests.swift create mode 100644 Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift create mode 100644 Where/WhereCore/Tests/PlannedStayRecordTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png create mode 100644 Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift create mode 100644 Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift create mode 100644 Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift create mode 100644 Where/WhereUI/Sources/Model/LocationForecastModel.swift create mode 100644 Where/WhereUI/Tests/LocationForecastModelTests.swift diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 4b2ed47e0..85f99cdfb 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -23,7 +23,8 @@ # old joined key and recovering any legacy epoch value to a calendar day. # - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes # `primaryRegions` from the tracked ids (null appearance, listed order) when -# absent, and sets `formatVersion` to 2 (the current version). +# absent, adds an empty planned-stay register for pre-v3 archives, and sets +# `formatVersion` to 3 (the current version). # # Idempotent: re-running on an already-upgraded archive is a no-op (it only # touches legacy `date` / `key` fields and unmapped region ids). @@ -40,7 +41,7 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 2 +CURRENT_FORMAT_VERSION = 3 # Former enum-case region ids -> current catalog ids. `canada` / `other` are # unchanged but listed so an already-current id passes through untouched. @@ -178,6 +179,7 @@ def upgrade_manifest(manifest) manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index| { "region" => id, "appearance" => nil, "order" => index } end + manifest["plannedStayRecords"] ||= [] manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 0357a0d3e..e1eb358a6 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -50,6 +50,10 @@ internal shape. The archive is strict synthesized `Codable` — no in-code legacy decode; a shape change bumps `BackupArchive.currentFormatVersion` and extends [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb) instead. +- **The planned stay is a last-writer register with tombstones.** Resolve + duplicate CloudKit revisions by `updatedAt` then UUID, and clear or expire by + writing a newer `nil` value; deleting the winner can resurrect stale intent. + Guards: `PlannedStayCoordinatorTests`. - **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (Y-M-D) is the timezone-independent identity every stored user record and day comparison keys on; persisting a `Date` makes a day drift across time-zone diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index a49033d66..6feed4380 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -75,6 +75,11 @@ one it belongs to rather than to a god-object: - **`DayAggregator`** — turns samples + manual overlays into those reports, carrying the injected `Calendar` (which decides how a `sample.timestamp` buckets into a `CalendarDay`). +- **`LocationForecast` / `PlannedStayCoordinator`** — annualizes a region's + current-year day count after three complete months, optionally counting one + synced “here through” stay before resuming the year-to-date pace. Forecasts + stay independent per region, so a future residency-percentage goal can + compare against them without changing the estimate. ### Location diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index db37184ef..1f2f44a7e 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -17,11 +17,12 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// `BackupService.readArchive`, which rejects any other version). /// /// v2 adds `primaryRegions` (each tracked region's picked appearance + pick - /// order). There's no in-app decode fallback for a pre-v2 archive — it's + /// order); v3 adds `plannedStayRecords`. There's no in-app decode fallback + /// for an older archive — it's /// reshaped out of band by `Tools/upgrade-backup.rb` (which synthesizes /// `primaryRegions` from `trackedRegions`), matching the module's /// no-migration-on-read rule (see `AGENTS.md`). - public static let currentFormatVersion = 2 + public static let currentFormatVersion = 3 public let formatVersion: Int public let exportedAt: Date @@ -40,6 +41,9 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// brings back the *look*, not just the region set. Import restores from /// this; `trackedRegions` is the derived id list. public let primaryRegions: [PrimaryRegion] + /// Revisions of the synced planned-stay register, including its clearing + /// tombstone, so restore cannot resurrect an older active stay. + public let plannedStayRecords: [PlannedStayRecord] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -53,6 +57,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { dismissedIssues: [DismissedIssue], trackedRegions: [Region], primaryRegions: [PrimaryRegion], + plannedStayRecords: [PlannedStayRecord] = [], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -63,6 +68,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.dismissedIssues = dismissedIssues self.trackedRegions = trackedRegions self.primaryRegions = primaryRegions + self.plannedStayRecords = plannedStayRecords self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index fd7b97436..e28c52fa7 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -80,7 +80,8 @@ public actor BackupCoordinator { /// here and jump to `1` once the archive file exists. private static let exportBlobLoadFraction = 0.8 - /// Serialize the entire store (all four tables plus evidence blobs) to a + /// Serialize the entire store (including planned-stay revisions and + /// evidence blobs) to a /// `.zip` in a fresh temporary directory and return its URL, first purging /// the previous export's directory. The caller shares the file; the next /// export (or process exit) reclaims the disk. @@ -117,6 +118,7 @@ public actor BackupCoordinator { manualDays: store.allManualDays(), dismissedIssues: store.allDismissedIssues(), primaryRegions: store.primaryRegions(), + plannedStayRecords: store.plannedStayRecords(), ) } let evidence = tables.evidence @@ -145,6 +147,7 @@ public actor BackupCoordinator { // The bare ids ride alongside the primary regions for older readers. trackedRegions: tables.primaryRegions.map(\.region), primaryRegions: tables.primaryRegions, + plannedStayRecords: tables.plannedStayRecords, blobs: blobs, ) }.value @@ -162,6 +165,7 @@ public actor BackupCoordinator { let manualDays: [DayPresence] let dismissedIssues: [DismissedIssue] let primaryRegions: [PrimaryRegion] + let plannedStayRecords: [PlannedStayRecord] } /// Delete the most recent export's staging directory now, rather than @@ -228,6 +232,7 @@ public actor BackupCoordinator { let blobs = result.blobs let total = archive.samples.count + archive.evidence.count + archive.manualDays.count + archive.dismissedIssues.count + + archive.plannedStayRecords.count try await Self.logger.measure(.importWrite) { try await store.perform { @@ -263,6 +268,10 @@ public actor BackupCoordinator { try await store.restoreDismissedIssue(dismissal) report() } + for plannedStay in archive.plannedStayRecords { + try await store.restorePlannedStayRecord(plannedStay) + report() + } // Primary regions (with their picked looks) round-trip like any // other data. On `.replace` the store was cleared above, so write // the archive's set exactly; on `.merge` union it into the current diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 5b80978b4..ca85fa394 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -82,6 +82,7 @@ public struct BackupService: Sendable { dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], primaryRegions: [PrimaryRegion] = [], + plannedStayRecords: [PlannedStayRecord] = [], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -116,6 +117,7 @@ public struct BackupService: Sendable { dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, primaryRegions: primaryRegions, + plannedStayRecords: plannedStayRecords, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { diff --git a/Where/WhereCore/Sources/Forecasting/LocationForecast.swift b/Where/WhereCore/Sources/Forecasting/LocationForecast.swift new file mode 100644 index 000000000..4cc615775 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/LocationForecast.swift @@ -0,0 +1,68 @@ +import Foundation +import RegionKit + +/// A region's independently calculated current-year residency estimate. +/// +/// This result deliberately contains only the estimate and its inputs. A future +/// residency goal (for example, “55% of the year”) can compare against it +/// without becoming another forecasting policy or changing planned-stay math. +public struct LocationForecast: Hashable, Sendable { + public let region: Region + public let year: Int + public let yearToDateDays: Int + public let elapsedDays: Int + public let plannedDays: Int + public let projectedRemainingDays: Double + public let estimatedTotalDays: Int + + public var estimatedFractionOfYear: Double { + let daysInYear = CalendarDay.yearRange(year).lowerBound + .days(through: CalendarDay.lastDay(ofYear: year)).count + guard daysInYear > 0 else { return 0 } + return Double(estimatedTotalDays) / Double(daysInYear) + } + + /// Estimate a current year's total once three complete calendar months have + /// elapsed. Returns `nil` before April 1 and for any non-current report. + public static func estimate( + region: Region, + report: YearReport, + asOf date: Date, + calendar: Calendar, + plannedStay: PlannedStay?, + ) -> LocationForecast? { + let today = CalendarDay(from: date, in: calendar) + guard report.year == today.year else { return nil } + guard today >= CalendarDay(year: report.year, month: 4, day: 1) else { return nil } + + let firstDay = CalendarDay(year: report.year, month: 1, day: 1) + let lastDay = CalendarDay.lastDay(ofYear: report.year) + let elapsedDays = firstDay.days(through: today).count + let yearLength = firstDay.days(through: lastDay).count + guard elapsedDays > 0, yearLength > 0 else { return nil } + + let yearToDateDays = report.totals[region, default: 0] + 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 plannedEnd = matchingStay.map { min($0.through, lastDay) } + let plannedDays = plannedEnd.map { tomorrow.days(through: $0).count } ?? 0 + let projectionStart = plannedEnd?.adding(days: 1) ?? tomorrow + let remainingDays = projectionStart.days(through: lastDay).count + let projectedRemainingDays = baselineRate * Double(remainingDays) + let estimated = Double(yearToDateDays + plannedDays) + projectedRemainingDays + + return LocationForecast( + region: region, + year: report.year, + yearToDateDays: yearToDateDays, + elapsedDays: elapsedDays, + plannedDays: plannedDays, + projectedRemainingDays: projectedRemainingDays, + estimatedTotalDays: min(yearLength, max(0, Int(estimated.rounded()))), + ) + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStay.swift b/Where/WhereCore/Sources/Forecasting/PlannedStay.swift new file mode 100644 index 000000000..bb87cf3df --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStay.swift @@ -0,0 +1,15 @@ +import Foundation +import RegionKit + +/// User intent that the current stay in `region` continues through an inclusive +/// calendar day. The day is timezone-independent so travel cannot move the +/// asserted departure onto a neighboring date. +public struct PlannedStay: Hashable, Sendable, Codable { + public let region: Region + public let through: CalendarDay + + public init(region: Region, through: CalendarDay) { + self.region = region + self.through = through + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift new file mode 100644 index 000000000..a55c3f41a --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift @@ -0,0 +1,49 @@ +import Foundation +import RegionKit + +/// Reads and writes the single CloudKit-synced planned-stay register. +public struct PlannedStayCoordinator: Sendable { + private let store: any WhereStore + private let calendar: Calendar + private let now: @Sendable () -> Date + + init(store: any WhereStore, calendar: Calendar, now: @escaping @Sendable () -> Date) { + self.store = store + self.calendar = calendar + self.now = now + } + + /// The active stay as of the injected clock. An expired value is replaced + /// with a tombstone before returning so every device converges on “cleared.” + public func active() async throws -> PlannedStay? { + guard let record = try await latestRecord() else { return nil } + guard let stay = record.value else { return nil } + let today = CalendarDay(from: now(), in: calendar) + guard stay.through < today else { return stay } + try await write(value: nil) + return nil + } + + /// Replace any prior intent with a stay through the inclusive day. + public func set(region: Region, through: CalendarDay) async throws { + try await write(value: PlannedStay(region: region, through: through)) + } + + /// Clear the active stay with a synced tombstone. + public func clear() async throws { + try await write(value: nil) + } + + private func latestRecord() async throws -> PlannedStayRecord? { + try await store.plannedStayRecords().max { lhs, rhs in + PlannedStayRecord.newer(rhs, than: lhs) + } + } + + private func write(value: PlannedStay?) async throws { + let record = PlannedStayRecord(id: UUID(), value: value, updatedAt: now()) + try await store.perform { + try await store.replacePlannedStayRecord(with: record) + } + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift new file mode 100644 index 000000000..a0719c4b8 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift @@ -0,0 +1,23 @@ +import Foundation + +/// One revision of the single synced planned-stay register. A `nil` value is a +/// tombstone, retained so a delayed CloudKit import cannot resurrect an older +/// active stay after it was cleared or expired. +public struct PlannedStayRecord: Hashable, Sendable, Codable, Identifiable { + public let id: UUID + public let value: PlannedStay? + public let updatedAt: Date + + public init(id: UUID, value: PlannedStay?, updatedAt: Date) { + self.id = id + self.value = value + self.updatedAt = updatedAt + } + + /// Deterministic last-writer ordering for duplicate rows produced by + /// eventually-consistent CloudKit writes. + public static func newer(_ lhs: PlannedStayRecord, than rhs: PlannedStayRecord) -> Bool { + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id.uuidString > rhs.id.uuidString + } +} diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 90939052e..9c950235a 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -261,6 +261,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDManualDay.self, SDDismissedIssue.self, SDTrackedRegion.self, + SDPlannedStay.self, ] } @@ -699,6 +700,45 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { for tracked in try context.fetch(FetchDescriptor()) { context.delete(tracked) } + for plannedStay in try context.fetch(FetchDescriptor()) { + context.delete(plannedStay) + } + } + + // MARK: - Planned stay + + public func plannedStayRecords() async throws -> [PlannedStayRecord] { + let context = readContext() + var descriptor = FetchDescriptor(sortBy: [ + SortDescriptor(\.updatedAt), + SortDescriptor(\.id), + ]) + descriptor.includePendingChanges = true + return try context.fetch(descriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + } + + public func replacePlannedStayRecord(with record: PlannedStayRecord) async throws { + let context = mutationContext() + for existing in try context.fetch(FetchDescriptor()) { + context.delete(existing) + } + context.insert(SDPlannedStay(value: record)) + } + + public func restorePlannedStayRecord(_ record: PlannedStayRecord) async throws { + let context = mutationContext() + let id = record.id + let existing = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.id == id }, + )) + for duplicate in existing { + context.delete(duplicate) + } + context.insert(SDPlannedStay(value: record)) } public func dismissedIssueIDs() async throws -> Set { @@ -1164,3 +1204,41 @@ final class SDTrackedRegion { orderIndex = order } } + +/// One CloudKit-compatible revision of the single planned-stay register. Every +/// field is optional as required by the mirrored schema. `nil` region/day +/// together represents a tombstone; only a mismatched pair is corrupt. +@Model +final class SDPlannedStay { + var id: UUID? + var regionID: String? + var throughDayKey: String? + var updatedAt: Date? + + init() {} + + convenience init(value: PlannedStayRecord) { + self.init() + id = value.id + regionID = value.value?.region.rawValue + throughDayKey = value.value?.through.description + updatedAt = value.updatedAt + } + + func toValue() -> PlannedStayRecord? { + guard let id, let updatedAt else { return nil } + let stay: PlannedStay? + switch (regionID, throughDayKey) { + case (nil, nil): + stay = nil + case let (regionID?, throughDayKey?): + guard let region = Region(rawValue: regionID), + let through = CalendarDay(iso: throughDayKey) + else { return nil } + stay = PlannedStay(region: region, through: through) + case (.some, nil), (nil, .some): + return nil + } + return PlannedStayRecord(id: id, value: stay, updatedAt: updatedAt) + } +} diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index f7f1300c1..2f9f7b0a9 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -88,6 +88,20 @@ public protocol WhereStore: Sendable { /// verbatim. func allDismissedIssues() async throws -> [DismissedIssue] + /// Every revision of the single planned-stay register. Multiple rows can + /// temporarily exist after CloudKit merges; callers choose the newest + /// `PlannedStayRecord` deterministically. + func plannedStayRecords() async throws -> [PlannedStayRecord] + + /// Replace local planned-stay revisions with `record`, retaining tombstones + /// so an older remote row cannot resurrect cleared intent. Must run inside + /// `perform { ... }`. + func replacePlannedStayRecord(with record: PlannedStayRecord) async throws + + /// Upsert an exact planned-stay revision during backup import. Must run + /// inside `perform { ... }`. + func restorePlannedStayRecord(_ record: PlannedStayRecord) async throws + /// Persist or remove a dismissed data-resolution issue. Must run inside /// `perform { ... }`. Upserts when `dismissed == true` (stamping the current /// date); deletes when false. @@ -157,4 +171,12 @@ extension WhereStore { /// Default: a no-op. `SwiftDataStore` overrides this to replace the persisted /// rows; test fakes that don't exercise persistence inherit the no-op. public func setPrimaryRegions(_: [PrimaryRegion]) async throws {} + + public func plannedStayRecords() async throws -> [PlannedStayRecord] { + [] + } + + public func replacePlannedStayRecord(with _: PlannedStayRecord) async throws {} + + public func restorePlannedStayRecord(_: PlannedStayRecord) async throws {} } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index c7f6203c6..4682926b7 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -33,6 +33,9 @@ public struct WhereServices: Sendable { public let journal: DayJournal /// Backup export / import. public let backup: BackupCoordinator + /// The single synced “I’ll be here through…” intent used by location + /// forecasts. + public let plannedStays: PlannedStayCoordinator /// Data-quality issue detection for the Resolve tab. public let resolution: DataIssueScanner /// On-device summary of a selectable look-back window of tracked locations @@ -187,6 +190,11 @@ public struct WhereServices: Sendable { store: store, onImport: { await journal.reconcileAfterDayChange() }, ) + let plannedStays = PlannedStayCoordinator( + store: store, + calendar: aggregator.calendar, + now: now, + ) let recentActivity = RecentActivitySummarizer( store: store, attributor: attributor, @@ -205,6 +213,7 @@ public struct WhereServices: Sendable { self.ingestor = ingestor self.journal = journal self.backup = backup + self.plannedStays = plannedStays self.resolution = resolution self.recentActivity = recentActivity self.store = store diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index dbd2132ed..fd55085d4 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -47,6 +47,15 @@ struct BackupCoordinatorTests { dismissedAt: Date(timeIntervalSince1970: 1_700_000_000), ) + private static let plannedStay = PlannedStayRecord( + id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!, + value: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 9, day: 1), + ), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + ) + /// Seed all four tables (sample, evidence + blob, manual day, dismissed /// issue) directly into a store so backup tests don't depend on the journal. private static func seed(_ store: SwiftDataStore) async throws { @@ -59,6 +68,7 @@ struct BackupCoordinatorTests { regions: [.newYork], )) try await store.restoreDismissedIssue(dismissal) + try await store.restorePlannedStayRecord(plannedStay) } } @@ -84,6 +94,7 @@ struct BackupCoordinatorTests { #expect(try await destination.store.allDismissedIssues() == source.store .allDismissedIssues()) #expect(try await destination.store.allDismissedIssues() == [Self.dismissal]) + #expect(try await destination.store.plannedStayRecords() == [Self.plannedStay]) #expect(try await destination.store.evidenceBlob(for: Self.evidence.id) == Self.blob) // An import that lands new data runs the post-import hook once. #expect(await destination.onImport.count == 1) diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index a0757de48..517816ac2 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -239,7 +239,7 @@ struct BackupServiceTests { } @Test func manifestRoundTripsThroughJSON() throws { - let archive = BackupArchive( + let archive = try BackupArchive( exportedAt: Self.exportDate, samples: Self.sampleFixtures(), evidence: Self.evidenceFixtures(), @@ -258,6 +258,14 @@ struct BackupServiceTests { ), PrimaryRegion(region: .newYork, appearance: nil, order: 1), ], + plannedStayRecords: [PlannedStayRecord( + id: #require(UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")), + value: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 9, day: 1), + ), + updatedAt: Self.exportDate, + )], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", @@ -273,7 +281,7 @@ struct BackupServiceTests { let decoded = try decoder.decode(BackupArchive.self, from: data) #expect(decoded == archive) - #expect(decoded.formatVersion == 2) + #expect(decoded.formatVersion == BackupArchive.currentFormatVersion) } @Test func readingAFileThatIsNotAZipThrows() throws { diff --git a/Where/WhereCore/Tests/LocationForecastTests.swift b/Where/WhereCore/Tests/LocationForecastTests.swift new file mode 100644 index 000000000..ab423766d --- /dev/null +++ b/Where/WhereCore/Tests/LocationForecastTests.swift @@ -0,0 +1,126 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct LocationForecastTests { + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private static func date(_ month: Int, _ day: Int, year: Int = 2026) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + private static func report(days: Int = 91, year: Int = 2026) -> YearReport { + YearReport(year: year, days: [], totals: [.newYork: days]) + } + + @Test(arguments: [(1, 1), (3, 31)]) + func unavailableUntilThreeFullMonthsHaveElapsed(month: Int, day: Int) { + #expect(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(month, day), + calendar: Self.calendar, + plannedStay: nil, + ) == nil) + } + + @Test func becomesAvailableOnAprilFirst() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(4, 1), + calendar: Self.calendar, + plannedStay: nil, + )) + + #expect(forecast.elapsedDays == 91) + #expect(forecast.estimatedTotalDays == 365) + } + + @Test func unavailableForAPastReport() { + #expect(LocationForecast.estimate( + region: .newYork, + report: Self.report(year: 2025), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: nil, + ) == nil) + } + + @Test func annualizesElapsedCalendarDayRate() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: nil, + )) + + #expect(forecast.elapsedDays == 182) + #expect(forecast.yearToDateDays == 91) + #expect(forecast.plannedDays == 0) + #expect(forecast.projectedRemainingDays == 91.5) + #expect(forecast.estimatedTotalDays == 183) + } + + @Test func matchingStayCountsThroughItsInclusiveDateThenResumesBaseline() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 7, day: 10), + ), + )) + + #expect(forecast.plannedDays == 9) + #expect(forecast.projectedRemainingDays == 87) + #expect(forecast.estimatedTotalDays == 187) + } + + @Test func crossYearStayCountsEveryRemainingDayThisYear() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2027, month: 2, day: 1), + ), + )) + + #expect(forecast.plannedDays == 183) + #expect(forecast.projectedRemainingDays == 0) + #expect(forecast.estimatedTotalDays == 274) + } + + @Test func anotherRegionsStayDoesNotChangeTheEstimate() throws { + let baseline = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: nil, + )) + let withCaliforniaStay = 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), + ), + )) + + #expect(withCaliforniaStay == baseline) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift new file mode 100644 index 000000000..d824c86df --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift @@ -0,0 +1,80 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct PlannedStayCoordinatorTests { + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private static let now = calendar.date( + from: DateComponents(year: 2026, month: 7, day: 1, hour: 12), + )! + + private static func makeCoordinator( + store: SwiftDataStore, + now: Date = Self.now, + ) -> PlannedStayCoordinator { + PlannedStayCoordinator(store: store, calendar: calendar, now: { now }) + } + + @Test func setAndClearRoundTripThroughTheStore() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.makeCoordinator(store: store) + let through = CalendarDay(year: 2026, month: 7, day: 10) + + try await coordinator.set(region: .newYork, through: through) + #expect(try await coordinator.active() == PlannedStay(region: .newYork, through: through)) + + try await coordinator.clear() + #expect(try await coordinator.active() == nil) + let records = try await store.plannedStayRecords() + #expect(records.count == 1) + #expect(records.first?.value == nil) + } + + @Test func expiredStayWritesATombstoneEvenBeforeForecastEligibility() async throws { + let store = try SwiftDataStore.inMemory() + let march = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 3, day: 1, hour: 12), + )) + let coordinator = Self.makeCoordinator(store: store, now: march) + try await coordinator.set( + region: .newYork, + through: CalendarDay(year: 2026, month: 2, day: 28), + ) + + #expect(try await coordinator.active() == nil) + #expect(try await store.plannedStayRecords().first?.value == nil) + } + + @Test func newestSyncedRevisionWinsDeterministically() async throws { + let store = try SwiftDataStore.inMemory() + let older = try PlannedStayRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + value: PlannedStay( + region: .california, + through: CalendarDay(year: 2026, month: 8, day: 1), + ), + updatedAt: Self.now.addingTimeInterval(-1), + ) + let newer = try PlannedStayRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + value: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 9, day: 1), + ), + updatedAt: Self.now, + ) + try await store.perform { + try await store.restorePlannedStayRecord(newer) + try await store.restorePlannedStayRecord(older) + } + + let coordinator = Self.makeCoordinator(store: store) + #expect(try await coordinator.active() == newer.value) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayRecordTests.swift b/Where/WhereCore/Tests/PlannedStayRecordTests.swift new file mode 100644 index 000000000..dbfa88506 --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayRecordTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import WhereCore + +struct PlannedStayRecordTests { + @Test func newerUsesTheIdentifierToBreakTimestampTies() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let lower = try PlannedStayRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + value: nil, + updatedAt: updatedAt, + ) + let higher = try PlannedStayRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + value: nil, + updatedAt: updatedAt, + ) + + #expect(PlannedStayRecord.newer(higher, than: lower)) + #expect(!PlannedStayRecord.newer(lower, than: higher)) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 1dec361ed..8b7c99e4e 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -4,7 +4,7 @@ WhereUI is the SwiftUI layer of the Where feature: the screens, the shared components and widget views, and the `@Observable` view models that orchestrate `WhereCore` for them (`WhereModel`, the `WhereSession` coordinator, and the scoped `YearReportModel` / `ResolveModel` / -`BackupModel` / `RemindersSettingsModel`). Layering, localization, preview, +`LocationForecastModel` / `BackupModel` / `RemindersSettingsModel`). Layering, localization, preview, and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) — read that and the root [`AGENTS.md`](../../AGENTS.md) first. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index d9ccdb363..2c47fda8c 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -73,7 +73,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `startTracking()` / `stopTracking()`, `refreshWidgetSnapshot()`). It holds no presentation state of its own. - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected - year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus + year's `YearReport`, its `LoadState`, and the manual-day edit intents) with a + focused **`LocationForecastModel`** child for the synced planned stay, plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** (export/import), and **`RemindersSettingsModel`** (notification prefs). Each orchestrates `WhereServices`; none reimplements Core rules. diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png new file mode 100644 index 000000000..f7904e925 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:190a473ed3f90cd4a9739e46fe5b138f4c97f8930b5bc43fe245ce243723bf54 +size 377174 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png new file mode 100644 index 000000000..7470e0d0f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61bd204fc9b510ada64912e172aa68f630d757d206809ef21ba0eba9ce44e69a +size 308961 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index 8cd77dce6..44c8179d0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d45d9e9b20df9a30bf7a96f18ec63f1705294f800ad6cafeca7598556428d685 -size 199908 +oid sha256:fbd97eef1936628d202f182b0b2d4e96910ce21bd3137dccac3f77583f222702 +size 288952 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png index d1ad26a62..aaba33b58 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e193af0cb5abb6526c0df8eeb23d31d22a09dd6c8066e38845262740e13ea152 -size 188886 +oid sha256:f4ca7454ee7f231c4eddb7e5a2ec283d8e0e816f29384cd2cf09ff83d4033d15 +size 244267 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index b620ad844..2cdf13251 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1d333f072d4f3a484050ea77e3bab34f41feb0cc8b312e8a7c08d0d942eb3f6 -size 3411142 +oid sha256:0c4ba9164aeeaee0e94193a4f2950b2850e5a4545da938415d1e7e0f2f5249fc +size 3519061 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index a486bb065..8e9b0a575 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f284751fb6e582782d6c50abed9bb8eb311578e25a0eab8668ce288fdaedf3c -size 2316172 +oid sha256:bb61fa97309c499098d2d6a5ea6560b5da4e04f59eab0fb833f2448d044eb120 +size 2527734 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 83ab2943e..f09628c19 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d529a7845264f909dd6426dcc6d84e322b2d483c7192d372749393e9c9c6fff -size 3764161 +oid sha256:9c9ca2f743b69380933148c01ef04c88aebbfe122da156d4ccda4fda6467a963 +size 4333904 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 1bf3b1859..7eee894e7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd20479556e72b37f66510531806cb6bd9e9559c107a813a8a4390b3049721e4 -size 3396140 +oid sha256:343d7e2e8e8e4904ee2d3f3b098ca58273b235a4a5b1a0440c58910fb1a1fc42 +size 3504547 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 59c6866e2..7ecbc7289 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d1b93f787124b3a89025762e8b431539b4ec1794fff606cc4d4fc2f189c2381 -size 3790884 +oid sha256:d5fdeafb9f1bb2520d6a1b2e36ba11fe951832641a2c8aed6a721a6a322abc85 +size 3873284 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 68cdb35d3..984b75384 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0900251620d9d375d28f86f9735704ee4cf1d07339fc855a5cfd01841c0f6525 -size 2142029 +oid sha256:d816c34c34091f15410780be5bf5c7aee11484b6f53a39f719ded35832a03d3d +size 2419916 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index 06cedbf60..e644a3349 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b91622f65fe9522ad5bf10f7a525998c13c5a445e71945c9b64cfcace5e9be1 -size 1407081 +oid sha256:f2a98e97d5b3795d88969beede3372e36a760554ae1b2ee7e8dfb020f2efe921 +size 1610631 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 75538b7c1..d6d3aa8eb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3f3efbd89321bef942f4893c598b11ec49ff9123c57df6e739c57abd4d04f71 -size 2552779 +oid sha256:85d6640eae187081a6e77aab83781d5586bb25b93fa59e5f63f4c1b8cc79520b +size 1972642 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 747da9ae9..194fc818a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a3189fac99b485d4f1fd3c0ae8538389170332e1d57e6ba7f2955e668f1d425 -size 2082184 +oid sha256:7c9025fbb72f36e095af4ad95c9662fcefb548475f205cba88b039a3ee92de02 +size 2398746 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 3f1e6f9a0..5de522f58 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62430aefbeffe0ad9923b64533cdb1c9f31db017f8104c280c9d50f9259a3832 -size 2308676 +oid sha256:8de027835228b3dd33a084ce43ccf8f3f8184262d75aa4a3e839ff943b78449b +size 2442306 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png new file mode 100644 index 000000000..f4c622077 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f60d542f3ad6007995dbda57351abf3cdd0f4b8fe31818bbd29256438dcaf8a +size 2466453 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png new file mode 100644 index 000000000..c0f264bf4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3322894475bdc6bfdb1315fe031bcfc3202e542e0cf6b17a40ece6b68f221b7d +size 2468972 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift new file mode 100644 index 000000000..bec945016 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -0,0 +1,97 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Annual location estimates shared by the Locations summary and a focused +/// region calendar. An optional edit action belongs only to the current region. +struct LocationForecastPanel: View { + let forecasts: [LocationForecast] + var plannedStay: PlannedStay? + var editableRegion: Region? + var editAction: (() -> Void)? + + @Environment(\.stylesheet) private var stylesheet + + private var style: WhereStylesheet.LocationForecastStyle { + stylesheet.locationForecast + } + + var body: some View { + VStack(alignment: .leading, spacing: style.rowSpacing) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "chart.line.uptrend.xyaxis") + .accessibilityHidden(true) + Text(String(localized: .locationForecastTitle)) + } + .font(.headline) + + ForEach(forecasts, id: \.region) { forecast in + LocationForecastRow( + forecast: forecast, + plannedStay: plannedStay, + ) + } + + if editableRegion != nil, let editAction { + Button( + String(localized: .locationForecastEditStay), + systemImage: "calendar.badge.clock", + action: editAction, + ) + .buttonStyle(.bordered) + } + } + .padding(style.padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + Color.clear.glassEffect( + .regular, + in: RoundedRectangle(cornerRadius: style.cornerRadius), + ) + } + } +} + +private struct LocationForecastRow: View { + let forecast: LocationForecast + var plannedStay: PlannedStay? + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + VStack(alignment: .leading, spacing: stylesheet.locationForecast.estimateSpacing) { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: stylesheet.locationForecast.estimateSpacing) { + Text(forecast.region.localizedName) + .font(.subheadline.bold()) + Text(WhereFormat.locationForecastEstimate(days: forecast.estimatedTotalDays)) + .font(.subheadline) + .monospacedDigit() + } + } else { + HStack(alignment: .firstTextBaseline) { + Text(forecast.region.localizedName) + .font(.subheadline.bold()) + Spacer(minLength: stylesheet.spacing.large) + Text(WhereFormat.locationForecastEstimate(days: forecast.estimatedTotalDays)) + .font(.subheadline) + .monospacedDigit() + } + } + Text(WhereFormat.locationForecastBasis( + yearToDateDays: forecast.yearToDateDays, + elapsedDays: forecast.elapsedDays, + )) + .font(.footnote) + .foregroundStyle(.secondary) + + if let plannedStay, plannedStay.region == forecast.region { + Text(WhereFormat.locationForecastPlan(through: plannedStay.through)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + } +} diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift new file mode 100644 index 000000000..b1b443b73 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift @@ -0,0 +1,99 @@ +import RegionKit +import SwiftUI + +/// Sheet for setting or removing the inclusive departure day for the currently +/// focused region. +struct PlannedStayEditor: View { + private enum SaveState: Equatable { + case idle + case saving + case failed(String) + } + + let region: Region + let model: LocationForecastModel + + @Environment(\.dismiss) private var dismiss + @State private var through: Date + @State private var saveState: SaveState = .idle + + init(region: Region, model: LocationForecastModel) { + self.region = region + self.model = model + _through = State(initialValue: model.departureDate(for: region)) + } + + var body: some View { + NavigationStack { + Form { + Section { + WhereDatePicker( + String(localized: .locationForecastEditorDate), + selection: $through, + earliest: model.minimumDepartureDate, + displayedComponents: .date, + ) + } footer: { + Text(String(localized: .locationForecastEditorFooter)) + } + + if case let .failed(message) = saveState { + Section { + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + + if model.activePlannedStay?.region == region { + Section { + Button( + String(localized: .locationForecastRemovePlan), + role: .destructive, + action: removePlan, + ) + } + } + } + .navigationTitle(String(localized: .locationForecastEditorTitle)) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(saveState == .saving) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: .commonCancel), action: dismiss.callAsFunction) + .disabled(saveState == .saving) + } + ToolbarItem(placement: .confirmationAction) { + if saveState == .saving { + ProgressView() + } else { + Button(String(localized: .commonSave), action: save) + } + } + } + } + } + + private func save() { + saveState = .saving + Task { + do { + try await model.set(region: region, through: through) + dismiss() + } catch { + saveState = .failed(error.localizedDescription) + } + } + } + + private func removePlan() { + saveState = .saving + Task { + do { + try await model.clear() + dismiss() + } catch { + saveState = .failed(error.localizedDescription) + } + } + } +} diff --git a/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift b/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift new file mode 100644 index 000000000..530091f07 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +enum LocationForecastModelLog: LogEvent { + case loadFailed(description: String) + case saveFailed(description: String) + case clearFailed(description: String) + + static let eventName = "LocationForecast" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(description): + "Failed to load the planned stay: \(description)" + case let .saveFailed(description): + "Failed to save the planned stay: \(description)" + case let .clearFailed(description): + "Failed to clear the planned stay: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Model/LocationForecastModel.swift b/Where/WhereUI/Sources/Model/LocationForecastModel.swift new file mode 100644 index 000000000..3d1efeffc --- /dev/null +++ b/Where/WhereUI/Sources/Model/LocationForecastModel.swift @@ -0,0 +1,105 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// Scene-scoped observable state for the synced planned stay. Forecast math +/// remains a pure WhereCore derivation so future residency goals can compare +/// with the result without becoming persistence or UI policy. +@MainActor +@Observable +final class LocationForecastModel { + private(set) var activePlannedStay: PlannedStay? + + private let services: WhereServices + private let calendar: Calendar + private let now: @Sendable () -> Date + private static let logger = WhereLog.session(LocationForecastModelLog.self) + + init( + services: WhereServices, + calendar: Calendar, + now: @escaping @Sendable () -> Date, + ) { + self.services = services + self.calendar = calendar + self.now = now + } + + func refresh() async { + do { + let stay = try await services.plannedStays.active() + if activePlannedStay != stay { activePlannedStay = stay } + } catch { + Self.logger { .loadFailed(description: error.localizedDescription) } + } + } + + func forecast(for region: Region, report: YearReport?) -> LocationForecast? { + guard let report else { return nil } + return LocationForecast.estimate( + region: region, + report: report, + asOf: now(), + calendar: calendar, + plannedStay: activePlannedStay, + ) + } + + /// Up to three present, named regions for the Locations-tab summary. + /// Independent from `RegionRanking.primaryCount`, which still owns the two + /// large cards. + func leadingForecasts(report: YearReport?, limit: Int = 3) -> [LocationForecast] { + guard let report else { return [] } + return RegionRanking.ranked(report: report) + .filter { $0.region != .other } + .prefix(limit) + .compactMap { forecast(for: $0.region, report: report) } + } + + func isCurrent(_ region: Region, report: YearReport?) -> Bool { + guard let report else { return false } + let today = CalendarDay(from: now(), in: calendar) + return report.days.first(where: { $0.day == today })?.regions.contains(region) == true + } + + func departureDate(for region: Region) -> Date { + guard let stay = activePlannedStay, stay.region == region else { + return calendar.startOfDay(for: now()) + } + return stay.through.startOfDay(in: calendar) + } + + var minimumDepartureDate: Date { + calendar.startOfDay(for: now()) + } + + func set(region: Region, through date: Date) async throws { + let day = CalendarDay(from: date, in: calendar) + do { + try await services.plannedStays.set(region: region, through: day) + activePlannedStay = PlannedStay(region: region, through: day) + } catch { + Self.logger { .saveFailed(description: error.localizedDescription) } + throw error + } + } + + func clear() async throws { + do { + try await services.plannedStays.clear() + activePlannedStay = nil + } catch { + Self.logger { .clearFailed(description: error.localizedDescription) } + throw error + } + } +} + +#if DEBUG + extension LocationForecastModel { + func setActivePlannedStay(_ stay: PlannedStay?) { + activePlannedStay = stay + } + } +#endif diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 7a73c1ea0..6fb6b2ba9 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -97,6 +97,9 @@ public final class YearReportModel { /// Gregorian calendar in the current time zone — matches the day keys the /// aggregator produces in `report.days`, so the missing-day math lines up. let calendar: Calendar + /// Synced planned-stay state and pure forecast projections for the + /// Locations surfaces. + let forecasts: LocationForecastModel /// Long-lived subscription to `services.dataChangeUpdates()` while the scene /// is active. `@ObservationIgnored` (plumbing, not UI state) and @@ -205,6 +208,7 @@ public final class YearReportModel { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current self.calendar = calendar + forecasts = LocationForecastModel(services: services, calendar: calendar, now: now) loadState = report == nil ? .idle : .loaded } @@ -269,6 +273,7 @@ public final class YearReportModel { await refresh() await refreshEvidenceDayKeys() await refreshDataIssueCount(force: forceDataIssueCount) + await forecasts.refresh() } } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 0d9651822..ed6b2f050 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -245,6 +245,16 @@ ) } + @MainActor + public static func plannedStayYearReportModel() -> YearReportModel { + let report = loadedYearReportModel() + report.forecasts.setActivePlannedStay(PlannedStay( + region: .newYork, + through: CalendarDay(year: year, month: 8, day: 15), + )) + return report + } + /// An empty report model (in-memory services, no data) for empty-state /// previews. @MainActor diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 860ddefd4..315283b13 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -19,6 +19,7 @@ struct CalendarContentView: View { @Environment(\.stylesheet) private var stylesheet @State private var monthsLoad: Result<[CalendarMonth], Error>? + @State private var showingPlannedStayEditor = false private static let logger = WhereLog.session(CalendarViewLog.self) @@ -79,6 +80,11 @@ struct CalendarContentView: View { // Log View Mode: reveal an inspect badge for this calendar's events. A // no-op in release. .debugLogInspectable(WhereLog.session(CalendarViewLog.self)) + .sheet(isPresented: $showingPlannedStayEditor) { + if let focusedRegion { + PlannedStayEditor(region: focusedRegion, model: report.forecasts) + } + } } private func calendarLoadID(report yearReport: YearReport) -> CalendarLoadID { @@ -117,6 +123,19 @@ struct CalendarContentView: View { private func calendarContent(months: [CalendarMonth]) -> some View { ScrollView { LazyVStack(spacing: stylesheet.calendar.monthSpacing) { + if let focusedForecast { + LocationForecastPanel( + forecasts: [focusedForecast], + plannedStay: report.forecasts.activePlannedStay, + editableRegion: report.forecasts.isCurrent( + focusedForecast.region, + report: report.report, + ) ? focusedForecast.region : nil, + editAction: { + showingPlannedStayEditor = true + }, + ) + } ForEach(shownMonths(months)) { month in MonthGridView(month: month, focusedRegion: focusedRegion) } @@ -125,6 +144,11 @@ struct CalendarContentView: View { } } + private var focusedForecast: LocationForecast? { + guard let focusedRegion else { return nil } + return report.forecasts.forecast(for: focusedRegion, report: report.report) + } + /// The months to show, newest first. Future months are omitted; a past year /// has no future months, so it shows the full year from December backward. private func shownMonths(_ months: [CalendarMonth]) -> [CalendarMonth] { @@ -480,6 +504,14 @@ private struct DayCell: View { ) } } + whereSnapshot(name: "FocusedPlannedStay", configurations: .phoneLightDark) { + NavigationStack { + CalendarContentView( + focusedRegion: .newYork, + report: PreviewSupport.plannedStayYearReportModel(), + ) + } + } // The shown months in one image. The full-content frame measures the // scroll view's content height, so every lazy month materializes and // nothing scrolls — which needs the chrome-free view, since a diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 35a13b5a5..580e998e9 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -157,6 +157,22 @@ struct LocationsView: View { .defaultScrollAnchor(.center) .scrollBounceBehavior(.basedOnSize) .accessibilityIdentifier("where_root_title") + .safeAreaInset(edge: .bottom) { + if !topForecasts.isEmpty { + LocationForecastPanel( + forecasts: topForecasts, + plannedStay: report.forecasts.activePlannedStay, + ) + .padding(.horizontal) + .padding(.bottom, stylesheet.spacing.small) + } + } + } + + /// Three forecast rows are independent from the two-card Primary split. + /// `.other` is a catch-all rather than a place a user can plan around. + private var topForecasts: [LocationForecast] { + report.forecasts.leadingForecasts(report: report.report) } /// The region's calendar, pushed as a nested view. It's the zoom @@ -235,6 +251,9 @@ private struct ResolveToolbarLabel: View { ) { LocationsView(report: PreviewSupport.loadedYearReportModel()) } + whereSnapshot(name: "PlannedStay", configurations: .phoneLightDark) { + LocationsView(report: PreviewSupport.plannedStayYearReportModel()) + } whereSnapshot(name: "Empty", configurations: .phoneLightDark) { LocationsView(report: PreviewSupport.emptyYearReportModel()) } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index eaf075311..2cc2ae96c 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2867,6 +2867,108 @@ } } }, + "locationForecast.basis" : { + "comment" : "Short explanation below a location forecast. The first value is the region's year-to-date day count and the second is the elapsed calendar-day count.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Based on %1$@ here across %2$@ elapsed." + } + } + } + }, + "locationForecast.editStay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "I’ll be here through…" + } + } + } + }, + "locationForecast.editor.date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Here through" + } + } + } + }, + "locationForecast.editor.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The selected day is included in your planned stay. The estimate resumes your year-to-date pace the following day." + } + } + } + }, + "locationForecast.editor.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Plan This Stay" + } + } + } + }, + "locationForecast.estimate" : { + "comment" : "Annual location forecast; the placeholder is an already-localized day count, for example '183 days'.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About %@ this year" + } + } + } + }, + "locationForecast.plan" : { + "comment" : "Explains the planned-stay input included in an annual location forecast.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Includes staying through %@." + } + } + } + }, + "locationForecast.removePlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Plan" + } + } + } + }, + "locationForecast.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Estimated Time" + } + } + } + }, "locations.elsewhere.subtitle" : { "comment" : "Subtitle on the Locations tab's Elsewhere entry card: region count.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 64f33d238..7c39cb2db 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -60,6 +60,26 @@ enum WhereFormat { String(localized: .manualRangeFooter(count)) } + static func locationForecastEstimate(days: Int) -> String { + String(localized: .locationForecastEstimate(dayCount(days))) + } + + static func locationForecastBasis(yearToDateDays: Int, elapsedDays: Int) -> String { + String(localized: .locationForecastBasis( + dayCount(yearToDateDays), + dayCount(elapsedDays), + )) + } + + static func locationForecastPlan(through day: CalendarDay) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let date = day.startOfDay(in: calendar) + return String(localized: .locationForecastPlan( + date.formatted(.dateTime.month(.wide).day().year()), + )) + } + static func settingsBackupImportedMessage( samples: Int, evidence: Int, diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 173d57dcf..2522081b0 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -22,6 +22,7 @@ struct WhereStylesheet: BStylesheet { var regionPicker = RegionPickerStyle.standard var evidence = EvidenceStyle.standard var elsewhereCard = ElsewhereCardStyle.standard + var locationForecast = LocationForecastStyle.standard var palette = Palette.standard var motion = Motion.standard var launch = LaunchStyle.standard @@ -68,6 +69,26 @@ struct WhereStylesheet: BStylesheet { static let `default` = WhereStylesheet() } +// MARK: - Location forecast + +extension WhereStylesheet { + /// Geometry for the annual-estimate panel shared by the Locations tab and + /// region-focused calendars. + struct LocationForecastStyle: Equatable { + var cornerRadius: CGFloat + var padding: CGFloat + var rowSpacing: CGFloat + var estimateSpacing: CGFloat + + static let standard = LocationForecastStyle( + cornerRadius: 22, + padding: 16, + rowSpacing: 12, + estimateSpacing: 3, + ) + } +} + extension WhereStylesheet { /// Generic spacing scale, in points. struct Spacing: Equatable { diff --git a/Where/WhereUI/Tests/LocationForecastModelTests.swift b/Where/WhereUI/Tests/LocationForecastModelTests.swift new file mode 100644 index 000000000..d6e1c3ec5 --- /dev/null +++ b/Where/WhereUI/Tests/LocationForecastModelTests.swift @@ -0,0 +1,103 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct LocationForecastModelTests { + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + + private static let now = calendar.date( + from: DateComponents(year: 2026, month: 7, day: 15, hour: 12), + )! + + private static func services(store: any WhereStore) -> WhereServices { + WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + now: { now }, + ) + } + + private static func report() -> YearReport { + YearReport( + year: 2026, + days: [DayPresence(date: now, in: calendar, regions: [.newYork])], + totals: [ + .other: 150, + .california: 100, + .newYork: 90, + .canada: 80, + .europeanUnion: 70, + ], + ) + } + + @Test func leadingForecastsExcludeElsewhereWithoutChangingPrimaryCards() throws { + let services = try Self.services(store: SwiftDataStore.inMemory()) + let model = LocationForecastModel( + services: services, + calendar: Self.calendar, + now: { Self.now }, + ) + let report = Self.report() + + #expect(model.leadingForecasts(report: report).map(\.region) == [ + .california, + .newYork, + .canada, + ]) + #expect(RegionRanking(report: report).primary.count == RegionRanking.primaryCount) + } + + @Test func onlyARegionRecordedTodayCanEditAStay() throws { + let model = try LocationForecastModel( + services: Self.services(store: SwiftDataStore.inMemory()), + calendar: Self.calendar, + now: { Self.now }, + ) + + #expect(model.isCurrent(.newYork, report: Self.report())) + #expect(model.isCurrent(.california, report: Self.report()) == false) + } + + @Test func saveAndClearUpdateTheObservableValueImmediately() async throws { + let model = try LocationForecastModel( + services: Self.services(store: SwiftDataStore.inMemory()), + calendar: Self.calendar, + now: { Self.now }, + ) + let through = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 8, day: 1), + )) + + try await model.set(region: .newYork, through: through) + #expect(model.activePlannedStay == PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 8, day: 1), + )) + + try await model.clear() + #expect(model.activePlannedStay == nil) + } + + @Test func failedSaveKeepsTheLastGoodValue() async throws { + let store = try TestStore() + await store.failPlannedStays() + let model = LocationForecastModel( + services: Self.services(store: store), + calendar: Self.calendar, + now: { Self.now }, + ) + + await #expect(throws: PlannedStaySaveFailure.self) { + try await model.set(region: .newYork, through: Self.now) + } + #expect(model.activePlannedStay == nil) + } +} diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 2eb7ff1f5..66a4f68b9 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -8,6 +8,8 @@ struct ManualSaveFailure: Error, Equatable {} /// year-report load can be forced to fail. struct SampleReadFailure: Error, Equatable {} +struct PlannedStaySaveFailure: Error, Equatable {} + /// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds /// two hooks the view-model tests need: /// @@ -28,6 +30,7 @@ actor TestStore: WhereStore { private var shouldFailManualDay = false private var shouldFailSamples = false + private var shouldFailPlannedStay = false init() throws { backing = try SwiftDataStore.inMemory() @@ -60,6 +63,10 @@ actor TestStore: WhereStore { shouldFailSamples = true } + func failPlannedStays() { + shouldFailPlannedStay = true + } + // MARK: - WhereStore func perform(_ block: @Sendable () async throws -> T) async throws -> T { @@ -148,4 +155,17 @@ actor TestStore: WhereStore { func restoreDismissedIssue(_ issue: DismissedIssue) async throws { try await backing.restoreDismissedIssue(issue) } + + func plannedStayRecords() async throws -> [PlannedStayRecord] { + try await backing.plannedStayRecords() + } + + func replacePlannedStayRecord(with record: PlannedStayRecord) async throws { + if shouldFailPlannedStay { throw PlannedStaySaveFailure() } + try await backing.replacePlannedStayRecord(with: record) + } + + func restorePlannedStayRecord(_ record: PlannedStayRecord) async throws { + try await backing.restorePlannedStayRecord(record) + } } diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index c37de8983..7ad7e15fa 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -37,6 +37,19 @@ struct WhereFormatTests { #expect(WhereFormat.dayUnit(2) == "days") } + @Test func locationForecastCopyComposesLocalizedDayCounts() { + #expect(WhereFormat.locationForecastEstimate(days: 183) == "About 183 days this year") + #expect( + WhereFormat.locationForecastBasis(yearToDateDays: 91, elapsedDays: 182) + == "Based on 91 days here across 182 days elapsed.", + ) + #expect( + WhereFormat.locationForecastPlan( + through: CalendarDay(year: 2026, month: 8, day: 15), + ) == "Includes staying through August 15, 2026.", + ) + } + /// The one string that agrees grammatically via automatic inflection /// (`^[%lld region](inflect: true)`) rather than an explicit plural /// variation, so both forms are worth pinning. diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 32fe6573e..6ba9a0cd7 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -247,6 +247,14 @@ struct WhereStylesheetTests { #expect(month.unfocusedRowOpacity == 0.55) } + @Test func locationForecastStyle() { + let forecast = style.locationForecast + #expect(forecast.cornerRadius == 22) + #expect(forecast.padding == 16) + #expect(forecast.rowSpacing == 12) + #expect(forecast.estimateSpacing == 3) + } + @Test func appIconStyle() { let appIcon = style.appIcon #expect(appIcon.gridMax == 180) From 41c5e4ca74ac296a94d34ccc22988c97bdd2a07b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 17:54:17 -0700 Subject: [PATCH 02/20] Make planned stay expiry conditional --- .../Forecasting/PlannedStayCoordinator.swift | 21 ++++++++++++++- .../Tests/PlannedStayCoordinatorTests.swift | 27 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift index a55c3f41a..85135190b 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift @@ -20,7 +20,7 @@ public struct PlannedStayCoordinator: Sendable { guard let stay = record.value else { return nil } let today = CalendarDay(from: now(), in: calendar) guard stay.through < today else { return stay } - try await write(value: nil) + try await expireIfLatest(record, asOf: today) return nil } @@ -40,6 +40,25 @@ public struct PlannedStayCoordinator: Sendable { } } + /// Clear `expiredRecord` only if it is still the winning revision. The + /// transactional re-read prevents a stale `active()` read from erasing a + /// newer stay saved while that read was suspended. + func expireIfLatest( + _ expiredRecord: PlannedStayRecord, + asOf today: CalendarDay, + ) async throws { + try await store.perform { + guard try await latestRecord() == expiredRecord else { return } + guard let stay = expiredRecord.value, stay.through < today else { return } + let tombstone = PlannedStayRecord( + id: UUID(), + value: nil, + updatedAt: max(now(), expiredRecord.updatedAt.addingTimeInterval(0.001)), + ) + try await store.replacePlannedStayRecord(with: tombstone) + } + } + private func write(value: PlannedStay?) async throws { let record = PlannedStayRecord(id: UUID(), value: value, updatedAt: now()) try await store.perform { diff --git a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift index d824c86df..fdcf3ea29 100644 --- a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift +++ b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift @@ -46,9 +46,34 @@ struct PlannedStayCoordinatorTests { region: .newYork, through: CalendarDay(year: 2026, month: 2, day: 28), ) + let expiredRecord = try #require(await store.plannedStayRecords().first) #expect(try await coordinator.active() == nil) - #expect(try await store.plannedStayRecords().first?.value == nil) + let tombstone = try #require(await store.plannedStayRecords().first) + #expect(tombstone.value == nil) + #expect(tombstone.updatedAt > expiredRecord.updatedAt) + } + + @Test func staleExpiryCannotOverwriteANewerStay() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.makeCoordinator(store: store) + try await coordinator.set( + region: .california, + through: CalendarDay(year: 2026, month: 6, day: 30), + ) + let staleExpiredRecord = try #require(await store.plannedStayRecords().first) + + let futureStay = PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 8, day: 1), + ) + try await coordinator.set(region: futureStay.region, through: futureStay.through) + try await coordinator.expireIfLatest( + staleExpiredRecord, + asOf: CalendarDay(year: 2026, month: 7, day: 1), + ) + + #expect(try await coordinator.active() == futureStay) } @Test func newestSyncedRevisionWinsDeterministically() async throws { From a70174bed1875bbc61110a4f609e2eb398a1a5a5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 17:57:02 -0700 Subject: [PATCH 03/20] Add forecast panel preview --- .../Sources/Forecasting/LocationForecastPanel.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index bec945016..931c713be 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -95,3 +95,16 @@ private struct LocationForecastRow: View { .accessibilityElement(children: .combine) } } + +#if DEBUG + #Preview { + let report = PreviewSupport.plannedStayYearReportModel() + LocationForecastPanel( + forecasts: report.forecasts.leadingForecasts(report: report.report), + plannedStay: report.forecasts.activePlannedStay, + editableRegion: .newYork, + editAction: {}, + ) + .padding() + } +#endif From f0bad2b8f516ee0b520875e133ddce3eee52ce09 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 17:58:16 -0700 Subject: [PATCH 04/20] Add planned stay editor preview --- Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift index b1b443b73..f8f13d107 100644 --- a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift @@ -97,3 +97,10 @@ struct PlannedStayEditor: View { } } } + +#if DEBUG + #Preview { + let report = PreviewSupport.plannedStayYearReportModel() + PlannedStayEditor(region: .newYork, model: report.forecasts) + } +#endif From d841da528b162593c1ce467135b61b7f84419755 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 20:01:40 -0700 Subject: [PATCH 05/20] Make location forecasts conversational --- ...endarContent.FocusedPlannedStay_iPhone.png | 4 ++-- ...Content.FocusedPlannedStay_iPhone_dark.png | 4 ++-- .../calendarContent.Focused_iPhone.png | 4 ++-- .../calendarContent.Focused_iPhone_dark.png | 4 ++-- .../locations.Loaded_iPad.png | 4 ++-- .../locations.Loaded_iPad_accessibility.png | 4 ++-- .../locations.Loaded_iPad_ax5.png | 4 ++-- .../locations.Loaded_iPad_contrast.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone.png | 4 ++-- .../locations.Loaded_iPhone_accessibility.png | 4 ++-- .../locations.Loaded_iPhone_ax5.png | 4 ++-- .../locations.Loaded_iPhone_contrast.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.PlannedStay_iPhone.png | 4 ++-- .../locations.PlannedStay_iPhone_dark.png | 4 ++-- .../Forecasting/LocationForecastPanel.swift | 24 ++++--------------- .../Sources/Resources/Localizable.xcstrings | 4 ++-- .../WhereUI/Sources/Shared/WhereFormat.swift | 7 ++++-- Where/WhereUI/Tests/WhereFormatTests.swift | 10 +++++++- 20 files changed, 53 insertions(+), 56 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index f7904e925..a316caf3b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:190a473ed3f90cd4a9739e46fe5b138f4c97f8930b5bc43fe245ce243723bf54 -size 377174 +oid sha256:5f49b62033b01833c8f6f5033e7d4ad8dfeebef952300c7dd71c5dfa375d2b30 +size 378369 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index 7470e0d0f..deeccef69 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61bd204fc9b510ada64912e172aa68f630d757d206809ef21ba0eba9ce44e69a -size 308961 +oid sha256:9764789c48954874f1273caa818d592777fbcd03e8b179f09131c4f787839db6 +size 310036 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index 44c8179d0..709668946 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbd97eef1936628d202f182b0b2d4e96910ce21bd3137dccac3f77583f222702 -size 288952 +oid sha256:da480fbf020e3af3ec7e98301382e4751e48f9e42e2aaf45a47b7bff80886ff0 +size 289777 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png index aaba33b58..f7528d860 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4ca7454ee7f231c4eddb7e5a2ec283d8e0e816f29384cd2cf09ff83d4033d15 -size 244267 +oid sha256:ef06106d2d00e9a8e562b3dd949cbd10b432810bdfb4ca03b5193e57a3892323 +size 245005 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 2cdf13251..4b7c805c2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c4ba9164aeeaee0e94193a4f2950b2850e5a4545da938415d1e7e0f2f5249fc -size 3519061 +oid sha256:63c08dd86ced78212c1cfdc436c4d1aa49573de1e9467f852fe54f175755e319 +size 3521411 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index 8e9b0a575..ade155780 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb61fa97309c499098d2d6a5ea6560b5da4e04f59eab0fb833f2448d044eb120 -size 2527734 +oid sha256:ae831837612475fdfb8ce2bb010860d87ffcce655da617662f22e3c1f85f6bd8 +size 2533027 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index f09628c19..0e12c9fa4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c9ca2f743b69380933148c01ef04c88aebbfe122da156d4ccda4fda6467a963 -size 4333904 +oid sha256:6412a57944603508083d3b0306b154cb69f6d4b95e8e0ee1f0ce7276ba8a15d7 +size 4495106 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 7eee894e7..d6fcd8805 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:343d7e2e8e8e4904ee2d3f3b098ca58273b235a4a5b1a0440c58910fb1a1fc42 -size 3504547 +oid sha256:a2ce6de2e5a75f279fc565663d835c1bb8cb26f302b253fe03bc3553bef840cb +size 3507460 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 7ecbc7289..c85dc5538 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5fdeafb9f1bb2520d6a1b2e36ba11fe951832641a2c8aed6a721a6a322abc85 -size 3873284 +oid sha256:9dec73dd9dacf86ba816fc4c09b99c73e92b5710aeb576c70e82a0bb6c9c8ea0 +size 3876164 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 984b75384..2de3fb9ba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d816c34c34091f15410780be5bf5c7aee11484b6f53a39f719ded35832a03d3d -size 2419916 +oid sha256:924be54047a5dc44eb585ab2cc765acd6231f9e0c1b133bd649007faa15caafe +size 2422649 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index e644a3349..6f81a2140 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2a98e97d5b3795d88969beede3372e36a760554ae1b2ee7e8dfb020f2efe921 -size 1610631 +oid sha256:8039bf161ac87045d3c2debe544478e229a533e24d856ce11f0eeaa989f8358a +size 1617151 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index d6d3aa8eb..5a37c1ad7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85d6640eae187081a6e77aab83781d5586bb25b93fa59e5f63f4c1b8cc79520b -size 1972642 +oid sha256:22c5948eecded26429253988aeba388cb1301d18eb433bcc6091b34ced4622ba +size 1959427 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 194fc818a..f869113b2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c9025fbb72f36e095af4ad95c9662fcefb548475f205cba88b039a3ee92de02 -size 2398746 +oid sha256:d7c442b377a1ef1f2b0dc77e3f31dd31d6a793d98718296bdc6cc206d3ab7f77 +size 2402215 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 5de522f58..1b30863e9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8de027835228b3dd33a084ce43ccf8f3f8184262d75aa4a3e839ff943b78449b -size 2442306 +oid sha256:aac4cff36c80804419e301dff28516bdba8977ff0b4524bae38178fb1f2e56b2 +size 2445589 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index f4c622077..9bd9fdccb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f60d542f3ad6007995dbda57351abf3cdd0f4b8fe31818bbd29256438dcaf8a -size 2466453 +oid sha256:de47bc439393c2ceecf01a63d9275ecee81f3eba8b98e69e5ce4a65ee26a6ff3 +size 2470230 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index c0f264bf4..cd6900f30 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3322894475bdc6bfdb1315fe031bcfc3202e542e0cf6b17a40ece6b68f221b7d -size 2468972 +oid sha256:38a49d2a848a97180df5b99d322ab75df830e06acdd607419605f7f851d60c88 +size 2471540 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index 931c713be..47ba5531e 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -57,28 +57,14 @@ private struct LocationForecastRow: View { var plannedStay: PlannedStay? @Environment(\.stylesheet) private var stylesheet - @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { VStack(alignment: .leading, spacing: stylesheet.locationForecast.estimateSpacing) { - if dynamicTypeSize.isAccessibilitySize { - VStack(alignment: .leading, spacing: stylesheet.locationForecast.estimateSpacing) { - Text(forecast.region.localizedName) - .font(.subheadline.bold()) - Text(WhereFormat.locationForecastEstimate(days: forecast.estimatedTotalDays)) - .font(.subheadline) - .monospacedDigit() - } - } else { - HStack(alignment: .firstTextBaseline) { - Text(forecast.region.localizedName) - .font(.subheadline.bold()) - Spacer(minLength: stylesheet.spacing.large) - Text(WhereFormat.locationForecastEstimate(days: forecast.estimatedTotalDays)) - .font(.subheadline) - .monospacedDigit() - } - } + Text(WhereFormat.locationForecastEstimate( + region: forecast.region, + days: forecast.estimatedTotalDays, + )) + .font(.subheadline) Text(WhereFormat.locationForecastBasis( yearToDateDays: forecast.yearToDateDays, elapsedDays: forecast.elapsedDays, diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 2cc2ae96c..91594b30b 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2924,13 +2924,13 @@ } }, "locationForecast.estimate" : { - "comment" : "Annual location forecast; the placeholder is an already-localized day count, for example '183 days'.", + "comment" : "Annual location forecast sentence. The first placeholder is a region name; the second is an already-localized day count, for example '183 days', and is emphasized.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "About %@ this year" + "value" : "%1$@ might be **%2$@** this year" } } } diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 7c39cb2db..c35d45840 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -60,8 +60,11 @@ enum WhereFormat { String(localized: .manualRangeFooter(count)) } - static func locationForecastEstimate(days: Int) -> String { - String(localized: .locationForecastEstimate(dayCount(days))) + static func locationForecastEstimate(region: Region, days: Int) -> AttributedString { + AttributedString(localized: .locationForecastEstimate( + region.localizedName, + dayCount(days), + )) } static func locationForecastBasis(yearToDateDays: Int, elapsedDays: Int) -> String { diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index 7ad7e15fa..5f4745ea2 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -38,7 +38,15 @@ struct WhereFormatTests { } @Test func locationForecastCopyComposesLocalizedDayCounts() { - #expect(WhereFormat.locationForecastEstimate(days: 183) == "About 183 days this year") + let estimate = WhereFormat.locationForecastEstimate(region: .newYork, days: 183) + #expect(String(estimate.characters) == "New York might be 183 days this year") + let emphasized = estimate.runs.compactMap { run -> String? in + guard run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true else { + return nil + } + return String(estimate[run.range].characters) + } + #expect(emphasized == ["183 days"]) #expect( WhereFormat.locationForecastBasis(yearToDateDays: 91, elapsedDays: 182) == "Based on 91 days here across 182 days elapsed.", From e5b71839e0e79f5e02cb34b4fded14ff0bb7fc47 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 20:23:22 -0700 Subject: [PATCH 06/20] Show elapsed forecast time once --- ...endarContent.FocusedPlannedStay_iPhone.png | 4 +- ...Content.FocusedPlannedStay_iPhone_dark.png | 4 +- .../calendarContent.Focused_iPhone.png | 4 +- .../calendarContent.Focused_iPhone_dark.png | 4 +- .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_accessibility.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_accessibility.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.PlannedStay_iPhone.png | 4 +- .../locations.PlannedStay_iPhone_dark.png | 4 +- .../Forecasting/LocationForecastPanel.swift | 43 ++++++++++++++++--- .../Sources/Resources/Localizable.xcstrings | 16 ++++++- .../WhereUI/Sources/Shared/WhereFormat.swift | 11 ++--- Where/WhereUI/Tests/WhereFormatTests.swift | 5 ++- 20 files changed, 92 insertions(+), 47 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index a316caf3b..38e5bdeaf 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f49b62033b01833c8f6f5033e7d4ad8dfeebef952300c7dd71c5dfa375d2b30 -size 378369 +oid sha256:796b70821657abf6f855719b5b6460c0ebffe118457c1b73ea2b3c506a291a59 +size 378006 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index deeccef69..1da72d5e9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9764789c48954874f1273caa818d592777fbcd03e8b179f09131c4f787839db6 -size 310036 +oid sha256:94a85eb1cab84ca27dbe5099484d49053aae615eef3ff7206bb3a892ab2e5a15 +size 312091 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index 709668946..90ca1c3ff 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da480fbf020e3af3ec7e98301382e4751e48f9e42e2aaf45a47b7bff80886ff0 -size 289777 +oid sha256:8500df8178e2610b26d68a3827c6659f83a4f73ee395aff590007b8dd29e254d +size 290863 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png index f7528d860..df613d0b4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef06106d2d00e9a8e562b3dd949cbd10b432810bdfb4ca03b5193e57a3892323 -size 245005 +oid sha256:9356ae1743de23833cc8d83c10664660ef8036ec2c5a380281def95471da3864 +size 246439 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 4b7c805c2..747b9b89a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63c08dd86ced78212c1cfdc436c4d1aa49573de1e9467f852fe54f175755e319 -size 3521411 +oid sha256:a0e86bbd9f28c7b455d9d4133516e94aa15e6692b003c5c66d439dad840d290a +size 3516639 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index ade155780..8c2ace0fb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae831837612475fdfb8ce2bb010860d87ffcce655da617662f22e3c1f85f6bd8 -size 2533027 +oid sha256:f88898d7f346a26a2274544300040313954a2c9247904a7b36fde3252fd07376 +size 2517977 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 0e12c9fa4..68a69feb2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6412a57944603508083d3b0306b154cb69f6d4b95e8e0ee1f0ce7276ba8a15d7 -size 4495106 +oid sha256:57c2e37ab87953d07d18674a2ecc10a53ac0eecf0603a7dc59655a2bea0f9ffc +size 4674587 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index d6fcd8805..2eabd479f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2ce6de2e5a75f279fc565663d835c1bb8cb26f302b253fe03bc3553bef840cb -size 3507460 +oid sha256:18e1d3324e4d23d3aff430f0d90d1af3cee4219bee3ca3e5aeac72c887e45c08 +size 3503115 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index c85dc5538..88df84724 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9dec73dd9dacf86ba816fc4c09b99c73e92b5710aeb576c70e82a0bb6c9c8ea0 -size 3876164 +oid sha256:e78e8f4ef0951d04c9b157d675708ddf54456497dab1e2096312e601f3789097 +size 3871338 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 2de3fb9ba..540b288b2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:924be54047a5dc44eb585ab2cc765acd6231f9e0c1b133bd649007faa15caafe -size 2422649 +oid sha256:8c41be2ff7c64926bd4c03735a4590c7495fd8bab0b036bac688b12e9bb1ac59 +size 2414325 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index 6f81a2140..4e71b9a5a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8039bf161ac87045d3c2debe544478e229a533e24d856ce11f0eeaa989f8358a -size 1617151 +oid sha256:41181a1b5b4e2b6d0b0884a3bccfbd19b6b95a777062a29e1ffd79794e82a5fd +size 1605631 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 5a37c1ad7..51e7669da 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22c5948eecded26429253988aeba388cb1301d18eb433bcc6091b34ced4622ba -size 1959427 +oid sha256:491df8785c28e88fa9051a4adb4750e57d4c439cacae7e7b3dc5b51a0db8e474 +size 1920925 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index f869113b2..9d44c4394 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7c442b377a1ef1f2b0dc77e3f31dd31d6a793d98718296bdc6cc206d3ab7f77 -size 2402215 +oid sha256:6788893ddeb6792657590d15956b495d0f2a1e634b7b3da0df1161f65ca04523 +size 2391493 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 1b30863e9..6faceab28 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aac4cff36c80804419e301dff28516bdba8977ff0b4524bae38178fb1f2e56b2 -size 2445589 +oid sha256:b52e6674495a4f1a269962adc813bf1ce490ae9115871af99356ca6760672991 +size 2439977 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index 9bd9fdccb..9984d8051 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de47bc439393c2ceecf01a63d9275ecee81f3eba8b98e69e5ce4a65ee26a6ff3 -size 2470230 +oid sha256:f42157d1231de1817b3182fb52bc49fa08c14c033b982e2770607fd3fdba4eed +size 2461273 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index cd6900f30..cfd45b174 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38a49d2a848a97180df5b99d322ab75df830e06acdd607419605f7f851d60c88 -size 2471540 +oid sha256:669b2693e61d81ec9757361bcee7b384a8b2bfa4ed7a26dfcbe605a3bb101584 +size 2466671 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index 47ba5531e..86ab9ab19 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -18,12 +18,44 @@ struct LocationForecastPanel: View { var body: some View { VStack(alignment: .leading, spacing: style.rowSpacing) { - HStack(alignment: .firstTextBaseline) { - Image(systemName: "chart.line.uptrend.xyaxis") - .accessibilityHidden(true) - Text(String(localized: .locationForecastTitle)) + if let elapsedDays = forecasts.first?.elapsedDays { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "chart.line.uptrend.xyaxis") + .accessibilityHidden(true) + Text(String(localized: .locationForecastTitle)) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: stylesheet.spacing.large) + Text(WhereFormat.locationForecastElapsed(days: elapsedDays)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + .fixedSize(horizontal: true, vertical: false) + } + + VStack(alignment: .leading, spacing: style.estimateSpacing) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "chart.line.uptrend.xyaxis") + .accessibilityHidden(true) + Text(String(localized: .locationForecastTitle)) + } + Text(WhereFormat.locationForecastElapsed(days: elapsedDays)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .font(.headline) + } else { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "chart.line.uptrend.xyaxis") + .accessibilityHidden(true) + Text(String(localized: .locationForecastTitle)) + } + .font(.headline) } - .font(.headline) ForEach(forecasts, id: \.region) { forecast in LocationForecastRow( @@ -67,7 +99,6 @@ private struct LocationForecastRow: View { .font(.subheadline) Text(WhereFormat.locationForecastBasis( yearToDateDays: forecast.yearToDateDays, - elapsedDays: forecast.elapsedDays, )) .font(.footnote) .foregroundStyle(.secondary) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 91594b30b..710b456ed 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -2868,13 +2868,13 @@ } }, "locationForecast.basis" : { - "comment" : "Short explanation below a location forecast. The first value is the region's year-to-date day count and the second is the elapsed calendar-day count.", + "comment" : "Short explanation below a location forecast. The placeholder is the region's year-to-date day count.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Based on %1$@ here across %2$@ elapsed." + "value" : "Based on %@ here." } } } @@ -2923,6 +2923,18 @@ } } }, + "locationForecast.elapsed" : { + "comment" : "Shared elapsed-time context in the annual location forecast header. The placeholder is an already-localized day count, for example '182 days'.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ elapsed" + } + } + } + }, "locationForecast.estimate" : { "comment" : "Annual location forecast sentence. The first placeholder is a region name; the second is an already-localized day count, for example '183 days', and is emphasized.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index c35d45840..7d566f0e8 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -67,11 +67,12 @@ enum WhereFormat { )) } - static func locationForecastBasis(yearToDateDays: Int, elapsedDays: Int) -> String { - String(localized: .locationForecastBasis( - dayCount(yearToDateDays), - dayCount(elapsedDays), - )) + static func locationForecastElapsed(days: Int) -> String { + String(localized: .locationForecastElapsed(dayCount(days))) + } + + static func locationForecastBasis(yearToDateDays: Int) -> String { + String(localized: .locationForecastBasis(dayCount(yearToDateDays))) } static func locationForecastPlan(through day: CalendarDay) -> String { diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index 5f4745ea2..6f408afbb 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -47,9 +47,10 @@ struct WhereFormatTests { return String(estimate[run.range].characters) } #expect(emphasized == ["183 days"]) + #expect(WhereFormat.locationForecastElapsed(days: 182) == "182 days elapsed") #expect( - WhereFormat.locationForecastBasis(yearToDateDays: 91, elapsedDays: 182) - == "Based on 91 days here across 182 days elapsed.", + WhereFormat.locationForecastBasis(yearToDateDays: 91) + == "Based on 91 days here.", ) #expect( WhereFormat.locationForecastPlan( From 42a03f447fdbcf8c72e2b5c09d60eafaa2d7a1e5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 21:40:05 -0700 Subject: [PATCH 07/20] Make location forecasts optional and collapsible --- Where/WhereCore/README.md | 2 +- .../Preferences/WherePreferences.swift | 15 +- .../Tests/WherePreferencesTests.swift | 20 +++ .../locations.ForecastsHidden_iPhone.png | 3 + .../locations.ForecastsHidden_iPhone_dark.png | 3 + .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_accessibility.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_accessibility.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.PlannedStay_iPhone.png | 4 +- .../locations.PlannedStay_iPhone_dark.png | 4 +- .../Forecasting/LocationForecastPanel.swift | 130 +++++++++++------- .../Sources/Model/YearReportModel.swift | 13 ++ .../Sources/Primary/LocationsView.swift | 12 +- .../Sources/Resources/Localizable.xcstrings | 47 +++++++ .../Settings/LocationSettingsView.swift | 29 +++- .../Sources/Settings/SettingsView.swift | 2 +- .../Sources/Shared/WhereStylesheet.swift | 4 + .../Tests/LocationSettingsViewTests.swift | 6 +- Where/WhereUI/Tests/SettingsSearchTests.swift | 9 ++ .../WhereUI/Tests/WhereStylesheetTests.swift | 2 + .../WhereUI/Tests/YearReportModelTests.swift | 15 ++ 28 files changed, 276 insertions(+), 84 deletions(-) create mode 100644 Where/WhereCore/Tests/WherePreferencesTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 6feed4380..99fa16b9f 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -112,7 +112,7 @@ one it belongs to rather than to a god-object: `ZIPFoundation`). - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. -- **`WherePreferences`** — persisted user intent (onboarding, tracking intent, +- **`WherePreferences`** — persisted user intent (onboarding, tracking and forecast visibility, reminder / summary schedules) behind a `KeyValueStore`. The store has no default: production names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index ddaf769b4..38c9b5e05 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -1,7 +1,8 @@ import Foundation /// The app's persisted user intent — onboarding completion, background-tracking -/// intent, and the reminder / daily-summary schedules — behind a `KeyValueStore` +/// intent, forecast visibility, and the reminder / daily-summary schedules — +/// behind a `KeyValueStore` /// so production uses `UserDefaults` and tests use an in-memory double. /// /// `store` is deliberately not defaulted: defaulting it to @@ -36,6 +37,17 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.wantsTracking.rawValue) } } + /// Whether the annual-estimate summary appears on the Locations tab. + /// Defaults to `true` so an existing or fresh install sees the feature until + /// the user explicitly turns it off. + public var showsLocationForecastsOnLocationsTab: Bool { + get { + store.object(forKey: Keys.showsLocationForecastsOnLocationsTab.rawValue) as? Bool + ?? true + } + set { store.set(newValue, forKey: Keys.showsLocationForecastsOnLocationsTab.rawValue) } + } + /// Whether the daily "log before the day ends" reminder is enabled. Defaults /// to `true` so the safety net is active out of the box. public var remindersEnabled: Bool { @@ -116,6 +128,7 @@ public final class WherePreferences { private enum Keys: String, CaseIterable { case hasOnboarded = "where.hasOnboarded" case wantsTracking = "where.wantsBackgroundTracking" + case showsLocationForecastsOnLocationsTab = "where.showsLocationForecastsOnLocationsTab" case remindersEnabled = "where.remindersEnabled" case reminderHour = "where.reminderHour" case reminderMinute = "where.reminderMinute" diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift new file mode 100644 index 000000000..5ba1b8745 --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + @Test func locationForecastsAreShownByDefault() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + + #expect(preferences.showsLocationForecastsOnLocationsTab) + } + + @Test func locationForecastVisibilityPersistsAndResets() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + + preferences.showsLocationForecastsOnLocationsTab = false + #expect(preferences.showsLocationForecastsOnLocationsTab == false) + + preferences.reset() + #expect(preferences.showsLocationForecastsOnLocationsTab) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png new file mode 100644 index 000000000..68cdb35d3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0900251620d9d375d28f86f9735704ee4cf1d07339fc855a5cfd01841c0f6525 +size 2142029 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png new file mode 100644 index 000000000..ab54c334b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcfdb83f5e690da756086937af5c6e49a7b55deaa03712439530803891ac136d +size 2308445 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 747b9b89a..4e416ca45 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0e86bbd9f28c7b455d9d4133516e94aa15e6692b003c5c66d439dad840d290a -size 3516639 +oid sha256:4e95b9b1e93fa33bb2f7b641de808f2ae783d3af2c44db37cd20c75756ee33c1 +size 3430212 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index 8c2ace0fb..65e2e395e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f88898d7f346a26a2274544300040313954a2c9247904a7b36fde3252fd07376 -size 2517977 +oid sha256:caae5c549feb0080039e3cb224404bea0998a799df937ff7b6443d8bf6a15955 +size 2358892 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 68a69feb2..2e7086294 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57c2e37ab87953d07d18674a2ecc10a53ac0eecf0603a7dc59655a2bea0f9ffc -size 4674587 +oid sha256:da0ba2f58e3332b7fcc73183a34dc800a972920eba553b2acecffddbace811d0 +size 3808571 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 2eabd479f..2025fe104 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18e1d3324e4d23d3aff430f0d90d1af3cee4219bee3ca3e5aeac72c887e45c08 -size 3503115 +oid sha256:062037d03257869313cc97c0cd49c3bc2fe5e410188867682ae12f2cd11ee41a +size 3414953 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 88df84724..24f9de230 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e78e8f4ef0951d04c9b157d675708ddf54456497dab1e2096312e601f3789097 -size 3871338 +oid sha256:a151fca4d592bb205f4e3cca25f135466266d0461565d1ef38d7a62377bf8ae2 +size 3823111 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 540b288b2..320c148ba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c41be2ff7c64926bd4c03735a4590c7495fd8bab0b036bac688b12e9bb1ac59 -size 2414325 +oid sha256:fe128d464f5a6db1431d37d885dc6a4f285fb685e9ccbdf8497a5bafaea331e2 +size 2155623 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index 4e71b9a5a..9edff1ae8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41181a1b5b4e2b6d0b0884a3bccfbd19b6b95a777062a29e1ffd79794e82a5fd -size 1605631 +oid sha256:3a009cbbf4a673bae0b3a6241beb1c19b40022b13795c17a70496489ccc9a9c2 +size 1440719 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 51e7669da..534b49152 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:491df8785c28e88fa9051a4adb4750e57d4c439cacae7e7b3dc5b51a0db8e474 -size 1920925 +oid sha256:ca20cb9920507758b9e8eaeebc411588692bd8649093869053fd7240aacff614 +size 1916812 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 9d44c4394..42ec4392c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6788893ddeb6792657590d15956b495d0f2a1e634b7b3da0df1161f65ca04523 -size 2391493 +oid sha256:9c06b9278e88025af81105038c735e3f0d3fd89a294140f741363b4b23ed847f +size 2102144 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 6faceab28..2cf23b240 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b52e6674495a4f1a269962adc813bf1ce490ae9115871af99356ca6760672991 -size 2439977 +oid sha256:c59a7308b53b6dced217a4d5c75d523526262c0a3b8e1b6dabf0f71e3f6994d1 +size 2330178 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index 9984d8051..320c148ba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f42157d1231de1817b3182fb52bc49fa08c14c033b982e2770607fd3fdba4eed -size 2461273 +oid sha256:fe128d464f5a6db1431d37d885dc6a4f285fb685e9ccbdf8497a5bafaea331e2 +size 2155623 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index cfd45b174..57cf47736 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:669b2693e61d81ec9757361bcee7b384a8b2bfa4ed7a26dfcbe605a3bb101584 -size 2466671 +oid sha256:f0f6a29463a399be279241e46d40036acfa442e8e20737ab515b54c958b2de4a +size 2330181 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index 86ab9ab19..174b7de43 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -9,6 +9,9 @@ struct LocationForecastPanel: View { var plannedStay: PlannedStay? var editableRegion: Region? var editAction: (() -> Void)? + var isCollapsible = false + + @State private var isExpanded = false @Environment(\.stylesheet) private var stylesheet @@ -18,68 +21,97 @@ struct LocationForecastPanel: View { var body: some View { VStack(alignment: .leading, spacing: style.rowSpacing) { - if let elapsedDays = forecasts.first?.elapsedDays { - ViewThatFits(in: .horizontal) { - HStack(alignment: .firstTextBaseline) { - Image(systemName: "chart.line.uptrend.xyaxis") - .accessibilityHidden(true) - Text(String(localized: .locationForecastTitle)) - .fixedSize(horizontal: true, vertical: false) - Spacer(minLength: stylesheet.spacing.large) - Text(WhereFormat.locationForecastElapsed(days: elapsedDays)) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - .fixedSize(horizontal: true, vertical: false) - } - - VStack(alignment: .leading, spacing: style.estimateSpacing) { - HStack(alignment: .firstTextBaseline) { - Image(systemName: "chart.line.uptrend.xyaxis") - .accessibilityHidden(true) - Text(String(localized: .locationForecastTitle)) - } - Text(WhereFormat.locationForecastElapsed(days: elapsedDays)) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - .multilineTextAlignment(.trailing) - .frame(maxWidth: .infinity, alignment: .trailing) + if isCollapsible { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: style.rowSpacing) { + forecastContent } + .padding(.top, style.rowSpacing) } - .font(.headline) + label: { + forecastHeader + } + .tint(.primary) + } else { + forecastHeader + forecastContent + } + } + .padding(style.padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + let shape = RoundedRectangle(cornerRadius: style.cornerRadius) + if isCollapsible { + shape + .fill(.background) + .overlay { + shape.strokeBorder(.quaternary, lineWidth: style.borderWidth) + } } else { + Color.clear.glassEffect(.regular, in: shape) + } + } + .animation(style.expansionAnimation, value: isExpanded) + } + + @ViewBuilder + private var forecastHeader: some View { + if let elapsedDays = forecasts.first?.elapsedDays { + ViewThatFits(in: .horizontal) { HStack(alignment: .firstTextBaseline) { Image(systemName: "chart.line.uptrend.xyaxis") .accessibilityHidden(true) Text(String(localized: .locationForecastTitle)) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: stylesheet.spacing.large) + Text(WhereFormat.locationForecastElapsed(days: elapsedDays)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + .fixedSize(horizontal: true, vertical: false) } - .font(.headline) - } - ForEach(forecasts, id: \.region) { forecast in - LocationForecastRow( - forecast: forecast, - plannedStay: plannedStay, - ) + VStack(alignment: .leading, spacing: style.estimateSpacing) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "chart.line.uptrend.xyaxis") + .accessibilityHidden(true) + Text(String(localized: .locationForecastTitle)) + } + Text(WhereFormat.locationForecastElapsed(days: elapsedDays)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + } } - - if editableRegion != nil, let editAction { - Button( - String(localized: .locationForecastEditStay), - systemImage: "calendar.badge.clock", - action: editAction, - ) - .buttonStyle(.bordered) + .font(.headline) + } else { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "chart.line.uptrend.xyaxis") + .accessibilityHidden(true) + Text(String(localized: .locationForecastTitle)) } + .font(.headline) } - .padding(style.padding) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - Color.clear.glassEffect( - .regular, - in: RoundedRectangle(cornerRadius: style.cornerRadius), + } + + @ViewBuilder + private var forecastContent: some View { + ForEach(forecasts, id: \.region) { forecast in + LocationForecastRow( + forecast: forecast, + plannedStay: plannedStay, + ) + } + + if editableRegion != nil, let editAction { + Button( + String(localized: .locationForecastEditStay), + systemImage: "calendar.badge.clock", + action: editAction, ) + .buttonStyle(.bordered) } } } diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 6fb6b2ba9..d0cce03a5 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -117,6 +117,17 @@ public final class YearReportModel { /// of sync with the badge count. private var driftThresholdStorage: DriftThreshold + /// Observed mirror of the Locations tab's forecast-visibility preference. + /// `WherePreferences` is intentionally not observable, so Settings writes + /// through this property to update the mounted Locations tab immediately. + public var showsLocationForecastsOnLocationsTab: Bool { + didSet { + guard oldValue != showsLocationForecastsOnLocationsTab else { return } + preferences.showsLocationForecastsOnLocationsTab = + showsLocationForecastsOnLocationsTab + } + } + /// GPS border-drift detection threshold (device setting). The setter persists /// it, forces a badge recount, and — through the observed mirror — re-keys /// `dataIssueScanInputs` so the Resolve list re-scans immediately, not just on @@ -203,6 +214,8 @@ public final class YearReportModel { self.selectedYear = selectedYear self.preferences = preferences self.now = now + showsLocationForecastsOnLocationsTab = + preferences.showsLocationForecastsOnLocationsTab driftThresholdStorage = DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default var calendar = Calendar(identifier: .gregorian) diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 580e998e9..11658bdaf 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -158,10 +158,11 @@ struct LocationsView: View { .scrollBounceBehavior(.basedOnSize) .accessibilityIdentifier("where_root_title") .safeAreaInset(edge: .bottom) { - if !topForecasts.isEmpty { + if report.showsLocationForecastsOnLocationsTab, !topForecasts.isEmpty { LocationForecastPanel( forecasts: topForecasts, plannedStay: report.forecasts.activePlannedStay, + isCollapsible: true, ) .padding(.horizontal) .padding(.bottom, stylesheet.spacing.small) @@ -254,6 +255,9 @@ private struct ResolveToolbarLabel: View { whereSnapshot(name: "PlannedStay", configurations: .phoneLightDark) { LocationsView(report: PreviewSupport.plannedStayYearReportModel()) } + whereSnapshot(name: "ForecastsHidden", configurations: .phoneLightDark) { + LocationsView(report: forecastsHiddenReport()) + } whereSnapshot(name: "Empty", configurations: .phoneLightDark) { LocationsView(report: PreviewSupport.emptyYearReportModel()) } @@ -264,6 +268,12 @@ private struct ResolveToolbarLabel: View { LocationsView(report: PreviewSupport.elsewhereOnlyYearReportModel()) } } + + private static func forecastsHiddenReport() -> YearReportModel { + let report = PreviewSupport.loadedYearReportModel() + report.showsLocationForecastsOnLocationsTab = false + return report + } } #Preview { diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 710b456ed..e17e3981c 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -5941,6 +5941,18 @@ } } }, + "settings.keywords.locationForecasts" : { + "comment" : "Comma-separated Settings search keywords for the annual location forecast visibility toggle.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "estimate, estimates, estimated time, forecast, forecasts, annual, year" + } + } + } + }, "settings.keywords.loggedDays" : { "extractionState" : "manual", "localizations" : { @@ -6029,6 +6041,41 @@ } } }, + "settings.location.forecasts.footer" : { + "comment" : "Explains the scope of the annual location forecast visibility toggle.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show or hide the annual estimated-time summary at the bottom of the Locations tab." + } + } + } + }, + "settings.location.forecasts.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Estimated Time" + } + } + } + }, + "settings.location.forecasts.toggle" : { + "comment" : "Toggle that controls whether annual location forecasts appear on the Locations tab.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show on Locations tab" + } + } + } + }, "settings.location.grant" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/LocationSettingsView.swift b/Where/WhereUI/Sources/Settings/LocationSettingsView.swift index 4c01a13ae..b6fd40278 100644 --- a/Where/WhereUI/Sources/Settings/LocationSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/LocationSettingsView.swift @@ -1,10 +1,10 @@ import SwiftUI import WhereCore -/// Settings drill-in for location permission and background tracking: the live -/// status row, the tracking toggle, and the grant / open-Settings affordances -/// that depend on the current authorization. +/// Settings drill-in for location permission, background tracking, and the +/// Locations tab's annual-estimate visibility. struct LocationSettingsView: View { + let report: YearReportModel var focus: SettingsFocus? @Environment(WhereSession.self) private var session @@ -12,6 +12,7 @@ struct LocationSettingsView: View { var body: some View { @Bindable var session = session + @Bindable var report = report SettingsFocusScope(focus: focus) { Form { Section { @@ -54,6 +55,20 @@ struct LocationSettingsView: View { } footer: { Text(String(localized: .settingsLocationFooter)) } + + Section { + Toggle(isOn: $report.showsLocationForecastsOnLocationsTab) { + Label( + String(localized: .settingsLocationForecastsToggle), + systemImage: "chart.line.uptrend.xyaxis", + ) + } + .settingsRow(Item.forecasts) + } header: { + Text(String(localized: .settingsLocationForecastsHeader)) + } footer: { + Text(String(localized: .settingsLocationForecastsFooter)) + } } } .navigationTitle(String(localized: .settingsLocationHeader)) @@ -100,16 +115,20 @@ extension LocationSettingsView: SettingsSection { enum Item: SettingsItem { case tracking + case forecasts var title: String { switch self { case .tracking: String(localized: .settingsLocationToggle) + case .forecasts: String(localized: .settingsLocationForecastsToggle) } } var keywords: [String] { switch self { case .tracking: splitKeywords(String(localized: .settingsKeywordsTracking)) + case .forecasts: + splitKeywords(String(localized: .settingsKeywordsLocationForecasts)) } } } @@ -118,7 +137,7 @@ extension LocationSettingsView: SettingsSection { #if DEBUG #Preview { NavigationStack { - LocationSettingsView() + LocationSettingsView(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedSession()) } .whereBroadwayRoot() @@ -131,7 +150,7 @@ extension LocationSettingsView: SettingsSection { LocationSettingsView.self, title: "Location Settings", ) { _ in - LocationSettingsView() + LocationSettingsView(report: PreviewSupport.loadedYearReportModel()) } } #endif diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 5926012f8..905d612ba 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -221,7 +221,7 @@ struct SettingsView: View { case .loggedDays: LoggedDaysView(report: report) case .location: - LocationSettingsView(focus: route.focus) + LocationSettingsView(report: report, focus: route.focus) case .regions: // Regions is presented as a sheet (`isSheet`), so it's never // routed here; this arm only keeps the switch exhaustive. diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 2522081b0..90b43abd3 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -79,12 +79,16 @@ extension WhereStylesheet { var padding: CGFloat var rowSpacing: CGFloat var estimateSpacing: CGFloat + var borderWidth: CGFloat + var expansionAnimation: Animation static let standard = LocationForecastStyle( cornerRadius: 22, padding: 16, rowSpacing: 12, estimateSpacing: 3, + borderWidth: 1, + expansionAnimation: .easeInOut(duration: 0.2), ) } } diff --git a/Where/WhereUI/Tests/LocationSettingsViewTests.swift b/Where/WhereUI/Tests/LocationSettingsViewTests.swift index da4cc4784..10c195b7e 100644 --- a/Where/WhereUI/Tests/LocationSettingsViewTests.swift +++ b/Where/WhereUI/Tests/LocationSettingsViewTests.swift @@ -6,8 +6,10 @@ import Testing @MainActor struct LocationSettingsViewTests { @Test func hostsWithASession() throws { - let rootView = NavigationStack { LocationSettingsView() } - .environment(PreviewSupport.loadedSession()) + let rootView = NavigationStack { + LocationSettingsView(report: PreviewSupport.loadedYearReportModel()) + } + .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index 647d23acc..81750bbee 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -49,6 +49,15 @@ struct SettingsSearchTests { #expect(destinations.contains(.alerts)) } + @Test func matchesLocationForecastVisibilityOnEstimateKeyword() { + let results = SettingsCatalog.results(matching: "estimate") + + #expect(results.contains { + $0.destination == .location + && $0.title == String(localized: .settingsLocationForecastsToggle) + }) + } + @Test func matchesTheAboutScreenOnALicenseKeyword() { // "license" is nowhere in a section title, so this only passes if the // About screen's keywords are registered. diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 6ba9a0cd7..c7dd0013e 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -253,6 +253,8 @@ struct WhereStylesheetTests { #expect(forecast.padding == 16) #expect(forecast.rowSpacing == 12) #expect(forecast.estimateSpacing == 3) + #expect(forecast.borderWidth == 1) + #expect(forecast.expansionAnimation == .easeInOut(duration: 0.2)) } @Test func appIconStyle() { diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index c08216d38..5766b9417 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -258,6 +258,21 @@ struct YearReportModelTests { #expect(preferences.driftThresholdMeters == DriftThreshold.km25.rawValue) } + @Test func locationForecastVisibilityMirrorsAndPersists() throws { + let preferences = makePreferences() + preferences.showsLocationForecastsOnLocationsTab = false + let report = try YearReportModel( + services: makeServices(), + selectedYear: 2026, + preferences: preferences, + ) + + #expect(report.showsLocationForecastsOnLocationsTab == false) + + report.showsLocationForecastsOnLocationsTab = true + #expect(preferences.showsLocationForecastsOnLocationsTab) + } + /// The Resolve list keys its scan `.task(id:)` on `dataIssueScanInputs`, so a /// drift-threshold change must change that identity — otherwise the list keeps /// a stale scan while the badge count moves and the two visibly disagree. The From 53a137144efd0b3f20ee79d7249bab976dd9f2d4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 16:41:16 -0700 Subject: [PATCH 08/20] Refine location forecast card chrome --- .../locations.Loaded_iPad.png | 4 ++-- .../locations.Loaded_iPad_accessibility.png | 4 ++-- .../locations.Loaded_iPad_ax5.png | 4 ++-- .../locations.Loaded_iPad_contrast.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone.png | 4 ++-- .../locations.Loaded_iPhone_accessibility.png | 4 ++-- .../locations.Loaded_iPhone_ax5.png | 4 ++-- .../locations.Loaded_iPhone_contrast.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.PlannedStay_iPhone.png | 4 ++-- .../locations.PlannedStay_iPhone_dark.png | 4 ++-- .../Sources/Forecasting/LocationForecastPanel.swift | 7 ++++++- Where/WhereUI/Sources/Shared/WhereStylesheet.swift | 10 +++++++++- Where/WhereUI/Tests/WhereStylesheetTests.swift | 6 +++++- 15 files changed, 44 insertions(+), 27 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 4e416ca45..97e8c6e87 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e95b9b1e93fa33bb2f7b641de808f2ae783d3af2c44db37cd20c75756ee33c1 -size 3430212 +oid sha256:9e7236dd001d26d853c92c6963a56e4e085fcce98c6d7ccf06c5f44fb813d333 +size 3554662 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index 65e2e395e..f7a5b883b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:caae5c549feb0080039e3cb224404bea0998a799df937ff7b6443d8bf6a15955 -size 2358892 +oid sha256:b5616924332dda2ff625f08bc8569d41fd1ea2d6d98cb0dcebf5d4979a862a50 +size 2490207 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 2e7086294..620a87d39 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da0ba2f58e3332b7fcc73183a34dc800a972920eba553b2acecffddbace811d0 -size 3808571 +oid sha256:b16e9662e55228ca45fbddbe347e993e84f299d13cea9bf1594b324603efd1d7 +size 3974571 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 2025fe104..7841d3808 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:062037d03257869313cc97c0cd49c3bc2fe5e410188867682ae12f2cd11ee41a -size 3414953 +oid sha256:ff5a0b2669e7b810094e55660a37ccb61357f0ff86db2ef9def067cddf3f7e38 +size 3538468 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 24f9de230..c8bf39427 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a151fca4d592bb205f4e3cca25f135466266d0461565d1ef38d7a62377bf8ae2 -size 3823111 +oid sha256:0b480a1515537d7ead47e90c234138e6bc12561881816bfdd84a98d600a7cfa2 +size 3820714 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 320c148ba..6de9c94d3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe128d464f5a6db1431d37d885dc6a4f285fb685e9ccbdf8497a5bafaea331e2 -size 2155623 +oid sha256:8cc0b6b1a51a146c8a02c4a3fea42962da5ee51fee502259c8994acd05419da8 +size 2227479 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index 9edff1ae8..c092efc39 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a009cbbf4a673bae0b3a6241beb1c19b40022b13795c17a70496489ccc9a9c2 -size 1440719 +oid sha256:507bd157271485c7b105ccc3d89fe4e09808e019739928a6c5be476e18ebec16 +size 1517639 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 534b49152..cba554009 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca20cb9920507758b9e8eaeebc411588692bd8649093869053fd7240aacff614 -size 1916812 +oid sha256:a262e165bbf8e3394ad65c575914b6c0ef0c050b2e71c959b796b89ab76b5569 +size 2054545 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 42ec4392c..39ede0930 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c06b9278e88025af81105038c735e3f0d3fd89a294140f741363b4b23ed847f -size 2102144 +oid sha256:7be3b390d7482f3d9d181436a550677c50fd4fec2b2fe60b2a3a8ad9407a4206 +size 2172274 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 2cf23b240..d28290fde 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c59a7308b53b6dced217a4d5c75d523526262c0a3b8e1b6dabf0f71e3f6994d1 -size 2330178 +oid sha256:424bbd6d24c5c7952517e764a3448793f974d36301ae3e07114d7bef7bcfc5b1 +size 2327895 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index 320c148ba..6de9c94d3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe128d464f5a6db1431d37d885dc6a4f285fb685e9ccbdf8497a5bafaea331e2 -size 2155623 +oid sha256:8cc0b6b1a51a146c8a02c4a3fea42962da5ee51fee502259c8994acd05419da8 +size 2227479 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index 57cf47736..0099b0c5d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0f6a29463a399be279241e46d40036acfa442e8e20737ab515b54c958b2de4a -size 2330181 +oid sha256:476ce41eae46722bab2774e1d2073cdd2deb793ff54f7f54129d7bb5cc769617 +size 2327898 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index 174b7de43..e526fc292 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -45,8 +45,13 @@ struct LocationForecastPanel: View { shape .fill(.background) .overlay { - shape.strokeBorder(.quaternary, lineWidth: style.borderWidth) + shape.strokeBorder(style.borderColor, lineWidth: style.borderWidth) } + .shadow( + color: style.shadowColor, + radius: style.shadowRadius, + y: style.shadowOffsetY, + ) } else { Color.clear.glassEffect(.regular, in: shape) } diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 90b43abd3..e98f4099b 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -79,7 +79,11 @@ extension WhereStylesheet { var padding: CGFloat var rowSpacing: CGFloat var estimateSpacing: CGFloat + var borderColor: Color var borderWidth: CGFloat + var shadowColor: Color + var shadowRadius: CGFloat + var shadowOffsetY: CGFloat var expansionAnimation: Animation static let standard = LocationForecastStyle( @@ -87,7 +91,11 @@ extension WhereStylesheet { padding: 16, rowSpacing: 12, estimateSpacing: 3, - borderWidth: 1, + borderColor: Color.primary.opacity(0.06), + borderWidth: 0.5, + shadowColor: Color.black.opacity(0.06), + shadowRadius: 8, + shadowOffsetY: 2, expansionAnimation: .easeInOut(duration: 0.2), ) } diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index c7dd0013e..cb1a31c5c 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -253,7 +253,11 @@ struct WhereStylesheetTests { #expect(forecast.padding == 16) #expect(forecast.rowSpacing == 12) #expect(forecast.estimateSpacing == 3) - #expect(forecast.borderWidth == 1) + #expect(forecast.borderColor == Color.primary.opacity(0.06)) + #expect(forecast.borderWidth == 0.5) + #expect(forecast.shadowColor == Color.black.opacity(0.06)) + #expect(forecast.shadowRadius == 8) + #expect(forecast.shadowOffsetY == 2) #expect(forecast.expansionAnimation == .easeInOut(duration: 0.2)) } From 74a59515ae7018af9b4bba277b74cbeca53c3d18 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 16:52:28 -0700 Subject: [PATCH 09/20] Soften location forecast disclosure --- .../locations.Loaded_iPad.png | 4 +-- .../locations.Loaded_iPad_accessibility.png | 4 +-- .../locations.Loaded_iPad_ax5.png | 4 +-- .../locations.Loaded_iPad_contrast.png | 4 +-- .../locations.Loaded_iPad_dark.png | 4 +-- .../locations.Loaded_iPhone.png | 4 +-- .../locations.Loaded_iPhone_accessibility.png | 4 +-- .../locations.Loaded_iPhone_ax5.png | 4 +-- .../locations.Loaded_iPhone_contrast.png | 4 +-- .../locations.Loaded_iPhone_dark.png | 4 +-- .../locations.PlannedStay_iPhone.png | 4 +-- .../locations.PlannedStay_iPhone_dark.png | 4 +-- .../LocationForecastDisclosureStyle.swift | 29 +++++++++++++++++++ .../Forecasting/LocationForecastPanel.swift | 4 ++- .../Sources/Shared/WhereStylesheet.swift | 2 ++ .../WhereUI/Tests/WhereStylesheetTests.swift | 1 + 16 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 97e8c6e87..4189b1038 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e7236dd001d26d853c92c6963a56e4e085fcce98c6d7ccf06c5f44fb813d333 -size 3554662 +oid sha256:1759df798d453da3a6db2e009f2b1ba7e3efa225c6427994dbd317cb50b34ba5 +size 3553325 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index f7a5b883b..a92bc9413 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5616924332dda2ff625f08bc8569d41fd1ea2d6d98cb0dcebf5d4979a862a50 -size 2490207 +oid sha256:69d1ab46bfd386e5fd5c02c9fad9372e7a117178f138c2ff0341add1cb7e679a +size 2478802 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 620a87d39..49305a3e3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b16e9662e55228ca45fbddbe347e993e84f299d13cea9bf1594b324603efd1d7 -size 3974571 +oid sha256:8106b54168502ab3a494fbe918d93c285e483f5dd2d7b51d9a7eafc14d84f414 +size 3969242 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 7841d3808..e350192cc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff5a0b2669e7b810094e55660a37ccb61357f0ff86db2ef9def067cddf3f7e38 -size 3538468 +oid sha256:497b2921f79448b9c8d946fa54ad48ffcc55d529afbe887a7945887206e74c7c +size 3530826 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index c8bf39427..4883c53ce 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b480a1515537d7ead47e90c234138e6bc12561881816bfdd84a98d600a7cfa2 -size 3820714 +oid sha256:53b2e97d03c7f79f862d3a242cd527c826ab03dd574b6b04a393b25d0589fade +size 3826137 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 6de9c94d3..2f3b51af0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cc0b6b1a51a146c8a02c4a3fea42962da5ee51fee502259c8994acd05419da8 -size 2227479 +oid sha256:532305f6ac03b0b3c2db6f57048e389f2a1d8e7bf16ea81923e51ce53a958ca3 +size 2224354 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index c092efc39..af668dd34 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:507bd157271485c7b105ccc3d89fe4e09808e019739928a6c5be476e18ebec16 -size 1517639 +oid sha256:cddf2446c0381eff8461da9ba7c53bec31d47b5e61ab3917b7d898644ace5b8a +size 1507742 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index cba554009..e08a6d98b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a262e165bbf8e3394ad65c575914b6c0ef0c050b2e71c959b796b89ab76b5569 -size 2054545 +oid sha256:f4cc07452f4d6f893e9e6186f33b2026a05f542e7f7493d74caff88745476b92 +size 2061651 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 39ede0930..f964619c7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7be3b390d7482f3d9d181436a550677c50fd4fec2b2fe60b2a3a8ad9407a4206 -size 2172274 +oid sha256:66acdbc25059afebfb1eebddced70b37eece99f2cc6dd13df61cc50a8c423948 +size 2163602 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index d28290fde..2d5e9fbb5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:424bbd6d24c5c7952517e764a3448793f974d36301ae3e07114d7bef7bcfc5b1 -size 2327895 +oid sha256:d7071da2f0e6f20e331a85e1d6b45858801ba5dd04778b0d30ed365efdcf1691 +size 2322965 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index 6de9c94d3..2f3b51af0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cc0b6b1a51a146c8a02c4a3fea42962da5ee51fee502259c8994acd05419da8 -size 2227479 +oid sha256:532305f6ac03b0b3c2db6f57048e389f2a1d8e7bf16ea81923e51ce53a958ca3 +size 2224354 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index 0099b0c5d..b9b944275 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:476ce41eae46722bab2774e1d2073cdd2deb793ff54f7f54129d7bb5cc769617 -size 2327898 +oid sha256:0a75c5ffd8d0ee120c0be544972adcad103fc98decb44ad3f4ca239c14485978 +size 2322967 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift new file mode 100644 index 000000000..2a971d34c --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A quiet disclosure treatment for the floating Locations forecast card. +struct LocationForecastDisclosureStyle: DisclosureGroupStyle { + let foregroundColor: Color + + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + configuration.isExpanded.toggle() + } label: { + HStack(alignment: .firstTextBaseline) { + configuration.label + Image(systemName: "chevron.right") + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(foregroundColor) + + if configuration.isExpanded { + configuration.content + .foregroundStyle(.primary) + } + } + } +} diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index e526fc292..767b429c8 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -31,7 +31,9 @@ struct LocationForecastPanel: View { label: { forecastHeader } - .tint(.primary) + .disclosureGroupStyle(LocationForecastDisclosureStyle( + foregroundColor: style.collapsedLabelColor, + )) } else { forecastHeader forecastContent diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index e98f4099b..bc85d0c02 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -79,6 +79,7 @@ extension WhereStylesheet { var padding: CGFloat var rowSpacing: CGFloat var estimateSpacing: CGFloat + var collapsedLabelColor: Color var borderColor: Color var borderWidth: CGFloat var shadowColor: Color @@ -91,6 +92,7 @@ extension WhereStylesheet { padding: 16, rowSpacing: 12, estimateSpacing: 3, + collapsedLabelColor: Color.primary.opacity(0.5), borderColor: Color.primary.opacity(0.06), borderWidth: 0.5, shadowColor: Color.black.opacity(0.06), diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index cb1a31c5c..2d935eebc 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -253,6 +253,7 @@ struct WhereStylesheetTests { #expect(forecast.padding == 16) #expect(forecast.rowSpacing == 12) #expect(forecast.estimateSpacing == 3) + #expect(forecast.collapsedLabelColor == Color.primary.opacity(0.5)) #expect(forecast.borderColor == Color.primary.opacity(0.06)) #expect(forecast.borderWidth == 0.5) #expect(forecast.shadowColor == Color.black.opacity(0.06)) From 5952bf38e8a690fa701fcdd1689750321375d5e3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 17:48:00 -0700 Subject: [PATCH 10/20] Distinguish planned days in the calendar --- Where/WhereUI/README.md | 5 + ...endarContent.FocusedPlannedStay_iPhone.png | 4 +- ...Content.FocusedPlannedStay_iPhone_dark.png | 4 +- .../Sources/Model/LocationForecastModel.swift | 10 ++ .../Sources/Preview/PreviewSupport.swift | 21 +++- .../Sources/Primary/CalendarContentView.swift | 96 ++++++++++++++++--- .../Sources/Primary/PlannedStayHatch.swift | 33 +++++++ .../Sources/Resources/Localizable.xcstrings | 12 +++ .../WhereUI/Sources/Shared/WhereFormat.swift | 10 +- .../Sources/Shared/WhereStylesheet.swift | 16 ++++ .../Tests/LocationForecastModelTests.swift | 15 +++ Where/WhereUI/Tests/WhereFormatTests.swift | 18 ++++ .../WhereUI/Tests/WhereStylesheetTests.swift | 6 ++ 13 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/PlannedStayHatch.swift diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4dee2ea1a..c9883a166 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -112,6 +112,11 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `whereBroadwayRoot(regionStyles:)` — from `WhereSession`'s live resolver in the app, the `WidgetSnapshot` in the widget process, and services in App Intents — falling back to a deterministic default from `RegionAppearanceCatalog`. +- **`CalendarContentView`** — the selected year's recorded location history, + with a region-focused destination from each Locations card. An active planned + stay extends the calendar through its end month and renders tomorrow through + the inclusive end date with a hatched region band; recorded monthly totals + remain historical. - **`whereBroadwayRoot()`** — seeds the Broadway design-system context so descendants resolve the `WhereStylesheet` tokens (see [Design system](#design-system)). Applied by `RootView` and by each widget. diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index 38e5bdeaf..77da331de 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:796b70821657abf6f855719b5b6460c0ebffe118457c1b73ea2b3c506a291a59 -size 378006 +oid sha256:e474c8fcc8c1519b8697253587920dcfee4a5642d4503d3f4c4d18fdb3cb2eab +size 385150 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index 1da72d5e9..83edb640b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94a85eb1cab84ca27dbe5099484d49053aae615eef3ff7206bb3a892ab2e5a15 -size 312091 +oid sha256:5fc59d0462467d6cbcad98735676f8334366ff90a0a4bd50036adb77d35f2bf4 +size 316116 diff --git a/Where/WhereUI/Sources/Model/LocationForecastModel.swift b/Where/WhereUI/Sources/Model/LocationForecastModel.swift index 3d1efeffc..c9cc54856 100644 --- a/Where/WhereUI/Sources/Model/LocationForecastModel.swift +++ b/Where/WhereUI/Sources/Model/LocationForecastModel.swift @@ -63,6 +63,16 @@ final class LocationForecastModel { return report.days.first(where: { $0.day == today })?.regions.contains(region) == true } + /// The user's planned region for a future calendar day. Today remains + /// recorded presence; the projection begins tomorrow and includes the + /// selected through-day. + func plannedRegion(on day: CalendarDay) -> Region? { + guard let stay = activePlannedStay else { return nil } + let today = CalendarDay(from: now(), in: calendar) + guard day > today, day <= stay.through else { return nil } + return stay.region + } + func departureDate(for region: Region) -> Date { guard let stay = activePlannedStay, stay.region == region else { return calendar.startOfDay(for: now()) diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index ed6b2f050..960dd34b3 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -247,7 +247,26 @@ @MainActor public static func plannedStayYearReportModel() -> YearReportModel { - let report = loadedYearReportModel() + let completeReport = sampleReport() + let today = CalendarDay(from: referenceNow, in: .current) + let recordedDays = completeReport.days.filter { $0.day <= today } + var recordedTotals: [Region: Int] = [:] + for day in recordedDays { + for region in day.regions { + recordedTotals[region, default: 0] += 1 + } + } + let report = YearReportModel( + services: previewServices(), + report: YearReport( + year: completeReport.year, + days: recordedDays, + totals: recordedTotals, + ), + selectedYear: year, + preferences: previewPreferences(), + now: { referenceNow }, + ) report.forecasts.setActivePlannedStay(PlannedStay( region: .newYork, through: CalendarDay(year: year, month: 8, day: 15), diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 315283b13..15d8abcfe 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -137,7 +137,12 @@ struct CalendarContentView: View { ) } ForEach(shownMonths(months)) { month in - MonthGridView(month: month, focusedRegion: focusedRegion) + MonthGridView( + month: month, + focusedRegion: focusedRegion, + dateCalendar: report.calendar, + plannedRegion: report.forecasts.plannedRegion(on:), + ) } } .padding() @@ -149,8 +154,17 @@ struct CalendarContentView: View { return report.forecasts.forecast(for: focusedRegion, report: report.report) } - /// The months to show, newest first. Future months are omitted; a past year - /// has no future months, so it shows the full year from December backward. + /// A plan belongs on the selected year's calendar and, when this is a + /// region-focused calendar, only on that region's destination. + private var displayedPlannedStay: PlannedStay? { + guard let stay = report.forecasts.activePlannedStay else { return nil } + guard stay.through.year == report.report?.year else { return nil } + guard focusedRegion == nil || focusedRegion == stay.region else { return nil } + return stay + } + + /// The months to show, newest first. Future months are omitted unless a + /// planned stay reaches into them; a past year shows the full year. private func shownMonths(_ months: [CalendarMonth]) -> [CalendarMonth] { guard let currentMonthStart = report.calendar @@ -159,8 +173,15 @@ struct CalendarContentView: View { else { return Array(months.reversed()) } + let lastShownMonth = displayedPlannedStay.flatMap { stay in + report.calendar.date(from: DateComponents( + year: stay.through.year, + month: stay.through.month, + day: 1, + )) + }.map { max(currentMonthStart, $0) } ?? currentMonthStart return Array(months - .filter { $0.startOfMonth <= currentMonthStart } + .filter { $0.startOfMonth <= lastShownMonth } .reversed()) } } @@ -171,6 +192,8 @@ private struct MonthGridView: View { let month: CalendarMonth /// The region the calendar is focused on, if any — emphasized in the footer. var focusedRegion: Region? + let dateCalendar: Calendar + let plannedRegion: (CalendarDay) -> Region? @Environment(\.stylesheet) private var stylesheet @@ -244,19 +267,26 @@ private struct MonthGridView: View { private func bandGeometry(at index: Int) -> DayBandGeometry { let days = month.days let day = days[index] - guard !day.regions.isEmpty else { return .none } + let regions = displayedRegions(for: day) + guard !regions.isEmpty else { return .none } - let regionSet = Set(day.regions) + let regionSet = Set(regions) + let isPlanned = plannedRegion(on: day) != nil let column = (month.leadingBlankCount + index) % month.weekdayCount let isRowStart = column == 0 let isRowEnd = column == month.weekdayCount - 1 - let joinsLeft = index > 0 && Set(days[index - 1].regions) == regionSet - let joinsRight = index < days.count - 1 && Set(days[index + 1].regions) == regionSet + let joinsLeft = index > 0 + && Set(displayedRegions(for: days[index - 1])) == regionSet + && (plannedRegion(on: days[index - 1]) != nil) == isPlanned + let joinsRight = index < days.count - 1 + && Set(displayedRegions(for: days[index + 1])) == regionSet + && (plannedRegion(on: days[index + 1]) != nil) == isPlanned let band = calendar.regionBand let halfGap = calendar.month.gridSpacing / 2 return DayBandGeometry( - regions: day.regions, + regions: regions, + isPlanned: isPlanned, leadingRadius: joinsLeft ? (isRowStart ? band.continuationRadius : 0) : band .cornerRadius, trailingRadius: joinsRight ? (isRowEnd ? band.continuationRadius : 0) : band @@ -265,6 +295,21 @@ private struct MonthGridView: View { extendTrailing: joinsRight && !isRowEnd ? halfGap : 0, ) } + + private func displayedRegions(for day: CalendarDayCell) -> [Region] { + var regions = Set(day.regions) + if let region = plannedRegion(on: day) { + regions.insert(region) + } + return Region.inCanonicalOrder(regions) + } + + private func plannedRegion(on day: CalendarDayCell) -> Region? { + let key = CalendarDay(from: day.date, in: dateCalendar) + guard let region = plannedRegion(key) else { return nil } + guard focusedRegion == nil || focusedRegion == region else { return nil } + return region + } } /// How to draw a day's slice of the region "stay" pill: which corners round @@ -272,6 +317,7 @@ private struct MonthGridView: View { /// one connected shape. Empty `regions` means no pill. private struct DayBandGeometry { var regions: [Region] + var isPlanned: Bool var leadingRadius: CGFloat var trailingRadius: CGFloat var extendLeading: CGFloat @@ -279,6 +325,7 @@ private struct DayBandGeometry { static let none = DayBandGeometry( regions: [], + isPlanned: false, leadingRadius: 0, trailingRadius: 0, extendLeading: 0, @@ -401,9 +448,10 @@ private struct DayCell: View { .accessibilityLabel( WhereFormat.calendarDayAccessibility( date: day.date, - regions: day.regions, + regions: band.regions, needsAttention: day.needsAttention, hasEvidence: day.hasEvidence, + isPlanned: band.isPlanned, ), ) } @@ -413,9 +461,9 @@ private struct DayCell: View { /// day the dots overlap into a cluster, the rims keeping them distinct. /// Empty days keep the row height so the grid baseline is even. private var dots: some View { - let isCluster = day.regions.count > 1 + let isCluster = band.regions.count > 1 return HStack(spacing: isCluster ? -calendar.day.dotOverlap : calendar.day.contentSpacing) { - ForEach(day.regions, id: \.self) { region in + ForEach(band.regions, id: \.self) { region in Circle() .fill(regionStyles.style(for: region).tint) .frame(width: calendar.day.dotSize, height: calendar.day.dotSize) @@ -439,14 +487,32 @@ private struct DayCell: View { private var stayPill: some View { if !band.regions.isEmpty { GeometryReader { proxy in - UnevenRoundedRectangle( + let shape = UnevenRoundedRectangle( topLeadingRadius: band.leadingRadius, bottomLeadingRadius: band.leadingRadius, bottomTrailingRadius: band.trailingRadius, topTrailingRadius: band.trailingRadius, ) - .fill(pillFill) - .opacity(calendar.regionBand.opacity) + ZStack { + shape + .fill(pillFill) + .opacity( + band.isPlanned + ? calendar.regionBand.planned.fillOpacity + : calendar.regionBand.opacity, + ) + if band.isPlanned { + PlannedStayHatch( + color: band.regions + .first + .map { regionStyles.style(for: $0).tint } ?? .accentColor, + spacing: calendar.regionBand.planned.hatchSpacing, + lineWidth: calendar.regionBand.planned.hatchLineWidth, + ) + .opacity(calendar.regionBand.planned.hatchOpacity) + .clipShape(shape) + } + } .frame( width: proxy.size.width + band.extendLeading + band.extendTrailing, height: proxy.size.height, diff --git a/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift b/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift new file mode 100644 index 000000000..a3787ade8 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Diagonal lines distinguishing planned calendar presence from recorded days +/// without relying on color alone. +struct PlannedStayHatch: View { + let color: Color + let spacing: CGFloat + let lineWidth: CGFloat + + var body: some View { + Canvas { context, size in + var path = Path() + var x = -size.height + while x < size.width { + path.move(to: CGPoint(x: x, y: size.height)) + path.addLine(to: CGPoint(x: x + size.height, y: 0)) + x += spacing + } + context.stroke(path, with: .color(color), lineWidth: lineWidth) + } + .accessibilityHidden(true) + } +} + +#if DEBUG + #Preview { + PlannedStayHatch(color: .indigo, spacing: 6, lineWidth: 1) + .frame(width: 240, height: 80) + .background(.indigo.opacity(0.08)) + .clipShape(.rect(cornerRadius: 16)) + .padding() + } +#endif diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 988b0e482..d90eac5aa 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -189,6 +189,18 @@ } } }, + "calendar.day.planned.accessibility" : { + "comment" : "Accessibility label appended to a future calendar day covered by the user's planned stay.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@, planned" + } + } + } + }, "calendar.region.title" : { "comment" : "Title for the calendar when it's focused on a single region, e.g. \"California · 2026\".", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 7d566f0e8..78bfda39c 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -193,12 +193,16 @@ enum WhereFormat { regions: [Region], needsAttention: Bool, hasEvidence: Bool, + isPlanned: Bool, ) -> String { - let base = calendarDayBase(date: date, regions: regions, needsAttention: needsAttention) - guard hasEvidence else { return base } + var label = calendarDayBase(date: date, regions: regions, needsAttention: needsAttention) + if isPlanned { + label = String(localized: .calendarDayPlannedAccessibility(label)) + } + guard hasEvidence else { return label } // Append the attachment cue so VoiceOver announces it after the day's // regions/status, e.g. "Monday, March 4, California, has evidence". - return String(localized: .calendarDayHasEvidenceAccessibility(base)) + return String(localized: .calendarDayHasEvidenceAccessibility(label)) } private static func calendarDayBase( diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 42ebac7bc..63fb62b29 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -883,6 +883,16 @@ extension WhereStylesheet { /// Padding between the day content (number + dots) and the pill's /// top/bottom edges, so the pill doesn't butt against the dots. var verticalInset: CGFloat + /// Lower-opacity fill plus a diagonal pattern for future days the + /// user has planned but not yet recorded. + var planned: Planned + + struct Planned: Equatable { + var fillOpacity: Double + var hatchOpacity: Double + var hatchSpacing: CGFloat + var hatchLineWidth: CGFloat + } } /// The paperclip badge in a day cell's top-trailing corner marking a day @@ -933,6 +943,12 @@ extension WhereStylesheet { cornerRadius: 14, continuationRadius: 3, verticalInset: 4, + planned: RegionBand.Planned( + fillOpacity: 0.07, + hatchOpacity: 0.32, + hatchSpacing: 6, + hatchLineWidth: 1, + ), ), day: DayStyle( minHeight: 44, diff --git a/Where/WhereUI/Tests/LocationForecastModelTests.swift b/Where/WhereUI/Tests/LocationForecastModelTests.swift index d6e1c3ec5..19a45add3 100644 --- a/Where/WhereUI/Tests/LocationForecastModelTests.swift +++ b/Where/WhereUI/Tests/LocationForecastModelTests.swift @@ -86,6 +86,21 @@ struct LocationForecastModelTests { #expect(model.activePlannedStay == nil) } + @Test func plannedRegionCoversTomorrowThroughTheSelectedDay() async throws { + let model = try LocationForecastModel( + services: Self.services(store: SwiftDataStore.inMemory()), + calendar: Self.calendar, + now: { Self.now }, + ) + let through = CalendarDay(year: 2026, month: 7, day: 18) + try await model.set(region: .newYork, through: through.startOfDay(in: Self.calendar)) + + #expect(model.plannedRegion(on: CalendarDay(year: 2026, month: 7, day: 15)) == nil) + #expect(model.plannedRegion(on: CalendarDay(year: 2026, month: 7, day: 16)) == .newYork) + #expect(model.plannedRegion(on: through) == .newYork) + #expect(model.plannedRegion(on: CalendarDay(year: 2026, month: 7, day: 19)) == nil) + } + @Test func failedSaveKeepsTheLastGoodValue() async throws { let store = try TestStore() await store.failPlannedStays() diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index 6f408afbb..90477ed22 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -143,17 +143,35 @@ struct WhereFormatTests { regions: [.california], needsAttention: false, hasEvidence: true, + isPlanned: false, ) let without = WhereFormat.calendarDayAccessibility( date: date, regions: [.california], needsAttention: false, hasEvidence: false, + isPlanned: false, ) #expect(withEvidence.hasSuffix("has evidence")) #expect(!without.hasSuffix("has evidence")) } + @Test func calendarDayAccessibilityIdentifiesPlannedPresence() throws { + let calendar = Calendar(identifier: .gregorian) + let label = try WhereFormat.calendarDayAccessibility( + date: #require(calendar.date( + from: DateComponents(year: 2026, month: 7, day: 16), + )), + regions: [.newYork], + needsAttention: false, + hasEvidence: false, + isPlanned: true, + ) + + #expect(label.contains("planned")) + #expect(label.contains(Region.newYork.localizedName)) + } + @Test func regionMapKindSwitchesResolve() { #expect(WhereFormat.regionMapKind(.attribution) == "Attribution") #expect(WhereFormat.regionMapKind(.source) == "Source") diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 5d4303f20..b4a5f81cb 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -205,6 +205,12 @@ struct WhereStylesheetTests { #expect(calendar.regionBand.cornerRadius == 14) #expect(calendar.regionBand.continuationRadius == 3) #expect(calendar.regionBand.verticalInset == 4) + #expect(calendar.regionBand.planned == .init( + fillOpacity: 0.07, + hatchOpacity: 0.32, + hatchSpacing: 6, + hatchLineWidth: 1, + )) let day = calendar.day #expect(day.minHeight == 44) From 96f2f14decffb5b93d6c4d6cbf507e4779aad930 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:02:42 -0700 Subject: [PATCH 11/20] Align planned stay hatches across calendar cells --- ...endarContent.FocusedPlannedStay_iPhone.png | 4 +-- ...Content.FocusedPlannedStay_iPhone_dark.png | 4 +-- .../Sources/Primary/CalendarContentView.swift | 6 +++++ .../Sources/Primary/PlannedStayHatch.swift | 21 ++++++++++++++-- .../WhereUI/Tests/PlannedStayHatchTests.swift | 25 +++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 Where/WhereUI/Tests/PlannedStayHatchTests.swift diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index 77da331de..448f93ed8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e474c8fcc8c1519b8697253587920dcfee4a5642d4503d3f4c4d18fdb3cb2eab -size 385150 +oid sha256:2fa51ee3d2f42592bc90fa53ea4d7861b0563696c675e6954a73d5ea0ec5657a +size 390367 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index 83edb640b..ad6390cb9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5fc59d0462467d6cbcad98735676f8334366ff90a0a4bd50036adb77d35f2bf4 -size 316116 +oid sha256:f843ac97686c5328319d523e4b54eb1c94b0afcfe89ab6a60808d81147f3e5b9 +size 321668 diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 15d8abcfe..87cf98d40 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -287,6 +287,7 @@ private struct MonthGridView: View { return DayBandGeometry( regions: regions, isPlanned: isPlanned, + column: column, leadingRadius: joinsLeft ? (isRowStart ? band.continuationRadius : 0) : band .cornerRadius, trailingRadius: joinsRight ? (isRowEnd ? band.continuationRadius : 0) : band @@ -318,6 +319,7 @@ private struct MonthGridView: View { private struct DayBandGeometry { var regions: [Region] var isPlanned: Bool + var column: Int var leadingRadius: CGFloat var trailingRadius: CGFloat var extendLeading: CGFloat @@ -326,6 +328,7 @@ private struct DayBandGeometry { static let none = DayBandGeometry( regions: [], isPlanned: false, + column: 0, leadingRadius: 0, trailingRadius: 0, extendLeading: 0, @@ -508,6 +511,9 @@ private struct DayCell: View { .map { regionStyles.style(for: $0).tint } ?? .accentColor, spacing: calendar.regionBand.planned.hatchSpacing, lineWidth: calendar.regionBand.planned.hatchLineWidth, + gridOriginX: CGFloat(band.column) + * (proxy.size.width + calendar.month.gridSpacing) + - band.extendLeading, ) .opacity(calendar.regionBand.planned.hatchOpacity) .clipShape(shape) diff --git a/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift b/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift index a3787ade8..82e76a7d5 100644 --- a/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift +++ b/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift @@ -6,11 +6,18 @@ struct PlannedStayHatch: View { let color: Color let spacing: CGFloat let lineWidth: CGFloat + /// This slice's horizontal origin in its enclosing grid. It keeps every + /// slice on one stripe lattice instead of restarting the pattern per cell. + let gridOriginX: CGFloat var body: some View { Canvas { context, size in var path = Path() - var x = -size.height + var x = Self.firstLineX( + height: size.height, + gridOriginX: gridOriginX, + spacing: spacing, + ) while x < size.width { path.move(to: CGPoint(x: x, y: size.height)) path.addLine(to: CGPoint(x: x + size.height, y: 0)) @@ -20,11 +27,21 @@ struct PlannedStayHatch: View { } .accessibilityHidden(true) } + + /// Starts far enough off-canvas to cover the top-leading corner while + /// cancelling the slice's grid offset from the pattern phase. + static func firstLineX( + height: CGFloat, + gridOriginX: CGFloat, + spacing: CGFloat, + ) -> CGFloat { + -height - gridOriginX.truncatingRemainder(dividingBy: spacing) + } } #if DEBUG #Preview { - PlannedStayHatch(color: .indigo, spacing: 6, lineWidth: 1) + PlannedStayHatch(color: .indigo, spacing: 6, lineWidth: 1, gridOriginX: 0) .frame(width: 240, height: 80) .background(.indigo.opacity(0.08)) .clipShape(.rect(cornerRadius: 16)) diff --git a/Where/WhereUI/Tests/PlannedStayHatchTests.swift b/Where/WhereUI/Tests/PlannedStayHatchTests.swift new file mode 100644 index 000000000..5dd2f2b90 --- /dev/null +++ b/Where/WhereUI/Tests/PlannedStayHatchTests.swift @@ -0,0 +1,25 @@ +import SwiftUI +import Testing +@testable import WhereUI + +struct PlannedStayHatchTests { + @Test func cellsShareOneStripeLattice() { + let height: CGFloat = 44 + let spacing: CGFloat = 6 + let cellWidth: CGFloat = 105.5 + let gridSpacing: CGFloat = 6 + + for column in 0 ..< 7 { + let extendLeading = column == 0 ? 0 : gridSpacing / 2 + let origin = CGFloat(column) * (cellWidth + gridSpacing) - extendLeading + let firstLine = PlannedStayHatch.firstLineX( + height: height, + gridOriginX: origin, + spacing: spacing, + ) + let globalStripePosition = origin + firstLine + height + + #expect(abs(globalStripePosition.truncatingRemainder(dividingBy: spacing)) < 0.000_001) + } + } +} From 4f322b24a436bc0941f060a26231e175ec9eb117 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:22:53 -0700 Subject: [PATCH 12/20] Restore chronological calendar order --- Where/WhereUI/README.md | 8 +++---- .../calendarContent.Empty_iPhone.png | 4 ++-- .../calendarContent.Empty_iPhone_dark.png | 4 ++-- ...cusedPlannedStayFullContent_fullHeight.png | 3 +++ ...endarContent.FocusedPlannedStay_iPhone.png | 4 ++-- ...Content.FocusedPlannedStay_iPhone_dark.png | 4 ++-- .../calendarContent.Focused_iPhone.png | 4 ++-- .../calendarContent.Focused_iPhone_dark.png | 4 ++-- ...calendarContent.FullContent_fullHeight.png | 4 ++-- .../calendarContent.MissingDays_iPhone.png | 4 ++-- ...alendarContent.MissingDays_iPhone_dark.png | 4 ++-- .../calendarContent.WithData_iPad.png | 4 ++-- ...darContent.WithData_iPad_accessibility.png | 4 ++-- .../calendarContent.WithData_iPad_ax5.png | 4 ++-- ...calendarContent.WithData_iPad_contrast.png | 4 ++-- .../calendarContent.WithData_iPad_dark.png | 4 ++-- .../calendarContent.WithData_iPhone.png | 4 ++-- ...rContent.WithData_iPhone_accessibility.png | 4 ++-- .../calendarContent.WithData_iPhone_ax5.png | 4 ++-- ...lendarContent.WithData_iPhone_contrast.png | 4 ++-- .../calendarContent.WithData_iPhone_dark.png | 4 ++-- .../year.Empty_iPhone.png | 4 ++-- .../year.Empty_iPhone_dark.png | 4 ++-- .../year.Loaded_iPad.png | 4 ++-- .../year.Loaded_iPad_accessibility.png | 4 ++-- .../year.Loaded_iPad_ax5.png | 4 ++-- .../year.Loaded_iPad_contrast.png | 4 ++-- .../year.Loaded_iPad_dark.png | 4 ++-- .../year.Loaded_iPhone.png | 4 ++-- .../year.Loaded_iPhone_accessibility.png | 4 ++-- .../year.Loaded_iPhone_ax5.png | 4 ++-- .../year.Loaded_iPhone_contrast.png | 4 ++-- .../year.Loaded_iPhone_dark.png | 4 ++-- .../Sources/Primary/CalendarContentView.swift | 21 +++++++++++++------ 34 files changed, 84 insertions(+), 72 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index c9883a166..371046bef 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -113,10 +113,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's app, the `WidgetSnapshot` in the widget process, and services in App Intents — falling back to a deterministic default from `RegionAppearanceCatalog`. - **`CalendarContentView`** — the selected year's recorded location history, - with a region-focused destination from each Locations card. An active planned - stay extends the calendar through its end month and renders tomorrow through - the inclusive end date with a hatched region band; recorded monthly totals - remain historical. + presented chronologically from January downward, with a region-focused + destination from each Locations card. An active planned stay extends the + calendar through its end month and renders tomorrow through the inclusive end + date with a hatched region band; recorded monthly totals remain historical. - **`whereBroadwayRoot()`** — seeds the Broadway design-system context so descendants resolve the `WhereStylesheet` tokens (see [Design system](#design-system)). Applied by `RootView` and by each widget. diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png index 2f32ec042..865e986a8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c7c31e64348c09ab87fead4e30c982799396758b08be3a89f00a25ac00aa60a -size 221085 +oid sha256:27363b32430b1e790678e86073d935582034051af2ecbaad4a038ae0853eb8d7 +size 233676 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png index 08cd4ee16..aa7c29cd9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5cc46db36804d7df1f4418d55deee05716c9948740301b23d5064248cb57f987 -size 207938 +oid sha256:21df3bcee49550abaff656debc831422383392998c9302a3cc3f46334d6414c5 +size 219802 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png new file mode 100644 index 000000000..1ea0f9e52 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4096cf90373441f56b9487215050f1fd05a0b74c8d860cf6643af948d36cece7 +size 1218608 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index 448f93ed8..e29b13e4a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fa51ee3d2f42592bc90fa53ea4d7861b0563696c675e6954a73d5ea0ec5657a -size 390367 +oid sha256:ba6ba9afc4ce7e5010062566647b9502d478419b5ac6a01d33d378297313a075 +size 357191 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index ad6390cb9..306f8aaec 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f843ac97686c5328319d523e4b54eb1c94b0afcfe89ab6a60808d81147f3e5b9 -size 321668 +oid sha256:bd1e54679ea60832122689e1fe33c5a1072ea44ba313b9940d6d38348296607c +size 283057 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index 90ca1c3ff..171bea19c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8500df8178e2610b26d68a3827c6659f83a4f73ee395aff590007b8dd29e254d -size 290863 +oid sha256:b108805c057de3767e8b96b7e55e595ae6f3cf5c777853b4fe08f7e0412d4cb6 +size 321496 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png index df613d0b4..e23405036 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9356ae1743de23833cc8d83c10664660ef8036ec2c5a380281def95471da3864 -size 246439 +oid sha256:be4755b7cb7d93ba0aaecaf0a45ebee7a08f5b15297719e3795c8c4a634674d3 +size 282297 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png index d6bbaef3f..59ad3ecf0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85b05a6dad04002a85dc0c1a520a14d86e18b06117fd0e8934f4f564bb530af2 -size 1009600 +oid sha256:0ae0964b1f13a5f3d4c2dfcd337f85e60025a8e01694a23a7bb05b6dc9d357a4 +size 866620 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png index e1e38d16c..9d58489ef 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a13c0d8cf8bcfa5eac237d97514c8abce9ca2d16f7aaeead5f6faa44d2d9cb6 -size 218511 +oid sha256:1a1fc7a8a8d42cb161723cc3854a3282ffd46baa384cae53bfac350e366622cc +size 218288 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png index 3df5d04fd..457c334a8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e1a9f64f52ae2b870dcbd7a09eca768f4fc4a686a6c902dc579a434cb06357d -size 208368 +oid sha256:56ec736df4c83ec4b85b2a4eb32ec1371e239d31c12db8e3832516adfd670ad7 +size 208363 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png index 327d36316..925029506 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:952002a769e972a5bc49796c28ac58fe966c1e95ac8be96ca29a1775c9aa0d67 -size 437880 +oid sha256:0c4cae71c3e1d843b0c1b20cc014aaa7a0ecd07795adbf95f56b7e86bf69d08e +size 442184 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_accessibility.png index 7747d633b..9b2fb6cc6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10293899a159dbc787b64a2d1b1b75775e9d7f171aa1fe6a866bc55fae09d1f3 -size 1481620 +oid sha256:74ceda0f1aaf1761b24c58be4b7990a574e9a624f8f7814d1182bcc2a9f2f2cc +size 1531227 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png index 90ccb3ffc..c6bc850f3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cd2cba2722d4d1777e3da3fdf56d47b0be3af246a47dd0dcc3a2e0badd644b1 -size 468706 +oid sha256:2faf4d2846da94596fb5c05ff5a3ea244ab0cd4851ad3e68457cbb1c0186a47e +size 473401 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png index e11f30e8e..5fe661ff3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4cc4c1ef60e3d03c4420a5fa54620b021e8054411a65d022bf0eaa9a3757bfc -size 437132 +oid sha256:f6482f3e92fb1fb932cd8b5cc2052ef65b52699f49397273398adfcc5e39ee62 +size 441843 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png index 66815a9d3..a4b6e60c9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb68489d4b60890353aeec5783d1aa5cf58e77276a18c6f077bc0a165c1a42c1 -size 429576 +oid sha256:612a174143be1e1fdb6db632dfc5a876c9ddfbf87b50b723b2439d7afb1f825f +size 431190 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png index 9f979b375..a2c59c091 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:602f75b42b3914c4fe75170e65bbcfe2a56833d962ac7da41af2b01327a0c37a -size 227848 +oid sha256:143be88ab5f924720e54147333ce49a2b977e0fc845e364202e67df1653587e7 +size 232147 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_accessibility.png index 1214cbde1..99960ee6a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52dd2b125a6a4c651edb06b5b97aa7d74146d898319aa563bcc3fab3f788c02e -size 923875 +oid sha256:d9c8ccf41925bfef7ae72407a6e6894b4d48c680e54c3075f2d724b69d33ab1f +size 979635 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png index d83cb15cd..c12755a54 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1369ddbb8ba728a515bf4c6c17c23579b825b378d1432ac402d6f32ee110a52c -size 223318 +oid sha256:f105d15ff57d202a34224e517ca22a7e0522b4bb0a201f1ee612dac8261f020e +size 227728 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png index a777c0f67..398789596 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f141a127ebf6162e774f704c718eba53842bda4d86f259abe08eeb51c93afda -size 231519 +oid sha256:04471e8d4179595ebaf5fcd6618ddb90d389d4489ff831135c9b2f4b3a5af439 +size 232962 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png index ef02c7563..afb8fa5a2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5d799f6b1b57318e64ca63694b07f8ff8bc5f49e189298cb8e55197940f00fc -size 224211 +oid sha256:b5e890866cc5ab8723494e6fcd8c7a836f9c583e6fb72f58ff25d892c339dc09 +size 227854 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index ea619e7a0..70e85e3d4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc569837290394ab6c6343e5a285623625fa30ed11307ed4d7374b98156a31d9 -size 260382 +oid sha256:945a0d94dc2f9e50f4b0345dec292e2278d347c2e84cb83785a6a6fb31ca9159 +size 269795 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png index 08da0e034..1309a6bbd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8895fbbfb0828fbe6d20bfdfa3d7eb370505629a245c9d434645b160a15c5f3 -size 254203 +oid sha256:4fde62fde198e7ef8178834a2eccdd60a890fc3254214aed97e285a636ef4aee +size 264129 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png index 796be76d2..fb9a39c00 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75d32fdcedf46032c8bc3fd16e173b887b549e8b0affc7c3151e49b018261f84 -size 473865 +oid sha256:6f00159a4afbcc7274dd0aee85e3fceb7be782476f4a6f9803abd8428aaa5e33 +size 486665 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png index 8083afa42..9bc50a773 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d0c975ba7eadfb9be744b7a9b5430c61c01f12852325ea073d333ede0c6687b -size 1516565 +oid sha256:3cf657d3b14db60d197190c9e84b9889831a886284513874a4dc70ef7474e87e +size 1576986 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png index 4a7ff0aaa..4c44ee2bb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c73c31eeb5b4bb2aee20f8c82e40189816f66b228f62546aa40bd962369f74fc -size 572582 +oid sha256:65c2448e82900ffff7c0379c22b2118b7202403a6d19abf1d58b0e8b486ebc45 +size 603979 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png index 7729d1638..28397300f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d2a21398821f9ebe34fd1577a9e3faf27f2145205aaf03b89d1955769fbc3a2 -size 466645 +oid sha256:684c39aca2e67374c35cbcc50a16ffbbbdc6f1b917decc2f9a91b07a76f25341 +size 479957 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png index 6ccd8ee4c..18ea70ce8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bb6d911d9002137b87d6a00ddf05672a8054c647f54497b3574a0449da1500f -size 472330 +oid sha256:cd70fa1e89fc54e3710f82df93c3fa100db4b49ebd70382fd52be60854cf8ff7 +size 482600 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png index 7f9a11365..fb21bfd91 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:812507aef0e8c2da706f5a62e9184dae265650d0b393de93d7fef3e03c50312c -size 272482 +oid sha256:9cd69b6f85a3586a00d689297ad19cefe52d92df533566264ce2df0a755aef58 +size 278922 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png index 5500fe300..750a8735d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afeaa16d79d60a151bf07d17ce19ac90cf7b219bfc2621714af581c2b5f4b412 -size 975165 +oid sha256:32e6e110594a300fc3a9719910cc88c657a43e2ba07f1bfb20e48722e75defcd +size 1024445 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png index 1b3cdb425..268fc3eff 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99a49eb9a481e808c7c9acbde7674ba5d0d209f4adb4c0a9805b311f6352df29 -size 265110 +oid sha256:8d4fee3229f9a7c904144c8960b2422b196c29c04edc71c85443179ece3a850a +size 267395 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png index 85be0c532..4e2929d7f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1220dfc213c9b9cc6b634a8c37ae82c809fc7751e0573111aa0781692ffc427c -size 268436 +oid sha256:5f0b206d54f27e7dc830a1b1da09d7be59a6f44c2865caa30fc6a4010fc6e7b7 +size 271304 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png index f5abd670d..a564be3fd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7eb37e1f1bde59fa4e63b00255f19023f9deebc097b72d94e1e72ab004fc9051 -size 280973 +oid sha256:b534878f07d092da42e9ca2c636f2204c40dc7e103619f4cbe974f3dbe4812e8 +size 283530 diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 87cf98d40..a44030962 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -163,15 +163,15 @@ struct CalendarContentView: View { return stay } - /// The months to show, newest first. Future months are omitted unless a - /// planned stay reaches into them; a past year shows the full year. + /// The months to show in chronological order. Future months are omitted + /// unless a planned stay reaches into them; a past year shows the full year. private func shownMonths(_ months: [CalendarMonth]) -> [CalendarMonth] { guard let currentMonthStart = report.calendar .dateInterval(of: .month, for: report.referenceDate)? .start else { - return Array(months.reversed()) + return months } let lastShownMonth = displayedPlannedStay.flatMap { stay in report.calendar.date(from: DateComponents( @@ -180,9 +180,7 @@ struct CalendarContentView: View { day: 1, )) }.map { max(currentMonthStart, $0) } ?? currentMonthStart - return Array(months - .filter { $0.startOfMonth <= lastShownMonth } - .reversed()) + return months.filter { $0.startOfMonth <= lastShownMonth } } } @@ -584,6 +582,17 @@ private struct DayCell: View { ) } } + whereSnapshot( + name: "FocusedPlannedStayFullContent", + configurations: [ + SnapshotConfiguration(device: .fullContent(name: "fullHeight", width: 402)), + ], + ) { + CalendarContentView( + focusedRegion: .newYork, + report: PreviewSupport.plannedStayYearReportModel(), + ) + } // The shown months in one image. The full-content frame measures the // scroll view's content height, so every lazy month materializes and // nothing scrolls — which needs the chrome-free view, since a From 16f233bc9bfab4a5003e595e7cf49b616a8e3e03 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:39:33 -0700 Subject: [PATCH 13/20] Return concurrent planned stay from expiry --- .../Forecasting/PlannedStayCoordinator.swift | 17 +++++++++++------ .../Tests/PlannedStayCoordinatorTests.swift | 3 ++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift index 85135190b..cae2345bd 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift @@ -20,8 +20,7 @@ public struct PlannedStayCoordinator: Sendable { guard let stay = record.value else { return nil } let today = CalendarDay(from: now(), in: calendar) guard stay.through < today else { return stay } - try await expireIfLatest(record, asOf: today) - return nil + return try await expireIfLatest(record, asOf: today) } /// Replace any prior intent with a stay through the inclusive day. @@ -42,20 +41,26 @@ public struct PlannedStayCoordinator: Sendable { /// Clear `expiredRecord` only if it is still the winning revision. The /// transactional re-read prevents a stale `active()` read from erasing a - /// newer stay saved while that read was suspended. + /// newer stay saved while that read was suspended, and returns that newer + /// stay so the caller cannot replace it with stale `nil` state. func expireIfLatest( _ expiredRecord: PlannedStayRecord, asOf today: CalendarDay, - ) async throws { + ) async throws -> PlannedStay? { try await store.perform { - guard try await latestRecord() == expiredRecord else { return } - guard let stay = expiredRecord.value, stay.through < today else { return } + guard let latest = try await latestRecord() else { return nil } + guard latest == expiredRecord else { + guard let stay = latest.value, stay.through >= today else { return nil } + return stay + } + guard let stay = expiredRecord.value, stay.through < today else { return nil } let tombstone = PlannedStayRecord( id: UUID(), value: nil, updatedAt: max(now(), expiredRecord.updatedAt.addingTimeInterval(0.001)), ) try await store.replacePlannedStayRecord(with: tombstone) + return nil } } diff --git a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift index fdcf3ea29..51e6dca2a 100644 --- a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift +++ b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift @@ -68,11 +68,12 @@ struct PlannedStayCoordinatorTests { through: CalendarDay(year: 2026, month: 8, day: 1), ) try await coordinator.set(region: futureStay.region, through: futureStay.through) - try await coordinator.expireIfLatest( + let activeStay = try await coordinator.expireIfLatest( staleExpiredRecord, asOf: CalendarDay(year: 2026, month: 7, day: 1), ) + #expect(activeStay == futureStay) #expect(try await coordinator.active() == futureStay) } From 26a6e20a828c19b5cb8cafe0e9d94bfff3cc4d55 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:41:17 -0700 Subject: [PATCH 14/20] Keep planned stay revisions monotonic --- .../Forecasting/PlannedStayCoordinator.swift | 6 ++++- .../Tests/PlannedStayCoordinatorTests.swift | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift index cae2345bd..397f58921 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift @@ -65,8 +65,12 @@ public struct PlannedStayCoordinator: Sendable { } private func write(value: PlannedStay?) async throws { - let record = PlannedStayRecord(id: UUID(), value: value, updatedAt: now()) try await store.perform { + let latest = try await latestRecord() + let timestamp = latest.map { + max(now(), $0.updatedAt.addingTimeInterval(0.001)) + } ?? now() + let record = PlannedStayRecord(id: UUID(), value: value, updatedAt: timestamp) try await store.replacePlannedStayRecord(with: record) } } diff --git a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift index 51e6dca2a..5e7f26327 100644 --- a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift +++ b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift @@ -103,4 +103,29 @@ struct PlannedStayCoordinatorTests { let coordinator = Self.makeCoordinator(store: store) #expect(try await coordinator.active() == newer.value) } + + @Test func localWriteAdvancesPastAFutureDatedSyncedRevision() async throws { + let store = try SwiftDataStore.inMemory() + let synced = PlannedStayRecord( + id: UUID(), + value: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 8, day: 1), + ), + updatedAt: Self.now.addingTimeInterval(60), + ) + try await store.perform { + try await store.restorePlannedStayRecord(synced) + } + + let coordinator = Self.makeCoordinator(store: store) + try await coordinator.clear() + let tombstone = try #require(await store.plannedStayRecords().first) + #expect(tombstone.updatedAt > synced.updatedAt) + + try await store.perform { + try await store.restorePlannedStayRecord(synced) + } + #expect(try await coordinator.active() == nil) + } } From 30a67f1050421855deb90a0727f1797cb0cbfc59 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:42:51 -0700 Subject: [PATCH 15/20] Show cross-year planned stays in calendar --- .../Sources/Model/LocationForecastModel.swift | 13 +++++++++++++ .../Sources/Primary/CalendarContentView.swift | 4 ++-- .../Tests/LocationForecastModelTests.swift | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Where/WhereUI/Sources/Model/LocationForecastModel.swift b/Where/WhereUI/Sources/Model/LocationForecastModel.swift index c9cc54856..76eabe16f 100644 --- a/Where/WhereUI/Sources/Model/LocationForecastModel.swift +++ b/Where/WhereUI/Sources/Model/LocationForecastModel.swift @@ -73,6 +73,19 @@ final class LocationForecastModel { return stay.region } + /// The active stay when its future projection intersects `year`. A stay + /// ending next year still occupies the rest of this year; a past report has + /// no overlap because projections begin tomorrow. + func plannedStay(intersecting year: Int) -> PlannedStay? { + guard let stay = activePlannedStay else { return nil } + let tomorrow = CalendarDay(from: now(), in: calendar).adding(days: 1) + let firstDay = CalendarDay(year: year, month: 1, day: 1) + let lastDay = CalendarDay.lastDay(ofYear: year) + let projectedStart = max(tomorrow, firstDay) + let projectedEnd = min(stay.through, lastDay) + return projectedStart <= projectedEnd ? stay : nil + } + func departureDate(for region: Region) -> Date { guard let stay = activePlannedStay, stay.region == region else { return calendar.startOfDay(for: now()) diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index a44030962..1ce30d56f 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -157,8 +157,8 @@ struct CalendarContentView: View { /// A plan belongs on the selected year's calendar and, when this is a /// region-focused calendar, only on that region's destination. private var displayedPlannedStay: PlannedStay? { - guard let stay = report.forecasts.activePlannedStay else { return nil } - guard stay.through.year == report.report?.year else { return nil } + guard let year = report.report?.year else { return nil } + guard let stay = report.forecasts.plannedStay(intersecting: year) else { return nil } guard focusedRegion == nil || focusedRegion == stay.region else { return nil } return stay } diff --git a/Where/WhereUI/Tests/LocationForecastModelTests.swift b/Where/WhereUI/Tests/LocationForecastModelTests.swift index 19a45add3..f1a35e965 100644 --- a/Where/WhereUI/Tests/LocationForecastModelTests.swift +++ b/Where/WhereUI/Tests/LocationForecastModelTests.swift @@ -101,6 +101,25 @@ struct LocationForecastModelTests { #expect(model.plannedRegion(on: CalendarDay(year: 2026, month: 7, day: 19)) == nil) } + @Test func crossYearStayIntersectsTheRestOfTheCurrentYear() async throws { + let model = try LocationForecastModel( + services: Self.services(store: SwiftDataStore.inMemory()), + calendar: Self.calendar, + now: { Self.now }, + ) + let stay = PlannedStay( + region: .newYork, + through: CalendarDay(year: 2027, month: 2, day: 1), + ) + try await model.set( + region: stay.region, + through: stay.through.startOfDay(in: Self.calendar), + ) + + #expect(model.plannedStay(intersecting: 2026) == stay) + #expect(model.plannedStay(intersecting: 2025) == nil) + } + @Test func failedSaveKeepsTheLastGoodValue() async throws { let store = try TestStore() await store.failPlannedStays() From 25ab8710bf2dcd93c392ac27342d9ac234c5d599 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:43:25 -0700 Subject: [PATCH 16/20] Keep planned stay previews Gregorian --- Where/WhereUI/Sources/Preview/PreviewSupport.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 960dd34b3..ba39955f5 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -248,7 +248,9 @@ @MainActor public static func plannedStayYearReportModel() -> YearReportModel { let completeReport = sampleReport() - let today = CalendarDay(from: referenceNow, in: .current) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let today = CalendarDay(from: referenceNow, in: calendar) let recordedDays = completeReport.days.filter { $0.day <= today } var recordedTotals: [Region: Int] = [:] for day in recordedDays { From 358de6af57b66f57eaf3be519e775676cb7d779b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:52:37 -0700 Subject: [PATCH 17/20] Refresh Gregorian planned stay snapshots --- .../locations.PlannedStay_iPhone.png | 4 ++-- .../locations.PlannedStay_iPhone_dark.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index 2f3b51af0..64b65a8dd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:532305f6ac03b0b3c2db6f57048e389f2a1d8e7bf16ea81923e51ce53a958ca3 -size 2224354 +oid sha256:be00ad30219fcbacf4921f50f618919dd5f7afff4d43e828b3ae5a29d2901a03 +size 2183444 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index b9b944275..961a9b104 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a75c5ffd8d0ee120c0be544972adcad103fc98decb44ad3f4ca239c14485978 -size 2322967 +oid sha256:261dac5247ea2392bbd91be11b6a88643d69053a6d58a2f9c96dea5a80884b7f +size 2262525 From 42409e5a9a5009f76f68d127176a1401e0bb6718 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 18:54:04 -0700 Subject: [PATCH 18/20] Place estimate after current calendar month --- ...cusedPlannedStayFullContent_fullHeight.png | 4 +- ...endarContent.FocusedPlannedStay_iPhone.png | 4 +- ...Content.FocusedPlannedStay_iPhone_dark.png | 4 +- .../calendarContent.Focused_iPhone.png | 4 +- .../calendarContent.Focused_iPhone_dark.png | 4 +- .../Sources/Primary/CalendarContentView.swift | 42 ++++++++++--------- 6 files changed, 33 insertions(+), 29 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png index 1ea0f9e52..99fe8f8be 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4096cf90373441f56b9487215050f1fd05a0b74c8d860cf6643af948d36cece7 -size 1218608 +oid sha256:ebb68c9c1a1b1f6f566b1583089c4de3282c91b4998502d366cd4a2ddd574179 +size 1113201 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index e29b13e4a..352a0e732 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba6ba9afc4ce7e5010062566647b9502d478419b5ac6a01d33d378297313a075 -size 357191 +oid sha256:d96b8e0041bf25a9eecad4745f2886327fca0fe0b3f6f01bd85b232df2a9a275 +size 204724 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index 306f8aaec..d4130e4cc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd1e54679ea60832122689e1fe33c5a1072ea44ba313b9940d6d38348296607c -size 283057 +oid sha256:207a5cf541ea7c485927b87a6cccaaecae69c7e3b5ab9a1e6b400ebc04ddc20d +size 193740 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index 171bea19c..62295173c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b108805c057de3767e8b96b7e55e595ae6f3cf5c777853b4fe08f7e0412d4cb6 -size 321496 +oid sha256:7af20df893dd783f0202d928738781ebf80ad18905930d3f992e6df51c35dc15 +size 231836 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png index e23405036..0e7782ac0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be4755b7cb7d93ba0aaecaf0a45ebee7a08f5b15297719e3795c8c4a634674d3 -size 282297 +oid sha256:2daa771d69ea06e5b711bda555b90cd77dfcd5005822b647242c7f4d1b4f2ec9 +size 227403 diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 1ce30d56f..8a7e3fcec 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -123,26 +123,30 @@ struct CalendarContentView: View { private func calendarContent(months: [CalendarMonth]) -> some View { ScrollView { LazyVStack(spacing: stylesheet.calendar.monthSpacing) { - if let focusedForecast { - LocationForecastPanel( - forecasts: [focusedForecast], - plannedStay: report.forecasts.activePlannedStay, - editableRegion: report.forecasts.isCurrent( - focusedForecast.region, - report: report.report, - ) ? focusedForecast.region : nil, - editAction: { - showingPlannedStayEditor = true - }, - ) - } ForEach(shownMonths(months)) { month in - MonthGridView( - month: month, - focusedRegion: focusedRegion, - dateCalendar: report.calendar, - plannedRegion: report.forecasts.plannedRegion(on:), - ) + VStack(spacing: stylesheet.calendar.monthSpacing) { + MonthGridView( + month: month, + focusedRegion: focusedRegion, + dateCalendar: report.calendar, + plannedRegion: report.forecasts.plannedRegion(on:), + ) + // In chronological flow, the estimate belongs immediately + // after the month whose recorded pace it is projecting from. + if month.isCurrentMonth, let focusedForecast { + LocationForecastPanel( + forecasts: [focusedForecast], + plannedStay: report.forecasts.activePlannedStay, + editableRegion: report.forecasts.isCurrent( + focusedForecast.region, + report: report.report, + ) ? focusedForecast.region : nil, + editAction: { + showingPlannedStayEditor = true + }, + ) + } + } } } .padding() From 14bdd50caff246e055ff4d41a3b109abff9fc2df Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 19:24:49 -0700 Subject: [PATCH 19/20] Add planned stay editor snapshots --- .../PlannedStayEditorSnapshotTests.swift | 10 +++++++++ .../plannedStayEditor.ExistingPlan_iPhone.png | 3 +++ ...nedStayEditor.ExistingPlan_iPhone_dark.png | 3 +++ .../plannedStayEditor.NewPlan_iPad.png | 3 +++ ...dStayEditor.NewPlan_iPad_accessibility.png | 3 +++ .../plannedStayEditor.NewPlan_iPad_ax5.png | 3 +++ ...lannedStayEditor.NewPlan_iPad_contrast.png | 3 +++ .../plannedStayEditor.NewPlan_iPad_dark.png | 3 +++ .../plannedStayEditor.NewPlan_iPhone.png | 3 +++ ...tayEditor.NewPlan_iPhone_accessibility.png | 3 +++ .../plannedStayEditor.NewPlan_iPhone_ax5.png | 3 +++ ...nnedStayEditor.NewPlan_iPhone_contrast.png | 3 +++ .../plannedStayEditor.NewPlan_iPhone_dark.png | 3 +++ .../Forecasting/PlannedStayEditor.swift | 21 +++++++++++++++++-- 14 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png diff --git a/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift b/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift new file mode 100644 index 000000000..b102b5846 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct PlannedStayEditorSnapshotTests { + @Test func plannedStayEditor() async { + await assertSnapshots(of: PlannedStayEditor.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png new file mode 100644 index 000000000..1e00d6a2f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fef9b1d329d2997767fdda2388afc91d0de41795ad40b28f1ab24b25d5bab4bf +size 167024 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png new file mode 100644 index 000000000..54eb4722b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3c7c6b732a2ed94318f202c818207196f42f77fc77b1cc02d9db389b183f3db +size 151625 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png new file mode 100644 index 000000000..2f009b948 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56581c7cc2fc48e2d7796cad824c403b828e4c41e4ad739b630a6fa4d13cb8c4 +size 274748 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png new file mode 100644 index 000000000..018e313a9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1b51e161a021adcd873578aaa9bb8b739f91a047c528845f8d44c388e09bafc +size 408528 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png new file mode 100644 index 000000000..d6258d5c0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5ff6785afa9164f5cf0d0dc27882b7e196e6f2dc65896ffdcf1d7c870af47c1 +size 374274 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png new file mode 100644 index 000000000..70b3f5636 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f82b8d89f44e82e8fe9d74d098e6be39587a29a382aae5728b0d4becc7356e56 +size 262106 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png new file mode 100644 index 000000000..b499e762c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77c7daa53a9537f9657b80ed1be2744531c27838f604cbea830bdd0a025617e4 +size 258102 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png new file mode 100644 index 000000000..43a0a2282 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e34ca6205f04cba8e9803d91b55553f3c88093a4259de8969b90bb61f0edb934 +size 158648 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png new file mode 100644 index 000000000..dd7c9d354 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04f9054dca40842125184b04dae6df29f23cf331ffa5e80d365be77e0c27eaa6 +size 270833 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png new file mode 100644 index 000000000..4e14f58b0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fd72442731cc7c66e9f31e8f1ccbde867f88e91c0ec3f0a0624bdedb99a8614 +size 260723 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png new file mode 100644 index 000000000..e9e241826 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e5700a51985cdd48e002edb065955b268f8981943784b239217d750e2802f15 +size 146538 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png new file mode 100644 index 000000000..368db2d49 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35297eeea57ee5986c9ef6a9463a5c6aabd541d48b92e0ee64d28da9819b0e4f +size 141719 diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift index f8f13d107..6ab320588 100644 --- a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift @@ -1,4 +1,5 @@ import RegionKit +import SnapshotKit import SwiftUI /// Sheet for setting or removing the inclusive departure day for the currently @@ -99,8 +100,24 @@ struct PlannedStayEditor: View { } #if DEBUG + extension PlannedStayEditor: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "NewPlan", configurations: .screenDefaults) { + PlannedStayEditor( + region: .newYork, + model: PreviewSupport.loadedYearReportModel().forecasts, + ) + } + whereSnapshot(name: "ExistingPlan", configurations: .phoneLightDark) { + PlannedStayEditor( + region: .newYork, + model: PreviewSupport.plannedStayYearReportModel().forecasts, + ) + } + } + } + #Preview { - let report = PreviewSupport.plannedStayYearReportModel() - PlannedStayEditor(region: .newYork, model: report.forecasts) + PlannedStayEditor.snapshotPreviews } #endif From bbd986f68ddc53f3602c36bf6e56813e39d33e4b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 5 Aug 2026 19:44:59 -0700 Subject: [PATCH 20/20] Give snapshots solid adaptive backgrounds --- ...arContent.FocusedPlannedStayFullContent_fullHeight.png | 4 ++-- .../calendarContent.FullContent_fullHeight.png | 4 ++-- Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift | 8 ++++++++ Where/WhereUI/Sources/Primary/CalendarContentView.swift | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png index 99fe8f8be..f6b881bc2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStayFullContent_fullHeight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebb68c9c1a1b1f6f566b1583089c4de3282c91b4998502d366cd4a2ddd574179 -size 1113201 +oid sha256:41e38b8c008484a2bc0e74b68ec9a890b154c84321f1249844ab96263db98f9f +size 1217589 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png index 59ad3ecf0..b1bdefcf7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ae0964b1f13a5f3d4c2dfcd337f85e60025a8e01694a23a7bb05b6dc9d357a4 -size 866620 +oid sha256:8cf5a9cfca7e53a4d585598e157d0a1a424b1a8c9d7dd14d74ecc5ecb4c592bc +size 941010 diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift index 6ab320588..5b3395b60 100644 --- a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift @@ -107,12 +107,20 @@ struct PlannedStayEditor: View { region: .newYork, model: PreviewSupport.loadedYearReportModel().forecasts, ) + .background { + Color(.systemBackground) + .ignoresSafeArea() + } } whereSnapshot(name: "ExistingPlan", configurations: .phoneLightDark) { PlannedStayEditor( region: .newYork, model: PreviewSupport.plannedStayYearReportModel().forecasts, ) + .background { + Color(.systemBackground) + .ignoresSafeArea() + } } } } diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 8a7e3fcec..0fec29cb7 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -596,6 +596,7 @@ private struct DayCell: View { focusedRegion: .newYork, report: PreviewSupport.plannedStayYearReportModel(), ) + .background(Color(.systemBackground)) } // The shown months in one image. The full-content frame measures the // scroll view's content height, so every lazy month materializes and @@ -609,6 +610,7 @@ private struct DayCell: View { ], ) { CalendarContentView(report: PreviewSupport.loadedYearReportModel()) + .background(Color(.systemBackground)) } } }