diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 4b2ed47e..85f99cdf 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 8384d350..e91664f3 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 74ab3856..c2c4ffa3 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 @@ -107,10 +112,11 @@ 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, - reminder / summary schedules) plus the year-keyed Location-card counts used - for presentation continuity, behind a `KeyValueStore`. The store has no - default: production names `UserDefaults.standard` and everything else names +- **`WherePreferences`** — persisted user intent (onboarding, tracking and + forecast visibility, reminder / summary schedules) plus the year-keyed + Location-card counts used for presentation continuity, 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 defaults by saying nothing. - **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index db37184e..1f2f44a7 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 fd7b9743..e28c52fa 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 5b80978b..ca85fa39 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 00000000..4cc61577 --- /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 00000000..bb87cf3d --- /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 00000000..397f5892 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift @@ -0,0 +1,77 @@ +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 } + return try await expireIfLatest(record, asOf: today) + } + + /// 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) + } + } + + /// 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, and returns that newer + /// stay so the caller cannot replace it with stale `nil` state. + func expireIfLatest( + _ expiredRecord: PlannedStayRecord, + asOf today: CalendarDay, + ) async throws -> PlannedStay? { + try await store.perform { + 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 + } + } + + private func write(value: PlannedStay?) async throws { + 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/Sources/Forecasting/PlannedStayRecord.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift new file mode 100644 index 00000000..a0719c4b --- /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 90939052..9c950235 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 f7f1300c..2f9f7b0a 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/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index 82745805..e81edea9 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -2,9 +2,9 @@ import Foundation import RegionKit /// The app's persisted user intent — onboarding completion, background-tracking -/// intent, and the reminder / daily-summary schedules — plus small pieces of UI -/// continuity state, behind a `KeyValueStore` so production uses `UserDefaults` -/// and tests use an in-memory double. +/// intent, forecast visibility, and the reminder / daily-summary schedules — +/// plus small pieces of UI continuity state, behind a `KeyValueStore` so +/// production uses `UserDefaults` and tests use an in-memory double. /// /// `store` is deliberately not defaulted: defaulting it to /// `UserDefaults.standard` made the real, process-wide defaults the thing you @@ -38,6 +38,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 { @@ -148,6 +159,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/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index c7f6203c..4682926b 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 dbd2132e..fd55085d 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 a0757de4..517816ac 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 00000000..ab423766 --- /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 00000000..5e7f2632 --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift @@ -0,0 +1,131 @@ +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), + ) + let expiredRecord = try #require(await store.plannedStayRecords().first) + + #expect(try await coordinator.active() == 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) + let activeStay = try await coordinator.expireIfLatest( + staleExpiredRecord, + asOf: CalendarDay(year: 2026, month: 7, day: 1), + ) + + #expect(activeStay == futureStay) + #expect(try await coordinator.active() == futureStay) + } + + @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) + } + + @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) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayRecordTests.swift b/Where/WhereCore/Tests/PlannedStayRecordTests.swift new file mode 100644 index 00000000..dbfa8850 --- /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/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index 71df3e7b..7121b246 100644 --- a/Where/WhereCore/Tests/WherePreferencesTests.swift +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -12,6 +12,7 @@ struct WherePreferencesTests { #expect(preferences.hasOnboarded == false) #expect(preferences.wantsTracking) + #expect(preferences.showsLocationForecastsOnLocationsTab) #expect(preferences.remindersEnabled) #expect(preferences.reminderTime == .defaultEvening) #expect(preferences.summaryEnabled) @@ -21,6 +22,14 @@ struct WherePreferencesTests { #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) } + @Test func locationForecastVisibilityPersists() { + let preferences = preferences() + + preferences.showsLocationForecastsOnLocationsTab = false + + #expect(preferences.showsLocationForecastsOnLocationsTab == false) + } + @Test func locationDayCountsRoundTripIndependentlyByYear() { let preferences = preferences() let counts2025: [Region: Int] = [.california: 42, .other: 3] @@ -37,6 +46,7 @@ struct WherePreferencesTests { let preferences = preferences() preferences.hasOnboarded = true preferences.wantsTracking = false + preferences.showsLocationForecastsOnLocationsTab = false preferences.remindersEnabled = false preferences.reminderTime = ReminderTime(hour: 9, minute: 15) preferences.summaryEnabled = false @@ -49,6 +59,7 @@ struct WherePreferencesTests { #expect(preferences.hasOnboarded == false) #expect(preferences.wantsTracking) + #expect(preferences.showsLocationForecastsOnLocationsTab) #expect(preferences.remindersEnabled) #expect(preferences.reminderTime == .defaultEvening) #expect(preferences.summaryEnabled) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index a696c691..b721f193 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 3ec7dbb3..371046be 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -74,7 +74,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), **`RemindersSettingsModel`** (notification prefs), and **`LocationDayCountPresentationModel`** (the last primary-card counts the @@ -111,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, + 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/PlannedStayEditorSnapshotTests.swift b/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift new file mode 100644 index 00000000..b102b584 --- /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__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png index 2f32ec04..865e986a 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 08cd4ee1..aa7c29cd 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 00000000..f6b881bc --- /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:41e38b8c008484a2bc0e74b68ec9a890b154c84321f1249844ab96263db98f9f +size 1217589 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 00000000..352a0e73 --- /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: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 new file mode 100644 index 00000000..d4130e4c --- /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: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 8cd77dce..62295173 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: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 d1ad26a6..0e7782ac 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:2daa771d69ea06e5b711bda555b90cd77dfcd5005822b647242c7f4d1b4f2ec9 +size 227403 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png index d6bbaef3..b1bdefcf 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:8cf5a9cfca7e53a4d585598e157d0a1a424b1a8c9d7dd14d74ecc5ecb4c592bc +size 941010 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png index e1e38d16..9d58489e 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 3df5d04f..457c334a 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 327d3631..92502950 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 7747d633..9b2fb6cc 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 90ccb3ff..c6bc850f 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 e11f30e8..5fe661ff 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 66815a9d..a4b6e60c 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 9f979b37..a2c59c09 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 1214cbde..99960ee6 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 d83cb15c..c12755a5 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 a777c0f6..39878959 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 ef02c756..afb8fa5a 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__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png new file mode 100644 index 00000000..68cdb35d --- /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 00000000..ab54c334 --- /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 b620ad84..4189b103 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: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 a486bb06..a92bc941 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: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 83ab2943..49305a3e 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: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 1bf3b185..e350192c 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: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 59c6866e..4883c53c 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: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 68cdb35d..2f3b51af 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: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 06cedbf6..af668dd3 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: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 75538b7c..e08a6d98 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: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 747da9ae..f964619c 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: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 3f1e6f9a..2d5e9fbb 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: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 new file mode 100644 index 00000000..64b65a8d --- /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: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 new file mode 100644 index 00000000..961a9b10 --- /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:261dac5247ea2392bbd91be11b6a88643d69053a6d58a2f9c96dea5a80884b7f +size 2262525 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 00000000..1e00d6a2 --- /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 00000000..54eb4722 --- /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 00000000..2f009b94 --- /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 00000000..018e313a --- /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 00000000..d6258d5c --- /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 00000000..70b3f563 --- /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 00000000..b499e762 --- /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 00000000..43a0a228 --- /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 00000000..dd7c9d35 --- /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 00000000..4e14f58b --- /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 00000000..e9e24182 --- /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 00000000..368db2d4 --- /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/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index ea619e7a..70e85e3d 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 08da0e03..1309a6bb 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 796be76d..fb9a39c0 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 8083afa4..9bc50a77 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 4a7ff0aa..4c44ee2b 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 7729d163..28397300 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 6ccd8ee4..18ea70ce 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 7f9a1136..fb21bfd9 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 5500fe30..750a8735 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 1b3cdb42..268fc3ef 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 85be0c53..4e2929d7 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 f5abd670..a564be3f 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/Forecasting/LocationForecastDisclosureStyle.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift new file mode 100644 index 00000000..2a971d34 --- /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 new file mode 100644 index 00000000..767b429c --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -0,0 +1,166 @@ +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)? + var isCollapsible = false + + @State private var isExpanded = false + + @Environment(\.stylesheet) private var stylesheet + + private var style: WhereStylesheet.LocationForecastStyle { + stylesheet.locationForecast + } + + var body: some View { + VStack(alignment: .leading, spacing: style.rowSpacing) { + if isCollapsible { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: style.rowSpacing) { + forecastContent + } + .padding(.top, style.rowSpacing) + } + label: { + forecastHeader + } + .disclosureGroupStyle(LocationForecastDisclosureStyle( + foregroundColor: style.collapsedLabelColor, + )) + } 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(style.borderColor, lineWidth: style.borderWidth) + } + .shadow( + color: style.shadowColor, + radius: style.shadowRadius, + y: style.shadowOffsetY, + ) + } 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) + } + + 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) + } + } + + @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) + } + } +} + +private struct LocationForecastRow: View { + let forecast: LocationForecast + var plannedStay: PlannedStay? + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + VStack(alignment: .leading, spacing: stylesheet.locationForecast.estimateSpacing) { + Text(WhereFormat.locationForecastEstimate( + region: forecast.region, + days: forecast.estimatedTotalDays, + )) + .font(.subheadline) + Text(WhereFormat.locationForecastBasis( + yearToDateDays: forecast.yearToDateDays, + )) + .font(.footnote) + .foregroundStyle(.secondary) + + if let plannedStay, plannedStay.region == forecast.region { + Text(WhereFormat.locationForecastPlan(through: plannedStay.through)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .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 diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift new file mode 100644 index 00000000..5b3395b6 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift @@ -0,0 +1,131 @@ +import RegionKit +import SnapshotKit +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) + } + } + } +} + +#if DEBUG + extension PlannedStayEditor: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "NewPlan", configurations: .screenDefaults) { + PlannedStayEditor( + 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() + } + } + } + } + + #Preview { + PlannedStayEditor.snapshotPreviews + } +#endif diff --git a/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift b/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift new file mode 100644 index 00000000..530091f0 --- /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 00000000..76eabe16 --- /dev/null +++ b/Where/WhereUI/Sources/Model/LocationForecastModel.swift @@ -0,0 +1,128 @@ +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 + } + + /// 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 + } + + /// 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()) + } + 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 7a73c1ea..d0cce03a 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 @@ -114,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 @@ -200,11 +214,14 @@ 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) calendar.timeZone = .current self.calendar = calendar + forecasts = LocationForecastModel(services: services, calendar: calendar, now: now) loadState = report == nil ? .idle : .loaded } @@ -269,6 +286,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 0d965182..ba39955f 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -245,6 +245,37 @@ ) } + @MainActor + public static func plannedStayYearReportModel() -> YearReportModel { + let completeReport = sampleReport() + 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 { + 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), + )) + 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 860ddefd..0fec29cb 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 { @@ -118,26 +124,67 @@ struct CalendarContentView: View { ScrollView { LazyVStack(spacing: stylesheet.calendar.monthSpacing) { ForEach(shownMonths(months)) { month in - MonthGridView(month: month, focusedRegion: focusedRegion) + 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() } } - /// 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 var focusedForecast: LocationForecast? { + guard let focusedRegion else { return nil } + return report.forecasts.forecast(for: focusedRegion, report: report.report) + } + + /// 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 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 + } + + /// 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 } - return Array(months - .filter { $0.startOfMonth <= currentMonthStart } - .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 months.filter { $0.startOfMonth <= lastShownMonth } } } @@ -147,6 +194,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 @@ -220,19 +269,27 @@ 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, + column: column, leadingRadius: joinsLeft ? (isRowStart ? band.continuationRadius : 0) : band .cornerRadius, trailingRadius: joinsRight ? (isRowEnd ? band.continuationRadius : 0) : band @@ -241,6 +298,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 @@ -248,6 +320,8 @@ private struct MonthGridView: View { /// one connected shape. Empty `regions` means no pill. private struct DayBandGeometry { var regions: [Region] + var isPlanned: Bool + var column: Int var leadingRadius: CGFloat var trailingRadius: CGFloat var extendLeading: CGFloat @@ -255,6 +329,8 @@ private struct DayBandGeometry { static let none = DayBandGeometry( regions: [], + isPlanned: false, + column: 0, leadingRadius: 0, trailingRadius: 0, extendLeading: 0, @@ -377,9 +453,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, ), ) } @@ -389,9 +466,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) @@ -415,14 +492,35 @@ 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, + gridOriginX: CGFloat(band.column) + * (proxy.size.width + calendar.month.gridSpacing) + - band.extendLeading, + ) + .opacity(calendar.regionBand.planned.hatchOpacity) + .clipShape(shape) + } + } .frame( width: proxy.size.width + band.extendLeading + band.extendTrailing, height: proxy.size.height, @@ -480,6 +578,26 @@ private struct DayCell: View { ) } } + whereSnapshot(name: "FocusedPlannedStay", configurations: .phoneLightDark) { + NavigationStack { + CalendarContentView( + focusedRegion: .newYork, + report: PreviewSupport.plannedStayYearReportModel(), + ) + } + } + whereSnapshot( + name: "FocusedPlannedStayFullContent", + configurations: [ + SnapshotConfiguration(device: .fullContent(name: "fullHeight", width: 402)), + ], + ) { + CalendarContentView( + 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 // nothing scrolls — which needs the chrome-free view, since a @@ -492,6 +610,7 @@ private struct DayCell: View { ], ) { CalendarContentView(report: PreviewSupport.loadedYearReportModel()) + .background(Color(.systemBackground)) } } } diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index d7e7dd7c..bc7e82d7 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -179,6 +179,17 @@ struct LocationsView: View { .defaultScrollAnchor(.center) .scrollBounceBehavior(.basedOnSize) .accessibilityIdentifier("where_root_title") + .safeAreaInset(edge: .bottom) { + if report.showsLocationForecastsOnLocationsTab, !topForecasts.isEmpty { + LocationForecastPanel( + forecasts: topForecasts, + plannedStay: report.forecasts.activePlannedStay, + isCollapsible: true, + ) + .padding(.horizontal) + .padding(.bottom, stylesheet.spacing.small) + } + } .onAppear { isCardSurfaceVisible = true } .onDisappear { isCardSurfaceVisible = false } // The task belongs to the cards, and its ID includes explicit visibility @@ -197,6 +208,12 @@ struct LocationsView: View { ) } + /// 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 /// destination: the tapped card expands into it via matched geometry, and the /// stack's back gesture collapses it again. @@ -273,6 +290,12 @@ private struct ResolveToolbarLabel: View { ) { LocationsView(report: PreviewSupport.loadedYearReportModel()) } + 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()) } @@ -283,6 +306,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/Primary/PlannedStayHatch.swift b/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift new file mode 100644 index 00000000..82e76a7d --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PlannedStayHatch.swift @@ -0,0 +1,50 @@ +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 + /// 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 = 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)) + x += spacing + } + context.stroke(path, with: .color(color), lineWidth: lineWidth) + } + .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, gridOriginX: 0) + .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 8aef4f16..d90eac5a 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", @@ -2867,6 +2879,120 @@ } } }, + "locationForecast.basis" : { + "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 %@ here." + } + } + } + }, + "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.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", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ might be **%2$@** 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", @@ -5851,6 +5977,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" : { @@ -5939,6 +6077,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 4c01a13a..b6fd4027 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 5926012f..905d612b 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/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 64f33d23..78bfda39 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -60,6 +60,30 @@ enum WhereFormat { String(localized: .manualRangeFooter(count)) } + static func locationForecastEstimate(region: Region, days: Int) -> AttributedString { + AttributedString(localized: .locationForecastEstimate( + region.localizedName, + dayCount(days), + )) + } + + 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 { + 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, @@ -169,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 bfb2f4ff..63fb62b2 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 @@ -69,6 +70,40 @@ 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 + var collapsedLabelColor: Color + var borderColor: Color + var borderWidth: CGFloat + var shadowColor: Color + var shadowRadius: CGFloat + var shadowOffsetY: CGFloat + var expansionAnimation: Animation + + static let standard = LocationForecastStyle( + cornerRadius: 22, + 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), + shadowRadius: 8, + shadowOffsetY: 2, + expansionAnimation: .easeInOut(duration: 0.2), + ) + } +} + extension WhereStylesheet { /// Generic spacing scale, in points. struct Spacing: Equatable { @@ -848,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 @@ -898,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 new file mode 100644 index 00000000..f1a35e96 --- /dev/null +++ b/Where/WhereUI/Tests/LocationForecastModelTests.swift @@ -0,0 +1,137 @@ +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 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 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() + 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/LocationSettingsViewTests.swift b/Where/WhereUI/Tests/LocationSettingsViewTests.swift index da4cc478..10c195b7 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/PlannedStayHatchTests.swift b/Where/WhereUI/Tests/PlannedStayHatchTests.swift new file mode 100644 index 00000000..5dd2f2b9 --- /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) + } + } +} diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index 647d23ac..81750bbe 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/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 2eb7ff1f..66a4f68b 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 c37de898..90477ed2 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -37,6 +37,28 @@ struct WhereFormatTests { #expect(WhereFormat.dayUnit(2) == "days") } + @Test func locationForecastCopyComposesLocalizedDayCounts() { + 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.locationForecastElapsed(days: 182) == "182 days elapsed") + #expect( + WhereFormat.locationForecastBasis(yearToDateDays: 91) + == "Based on 91 days here.", + ) + #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. @@ -121,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 603e0e0e..b4a5f81c 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) @@ -247,6 +253,21 @@ 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) + #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)) + #expect(forecast.shadowRadius == 8) + #expect(forecast.shadowOffsetY == 2) + #expect(forecast.expansionAnimation == .easeInOut(duration: 0.2)) + } + @Test func appIconStyle() { let appIcon = style.appIcon #expect(appIcon.gridMax == 180) diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index c08216d3..5766b941 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