diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb index bd8192867..876fe3b3a 100644 --- a/Where/Tools/Tests/upgrade_backup_test.rb +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -7,10 +7,11 @@ class UpgradeBackupTest < Minitest::Test def test_v1_adds_current_tables_without_inventing_recording_consent upgraded = upgrade_manifest(base_manifest(1)) - assert_equal 4, upgraded.fetch("formatVersion") + assert_equal 5, upgraded.fetch("formatVersion") assert_equal [], upgraded.fetch("recordingDeviceProfiles") assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges") assert_equal [], upgraded.fetch("recordingDeviceRemovals") + assert_equal [], upgraded.fetch("plannedStayRecords") assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID") end @@ -53,7 +54,7 @@ def test_v3_reshapes_recording_device_data upgraded = upgrade_manifest(manifest) - assert_equal 4, upgraded.fetch("formatVersion") + assert_equal 5, upgraded.fetch("formatVersion") assert_equal({ "kind" => { "other" => {} }, "registrationGenerationID" => "generation-id", @@ -66,8 +67,8 @@ def test_v3_reshapes_recording_device_data }, upgraded.fetch("recordingDeviceMetadataChanges").last) end - def test_v4_is_idempotent - once = upgrade_manifest(base_manifest(4)) + def test_v5_is_idempotent + once = upgrade_manifest(base_manifest(5)) assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once))) end @@ -83,7 +84,7 @@ def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps end def test_rejects_branch_only_or_future_formats - error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(5)) } + error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(6)) } assert_equal 1, error.status end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 04c920992..dd85486f2 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -1,9 +1,10 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Reshapes a legacy Where backup into the current v4 manifest. The automatic-recording feature +# Reshapes a legacy Where backup into the current v5 manifest. The automatic-recording feature # was not shipped in v1 or v2, so upgrading adds the recording tables empty; it never invents an -# installation or recording consent. v4 expands device kinds and groups metadata edit payloads. +# installation or recording consent. v4 expands device kinds and groups metadata edit payloads; +# v5 adds an empty planned-stay register when the source predates it. require "json" require "tmpdir" @@ -12,7 +13,7 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 4 +CURRENT_FORMAT_VERSION = 5 SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze REGION_MAP = { @@ -188,6 +189,7 @@ def upgrade_manifest(manifest) manifest["recordingDeviceProfiles"] ||= [] manifest["recordingDeviceMetadataChanges"] ||= [] manifest["recordingDeviceRemovals"] ||= [] + manifest["plannedStayRecords"] ||= [] upgrade_recording_devices!(manifest, source_version) manifest.delete("recordingDevices") manifest.delete("recordingDeviceCheckIns") diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 8df952cbe..f50142e3d 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -63,6 +63,9 @@ internal shape. `BackupArchive.currentFormatVersion` and extends [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb); never add an in-code legacy decode fallback. +- **The planned stay is a generation-scoped 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 (`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 6f63d8c4f..05c9c47f7 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -65,6 +65,9 @@ one it belongs to rather than to a god-object: (`clearManualDay` / `clearYear` / `eraseAllData`), evidence, and issue dismissals. Each write commits, then awaits its reminder reconcile + widget publish so the next reader sees a fully-applied change. +- **`PlannedStayCoordinator`** — the synced, generation-scoped last-writer register behind “I’ll + be here through…”. Clears and expiry write tombstones, and annual forecasts consume its current + value without coupling projection math to persistence. - **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a given `WhereServices`: a plausible current year of living in New York with @@ -142,7 +145,7 @@ one it belongs to rather than to a god-object: - **`WidgetSnapshotPublisher`** — republishes the App Group snapshot the widgets read, with a freshness policy. - **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins - tables and evidence blobs to one generation-consistent snapshot. Merge preserves queued locations + tables, planned-stay revisions, and evidence blobs to one generation-consistent snapshot. Merge preserves queued locations and the installation-local recording choice. Replace writes the archive into a new child generation, retains existing removal tombstones, and preserves the local choice before pending fixes are discarded. A prepared @@ -162,7 +165,7 @@ one it belongs to rather than to a god-object: `InstallationRecordingContextStoring` keeps the persistence adapter outside the domain value. - **`WherePreferences`** — persisted user intent (onboarding, - reminder / summary schedules, Locations-card GPS-dot visibility) plus the + reminder / summary schedules, Locations-card GPS-dot and annual-forecast visibility) 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 diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 719121b0c..418b8a535 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -9,7 +9,8 @@ import RegionKit /// The arrays represent the persisted collections (`SDLocationSample` / /// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via /// their value-type representations, plus installation profiles, nickname events, the -/// device-removal tombstones. Target-owned check-ins and local recording consent are +/// device-removal tombstones, and planned-stay revisions. Target-owned check-ins and local +/// recording consent are /// intentionally excluded because a backup cannot restore proof of local physical state. public struct BackupArchive: Codable, Sendable, Hashable { /// Bumped whenever the archive's on-disk shape changes in a way older @@ -19,11 +20,11 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// /// v3 adds sample provenance, immutable installation profiles, nickname changes, and archive /// tombstones. v4 expands device kinds, groups metadata edit payloads, and renames the - /// profile's registration-generation key. There's no in-app decode fallback for an older - /// archive — it is reshaped out of band by + /// profile's registration-generation key; v5 adds `plannedStayRecords`. There's no in-app + /// decode fallback for an older archive — it is reshaped out of band by /// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see /// `AGENTS.md`). - public static let currentFormatVersion = 4 + public static let currentFormatVersion = 5 public let formatVersion: Int public let exportedAt: Date @@ -48,6 +49,9 @@ public struct BackupArchive: Codable, Sendable, Hashable { public let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] /// Irreversible installation-removal tombstones. public let recordingDeviceRemovals: [RecordingDeviceRemoval] + /// 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] @@ -64,6 +68,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { recordingDeviceProfiles: [RecordingDeviceProfile], recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceRemovals: [RecordingDeviceRemoval], + plannedStayRecords: [PlannedStayRecord], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -77,6 +82,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.recordingDeviceProfiles = recordingDeviceProfiles self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges self.recordingDeviceRemovals = recordingDeviceRemovals + self.plannedStayRecords = plannedStayRecords self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 79ac50a8a..976033687 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -166,6 +166,7 @@ public actor BackupCoordinator { recordingDeviceProfiles: store.recordingDeviceProfiles(), recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), recordingDeviceRemovals: store.recordingDeviceRemovals(), + plannedStayRecords: store.plannedStayRecords(), ) } let evidence = tables.evidence @@ -200,6 +201,7 @@ public actor BackupCoordinator { recordingDeviceProfiles: tables.recordingDeviceProfiles, recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, recordingDeviceRemovals: tables.recordingDeviceRemovals, + plannedStayRecords: tables.plannedStayRecords, blobs: snapshot.blobs, ) }.value @@ -220,6 +222,7 @@ public actor BackupCoordinator { let recordingDeviceProfiles: [RecordingDeviceProfile] let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] let recordingDeviceRemovals: [RecordingDeviceRemoval] + let plannedStayRecords: [PlannedStayRecord] } private struct ExportSnapshot { @@ -411,6 +414,7 @@ public actor BackupCoordinator { + archive.recordingDeviceProfiles.count + archive.recordingDeviceMetadataChanges.count + archive.recordingDeviceRemovals.count + + archive.plannedStayRecords.count // Decode and validate before touching live recording. Once the archive is known-good, // close ingestion before either merge or replace so a streamed sample cannot cross the @@ -476,6 +480,10 @@ public actor BackupCoordinator { try await store.restoreDismissedIssue(dismissal) report() } + for plannedStay in archive.plannedStayRecords { + try await store.restorePlannedStayRecord(plannedStay) + report() + } for profile in archive.recordingDeviceProfiles { try await store.addRecordingDeviceProfile(profile) report() diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 22c4bae31..686a8f27e 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -99,6 +99,7 @@ public struct BackupService: Sendable { recordingDeviceProfiles: [RecordingDeviceProfile], recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceRemovals: [RecordingDeviceRemoval], + plannedStayRecords: [PlannedStayRecord], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -139,6 +140,7 @@ public struct BackupService: Sendable { recordingDeviceProfiles: recordingDeviceProfiles, recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceRemovals: recordingDeviceRemovals, + plannedStayRecords: plannedStayRecords, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { diff --git a/Where/WhereCore/Sources/Forecasting/LocationForecast.swift b/Where/WhereCore/Sources/Forecasting/LocationForecast.swift new file mode 100644 index 000000000..4cc615775 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/LocationForecast.swift @@ -0,0 +1,68 @@ +import Foundation +import RegionKit + +/// A region's independently calculated current-year residency estimate. +/// +/// This result deliberately contains only the estimate and its inputs. A future +/// residency goal (for example, “55% of the year”) can compare against it +/// without becoming another forecasting policy or changing planned-stay math. +public struct LocationForecast: Hashable, Sendable { + public let region: Region + public let year: Int + public let yearToDateDays: Int + public let elapsedDays: Int + public let plannedDays: Int + public let projectedRemainingDays: Double + public let estimatedTotalDays: Int + + public var estimatedFractionOfYear: Double { + let daysInYear = CalendarDay.yearRange(year).lowerBound + .days(through: CalendarDay.lastDay(ofYear: year)).count + guard daysInYear > 0 else { return 0 } + return Double(estimatedTotalDays) / Double(daysInYear) + } + + /// Estimate a current year's total once three complete calendar months have + /// elapsed. Returns `nil` before April 1 and for any non-current report. + public static func estimate( + region: Region, + report: YearReport, + asOf date: Date, + calendar: Calendar, + plannedStay: PlannedStay?, + ) -> LocationForecast? { + let today = CalendarDay(from: date, in: calendar) + guard report.year == today.year else { return nil } + guard today >= CalendarDay(year: report.year, month: 4, day: 1) else { return nil } + + let firstDay = CalendarDay(year: report.year, month: 1, day: 1) + let lastDay = CalendarDay.lastDay(ofYear: report.year) + let elapsedDays = firstDay.days(through: today).count + let yearLength = firstDay.days(through: lastDay).count + guard elapsedDays > 0, yearLength > 0 else { return nil } + + let yearToDateDays = report.totals[region, default: 0] + let baselineRate = Double(yearToDateDays) / Double(elapsedDays) + let tomorrow = today.adding(days: 1) + + let matchingStay = plannedStay.flatMap { stay in + stay.region == region && stay.through >= today ? stay : nil + } + let plannedEnd = matchingStay.map { min($0.through, lastDay) } + let plannedDays = plannedEnd.map { tomorrow.days(through: $0).count } ?? 0 + let projectionStart = plannedEnd?.adding(days: 1) ?? tomorrow + let remainingDays = projectionStart.days(through: lastDay).count + let projectedRemainingDays = baselineRate * Double(remainingDays) + let estimated = Double(yearToDateDays + plannedDays) + projectedRemainingDays + + return LocationForecast( + region: region, + year: report.year, + yearToDateDays: yearToDateDays, + elapsedDays: elapsedDays, + plannedDays: plannedDays, + projectedRemainingDays: projectedRemainingDays, + estimatedTotalDays: min(yearLength, max(0, Int(estimated.rounded()))), + ) + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStay.swift b/Where/WhereCore/Sources/Forecasting/PlannedStay.swift new file mode 100644 index 000000000..bb87cf3df --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStay.swift @@ -0,0 +1,15 @@ +import Foundation +import RegionKit + +/// User intent that the current stay in `region` continues through an inclusive +/// calendar day. The day is timezone-independent so travel cannot move the +/// asserted departure onto a neighboring date. +public struct PlannedStay: Hashable, Sendable, Codable { + public let region: Region + public let through: CalendarDay + + public init(region: Region, through: CalendarDay) { + self.region = region + self.through = through + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift new file mode 100644 index 000000000..397f58921 --- /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 000000000..a0719c4b8 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift @@ -0,0 +1,23 @@ +import Foundation + +/// One revision of the single synced planned-stay register. A `nil` value is a +/// tombstone, retained so a delayed CloudKit import cannot resurrect an older +/// active stay after it was cleared or expired. +public struct PlannedStayRecord: Hashable, Sendable, Codable, Identifiable { + public let id: UUID + public let value: PlannedStay? + public let updatedAt: Date + + public init(id: UUID, value: PlannedStay?, updatedAt: Date) { + self.id = id + self.value = value + self.updatedAt = updatedAt + } + + /// Deterministic last-writer ordering for duplicate rows produced by + /// eventually-consistent CloudKit writes. + public static func newer(_ lhs: PlannedStayRecord, than rhs: PlannedStayRecord) -> Bool { + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id.uuidString > rhs.id.uuidString + } +} diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 311c52c1e..6d8a2d783 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -217,6 +217,32 @@ private enum GenerationScopedFetch { }) } + static func plannedStays( + belongingTo generationID: WhereDataGenerationID, + sortBy: [SortDescriptor] = [], + ) -> FetchDescriptor { + let membership = GenerationMembership(generationID) + let storedGenerationID = membership.storedID + let includesLegacy = membership.includesLegacy + return descriptor(predicate: #Predicate { + $0.generationID == storedGenerationID || + (includesLegacy && $0.generationID == nil) + }, sortBy: sortBy) + } + + static func plannedStays( + belongingTo generationID: WhereDataGenerationID, + id: UUID, + ) -> FetchDescriptor { + let membership = GenerationMembership(generationID) + let storedGenerationID = membership.storedID + let includesLegacy = membership.includesLegacy + return descriptor(predicate: #Predicate { + ($0.generationID == storedGenerationID || + (includesLegacy && $0.generationID == nil)) && $0.id == id + }) + } + static func metadataChanges( belongingTo generationID: WhereDataGenerationID, sortBy: [SortDescriptor] = [], @@ -543,6 +569,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDManualDay.self, SDDismissedIssue.self, SDTrackedRegion.self, + SDPlannedStay.self, SDRecordingDeviceProfile.self, SDRecordingDeviceMetadataChange.self, SDRecordingDeviceCheckIn.self, @@ -1154,6 +1181,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { )) { context.delete(record) } + for record in try context.fetch(GenerationScopedFetch.plannedStays( + belongingTo: generationID, + )) { + context.delete(record) + } for record in try context.fetch(GenerationScopedFetch.metadataChanges( belongingTo: generationID, )) { @@ -1734,6 +1766,45 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + // MARK: - Planned stay + + public func plannedStayRecords() async throws -> [PlannedStayRecord] { + let context = readContext() + let generationID = try readGenerationID(in: context) + let descriptor = GenerationScopedFetch.plannedStays( + belongingTo: generationID, + sortBy: [SortDescriptor(\.updatedAt), SortDescriptor(\.id)], + ) + 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() + let generationID = mutationGenerationID() + for existing in try context.fetch(GenerationScopedFetch.plannedStays( + belongingTo: generationID, + )) { + context.delete(existing) + } + context.insert(SDPlannedStay(value: record, generationID: generationID)) + } + + public func restorePlannedStayRecord(_ record: PlannedStayRecord) async throws { + let context = mutationContext() + let generationID = mutationGenerationID() + for duplicate in try context.fetch(GenerationScopedFetch.plannedStays( + belongingTo: generationID, + id: record.id, + )) { + context.delete(duplicate) + } + context.insert(SDPlannedStay(value: record, generationID: generationID)) + } + // MARK: - Tracked regions public func trackedRegions() async throws -> Set { @@ -2270,6 +2341,46 @@ final class SDTrackedRegion { } } +/// 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 generationID: UUID? + var id: UUID? + var regionID: String? + var throughDayKey: String? + var updatedAt: Date? + + init() {} + + convenience init(value: PlannedStayRecord, generationID: WhereDataGenerationID) { + self.init() + self.generationID = generationID.rawValue + 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) + } +} + /// Immutable identity row written once by its installation. Every field is optional because /// CloudKit may materialize a partial record before all fields arrive. @Model diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 2c6b24f9b..2e4ce648f 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -182,6 +182,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. @@ -288,4 +302,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 a724b6941..9bdc52a35 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -1,8 +1,8 @@ import Foundation import RegionKit -/// The app's persisted user intent — onboarding completion and the reminder / -/// daily-summary schedules — plus small pieces of UI +/// The app's persisted user intent — onboarding completion, 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. /// @@ -39,6 +39,17 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.showsRecordedLocationDots.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 { @@ -149,6 +160,7 @@ public final class WherePreferences { private enum Keys: String, CaseIterable { case hasOnboarded = "where.hasOnboarded" case showsRecordedLocationDots = "where.showsRecordedLocationDots" + 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 aff11e36c..d96dc5789 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -58,6 +58,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 @@ -267,6 +270,11 @@ public struct WhereServices: Sendable { changes: store.remoteChanges(), reconcile: { await derivedData.reconcile() }, ) + let plannedStays = PlannedStayCoordinator( + store: store, + calendar: aggregator.calendar, + now: now, + ) let recentActivity = RecentActivitySummarizer( store: store, attributor: attributor, @@ -286,6 +294,7 @@ public struct WhereServices: Sendable { self.recording = recording 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 6131507a9..dd858826f 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -94,6 +94,14 @@ struct BackupCoordinatorTests { private static let recordingDeviceID = RecordingDeviceID( rawValue: UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!, ) + private static let plannedStay = PlannedStayRecord( + id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + value: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 9, day: 1), + ), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + ) /// Seed every persisted domain directly into a store so backup tests don't /// depend on the journal or recording controller. @@ -107,6 +115,7 @@ struct BackupCoordinatorTests { regions: [.newYork], )) try await store.restoreDismissedIssue(dismissal) + try await store.restorePlannedStayRecord(plannedStay) try await store.addRecordingDeviceProfile(RecordingDeviceProfile( id: recordingDeviceID, systemName: "iPad", @@ -168,6 +177,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.recordingDeviceProfiles() == source.store .recordingDeviceProfiles()) #expect(try await destination.store.recordingDeviceMetadataChanges() == source.store @@ -514,6 +524,7 @@ struct BackupCoordinatorTests { recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], blobs: [:], ) defer { try? FileManager.default.removeItem(at: secondURL.deletingLastPathComponent()) } diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index ea5c54ff1..6c74491f9 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -121,6 +121,19 @@ struct BackupServiceTests { ] } + private static func plannedStayFixtures() -> [PlannedStayRecord] { + [ + PlannedStayRecord( + id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!, + value: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 9, day: 1), + ), + updatedAt: exportDate, + ), + ] + } + private static func archive() -> BackupArchive { BackupArchive( exportedAt: exportDate, @@ -133,6 +146,7 @@ struct BackupServiceTests { recordingDeviceProfiles: recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], assets: [], ) } @@ -206,6 +220,7 @@ struct BackupServiceTests { recordingDeviceProfiles: recordingDeviceProfiles, recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceRemovals: [deviceArchive], + plannedStayRecords: Self.plannedStayFixtures(), blobs: blobs, exportedAt: Self.exportDate, ) @@ -226,6 +241,7 @@ struct BackupServiceTests { #expect(result.archive.recordingDeviceProfiles == recordingDeviceProfiles) #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) #expect(result.archive.recordingDeviceRemovals == [deviceArchive]) + #expect(result.archive.plannedStayRecords == Self.plannedStayFixtures()) let encodedManifest = try #require(String( data: BackupService.makeEncoder().encode(result.archive), encoding: .utf8, @@ -239,12 +255,12 @@ struct BackupServiceTests { } @Test func unsupportedFormatIsRejectedBeforeItsMissingCurrentFieldsAreDecoded() { - let legacyManifest = Data(#"{"formatVersion":5}"#.utf8) + let legacyManifest = Data(#"{"formatVersion":6}"#.utf8) do { _ = try BackupService.decodeManifest(legacyManifest) Issue.record("Expected the legacy backup format to be rejected.") - } catch BackupService.BackupError.unsupportedFormatVersion(5) { + } catch BackupService.BackupError.unsupportedFormatVersion(6) { // Expected: the version envelope was decoded before the strict current shape. } catch { Issue.record("Unexpected error: \(error)") @@ -291,6 +307,7 @@ struct BackupServiceTests { recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), recordingDeviceRemovals: [], + plannedStayRecords: [], assets: [], ) var json = try #require(String( @@ -321,6 +338,7 @@ struct BackupServiceTests { recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -352,6 +370,7 @@ struct BackupServiceTests { recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -373,6 +392,7 @@ struct BackupServiceTests { recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -406,6 +426,7 @@ struct BackupServiceTests { recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -443,6 +464,7 @@ struct BackupServiceTests { recordingDeviceProfiles: [], recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], + plannedStayRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -476,6 +498,7 @@ struct BackupServiceTests { recordingDeviceProfiles: Self.recordingDeviceProfileFixtures(), recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), recordingDeviceRemovals: [], + plannedStayRecords: Self.plannedStayFixtures(), assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", diff --git a/Where/WhereCore/Tests/LocationForecastTests.swift b/Where/WhereCore/Tests/LocationForecastTests.swift new file mode 100644 index 000000000..ab423766d --- /dev/null +++ b/Where/WhereCore/Tests/LocationForecastTests.swift @@ -0,0 +1,126 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct LocationForecastTests { + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private static func date(_ month: Int, _ day: Int, year: Int = 2026) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + private static func report(days: Int = 91, year: Int = 2026) -> YearReport { + YearReport(year: year, days: [], totals: [.newYork: days]) + } + + @Test(arguments: [(1, 1), (3, 31)]) + func unavailableUntilThreeFullMonthsHaveElapsed(month: Int, day: Int) { + #expect(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(month, day), + calendar: Self.calendar, + plannedStay: nil, + ) == nil) + } + + @Test func becomesAvailableOnAprilFirst() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(4, 1), + calendar: Self.calendar, + plannedStay: nil, + )) + + #expect(forecast.elapsedDays == 91) + #expect(forecast.estimatedTotalDays == 365) + } + + @Test func unavailableForAPastReport() { + #expect(LocationForecast.estimate( + region: .newYork, + report: Self.report(year: 2025), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: nil, + ) == nil) + } + + @Test func annualizesElapsedCalendarDayRate() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: nil, + )) + + #expect(forecast.elapsedDays == 182) + #expect(forecast.yearToDateDays == 91) + #expect(forecast.plannedDays == 0) + #expect(forecast.projectedRemainingDays == 91.5) + #expect(forecast.estimatedTotalDays == 183) + } + + @Test func matchingStayCountsThroughItsInclusiveDateThenResumesBaseline() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2026, month: 7, day: 10), + ), + )) + + #expect(forecast.plannedDays == 9) + #expect(forecast.projectedRemainingDays == 87) + #expect(forecast.estimatedTotalDays == 187) + } + + @Test func crossYearStayCountsEveryRemainingDayThisYear() throws { + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: PlannedStay( + region: .newYork, + through: CalendarDay(year: 2027, month: 2, day: 1), + ), + )) + + #expect(forecast.plannedDays == 183) + #expect(forecast.projectedRemainingDays == 0) + #expect(forecast.estimatedTotalDays == 274) + } + + @Test func anotherRegionsStayDoesNotChangeTheEstimate() throws { + let baseline = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: nil, + )) + let withCaliforniaStay = try #require(LocationForecast.estimate( + region: .newYork, + report: Self.report(), + asOf: Self.date(7, 1), + calendar: Self.calendar, + plannedStay: PlannedStay( + region: .california, + through: CalendarDay(year: 2026, month: 8, day: 1), + ), + )) + + #expect(withCaliforniaStay == baseline) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift new file mode 100644 index 000000000..5e7f26327 --- /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 000000000..dbfa88506 --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayRecordTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import WhereCore + +struct PlannedStayRecordTests { + @Test func newerUsesTheIdentifierToBreakTimestampTies() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let lower = try PlannedStayRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + value: nil, + updatedAt: updatedAt, + ) + let higher = try PlannedStayRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + value: nil, + updatedAt: updatedAt, + ) + + #expect(PlannedStayRecord.newer(higher, than: lower)) + #expect(!PlannedStayRecord.newer(lower, than: higher)) + } +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index 593dabd36..44894fa97 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.showsRecordedLocationDots) + #expect(preferences.showsLocationForecastsOnLocationsTab) #expect(preferences.remindersEnabled) #expect(preferences.reminderTime == .defaultEvening) #expect(preferences.summaryEnabled) @@ -37,6 +38,7 @@ struct WherePreferencesTests { let preferences = preferences() preferences.hasOnboarded = true preferences.showsRecordedLocationDots = false + preferences.showsLocationForecastsOnLocationsTab = false preferences.remindersEnabled = false preferences.reminderTime = ReminderTime(hour: 9, minute: 15) preferences.summaryEnabled = false @@ -49,6 +51,7 @@ struct WherePreferencesTests { #expect(preferences.hasOnboarded == false) #expect(preferences.showsRecordedLocationDots) + #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 7965f0d81..79f1a332c 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -5,7 +5,7 @@ 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` / `DevicesSettingsModel` / `OnboardingFlowModel` / -`OnboardingImportRecoveryModel`). +`OnboardingImportRecoveryModel` / `LocationForecastModel`). 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. @@ -72,6 +72,8 @@ Layering, localization, preview, and testing conventions live in the feature project, simplify, or spatially reduce artwork in a card's `body`. - Keep Locations-card points on `YearReportModel`'s loaded `YearReportDetails`. +- Keep planned-stay persistence and forecast math in WhereCore; `LocationForecastModel` only mirrors + the active register and orchestrates intents for the Locations and calendar surfaces. - Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`, typewriter reveals) must consult the shared `@MotionIsStatic` helper ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index a0e1bf9ac..a54997813 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -92,7 +92,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's per-device recording changes, `startTracking()` / `stopTracking()`, `refreshWidgetSnapshot()`). It holds no presentation state of its own. - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected - year's `YearReportDetails`, its `LoadState`, and the manual-day edit intents), plus + year's `YearReportDetails`, its `LoadState`, manual-day edit intents, and the shared + **`LocationForecastModel`** planned-stay mirror), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** (Settings export progress and failures), **`RemindersSettingsModel`** (notification prefs), @@ -288,6 +289,11 @@ invalidate the card's text or Canvas artwork. The card adds no standalone edge stroke; its containing Liquid Glass surface owns the subtle outer border so direct and production rendering do not diverge. +After at least three months of the current year, Locations can reveal a collapsible annual estimate +from the recorded pace; Settings > Appearance can disable it entirely. A focused region calendar +places the estimate after the current month and renders “I’ll be here through…” future days with a +continuous hatched band, distinct from recorded presence. + DEBUG builds include Card Designer Studio under Settings → Appearance. It edits a versioned, persisted draft of the regular, compact, and shared card presentation, previews both appearances with live tilt, and exports the full diff --git a/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift b/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift new file mode 100644 index 000000000..b102b5846 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/PlannedStayEditorSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct PlannedStayEditorSnapshotTests { + @Test func plannedStayEditor() async { + await assertSnapshots(of: PlannedStayEditor.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png index c80cffb41..eeb8fb1e0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8f586b22b65d17708e8355addc64a5cc7f8e5ccf35608c19fe23a221f984f84 -size 238625 +oid sha256:23ac10dd726c4466d21e19c2767652a59bead5231e5e25ba0835685b2bd3a6c2 +size 262180 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png index 09b49b945..cbd9347a8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a397c2750f32bc4a5e99f68c0dea531c7d2508da4ee7925e4d2b519afc8492b -size 516941 +oid sha256:915eefd78de76bb5ba021471c933941d329c9dbad8f52e265451008001186485 +size 576433 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png index 4f3b0f165..be2dc963a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7a4052c05628949be557e4b0136f6bc55f9fe10df2bb4e6137fb6031b57d4d1 -size 337275 +oid sha256:1f57e5a5fa8fd90e2a62ac2a33e2dd95bea2effeb469a84436c26520e3cf211d +size 411979 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png index c84d3192c..0fc62fc7f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:070bb6f19bd9475533b180c9481e421027e5b15d3ab3efca7c8674bb9873e9df -size 239600 +oid sha256:d2cb9d9665c24423225b092f2d2193c3a9a116f15121fc605cbd7746ceb02b97 +size 264145 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png index 6aada851b..08a395b2a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55afc8e0b2bf1b99c0f6692d472ce7121d3f2c81ad268b12f87551b21ef6ff23 -size 239163 +oid sha256:817412d4d8cfa85ede65f64742c4b6fe8acfa25d34d6fa6eaec54955bf56785e +size 264189 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png index 1f1fe17aa..b2ee0867b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:089d0157447d784724ac1d6d283ccfcadceae4e0a7f0b1baa22a40c2076d1908 -size 122139 +oid sha256:3cbc55698a38661561b944cf403be404d778225fc18174620fce26713eb6a37f +size 147640 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png index 4ba1d8eb4..0da657032 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3cb7509db4e0de8b9e3f235742a139ead75642582872dfb5f651d3803300bbf -size 322118 +oid sha256:92aeb18c991f90f484901dcf44349f7cc4ce34621a203b50b013ea1ada75ff77 +size 380893 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png index 6cc4cddd3..f8028a5d2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cccb5744c58e83f2226e0c7aaf7d9655cc4df51cd503fccafaea686ceac81013 -size 277442 +oid sha256:d963dd326bb209c4f73a3d6d3b98632abd11811bf4f168960e5cb326de6ba5c8 +size 357285 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png index d50db5b3e..a48415e38 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33d76a272eab37f397d692b5330054ab4262d34914b2468bbffc097e750b3a97 -size 123346 +oid sha256:b477751714d13c6e000adba324c7696f90adf574ee14d2e45b15e86a84723f74 +size 149922 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png index a166d2f30..cef787c64 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5c13d082e844a9f0d52ab730337f17c500c2999cc0dd05de6d754cbaf9447d9 -size 123383 +oid sha256:f4ffedc8f9f8c18c8315d7eebd57a676021f2df0544016beac0b07ec4d1bdfa1 +size 149787 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png index 79c180ea3..b15ad50a5 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:4a7e54e1227871b7d48ba0a6c815444282dd628c1d6c4dd0c53d6581be3680bb -size 690381 +oid sha256:c81ef681ed81bb3319b40a9e0f76d6016b3fd3f5eb0c854ed73571c9c8bea012 +size 690375 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 394026443..f0f96bfec 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:2b5dfa543283722a47dd5e94e551b22a2cb33231847587470616ebeaf6123ced -size 648652 +oid sha256:85027f31e97dfec0cc450bec8ca469500a2a3642ee262ce48500047e43a529a5 +size 648125 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png new file mode 100644 index 000000000..1af05fb8a --- /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:529b7b29fc5ca6f614ea319b547a1e23725248f39cb90af44910d810562daa3e +size 1067372 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png new file mode 100644 index 000000000..55759b50b --- /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:fedc0bd3e06903dc0441d0085b3e1e2da18974d6bf3fef010a7cd2883670281a +size 972085 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index a5b060d71..eb09988f5 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:05fb3a11fe5e29f7309ee9506f8270b3fbf4d8eebfbaf85863be2fb377327313 -size 759843 +oid sha256:d19e47f97f9af292cc55001ad879272cfea53da9a17aeed1afc86ace9afa6411 +size 880899 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 edac051a3..841f6516b 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:7f06e69ecf7cedfbd0f95be37aafd7651bce7037b736289673e3c7dd43ad49a5 -size 731719 +oid sha256:5793809829b802062b73960e737df1c9b16d4c6eae83ca63e56ea66545b4437b +size 820542 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png index e1e38d16c..9d58489ef 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a13c0d8cf8bcfa5eac237d97514c8abce9ca2d16f7aaeead5f6faa44d2d9cb6 -size 218511 +oid sha256:1a1fc7a8a8d42cb161723cc3854a3282ffd46baa384cae53bfac350e366622cc +size 218288 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png index 3df5d04fd..457c334a8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e1a9f64f52ae2b870dcbd7a09eca768f4fc4a686a6c902dc579a434cb06357d -size 208368 +oid sha256:56ec736df4c83ec4b85b2a4eb32ec1371e239d31c12db8e3832516adfd670ad7 +size 208363 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png index 5a7fb8e5a..8132cf5f6 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:f84ece8c1cce0bd4f5a4910a4c5a0bcfb200d14355c9c51557b45b71691dee61 -size 1071854 +oid sha256:304f176d72caff7f558475e5da73eaa4e5666dd9c89d3e84ae2106623d10be3f +size 1071254 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 41474ea11..d8d63b3c0 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:20c3dc58ebcf58ba681dbb9373a6abc0a52a619497585904561ca375af0b843d -size 3633520 +oid sha256:434ff4fffa865391eaae15787f0c07c67d1e03e036f95025680a3361d7c33ef1 +size 3605403 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 f04e23054..b60f1d9b7 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:adf2abbcd24db11fd5abe6c3ec4b8dfa782b6495d69fa27372beb7a68cabcd6c -size 1666729 +oid sha256:f29d0e6cf0cbfba56ce37bf7de12467f7d1e81eed22606a9b6dda5deff9387ba +size 1667196 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 b906a60b6..9224d5807 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:fd167536f3e366502f79f10fac7a84419d6dc826aca25dd73dfeed07cd6f4fb5 -size 1070235 +oid sha256:510694e619440871941f1a96eaedd8883385e3b499be3f3f3157849119536f40 +size 1070323 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 5f357864e..9acfe8411 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:1ab525a778cdc9aa9968602f870b69d7d0f2c480ee8b3c8afe276c914cf90fea -size 1045894 +oid sha256:6c0a410ccef6c1af5b23f8a97af68bb9aafe608b9195e7ef760d9a159c8585d6 +size 1046030 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png index b4f6935c5..66e47e66a 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:9199867dcdccc46d3a76bf2ed13aed6f32bb59daecd54764d35d6ce2b22bd15a -size 793550 +oid sha256:fcbf193448c91a10933219e591eab1f6cec3c0479c40c64a59acc2d371caf5e1 +size 793759 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 7cd8f8c44..4b8d1e6e3 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:171cedde1b7bd43b44640bc8470721880929bca8b31c071b812fc8d08def7aa7 -size 3204875 +oid sha256:9e4e6b5860d4093cb5bafd0094321fdabb3836de66b585e07cc71e7db7c0d5f9 +size 3188352 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 f0f6d3f65..e9db6b1b7 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:e15298d930d08759d793119fa1a72cc058c5982ce52095ee3100067b3e5cfd97 -size 1440986 +oid sha256:a7583eec2aa6e470de0085f8074679129947ac71b5c5c6fa63ec34b5fb679af0 +size 1441089 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 283ff2a09..1320fabc0 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:d55884e173ac443783e584df8b9f710ee5824946380df29b68d6f6c9bf7850f1 -size 795173 +oid sha256:1a302173f98cf2df5652be0ecbde436db31a922fe8a392e304524851d6c591a1 +size 796089 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 085103ab6..67f66ab98 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:99e92fd738820b3b819908ff4e147c31ad4d5fefbcca3a60972a064ef6526da8 -size 774037 +oid sha256:a1e0077c4d921d1cf020cb99d36313b31ddfbdfa99f53ec6593af396a7172a91 +size 774165 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone.png index 68cdb35d3..2f3b51af0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_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.DotsHidden_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone_dark.png index ab54c334b..b9b944275 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fcfdb83f5e690da756086937af5c6e49a7b55deaa03712439530803891ac136d -size 2308445 +oid sha256:0a75c5ffd8d0ee120c0be544972adcad103fc98decb44ad3f4ca239c14485978 +size 2322967 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png new file mode 100644 index 000000000..68cdb35d3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0900251620d9d375d28f86f9735704ee4cf1d07339fc855a5cfd01841c0f6525 +size 2142029 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png new file mode 100644 index 000000000..ab54c334b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcfdb83f5e690da756086937af5c6e49a7b55deaa03712439530803891ac136d +size 2308445 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index f26276fb5..92077a773 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:d488b2d430ebd7670f5b53d9d52807826da0b0e19d5ae03b4a96e17c393dfa76 -size 3422866 +oid sha256:abb896c85dd06ea814afcc541a86213b3bdc9a10959ad4332f14f5e95a79bdae +size 3565897 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 e4745b3e5..c8a338f19 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:5838ed0b77bc13f1b7c99daaa5ff8df9c6b2391433bc8c5e73051c20355a217d -size 2318218 +oid sha256:4321383f850cec21934171e80d29f77a942ee925f75cb93e443b75322b7f29c0 +size 2456935 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 75afb7d59..69383da7a 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:d466657f5863a6e3315531dc4ae93c4b831f87bae8b31fc6061bfb5d1434852e -size 3776778 +oid sha256:90df92ec892544fd17e9ea6fa1c49d015800b8a49582ca3c8af1e291025f4cfb +size 3981615 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 d34682cd3..d4cb98d53 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:19782e6e081264fbed797dfa86aec48c0cdb8d036e270260d83c2b025f131d0a -size 3410904 +oid sha256:0aef8f2a2c220bdde06a47aa70ff20af50b29366fbdf9b4da9131794916d7dc1 +size 3544558 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 c54cf2d2b..ef862ef72 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:e6797c838d2884189b63021e475c3898a427880bf53f8baeab29a4e144c361d6 -size 3807427 +oid sha256:e8d392d77bf09ff9c4e8a6218a5dc804ac1c747a9d183d99e087bb1b4842a704 +size 3841965 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 9f8bee274..b7d3928ab 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:e00724261d79f82073d650f1a2637b421ab288d97f300a85e9dd41935b5ee003 -size 2150658 +oid sha256:1f3adaece03ab0ba04ccd2c0281c277ed114a6175a780ce60372ba0d4ae4e64f +size 2232277 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 151014cf4..cb9ca6219 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:76f289ea5277db12c85e8b838565b0c7acf5555a1adaaa18fccdab01b5696a35 -size 1424180 +oid sha256:b328777427e66585aed4376dae32149df10109526a62a18f49fdbf0d3cf0ca3e +size 1492553 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 d581768d7..52c3c96b4 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:5b5ef92b218355cad8dcf629a9a9827f8f342228e3f3912b194e72a456404ab6 -size 2570980 +oid sha256:b0ce1da7ddf796d1fc2878e0a1947348a1daca566d8ef255fa5f67c84fdc6265 +size 2782536 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 1b510db2c..54f1157e2 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:172056cdca39c5ab40c37f77d338f1b8ef75e1d7d97add0db925d12e293f9f8a -size 2093897 +oid sha256:67695e63cabe1083249ef4e4e0a3a7d7187899da81cff936c713230370facd52 +size 2174136 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 985c1749a..2cc02fe86 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:eb6d6008936f9c49311073528f65b9012bad51f8d12869408fcd17cb9effdd5c -size 2317587 +oid sha256:feafa456720ca375c600a190d6474916c9e5792a806eb18ef2337ff4372e445a +size 2332239 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png new file mode 100644 index 000000000..64b65a8dd --- /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 000000000..961a9b104 --- /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 000000000..1e00d6a2f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fef9b1d329d2997767fdda2388afc91d0de41795ad40b28f1ab24b25d5bab4bf +size 167024 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png new file mode 100644 index 000000000..54eb4722b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3c7c6b732a2ed94318f202c818207196f42f77fc77b1cc02d9db389b183f3db +size 151625 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png new file mode 100644 index 000000000..2f009b948 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56581c7cc2fc48e2d7796cad824c403b828e4c41e4ad739b630a6fa4d13cb8c4 +size 274748 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png new file mode 100644 index 000000000..eb88ffba3 --- /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:058d7a54ef0a6c20384245a632e49755ba4c4b4e8becf34edb7590f1b9148b13 +size 407402 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png new file mode 100644 index 000000000..d6258d5c0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5ff6785afa9164f5cf0d0dc27882b7e196e6f2dc65896ffdcf1d7c870af47c1 +size 374274 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png new file mode 100644 index 000000000..70b3f5636 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f82b8d89f44e82e8fe9d74d098e6be39587a29a382aae5728b0d4becc7356e56 +size 262106 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png new file mode 100644 index 000000000..b499e762c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77c7daa53a9537f9657b80ed1be2744531c27838f604cbea830bdd0a025617e4 +size 258102 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png new file mode 100644 index 000000000..43a0a2282 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e34ca6205f04cba8e9803d91b55553f3c88093a4259de8969b90bb61f0edb934 +size 158648 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png new file mode 100644 index 000000000..b08edb8bf --- /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:8440d553cd8fe46947c31cd2a67c2520a9a82e80d77f81b500bbd6a6f3ed3019 +size 269961 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png new file mode 100644 index 000000000..4e14f58b0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fd72442731cc7c66e9f31e8f1ccbde867f88e91c0ec3f0a0624bdedb99a8614 +size 260723 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png new file mode 100644 index 000000000..e9e241826 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e5700a51985cdd48e002edb065955b268f8981943784b239217d750e2802f15 +size 146538 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png new file mode 100644 index 000000000..368db2d49 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35297eeea57ee5986c9ef6a9463a5c6aabd541d48b92e0ee64d28da9819b0e4f +size 141719 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index af631ac15..e639bf38d 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:dad9e7c911fddc398d83692c82ef7e5146ac7c29638889c195ccf0b79ba69606 -size 742511 +oid sha256:ac33a3926f1ebcf1d27ee4c241dfc3d565f4618daf7595a2a3a060fefbef75c1 +size 742921 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 e006ea316..0088bba1b 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:a22f9f5db2bd9e5d4bebcb63bdd094021591b9fc4a5ffbdb0fa5f8d3ea4c3a20 -size 701940 +oid sha256:cd84ef962f0990cdafcc5d0d0f7fae96fe69f46100552cb7c07ab8b66a55e433 +size 701113 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png index d0b035166..e5850fe9b 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:63e0721c2d1b276ce8bcad93ef4fe9da0421832abf0337d9c5f318a116a576d7 -size 1129475 +oid sha256:e6421601248a8cc28b7de34b5b9361d2df4e77751b7626efe6a48d6abea9529a +size 1129017 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 dae390b0f..983acf28b 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:556fe0890da7c20e1d109b6573db89246f827517f16875bca7bc368390fc7b67 -size 3565301 +oid sha256:11ceb4e0baf69a912f27b0d11effc52cf98d29c6556eb9763775bfb68cac96a1 +size 3614636 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 801310c22..a279c9ed5 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:c90505f1aeef80edbceffe6b586d427aaf82720c5ebf2b0abaa385cafe674524 -size 1835845 +oid sha256:a583b5e632d863d6d89a842dd01f2976201965734187f685f67621e27c14d848 +size 1833157 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 4076df370..cf3ec60a6 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:72fb188be14093ed10092663646bd743a420319438a9c00bd2eb92abf0fcf1b6 -size 1125887 +oid sha256:a1f62b445dc55850783713f8778acca7bfdcbcc5f9c5c26e694504a6081239e6 +size 1126312 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 93a33f207..77d0d929e 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:837ec3476025546c3fb48e87be475bc9c73405314f5ab17854578a0a1cfb6124 -size 1102171 +oid sha256:8a45e064b79072663f566a0463c3dc925fa31829ad500baab7089bfeb6b0d1ca +size 1102289 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png index e37c31bf9..102ab1a3d 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:abb6f2d537e704aa6cab4086e0cf216d7c39079cf9e79b572fe34da21cbe00a3 -size 839562 +oid sha256:5dc6163077599457ec9dd38a754cac28fe98d5b5e2e2cb3b7ae9724f118e1abd +size 839244 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 bc6ef4505..1391bdb50 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:fee23b51b281d1e8d95a4b487222a3f43105c7f889b6bca47850c4e24dfda0ea -size 3183826 +oid sha256:9f158d1b2e854e5590785e13e4c246a376bce5de8395a1e6839c7f27703f1b75 +size 3232873 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 e2642a1d2..45ef7ea17 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:411100ea8116452e2f7ade54276a33e546c833eb75cd53ee9dd0e9b63a046103 -size 1059305 +oid sha256:1f7a969fe7538e2c22df8cbe6355a621baf0cb4b72b169f9bc8684103c125303 +size 1059764 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 c68326b50..33cc3105a 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:c9f5e97b867e37d70cbf72caa3d9b702fe477574e9a54005a75770ad32a07698 -size 839678 +oid sha256:a49fe57b8c97392617aab8c430e873f2cc8cf98c65e5aac8609472b1f35a3e2b +size 840018 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 e37d9c81f..88bbbc275 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:9a8b50351745bdf29a0ce2c56eefb82b22b106d96c61d4dd5ad7174d1ba1447f -size 818861 +oid sha256:11057a2b392b1d67848fb0d91c73e7e77c831dfa32561c1e7944495f2d4b6ba2 +size 819172 diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift new file mode 100644 index 000000000..2a971d34c --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastDisclosureStyle.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A quiet disclosure treatment for the floating Locations forecast card. +struct LocationForecastDisclosureStyle: DisclosureGroupStyle { + let foregroundColor: Color + + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + configuration.isExpanded.toggle() + } label: { + HStack(alignment: .firstTextBaseline) { + configuration.label + Image(systemName: "chevron.right") + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(foregroundColor) + + if configuration.isExpanded { + configuration.content + .foregroundStyle(.primary) + } + } + } +} diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift new file mode 100644 index 000000000..767b429c8 --- /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 000000000..5b3395b60 --- /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 000000000..530091f07 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +enum LocationForecastModelLog: LogEvent { + case loadFailed(description: String) + case saveFailed(description: String) + case clearFailed(description: String) + + static let eventName = "LocationForecast" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(description): + "Failed to load the planned stay: \(description)" + case let .saveFailed(description): + "Failed to save the planned stay: \(description)" + case let .clearFailed(description): + "Failed to clear the planned stay: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Model/LocationForecastModel.swift b/Where/WhereUI/Sources/Model/LocationForecastModel.swift new file mode 100644 index 000000000..76eabe16f --- /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 37156e844..af2250388 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -119,6 +119,9 @@ public final class YearReportModel { let services: WhereServices /// Persisted user intent. Exposed for the same reason as `services`. let preferences: WherePreferences + /// Synced planned-stay state and pure forecast projections for the + /// Locations surfaces. + let forecasts: LocationForecastModel /// The model's notion of "now", forwarded for calendar / missing-day math. let now: @Sendable () -> Date @@ -147,6 +150,17 @@ public final class YearReportModel { /// model publishes this value to both the Appearance toggle and Locations. private var showsRecordedLocationDotsStorage: Bool + /// 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 + } + } + /// Whether Locations cards render their recorded GPS constellations. /// Writes persist synchronously and update the visible cards immediately. var showsRecordedLocationDots: Bool { @@ -249,9 +263,12 @@ public final class YearReportModel { driftThresholdStorage = DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default showsRecordedLocationDotsStorage = preferences.showsRecordedLocationDots + showsLocationForecastsOnLocationsTab = + preferences.showsLocationForecastsOnLocationsTab var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current self.calendar = calendar + forecasts = LocationForecastModel(services: services, calendar: calendar, now: now) loadState = details == nil ? .idle : .loaded } @@ -314,6 +331,7 @@ public final class YearReportModel { func refreshAll(forceDataIssueCount: Bool) async { await Self.logger.measure(.sceneRefresh, budget: .seconds(3)) { await refresh() + await forecasts.refresh() await refreshEvidenceDayKeys() await refreshDataIssueCount(force: forceDataIssueCount) } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index a5e6a5cca..d8a4f397d 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -282,6 +282,42 @@ ) } + /// A report stopped at the pinned "today" with a deterministic future + /// New York stay, for forecast and planned-calendar previews. + @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 model = YearReportModel( + services: previewServices(), + details: YearReportDetails( + report: YearReport( + year: completeReport.year, + days: recordedDays, + totals: recordedTotals, + ), + primaryRegionLocations: sampleRegionLocations(), + ), + selectedYear: year, + preferences: previewPreferences(), + now: { referenceNow }, + ) + model.forecasts.setActivePlannedStay(PlannedStay( + region: .newYork, + through: CalendarDay(year: year, month: 8, day: 15), + )) + return model + } + /// The same complete year value `ReportReader.yearReportDetails` returns /// in production, built synchronously for previews and image tests. public static func sampleYearReportDetails() -> YearReportDetails { diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 642354b7e..77fce84c5 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,14 @@ private struct DayCell: View { ) } } + whereSnapshot(name: "FocusedPlannedStay", configurations: .fullContentPhoneLightDark) { + NavigationStack { + CalendarContentView( + focusedRegion: .newYork, + report: PreviewSupport.plannedStayYearReportModel(), + ) + } + } } } diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 0449dc7c7..e6eaa85bb 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -183,6 +183,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 @@ -210,6 +221,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. @@ -286,6 +303,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()) } @@ -301,6 +324,12 @@ private struct ResolveToolbarLabel: View { ) } } + + 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 000000000..82e76a7d5 --- /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 0e303dabb..c606cfb2c 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -237,6 +237,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", @@ -2983,6 +2995,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", @@ -5519,6 +5645,30 @@ } } }, + "settings.appearance.locationForecasts.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 in Locations." + } + } + } + }, + "settings.appearance.locationForecasts.toggle" : { + "comment" : "Toggle that controls whether annual location forecasts appear on the Locations tab.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Estimated time" + } + } + } + }, "settings.attachments.row" : { "comment" : "Row title for the attachments (evidence) screen in the Settings Data group.", "extractionState" : "manual", @@ -7395,6 +7545,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" : { diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index 59f308e8b..584e41182 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -29,6 +29,18 @@ struct AppearanceSettingsView: View { Text(String(localized: .settingsAppearanceLocationDotsFooter)) } + Section { + Toggle(isOn: $report.showsLocationForecastsOnLocationsTab) { + Label( + String(localized: .settingsAppearanceLocationForecastsToggle), + systemImage: "chart.line.uptrend.xyaxis", + ) + } + .settingsRow(Item.locationForecasts) + } footer: { + Text(String(localized: .settingsAppearanceLocationForecastsFooter)) + } + Section { // A sheet (not a push) so the icon picker's Done/commit point // is explicit, matching the app's other editor flows. @@ -79,6 +91,7 @@ extension AppearanceSettingsView: SettingsSection { enum Item: SettingsItem { case locationDots + case locationForecasts case appIcon #if DEBUG case cardDesigner @@ -88,6 +101,8 @@ extension AppearanceSettingsView: SettingsSection { switch self { case .locationDots: String(localized: .settingsAppearanceLocationDotsToggle) + case .locationForecasts: + String(localized: .settingsAppearanceLocationForecastsToggle) case .appIcon: String(localized: .settingsAppIconLink) #if DEBUG case .cardDesigner: String(localized: .cardDesignerTitle) @@ -99,6 +114,8 @@ extension AppearanceSettingsView: SettingsSection { switch self { case .locationDots: splitKeywords(String(localized: .settingsKeywordsLocationDots)) + case .locationForecasts: + splitKeywords(String(localized: .settingsKeywordsLocationForecasts)) case .appIcon: splitKeywords(String(localized: .settingsKeywordsAppIcon)) #if DEBUG case .cardDesigner: diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index a8e489305..e10b74731 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, @@ -201,12 +225,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 9fbe09b86..70562f339 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 @@ -80,6 +81,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 { @@ -889,6 +924,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 @@ -939,6 +984,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 000000000..f1a35e965 --- /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/PlannedStayHatchTests.swift b/Where/WhereUI/Tests/PlannedStayHatchTests.swift new file mode 100644 index 000000000..5dd2f2b90 --- /dev/null +++ b/Where/WhereUI/Tests/PlannedStayHatchTests.swift @@ -0,0 +1,25 @@ +import SwiftUI +import Testing +@testable import WhereUI + +struct PlannedStayHatchTests { + @Test func cellsShareOneStripeLattice() { + let height: CGFloat = 44 + let spacing: CGFloat = 6 + let cellWidth: CGFloat = 105.5 + let gridSpacing: CGFloat = 6 + + for column in 0 ..< 7 { + let extendLeading = column == 0 ? 0 : gridSpacing / 2 + let origin = CGFloat(column) * (cellWidth + gridSpacing) - extendLeading + let firstLine = PlannedStayHatch.firstLineX( + height: height, + gridOriginX: origin, + spacing: spacing, + ) + let globalStripePosition = origin + firstLine + height + + #expect(abs(globalStripePosition.truncatingRemainder(dividingBy: spacing)) < 0.000_001) + } + } +} diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index 03d5eac32..19ee38f51 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -50,6 +50,15 @@ struct SettingsSearchTests { #expect(destinations.contains(.appearance)) } + @Test func matchesLocationForecastVisibilityOnEstimateKeyword() { + let results = SettingsCatalog.results(matching: "estimate") + + #expect(results.contains { + $0.destination == .appearance + && $0.title == String(localized: .settingsAppearanceLocationForecastsToggle) + }) + } + @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 86846153e..b92d688d1 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -4,6 +4,9 @@ import WhereCore /// Thrown by `TestStore.setManualDay` when failure injection is enabled. struct ManualSaveFailure: Error, Equatable {} +/// Thrown by planned-stay writes when failure injection is enabled. +struct PlannedStaySaveFailure: Error, Equatable {} + /// Thrown by `TestStore.samples(in:)` when failure injection is enabled, so a /// year-report load can be forced to fail. struct SampleReadFailure: Error, Equatable {} @@ -39,6 +42,7 @@ actor TestStore: WhereStore { private var recordingDevicesArrival: CheckedContinuation? private var shouldFailManualDay = false + private var shouldFailPlannedStay = false private var shouldFailSamples = false private var shouldFailNextRecordingDeviceWrite = false @@ -88,6 +92,10 @@ actor TestStore: WhereStore { shouldFailManualDay = true } + func failPlannedStays() { + shouldFailPlannedStay = true + } + /// Makes `samples(in:)` throw, so a `refresh()`'s year-report load fails and /// the model's `.failed` load state is exercisable. func failSamples() { @@ -271,4 +279,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 6b84b987e..0d5e2812b 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. @@ -139,17 +161,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 8e7e703ba..dc16063f8 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -216,6 +216,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) @@ -258,6 +264,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 3cf55983b..3756cf066 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -279,6 +279,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