diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index b46d4dc0b..57312598b 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -21,6 +21,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format - **Load the canvas from the viewport.** Keep at most six automatic screen trees live. A manually requested preview replaces that set with one tree. Presenting the focused inspector suspends the canvas set. - **Open the canvas fitted to its first group's width.** Reserve whole-graph framing for the explicit Fit All action. - **Cap automatic graph-depth stacks at the stylesheet row limit.** Spill overflow right inside one labeled depth band while preserving explicit `FlyoverPosition` values exactly. +- **Draw connectors in bounded, viewport-filtered tiles.** Never allocate a single full-graph drawing texture; see `FlyoverConnectorTilePlanTests`. - **Invoke variant builders through the serial deferred load coordinator.** Never invoke them synchronously from a SwiftUI `body`. Preview fixtures may open expensive in-memory stores. - **Canvas preview readiness is the latest nonempty visible-load expectation.** Variant or generation changes supersede stale completions. Cancelled waiters must resume. `FlyoverSnapshotTests` awaits it before full-content measurement. - **Keep global traits session-only.** Apply them to registered content, not Flyover chrome. diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 47420a874..e8a2c2ce5 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -115,6 +115,8 @@ The initial canvas zoom fits the first group to the available width so its cards Reach later groups by horizontal scrolling. Pinching or moving the zoom slider preserves the canvas point at the center of the visible viewport. **Fit All** remains available for a whole-graph overview. +Connectors use bounded drawing tiles and omit tiles outside the visible area. +Large catalogs therefore keep their navigation lines without requiring one oversized drawing texture. ## App integration diff --git a/Shared/Flyover/Sources/FlyoverCanvasView.swift b/Shared/Flyover/Sources/FlyoverCanvasView.swift index 409573e11..3b6ab548e 100644 --- a/Shared/Flyover/Sources/FlyoverCanvasView.swift +++ b/Shared/Flyover/Sources/FlyoverCanvasView.swift @@ -59,7 +59,11 @@ struct FlyoverCanvasView: View { FlyoverDepthBandBackdrop(band: band) } - FlyoverConnectorCanvas(catalog: catalog, layout: layout) + FlyoverConnectorCanvas( + catalog: catalog, + layout: layout, + renderPlan: renderPlan, + ) ForEach(catalog.screens, id: \.id) { screen in if let frame = layout.screenFrames[screen.id], diff --git a/Shared/Flyover/Sources/FlyoverConnectorCanvas.swift b/Shared/Flyover/Sources/FlyoverConnectorCanvas.swift index a068a2087..809c75998 100644 --- a/Shared/Flyover/Sources/FlyoverConnectorCanvas.swift +++ b/Shared/Flyover/Sources/FlyoverConnectorCanvas.swift @@ -1,13 +1,34 @@ import SwiftUI -/// Draws push and modal navigation relationships behind Flyover cards. +/// Draws navigation relationships in bounded tiles, retaining graph coordinates for every route. struct FlyoverConnectorCanvas: View { let catalog: FlyoverCatalog let layout: FlyoverLayoutResult + let renderPlan: FlyoverCanvasRenderPlan @Environment(\.flyoverStylesheet) private var stylesheet var body: some View { + let tiles = FlyoverConnectorTilePlan(canvasSize: layout.canvasSize).tiles + .filter { renderPlan.shouldDisplay($0.frame) } + + ZStack(alignment: .topLeading) { + ForEach(tiles) { tile in + canvas(in: tile.frame) + .frame(width: tile.frame.width, height: tile.frame.height) + .clipped(antialiased: false) + .position(x: tile.frame.midX, y: tile.frame.midY) + } + } + .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private func canvas(in frame: CGRect) -> some View { Canvas { context, _ in + // Keep one coordinate system so curves, dash phases, and labels + // continue unchanged across tile edges. + context.translateBy(x: -frame.minX, y: -frame.minY) for transition in catalog.transitions { guard let source = layout.screenFrames[transition.source], @@ -23,9 +44,6 @@ struct FlyoverConnectorCanvas: View { ) } } - .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) - .allowsHitTesting(false) - .accessibilityHidden(true) } private func draw( diff --git a/Shared/Flyover/Sources/FlyoverConnectorTilePlan.swift b/Shared/Flyover/Sources/FlyoverConnectorTilePlan.swift new file mode 100644 index 000000000..06ba513d7 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverConnectorTilePlan.swift @@ -0,0 +1,49 @@ +import CoreGraphics + +/// Partitions graph coordinates into bounded drawing surfaces without gaps or overlaps. +struct FlyoverConnectorTilePlan { + /// A rendering budget, independent of the graph's appearance or current zoom. + /// At 3× display scale and the maximum 1.25× zoom, each edge stays below 4096 pixels. + static let maximumDimension: CGFloat = 1024 + + struct Tile: Identifiable, Equatable { + struct ID: Hashable { + let column: Int + let row: Int + } + + let id: ID + let frame: CGRect + } + + let canvasSize: CGSize + + var tiles: [Tile] { + precondition(canvasSize.width.isFinite && canvasSize.height.isFinite) + guard canvasSize.width > 0, canvasSize.height > 0 else { + return [] + } + + let columns = Int(ceil(canvasSize.width / Self.maximumDimension)) + let rows = Int(ceil(canvasSize.height / Self.maximumDimension)) + + return (0 ..< rows).flatMap { row in + (0 ..< columns).map { column in + let origin = CGPoint( + x: CGFloat(column) * Self.maximumDimension, + y: CGFloat(row) * Self.maximumDimension, + ) + return Tile( + id: Tile.ID(column: column, row: row), + frame: CGRect( + origin: origin, + size: CGSize( + width: min(Self.maximumDimension, canvasSize.width - origin.x), + height: min(Self.maximumDimension, canvasSize.height - origin.y), + ), + ), + ) + } + } + } +} diff --git a/Shared/Flyover/Tests/FlyoverConnectorTilePlanTests.swift b/Shared/Flyover/Tests/FlyoverConnectorTilePlanTests.swift new file mode 100644 index 000000000..6053f3363 --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverConnectorTilePlanTests.swift @@ -0,0 +1,58 @@ +import CoreGraphics +@testable import Flyover +import Testing + +struct FlyoverConnectorTilePlanTests { + @Test func partitionsTheOversizedWhereGraphIntoBoundedSurfaces() throws { + // The previous full-graph Canvas requested an 18000×6768-pixel texture + // on iPad and dropped every connector when that allocation failed. + let size = CGSize(width: 9000, height: 3384) + let tiles = FlyoverConnectorTilePlan(canvasSize: size).tiles + + #expect(tiles.count == 36) + #expect(Set(tiles.map(\.id)).count == tiles.count) + #expect(tiles.allSatisfy { + $0.frame.width <= FlyoverConnectorTilePlan.maximumDimension + && $0.frame.height <= FlyoverConnectorTilePlan.maximumDimension + }) + #expect(tiles.reduce(CGRect.null) { $0.union($1.frame) } == CGRect( + origin: .zero, + size: size, + )) + #expect(tiles.reduce(0) { $0 + $1.frame.width * $1.frame.height } == size.width * size + .height) + for (index, tile) in tiles.enumerated() { + #expect(tiles.dropFirst(index + 1).allSatisfy { + tile.frame.intersection($0.frame).isEmpty + }) + } + + let last = try #require(tiles.last) + #expect(last.frame == CGRect(x: 8192, y: 3072, width: 808, height: 312)) + } + + @Test func retainsFractionalEdgeCoverageAndStableTileIdentities() { + let size = CGSize(width: 1024.5, height: 2048.25) + let initial = FlyoverConnectorTilePlan(canvasSize: size).tiles + let expanded = FlyoverConnectorTilePlan(canvasSize: CGSize(width: 1500, height: 2500)).tiles + let expectedArea: CGFloat = size.width * size.height + let actualArea: CGFloat = initial.reduce(0) { $0 + $1.frame.width * $1.frame.height } + + #expect(initial.count == 6) + #expect(initial.map(\.id) == expanded.map(\.id)) + #expect(initial.last?.frame == CGRect(x: 1024, y: 2048, width: 0.5, height: 0.25)) + #expect(actualArea == expectedArea) + } + + @Test func exactMultiplesDoNotCreateEmptyEdgeTiles() { + let tiles = FlyoverConnectorTilePlan(canvasSize: CGSize(width: 2048, height: 1024)).tiles + + #expect(tiles.count == 2) + #expect(tiles.allSatisfy { $0.frame.size == CGSize(width: 1024, height: 1024) }) + } + + @Test(arguments: [CGSize.zero, CGSize(width: 0, height: 100), CGSize(width: 100, height: 0)]) + func emptyCanvasHasNoDrawingSurfaces(size: CGSize) { + #expect(FlyoverConnectorTilePlan(canvasSize: size).tiles.isEmpty) + } +} diff --git a/Where/TODOs.md b/Where/TODOs.md index c375572e7..ea6dba3e7 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -51,12 +51,12 @@ The item format and the placement rule live in the root - refactor(WhereCore): Durable write-back is **read-repair**, decoupled from read correctness: opportunistically (batched, on `.NSPersistentStoreRemoteChange` + launch) rewrite stale records to the current version and stamp it, so old builds can honor exclusion. Transforms must be deterministic + commutative so two devices healing the same record via CloudKit converge (LWW-safe). (agent) - design(WhereCore): Open question — the exclusion UX, where an older device progressively hides days a newer device has touched, needs a deliberate warning surface, not a silent drop. (agent) - fix(WhereUI) [needs-design]: broken-snapshots — the snapshot suite pinned genuinely broken renderings as references, flagged with `[Fix later]` review comments on PR #101 and merged anyway to land the suite. These are not flaky captures (those have their own ledger below) — each is a faithful, reproducible image of something actually wrong, so re-recording is never the fix. Fix the view, the capture frame, or the pipeline as each item says, then re-record just that reference under `Where/WhereUI/SnapshotTests/__Snapshots__/`. Most cluster on the accessibility axes `.screenDefaults` added — the ax5 Dynamic Type and VoiceOver-annotated configurations that nothing rendered before this suite existed. (pr#101 review) - - fix(WhereUI) [quick-win]: broken-snapshots: the calendar day grid breaks at accessibility Dynamic Type. Every two-digit date truncates to its first digit — the 10th–31st render as "1", "2", or "3" — because the day number is clamped to a fixed square (`DayCell` at `CalendarContentView.swift:489-493`, `.frame(width: calendar.day.numberSize, height: calendar.day.numberSize)`), and the weekday header row wraps mid-word ("Sun" over two lines, "Wed" over three) because each symbol is a plain `Text` in an equal-width grid column (`:313-317`). Both show in `calendarContent.WithData_iPhone_ax5.png`; the digit truncation also hits `..._iPad_ax5.png`, where the extra width goes to inter-column gaps instead of the numbers. Showing "1" where the date is 10 is wrong content, not merely tight layout. **The references have now been re-recorded twice with the layout code unchanged — by PR #196 and again by PR #297's Xcode 27 beta 6 refresh — so they pin the same defect at a third recording.** Re-check the current image before fixing, and re-record after. (pr#101 review; re-verified 2026-09-06) - - fix(WhereUI) [needs-design]: broken-snapshots: `YearView` overflows horizontally at ax5. In `year.Loaded_iPhone_ax5.png` the month title reads "nuary", the day grid is clipped on both edges, and the Calendar/Timeline pill runs off the trailing edge. The suspect is `YearModePicker`, whose segment labels take their intrinsic width via `.fixedSize()` (`YearView.swift:110`, with an in-source comment explaining it keeps labels from truncating mid-animation) inside a bottom `safeAreaInset` (`:40-43`), making it wider than the screen at ax5. Confirm the oversized inset is what widens the layout beneath it, then make the picker fit at accessibility sizes (icon-only, wrapped, or scrollable) — note the `.fixedSize()` is deliberate, so the fix has to keep animation from truncating too. (pr#101 review; re-verified 2026-08-09) - fix(WhereUI) [quick-win]: broken-snapshots: the Resolve toolbar badge sits awkwardly on the iOS 26 glass toolbar button. `ResolveToolbarLabel` hand-rolls the badge as a red `Capsule` overlaid on the `checklist` symbol and pushes it out with a fixed `.offset(x: spacing.small, y: -spacing.small)` (`LocationsView.swift:367-382`, the red capsule + offset at `:380-381` — moved by PR #302's card work), landing it half outside the button's own glass capsule — visible in `root.LoggedIn_iPhone.png`. Use SwiftUI's `.badge()` on the toolbar item, or offset against the resolved chrome rather than a fixed spacing token. (pr#101 review; re-verified 2026-09-06) - fix(WhereUI): broken-snapshots: `locations.Loaded_iPad.png` bakes in raw inflection markup — the Elsewhere card's subtitle renders literally as `^[3 region](inflect: true)`. This is the `locations.elsewhere.subtitle` P1 filed above, now pinned as a reference; recorded here so the image isn't mistaken for correct output, and so that reference is re-recorded when the fix lands. (pr#101 review) ## P2s (Nice to have) +- test(WhereUI) [needs-design]: Stabilize initial calendar positioning in the app shell. Repeated July `MainTabs` captures place the current month either fully visible or below the tab accessory. `CalendarContentView.swift:172-176` marks positioning complete immediately after scrolling to a lazily laid-out month. Reproduce outside capture and tie completion to layout before changing production behavior. The welcome-overlay fixtures use January to avoid this unrelated dependency; dedicated calendar snapshots retain longer-year coverage. (agent 2026-09-17) +- test(WhereUI) [quick-win]: Investigate the native iPad confirmation-button snapshot artifact — the system Save button contains a horizontal gray band in `PlannedStayEditor.swift:112` toolbar captures. It also appears in the previous `NewPlan_iPad.png` reference and persists with a one-second settle floor. Verify on a live iPad runtime and isolate the native glass capture before changing production appearance; keep the button accessibility in that probe. (agent 2026-09-13) - test(WhereUI) [quick-win]: Cover the welcome overlay's scrolling and modal semantics — `WelcomeFirst` / `WelcomeBack` use fixed `.phoneLightDark` frames and one fixed AX5 frame (`Primary/LocationsView.swift:447-462`) while the modal contains a `ScrollView` inside a greedy `GeometryReader` (`Primary/LocationWelcomeOverlay.swift:25-39`). No welcome configuration uses the semantic `.accessibility` capture or an iPad frame. Capture the shared scrolling child with full-content sizing if the bounded modal cannot converge, and add a semantic modal case plus iPad coverage. Keep the production modal/focus wiring; a fixed AX5 image cannot prove that all scrollable controls or VoiceOver elements remain reachable. (audit 2026-09-07, PRs #309/#311) - feat(Where): Consider the user-assigned device-name entitlement and matching provisioning-profile support so the Devices screen can offer a better initial label than the generic hardware family. Keep the current generic name until the entitlement is intentionally provisioned; never silently depend on an entitlement absent from developer signing. (`InstallationRecordingContextStore.swift:227-242` still derives `systemName` from `UIDevice.current.model`, and `Project.swift` declares no such entitlement; PR #160 review, re-verified 2026-08-09) - feat(WhereUI) [needs-design]: Give the app a branded launch screen. `UILaunchScreen` is an empty dictionary (`Project.swift`), so the pre-main frame is plain white. Measured from a fresh-install simulator recording, a first run reads as ~1.7s of white → ~0.25s of the dark `LaunchSplashView` → the light onboarding screen, so the splash registers as a quarter-second dark blip between two light screens rather than as the app opening. A launch screen matching the splash's background + icon would make that continuous. Note this is the right layer to fix it at: the splash's own `minimumSplashDuration` hold deliberately gates only the `.ready` reveal, not a gate transition like onboarding, so lengthening the hold would just delay interactive UI. (agent) @@ -104,6 +104,10 @@ re-recording: # Completed issues +- fix(WhereUI) [needs-design]: broken-snapshots: `YearView` overflows horizontally at ax5. In `year.Loaded_iPhone_ax5.png` the month title reads "nuary", the day grid is clipped on both edges, and the Calendar/Timeline pill runs off the trailing edge. The suspect is `YearModePicker`, whose segment labels take their intrinsic width via `.fixedSize()` (`YearView.swift:110`, with an in-source comment explaining it keeps labels from truncating mid-animation) inside a bottom `safeAreaInset` (`:40-43`), making it wider than the screen at ax5. Confirm the oversized inset is what widens the layout beneath it, then make the picker fit at accessibility sizes (icon-only, wrapped, or scrollable) — note the `.fixedSize()` is deliberate, so the fix has to keep animation from truncating too. (pr#101 review; re-verified 2026-08-09) Resolved by stacking the Calendar/Timeline choices at accessibility sizes while preserving their intrinsic labels and selection animation; corrected phone/iPad AX5 captures reviewed. + +- fix(WhereUI) [quick-win]: broken-snapshots: the calendar day grid breaks at accessibility Dynamic Type. Every two-digit date truncates to its first digit — the 10th–31st render as "1", "2", or "3" — because the day number is clamped to a fixed square (`DayCell` at `CalendarContentView.swift:489-493`, `.frame(width: calendar.day.numberSize, height: calendar.day.numberSize)`), and the weekday header row wraps mid-word ("Sun" over two lines, "Wed" over three) because each symbol is a plain `Text` in an equal-width grid column (`:313-317`). Both show in `calendarContent.WithData_iPhone_ax5.png`; the digit truncation also hits `..._iPad_ax5.png`, where the extra width goes to inter-column gaps instead of the numbers. Showing "1" where the date is 10 is wrong content, not merely tight layout. **The references have now been re-recorded twice with the layout code unchanged — by PR #196 and again by PR #297's Xcode 27 beta 6 refresh — so they pin the same defect at a third recording.** Re-check the current image before fixing, and re-record after. (pr#101 review; re-verified 2026-09-06) Resolved with fixed compact date fonts, larger accessibility date cells, single-line weekdays, and stacked month summaries; corrected phone/iPad AX5 captures reviewed. + - fix(WhereUI) [quick-win]: Refresh the live-region welcome when the scene becomes active. Closed 2026-09-13: `MainTabs` now owns the welcome state and keys a bounded, confidence-gated lookup to foreground activity regardless of the selected tab. The source rejects stale one-shot callbacks, the resolver enforces the 1 km and boundary confidence gates, and app-shell tests and snapshots cover cancellation, repeated resolution, actionable status, and presentation over multiple tabs. (audit 2026-09-07, PR #309) - docs(WhereCore) [quick-win]: Refresh stale doc claims. Closed 2026-09-07: the module AGENTS.md names the existing reconciliation exceptions; the README states the missing daily-summary fan-out and the failed-badge zero fallback; and the ingestor comment credits typed WhereLog events. PR #172 had already corrected the RegionViewer data-source description and RegionKit decoding-coverage claim; the earlier RootView and share-extension doc corrections remain shipped. The underlying summary, picker, badge, and GeoJSON-test items stay open. (audit 2026-07-26) diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb index 876fe3b3a..57941a609 100644 --- a/Where/Tools/Tests/upgrade_backup_test.rb +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -7,11 +7,12 @@ class UpgradeBackupTest < Minitest::Test def test_v1_adds_current_tables_without_inventing_recording_consent upgraded = upgrade_manifest(base_manifest(1)) - assert_equal 5, upgraded.fetch("formatVersion") + assert_equal 6, upgraded.fetch("formatVersion") assert_equal [], upgraded.fetch("recordingDeviceProfiles") assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges") assert_equal [], upgraded.fetch("recordingDeviceRemovals") assert_equal [], upgraded.fetch("plannedStayRecords") + assert_equal [], upgraded.fetch("homeRegionRecords") assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID") end @@ -54,7 +55,7 @@ def test_v3_reshapes_recording_device_data upgraded = upgrade_manifest(manifest) - assert_equal 5, upgraded.fetch("formatVersion") + assert_equal 6, upgraded.fetch("formatVersion") assert_equal({ "kind" => { "other" => {} }, "registrationGenerationID" => "generation-id", @@ -67,8 +68,8 @@ def test_v3_reshapes_recording_device_data }, upgraded.fetch("recordingDeviceMetadataChanges").last) end - def test_v5_is_idempotent - once = upgrade_manifest(base_manifest(5)) + def test_v6_is_idempotent + once = upgrade_manifest(base_manifest(6)) assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once))) end @@ -84,10 +85,106 @@ 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(6)) } + error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(7)) } assert_equal 1, error.status end + def test_v5_stay_revisions_share_one_identity_and_keep_tombstones + timestamp = Time.iso8601("2026-09-13T23:45:00Z").to_f + revisions = [ + { "id" => "old", "updatedAt" => timestamp, "value" => { + "region" => "us-NY", "through" => { "year" => 2026, "month" => 10, "day" => 1 }, + } }, + { "id" => "clear", "updatedAt" => timestamp + 1 }, + ] + manifest = base_manifest(5).merge("plannedStayRecords" => revisions) + + upgraded = upgrade_manifest(manifest).fetch("plannedStayRecords") + + assert_equal %w[old clear], upgraded.map { |record| record.fetch("id") } + assert_equal [LEGACY_PLANNED_STAY_ID], upgraded.map { |record| record.fetch("stayID") }.uniq + assert_equal timestamp, upgraded.first.fetch("updatedAt") + assert_nil upgraded.last["value"] + value = upgraded.first.fetch("value") + assert_equal LEGACY_PLANNED_STAY_ID, value.fetch("id") + assert_equal "us-NY", value.fetch("region") + assert_equal({ "year" => 2026, "month" => 9, "day" => 13 }, value.fetch("arrival").fetch("earliest")) + assert_equal value.fetch("arrival").fetch("earliest"), value.fetch("arrival").fetch("latest") + assert_equal({ "year" => 2026, "month" => 10, "day" => 1 }, value.fetch("departure").fetch("earliest")) + assert_equal value.fetch("departure").fetch("earliest"), value.fetch("departure").fetch("latest") + end + + def test_v5_inferred_arrival_is_independent_of_export_date + manifest = base_manifest(5).merge("plannedStayRecords" => [{ + "id" => "revision", "updatedAt" => "2026-09-13T23:45:00-04:00", "value" => { + "region" => "us-NY", "through" => { "year" => 2026, "month" => 10, "day" => 1 }, + }, + }]) + another = Marshal.load(Marshal.dump(manifest)).merge("exportedAt" => 2_000_000_000) + + first = upgrade_manifest(manifest).fetch("plannedStayRecords") + second = upgrade_manifest(another).fetch("plannedStayRecords") + + assert_equal first, second + assert_equal({ "year" => 2026, "month" => 9, "day" => 14 }, first.first.fetch("value").fetch("arrival").fetch("earliest")) + end + + def test_v5_inferred_arrival_is_clamped_to_departure_for_clock_skew_or_completed_stays + manifest = base_manifest(5).merge("plannedStayRecords" => [{ + "id" => "revision", "updatedAt" => Time.iso8601("2026-12-01T00:00:00Z").to_f, "value" => { + "region" => "us-NY", "through" => { "year" => 2026, "month" => 9, "day" => 1 }, + }, + }]) + upgraded = upgrade_manifest(manifest) + value = upgraded.fetch("plannedStayRecords").first.fetch("value") + + assert_equal value.fetch("departure"), value.fetch("arrival") + assert_equal upgraded, upgrade_manifest(Marshal.load(Marshal.dump(upgraded))) + end + + def test_v5_inferred_arrival_uses_gregorian_leap_dates_before_the_historical_calendar_switch + leap_day = { "year" => 1504, "month" => 2, "day" => 29 } + manifest = base_manifest(5).merge("plannedStayRecords" => [{ + "id" => "revision", "updatedAt" => Time.utc(1504, 2, 29).to_f, "value" => { + "region" => "us-NY", "through" => leap_day, + }, + }]) + + value = upgrade_manifest(manifest).fetch("plannedStayRecords").first.fetch("value") + + assert_equal leap_day, value.fetch("arrival").fetch("earliest") + assert_equal leap_day, value.fetch("departure").fetch("earliest") + end + + def test_v5_rejects_a_julian_only_leap_date + manifest = base_manifest(5).merge("plannedStayRecords" => [{ + "id" => "revision", "updatedAt" => Time.utc(1500, 2, 28).to_f, "value" => { + "region" => "us-NY", "through" => { "year" => 1500, "month" => 2, "day" => 29 }, + }, + }]) + + error = assert_raises(SystemExit) { upgrade_manifest(manifest) } + assert_equal 1, error.status + end + + def test_v6_keeps_flexible_windows_and_home_tombstones_unchanged + manifest = base_manifest(6).merge( + "plannedStayRecords" => [{ "id" => "revision", "stayID" => "stay", "value" => { + "id" => "stay", "region" => "us-NY", + "arrival" => { "earliest" => { "year" => 2026, "month" => 10, "day" => 10 }, "latest" => { "year" => 2026, "month" => 10, "day" => 12 } }, + "departure" => { "earliest" => { "year" => 2026, "month" => 10, "day" => 20 }, "latest" => { "year" => 2026, "month" => 10, "day" => 25 } }, + } }], + "homeRegionRecords" => [{ "id" => "home", "region" => "us-CA", "updatedAt" => 1 }, { "id" => "clear", "updatedAt" => 2 }], + ) + stays = Marshal.load(Marshal.dump(manifest.fetch("plannedStayRecords"))) + homes = Marshal.load(Marshal.dump(manifest.fetch("homeRegionRecords"))) + + upgraded = upgrade_manifest(manifest) + + assert_equal stays, upgraded.fetch("plannedStayRecords") + assert_equal homes, upgraded.fetch("homeRegionRecords") + end + private def base_manifest(version) diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index dd85486f2..510b69add 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -1,20 +1,25 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Reshapes a legacy Where backup into the current v5 manifest. The automatic-recording feature +# Reshapes a legacy Where backup into the current v6 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; -# v5 adds an empty planned-stay register when the source predates it. +# v5 adds an empty planned-stay register when the source predates it. v6 expands that register +# into independent plans and adds the forecast home-region register. require "json" require "tmpdir" require "fileutils" require "time" require "set" +require "date" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 5 +CURRENT_FORMAT_VERSION = 6 SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze +# All pre-v6 records revised the same logical stay. Keep that identity across archives, including +# clearing tombstones, so merging upgraded backups cannot revive superseded plans. +LEGACY_PLANNED_STAY_ID = "9D6B2F5A-2C8E-4B91-9D43-E7F41A0916C0" REGION_MAP = { "california" => "us-CA", @@ -32,7 +37,7 @@ DATE_KEYS = %w[ exportedAt timestamp capturedAt dismissedAt registeredAt changedAt removedAt recordedAt - lastSeenAt auditRecordedAt auditLocationTimestamp + lastSeenAt auditRecordedAt auditLocationTimestamp updatedAt ].to_set.freeze def die(message) @@ -167,6 +172,36 @@ def source_format_version(manifest) version end +def upgrade_planned_stays!(manifest, source_version) + return unless source_version < 6 + + Array(manifest["plannedStayRecords"]).each do |record| + record["stayID"] = LEGACY_PLANNED_STAY_ID + value = record["value"] + next if value.nil? + + through = value.fetch("through") + departure = Date.new(through.fetch("year"), through.fetch("month"), through.fetch("day"), Date::GREGORIAN) + # v5 never stored arrival. The revision's UTC day is a stable inference across exports; + # exportedAt would give one revision conflicting payloads in backups made on different days. + # Clamp future clock skew and already-completed plans so arrival cannot follow departure. + updated_at = record.fetch("updatedAt") + die "planned-stay updatedAt must be a timestamp" unless updated_at.is_a?(Numeric) && updated_at.finite? + updated_time = Time.at(updated_at).utc + updated_day = Date.new(updated_time.year, updated_time.month, updated_time.day, Date::GREGORIAN) + arrival = [updated_day, departure].min + arrival_day = { "year" => arrival.year, "month" => arrival.month, "day" => arrival.day } + record["value"] = { + "id" => LEGACY_PLANNED_STAY_ID, + "region" => value.fetch("region"), + "arrival" => { "earliest" => arrival_day.dup, "latest" => arrival_day.dup }, + "departure" => { "earliest" => through.dup, "latest" => through.dup }, + } + rescue KeyError, ArgumentError, RangeError => error + die "could not upgrade planned stay: #{error.message}" + end +end + def upgrade_manifest(manifest) source_version = source_format_version(manifest) warnings = [] @@ -190,7 +225,9 @@ def upgrade_manifest(manifest) manifest["recordingDeviceMetadataChanges"] ||= [] manifest["recordingDeviceRemovals"] ||= [] manifest["plannedStayRecords"] ||= [] + manifest["homeRegionRecords"] ||= [] upgrade_recording_devices!(manifest, source_version) + upgrade_planned_stays!(manifest, source_version) manifest.delete("recordingDevices") manifest.delete("recordingDeviceCheckIns") manifest.delete("recordingPolicyChanges") diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 7c45f440d..6a774f69e 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -73,9 +73,15 @@ internal shape. bumps `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`). +- **Resolve planned stays independently by their stable stay identity.** Order revisions by + `updatedAt` then revision UUID. Delete with a newer nil tombstone. Retain completed plans + (`PlannedStayCoordinatorTests`). +- **Keep forecast home intent separate from tracked regions and display preferences.** A nil + home-register revision selects historical estimates. Read both planning registers in one + `readSnapshot`. Never write plans into recorded presence (`PlannedStayCoordinatorTests`). +- **Validate planning windows at store and backup boundaries.** Use inclusive `CalendarDay` + endpoints with latest arrival no later than earliest departure. Preserve all revision identities + and tombstones during backup restore (`BackupServiceTests`, `BackupCoordinatorTests`). - **Keep planned-stay location checks advisory.** `PlannedStayLocationVerifier` accepts a fix inside the region or within the configured drift threshold outside its boundary. Do not add horizontal accuracy to the threshold. diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 8b7ca2a13..20cbaae53 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -67,12 +67,18 @@ one it belongs to rather than to a god-object: dismissals. Writes await reminder/issue reconciliation and widget publication after committing. The local fan-out does not yet refresh daily summaries; that gap is tracked in [`../TODOs.md`](../TODOs.md). -- **`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. +- **`PlannedStayCoordinator`** — stores independent travel plans and an optional forecast home + region. Each plan has a stable identity and inclusive arrival and departure windows. + `create(_:)`, `update(_:)`, and `delete(stayID:)` affect one plan. Completed plans remain + available. `snapshot()` reads plans and the home choice from one store snapshot. + `setHomeRegion(_:)` assigns unplanned days to that region; nil selects historical estimates. + This choice does not change tracked regions or recorded presence. Turning estimate display off + does not remove plans. Each register resolves synced revisions by timestamp, then revision UUID. + Deletion and historical selection retain tombstones to prevent stale imports from restoring old intent. - **`PlannedStayLocationVerifier`** — gets a current location and compares it with the selected region. The configured drift threshold expands the accepted area outside the region boundary. A missing location or missing geometry returns an unavailable result. + The itinerary editor does not request location verification. - **`CurrentRegionResolver`** — returns a typed live-region resolution only while automatic recording is authorized. A successful decision requires a fix no more than 60 seconds old, with valid horizontal accuracy at or below @@ -172,7 +178,7 @@ one it belongs to rather than to a god-object: - **`WidgetPresentationPublisher`** — atomically writes the device-local `WhereTheme` to its own App Group file and reloads WidgetKit without reading or rebuilding widget data. - **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins - tables, planned-stay revisions, and evidence blobs to one generation-consistent snapshot. Merge preserves queued locations + tables, planned-stay and home-region 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 @@ -184,6 +190,14 @@ one it belongs to rather than to a god-object: sidecar tombstone before clearing recovery, so a cold launch can repair a preference write that did not reach disk without offering the same archive again. Check-ins are deliberately neither exported nor restored because they are live advisory status. + Backup format v6 stores independent stays, date windows, and the home register. + The offline `../Tools/upgrade-backup.rb` command upgrades earlier backups before import. + All v5 planned-stay revisions retain one shared legacy identity, including tombstones. + The converter infers exact arrival from each revision's UTC date, capped at its final day. + Revision IDs, timestamps, and final days remain unchanged. No in-app legacy recovery runs. + Export before upgrading the app, upgrade the archive offline, then replace-import it. + Update every syncing installation before editing plans. Older builds use a single + stay register and can delete independent stays in the shared store. - **`InstallationRecordingContext`** — the device-local installation identity, explicitly confirmed local recording choice, and stable timestamp for recreating its immutable device profile idempotently. diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 418b8a535..77e18405d 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -20,11 +20,12 @@ 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; v5 adds `plannedStayRecords`. There's no in-app + /// profile's registration-generation key; v5 adds `plannedStayRecords`. v6 adds independent + /// stay identities, arrival/departure windows, and `homeRegionRecords`. There is 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 = 5 + public static let currentFormatVersion = 6 public let formatVersion: Int public let exportedAt: Date @@ -49,9 +50,10 @@ 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. + /// All synced stay revisions, including per-stay deletion tombstones. public let plannedStayRecords: [PlannedStayRecord] + /// Synced forecast home choices. Nil-region revisions select historical estimates. + public let homeRegionRecords: [HomeRegionRecord] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -69,6 +71,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceRemovals: [RecordingDeviceRemoval], plannedStayRecords: [PlannedStayRecord], + homeRegionRecords: [HomeRegionRecord], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -83,6 +86,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges self.recordingDeviceRemovals = recordingDeviceRemovals self.plannedStayRecords = plannedStayRecords + self.homeRegionRecords = homeRegionRecords self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 976033687..c33cb6e52 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -167,6 +167,7 @@ public actor BackupCoordinator { recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), recordingDeviceRemovals: store.recordingDeviceRemovals(), plannedStayRecords: store.plannedStayRecords(), + homeRegionRecords: store.homeRegionRecords(), ) } let evidence = tables.evidence @@ -202,6 +203,7 @@ public actor BackupCoordinator { recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, recordingDeviceRemovals: tables.recordingDeviceRemovals, plannedStayRecords: tables.plannedStayRecords, + homeRegionRecords: tables.homeRegionRecords, blobs: snapshot.blobs, ) }.value @@ -223,6 +225,7 @@ public actor BackupCoordinator { let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] let recordingDeviceRemovals: [RecordingDeviceRemoval] let plannedStayRecords: [PlannedStayRecord] + let homeRegionRecords: [HomeRegionRecord] } private struct ExportSnapshot { @@ -415,6 +418,7 @@ public actor BackupCoordinator { + archive.recordingDeviceMetadataChanges.count + archive.recordingDeviceRemovals.count + archive.plannedStayRecords.count + + archive.homeRegionRecords.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 @@ -484,6 +488,10 @@ public actor BackupCoordinator { try await store.restorePlannedStayRecord(plannedStay) report() } + for home in archive.homeRegionRecords { + try await store.restoreHomeRegionRecord(home) + 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 686a8f27e..eae899902 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -36,7 +36,7 @@ public struct BackupService: Sendable { /// Failures specific to reading a backup file. Transport / file-system /// errors surface as the underlying `Error` instead. - public enum BackupError: Error, LocalizedError { + public enum BackupError: Error, Equatable, LocalizedError { /// The zip opened but contained no `manifest.json` at its root — it /// is almost certainly not a Where backup. case manifestMissing @@ -46,6 +46,8 @@ public struct BackupService: Sendable { /// Recording rows decoded structurally but violate persisted invariants (for example a /// a negative causal revision or incomplete removal history). case invalidRecordingData + /// Planning revisions violate identity or date-window invariants. + case invalidPlanningData public var errorDescription: String? { switch self { @@ -55,6 +57,8 @@ public struct BackupService: Sendable { String(localized: .backupErrorUnsupportedFormatVersion(version)) case .invalidRecordingData: String(localized: .backupErrorInvalidRecordingData) + case .invalidPlanningData: + String(localized: .backupErrorInvalidPlanningData) } } } @@ -100,6 +104,7 @@ public struct BackupService: Sendable { recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceRemovals: [RecordingDeviceRemoval], plannedStayRecords: [PlannedStayRecord], + homeRegionRecords: [HomeRegionRecord], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -107,6 +112,7 @@ public struct BackupService: Sendable { try Self.validateRecordingData( metadataChanges: recordingDeviceMetadataChanges, ) + try Self.validatePlanningData(stays: plannedStayRecords, homes: homeRegionRecords) let fileManager = FileManager.default let workRoot = fileManager.temporaryDirectory .appendingPathComponent("where-backup-\(UUID().uuidString)", isDirectory: true) @@ -141,6 +147,7 @@ public struct BackupService: Sendable { recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceRemovals: recordingDeviceRemovals, plannedStayRecords: plannedStayRecords, + homeRegionRecords: homeRegionRecords, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { @@ -243,7 +250,30 @@ public struct BackupService: Sendable { guard envelope.formatVersion == BackupArchive.currentFormatVersion else { throw BackupError.unsupportedFormatVersion(envelope.formatVersion) } - return try decoder.decode(BackupArchive.self, from: data) + let archive = try decoder.decode(BackupArchive.self, from: data) + try validatePlanningData( + stays: archive.plannedStayRecords, + homes: archive.homeRegionRecords, + ) + return archive + } + + /// Synthesized decoding does not call validating initializers. Reject invalid planning + /// before recording pauses, assets load, or an import writes any rows. + private static func validatePlanningData( + stays: [PlannedStayRecord], + homes: [HomeRegionRecord], + ) throws { + do { + for record in stays { + try record.validate() + } + for record in homes { + try record.validate() + } + } catch { + throw BackupError.invalidPlanningData + } } /// Validate invariants that synthesized `Decodable` cannot route through the public diff --git a/Where/WhereCore/Sources/Forecasting/DayBounds.swift b/Where/WhereCore/Sources/Forecasting/DayBounds.swift new file mode 100644 index 000000000..4c7d7a2b1 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/DayBounds.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Inclusive whole-day bounds. Equal endpoints describe one estimate. +public struct DayBounds: Hashable, Sendable { + public let lower: Int + public let upper: Int + + public var isExact: Bool { + lower == upper + } + + public init(lower: Int, upper: Int) { + precondition(lower >= 0 && lower <= upper, "Day bounds must be ordered and nonnegative.") + self.lower = lower + self.upper = upper + } + + public init(exact days: Int) { + self.init(lower: days, upper: days) + } + + /// Keep one nearest-rounded estimate when exact; otherwise round outward. + static func rounded(lowerNumerator: Int, upperNumerator: Int, denominator: Int) -> DayBounds { + precondition(denominator > 0) + if lowerNumerator == upperNumerator { + return DayBounds(exact: (lowerNumerator + denominator / 2) / denominator) + } + return DayBounds( + lower: lowerNumerator / denominator, + upper: (upperNumerator + denominator - 1) / denominator, + ) + } +} diff --git a/Where/WhereCore/Sources/Forecasting/HomeRegionRecord.swift b/Where/WhereCore/Sources/Forecasting/HomeRegionRecord.swift new file mode 100644 index 000000000..8357f4999 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/HomeRegionRecord.swift @@ -0,0 +1,30 @@ +import Foundation +import RegionKit + +/// A revision of the forecast home choice. Nil selects historical estimates and +/// remains persisted so delayed sync cannot restore a superseded home region. +public struct HomeRegionRecord: Hashable, Sendable, Codable, Identifiable { + public let id: UUID + public let region: Region? + public let updatedAt: Date + + public init(id: UUID, region: Region?, updatedAt: Date) throws { + self.id = id + self.region = region + self.updatedAt = updatedAt + try validate() + } + + public func validate() throws { + if let region { + guard region != .other, Region(rawValue: region.rawValue) != nil else { + throw PlannedStay.ValidationError.unsupportedRegion + } + } + } + + public static func newer(_ lhs: HomeRegionRecord, than rhs: HomeRegionRecord) -> Bool { + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id.uuidString > rhs.id.uuidString + } +} diff --git a/Where/WhereCore/Sources/Forecasting/LocationForecast.swift b/Where/WhereCore/Sources/Forecasting/LocationForecast.swift index 1effb0aea..684316dc4 100644 --- a/Where/WhereCore/Sources/Forecasting/LocationForecast.swift +++ b/Where/WhereCore/Sources/Forecasting/LocationForecast.swift @@ -1,70 +1,119 @@ 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. +/// Independent bounds on a region's current-year estimate. Bounds describe +/// entered date choices, not confidence in future behavior. Component bounds +/// can occur in different scenarios and must not be added endpoint by endpoint. public struct LocationForecast: Hashable, Sendable { + public enum GapPolicy: Hashable, Sendable { + case historicalPattern + case home(Region) + } + 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 let plannedDays: DayBounds + public let projectedRemainingDays: DayBounds + public let estimatedTotalDays: DayBounds + public let gapPolicy: GapPolicy - public var estimatedFractionOfYear: Double { + public var estimatedFractionOfYear: ClosedRange { let daysInYear = CalendarDay.yearRange(year).lowerBound .days(through: CalendarDay.lastDay(ofYear: year)).count - guard daysInYear > 0 else { return 0 } - return Double(estimatedTotalDays) / Double(daysInYear) + return Double(estimatedTotalDays.lower) / Double(daysInYear) + ... Double(estimatedTotalDays.upper) / 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. + /// Historical forecasts need three complete months. Home forecasts can + /// begin on January 1. Neither policy estimates a non-current report year. public static func estimate( region: Region, report: YearReport, asOf date: Date, calendar: Calendar, - plannedStay: PlannedStay?, + planning: PlanningSnapshot, ) -> 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 } + guard planning.homeRegion != nil + || 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 futureDays = today.adding(days: 1).days(through: lastDay).count + let yearToDateDays = Set(report.days.filter { + $0.day.year == report.year && $0.day <= today && $0.regions.contains(region) + }.map(\.day)).count + let gapNumerator = planning.homeRegion.map { $0 == region ? elapsedDays : 0 } + ?? yearToDateDays + let coverage = Coverage( + region: region, + stays: planning.stays, + future: PlanningSnapshot.futureRange(intersecting: report.year, asOf: today), + ) - let activeStay = plannedStay.flatMap { stay in - stay.through >= tomorrow ? stay : nil - } - let plannedEnd = activeStay.map { min($0.through, lastDay) } - let plannedDays = activeStay?.region == region - ? plannedEnd.map { tomorrow.days(through: $0).count } ?? 0 - : 0 - let projectionStart = plannedEnd?.adding(days: 1) ?? tomorrow - let remainingDays = projectionStart.days(through: lastDay).count - let projectedRemainingDays = baselineRate * Double(remainingDays) - let estimated = Double(yearToDateDays + plannedDays) + projectedRemainingDays + // An own-region day replaces a gap weight <= 1 or another region's 0; + // another region replaces only the gap weight. These opposite extrema + // are therefore attainable even when several uncertain stays overlap. + let lowerReserved = coverage.certainTarget.union(coverage.possibleOthers).count + let upperReserved = coverage.possibleTarget.union(coverage.certainOthers).count + let lowerNumerator = (yearToDateDays + coverage.certainTarget.count) * elapsedDays + + (futureDays - lowerReserved) * gapNumerator + let upperNumerator = (yearToDateDays + coverage.possibleTarget.count) * elapsedDays + + (futureDays - upperReserved) * gapNumerator + let mostReserved = coverage.possibleTarget.union(coverage.possibleOthers).count + let leastReserved = coverage.certainTarget.union(coverage.certainOthers).count return LocationForecast( region: region, year: report.year, yearToDateDays: yearToDateDays, elapsedDays: elapsedDays, - plannedDays: plannedDays, - projectedRemainingDays: projectedRemainingDays, - estimatedTotalDays: min(yearLength, max(0, Int(estimated.rounded()))), + plannedDays: DayBounds( + lower: coverage.certainTarget.count, + upper: coverage.possibleTarget.count, + ), + projectedRemainingDays: .rounded( + lowerNumerator: (futureDays - mostReserved) * gapNumerator, + upperNumerator: (futureDays - leastReserved) * gapNumerator, + denominator: elapsedDays, + ), + estimatedTotalDays: .rounded( + lowerNumerator: lowerNumerator, + upperNumerator: upperNumerator, + denominator: elapsedDays, + ), + gapPolicy: planning.homeRegion.map { .home($0) } ?? .historicalPattern, ) } + + /// Day unions preserve independent plan identity while preventing duplicate + /// same-region days from inflating an estimate. + private struct Coverage { + var certainTarget: Set = [] + var possibleTarget: Set = [] + var certainOthers: Set = [] + var possibleOthers: Set = [] + + init(region: Region, stays: [PlannedStay], future: ClosedRange?) { + guard let future else { return } + for stay in stays { + let certain = PlanningSnapshot.intersection(stay.shortestRange, future) + .map { $0.lowerBound.days(through: $0.upperBound) } ?? [] + let possible = PlanningSnapshot.intersection(stay.longestRange, future) + .map { $0.lowerBound.days(through: $0.upperBound) } ?? [] + if stay.region == region { + certainTarget.formUnion(certain) + possibleTarget.formUnion(possible) + } else { + certainOthers.formUnion(certain) + possibleOthers.formUnion(possible) + } + } + } + } } diff --git a/Where/WhereCore/Sources/Forecasting/PlannedHomeInterval.swift b/Where/WhereCore/Sources/Forecasting/PlannedHomeInterval.swift new file mode 100644 index 000000000..b52fa8516 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedHomeInterval.swift @@ -0,0 +1,19 @@ +import Foundation +import RegionKit + +/// A contiguous run of assumed Home days with one certainty level. +public struct PlannedHomeInterval: Hashable, Sendable, Identifiable { + public let region: Region + public let start: CalendarDay + public let end: CalendarDay + public let certainty: PlanningCertainty + + public var id: Self { + self + } + + public var dayCount: DayBounds { + let count = start.days(through: end).count + return DayBounds(lower: certainty == .certain ? count : 0, upper: count) + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStay.swift b/Where/WhereCore/Sources/Forecasting/PlannedStay.swift index bb87cf3df..e7c6a23aa 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStay.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStay.swift @@ -1,15 +1,99 @@ 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 { +/// Independently editable travel intent. Both endpoint windows are inclusive, +/// and every permitted arrival precedes every permitted final day. +public struct PlannedStay: Hashable, Sendable, Codable, Identifiable { + /// Stable identity across revisions, encoded as one UUID rather than a wrapper object. + public struct ID: Hashable, Sendable, Codable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + /// A bare UUID is the persisted single-value identity shape. + public init(from decoder: any Decoder) throws { + rawValue = try UUID(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try rawValue.encode(to: encoder) + } + } + + /// Earliest and latest choices for one timezone-independent endpoint. + public struct DateWindow: Hashable, Sendable, Codable { + public let earliest: CalendarDay + public let latest: CalendarDay + + public var isExact: Bool { + earliest == latest + } + + public init(earliest: CalendarDay, latest: CalendarDay) throws { + self.earliest = earliest + self.latest = latest + try validate() + } + + public init(exact day: CalendarDay) { + earliest = day + latest = day + } + + public func validate() throws { + guard CalendarDay(iso: earliest.description) == earliest, + CalendarDay(iso: latest.description) == latest + else { throw ValidationError.invalidDay } + guard earliest <= latest else { throw ValidationError.reversedWindow } + } + } + + public enum ValidationError: Error, Equatable { + case invalidDay + case reversedWindow + case arrivalAfterDeparture + case unsupportedRegion + } + + public let id: ID public let region: Region - public let through: CalendarDay + public let arrival: DateWindow + public let departure: DateWindow - public init(region: Region, through: CalendarDay) { + public init(id: ID, region: Region, arrival: DateWindow, departure: DateWindow) throws { + self.id = id self.region = region - self.through = through + self.arrival = arrival + self.departure = departure + try validate() + } + + /// Validate values at persistence boundaries after synthesized decoding. + public func validate() throws { + guard region != .other, Region(rawValue: region.rawValue) != nil else { + throw ValidationError.unsupportedRegion + } + try arrival.validate() + try departure.validate() + guard arrival.latest <= departure.earliest else { + throw ValidationError.arrivalAfterDeparture + } + } + + public var shortestRange: ClosedRange { + arrival.latest ... departure.earliest + } + + public var longestRange: ClosedRange { + arrival.earliest ... departure.latest + } + + public var dayCount: DayBounds { + DayBounds( + lower: arrival.latest.days(through: departure.earliest).count, + upper: arrival.earliest.days(through: departure.latest).count, + ) } } diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift index 397f58921..f239d6357 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift @@ -1,77 +1,116 @@ import Foundation import RegionKit -/// Reads and writes the single CloudKit-synced planned-stay register. +/// Persists independent travel plans and the home region used for unplanned days. +/// Each plan and the home choice resolve synced revisions through their own register. public struct PlannedStayCoordinator: Sendable { + public enum PlanningError: Error, Equatable, LocalizedError { + case stayNotFound + case stayAlreadyExists + + public var errorDescription: String? { + switch self { + case .stayNotFound: + String(localized: .planningErrorStayNotFound) + case .stayAlreadyExists: + String(localized: .planningErrorStayAlreadyExists) + } + } + } + private let store: any WhereStore - private let calendar: Calendar private let now: @Sendable () -> Date - init(store: any WhereStore, calendar: Calendar, now: @escaping @Sendable () -> Date) { + init(store: any WhereStore, 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) + /// Read completed and upcoming plans with the home choice from one store snapshot. + /// Reading never expires intent or changes recorded history. + public func snapshot() async throws -> PlanningSnapshot { + try await store.readSnapshot { + let records = try await store.plannedStayRecords() + let home = try await latestHomeRecord() + let winners = Dictionary(grouping: records, by: \.stayID).values.compactMap { + $0.max { PlannedStayRecord.newer($1, than: $0) } + } + let stays = winners.compactMap(\.value).sorted { lhs, rhs in + if lhs.arrival.earliest != rhs.arrival.earliest { + return lhs.arrival.earliest < rhs.arrival.earliest + } + return lhs.id.rawValue.uuidString < rhs.id.rawValue.uuidString + } + return PlanningSnapshot(stays: stays, homeRegion: home?.region) + } } - /// 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)) + /// Persist a new draft with its stable identity. An identical retry is a no-op. + public func create(_ stay: PlannedStay) async throws { + try stay.validate() + try await store.performInCurrentGeneration { + if let latest = try await latestRecord(stayID: stay.id) { + guard latest.value == stay else { throw PlanningError.stayAlreadyExists } + return + } + try await write(stayID: stay.id, value: stay) + } } - /// Clear the active stay with a synced tombstone. - public func clear() async throws { - try await write(value: nil) + /// An edit cannot recreate a plan deleted on another device while its editor was open. + public func update(_ stay: PlannedStay) async throws { + try stay.validate() + try await store.performInCurrentGeneration { + guard try await latestRecord(stayID: stay.id)?.value != nil else { + throw PlanningError.stayNotFound + } + try await write(stayID: stay.id, value: stay) + } } - private func latestRecord() async throws -> PlannedStayRecord? { - try await store.plannedStayRecords().max { lhs, rhs in - PlannedStayRecord.newer(rhs, than: lhs) + /// Keep a tombstone for this identity so delayed revisions cannot restore the deleted plan. + public func delete(stayID: PlannedStay.ID) async throws { + try await store.performInCurrentGeneration { + try await write(stayID: stayID, value: nil) } } - /// 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( + /// A nil home choice selects historical estimates for unplanned days. + public func setHomeRegion(_ region: Region?) async throws { + try await store.performInCurrentGeneration { + let latest = try await latestHomeRecord() + let record = try HomeRegionRecord( id: UUID(), - value: nil, - updatedAt: max(now(), expiredRecord.updatedAt.addingTimeInterval(0.001)), + region: region, + updatedAt: nextTimestamp(after: latest?.updatedAt), ) - try await store.replacePlannedStayRecord(with: tombstone) - return nil + try await store.replaceHomeRegionRecord(with: record) } } - 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) + private func latestRecord(stayID: PlannedStay.ID) async throws -> PlannedStayRecord? { + try await store.plannedStayRecords().filter { $0.stayID == stayID }.max { + PlannedStayRecord.newer($1, than: $0) } } + + private func latestHomeRecord() async throws -> HomeRegionRecord? { + try await store.homeRegionRecords().max { HomeRegionRecord.newer($1, than: $0) } + } + + private func write(stayID: PlannedStay.ID, value: PlannedStay?) async throws { + let latest = try await latestRecord(stayID: stayID) + let record = try PlannedStayRecord( + id: UUID(), + stayID: stayID, + value: value, + updatedAt: nextTimestamp(after: latest?.updatedAt), + ) + try await store.replacePlannedStayRecord(with: record) + } + + private func nextTimestamp(after previous: Date?) -> Date { + let current = now() + return previous.map { max(current, $0.addingTimeInterval(0.001)) } ?? current + } } diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayInterval.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayInterval.swift new file mode 100644 index 000000000..c43232173 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayInterval.swift @@ -0,0 +1,29 @@ +import Foundation +import RegionKit + +/// One plan clipped to a displayed year's future. The original stay remains editable by ID. +public struct PlannedStayInterval: Hashable, Sendable, Identifiable { + public let stayID: PlannedStay.ID + public let region: Region + public let possibleRange: ClosedRange + public let certainRange: ClosedRange? + + public var id: PlannedStay.ID { + stayID + } + + public var start: CalendarDay { + possibleRange.lowerBound + } + + public var end: CalendarDay { + possibleRange.upperBound + } + + public var dayCount: DayBounds { + DayBounds( + lower: certainRange.map { $0.lowerBound.days(through: $0.upperBound).count } ?? 0, + upper: start.days(through: end).count, + ) + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayOverlap.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayOverlap.swift new file mode 100644 index 000000000..f98ca18c3 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayOverlap.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Independent plans that can share a day; a nil certain range means the overlap is optional. +public struct PlannedStayOverlap: Hashable, Sendable { + public let firstStayID: PlannedStay.ID + public let secondStayID: PlannedStay.ID + public let possibleRange: ClosedRange + public let certainRange: ClosedRange? +} diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift index a0719c4b8..9227fc405 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift @@ -1,17 +1,30 @@ 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. +/// One revision of one stay. A retained nil value prevents delayed sync from +/// resurrecting that stay without replacing independently edited plans. public struct PlannedStayRecord: Hashable, Sendable, Codable, Identifiable { + public enum ValidationError: Error, Equatable { + case mismatchedStayID + } + public let id: UUID + public let stayID: PlannedStay.ID public let value: PlannedStay? public let updatedAt: Date - public init(id: UUID, value: PlannedStay?, updatedAt: Date) { + public init(id: UUID, stayID: PlannedStay.ID, value: PlannedStay?, updatedAt: Date) throws { self.id = id + self.stayID = stayID self.value = value self.updatedAt = updatedAt + try validate() + } + + /// Validate current-format identity and date invariants after synthesized decoding. + public func validate() throws { + guard let value else { return } + guard value.id == stayID else { throw ValidationError.mismatchedStayID } + try value.validate() } /// Deterministic last-writer ordering for duplicate rows produced by diff --git a/Where/WhereCore/Sources/Forecasting/PlanningCertainty.swift b/Where/WhereCore/Sources/Forecasting/PlanningCertainty.swift new file mode 100644 index 000000000..e35723c28 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlanningCertainty.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Whether every endpoint choice or only some choices include a projected day. +public enum PlanningCertainty: Hashable, Sendable { + case certain + case possible +} diff --git a/Where/WhereCore/Sources/Forecasting/PlanningDayPresence.swift b/Where/WhereCore/Sources/Forecasting/PlanningDayPresence.swift new file mode 100644 index 000000000..02d790ec2 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlanningDayPresence.swift @@ -0,0 +1,33 @@ +import Foundation +import RegionKit + +/// Explicit intent and Home assumptions remain separate from measured presence. +public struct PlanningDayPresence: Hashable, Sendable { + public struct HomeAssumption: Hashable, Sendable { + public let region: Region + public let certainty: PlanningCertainty + } + + public enum Membership: Hashable, Sendable { + case planned(PlanningCertainty) + case homeAssumed(PlanningCertainty) + } + + public let certainRegions: Set + /// Includes certain regions as well as those reached by longer intervals. + public let possibleRegions: Set + public let homeAssumption: HomeAssumption? + + /// Effective region certainty for presentation. When an uncertain Home + /// plan is the only possible explicit presence, Home still covers the day + /// in every scenario. Raw fields retain the separate sources of that coverage. + public func membership(in region: Region) -> Membership? { + if certainRegions.contains(region) { return .planned(.certain) } + if homeAssumption?.region == region, possibleRegions == [region] { + return .homeAssumed(.certain) + } + if possibleRegions.contains(region) { return .planned(.possible) } + guard let homeAssumption, homeAssumption.region == region else { return nil } + return .homeAssumed(homeAssumption.certainty) + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlanningRegionSummary.swift b/Where/WhereCore/Sources/Forecasting/PlanningRegionSummary.swift new file mode 100644 index 000000000..850b16741 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlanningRegionSummary.swift @@ -0,0 +1,67 @@ +import Foundation +import RegionKit + +/// Explicit and assumed days in a displayed range. These independent bounds +/// preserve their provenance and must not be added endpoint by endpoint. +public struct PlanningRegionSummary: Hashable, Sendable, Identifiable { + public let region: Region + public let plannedDays: DayBounds + public let homeDays: DayBounds + + public var id: Region { + region + } + + public init(region: Region, plannedDays: DayBounds, homeDays: DayBounds) { + self.region = region + self.plannedDays = plannedDays + self.homeDays = homeDays + } +} + +extension PlanningSnapshot { + /// Summarize unique future days per region, retaining explicit-plan and + /// raw Home-assumption bounds even when their effective coverage is certain. + public func regionSummaries( + in range: ClosedRange, + asOf today: CalendarDay, + ) -> [PlanningRegionSummary] { + let start = max(range.lowerBound, today.adding(days: 1)) + guard start <= range.upperBound else { return [] } + var counts: [Region: RegionSummaryCounts] = [:] + for day in start.days(through: range.upperBound) { + let presence = plannedPresence(on: day, asOf: today) + for region in presence.possibleRegions { + counts[region, default: RegionSummaryCounts()].plannedPossible += 1 + if presence.certainRegions.contains(region) { + counts[region, default: RegionSummaryCounts()].plannedCertain += 1 + } + } + if let home = presence.homeAssumption { + counts[home.region, default: RegionSummaryCounts()].homePossible += 1 + if home.certainty == .certain { + counts[home.region, default: RegionSummaryCounts()].homeCertain += 1 + } + } + } + return counts.map { region, count in + PlanningRegionSummary( + region: region, + plannedDays: DayBounds(lower: count.plannedCertain, upper: count.plannedPossible), + homeDays: DayBounds(lower: count.homeCertain, upper: count.homePossible), + ) + }.sorted { + Region.declarationOrder[$0.region, default: 0] + < Region.declarationOrder[$1.region, default: 0] + } + } + + /// Accumulation stays per region so explicit and Home counts cannot drift + /// into separate region collections. A row is created only for a possible day. + private struct RegionSummaryCounts { + var plannedCertain = 0 + var plannedPossible = 0 + var homeCertain = 0 + var homePossible = 0 + } +} diff --git a/Where/WhereCore/Sources/Forecasting/PlanningSnapshot.swift b/Where/WhereCore/Sources/Forecasting/PlanningSnapshot.swift new file mode 100644 index 000000000..5d8e76ec8 --- /dev/null +++ b/Where/WhereCore/Sources/Forecasting/PlanningSnapshot.swift @@ -0,0 +1,142 @@ +import Foundation +import RegionKit + +/// Resolved travel intent and the optional Home region used for future gaps. +/// A nil Home keeps the historical-rate policy. Plans never change recorded days. +public struct PlanningSnapshot: Hashable, Sendable { + public let stays: [PlannedStay] + public let homeRegion: Region? + + public init(stays: [PlannedStay], homeRegion: Region?) { + self.stays = stays + self.homeRegion = homeRegion + } + + public func plannedPresence( + on day: CalendarDay, + asOf today: CalendarDay, + ) -> PlanningDayPresence { + guard day > today else { + return PlanningDayPresence(certainRegions: [], possibleRegions: [], homeAssumption: nil) + } + let possible = Set(stays.filter { $0.longestRange.contains(day) }.map(\.region)) + let certain = Set(stays.filter { $0.shortestRange.contains(day) }.map(\.region)) + let homeAssumption = homeRegion.flatMap { region in + certain.isEmpty + ? PlanningDayPresence.HomeAssumption( + region: region, + certainty: possible.isEmpty ? .certain : .possible, + ) + : nil + } + return PlanningDayPresence( + certainRegions: certain, + possibleRegions: possible, + homeAssumption: homeAssumption, + ) + } + + public func stayIntervals( + intersecting year: Int, + asOf today: CalendarDay, + ) -> [PlannedStayInterval] { + guard let future = Self.futureRange(intersecting: year, asOf: today) else { return [] } + return stays.compactMap { stay in + guard let possible = Self.intersection(stay.longestRange, future) else { return nil } + return PlannedStayInterval( + stayID: stay.id, + region: stay.region, + possibleRange: possible, + certainRange: Self.intersection(stay.shortestRange, future), + ) + }.sorted { + if $0.start != $1.start { return $0.start < $1.start } + return $0.stayID.rawValue.uuidString < $1.stayID.rawValue.uuidString + } + } + + /// Consecutive future gaps, split wherever the certainty of Home changes. + public func homeIntervals( + intersecting year: Int, + asOf today: CalendarDay, + ) -> [PlannedHomeInterval] { + guard homeRegion != nil, + let future = Self.futureRange(intersecting: year, asOf: today) + else { return [] } + var intervals: [PlannedHomeInterval] = [] + var current: PlannedHomeInterval? + for day in future.lowerBound.days(through: future.upperBound) { + let home = plannedPresence(on: day, asOf: today).homeAssumption + if let home, let previous = current, previous.certainty == home.certainty { + current = PlannedHomeInterval( + region: home.region, + start: previous.start, + end: day, + certainty: home.certainty, + ) + } else { + if let current { intervals.append(current) } + current = home.map { + PlannedHomeInterval( + region: $0.region, + start: day, + end: day, + certainty: $0.certainty, + ) + } + } + } + if let current { intervals.append(current) } + return intervals + } + + /// Flag any pair that can share a future day, including redundant same-region plans. + public func overlaps(asOf today: CalendarDay) -> [PlannedStayOverlap] { + let tomorrow = today.adding(days: 1) + let candidates = stays.filter { $0.departure.latest >= tomorrow }.sorted { + $0.id.rawValue.uuidString < $1.id.rawValue.uuidString + } + var result: [PlannedStayOverlap] = [] + for firstIndex in candidates.indices { + let first = candidates[firstIndex] + for secondIndex in (firstIndex + 1) ..< candidates.count { + let second = candidates[secondIndex] + guard first.id != second.id, + let possible = Self.intersection(first.longestRange, second.longestRange), + possible.upperBound >= tomorrow + else { continue } + let certain = Self.intersection(first.shortestRange, second.shortestRange) + .flatMap { range in + range.upperBound >= tomorrow + ? max(tomorrow, range.lowerBound) ... range.upperBound + : nil + } + result.append(PlannedStayOverlap( + firstStayID: first.id, + secondStayID: second.id, + possibleRange: max(tomorrow, possible.lowerBound) ... possible.upperBound, + certainRange: certain, + )) + } + } + return result + } + + static func futureRange( + intersecting year: Int, + asOf today: CalendarDay, + ) -> ClosedRange? { + let start = max(CalendarDay.yearRange(year).lowerBound, today.adding(days: 1)) + let end = CalendarDay.lastDay(ofYear: year) + return start <= end ? start ... end : nil + } + + static func intersection( + _ first: ClosedRange, + _ second: ClosedRange, + ) -> ClosedRange? { + let start = max(first.lowerBound, second.lowerBound) + let end = min(first.upperBound, second.upperBound) + return start <= end ? start ... end : nil + } +} diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 295f65547..c173cf708 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -243,6 +243,46 @@ private enum GenerationScopedFetch { }) } + static func plannedStays( + belongingTo generationID: WhereDataGenerationID, + stayID: PlannedStay.ID, + ) -> FetchDescriptor { + let membership = GenerationMembership(generationID) + let storedGenerationID = membership.storedID + let includesLegacy = membership.includesLegacy + let storedStayID = stayID.rawValue + return descriptor(predicate: #Predicate { + ($0.generationID == storedGenerationID || + (includesLegacy && $0.generationID == nil)) && $0.stayID == storedStayID + }) + } + + static func homeRegions( + 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 homeRegions( + 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] = [], @@ -588,6 +628,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDDismissedIssue.self, SDTrackedRegion.self, SDPlannedStay.self, + SDHomeRegion.self, SDRecordingDeviceProfile.self, SDRecordingDeviceMetadataChange.self, SDRecordingDeviceCheckIn.self, @@ -1204,6 +1245,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { )) { context.delete(record) } + for record in try context.fetch(GenerationScopedFetch.homeRegions( + belongingTo: generationID, + )) { + context.delete(record) + } for record in try context.fetch(GenerationScopedFetch.metadataChanges( belongingTo: generationID, )) { @@ -1801,10 +1847,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } public func replacePlannedStayRecord(with record: PlannedStayRecord) async throws { + try record.validate() let context = mutationContext() let generationID = mutationGenerationID() for existing in try context.fetch(GenerationScopedFetch.plannedStays( belongingTo: generationID, + stayID: record.stayID, )) { context.delete(existing) } @@ -1812,6 +1860,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } public func restorePlannedStayRecord(_ record: PlannedStayRecord) async throws { + try record.validate() let context = mutationContext() let generationID = mutationGenerationID() for duplicate in try context.fetch(GenerationScopedFetch.plannedStays( @@ -1823,6 +1872,45 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { context.insert(SDPlannedStay(value: record, generationID: generationID)) } + public func homeRegionRecords() async throws -> [HomeRegionRecord] { + let context = readContext() + let generationID = try readGenerationID(in: context) + let descriptor = GenerationScopedFetch.homeRegions( + 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 replaceHomeRegionRecord(with record: HomeRegionRecord) async throws { + try record.validate() + let context = mutationContext() + let generationID = mutationGenerationID() + for existing in try context.fetch(GenerationScopedFetch.homeRegions( + belongingTo: generationID, + )) { + context.delete(existing) + } + context.insert(SDHomeRegion(value: record, generationID: generationID)) + } + + public func restoreHomeRegionRecord(_ record: HomeRegionRecord) async throws { + try record.validate() + let context = mutationContext() + let generationID = mutationGenerationID() + for duplicate in try context.fetch(GenerationScopedFetch.homeRegions( + belongingTo: generationID, + id: record.id, + )) { + context.delete(duplicate) + } + context.insert(SDHomeRegion(value: record, generationID: generationID)) + } + // MARK: - Tracked regions public func trackedRegions() async throws -> Set { @@ -2359,15 +2447,18 @@ 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. +/// One revision for a stable stay identity. A fully absent payload is a tombstone; +/// a partial payload is corrupt and never becomes an inferred plan. @Model final class SDPlannedStay { var generationID: UUID? var id: UUID? + var stayID: UUID? var regionID: String? - var throughDayKey: String? + var arrivalEarliestDayKey: String? + var arrivalLatestDayKey: String? + var departureEarliestDayKey: String? + var departureLatestDayKey: String? var updatedAt: Date? init() {} @@ -2376,26 +2467,89 @@ final class SDPlannedStay { self.init() self.generationID = generationID.rawValue id = value.id + stayID = value.stayID.rawValue regionID = value.value?.region.rawValue - throughDayKey = value.value?.through.description + arrivalEarliestDayKey = value.value?.arrival.earliest.description + arrivalLatestDayKey = value.value?.arrival.latest.description + departureEarliestDayKey = value.value?.departure.earliest.description + departureLatestDayKey = value.value?.departure.latest.description updatedAt = value.updatedAt } func toValue() -> PlannedStayRecord? { - guard let id, let updatedAt else { return nil } - let stay: PlannedStay? - switch (regionID, throughDayKey) { - case (nil, nil): + guard let id, let stayID, let updatedAt else { return nil } + let identity = PlannedStay.ID(rawValue: stayID) + do { + let stay: PlannedStay? + if regionID == nil, arrivalEarliestDayKey == nil, arrivalLatestDayKey == nil, + departureEarliestDayKey == nil, departureLatestDayKey == nil + { stay = nil - case let (regionID?, throughDayKey?): - guard let region = Region(rawValue: regionID), - let through = CalendarDay(iso: throughDayKey) + } else { + guard let regionID, let region = Region(rawValue: regionID), + let arrivalEarliestDayKey, + let arrivalEarliest = CalendarDay(iso: arrivalEarliestDayKey), + let arrivalLatestDayKey, + let arrivalLatest = CalendarDay(iso: arrivalLatestDayKey), + let departureEarliestDayKey, + let departureEarliest = CalendarDay(iso: departureEarliestDayKey), + let departureLatestDayKey, + let departureLatest = CalendarDay(iso: departureLatestDayKey) else { return nil } - stay = PlannedStay(region: region, through: through) - case (.some, nil), (nil, .some): - return nil + stay = try PlannedStay( + id: identity, + region: region, + arrival: .init(earliest: arrivalEarliest, latest: arrivalLatest), + departure: .init(earliest: departureEarliest, latest: departureLatest), + ) + } + return try PlannedStayRecord( + id: id, + stayID: identity, + value: stay, + updatedAt: updatedAt, + ) + } catch { + // The owning store logs every row that fails to materialize. + return nil + } + } +} + +/// A CloudKit-compatible revision of the forecast home choice. Nil region means historical +/// estimates; an unknown region or missing revision metadata is a corrupt row. +@Model +final class SDHomeRegion { + var generationID: UUID? + var id: UUID? + var regionID: String? + var updatedAt: Date? + + init() {} + + convenience init(value: HomeRegionRecord, generationID: WhereDataGenerationID) { + self.init() + self.generationID = generationID.rawValue + id = value.id + regionID = value.region?.rawValue + updatedAt = value.updatedAt + } + + func toValue() -> HomeRegionRecord? { + guard let id, let updatedAt else { return nil } + let region: Region? + if let regionID { + guard let decoded = Region(rawValue: regionID) else { return nil } + region = decoded + } else { + region = nil + } + do { + return try HomeRegionRecord(id: id, region: region, updatedAt: updatedAt) + } catch { + // The owning store logs every row that fails to materialize. + return nil } - return PlannedStayRecord(id: id, value: stay, updatedAt: updatedAt) } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 2e4ce648f..5faabd5b8 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -182,12 +182,11 @@ 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. + /// Every planned-stay revision, including deletion tombstones. Resolve the newest revision + /// separately for each stable stay identity after CloudKit merges. func plannedStayRecords() async throws -> [PlannedStayRecord] - /// Replace local planned-stay revisions with `record`, retaining tombstones + /// Replace local revisions for this stay identity with `record`, retaining tombstones /// so an older remote row cannot resurrect cleared intent. Must run inside /// `perform { ... }`. func replacePlannedStayRecord(with record: PlannedStayRecord) async throws @@ -196,6 +195,15 @@ public protocol WhereStore: Sendable { /// inside `perform { ... }`. func restorePlannedStayRecord(_ record: PlannedStayRecord) async throws + /// Every revision of the forecast home choice, including historical-mode tombstones. + func homeRegionRecords() async throws -> [HomeRegionRecord] + + /// Replace local home revisions with a newer choice. Must run inside `perform { ... }`. + func replaceHomeRegionRecord(with record: HomeRegionRecord) async throws + + /// Restore an exact home revision during backup import. Must run inside `perform { ... }`. + func restoreHomeRegionRecord(_ record: HomeRegionRecord) 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. @@ -310,4 +318,12 @@ extension WhereStore { public func replacePlannedStayRecord(with _: PlannedStayRecord) async throws {} public func restorePlannedStayRecord(_: PlannedStayRecord) async throws {} + + public func homeRegionRecords() async throws -> [HomeRegionRecord] { + [] + } + + public func replaceHomeRegionRecord(with _: HomeRegionRecord) async throws {} + + public func restoreHomeRegionRecord(_: HomeRegionRecord) async throws {} } diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index ed4d2a99d..7626cf944 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -23,6 +23,17 @@ } } }, + "backup.error.invalidPlanningData" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This backup contains invalid planned stays. Check its dates and try again." + } + } + } + }, "backup.error.invalidRecordingData" : { "extractionState" : "manual", "localizations" : { @@ -112,6 +123,28 @@ } } }, + "planning.error.stayAlreadyExists" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This planned stay already exists. Open it again to edit its dates." + } + } + } + }, + "planning.error.stayNotFound" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This planned stay was deleted. Create a new stay to save these dates." + } + } + } + }, "recording.error.conflictingImmutableRecord" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 6deecf7fe..5790d3ebc 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -270,7 +270,6 @@ public struct WhereServices: Sendable { ) let plannedStays = PlannedStayCoordinator( store: store, - calendar: aggregator.calendar, now: now, ) let plannedStayLocation = PlannedStayLocationVerifier( diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index e2018e753..6578c2e71 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -94,14 +94,15 @@ 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), - ) + private static func plannedStay() throws -> PlannedStayRecord { + let stayID = try PlannedStay.ID(rawValue: #require(UUID( + uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + ))) + return try PlannedStayTestSupport.record( + stay: PlannedStayTestSupport.stay(id: stayID), + revisionID: #require(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + ) + } /// Seed every persisted domain directly into a store so backup tests don't /// depend on the journal or recording controller. @@ -115,7 +116,7 @@ struct BackupCoordinatorTests { regions: [.newYork], )) try await store.restoreDismissedIssue(dismissal) - try await store.restorePlannedStayRecord(plannedStay) + try await store.restorePlannedStayRecord(plannedStay()) try await store.addRecordingDeviceProfile(RecordingDeviceProfile( id: recordingDeviceID, systemName: "iPad", @@ -177,7 +178,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.plannedStayRecords() == [Self.plannedStay()]) #expect(try await destination.store.recordingDeviceProfiles() == source.store .recordingDeviceProfiles()) #expect(try await destination.store.recordingDeviceMetadataChanges() == source.store @@ -212,6 +213,66 @@ struct BackupCoordinatorTests { #expect(ids.count == 2) } + @Test(arguments: [BackupCoordinator.ImportStrategy.merge, .replace]) + func planningBackupPreservesIndependentRevisionsAndHomeTombstones( + strategy: BackupCoordinator.ImportStrategy, + ) async throws { + let source = try Self.makeHarness() + let active = try PlannedStayTestSupport.record(stay: PlannedStayTestSupport.stay()) + let deleted = try PlannedStayTestSupport.record(stay: PlannedStayTestSupport.stay()) + let deletion = try PlannedStayRecord( + id: UUID(), + stayID: deleted.stayID, + value: nil, + updatedAt: deleted.updatedAt.addingTimeInterval(1), + ) + let home = try HomeRegionRecord( + id: UUID(), + region: .california, + updatedAt: deleted.updatedAt, + ) + let historical = try HomeRegionRecord( + id: UUID(), + region: nil, + updatedAt: home.updatedAt.addingTimeInterval(1), + ) + try await source.store.perform { + for record in [active, deleted, deletion] { + try await source.store.restorePlannedStayRecord(record) + } + try await source.store.restoreHomeRegionRecord(home) + try await source.store.restoreHomeRegionRecord(historical) + } + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + let unrelated = try PlannedStayTestSupport.record( + stay: PlannedStayTestSupport.stay(region: .canada), + ) + try await destination.store.perform { + try await destination.store.restorePlannedStayRecord(unrelated) + try await destination.store.restoreHomeRegionRecord(home) + } + _ = try await destination.coordinator.importAndAcknowledgeBackup( + from: url, + strategy: strategy, + ) + + let expectedRecords = switch strategy { + case .merge: [active, deleted, deletion, unrelated] + case .replace: [active, deleted, deletion] + } + #expect(try await Set(destination.store.plannedStayRecords()) == Set(expectedRecords)) + #expect(try await Set(destination.store.homeRegionRecords()) == Set([home, historical])) + let planning = PlannedStayCoordinator(store: destination.store, now: { Date() }) + let snapshot = try await planning.snapshot() + #expect(snapshot.homeRegion == nil) + #expect(snapshot.stays.contains { $0.id == deleted.stayID } == false) + #expect(snapshot.stays.contains { $0.id == active.stayID }) + #expect(snapshot.stays.contains { $0.id == unrelated.stayID } == (strategy == .merge)) + } + @Test func replaceImportWipesPreexistingRows() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) @@ -525,6 +586,7 @@ struct BackupCoordinatorTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], blobs: [:], ) defer { try? FileManager.default.removeItem(at: secondURL.deletingLastPathComponent()) } diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 7e8cddd30..0c41fdef5 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -121,17 +121,15 @@ 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 plannedStayFixtures() throws -> [PlannedStayRecord] { + let stayID = try PlannedStay.ID(rawValue: #require(UUID( + uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + ))) + return try [PlannedStayTestSupport.record( + stay: PlannedStayTestSupport.stay(id: stayID), + revisionID: #require(UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")), + updatedAt: exportDate, + )] } private static func archive() -> BackupArchive { @@ -147,6 +145,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], assets: [], ) } @@ -221,6 +220,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceRemovals: [deviceArchive], plannedStayRecords: Self.plannedStayFixtures(), + homeRegionRecords: [], blobs: blobs, exportedAt: Self.exportDate, ) @@ -241,7 +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()) + #expect(try result.archive.plannedStayRecords == Self.plannedStayFixtures()) let encodedManifest = try #require(String( data: BackupService.makeEncoder().encode(result.archive), encoding: .utf8, @@ -255,12 +255,12 @@ struct BackupServiceTests { } @Test func unsupportedFormatIsRejectedBeforeItsMissingCurrentFieldsAreDecoded() { - let legacyManifest = Data(#"{"formatVersion":6}"#.utf8) + let legacyManifest = Data(#"{"formatVersion":5}"#.utf8) do { _ = try BackupService.decodeManifest(legacyManifest) Issue.record("Expected the legacy backup format to be rejected.") - } catch BackupService.BackupError.unsupportedFormatVersion(6) { + } catch BackupService.BackupError.unsupportedFormatVersion(5) { // Expected: the version envelope was decoded before the strict current shape. } catch { Issue.record("Unexpected error: \(error)") @@ -308,6 +308,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], assets: [], ) var json = try #require(String( @@ -339,6 +340,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -371,6 +373,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -393,6 +396,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -427,6 +431,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -465,6 +470,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + homeRegionRecords: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -476,7 +482,7 @@ struct BackupServiceTests { } @Test func manifestRoundTripsThroughJSON() throws { - let archive = BackupArchive( + let archive = try BackupArchive( exportedAt: Self.exportDate, samples: Self.sampleFixtures(), evidence: Self.evidenceFixtures(), @@ -499,6 +505,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), recordingDeviceRemovals: [], plannedStayRecords: Self.plannedStayFixtures(), + homeRegionRecords: [], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", @@ -524,6 +531,81 @@ struct BackupServiceTests { } } + @Test func currentManifestRejectsInvalidPlanningBeforeImport() throws { + let encoder = BackupService.makeEncoder() + var manifest = try #require( + JSONSerialization.jsonObject(with: encoder.encode(Self.archive())) as? [String: Any], + ) + let records = try Self.plannedStayFixtures() + var encodedRecords = try #require( + JSONSerialization.jsonObject(with: encoder.encode(records)) as? [[String: Any]], + ) + var value = try #require(encodedRecords[0]["value"] as? [String: Any]) + value["arrival"] = [ + "earliest": ["year": 2026, "month": 11, "day": 1], + "latest": ["year": 2026, "month": 11, "day": 3], + ] + encodedRecords[0]["value"] = value + manifest["plannedStayRecords"] = encodedRecords + + #expect(throws: BackupService.BackupError.invalidPlanningData) { + try BackupService.decodeManifest(JSONSerialization.data(withJSONObject: manifest)) + } + } + + @Test func currentManifestRejectsMismatchedStayIdentity() throws { + let encoder = BackupService.makeEncoder() + var manifest = try #require( + JSONSerialization.jsonObject(with: encoder.encode(Self.archive())) as? [String: Any], + ) + var records = try #require( + JSONSerialization + .jsonObject(with: encoder.encode(Self.plannedStayFixtures())) as? [[String: Any]], + ) + records[0]["stayID"] = UUID().uuidString + manifest["plannedStayRecords"] = records + + #expect(throws: BackupService.BackupError.invalidPlanningData) { + try BackupService.decodeManifest(JSONSerialization.data(withJSONObject: manifest)) + } + } + + @Test func currentManifestRejectsUnknownHomeRegion() throws { + var manifest = try #require( + JSONSerialization.jsonObject( + with: BackupService.makeEncoder().encode(Self.archive()), + ) as? [String: Any], + ) + manifest["homeRegionRecords"] = [[ + "id": UUID().uuidString, + "region": "not-a-region", + "updatedAt": 1_700_000_000, + ]] + + #expect(throws: BackupService.BackupError.invalidPlanningData) { + try BackupService.decodeManifest(JSONSerialization.data(withJSONObject: manifest)) + } + } + + @Test func currentManifestRejectsUnknownStayDestination() throws { + let encoder = BackupService.makeEncoder() + var manifest = try #require( + JSONSerialization.jsonObject(with: encoder.encode(Self.archive())) as? [String: Any], + ) + var records = try #require( + JSONSerialization + .jsonObject(with: encoder.encode(Self.plannedStayFixtures())) as? [[String: Any]], + ) + var value = try #require(records[0]["value"] as? [String: Any]) + value["region"] = "not-a-region" + records[0]["value"] = value + manifest["plannedStayRecords"] = records + + #expect(throws: BackupService.BackupError.invalidPlanningData) { + try BackupService.decodeManifest(JSONSerialization.data(withJSONObject: manifest)) + } + } + @Test func loadingADeclaredAssetThrowsWhenItsFileIsMissing() throws { let extractDirectory = FileManager.default.temporaryDirectory.appending( path: "where-missing-backup-asset-\(UUID().uuidString)", diff --git a/Where/WhereCore/Tests/DayBoundsTests.swift b/Where/WhereCore/Tests/DayBoundsTests.swift new file mode 100644 index 000000000..6919fd78c --- /dev/null +++ b/Where/WhereCore/Tests/DayBoundsTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import WhereCore + +struct DayBoundsTests { + @Test func unequalEndpointsRoundOutwardWithoutLosingDateUncertainty() { + #expect(DayBounds.rounded(lowerNumerator: 455, upperNumerator: 488, denominator: 4) + == DayBounds(lower: 113, upper: 122)) + #expect(DayBounds.rounded(lowerNumerator: 743, upperNumerator: 745, denominator: 4) + == DayBounds(lower: 185, upper: 187)) + } + + @Test func equalEndpointsKeepOneNearestRoundedEstimate() { + let oddDenominator = DayBounds.rounded(lowerNumerator: 5, upperNumerator: 5, denominator: 3) + #expect(oddDenominator == DayBounds(exact: 2)) + #expect(oddDenominator.isExact) + #expect(DayBounds.rounded(lowerNumerator: 5, upperNumerator: 5, denominator: 2) + == DayBounds(exact: 3)) + } +} diff --git a/Where/WhereCore/Tests/HomeRegionRecordTests.swift b/Where/WhereCore/Tests/HomeRegionRecordTests.swift new file mode 100644 index 000000000..fbb1e8a15 --- /dev/null +++ b/Where/WhereCore/Tests/HomeRegionRecordTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import WhereCore + +struct HomeRegionRecordTests { + @Test func newerUsesRevisionIdentityToBreakTimestampTies() throws { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let earlier = try HomeRegionRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + region: .california, + updatedAt: timestamp, + ) + let later = try HomeRegionRecord( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + region: nil, + updatedAt: timestamp, + ) + + #expect(HomeRegionRecord.newer(later, than: earlier)) + #expect(HomeRegionRecord.newer(earlier, than: later) == false) + } +} diff --git a/Where/WhereCore/Tests/LocationForecastTests.swift b/Where/WhereCore/Tests/LocationForecastTests.swift index a1b451935..0de6229aa 100644 --- a/Where/WhereCore/Tests/LocationForecastTests.swift +++ b/Where/WhereCore/Tests/LocationForecastTests.swift @@ -4,152 +4,341 @@ 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) { + @Test(arguments: [PlanningTestSupport.day(1, 1), PlanningTestSupport.day(3, 31)]) + func historicalPatternWaitsForThreeCompleteMonths(today: CalendarDay) { #expect(LocationForecast.estimate( region: .newYork, - report: Self.report(), - asOf: Self.date(month, day), - calendar: Self.calendar, - plannedStay: nil, + report: PlanningTestSupport.report(asOf: today, counts: [:]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: [], homeRegion: nil), ) == nil) } - @Test func becomesAvailableOnAprilFirst() throws { + @Test func historicalPatternBeginsOnAprilFirstAndUsesElapsedCalendarDays() throws { + let today = PlanningTestSupport.day(4, 1) let forecast = try #require(LocationForecast.estimate( region: .newYork, - report: Self.report(), - asOf: Self.date(4, 1), - calendar: Self.calendar, - plannedStay: nil, + report: PlanningTestSupport.report(asOf: today, counts: [.newYork: 91]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: [], homeRegion: nil), )) - #expect(forecast.elapsedDays == 91) - #expect(forecast.estimatedTotalDays == 365) + #expect(forecast.estimatedTotalDays == DayBounds(exact: 365)) } - @Test func unavailableForAPastReport() { + @Test func homeWorksOnJanuaryFirstWithoutInventingMissingRecordedDays() throws { + let today = PlanningTestSupport.day(1, 1) + let forecast = try #require(LocationForecast.estimate( + region: .california, + report: PlanningTestSupport.report(asOf: today, counts: [:]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: [], homeRegion: .california), + )) + #expect(forecast.yearToDateDays == 0) + #expect(forecast.estimatedTotalDays == DayBounds(exact: 364)) + #expect(forecast.gapPolicy == .home(.california)) + } + + @Test(arguments: [2025, 2027], [Region?.none, .some(.california)]) + func otherReportYearsHaveNoAnnualForecast(year: Int, home: Region?) { #expect(LocationForecast.estimate( region: .newYork, - report: Self.report(year: 2025), - asOf: Self.date(7, 1), - calendar: Self.calendar, - plannedStay: nil, + report: YearReport(year: year, days: [], totals: [:]), + asOf: PlanningTestSupport.date(PlanningTestSupport.day(7, 1)), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: [], homeRegion: home), ) == nil) } - @Test func annualizesElapsedCalendarDayRate() throws { + @Test func usesActualUniqueDaysThroughTodayInsteadOfWholeYearTotals() throws { + let today = PlanningTestSupport.day(7, 1) + let recorded = DayPresence(day: today, regions: [.newYork]) let forecast = try #require(LocationForecast.estimate( region: .newYork, - report: Self.report(), - asOf: Self.date(7, 1), - calendar: Self.calendar, - plannedStay: nil, + report: YearReport(year: 2026, days: [ + recorded, + recorded, + DayPresence(day: PlanningTestSupport.day(8, 1), regions: [.newYork]), + DayPresence(day: PlanningTestSupport.day(7, 1, year: 2025), regions: [.newYork]), + ], totals: [.newYork: 300]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: [], homeRegion: .california), )) - - #expect(forecast.elapsedDays == 182) - #expect(forecast.yearToDateDays == 91) - #expect(forecast.plannedDays == 0) - #expect(forecast.projectedRemainingDays == 91.5) - #expect(forecast.estimatedTotalDays == 183) + #expect(forecast.yearToDateDays == 1) + #expect(forecast.estimatedTotalDays == DayBounds(exact: 1)) } - @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( + @Test func separateNYCReturnsFillHomeGapsAndHistoricalGaps() throws { + let today = PlanningTestSupport.day(9, 13) + let stays = try [ + PlanningTestSupport.stay( region: .newYork, - through: CalendarDay(year: 2026, month: 7, day: 10), + arrival: PlanningTestSupport.day(9, 13), + departure: PlanningTestSupport.day(9, 20), ), - )) - - #expect(forecast.plannedDays == 9) - #expect(forecast.projectedRemainingDays == 87) - #expect(forecast.estimatedTotalDays == 187) + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 15), + latestArrival: PlanningTestSupport.day(10, 17), + departure: PlanningTestSupport.day(10, 30), + latestDeparture: PlanningTestSupport.day(11, 4), + ), + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 10), + latestArrival: PlanningTestSupport.day(12, 12), + departure: PlanningTestSupport.day(12, 20), + latestDeparture: PlanningTestSupport.day(12, 22), + ), + ] + let report = PlanningTestSupport.report( + asOf: today, + counts: [.newYork: 64, .california: 192], + ) + for home in [Region?.none, .some(.california)] { + let planning = PlanningSnapshot(stays: stays, homeRegion: home) + let ny = try #require(LocationForecast.estimate( + region: .newYork, + report: report, + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: planning, + )) + let ca = try #require(LocationForecast.estimate( + region: .california, + report: report, + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: planning, + )) + #expect(ny.elapsedDays == 256) + #expect(ny.plannedDays == DayBounds(lower: 30, upper: 41)) + #expect(ca.plannedDays == DayBounds(exact: 0)) + #expect(ny.estimatedTotalDays == (home == nil + ? DayBounds(lower: 113, upper: 122) : DayBounds(lower: 94, upper: 105))) + #expect(ca.estimatedTotalDays == (home == nil + ? DayBounds(lower: 243, upper: 252) : DayBounds(lower: 260, upper: 271))) + } } - @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( + @Test func opposingStayChoicesProduceBoundsThatAllShortOrAllLongMiss() throws { + let today = PlanningTestSupport.day(12, 20) + let stays = try [ + PlanningTestSupport.stay( region: .newYork, - through: CalendarDay(year: 2027, month: 2, day: 1), + arrival: PlanningTestSupport.day(12, 21), + departure: PlanningTestSupport.day(12, 22), + latestDeparture: PlanningTestSupport.day(12, 24), + ), + PlanningTestSupport.stay( + region: .california, + arrival: PlanningTestSupport.day(12, 26), + departure: PlanningTestSupport.day(12, 27), + latestDeparture: PlanningTestSupport.day(12, 29), ), + ] + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: PlanningTestSupport.report(asOf: today, counts: [.newYork: 177]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: stays, homeRegion: nil), )) - - #expect(forecast.plannedDays == 183) - #expect(forecast.projectedRemainingDays == 0) - #expect(forecast.estimatedTotalDays == 274) + // Mathematical endpoints are 181.5 and 183.5; all-short/all-long both give 182.5. + #expect(forecast.estimatedTotalDays == DayBounds(lower: 181, upper: 184)) } - @Test func anotherRegionsStayReservesItsDaysFromTheProjection() throws { - let withCaliforniaStay = try #require(LocationForecast.estimate( - region: .newYork, - report: Self.report(), - asOf: Self.date(7, 1), - calendar: Self.calendar, - plannedStay: PlannedStay( + @Test func overlappingPlansDeduplicateWithinRegionsAndKeepSharedDays() throws { + let today = PlanningTestSupport.day(12, 20) + let stays = try [ + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 21), + departure: PlanningTestSupport.day(12, 22), + latestDeparture: PlanningTestSupport.day(12, 24), + ), + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 23), + departure: PlanningTestSupport.day(12, 25), + ), + PlanningTestSupport.stay( region: .california, - through: CalendarDay(year: 2026, month: 8, day: 1), + arrival: PlanningTestSupport.day(12, 24), + departure: PlanningTestSupport.day(12, 25), + latestDeparture: PlanningTestSupport.day(12, 27), ), - )) - - #expect(withCaliforniaStay.plannedDays == 0) - #expect(withCaliforniaStay.projectedRemainingDays == 76) - #expect(withCaliforniaStay.estimatedTotalDays == 167) + ] + for home in [Region?.none, .some(.california)] { + for region in [Region.newYork, .california] { + let forecast = try #require(LocationForecast.estimate( + region: region, + report: PlanningTestSupport.report( + asOf: today, + counts: [.newYork: 177, .california: 177], + ), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: stays + stays, homeRegion: home), + )) + let expected: DayBounds = if home != nil { + DayBounds(exact: region == .newYork ? 182 : 185) + } else { + region == .newYork ? DayBounds(lower: 184, upper: 185) : DayBounds( + lower: 182, + upper: 183, + ) + } + #expect(forecast.estimatedTotalDays == expected) + } + } } - @Test func anotherRegionsCrossYearStayLeavesOnlyRecordedDays() throws { + @Test func yearClippingExcludesTodayAndHandlesLeapDayAndFuturePlans() throws { + let today = PlanningTestSupport.day(2, 28, year: 2028) + let stays = try [ + PlanningTestSupport.stay( + region: .newYork, + arrival: today, + departure: PlanningTestSupport.day(2, 29, year: 2028), + ), + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(1, 1, year: 2029), + departure: PlanningTestSupport.day(1, 10, year: 2029), + ), + ] let forecast = try #require(LocationForecast.estimate( region: .newYork, - report: Self.report(), - asOf: Self.date(7, 1), - calendar: Self.calendar, - plannedStay: PlannedStay( - region: .california, - through: CalendarDay(year: 2027, month: 2, day: 1), - ), + report: PlanningTestSupport.report(asOf: today, counts: [:]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: stays, homeRegion: .california), )) + #expect(forecast.plannedDays == DayBounds(exact: 1)) + #expect(forecast.estimatedTotalDays == DayBounds(exact: 1)) + } - #expect(forecast.plannedDays == 0) - #expect(forecast.projectedRemainingDays == 0) - #expect(forecast.estimatedTotalDays == 91) + @Test func yearEndContainsRecordedDaysOnly() throws { + let today = PlanningTestSupport.day(12, 31) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: today, + departure: PlanningTestSupport.day(2, 1, year: 2027), + ) + let forecast = try #require(LocationForecast.estimate( + region: .newYork, + report: PlanningTestSupport.report(asOf: today, counts: [.newYork: 91]), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: [stay], homeRegion: .newYork), + )) + #expect(forecast.estimatedTotalDays == DayBounds(exact: 91)) + #expect(forecast.projectedRemainingDays == DayBounds(exact: 0)) } - @Test func stayEndingTodayDoesNotReserveFutureDays() throws { + @Test(arguments: ["America/New_York", "America/Los_Angeles"]) + func endpointDaysStayFixedWhenTheCalendarTimeZoneChanges(zone: String) throws { + let today = PlanningTestSupport.day(9, 13) + var calendar = PlanningTestSupport.calendar + calendar.timeZone = try #require(TimeZone(identifier: zone)) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: today, + departure: PlanningTestSupport.day(9, 20), + ) let forecast = try #require(LocationForecast.estimate( region: .newYork, - report: Self.report(), - asOf: Self.date(7, 1), - calendar: Self.calendar, - plannedStay: PlannedStay( - region: .california, - through: CalendarDay(year: 2026, month: 7, day: 1), - ), + report: PlanningTestSupport.report(asOf: today, counts: [.newYork: 64]), + asOf: today.startOfDay(in: calendar), + calendar: calendar, + planning: PlanningSnapshot(stays: [stay], homeRegion: .california), )) + #expect(forecast.plannedDays == DayBounds(exact: 7)) + #expect(forecast.estimatedTotalDays == DayBounds(exact: 71)) + } - #expect(forecast.plannedDays == 0) - #expect(forecast.projectedRemainingDays == 91.5) - #expect(forecast.estimatedTotalDays == 183) + @Test func boundsMatchEveryFeasibleScenarioWithIndependentWindowsAndOverlaps() throws { + let today = PlanningTestSupport.day(12, 20) + let elapsed = 354 + let future = today.adding(days: 1).days(through: PlanningTestSupport.day(12, 31)) + let stays = try [ + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 21), + latestArrival: PlanningTestSupport.day(12, 22), + departure: PlanningTestSupport.day(12, 23), + latestDeparture: PlanningTestSupport.day(12, 24), + ), + PlanningTestSupport.stay( + region: .california, + arrival: PlanningTestSupport.day(12, 22), + latestArrival: PlanningTestSupport.day(12, 23), + departure: PlanningTestSupport.day(12, 24), + latestDeparture: PlanningTestSupport.day(12, 25), + ), + PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 24), + departure: PlanningTestSupport.day(12, 24), + latestDeparture: PlanningTestSupport.day(12, 26), + ), + ] + let choices = try stays.map { stay in + try stay.arrival.earliest.days(through: stay.arrival.latest).flatMap { start in + try stay.departure.earliest.days(through: stay.departure.latest).map { end in + try PlanningTestSupport.stay( + region: stay.region, + arrival: start, + departure: end, + stayID: stay.id, + ) + } + } + } + for home in [Region?.none, .some(.california)] { + for region in [Region.newYork, .california, .canada] { + let recorded = region == .canada ? 0 : 177 + let gapWeight = home.map { $0 == region ? elapsed : 0 } ?? recorded + var minimum = Int.max + var maximum = 0 + for first in choices[0] { + for second in choices[1] { + for third in choices[2] { + let scenario = [first, second, third] + var numerator = recorded * elapsed + for day in future { + let present = scenario.filter { $0.longestRange.contains(day) } + if present.contains(where: { $0.region == region }) { + numerator += elapsed + } else if present.isEmpty { + numerator += gapWeight + } + } + minimum = min(minimum, numerator) + maximum = max(maximum, numerator) + } + } + } + let expected = minimum == maximum + ? DayBounds(exact: (minimum + elapsed / 2) / elapsed) + : DayBounds(lower: minimum / elapsed, upper: (maximum + elapsed - 1) / elapsed) + let forecast = try #require(LocationForecast.estimate( + region: region, + report: PlanningTestSupport.report( + asOf: today, + counts: [.newYork: 177, .california: 177], + ), + asOf: PlanningTestSupport.date(today), + calendar: PlanningTestSupport.calendar, + planning: PlanningSnapshot(stays: stays, homeRegion: home), + )) + #expect(forecast.estimatedTotalDays == expected) + } + } } } diff --git a/Where/WhereCore/Tests/PlannedHomeIntervalTests.swift b/Where/WhereCore/Tests/PlannedHomeIntervalTests.swift new file mode 100644 index 000000000..a4e898905 --- /dev/null +++ b/Where/WhereCore/Tests/PlannedHomeIntervalTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import WhereCore + +struct PlannedHomeIntervalTests { + @Test func groupsHomeGapsAndSplitsAtUncertainTripEdges() throws { + let today = PlanningTestSupport.day(12, 25) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 28), + latestArrival: PlanningTestSupport.day(12, 29), + departure: PlanningTestSupport.day(12, 30), + latestDeparture: PlanningTestSupport.day(12, 31), + ) + let intervals = PlanningSnapshot(stays: [stay], homeRegion: .california) + .homeIntervals(intersecting: 2026, asOf: today) + try #require(intervals.count == 3) + #expect(intervals[0].start == PlanningTestSupport.day(12, 26)) + #expect(intervals[0].end == PlanningTestSupport.day(12, 27)) + #expect(intervals[0].certainty == .certain) + #expect(intervals[0].dayCount == DayBounds(exact: 2)) + #expect(intervals[1].start == PlanningTestSupport.day(12, 28)) + #expect(intervals[1].certainty == .possible) + #expect(intervals[1].dayCount == DayBounds(lower: 0, upper: 1)) + #expect(intervals[2].start == PlanningTestSupport.day(12, 31)) + #expect(intervals[2].dayCount == DayBounds(lower: 0, upper: 1)) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift index 5e7f26327..209e865c4 100644 --- a/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift +++ b/Where/WhereCore/Tests/PlannedStayCoordinatorTests.swift @@ -4,128 +4,195 @@ 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 let now = Date(timeIntervalSince1970: 1_780_000_000) - private static func makeCoordinator( - store: SwiftDataStore, - now: Date = Self.now, - ) -> PlannedStayCoordinator { - PlannedStayCoordinator(store: store, calendar: calendar, now: { now }) + private static func coordinator(store: SwiftDataStore) -> PlannedStayCoordinator { + PlannedStayCoordinator(store: store, now: { now }) } - @Test func setAndClearRoundTripThroughTheStore() async throws { + @Test func independentStaysSurviveEditingAndDeletingAnotherStay() 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 coordinator = Self.coordinator(store: store) + let october = try PlannedStayTestSupport.stay() + let december = try PlannedStayTestSupport.stay( + arrival: .init(year: 2026, month: 12, day: 10), + departure: .init(year: 2026, month: 12, day: 20), + ) + try await coordinator.create(october) + try await coordinator.create(december) + let edited = try PlannedStayTestSupport.stay(id: october.id, region: .california) + try await coordinator.update(edited) + + #expect(try await coordinator.snapshot().stays == [edited, december]) + try await coordinator.delete(stayID: october.id) + #expect(try await coordinator.snapshot().stays == [december]) let records = try await store.plannedStayRecords() - #expect(records.count == 1) - #expect(records.first?.value == nil) + #expect(records.count == 2) + #expect(records.first { $0.stayID == october.id }?.value == nil) } - @Test func expiredStayWritesATombstoneEvenBeforeForecastEligibility() async throws { + @Test func readingRetainsCompletedPlansAndDoesNotExpireRecords() 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 coordinator = Self.coordinator(store: store) + let completed = try PlannedStayTestSupport.stay( + arrival: .init(year: 2025, month: 1, day: 1), + departure: .init(year: 2025, month: 1, day: 10), ) - let expiredRecord = try #require(await store.plannedStayRecords().first) + try await coordinator.create(completed) + let before = try await store.plannedStayRecords() - #expect(try await coordinator.active() == nil) - let tombstone = try #require(await store.plannedStayRecords().first) - #expect(tombstone.value == nil) - #expect(tombstone.updatedAt > expiredRecord.updatedAt) + #expect(try await coordinator.snapshot().stays == [completed]) + #expect(try await store.plannedStayRecords() == before) } - @Test func staleExpiryCannotOverwriteANewerStay() async throws { + @Test func newestSyncedRevisionWinsSeparatelyForEachStay() 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 older = try PlannedStayTestSupport.stay(region: .california) + let newer = try PlannedStayTestSupport.stay(id: older.id, region: .newYork) + let unrelated = try PlannedStayTestSupport.stay(region: .canada) + let oldRecord = try PlannedStayTestSupport.record(stay: older, updatedAt: Self.now) + let newRecord = try PlannedStayTestSupport.record( + stay: newer, + updatedAt: Self.now.addingTimeInterval(1), ) - 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), + let unrelatedRecord = try PlannedStayTestSupport.record( + stay: unrelated, + updatedAt: Self.now, ) + try await store.perform { + try await store.restorePlannedStayRecord(newRecord) + try await store.restorePlannedStayRecord(oldRecord) + try await store.restorePlannedStayRecord(unrelatedRecord) + } - #expect(activeStay == futureStay) - #expect(try await coordinator.active() == futureStay) + let snapshot = try await Self.coordinator(store: store).snapshot() + #expect(Set(snapshot.stays) == Set([newer, unrelated])) } - @Test func newestSyncedRevisionWinsDeterministically() async throws { + @Test func deletionAdvancesPastAFutureRevisionAndDefeatsItsDelayedReimport() 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, + let coordinator = Self.coordinator(store: store) + let stay = try PlannedStayTestSupport.stay() + let future = try PlannedStayTestSupport.record( + stay: stay, + updatedAt: Self.now.addingTimeInterval(60), ) - try await store.perform { - try await store.restorePlannedStayRecord(newer) - try await store.restorePlannedStayRecord(older) + try await store.perform { try await store.restorePlannedStayRecord(future) } + try await coordinator.delete(stayID: stay.id) + let tombstone = try #require(await store.plannedStayRecords().first) + #expect(tombstone.updatedAt > future.updatedAt) + #expect(tombstone.value == nil) + + try await store.perform { try await store.restorePlannedStayRecord(future) } + #expect(try await coordinator.snapshot().stays.isEmpty) + } + + @Test func staleEditorCannotRecreateADeletedStay() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.coordinator(store: store) + let stay = try PlannedStayTestSupport.stay() + try await coordinator.create(stay) + try await coordinator.delete(stayID: stay.id) + + await #expect(throws: PlannedStayCoordinator.PlanningError.stayNotFound) { + try await coordinator.update(stay) } + #expect(try await coordinator.snapshot().stays.isEmpty) + } - let coordinator = Self.makeCoordinator(store: store) - #expect(try await coordinator.active() == newer.value) + @Test func retryingAnIdenticalCreateKeepsTheRevision() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.coordinator(store: store) + let stay = try PlannedStayTestSupport.stay() + try await coordinator.create(stay) + let before = try await store.plannedStayRecords() + try await coordinator.create(stay) + + #expect(try await store.plannedStayRecords() == before) } - @Test func localWriteAdvancesPastAFutureDatedSyncedRevision() async throws { + @Test func createRetryCannotOverwriteAnEditOrRestoreADeletedIdentity() async throws { let store = try SwiftDataStore.inMemory() - let synced = PlannedStayRecord( + let coordinator = Self.coordinator(store: store) + let original = try PlannedStayTestSupport.stay() + let edited = try PlannedStayTestSupport.stay(id: original.id, region: .california) + try await coordinator.create(original) + try await coordinator.update(edited) + + await #expect(throws: PlannedStayCoordinator.PlanningError.stayAlreadyExists) { + try await coordinator.create(original) + } + #expect(try await coordinator.snapshot().stays == [edited]) + try await coordinator.delete(stayID: original.id) + await #expect(throws: PlannedStayCoordinator.PlanningError.stayAlreadyExists) { + try await coordinator.create(original) + } + #expect(try await coordinator.snapshot().stays.isEmpty) + } + + @Test func historicalSelectionDefeatsAnOlderSyncedHomeChoice() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.coordinator(store: store) + let remote = try HomeRegionRecord( id: UUID(), - value: PlannedStay( - region: .newYork, - through: CalendarDay(year: 2026, month: 8, day: 1), - ), + region: .california, updatedAt: Self.now.addingTimeInterval(60), ) + try await store.perform { try await store.restoreHomeRegionRecord(remote) } + try await coordinator.setHomeRegion(nil) + let tombstone = try #require(await store.homeRegionRecords().first) + #expect(tombstone.updatedAt > remote.updatedAt) + #expect(tombstone.region == nil) + try await store.perform { try await store.restoreHomeRegionRecord(remote) } + + #expect(try await coordinator.snapshot().homeRegion == nil) + } + + @Test func homeAndUntrackedDestinationsDoNotChangeTrackingOrRecordedHistory() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.coordinator(store: store) + let texas = try #require(Region(rawValue: "us-TX")) + let tracking = try await store.trackedRegions() + let stay = try PlannedStayTestSupport.stay(region: texas) + try await coordinator.create(stay) + try await coordinator.setHomeRegion(texas) + + #expect(try await coordinator.snapshot().homeRegion == texas) + try await coordinator.setHomeRegion(.california) + try await coordinator.setHomeRegion(nil) + #expect(try await coordinator.snapshot().stays == [stay]) + #expect(try await store.trackedRegions() == tracking) + #expect(try await store.allManualDays().isEmpty) + #expect(try await store.allSamples().isEmpty) + } + + @Test func resetClearsBothPlanningRegisters() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.coordinator(store: store) + try await coordinator.create(PlannedStayTestSupport.stay()) + try await coordinator.setHomeRegion(.california) try await store.perform { - try await store.restorePlannedStayRecord(synced) + _ = try await store.rotateDataGeneration( + reason: .accountReset, + changedBy: .init(rawValue: UUID()), + at: Self.now, + ) } - let coordinator = Self.makeCoordinator(store: store) - try await coordinator.clear() - let tombstone = try #require(await store.plannedStayRecords().first) - #expect(tombstone.updatedAt > synced.updatedAt) + let snapshot = try await coordinator.snapshot() + #expect(snapshot.stays.isEmpty) + #expect(snapshot.homeRegion == nil) + #expect(try await store.plannedStayRecords().isEmpty) + #expect(try await store.homeRegionRecords().isEmpty) + } - try await store.perform { - try await store.restorePlannedStayRecord(synced) + @Test func homeRejectsTheUnattributedOtherRegion() async throws { + let store = try SwiftDataStore.inMemory() + let coordinator = Self.coordinator(store: store) + + await #expect(throws: PlannedStay.ValidationError.unsupportedRegion) { + try await coordinator.setHomeRegion(.other) } - #expect(try await coordinator.active() == nil) + #expect(try await store.homeRegionRecords().isEmpty) } } diff --git a/Where/WhereCore/Tests/PlannedStayIntervalTests.swift b/Where/WhereCore/Tests/PlannedStayIntervalTests.swift new file mode 100644 index 000000000..3872cf7db --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayIntervalTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import WhereCore + +struct PlannedStayIntervalTests { + @Test func clipsThePossibleEnvelopeAndCertainCoreIndependently() throws { + let today = PlanningTestSupport.day(12, 29) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 30), + latestArrival: PlanningTestSupport.day(1, 2, year: 2027), + departure: PlanningTestSupport.day(1, 5, year: 2027), + latestDeparture: PlanningTestSupport.day(1, 8, year: 2027), + ) + let planning = PlanningSnapshot(stays: [stay], homeRegion: nil) + let thisYear = try #require(planning.stayIntervals(intersecting: 2026, asOf: today).first) + #expect(thisYear.stayID == stay.id) + #expect(thisYear.start == PlanningTestSupport.day(12, 30)) + #expect(thisYear.end == PlanningTestSupport.day(12, 31)) + #expect(thisYear.dayCount == DayBounds(lower: 0, upper: 2)) + #expect(thisYear.certainRange == nil) + let nextYear = try #require(planning.stayIntervals(intersecting: 2027, asOf: today).first) + #expect(nextYear.dayCount == DayBounds(lower: 4, upper: 8)) + #expect(planning.stayIntervals(intersecting: 2025, asOf: today).isEmpty) + } + + @Test func clipsAnOngoingStayAfterTodayWithoutMovingItsStoredDates() throws { + let today = PlanningTestSupport.day(9, 13) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(9, 1), + latestArrival: PlanningTestSupport.day(9, 10), + departure: PlanningTestSupport.day(9, 20), + latestDeparture: PlanningTestSupport.day(9, 25), + ) + let interval = try #require(PlanningSnapshot(stays: [stay], homeRegion: nil) + .stayIntervals(intersecting: 2026, asOf: today).first) + #expect(interval.start == PlanningTestSupport.day(9, 14)) + #expect(interval.dayCount == DayBounds(lower: 7, upper: 12)) + #expect(stay.arrival.earliest == PlanningTestSupport.day(9, 1)) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayOverlapTests.swift b/Where/WhereCore/Tests/PlannedStayOverlapTests.swift new file mode 100644 index 000000000..c79eeded0 --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayOverlapTests.swift @@ -0,0 +1,50 @@ +import Testing +@testable import WhereCore + +struct PlannedStayOverlapTests { + @Test func flagsPossibleSharedDatesWithoutClaimingTheyAreCertain() throws { + let first = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 15), + latestArrival: PlanningTestSupport.day(10, 17), + departure: PlanningTestSupport.day(10, 30), + latestDeparture: PlanningTestSupport.day(11, 4), + ) + let second = try PlanningTestSupport.stay( + region: .california, + arrival: PlanningTestSupport.day(11, 1), + latestArrival: PlanningTestSupport.day(11, 3), + departure: PlanningTestSupport.day(11, 5), + latestDeparture: PlanningTestSupport.day(11, 8), + ) + let planning = PlanningSnapshot(stays: [first, second], homeRegion: nil) + let overlap = try #require(planning.overlaps(asOf: PlanningTestSupport.day(9, 13)).first) + #expect(Set([overlap.firstStayID, overlap.secondStayID]) == [first.id, second.id]) + #expect(overlap.possibleRange == PlanningTestSupport.day(11, 1) ... PlanningTestSupport.day( + 11, + 4, + )) + #expect(overlap.certainRange == nil) + #expect(planning.overlaps(asOf: PlanningTestSupport.day(11, 8)).isEmpty) + } + + @Test func keepsSameRegionOverlapVisibleAndClipsPastSharedDays() throws { + let first = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 1), + departure: PlanningTestSupport.day(10, 10), + ) + let second = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 5), + departure: PlanningTestSupport.day(10, 15), + ) + let overlap = try #require(PlanningSnapshot(stays: [first, second], homeRegion: nil) + .overlaps(asOf: PlanningTestSupport.day(10, 7)).first) + #expect(overlap.possibleRange == PlanningTestSupport.day(10, 8) ... PlanningTestSupport.day( + 10, + 10, + )) + #expect(overlap.certainRange == overlap.possibleRange) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayRecordTests.swift b/Where/WhereCore/Tests/PlannedStayRecordTests.swift index dbfa88506..d5f1a5705 100644 --- a/Where/WhereCore/Tests/PlannedStayRecordTests.swift +++ b/Where/WhereCore/Tests/PlannedStayRecordTests.swift @@ -3,20 +3,54 @@ import Testing @testable import WhereCore struct PlannedStayRecordTests { - @Test func newerUsesTheIdentifierToBreakTimestampTies() throws { + @Test func newerUsesTheRevisionIdentifierToBreakTimestampTies() throws { let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let stayID = PlannedStay.ID(rawValue: UUID()) let lower = try PlannedStayRecord( id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + stayID: stayID, value: nil, updatedAt: updatedAt, ) let higher = try PlannedStayRecord( id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + stayID: stayID, value: nil, updatedAt: updatedAt, ) - #expect(PlannedStayRecord.newer(higher, than: lower)) - #expect(!PlannedStayRecord.newer(lower, than: higher)) + #expect(PlannedStayRecord.newer(lower, than: higher) == false) + } + + @Test func revisionsCannotReplaceAnotherStayIdentity() throws { + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 1), + departure: PlanningTestSupport.day(10, 3), + ) + #expect(throws: PlannedStayRecord.ValidationError.mismatchedStayID) { + try PlannedStayRecord( + id: UUID(), + stayID: .init(rawValue: UUID()), + value: stay, + updatedAt: PlanningTestSupport.date(PlanningTestSupport.day(9, 1)), + ) + } + } + + @Test func clearingRetainsStableIdentityThroughCodable() throws { + let record = try PlannedStayRecord( + id: UUID(), + stayID: .init(rawValue: UUID()), + value: nil, + updatedAt: PlanningTestSupport.date(PlanningTestSupport.day(9, 1)), + ) + let decoded = try JSONDecoder().decode( + PlannedStayRecord.self, + from: JSONEncoder().encode(record), + ) + try decoded.validate() + #expect(decoded == record) + #expect(decoded.value == nil) } } diff --git a/Where/WhereCore/Tests/PlannedStayTestSupport.swift b/Where/WhereCore/Tests/PlannedStayTestSupport.swift new file mode 100644 index 000000000..d78d08883 --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayTestSupport.swift @@ -0,0 +1,28 @@ +import Foundation +import RegionKit +@testable import WhereCore + +/// Shared exact-date fixtures for planning persistence and backup tests. +enum PlannedStayTestSupport { + static func stay( + id: PlannedStay.ID = .init(rawValue: UUID()), + region: Region = .newYork, + arrival: CalendarDay = .init(year: 2026, month: 10, day: 10), + departure: CalendarDay = .init(year: 2026, month: 10, day: 24), + ) throws -> PlannedStay { + try PlannedStay( + id: id, + region: region, + arrival: .init(earliest: arrival, latest: arrival), + departure: .init(earliest: departure, latest: departure), + ) + } + + static func record( + stay: PlannedStay, + revisionID: UUID = UUID(), + updatedAt: Date = .init(timeIntervalSince1970: 1_700_000_000), + ) throws -> PlannedStayRecord { + try PlannedStayRecord(id: revisionID, stayID: stay.id, value: stay, updatedAt: updatedAt) + } +} diff --git a/Where/WhereCore/Tests/PlannedStayTests.swift b/Where/WhereCore/Tests/PlannedStayTests.swift new file mode 100644 index 000000000..e5518310a --- /dev/null +++ b/Where/WhereCore/Tests/PlannedStayTests.swift @@ -0,0 +1,82 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct PlannedStayTests { + @Test func independentWindowsHaveInclusiveNestedBounds() throws { + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 15), + latestArrival: PlanningTestSupport.day(10, 17), + departure: PlanningTestSupport.day(10, 30), + latestDeparture: PlanningTestSupport.day(11, 4), + ) + #expect(stay.dayCount == DayBounds(lower: 14, upper: 21)) + #expect(stay.shortestRange == PlanningTestSupport.day(10, 17) ... PlanningTestSupport.day( + 10, + 30, + )) + #expect(stay.longestRange == PlanningTestSupport.day(10, 15) ... PlanningTestSupport.day( + 11, + 4, + )) + } + + @Test func rejectsCrossedWindowsAndImpossibleDays() throws { + #expect(throws: PlannedStay.ValidationError.reversedWindow) { + try PlannedStay.DateWindow( + earliest: PlanningTestSupport.day(10, 17), + latest: PlanningTestSupport.day(10, 15), + ) + } + #expect(throws: PlannedStay.ValidationError.arrivalAfterDeparture) { + try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 15), + latestArrival: PlanningTestSupport.day(10, 17), + departure: PlanningTestSupport.day(10, 16), + latestDeparture: PlanningTestSupport.day(10, 20), + ) + } + #expect(throws: PlannedStay.ValidationError.invalidDay) { + try PlannedStay( + id: .init(rawValue: UUID()), + region: .newYork, + arrival: .init(exact: PlanningTestSupport.day(2, 30)), + departure: .init(exact: PlanningTestSupport.day(3, 1)), + ) + } + } + + @Test func rejectsUnsupportedDestinations() throws { + let unknown = try JSONDecoder().decode(Region.self, from: Data("\"unknown-region\"".utf8)) + for region in [Region.other, unknown] { + #expect(throws: PlannedStay.ValidationError.unsupportedRegion) { + try PlanningTestSupport.stay( + region: region, + arrival: PlanningTestSupport.day(10, 15), + departure: PlanningTestSupport.day(10, 15), + ) + } + } + } + + @Test func currentWireShapeUsesStableUUIDAndExplicitWindowFields() throws { + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(10, 15), + departure: PlanningTestSupport.day(10, 15), + ) + let data = try JSONEncoder().encode(stay) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["id"] as? String == stay.id.rawValue.uuidString) + let arrival = try #require(object["arrival"] as? [String: Any]) + #expect(Set(arrival.keys) == ["earliest", "latest"]) + let decoded = try JSONDecoder().decode(PlannedStay.self, from: data) + try decoded.validate() + #expect(decoded == stay) + #expect(stay.dayCount == DayBounds(exact: 1)) + #expect(stay.arrival.isExact) + } +} diff --git a/Where/WhereCore/Tests/PlanningDayPresenceTests.swift b/Where/WhereCore/Tests/PlanningDayPresenceTests.swift new file mode 100644 index 000000000..fc3f92551 --- /dev/null +++ b/Where/WhereCore/Tests/PlanningDayPresenceTests.swift @@ -0,0 +1,62 @@ +import RegionKit +import Testing +@testable import WhereCore + +struct PlanningDayPresenceTests { + @Test func distinguishesCertainPlansPossiblePlansAndHomeAssumptions() throws { + let today = PlanningTestSupport.day(12, 25) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 28), + latestArrival: PlanningTestSupport.day(12, 29), + departure: PlanningTestSupport.day(12, 30), + latestDeparture: PlanningTestSupport.day(12, 31), + ) + let planning = PlanningSnapshot(stays: [stay], homeRegion: .california) + let gap = planning.plannedPresence(on: PlanningTestSupport.day(12, 27), asOf: today) + #expect(gap.membership(in: .newYork) == nil) + #expect(gap.membership(in: .california) == .homeAssumed(.certain)) + let possible = planning.plannedPresence(on: PlanningTestSupport.day(12, 28), asOf: today) + #expect(possible.membership(in: .newYork) == .planned(.possible)) + #expect(possible.membership(in: .california) == .homeAssumed(.possible)) + let certain = planning.plannedPresence(on: PlanningTestSupport.day(12, 29), asOf: today) + #expect(certain.membership(in: .newYork) == .planned(.certain)) + #expect(certain.membership(in: .california) == nil) + } + + @Test func homePlanPreservesExplicitAndAssumedProvenanceSeparately() throws { + let today = PlanningTestSupport.day(12, 25) + let stay = try PlanningTestSupport.stay( + region: .california, + arrival: PlanningTestSupport.day(12, 28), + latestArrival: PlanningTestSupport.day(12, 29), + departure: PlanningTestSupport.day(12, 30), + ) + let planning = PlanningSnapshot(stays: [stay], homeRegion: .california) + let presence = planning.plannedPresence(on: PlanningTestSupport.day(12, 28), asOf: today) + // CA is present in every scenario. Raw fields retain the uncertain + // explicit plan and alternative assumption without weakening effective coverage. + #expect(presence.membership(in: .california) == .homeAssumed(.certain)) + #expect(presence.certainRegions.isEmpty) + #expect(presence.possibleRegions == [.california]) + #expect(presence.homeAssumption?.region == .california) + #expect(presence.homeAssumption?.certainty == .possible) + } + + @Test func anotherPossibleDestinationKeepsHomePresenceUncertain() throws { + let today = PlanningTestSupport.day(12, 25) + let stays = try [Region.california, .newYork].map { region in + try PlanningTestSupport.stay( + region: region, + arrival: PlanningTestSupport.day(12, 28), + latestArrival: PlanningTestSupport.day(12, 29), + departure: PlanningTestSupport.day(12, 30), + ) + } + let presence = PlanningSnapshot(stays: stays, homeRegion: .california) + .plannedPresence(on: PlanningTestSupport.day(12, 28), asOf: today) + #expect(presence.membership(in: .california) == .planned(.possible)) + #expect(presence.membership(in: .newYork) == .planned(.possible)) + #expect(presence.homeAssumption?.certainty == .possible) + } +} diff --git a/Where/WhereCore/Tests/PlanningRegionSummaryTests.swift b/Where/WhereCore/Tests/PlanningRegionSummaryTests.swift new file mode 100644 index 000000000..35f68faa1 --- /dev/null +++ b/Where/WhereCore/Tests/PlanningRegionSummaryTests.swift @@ -0,0 +1,59 @@ +import RegionKit +import Testing +@testable import WhereCore + +struct PlanningRegionSummaryTests { + @Test func unionsOverlappingAndDuplicatePlansAndExcludesTodayFromHomeGaps() throws { + let today = PlanningTestSupport.day(12, 25) + let uncertain = try PlanningTestSupport.stay( + region: .newYork, + arrival: today, + latestArrival: PlanningTestSupport.day(12, 27), + departure: PlanningTestSupport.day(12, 28), + latestDeparture: PlanningTestSupport.day(12, 30), + ) + let exact = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(12, 28), + departure: PlanningTestSupport.day(12, 29), + ) + let summaries = PlanningSnapshot( + stays: [uncertain, uncertain, exact], + homeRegion: .california, + ) + .regionSummaries( + in: PlanningTestSupport.day(12, 24) ... PlanningTestSupport.day(12, 31), + asOf: today, + ) + #expect(summaries.map(\.region) == Region.inCanonicalOrder([.newYork, .california])) + let ny = try #require(summaries.first { $0.region == .newYork }) + let ca = try #require(summaries.first { $0.region == .california }) + #expect(ny.plannedDays == DayBounds(lower: 3, upper: 5)) + #expect(ny.homeDays == DayBounds(exact: 0)) + #expect(ca.plannedDays == DayBounds(exact: 0)) + #expect(ca.homeDays == DayBounds(lower: 1, upper: 3)) + } + + @Test func retainsRawHomeAndExplicitBoundsWhenEffectivePresenceIsCertain() throws { + let today = PlanningTestSupport.day(12, 25) + let stay = try PlanningTestSupport.stay( + region: .california, + arrival: PlanningTestSupport.day(12, 28), + latestArrival: PlanningTestSupport.day(12, 29), + departure: PlanningTestSupport.day(12, 30), + latestDeparture: PlanningTestSupport.day(12, 31), + ) + let summary = try #require(PlanningSnapshot(stays: [stay], homeRegion: .california) + .regionSummaries(in: today ... PlanningTestSupport.day(12, 31), asOf: today).first) + #expect(summary.plannedDays == DayBounds(lower: 2, upper: 4)) + #expect(summary.homeDays == DayBounds(lower: 2, upper: 4)) + } + + @Test func omitsEmptyRegionsAndHistoricalRanges() { + let today = PlanningTestSupport.day(12, 25) + #expect(PlanningSnapshot(stays: [], homeRegion: nil) + .regionSummaries(in: today ... PlanningTestSupport.day(12, 31), asOf: today).isEmpty) + #expect(PlanningSnapshot(stays: [], homeRegion: .california) + .regionSummaries(in: PlanningTestSupport.day(12, 1) ... today, asOf: today).isEmpty) + } +} diff --git a/Where/WhereCore/Tests/PlanningSnapshotTests.swift b/Where/WhereCore/Tests/PlanningSnapshotTests.swift new file mode 100644 index 000000000..9730d5b46 --- /dev/null +++ b/Where/WhereCore/Tests/PlanningSnapshotTests.swift @@ -0,0 +1,44 @@ +import RegionKit +import Testing +@testable import WhereCore + +struct PlanningSnapshotTests { + @Test func projectionsNeverFillTodayOrHistoricalGaps() throws { + let today = PlanningTestSupport.day(9, 13) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(9, 1), + departure: PlanningTestSupport.day(9, 20), + ) + let planning = PlanningSnapshot(stays: [stay], homeRegion: .california) + for day in [today.adding(days: -1), today] { + let presence = planning.plannedPresence(on: day, asOf: today) + #expect(presence.possibleRegions.isEmpty) + #expect(presence.certainRegions.isEmpty) + #expect(presence.homeAssumption == nil) + } + #expect(planning.plannedPresence(on: today.adding(days: 1), asOf: today) + .membership(in: .newYork) == .planned(.certain)) + } + + @Test func historicalPolicyLeavesFutureGapsUnassigned() { + let today = PlanningTestSupport.day(9, 13) + let planning = PlanningSnapshot(stays: [], homeRegion: nil) + #expect(planning.plannedPresence(on: today.adding(days: 1), asOf: today) + .homeAssumption == nil) + #expect(planning.homeIntervals(intersecting: 2026, asOf: today).isEmpty) + } + + @Test func completedPlansRemainStoredButDoNotProject() throws { + let today = PlanningTestSupport.day(9, 13) + let stay = try PlanningTestSupport.stay( + region: .newYork, + arrival: PlanningTestSupport.day(8, 1), + departure: PlanningTestSupport.day(8, 20), + ) + let planning = PlanningSnapshot(stays: [stay], homeRegion: .california) + #expect(planning.stays == [stay]) + #expect(planning.stayIntervals(intersecting: 2026, asOf: today).isEmpty) + #expect(planning.overlaps(asOf: today).isEmpty) + } +} diff --git a/Where/WhereCore/Tests/PlanningTestSupport.swift b/Where/WhereCore/Tests/PlanningTestSupport.swift new file mode 100644 index 000000000..8654e7e00 --- /dev/null +++ b/Where/WhereCore/Tests/PlanningTestSupport.swift @@ -0,0 +1,51 @@ +import Foundation +import RegionKit +@testable import WhereCore + +/// Shared calendar and input builders for planning tests; every clock is fixed. +enum PlanningTestSupport { + static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + return calendar + } + + static func day(_ month: Int, _ day: Int, year: Int = 2026) -> CalendarDay { + CalendarDay(year: year, month: month, day: day) + } + + static func date(_ day: CalendarDay) -> Date { + day.startOfDay(in: calendar) + } + + static func stay( + region: Region, + arrival: CalendarDay, + latestArrival: CalendarDay? = nil, + departure: CalendarDay, + latestDeparture: CalendarDay? = nil, + stayID: PlannedStay.ID = .init(rawValue: UUID()), + ) throws -> PlannedStay { + try PlannedStay( + id: stayID, + region: region, + arrival: .init(earliest: arrival, latest: latestArrival ?? arrival), + departure: .init(earliest: departure, latest: latestDeparture ?? departure), + ) + } + + static func report(asOf today: CalendarDay, counts: [Region: Int]) -> YearReport { + let first = CalendarDay.yearRange(today.year).lowerBound + var byDay: [CalendarDay: Set] = [:] + for (region, count) in counts { + for offset in 0 ..< count { + byDay[first.adding(days: offset), default: []].insert(region) + } + } + return YearReport( + year: today.year, + days: byDay.map { DayPresence(day: $0.key, regions: $0.value) }, + totals: counts, + ) + } +} diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 1d30d2807..1de3921c4 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -9,6 +9,70 @@ import Testing /// covered by `StoreChangeBroadcasterTests`; here we assert the *store* fires it /// on a committed `perform` and stays silent on a rolled-back one. struct SwiftDataStoreTests { + @Test func planningRowsRoundTripFlexibleWindowsAndRejectPartialPayloads() throws { + let stay = try PlannedStay( + id: .init(rawValue: UUID()), + region: .newYork, + arrival: .init( + earliest: .init(year: 2026, month: 10, day: 10), + latest: .init(year: 2026, month: 10, day: 12), + ), + departure: .init( + earliest: .init(year: 2026, month: 10, day: 20), + latest: .init(year: 2026, month: 10, day: 25), + ), + ) + let record = try PlannedStayTestSupport.record(stay: stay) + let row = SDPlannedStay(value: record, generationID: .initial) + #expect(row.toValue() == record) + row.departureEarliestDayKey = "2026-10-01" + #expect(row.toValue() == nil) + row.departureEarliestDayKey = nil + #expect(row.toValue() == nil) + + let incompleteLegacy = SDPlannedStay() + incompleteLegacy.id = UUID() + incompleteLegacy.updatedAt = Date() + #expect(incompleteLegacy.toValue() == nil) + } + + @Test func homeRowsDistinguishHistoricalTombstonesFromCorruption() throws { + let tombstone = try HomeRegionRecord(id: UUID(), region: nil, updatedAt: Date()) + let row = SDHomeRegion(value: tombstone, generationID: .initial) + #expect(row.toValue() == tombstone) + row.regionID = "not-a-region" + #expect(row.toValue() == nil) + row.regionID = "other" + #expect(row.toValue() == nil) + row.regionID = "us-CA" + row.updatedAt = nil + #expect(row.toValue() == nil) + } + + @Test func delayedPlanningRowsCannotReappearAcrossAReset() async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let stay = try PlannedStayTestSupport.record(stay: PlannedStayTestSupport.stay()) + let home = try HomeRegionRecord(id: UUID(), region: .california, updatedAt: Date()) + try await store.perform { + try await store.restorePlannedStayRecord(stay) + try await store.restoreHomeRegionRecord(home) + _ = try await store.rotateDataGeneration( + reason: .accountReset, + changedBy: Self.generationWriterID, + at: Date(), + ) + } + let remote = ModelContext(container) + remote.insert(SDPlannedStay(value: stay, generationID: .initial)) + remote.insert(SDHomeRegion(value: home, generationID: .initial)) + try remote.save() + + let reader = SwiftDataStore(modelContainer: container) + #expect(try await reader.plannedStayRecords().isEmpty) + #expect(try await reader.homeRegionRecords().isEmpty) + } + @Test func inspectorStoreURLUsesTheResolvedAppGroupRoot() { let groupURL = FileManager.default.temporaryDirectory.appending( path: "where-group-\(UUID().uuidString)", diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index a6cc79584..d673ba4cd 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -91,11 +91,16 @@ Layering, localization, preview, and testing conventions live in the feature minimum and active-scene visibility: the first foreground-visible `MainTabs` reveal stays covered when headless promotion coalesces or the first hold is interrupted, while warm resumes never replay it. -- Keep planned-stay persistence, forecast math, and location verification in WhereCore. - `LocationForecastModel` mirrors the register and the advisory check for the Locations, calendar, - and timeline surfaces. -- Hide every forecast and planned-stay visualization behind - `YearReportModel.showsEstimatedTimeAndPlanning`; persist Off only after clearing the synced plan. +- Keep itinerary persistence, forecast bounds, overlaps, and Home projections in WhereCore. + `LocationForecastModel` mirrors one planning snapshot through store-change refreshes. + Complete the latest started read when its requesting sheet disappears; keep older results from replacing it. +- Keep each planned stay independently editable by its stable ID. Keep Home separate from tracked regions. +- Gate forecast visualizations with `YearReportModel.showsEstimatedTimeAndPlanning`. + Hiding estimates must preserve every stay and the Home setting. Settings can still open the planner. +- Keep calendar and timeline projections separate from recorded history. Label possible dates and Home assumptions. + Guard: `LocationForecastModelTests` and `EstimatedTimeAndPlanningSettingsModelTests`. +- Keep future itinerary years selectable while annual estimates remain current-year-only. + Guard: `YearReportModelTests.futureItineraryYearsRemainReachableWithoutEnablingAnnualEstimates`. - 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 90ef8a0e8..35ff4fa1b 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -113,7 +113,7 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `refreshWidgetSnapshot()`). It holds no presentation state of its own. - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReportDetails`, its `LoadState`, manual-day edit intents, and the shared - **`LocationForecastModel`** planned-stay mirror and current-location advisory), plus + **`LocationForecastModel`** itinerary and Home-assumption mirror), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** (Settings export progress and failures), **`RemindersSettingsModel`** (notification prefs), @@ -342,15 +342,34 @@ 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 three complete months, Locations can show a collapsible annual estimate from the recorded pace. -The user can plan a stay in one of the displayed regions. The shared estimate uses an adaptive -passport-visa endorsement. A neutral microprint border repeats the silhouettes of the two main -location cards around the security print and annual seal. Region-tinted rows use a solid and -hatched rule to distinguish recorded time from the projection. -A focused region calendar puts the same open endorsement after the current month. The calendar -shows planned future days with a continuous hatched band. This band is distinct from recorded -presence. Appearance settings hide every estimate and planning view only after the app clears the -synchronized plan. +The Locations toolbar opens **Planned stays**. Each stay has an independent identity, +a destination, and inclusive arrival and last-day windows. Exact dates use one day +per boundary. Flexible dates use earliest and latest choices. The latest arrival +cannot follow the earliest last day. Overlap labels identify shared dates without +blocking a save. The planner retains completed stays in its Past section. + +The scene shares one planning snapshot. Closing the planner does not discard a +started read or leave forecasts loading. A newer read still supersedes an older result. + +The planner offers **Past travel pattern** or one **Home region** for future gaps. +Home does not change the tracked-region selection. Untracked destinations link to +region settings. Historical estimates start April 1. Home estimates are available +throughout the current year. Plans can extend into future years. The visible-year +selector includes those years for Calendar and Timeline navigation. + +The estimate panel shows recorded, planned, and assumed or projected day counts. +Flexible plans produce independent per-region ranges. A range describes possible +date choices, not a statistical confidence interval. The panel includes destinations +with no recorded visits. Recorded location-card rankings remain independent of plans. + +Calendar and Timeline mark explicit plans, possible dates, and Home assumptions. +Each timeline stay opens its own editor. Future intent never becomes recorded history +as dates pass. Appearance settings can hide estimates without removing plans or Home. +The Estimated Time settings page also opens the planner. + +The shared estimate retains its passport-visa endorsement. A neutral microprint border +repeats the two main location-card silhouettes. Solid and hatched treatments distinguish +recorded totals from projected totals. The range band shows uncertainty at its outer edge. While the Locations cards are visible, a live reversal between the same two primary regions holds the previous counts and order through the existing reveal @@ -383,9 +402,10 @@ the happy path. See the feature ## Flyover `Sources/Developer/Flyover` owns an explicit `WhereFlyoverScreenID` catalog. -The enum is exhaustive and completeness-tested, so adding a top-level screen -produces one obvious registration update rather than depending on source -scanning or a macro that cannot discover navigation across the module. +Screen identities derive from their view types. Each view declares its own +registration and forward routes. Catalog tests check that each registration +appears once. The planning routes connect Locations, Planned stays, and the +individual stay editor. Opening Flyover asynchronously builds one `WhereScope.demo` and shares its seeded in-memory services, preferences, and session across live frames. That diff --git a/Where/WhereUI/SnapshotTests/PlannedStaysViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/PlannedStaysViewSnapshotTests.swift new file mode 100644 index 000000000..94d05fbe8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/PlannedStaysViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct PlannedStaysViewSnapshotTests { + @Test func plannedStays() async { + await assertSnapshots(of: PlannedStaysView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/PlanningRegionPickerViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/PlanningRegionPickerViewSnapshotTests.swift new file mode 100644 index 000000000..2418d0e50 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/PlanningRegionPickerViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct PlanningRegionPickerViewSnapshotTests { + @Test func planningRegionPicker() async { + await assertSnapshots(of: PlanningRegionPickerView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png index b15ad50a5..72b44d26c 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:c81ef681ed81bb3319b40a9e0f76d6016b3fd3f5eb0c854ed73571c9c8bea012 -size 690375 +oid sha256:32fe0b39085289f9b151dc8018426dd4966b1c2587909f1fac6963f960087284 +size 711078 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 f0f96bfec..344f1a3b9 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:85027f31e97dfec0cc450bec8ca469500a2a3642ee262ce48500047e43a529a5 -size 648125 +oid sha256:244d4adf225eb9c3409294293882db6257ea8dcca7daf5efa6d990035453903c +size 667030 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png index b9609b6d2..096190366 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf6cd8b1ebf35a9a18c7351b51a6657f21352a0e82ead0cdc4de2c061aa4fb8c -size 1711340 +oid sha256:a185bb660e5aa8cbe9f1cddaeaf6632fc9090bd73c841b46b5cebbb939aa3cd6 +size 1861760 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png index 457148288..d74ab7c88 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FocusedPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffbd7730d18a7599efea0c7d6b70a6324361091d15703495191d10725a05ea6c -size 1255661 +oid sha256:2b942a317f1e074bac3077ac0cad8142b486d0f0a8115ba300775d208d75594f +size 1344005 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index cdf2fec4e..3f7247c1b 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:0254b106fa724cab732583bba35a47c2e5e4ff046925bd238217b20abf401286 -size 1366735 +oid sha256:4cd0179117777af51b8e2affdac7e0de60a2d29870cba3b3d5a2e203875bf884 +size 1669235 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 00da35b64..00f9ef0c2 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:81a7269a8e49777565e7ea920c958673394c04c2b5dc07e103b65430a6fd4a17 -size 1035713 +oid sha256:74ea9d1ec3858a6b03752d4b95b7ae271942082d1941247b6b6c80118fb6b5b6 +size 1184948 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FutureWithoutPlans_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FutureWithoutPlans_iPhone.png new file mode 100644 index 000000000..805e0586a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FutureWithoutPlans_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8b92342bdd4f9883690127c6f7c91adf0753f41c22c354921ff732900b7f4bc +size 1074858 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FutureWithoutPlans_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FutureWithoutPlans_iPhone_dark.png new file mode 100644 index 000000000..1ad0ca10a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FutureWithoutPlans_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ea7b23e630d4ec8c464daf4319a7af7031aa5e30fa48fc3c110a85d8160f6ed +size 1008432 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone.png index 82cb03324..c36937d19 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c85b138664b80dbc4e7fef93d7b51a179ccc92154cca9d901dda8cec0280469d -size 736745 +oid sha256:e2c44ef78985248a195e430023742fa854be8c07e03620aab68fa908169ba446 +size 559662 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone_dark.png index 4133bf406..b3fe1a701 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.InitialPosition_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca29a7f281983d60211733ef8062d9fdbc6166b6458181d2abb4d9c4178a3750 -size 571437 +oid sha256:5934a0b77405b20886a10697e65931f6ae7f891eae6cfc57dd13a0ae0093f75a +size 464321 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad.png new file mode 100644 index 000000000..b6a25c11f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d78ede582d57f7c02ff8f9083b3eade078632947929ff38ddf1879cc65e83d23 +size 5532853 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_accessibility.png new file mode 100644 index 000000000..436c54f88 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11be19b61f24399d4e26d2ed99517785f97cfb63fb2450042a4085bd2cbd6992 +size 14483383 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_ax5.png new file mode 100644 index 000000000..9b67b33f8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a101ae2a07ab7e7573024e953361f6c547477f112226d24c500df4dc3459eb19 +size 11224187 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_contrast.png new file mode 100644 index 000000000..a62fa9db0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18b0310aa5b6dfeaf2bd35e3f3678232287bc47e4f6ee32a074b9d2bba6c17fa +size 5529221 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_dark.png new file mode 100644 index 000000000..c2931057b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:184e78456844b5d36d99229eba9c890b5ddc07e536f82481ebaaeac0c43307c4 +size 3842867 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone.png new file mode 100644 index 000000000..850c6138e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92c57f0e3ca27a6e3c361c0ac76f1a30837d3be5f3ba371c920e6789b00afdae +size 3422359 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_accessibility.png new file mode 100644 index 000000000..a9741bdb8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc0c5a3fd5cd3ee59023ef46691db02d210a881ea9fd0faa328fff06402f8035 +size 12642317 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_ax5.png new file mode 100644 index 000000000..3d62d950f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d734944c8569c77f6760a7467de9f66bde0f094636256550bb3a30c3edd91ad +size 8471050 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_contrast.png new file mode 100644 index 000000000..4a96aa7c5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcc203a10831754a79c417d96b6a8b81276229308b6ca80f3efd16baf0bff1cb +size 3421848 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_dark.png new file mode 100644 index 000000000..eae864e71 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Itinerary_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2a7a1473825522c1d581f99b549819638616e22fcc861addf54c038bd574b6c +size 2497436 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png index 9d58489ef..7680052d8 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:1a1fc7a8a8d42cb161723cc3854a3282ffd46baa384cae53bfac350e366622cc -size 218288 +oid sha256:7a2053f74989e848e5ede147dbf277be33622b5a59ed5699c6734aa2e5eac44f +size 230875 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 457c334a8..777ef1186 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:56ec736df4c83ec4b85b2a4eb32ec1371e239d31c12db8e3832516adfd670ad7 -size 208363 +oid sha256:e4887e36bdad843b7133fd48710f5b6d9621fb12ac23d83f6fbac784df0573c0 +size 220892 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone.png index b979eaf0e..d55792b6e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3ae87b9ce0e0731ae6f33a13d4cbe35c1d89ff2f01a95233d75d0705b2ebabe -size 2084179 +oid sha256:68aa3d1ecc58ac2197d97ce62b447cf31081bd4b70ec84213f1504447131528f +size 2346709 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone_dark.png index d8e3924ce..ac0a9251c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MultiRegionPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c95a330ebd6d11eb90a80e5859e05bdba648898958e06437a0ab5ef3eb83ef0 -size 1477230 +oid sha256:e4a8246572a49853992970aa9bc6c531fa8d7f3a2f8ac6ad416f251cbaf2ad98 +size 1598714 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone.png index 9ad106fd4..5c786cc7b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13f330f99401d66edc7dd6354ee2b98379526f8b6a14ebdb64cb880557625b67 -size 790456 +oid sha256:a1d41b10e6a9baea728499c818bf2fd7d7dadc2db445286339f17e3d4cc5b852 +size 781148 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone_dark.png index 2f9e732ed..a9196a1a5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.PlannedStayHidden_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5930876b73443460b4363df3ff76a79e2bc438f05566f7da7ba859e49a28110 -size 768349 +oid sha256:a37aae4a076268cfee4b6bc918de192160c2b8f01fde1216e1c870459a480891 +size 752199 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png index e7b613d5e..e0d521135 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:7c9f33662e71e8e7b4e747df38a0245dfd8a83411aaa1f1e59cb45c2f96daa1a -size 3120187 +oid sha256:5544619c5158987dbb0c3997207a00c684a05b2b9458faca399098a6b3bc089d +size 4930463 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 4fe2b3230..45b149401 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:261bfe772105639072e4a6d6559f4410c9c5143d75e0ee06f656add39f13fc9c -size 3597824 +oid sha256:a4898c8bce5fb884b42d5269aad2faaefe8ead7a468b2eec2fc6be965102691d +size 9173357 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 770f3f9d6..8d0bfb082 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:2819453081cfc4581aefdf799fc6848209abeb3726265df38a1c779d1086033f -size 6031285 +oid sha256:1268649604dead013e0602a6c4fece882879b6f5250c1e535bfcfef40ff377ea +size 10393494 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 edbe029b9..506f791eb 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:ae0d0c430d614f700dff05e5aaff0f86dd5fbdb445ad5140faa18aec31a7af24 -size 3127447 +oid sha256:bc09dbb55053dbe29852c520429b920c3ff9f366088cea71af37a16bbf4dd2ad +size 4964165 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 65649bdcc..73c8414d4 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:feb40c7e4e3bb092ad5460cfbf8bb9aa77ae3c2bc063ff0850d25191e55ee343 -size 2080657 +oid sha256:a6fce772676bf10913ce8a12f7c21b2b82e7539e2ffb4a481a688bcc5577baea +size 2952065 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png index 702a9a408..483c9ddfb 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:ad28ce054f2416c2a1bf4fde49d59cc01e5cd8357a2bea1da19473d1bf8e1972 -size 1853783 +oid sha256:f0eb333b43f3589442709dc8f314eebf778526afbdec66bace0a5710bb1a1772 +size 2781362 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 d334dde28..2e081947b 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:d67d6f0e5e8e42b21f1c7e19ea5cc420979674ad4eddc56b45dd736f8cb4afeb -size 2328127 +oid sha256:6501a0dbe6390378f7c6f238cbbf05e05a024db68c164071d057464aee606d26 +size 6962396 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 f7e58f5c3..693fa5ca4 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:43cf7beee832d76daefab31c9101f7ad668caf943c2187b38d441ecaf1bf909c -size 4185169 +oid sha256:7d2301cadbb95c3d76e4e7f7f1febba03ec1c586c56d0f85d883e16ab9438212 +size 7335268 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 e16c013bc..ba8ff4a93 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:adc8907dde1ba5ebee443bc620abeb7b8e3bf82a920e6e94d2c304d7f1a79198 -size 1861508 +oid sha256:17ca891ee95e4a02e9d09d4636c7bcab7d233ec5bfbc721ce813881adfab41e8 +size 2812136 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 2b98932a0..e090ab5c3 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:00ba47403b40450fbc04b9edf267ba8389fbf38d25bb0cc745519f7399f5f045 -size 1289430 +oid sha256:e289f3fd59ee4000f97c4494400bd87d1f30f387cfb7f1444f010ace171dbfcf +size 1699167 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone.png index 63d62e974..8803be6b8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e0304d3f16bc2fd440dcb2d3f1c2fbbd91f7ee04a9ba99f14642752ecf9b4e9d -size 757238 +oid sha256:25118c5b13fd7c914d3167022940ba6517ffc9e4150f3896c5d7b07c619dcc50 +size 744005 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone_dark.png index 671415f41..214a3d6f0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Disabled_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc53eba2408a9f42f6f81f4927ed300370623a9c9def79492e9f35ec4946c478 -size 776411 +oid sha256:2f348cb790110ba746edf64d5b062fcf3eba5a2bd41d3321a262698b352f7ff9 +size 760331 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad.png index 3521b4da4..8e5b5dc96 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31c007fed9ee5a454fb950ca85950e6b7493e79247a425d9e5eb7852087341c0 -size 2766189 +oid sha256:fcd43dd5efca633887c3e3f70005b80d388861b6139b3647762c9b4454ecc6d2 +size 3203945 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_accessibility.png index fc25afd52..b4ede27f2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb44f15e43371d33cc75fc470faf4c47162c681bc491bdf84dce72aa6c93d5c8 -size 3207733 +oid sha256:f2842a0aa30f70c71d33b3946d0ecb12bb042f0a590c273d351b14d8f0f6dc43 +size 3628531 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_ax5.png index 613ebe37c..e28b4ca74 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56f88734ae12dbe985d0daf826f3a8de2a6e851569701eb63e8a8513bcae93fa -size 6791793 +oid sha256:205aad2eba3c0b667ec2b93b8bd2b6dec38b1086241fe3d3684a21a71b29f995 +size 7649422 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_contrast.png index 1661f686d..3cc1d6f23 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:724e9c9ed59d61aeec1eefc5e8e1da40213d54c1e34bc33d2d79ce99737a3455 -size 2811258 +oid sha256:953dfde7e4a5e51b6c54ab495ba4f2ed860f3947b4b5dd00e4d67e7a37ff8efd +size 3255908 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_dark.png index 33c30948e..25f6569cb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58979e676ab2978fdf796484111aa5d9b955af501d9af031ffff3312da3d143e -size 1996565 +oid sha256:715ebd06ba038ca34a9ea4b5b68e3912a3ebe6e6c9ea26383e396e45ac8dbf68 +size 2187548 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone.png index 4dc87dcbc..6a5c28b67 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d049236dc1a219c28856139567bcb11355b76affaa7492606d19d20c5a2f862 -size 1614081 +oid sha256:925ca99c47d059200d0b5035f29354ee77a35752f96efecc8a36215bc1438d79 +size 1827674 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_accessibility.png index 5be0b7032..108238b68 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:246246307438e6ec46cf66a80bc8fa4e58fcd5fe51ac323eeefe0ec151607f27 -size 2142427 +oid sha256:4f233819c50c112bdad7bbdffba5a05208912a2db41a70165ca3329c959842fa +size 2348263 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_ax5.png index 591af4486..cf512454f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c53a15c23ad708db5ba2f66019064dd34169eb61a52b560426f6f049865ebd3 -size 4940950 +oid sha256:0ac0219e2cd49db40f99274443e2fbe21bf669c800b837f14acf5d683091877c +size 5466985 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_contrast.png index e5676b4eb..21dfc3c07 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04eda78d1f6d6380582446d8b9c524c08faad5e274c69c79da9c74a26bd6159a -size 1637557 +oid sha256:e78fef5445d50a40262099d79f7ebad5d222eeb0bc0a313f240611aa0073af8d +size 1842593 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_dark.png index 0fd273ad5..70d2b468a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Live_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da664695f4bcfd89ef653d468356cbd96d786de96059e90e2a9985323172b7c9 -size 1203617 +oid sha256:ce43b9cc2cd2428a75362e99e54cf79cd766300506e93fd0a221ed4bee57205c +size 1292830 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone.png index e9de5fc24..7ba458233 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8160d6b4a08e89dabe5f543c5d2a2ca022c454cd9d4ee4119e86d8fdb4c9fc44 -size 772123 +oid sha256:5b9120d18525e3306d4ce2a9a595ace5c33b2233b46ec357ded8d5d9be66f4bd +size 750364 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone_dark.png index ca97909d6..00d70aa16 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/EstimatedTimeFeaturesViewSnapshotTests/snapshots.Unavailable_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4e4e2c314df7cefb811172bf3d782cbc72ef57c85e8f33fa8061f355b5764e2 -size 789144 +oid sha256:4837331673f7b1f23a9508609ccbb101422c1e8ba4dc6371a640d6690f47bc37 +size 781876 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.DotsHidden_iPhone.png index a69d8d61f..b6f29624e 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:23896ac7f85e7c866709eed764edbf5e0856013bdfb9f9d5b47eabcf39f0a827 -size 2242064 +oid sha256:4496335b02766b250a41ad1576f214c15d9f76d6f04daf8f604b2c0f60c3f22e +size 2426470 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 3f1b57446..c57657fc1 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:534831ede24850d349a345a2b6ad651fbb68ff57fba04933f8b5705260a40a16 -size 2341800 +oid sha256:1b6cacbfe4087c3eb9f61161831d417166160d8164ba66376d9b45daf879c0df +size 2409878 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone.png index 9c0f24c44..9651c50d0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1e57bca53ce5ba3ddc4e471b46f83e6f85f374cd370f47d6d074d5c3c39baa4 -size 108230 +oid sha256:63b8158f3acb2bae8bd6d1f36dfa284959b51651b03b730edfe4538e9d472e1d +size 120620 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone_dark.png index 06ecb4e3a..613a38e52 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereOnly_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:416e3130e990a8610db37ee93f013cb0116b753daf3df724b2653d65af9a9ac6 -size 117727 +oid sha256:995670c10e82b7331b9eb6c50fa5e348d1fa7e7242c187700c2f2835b8fd04c5 +size 129828 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereWithHome_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereWithHome_iPhone.png new file mode 100644 index 000000000..adaa75214 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereWithHome_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7db577f9fb4f4fb3366105caf566ef49f8e8913a92057adc3376373fe8430912 +size 396401 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereWithHome_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereWithHome_iPhone_dark.png new file mode 100644 index 000000000..7752f1c30 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ElsewhereWithHome_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee67e15934fe8eb1cb5e8ddabdddb3a4ba95ae0a4df034707ef8362d05ca7b4c +size 262431 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone.png index 8e2dce736..c4d150900 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37d69a3fb9df28fea02c5ffab806634e4ae8961f3793f23007f5bbbbd125fc3a -size 102464 +oid sha256:f7a604bc7b8193300907e9063ef381b155764082f6e6eacfd9a9c68d8f294e97 +size 116607 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone_dark.png index bd821fd95..236d96f5a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Empty_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3dfb3e5eb42e3ea298a2e2edb6ca3b0b721d010ade7249a0bfbbb976f878ecb8 -size 111154 +oid sha256:e55e98b7d7e87d2c63a86f3b5929e58dd8552c55715c0ba68a204c949ed4e3ca +size 125823 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png index 8614c4d83..fe25d4869 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34bf84dfe929200ea0ad2ef0bc1608f09d1b9cca6b3258133de321a63d1f03a6 -size 2234823 +oid sha256:8cafba00037aaa9644abd8e1bcb1144de3e4c56531648667d74bb81f998bf55d +size 2235187 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png index d0faae77b..bd4813636 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ForecastsHidden_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a33805074af3b2b821eed6bd98ac33c4f2876546c29d318ba7aa48019b36942 -size 2328858 +oid sha256:c1593159501051a38c77e24c4d36d2f7b63703193ff1d1f159fa908fe22b7e77 +size 2327570 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad.png new file mode 100644 index 000000000..3fa552eda --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c0dfa250fdc98f0fb7bfd41a12482e06f0aa75a33b5c7406ff97873cc514ef5 +size 3922042 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_accessibility.png new file mode 100644 index 000000000..fb42891ad --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a827e6a5734804b09c57899aef80a280edf72a70fa9980338788178967f43196 +size 2776393 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_ax5.png new file mode 100644 index 000000000..eb68c64ca --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc44def49087b7cb243505d771a204f06c5dc816ea5a31f2fb4377f7f8a66478 +size 5286440 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_contrast.png new file mode 100644 index 000000000..97e534646 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a449d8333bf0b1f38d82d278717a236211621827cf537f3c5ab24151e5fb7003 +size 3897110 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_dark.png new file mode 100644 index 000000000..6cdfded47 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f12ecffc94157309fa8f44b7643a972fe082f29c8fdf35bf0e4adfa7ee5f943d +size 3934379 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone.png new file mode 100644 index 000000000..4f3b66f6b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26e017969b7ca333bf776c02b23e75c0b14f75010b29740b0f777492f573157c +size 2364773 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_accessibility.png new file mode 100644 index 000000000..74c01e360 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7ffd44c718641a446b8ad56d688fd19a2969753bb77c79fdcbf849510c4459e +size 1669414 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_ax5.png new file mode 100644 index 000000000..81778dd23 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50955f039768d91d57d3cb6f60dcc9babbc221c0e831b3f9c7a93a7bdc5e0d10 +size 3718513 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_contrast.png new file mode 100644 index 000000000..d5c5a4163 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bee388ef6a7f6b10a2a569f2204f7820616719c5c1f9f27a70dd1c5c21ae3908 +size 2307649 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_dark.png new file mode 100644 index 000000000..e4d84e194 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Itinerary_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f6d2ece14ac2ac4e92ee47c2da0768eea4f96ec9753eb0b0d37e9a8142034e0 +size 2345298 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 6b7142585..863877001 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:47795822f31d674d2efb578dfe6fc2f57169596565e71ed3dff11af90b1a8bcb -size 3577989 +oid sha256:a0ee4a28ca65f32f40626b123e98b9000cca9d4bcd103737b288ce421d713298 +size 4008000 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 1b2da5667..a4af50788 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:82f49ce93cfabab4b47b1b60105bdbfe6c7a3966dfb21f5052b7f2213f6679db -size 2510919 +oid sha256:399c12b29b2f63cc0f7422af627cc4a86a4915f9731421c7aa75123d1d678391 +size 2908440 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 fa678c875..9cced111a 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:02a222efc4743a1e727da9d4c88f1c90c0457ec142d6f4a3dd026d931bed7c74 -size 4416723 +oid sha256:33c34fc52891dff73cb8473c87bdea6dd2e3178e264046583808d32288d8acd2 +size 5489677 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 b7ede7c91..0b8fc2c6b 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:92b28297c4255536069b19ac9b7e6446c635bc1aaf4df1168d04b7101cc44493 -size 3567817 +oid sha256:1fc99b4a934c005f52e995eb3227268c0fba812f0e384e625ba2fef92a3b29b7 +size 3965687 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 013abad4b..e3339b32e 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:3c4448153e284511e830bf79c8f6c899eefa3f89d9b115c8f4026ab5f98aaa5e -size 3818088 +oid sha256:e7eb8351022c9564351ddbc06715ecabd1adfc3ef3f0d5da695d6bc8a5d67be5 +size 3995796 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 74fec1117..30ff75716 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:082c636ee058e3cc9c8c9ec38e5f766d812be74fe085b1144d8975a3e619aa89 -size 2249724 +oid sha256:7422717245432512b199116763dbabd04635ceabf62084703adbfc0cac04227f +size 2434473 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 29800df99..56fe9ca1b 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:d71b5f83fbc5c641b1fd30b5074dfc7cba2062731d70a433cd66677c0325dcd2 -size 1537545 +oid sha256:17ad65800307a24c9278df79ce1e1c5d6bbd000cfbbc37fe84ce0d358d998ac2 +size 1759047 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 136047246..80beaa00e 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:12d4563fd53dbeec414be8504de0f261dd68003c88d3bfe057124d929e4ad90f -size 3432518 +oid sha256:c3b84dfaf7ae9739193bbcb40f3a2edfb0887a2f18ec3f8fb34706979dda2b9f +size 4098006 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 9796c4bbc..098464429 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:9a4986f85d1ba75ae5cb0e24d8dc0896322fca9758f373980351b45051cde2f3 -size 2196500 +oid sha256:77b60d1ae0cf7fd0b784ef76b91daf9afcd1b37be6dd6789545e827971b2a822 +size 2374388 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 ec27341b8..02196bafa 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:19af7a62a3d9301f9c77ae60a9fb8cdbe975b0ce5f86a31dcbbf5d764a014aac -size 2349250 +oid sha256:a79dade7397a09c6f69e87d470d5904b48545d9aa166ea2faad775412c833261 +size 2419542 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index cff9f256d..7995068e1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c3658cc955e28876306afa4a3ea43a612fd85256b393c2714278038505a208f -size 1214991 +oid sha256:37119e2dc6bd9c122c51328253252ec15cf1211b2f0d3be4d24721a1112e0899 +size 1214870 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 6584910f0..d4f4129b0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cddb6bafb801f82b41e8ea630e803768eacacbc09587223a05e5e732d3169c5d -size 1239858 +oid sha256:bef2ffc969cfccd1281273878741ca666eb160d588e5776cdc7665278e2f9cd7 +size 1239793 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png index 100915767..0e8039ad9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42734aef9c3a3938a05406ca90e02fe0ceee56d7efbe732b687363fe68e87c0b -size 2219256 +oid sha256:619f38992db360aa7b4d3126adb42ea1bd7a8dcc00d02f023a4bf26b6b3258da +size 2363171 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png index 2c272ce6f..c6d9228dd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ea3f675f323578301020a94a1c7bf1fdf9ff07fd3113db2380854b5fa481911 -size 2295318 +oid sha256:769996beb80b32da30d5b1de2465d05c4da8fc885eafd88d0c11c861f0a55daf +size 2343134 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png index 9fa49dce2..fda58678c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b69fa723f8202113081dc8107b2786632d4a46181f4db09070855b24adb75c70 -size 490092 +oid sha256:81c6226380dc0e83f2fd7b0eca0a891c67d7b67ab88f8b63a45ffe022aa1af28 +size 256101 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png index f68708e86..598951c01 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dec35403c05cb22ee0090526a5d0337c5e17d319c1184a567cf638374afb23cb -size 653132 +oid sha256:65ad7e953cac54a96362b0d406ca2f60fc1adafc54fa7200b76e59c00d091144 +size 865942 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png index 47d4b0d95..c1fff11b1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdbbdb0cf6b70e93163673facc1d21812b76567d66127422faa99b8d76d7fa12 -size 828285 +oid sha256:0a024b335d40f801e74a769cabea8bc647e4549c2c4d68ceb5dd59a4ca569eac +size 518103 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png index 0ca7d2950..88ca3d438 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47036dcf9d825f1d2194b1000e954b471ad25a97f4887ef52472bd37e814575b -size 805967 +oid sha256:d989aa5819008d64866fdf57f0e6438e70574e763cae2bce9a51a1f259dc594c +size 238898 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png index 8eba17798..8fabdf1ca 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8f810bc3c903a0eee470aab5a858351c1f4edcc5ef0c96689b43416ac36699f -size 487933 +oid sha256:866c99a8e9528d99b179f28915897b30963b5ad53beeff5fdcbb9f01e380f79d +size 254520 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png index 09ec51ec5..8edac2203 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c0a3c34626f6bfd0425358fffa074c2b0f7be62c6cdd06e3b2be7df906665d3 -size 651268 +oid sha256:23984fe0dc0352c980de5288a01c62b7d359513567d3b3db1dc0ae46a61d9bec +size 862659 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png index fe9a39bbb..8f29e0564 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d54e86548441d4add318628172e62616b0a943d2159c2b168738afa5b647a9a -size 829246 +oid sha256:8b4380b2f2bf9e1887d09e16fe10ee2d337bfcf093599de54488b92808dd4407 +size 519692 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png index 505a428c9..b0e329956 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:327159735c38a6cc6c246aa19d813ed3989fa8ab841922c38e9e3d8f52c6d573 -size 805029 +oid sha256:cb0407f499977c82a107efd39625dbbcc5b44cb42d4b0fbfcf3b7a81209584e7 +size 237424 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png index 6e1524191..812990de3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:875ffd945db9f8f4eff53761b035f4ee9a0d80b28c0cec0f3dfba6d7bb5ef471 -size 1031894 +oid sha256:8cb89d52708e414554c1dce9982c5e3880d57615ebe3d2657a3b99cb97c715fb +size 1034533 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png index 18f9fb1d7..b2745d1af 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3824b30e1786a5ae9d1eb80f332c538cdc94a0a7d47bf4340aad2307b1a06b41 -size 914021 +oid sha256:9bd9864fa50e421263fb1eea5dc4c7cd13d1edfe106b3e345f0a450a847654ce +size 954863 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png index 235118837..b82f3835c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2161ade105f31e655f24f3d778fe649263b253552be256a1828955f2e2b85f80 -size 794734 +oid sha256:4b504750627079750f163c867266f9b22feabe3fb468375695f9f495134aa6db +size 794591 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png index 1007f7d36..a9f50372f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a51f6d91fa0b3aeb4b0cbe75e9177ea723d4a0ffce93bb479f83b229ff79fad7 -size 1247059 +oid sha256:203abb457599fd6674d30f41db9d2fe21c6d551b3db04c4b3fa98c7abb568e63 +size 1086559 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png index 65b07663f..16dd0dfe8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96b01a501c65c99951871a08456db9d988735c9c19291659c6f729c47394df37 -size 1052076 +oid sha256:cfdd710789139269f958f4342720e27f3feb79632dbf6aa4da8ddc01076c4003 +size 981325 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png index 7eb06a2ba..20416b53f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3143a402dbcbebf16b6f3b4bec965b543eec647be1449922a19d720ba28e50ce -size 2376508 +oid sha256:b6759baa28585a3adab4872c836707b749feb8b6203ef015d22bdbd5939d6eab +size 2420143 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png index 572155edb..1cb172d98 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98f460526a345ea45e732b41d58595d39f232f5dd9c3b4535f09eac7049bae6a -size 1302175 +oid sha256:9c77c3db92edcafcf0bbfecfc3bdd33e2bdb50b241fc01313b552796196b8893 +size 884288 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.AnyRegion_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.AnyRegion_iPhone.png new file mode 100644 index 000000000..8f67a4e6a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.AnyRegion_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcab23dfe1e4248ba8424d42932c8cb13ec182085cee63081a920a869935b2b7 +size 245132 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.AnyRegion_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.AnyRegion_iPhone_dark.png new file mode 100644 index 000000000..2dbffe855 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.AnyRegion_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d370ce22b3d7df4410f263a1e8bbb7f842ef3b09be18c4d169ba9a13e90e4987 +size 232351 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DefiniteOverlap_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DefiniteOverlap_iPhone.png new file mode 100644 index 000000000..025d2b266 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DefiniteOverlap_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0f94982c7221a8b97e35af456a757e5fcaefc5c3932503dd3054db36407e1b3 +size 320123 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DefiniteOverlap_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DefiniteOverlap_iPhone_dark.png new file mode 100644 index 000000000..eae0b0e9f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DefiniteOverlap_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f023a44f1e5c2c8e5c15aaee179b3be6995e6ec0d8f2f9e166aaf93718677927 +size 311968 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone.png new file mode 100644 index 000000000..ba053eaff --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04d90cfabf2d47ea78007ba5be8aaf305b2d8531ed2c617f603137a4638bcccf +size 354933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone_accessibility.png new file mode 100644 index 000000000..aa8946b71 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7de71fb7eaa5e203bef11b917a3138cb6e0d3484a8077ff6ea0be122334a461 +size 689504 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone_dark.png new file mode 100644 index 000000000..600db5a9d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.DeletedWhileEditing_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e3245547f795ebc22065e6fd16d6f530851d8d30d18e6eec0e587f806dd86ca +size 350589 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png deleted file mode 100644 index 1e00d6a2f..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 1634ad252..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.ExistingPlan_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c77ee65842083490f128fbb116586a380b79424a0eb6aa12e6e745a22cabe34c -size 150573 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad.png new file mode 100644 index 000000000..9f8b57d84 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e77ade45116ace8b8853de287aa13903804c8f578df5d1f1a12a6551c4a71fb7 +size 396415 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_accessibility.png new file mode 100644 index 000000000..1e0438449 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d524809697f7c912977885cbd150a14022786d68c8ef2d5edce0440dac1d88eb +size 725892 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_ax5.png new file mode 100644 index 000000000..91dcc4c18 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bc4f7e26aa77c7eac76aeb9ccd3affa800e88d1ccccd958a88c91ac0f07930c +size 867719 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_contrast.png new file mode 100644 index 000000000..7db838200 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8ad04c3db59f943b66a4ad3b732208e67ba780eaa1c86996015bf3f6bdf00d8 +size 387999 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_dark.png new file mode 100644 index 000000000..8e064722b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:259999369446b779fdc270e5667a7ae793eb4395bf2ceb79c5234817c8a23407 +size 386123 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone.png new file mode 100644 index 000000000..928983b55 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba5a62ff60ebdaac12f2100e6c812ce0daf19174492e74fd12774d0e1454702a +size 320628 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_accessibility.png new file mode 100644 index 000000000..45267a5a6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e13a406e9fddbd46a9ea17c30880f72514ac1ae22aed8e1e51278740cc1531a +size 625529 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_ax5.png new file mode 100644 index 000000000..42fe4aa01 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53bf71e6e2b99e60e5a0536cc90ffd5ce9be6044587ed404634bc7e770f23e8e +size 807159 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_contrast.png new file mode 100644 index 000000000..5c34b7fb8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8493c8c0d4db3b26d989a30be7d65f0d5bfb51b74d66355ed98ce763131d648a +size 316703 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_dark.png new file mode 100644 index 000000000..779ee7193 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.FlexibleStay_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f1d5eab6437c20ad39b194d128acfbd0a950f6e6754fe28c05bbdcfb01eab76 +size 312924 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png deleted file mode 100644 index 2f009b948..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 1ee856cba..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_accessibility.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3aebbc550f78dd86d89e645c5d5286cd259e4408fd36fd18b68946bbef4cd636 -size 445623 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png deleted file mode 100644 index d6258d5c0..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_ax5.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 70b3f5636..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_contrast.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 678bdaa8e..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPad_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e77c81983bda1df3e45e313005ea08d2c669ae7f78bdb88f4858c458c3604b04 -size 257157 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png deleted file mode 100644 index 43a0a2282..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index aa4862523..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_accessibility.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63868dd33fd9838827329e816475aa2fc75af5bb6768680e79975a606d1a5fa5 -size 307863 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png deleted file mode 100644 index 4e14f58b0..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_ax5.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index e9e241826..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_contrast.png +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index fcc1a4cf0..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewPlan_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f61942f147fbb81b46cb3d72dec0b85b9e854460a39eb10ef6c0a98ac58e456e -size 140682 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad.png new file mode 100644 index 000000000..03b9c824b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15f52bec70e241a2220c7d82bf03604579e433ecefc6648d8174b5de7956ec2a +size 353579 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_accessibility.png new file mode 100644 index 000000000..f7e4f0d2d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c6b800a6a5c46ea0d7ef0a921479f80a870ba0a61684b3b4070289853139874 +size 623371 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_ax5.png new file mode 100644 index 000000000..2cc721074 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5f271a9ac06daecf31c24364eafd4273c0ea8092591361524678e2857e8e4a3 +size 711249 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_contrast.png new file mode 100644 index 000000000..5b68157ea --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5aa6b4e47ff4e324331829d6fddb68789438f6a3067379e55658da8d258f46bf +size 343333 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_dark.png new file mode 100644 index 000000000..6294faf0e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bb651da6dbc7bebf6bc08d3328241fa38f5dec2d5057d42fa368ed01f542f3c +size 342190 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone.png new file mode 100644 index 000000000..238a26bd4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f7579bc3c0516e6ecbd114db5a5077444000a88935ad6c3c30da65de1a5d06a +size 233725 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_accessibility.png new file mode 100644 index 000000000..aeda67a12 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab7756863f8009d382ae2babaf2aefcc9db940ef62cb02a9059eb802ec3cf63 +size 482797 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_ax5.png new file mode 100644 index 000000000..8ecee41c1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a1f60a160d398e1d3982d6fe3d4519335b794a66092dfe8b4e4b559d3e77e8d +size 597081 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_contrast.png new file mode 100644 index 000000000..af99fb1c9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5e738285fb8932f61fcbb5bbb31080e3dfa086ba183100281b02346a2cfabd9 +size 225340 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_dark.png new file mode 100644 index 000000000..7de01896a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.NewStay_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c849004e017d8420ae4c3d849bece9d9cde206a4a9facba66bb5939b61705d63 +size 219615 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.OutsideRegion_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.OutsideRegion_iPhone.png deleted file mode 100644 index 64574c823..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.OutsideRegion_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:50f596e4530800bee0bd88ce03aa1d4e362aaf6a23f34d22ce1ede0f9d78c827 -size 294507 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.OutsideRegion_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.OutsideRegion_iPhone_dark.png deleted file mode 100644 index 5c2c682ea..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.OutsideRegion_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7babb3f74fd599e798c57549565bff21ab8b041878eb63de2515e250aaaa86d4 -size 273746 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.UnavailableLocation_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.UnavailableLocation_iPhone.png deleted file mode 100644 index dec5fd593..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.UnavailableLocation_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63677082279705adc8d1c1f258c05781ddeaa7f20d1a440888ad55aac2f5da84 -size 175370 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.UnavailableLocation_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.UnavailableLocation_iPhone_dark.png deleted file mode 100644 index e4672c8ee..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStayEditorSnapshotTests/plannedStayEditor.UnavailableLocation_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e9e8524b01d1c0ac4888e7dbbbee7d2b3d554c0edcd8b0b5057d98f15688b22 -size 160583 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Empty_iPhone.png new file mode 100644 index 000000000..0ccdfd456 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Empty_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c9da897a294f0bfb079f51c74eb976e4e53a6558cd80a7316b0487269bd4a6b +size 193011 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Empty_iPhone_dark.png new file mode 100644 index 000000000..fb2eeab2f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Empty_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7aeac0f2f415a1b977f859a85060ec1c09fb5a09f199faddb41f9e653caf6ac +size 185972 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad.png new file mode 100644 index 000000000..d0c4aecd3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75b9d0ddbafec52269f1e222c82afcb283737e424194099f2c31dad818f3c382 +size 420713 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_accessibility.png new file mode 100644 index 000000000..22b8af962 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee0ad409d0f2cf2452db06579bf7c4b36534e9dd142d3a329eb48f76fe3a697 +size 780168 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_ax5.png new file mode 100644 index 000000000..fcc853b47 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b89026ea45434dc35b29114c898df6c29f2764c2116c9aa57c4fdd5088c8290 +size 1211760 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_contrast.png new file mode 100644 index 000000000..b62e9dd75 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04d9d26038385c192b3f6ce102b34b4498861144a6adcd75e5d4b2956074c689 +size 422981 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_dark.png new file mode 100644 index 000000000..99288d36c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a755a3ef49e38eb874eaa74ad20bf6a887af7c0fd740e6047300a2121aba33f0 +size 422543 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone.png new file mode 100644 index 000000000..d9d220741 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a9ea9e8bf6687af1d69b3fc4713ba9873a9fe0ce6b20bcb0c72350829f21309 +size 354341 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_accessibility.png new file mode 100644 index 000000000..330838721 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57328d5e612b6335dc8a2471c911e100265a5900de4f4144ab6fad1b5524403f +size 675529 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_ax5.png new file mode 100644 index 000000000..3c343ab0c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4171a2c4d8e74692d54c9e03d8593c7fa30b2bf3af5dbb96b11a97bc607b6f3e +size 1257728 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_contrast.png new file mode 100644 index 000000000..22a9ebfc0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a763a1d6ad4efa16b26f25d5723f7ffbd767b25564e2d36459ef8482eb8aede +size 355612 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_dark.png new file mode 100644 index 000000000..3009c70c2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.Itinerary_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:628679edb1006f1b26f4800d904899f7d38badd9bd0363a340dd4789bbf7e282 +size 363891 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone.png new file mode 100644 index 000000000..3087eff1e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38ef661d1b5e52703c93752d7fe5d769aa8002c73154bc56abe1aa5ec7e63614 +size 394258 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone_accessibility.png new file mode 100644 index 000000000..08f44cc7f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b075329531b0088d8d3069474070033406cddea479f5c7bddbd08621c0674c48 +size 752130 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone_dark.png new file mode 100644 index 000000000..d7204923f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastExpanded_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:917324e310170a18b9130cddab0d793b06d8d9d74157fc34729cc57b5b5a5e00 +size 405762 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastTravelPattern_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastTravelPattern_iPhone.png new file mode 100644 index 000000000..fd1083c5d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastTravelPattern_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5f1ed518da8af2d1a304b76c60dcd943c7a759929cf7fbc70e59b94189de2ce +size 346344 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastTravelPattern_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastTravelPattern_iPhone_dark.png new file mode 100644 index 000000000..16a629104 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlannedStaysViewSnapshotTests/plannedStays.PastTravelPattern_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca607abe4b7d211f612ae660b7fd9090bba0bf3df0cb4e63c0c9f8a710f2b57d +size 356426 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad.png new file mode 100644 index 000000000..0f220d04d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cca7eeaf28f8124e394ccec875dc81b7083caef471dab67cf1e0025ec61ef599 +size 256025 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_accessibility.png new file mode 100644 index 000000000..a24a3c3e2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a52c33c950831c40caad7ee4bf743ec8bd9851b5902706567f8dd81e22ea13f1 +size 426789 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_ax5.png new file mode 100644 index 000000000..b0ee20625 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4d31fe3d4b3232628157ab95dd1c6ebfc50e99dd6e43ea93b5bc0d9de9eb7ce +size 358134 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_contrast.png new file mode 100644 index 000000000..da610c2af --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1a97dc5136474c585286e5441c88ab5dfe9215f6d96cff13403c26a9035454a +size 255131 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_dark.png new file mode 100644 index 000000000..10f29ec5d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca017018aae359d05f9c0e8c69b6e505546f2621c8c0b58bece48898a004edac +size 249665 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone.png new file mode 100644 index 000000000..e3f2f9d35 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af37ca70c37e1fb88f9b162459fbe70ebb03c57ec010f711e379614898107fe7 +size 170308 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_accessibility.png new file mode 100644 index 000000000..c49f060fa --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20f3a3c1be4ecf938bc83d1ce3ad2ac3c4819301d1a4f455d903a027dc7864ab +size 301445 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_ax5.png new file mode 100644 index 000000000..080ccf961 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0b9ed236462f394af1a815461f0fa868f5208977045b03c47a5a88a639ba19b +size 273621 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_contrast.png new file mode 100644 index 000000000..7059c40de --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c15954182bebe9739b8bffd806f777dd41790648ac4010f2b33571f4944d969 +size 154425 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_dark.png new file mode 100644 index 000000000..0926fa5d1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Home_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3672692999b5e001637a676e543e4047ff9cde45747ea38de8f03b0b3044492f +size 141323 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Search_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Search_iPhone.png new file mode 100644 index 000000000..d87a5e515 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Search_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f26782de6fc75a442649011d37dfa7b8ebbbaa097e136f6a3ffcb5004a9d8aa5 +size 169366 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Search_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Search_iPhone_dark.png new file mode 100644 index 000000000..30a373caf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PlanningRegionPickerViewSnapshotTests/planningRegionPicker.Search_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4e3dd7bed60f6c60a8f5629cd97e261576af9e5e432fd5130313168a8847076 +size 146973 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone.png index 8619751ad..7f05bb0b1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd7b9da0fb992c7730eba5667063e74d55d273da28aaf4e3963477993741cd1f -size 1384837 +oid sha256:a31e1667da39a238b71e59e86e98c1c0533b096984734b930dd1ad862f25703f +size 2248091 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone_dark.png index 036c15b72..e1253ab1d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColorInitialBottom_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f54009022eefc4d91491cba2f9a4c9cfea4c690ad164e296f021bb7c24ab67e0 -size 798090 +oid sha256:265426332ff77993d5e0319ffbafd25069af08fdca3f3d05bdb1e3ebd3145557 +size 1157166 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone.png index 72bbdd9bc..b87bb9a37 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:247ae89fe129fc1c9d7b46b03a211cb83ffa370436388845ef64ca1024640ace -size 1406763 +oid sha256:b2639cb688cde8f2e703a2e27de505f4f7f51f4447232176506bf7a700c83d06 +size 2340407 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone_dark.png index 6396c258d..66d82a4e9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.DifferentiateWithoutColor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3498a5f67d66b48d6bce2d1041faa0b2283908a240cf440b598fd44d0283cb5a -size 861573 +oid sha256:9d1f50835a71f25ea3e21ec6d71289f2ebb1e41caa7688a6ae0935414e766727 +size 1289261 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad.png index 31bde52a7..de6feef46 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:48f09d340616476f7d89b06b90f10148d600eab69196a9b001220ba1dfd54440 -size 2543177 +oid sha256:86dd16c46ef23003e5afaa8b8550fd2dfc9423d81f35266b1d436896bbf6379a +size 4449222 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_accessibility.png index da8819ebc..8b57eca05 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6721345fd31a423e1bb1bc445676e98654eecf3826ee17d6bf735fcb23deec20 -size 2489637 +oid sha256:f0cd62cbb826c73a5b517d5a99bc1b603785e635b4dea2c99b513b54a6088b9d +size 4379627 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_ax5.png index 1c468d6ff..31d09be15 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b63f40c719062eb87532e0e9d66e4c996b6f7adc3f6dadb16768a2f1dc00d84a -size 4807580 +oid sha256:bf3200e9a3193a4cf6f06adc2f5c056220bf37676dfc3ac6b320108af2e7ad33 +size 6027755 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_contrast.png index 10bec6141..4e74a5211 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7bd4705a23ae4abd762b304c9f85c3719b053518d0a080ecaa5950ae9c6c7775 -size 2574913 +oid sha256:9ae7b0e15c369f204562c175e8550714d78915c59618c82968a2245bfce373ae +size 4494262 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_dark.png index f96768d36..487a01687 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a931ad65526703cdc5ef297b635b099752a7ba75e6953fa71d29a2a09616bfbf -size 1501243 +oid sha256:201b6b05c10722ed25f9f52a1ae94a06fb12b62fd6f74d4ad15650983d977bc1 +size 2461742 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png index a58266d38..9cc299c87 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0f3b22e2d4aa4d5649952dd0477b5d60282bfcee514e25310d6a2c66541b7c8 -size 1384692 +oid sha256:3f28b44cf5123eff2b627eaaed7e332ff350e584071ca2277ef90a37b36d9ae5 +size 2002090 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_accessibility.png index aee922348..806a3e0be 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26c38a62ed489965a2293401536336b0dbf530f05fc879efc52ce9cd6bc73d6e -size 1390052 +oid sha256:4055900382982190472d3d229e1cacb17ed264d33216cef6478745a6543c7e14 +size 2311891 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_ax5.png index d60256d24..90d8c7f5a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78db2fb145e8b1f8e6eede3aff92e464dfbf0980579558df6b7f4947fb0bf85f -size 2236104 +oid sha256:42899a778d9811ec676f8f3f88223c12d336e14f3203e6263f20c10f7b28950f +size 2731324 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_contrast.png index 8ece7c4e2..9b65b9b0d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fdcff2d2464debadf896845101014e4e13d4cc99a437454940542ee82f45a1e -size 1375655 +oid sha256:d74a13fdbff2de64578ef65403b9bd475ce842d526cc8d0ea37d135f623f6c31 +size 2042933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png index 5f6912681..c55f6e591 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa46c564ab87a53ad2441bd69ebaf6d9d2114ae235806e358d9d7253a979e972 -size 793500 +oid sha256:c4ee0a3595ebf02e34cdfb5fed5da23a670e593b531234ac50f21b717448969b +size 1042338 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad.png new file mode 100644 index 000000000..a2a66edff --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f3734cc1ff6e07c3008c425dff67969b1a8e062613dc475d54c72716812af9e +size 3651740 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_accessibility.png new file mode 100644 index 000000000..6ed110b2f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c30b1a503b9a61d4acb1e63b0af7f8c7b0e7051ff6ac8f77e013ab9bb6f6604 +size 3940543 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_ax5.png new file mode 100644 index 000000000..ec55138d5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c5a078491a9a30276eb53e3f8425e6e28fbc76562f67b212f6e5a50bf8fd627 +size 9008434 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_contrast.png new file mode 100644 index 000000000..b39320de2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3d37cf8e3fac186a5ad1c7443ee1bd71e2d844cc1e1d683efae738be9ee7d63 +size 3677149 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_dark.png new file mode 100644 index 000000000..3f99d5fdd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc75ef90a92f3753c4846d4eb40c1b3f86b4844773e7383a465e884a9aa05f89 +size 2281365 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone.png new file mode 100644 index 000000000..6327daad5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efc1ee04ef5fad6469c53c47d25c64ad618b955aa704c06abeccf6fbbd40fadb +size 2168334 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_accessibility.png new file mode 100644 index 000000000..8702328e0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a952429f83d3aef9c42a1de5e458c40422016389bc794c1fcfeeeb05a5382457 +size 2547474 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_ax5.png new file mode 100644 index 000000000..f7772832e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae7fe0c6ff322105e345f1ea17d17d12f2456dd2975eb842a59db5a2c8e35678 +size 6940806 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_contrast.png new file mode 100644 index 000000000..1aa4ae544 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22df621b42f38fdfb9f03299a3b1fa922dd0174b075840a7bf181098b463d767 +size 2180867 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_dark.png new file mode 100644 index 000000000..9f5f071f5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Itinerary_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec3867ce30bee7f9e321d05892ee2e2089e1b5bccf24202c5a50dd40c9e51436 +size 1381504 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png index ade00f1fd..983955a48 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2493064b98771b3086d329c329c1042f57711dfeb163a2aa632010d53e31a79f -size 1343431 +oid sha256:cd8981c95a346e3616eba750d7b0a525817a66ea11b9fbc7dfa09f2b980fd161 +size 1595075 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png index 2f8fadbcc..27dd1ca64 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3df384db63f680c999c99e8bf38a25e1db59867adc8d090e5f66d50767708135 -size 764302 +oid sha256:2a3a6a93ca68c9283a800b8fced1d3652af80f578a221dea68da739f81b4d7b8 +size 888660 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone.png index bda28e4ec..cb3e04e44 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b1501b03e054e05936523a93ec99aeefcbb1a29e3fb3d9ab54b5d6cd4be370b -size 1344751 +oid sha256:983d6a5c4fa2999edb975f76b51e402f82281649091c9cfd7f03b9daa89ed6b1 +size 1599317 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone_dark.png index 754ba10f1..e757baf99 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayDifferentRegion_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d2651bc1358dc5ea353f3b69bd315c5b3d081e41e4338188f676d15283c9f76 -size 765255 +oid sha256:0cff3cd45b8d57bffec3612faad7415a464e61010c635a257f1509bc3283807d +size 888988 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone.png index 585df8afb..e78076194 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fec43f012b275635fc4f26c8d3c907a2e33f26b4ce094797c5683315e1c1d08b -size 1329722 +oid sha256:678736098eeb54911d62370188f1cd017ff53f087c11c5e58e12f0d5b43c23bf +size 1599002 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png index b79d1a5a0..c32619a2c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63befcc07248e9cbe21ff14a6f7c9faf46caaf45e65524c2d391d43fdb656792 -size 1494377 +oid sha256:4b7360eb457e37d1bee0c23c7829ebe7061003794a9968fe96268c403cbac0c2 +size 1793542 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_dark.png index 4be1913da..2b6037aef 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afc558716f8618dd5164fc32a696a72a09ee397f7a08787f2de1cb21ad29bf0e -size 745801 +oid sha256:f97d05efaff77795ae8ae3c43404ca6466be779ab800091ac277a7b5b16588b6 +size 889169 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone.png index a5bd0fe82..10ad14360 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6c4460a692e09fae7b44029c68547e2041bac6d93299509e63ea91881c1afbe -size 1323628 +oid sha256:48efba932899e656ea74786a98910e8726119b1f785159eba7f490d3df1d9a84 +size 1592458 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone_dark.png index 21621db35..b46fc5390 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.ShortPlannedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e71c00bc0004317a6f00973bd012766fad1f077ded071d98e18b3e459f2e4e5f -size 739856 +oid sha256:3eddedebc731b12260d8466f3f60e51d109b8976bb8b5dced8e452577f689a38 +size 881751 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png index 05703f4fb..9d20635b5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e504eaba70b73cbd04eb7aad5579b8f9c44fe4b7b0461737479e0f068fd4912f -size 2420814 +oid sha256:f4deca2818ddc60719e4a033de0ed4fafd91dfec4b712df35d1265b47d3ec4d0 +size 4243664 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png index 1be4bc317..f721e07ed 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40189401720d51588cdc29c64443fd08e260810b19f220012fdfcca5d6d15528 -size 2499854 +oid sha256:1d247c63985145ff8be40fd32dbe6232c8ac14027b0a6c884a4e11ead48fc5b2 +size 4352733 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_ax5.png index 38ce8e5e1..b42f98d9f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2b95cb7ea18e8a57617884c953cd44c4e71f00d65c2d03515e9e9baed2df5b5 -size 5012922 +oid sha256:922c7d0ff55fb3213711aa893d46bc173f41785a221854eacdd53c9cd20e7a41 +size 9371697 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_contrast.png index 207daa03f..7f7789900 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b7e1b237dcc857d65a2b935e50717cce418cf226cb8aa5fa0ea91aeb826197a -size 2434370 +oid sha256:deacc5d0381fb95f23022c0a4ca9c3b8814d42652f3bec83907c859d42674b35 +size 4280998 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png index 4fb7f197b..86495a760 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1855d1e49337483170ed6b1dcec03d3983b9834815183f573b423af0ae7d9476 -size 1405924 +oid sha256:5da0f4e1935fc8904bba385fcc1eb5c266a4c99ac9c7b5967bfb5a074af68061 +size 2294356 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png index 40e9a9215..9236c8bea 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ccb1f03fc4ce00e97f0d4c1769412df30322817118dde1ea12f9fb5f935039d6 -size 1340753 +oid sha256:365c519a98cbd90668cfac8a757a78b868da29a87eaf15f3d59758de252da3b7 +size 2281476 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png index ec255a0c2..eed419365 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c6aa4672bfc2610650d5a69881a75c9904b240b411d40efd780f472dc1f6e3d -size 1508959 +oid sha256:c367fb417546a9e814663d354f16d215e7764c463aae3d0a0600d6b52b76eda0 +size 2497773 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_ax5.png index 0d0c88830..46ed3a3e3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dbff7c2bb9e2b59294bcbc95ba6c66944174b195bf05daaf41cbfc3dadbcf75b -size 3309171 +oid sha256:8ccaa13e1488d7a7e221cbffc439b03ac403bacbc76e50488f374b1b2dcbef6b +size 6657090 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_contrast.png index a723c72b2..78fee8bd1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9544d375d67c9dd6f4701a4fdaa45450e82d12aea963b3665317fb383dd77847 -size 1350163 +oid sha256:78aae7a560121ddcb4be6731948f018af51acbb93dc9b9bacac8b44e5d292a7f +size 2319287 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png index 4eb0e8ef6..0462676e0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b10f1d8f07fc60b3f6f106d7e418b11bc6ebf494d7c9260be60d0faa69fdcebe -size 796709 +oid sha256:54349e0be9946a419f9915eb51e0910ca089addf50deac81ba101e7945b5b980 +size 1227420 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone.png index 9db8aa454..9e6b84f6a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a746c88054d92de90811faa8efdbd7bc012398befa665de19c89c57cb929fe01 -size 184526 +oid sha256:c27e5e4499538125aebd73f2588865c000d9f333fb05e12eb41961a94ec4490d +size 191196 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone_dark.png index 942d25067..507b5b04b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.LoggedIn_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f657d8b0b440aeec16d0046b1a6a5a70bd8e37573d16e0f16b78edcd9fc27137 -size 179383 +oid sha256:009c2c4526279bbdf099f47603a937e7e2a87b31d1a13b48b691a8864dcb9cf3 +size 185708 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone.png index bf9eb8472..46f9e42fd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:929d4a8a0cf7be1fbf906afae13af64f1069bc53a4b24c6356426e4092be4579 -size 185252 +oid sha256:d3111cc6f71474814872497f7cae33bae3512693ee4fda5eeea16804b3f3de46 +size 191962 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone_dark.png index 995284afb..a6d3529d7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RootViewSnapshotTests/root.RecordingConfigurationWarning_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bd09f67f7ba9d3c225f56280698a2aea27b150921f90df37d82e33102c7a3c7 -size 179699 +oid sha256:d006972bdfe1479b525737a9370ed805aff036b14b4d1af2b90b87e2e1c22261 +size 186060 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png index 7fb7c58a8..24dcf2dc8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/WhereFlyoverViewSnapshotTests/seededEntryState.WhereFlyover_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f8a62224b771defa4f38ce5b7577fb289f063a08ff71561043c2852fd071c4b -size 5646337 +oid sha256:6575dfa85d7bcec67a60f38fafdd2da07f83df8ca56000bc5019ab09fb88f746 +size 4730066 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index 18c8f34ce..a2ec4f7c3 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:ceca1ada475b1ac7c7a8327c98cb33067ac87e73f20d3d2268278c5c4ce80baf -size 731042 +oid sha256:4e68ad96b7f5bed1417912f8a105834a579449b33c5ea3572d4c1f2c6e6c3ae3 +size 746356 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 bc2406f4b..2f6c7c788 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:3a2cbd25135accd9580993d33457b9d136447ceeb23e86201fdfae0e0e480f99 -size 687274 +oid sha256:28f6330d69de85bee6d370c12c6c761b95eb2b5019b0ccd93bbb265eb9296661 +size 701445 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png index 0080b249b..3559d9748 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:f7d8efcebd05a7fc183469f9a42729f76d562734ab0c5ae83607b402ce7fe443 -size 3163784 +oid sha256:df5d8ad8b306bd13423ea74c37e1ead6402f8596af7dbe1ec012a4a368856817 +size 4967465 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 a160f11bf..7a235dac8 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:a54804f599f7affea112198854f66062f9de7b1562f71ea6e9a241408ca0e736 -size 3503260 +oid sha256:a16ab2ce71890d22503db9e7a03eab8cbeee9e5296211dbf9de7904def9c4251 +size 9224690 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 16bda132a..3b9d6a344 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:97ef18a1720defa87ce27e20695bad141585087727f98498f0077a69b8764906 -size 6130914 +oid sha256:c027e31596ae8ef3551a51e3c64401bc9522d71c42e353b94112a9df812beb58 +size 10581758 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 2fa80f488..30e2aea11 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:ff8ff4f0edbe8f5c26ee46bc41939631e9f8ab94e5086ea3d39022463e8fa134 -size 3169642 +oid sha256:c2c67b4380a4f2354587c957f4c4334d74ed20b7c60f5a2f634105525a93f8db +size 4997908 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 6f6a05ad6..5c76b7456 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:2584fe4a60ea9e69c0129530f65588bd4695f463896cde5acc8149294b214d3a -size 2123778 +oid sha256:d79c26bc3603c64f511a52cf19973ba2b9ae6c78515f943ba18c3031026b75c0 +size 2985352 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png index 8d3050f26..c4bf004c3 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:6ec40a73a9145e5e3f39964ab898982b83386947f4eaa4017cf52f3159e11861 -size 1887543 +oid sha256:16ba45d8cda0e7aa376b034d2c374ce7930ffbc0d40fa09ab03a72ac744287a7 +size 2810175 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 d7821ca8b..bdef7fc28 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:50e6a1f0cd16a65c7a8021ca65e1263b4b04b599701c6c72101ab56abcca96d2 -size 2293799 +oid sha256:aeb40a508b06ceb42d07a825850847871aa873ad3949311b99a5c1186bb0e540 +size 7001088 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 4426e0d68..7cf3f2999 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:31252907239cc754f6eeeb529116f4875c12d66cf11bc3373b5549ce97d93ea0 -size 3062888 +oid sha256:61cce75f5db569391cb5bd769b97bdf940ac43865ed2691b167524152aec4c5d +size 7496929 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 cfe954a5a..553fa8e49 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:c9ec7a24e3d8087561a96a3637d0bcae33745c0e0ee51dc9d3e18bffc14fdf4f -size 1895202 +oid sha256:65dcca05afa456a998837f1baab9d573ba0797f53594536eec55d7c4b1be5c64 +size 2840137 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 ea5323952..3b7dac030 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:71da0c23e55846a1a383d94eadab80dfe1e05ec24e8a15fe5d54a8d1fff886f0 -size 1320932 +oid sha256:c7050e8b3fc256e3b4354c890c213339673b1614d2022fbb63a9e143d8deb8a9 +size 1727303 diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index cd58f969b..9d4bea211 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -66,6 +66,8 @@ RegionPickerView.flyoverData, RegionCustomizeView.flyoverData, LocationsView.flyoverData, + PlannedStaysView.flyoverData, + PlannedStayEditor.flyoverData, CalendarContentView.flyoverData, ElsewhereView.flyoverData, RegionDaysView.flyoverData, diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastControls.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastControls.swift index 1f222bc51..cb3ec0d74 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastControls.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastControls.swift @@ -1,128 +1,30 @@ -import RegionKit import SFSafeSymbols import SwiftUI -import WhereCore -/// Planning controls shared by focused and multi-region forecast cards. +/// Opens the itinerary from an annual estimate. struct LocationForecastControls: View { - private enum ClearState: Equatable { - case idle - case clearing - case failed(String) - } - - let editableRegions: [Region] - var plannedStay: PlannedStay? - let editAction: (Region) -> Void - var clearAction: (@MainActor () async throws -> Void)? - - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let planningAction: () -> Void @Environment(\.stylesheet) private var stylesheet - @State private var clearState: ClearState = .idle var body: some View { - let style = stylesheet.locationForecast - let controls = style.controls - - VStack(alignment: .leading, spacing: controls.sectionSpacing) { - let layout = dynamicTypeSize.isAccessibilitySize - ? AnyLayout(VStackLayout(alignment: .leading, spacing: controls.layoutSpacing)) - : AnyLayout(HStackLayout(alignment: .center, spacing: controls.layoutSpacing)) - - layout { - if editableRegions.count == 1, let region = editableRegions.first { - Button( - String(localized: .locationForecastEditStay), - systemSymbol: .calendarBadgeClock, - ) { - editAction(region) - } - .buttonStyle(LocationForecastEndorsementButtonStyle( - tint: .primary, - expands: true, - controls: controls, - ink: style.ink, - )) - } else { - Menu { - ForEach(editableRegions, id: \.self) { region in - Button(region.localizedName) { - editAction(region) - } - } - } label: { - Label( - String(localized: .locationForecastEditStay), - systemSymbol: .calendarBadgeClock, - ) - } - .buttonStyle(LocationForecastEndorsementButtonStyle( - tint: .primary, - expands: true, - controls: controls, - ink: style.ink, - )) - } - - if let plannedStay, - editableRegions.contains(plannedStay.region), - clearAction != nil - { - if clearState == .clearing { - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity, minHeight: controls.minimumHeight) - .accessibilityLabel(String(localized: .locationForecastClearingStay)) - } else { - Button( - String(localized: .locationForecastClearStay), - role: .destructive, - action: clear, - ) - .buttonStyle(LocationForecastEndorsementButtonStyle( - tint: .red, - expands: dynamicTypeSize.isAccessibilitySize, - controls: controls, - ink: style.ink, - )) - } - } - } - - if case let .failed(message) = clearState { - Label(message, systemSymbol: .exclamationmarkTriangleFill) - .font(.footnote) - .foregroundStyle(.red) - } - } - } - - private func clear() { - guard let clearAction else { return } - clearState = .clearing - Task { - do { - try await clearAction() - clearState = .idle - } catch { - clearState = .failed(error.localizedDescription) - } - } + Button( + String(localized: .plannedStaysTitle), + systemSymbol: .calendarBadgeClock, + action: planningAction, + ) + .buttonStyle(LocationForecastEndorsementButtonStyle( + tint: .primary, + expands: true, + controls: stylesheet.locationForecast.controls, + ink: stylesheet.locationForecast.ink, + )) } } #if DEBUG #Preview { - LocationForecastControls( - editableRegions: [.california, .newYork], - plannedStay: PlannedStay( - region: .newYork, - through: CalendarDay(year: 2026, month: 8, day: 15), - ), - editAction: { _ in }, - clearAction: {}, - ) - .padding() - .whereBroadwayRoot() + LocationForecastControls(planningAction: {}) + .padding() + .whereBroadwayRoot() } #endif diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift index 6ea77ac84..3628e96f8 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastPanel.swift @@ -8,10 +8,8 @@ import WhereCore struct LocationForecastPanel: View { let forecasts: [LocationForecast] let microprintRegions: [Region] - var plannedStay: PlannedStay? - var editableRegions: [Region] = [] - var editAction: ((Region) -> Void)? - var clearAction: (@MainActor () async throws -> Void)? + var homeRegion: Region? + var planningAction: (() -> Void)? var isCollapsible = false @State private var isExpanded = false @@ -47,17 +45,17 @@ struct LocationForecastPanel: View { ForEach(forecasts, id: \.region) { forecast in LocationForecastRow( forecast: forecast, - plannedStay: plannedStay, + homeRegion: homeRegion, ) } - if !editableRegions.isEmpty, let editAction { - LocationForecastControls( - editableRegions: editableRegions, - plannedStay: plannedStay, - editAction: editAction, - clearAction: clearAction, - ) + if forecasts.contains(where: { !$0.estimatedTotalDays.isExact }) { + Text(String(localized: .forecastDateRangeExplanation)) + .font(.footnote) + .foregroundStyle(.secondary) + } + if let planningAction { + LocationForecastControls(planningAction: planningAction) } } .transition(.move(edge: .top).combined(with: .opacity)) @@ -145,10 +143,8 @@ struct LocationForecastPanel: View { LocationForecastPanel( forecasts: report.forecasts.leadingForecasts(report: report.report), microprintRegions: report.ranking.primary.map(\.region), - plannedStay: report.forecasts.activePlannedStay, - editableRegions: [.california, .newYork], - editAction: { _ in }, - clearAction: {}, + homeRegion: report.forecasts.planning.homeRegion, + planningAction: {}, ) .padding() .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastProgress.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastProgress.swift index 560ad27a0..2149cd3f8 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastProgress.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastProgress.swift @@ -13,7 +13,8 @@ struct LocationForecastProgress: View { let style = stylesheet.locationForecast let progress = style.progress let recordedFraction = fraction(forecast.yearToDateDays) - let estimatedFraction = forecast.estimatedFractionOfYear + let estimatedFraction = fraction(forecast.estimatedTotalDays.upper) + let minimumFraction = fraction(forecast.estimatedTotalDays.lower) GeometryReader { proxy in let recordedWidth = proxy.size.width * recordedFraction @@ -50,6 +51,16 @@ struct LocationForecastProgress: View { } } + if !forecast.estimatedTotalDays.isExact { + Capsule() + .fill(tint.opacity(style.ink.progressEstimateFillOpacity)) + .frame(width: proxy.size.width * minimumFraction) + Rectangle() + .fill(tint) + .frame(width: progress.hatchLineWidth) + .offset(x: proxy.size.width * minimumFraction) + } + if recordedWidth > 0 { Capsule() .fill(tint) diff --git a/Where/WhereUI/Sources/Forecasting/LocationForecastRow.swift b/Where/WhereUI/Sources/Forecasting/LocationForecastRow.swift index c95d15c5e..4c5f2b957 100644 --- a/Where/WhereUI/Sources/Forecasting/LocationForecastRow.swift +++ b/Where/WhereUI/Sources/Forecasting/LocationForecastRow.swift @@ -1,10 +1,11 @@ +import RegionKit import SwiftUI import WhereCore /// One region's annual projection rendered as a tinted visa endorsement. struct LocationForecastRow: View { let forecast: LocationForecast - var plannedStay: PlannedStay? + var homeRegion: Region? @Environment(\.regionStyles) private var regionStyles @Environment(\.stylesheet) private var stylesheet @@ -18,6 +19,9 @@ struct LocationForecastRow: View { VStack(alignment: .leading, spacing: row.contentSpacing) { LocationForecastEstimateLabel(forecast: forecast, tint: tint) + Text(WhereFormat.forecastPercentage(forecast.estimatedTotalDays, year: forecast.year)) + .font(.subheadline) + .foregroundStyle(.secondary) LocationForecastProgress(forecast: forecast, tint: tint) VStack(alignment: .leading, spacing: row.estimateSpacing) { @@ -27,8 +31,14 @@ struct LocationForecastRow: View { .font(row.detailFont) .foregroundStyle(.secondary) - if let plannedStay, plannedStay.region == forecast.region { - Text(WhereFormat.locationForecastPlan(through: plannedStay.through)) + if forecast.plannedDays.upper > 0 { + Text(String(localized: .forecastPlannedContribution(WhereFormat + .dayCount(forecast.plannedDays)))) + .font(row.detailFont) + .foregroundStyle(.secondary) + } + if forecast.projectedRemainingDays.upper > 0 { + Text(gapDescription) .font(row.detailFont) .foregroundStyle(.secondary) } @@ -49,6 +59,15 @@ struct LocationForecastRow: View { .accessibilityLabel(accessibilitySummary) } + private var gapDescription: String { + if homeRegion == forecast.region { + return String(localized: .forecastHomeContribution(WhereFormat + .dayCount(forecast.projectedRemainingDays))) + } + return String(localized: .forecastPatternContribution(WhereFormat + .dayCount(forecast.projectedRemainingDays))) + } + private var accessibilitySummary: String { var parts = [ String(WhereFormat.locationForecastEstimate( @@ -57,9 +76,16 @@ struct LocationForecastRow: View { ).characters), WhereFormat.locationForecastBasis(yearToDateDays: forecast.yearToDateDays), ] - if let plannedStay, plannedStay.region == forecast.region { - parts.append(WhereFormat.locationForecastPlan(through: plannedStay.through)) + parts.append(WhereFormat.forecastPercentage( + forecast.estimatedTotalDays, + year: forecast.year, + )) + if forecast.plannedDays.upper > 0 { + parts + .append(String(localized: .forecastPlannedContribution(WhereFormat + .dayCount(forecast.plannedDays)))) } + if forecast.projectedRemainingDays.upper > 0 { parts.append(gapDescription) } return parts.joined(separator: " ") } } @@ -70,7 +96,7 @@ struct LocationForecastRow: View { if let forecast = report.forecasts.leadingForecasts(report: report.report).first { LocationForecastRow( forecast: forecast, - plannedStay: report.forecasts.activePlannedStay, + homeRegion: report.forecasts.planning.homeRegion, ) .padding() .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayBoundarySection.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayBoundarySection.swift new file mode 100644 index 000000000..a14f0c923 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayBoundarySection.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// The same exact/flexible controls for arrival and inclusive departure. +struct PlannedStayBoundarySection: View { + let title: String + @Binding var boundary: PlannedStayEditorModel.Boundary + + var body: some View { + Section(title) { + Toggle(String(localized: .plannedStayEditorFlexibleDates), isOn: $boundary.isFlexible) + .accessibilityLabel(String(localized: .plannedStayEditorBoundaryAccessibility( + title, + String(localized: .plannedStayEditorFlexibleDates), + ))) + WhereDatePicker( + String(localized: boundary.isFlexible + ? .plannedStayEditorEarliest + : .plannedStayEditorExactDate), + selection: $boundary.earliest, + accessibilityTitle: String(localized: .plannedStayEditorBoundaryAccessibility( + title, + String(localized: boundary.isFlexible + ? .plannedStayEditorEarliest + : .plannedStayEditorExactDate), + )), + displayedComponents: .date, + ) + if boundary.isFlexible { + WhereDatePicker( + String(localized: .plannedStayEditorLatest), + selection: $boundary.latest, + earliest: boundary.earliest, + accessibilityTitle: String(localized: .plannedStayEditorBoundaryAccessibility( + title, + String(localized: .plannedStayEditorLatest), + )), + displayedComponents: .date, + ) + } + } + } +} + +#if DEBUG + #Preview { + Form { + PlannedStayBoundarySection( + title: String(localized: .plannedStayEditorArrival), + boundary: .constant(.init(date: PreviewSupport.referenceNow)), + ) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift index c924ea735..48552c605 100644 --- a/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditor.swift @@ -4,130 +4,166 @@ import SnapshotKit import SwiftUI import WhereCore -/// Sheet for setting or removing the inclusive departure day for the currently -/// focused region. +/// Creates or edits one stay without changing tracking or the other stays. struct PlannedStayEditor: View { - private struct LocationCheckID: Equatable { - let region: Region - let driftThreshold: DriftThreshold - } - - private enum SaveState: Equatable { - case idle - case saving - case failed(String) - } - - let region: Region - let model: LocationForecastModel - let driftThreshold: DriftThreshold - + @State private var model: PlannedStayEditorModel + @State private var showsManageRegions = false @Environment(\.dismiss) private var dismiss - @State private var through: Date - @State private var saveState: SaveState = .idle init( - region: Region, - model: LocationForecastModel, - driftThreshold: DriftThreshold, + report: YearReportModel, + stay: PlannedStay? = nil, + initialRegion: Region? = nil, ) { - self.region = region - self.model = model - self.driftThreshold = driftThreshold - _through = State(initialValue: model.departureDate(for: region)) + _model = State(initialValue: PlannedStayEditorModel( + report: report, + stay: stay, + initialRegion: initialRegion, + )) + } + + init(model: PlannedStayEditorModel) { + _model = State(initialValue: model) } var body: some View { + @Bindable var model = model + NavigationStack { Form { - let locationCheck = model.plannedStayLocationCheck( - for: region, - driftThreshold: driftThreshold, + if let message = model.report.forecasts.loadFailure { + Section { + Label(message, systemSymbol: .exclamationmarkTriangleFill) + .foregroundStyle(.red) + Button(String(localized: .commonRetry)) { + Task { await model.report.forecasts.refresh() } + } + } + } + destinationSection + PlannedStayBoundarySection( + title: String(localized: .plannedStayEditorArrival), + boundary: $model.arrival, + ) + PlannedStayBoundarySection( + title: String(localized: .plannedStayEditorLastDay), + boundary: $model.departure, ) Section { - WhereDatePicker( - String(localized: .locationForecastEditorDate), - selection: $through, - earliest: model.minimumDepartureDate, - displayedComponents: .date, - ) - } footer: { - if locationCheck == nil || locationCheck?.status == .accepted { - Text(String(localized: .locationForecastEditorFooter)) + if let message = model.validationMessage { + Label(message, systemSymbol: .exclamationmarkCircle) + .foregroundStyle(.secondary) } + } footer: { + Text(String(localized: .plannedStayEditorDatesFooter)) } - - if let locationCheck, locationCheck.status != .accepted { + if model.hasDefiniteOverlap || model.hasPossibleOverlap { Section { - PlannedStayLocationStatusRow(check: locationCheck) + if model.hasDefiniteOverlap { + Label( + String(localized: .plannedStaysDefiniteOverlap), + systemSymbol: .rectangleOnRectangle, + ) + } + if model.hasPossibleOverlap { + Label( + String(localized: .plannedStaysPossibleOverlap), + systemSymbol: .rectangleDashed, + ) + } } footer: { - Text(String(localized: .locationForecastEditorFooter)) + Text(String(localized: .plannedStaysOverlapFooter)) } } - - if case let .failed(message) = saveState { + if case let .failed(message) = model.saveState { Section { Label(message, systemSymbol: .exclamationmarkTriangleFill) .foregroundStyle(.red) } } - - if model.activePlannedStay?.region == region { + if model.isEditing { Section { - Button( - String(localized: .locationForecastRemovePlan), - role: .destructive, - action: removePlan, - ) + Button(String(localized: .plannedStayEditorDelete), role: .destructive) { + Task { + if await model.delete() { dismiss() } + } + } } } } - .navigationTitle(String(localized: .locationForecastEditorTitle)) + .environment(\.calendar, model.report.calendar) + .environment(\.timeZone, model.report.calendar.timeZone) + .disabled(model.isSaving) + .navigationTitle(String(localized: model.isEditing + ? .plannedStayEditorEditTitle + : .plannedStayEditorNewTitle)) .navigationBarTitleDisplayMode(.inline) - .interactiveDismissDisabled(saveState == .saving) + .interactiveDismissDisabled(model.isSaving) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String(localized: .commonCancel), action: dismiss.callAsFunction) - .disabled(saveState == .saving) + .disabled(model.isSaving) } ToolbarItem(placement: .confirmationAction) { - if saveState == .saving { + if model.isSaving { ProgressView() + .accessibilityLabel(String(localized: .commonSave)) } else { - Button(String(localized: .commonSave), action: save) + Button(String(localized: .commonSave)) { + Task { + if await model.save() { dismiss() } + } + } + .disabled(!model.canSave) } } } - .task(id: LocationCheckID(region: region, driftThreshold: driftThreshold)) { - await model.checkCurrentLocation( - for: region, - driftThreshold: driftThreshold, - ) - } } - } - - private func save() { - saveState = .saving - Task { - do { - try await model.set(region: region, through: through) - dismiss() - } catch { - saveState = .failed(error.localizedDescription) - } + .sheet(isPresented: $showsManageRegions, onDismiss: { + Task { await model.regionSelection.load() } + }) { + RegionsSettingsView( + usedThisYear: Set(model.report.report?.totals.keys.map(\.self) ?? []), + ) } + .task { await model.load() } } - private func removePlan() { - saveState = .saving - Task { - do { - try await model.clear() - dismiss() - } catch { - saveState = .failed(error.localizedDescription) + private var destinationSection: some View { + Section { + NavigationLink { + PlanningRegionPickerView( + model: model.regionSelection, + title: String(localized: .plannedStayEditorDestination), + selectedRegion: model.region, + ) { region in + model.region = region + } + } label: { + LabeledContent(String(localized: .plannedStayEditorDestination)) { + Text(model.region? + .localizedName ?? String(localized: .plannedStayEditorChooseRegion)) + } + } + if let region = model.region, + let tracked = model.regionSelection.trackedRegions, + !tracked.contains(region) + { + Text(String(localized: .plannedStayEditorUntracked)) + .foregroundStyle(.secondary) + Button(String(localized: .settingsRegionsManage), systemSymbol: .map) { + showsManageRegions = true + } } + if case let .failed(message) = model.regionSelection.loadState { + Label(message, systemSymbol: .exclamationmarkTriangle) + .foregroundStyle(.secondary) + Button(String(localized: .commonRetry)) { + Task { await model.regionSelection.load() } + } + } + } footer: { + Text(String(localized: .plannedStayEditorDestinationFooter)) } } } @@ -135,89 +171,84 @@ struct PlannedStayEditor: View { #if DEBUG extension PlannedStayEditor: SnapshotProviding { static var snapshots: [SnapshotCase] { - whereSnapshot(name: "NewPlan", configurations: .screenDefaults) { - let model = PreviewSupport.plannedStayEditorYearReportModel( - currentLocation: LocationSample( - timestamp: PreviewSupport.referenceNow, - coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), - horizontalAccuracy: 5, - source: .gpsSignificantChange, - ), - plannedStay: nil, - ) - PlannedStayEditor( - region: .newYork, - model: model.forecasts, - driftThreshold: .km1, - ) - .background { - Color(.systemBackground) - .ignoresSafeArea() - } + whereSnapshot( + name: "NewStay", + configurations: .fullContentScreenDefaults, + ) { + PlannedStayEditor(report: PreviewSupport.loadedYearReportModel()) } - whereSnapshot(name: "ExistingPlan", configurations: .phoneLightDark) { - let stay = PlannedStay( - region: .newYork, - through: CalendarDay(year: PreviewSupport.year, month: 8, day: 15), - ) - let model = PreviewSupport.plannedStayEditorYearReportModel( - currentLocation: LocationSample( - timestamp: PreviewSupport.referenceNow, - coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), - horizontalAccuracy: 5, - source: .gpsSignificantChange, - ), - plannedStay: stay, - ) + whereSnapshot(name: "AnyRegion", configurations: .fullContentPhoneLightDark) { PlannedStayEditor( - region: .newYork, - model: model.forecasts, - driftThreshold: .km1, + report: PreviewSupport.loadedYearReportModel(), + initialRegion: PrimaryRegionSelectionModel.usRegions.first { + $0 != .newYork && $0 != .california + }, ) - .background { - Color(.systemBackground) - .ignoresSafeArea() - } } - whereSnapshot(name: "OutsideRegion", configurations: .phoneLightDark) { - let model = PreviewSupport.plannedStayEditorYearReportModel( - currentLocation: LocationSample( - timestamp: PreviewSupport.referenceNow, - coordinate: Coordinate(latitude: 35.6762, longitude: 139.6503), - horizontalAccuracy: 5, - source: .gpsSignificantChange, + whereSnapshot( + name: "FlexibleStay", + configurations: .fullContentScreenDefaults, + ) { + let report = PreviewSupport.itineraryYearReportModel() + PlannedStayEditor(report: report, stay: report.forecasts.planning.stays.first { + !$0.arrival.isExact || !$0.departure.isExact + }) + } + whereSnapshot(name: "DefiniteOverlap", configurations: .fullContentPhoneLightDark) { + PlannedStayEditor(model: definiteOverlapEditorModel()) + } + let staleModel = flexibleEditorModel(report: PreviewSupport.itineraryYearReportModel()) + whereSnapshot( + name: "DeletedWhileEditing", + configurations: .fullContentPhoneLightDark + + SnapshotConfiguration.combinations( + devices: [.iPhoneFullContent], + snapshotTypes: [.accessibility], ), - plannedStay: nil, - ) - PlannedStayEditor( - region: .newYork, - model: model.forecasts, - driftThreshold: .km1, - ) - .background { - Color(.systemBackground) - .ignoresSafeArea() - } + onReadyToMeasure: { + // The preview mirrors an itinerary over an empty store. + // Await the real rejection before measuring its error row. + let saved = await staleModel.save() + precondition(!saved, "The stale editor fixture must expose the save failure") + }, + ) { + PlannedStayEditor(model: staleModel) } - whereSnapshot(name: "UnavailableLocation", configurations: .phoneLightDark) { - let model = PreviewSupport.plannedStayEditorYearReportModel( - currentLocation: nil, - plannedStay: nil, - ) - PlannedStayEditor( - region: .newYork, - model: model.forecasts, - driftThreshold: .km1, + } + + private static func definiteOverlapEditorModel() -> PlannedStayEditorModel { + let report = PreviewSupport.itineraryYearReportModel() + let model = flexibleEditorModel(report: report) + guard let other = report.forecasts.planning.stays + .first(where: { $0.region == .california }) + else { + preconditionFailure( + "The itinerary fixture requires the overlapping California stay", ) - .background { - Color(.systemBackground) - .ignoresSafeArea() - } } + model.departure.earliest = other.arrival.earliest.startOfDay(in: report.calendar) + return model + } + + private static func flexibleEditorModel(report: YearReportModel) -> PlannedStayEditorModel { + guard let stay = report.forecasts.planning.stays.first(where: { + !$0.arrival.isExact || !$0.departure.isExact + }) else { + preconditionFailure("The itinerary fixture requires a flexible stay") + } + return PlannedStayEditorModel(report: report, stay: stay, initialRegion: nil) } } - #Preview { - PlannedStayEditor.snapshotPreviews + #Preview { PlannedStayEditor.snapshotPreviews } + + extension PlannedStayEditor: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.hosted( + PlannedStayEditor.self, + title: "Planned Stay Editor", + navigationContainer: .none, + ) { world in + PlannedStayEditor(report: world.report) + } } #endif diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayEditorModel.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayEditorModel.swift new file mode 100644 index 000000000..7b0176c31 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStayEditorModel.swift @@ -0,0 +1,216 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// An independent stay draft. Calendar-day boundaries survive device timezone +/// changes; only the date-picker projections use the report's calendar. +@MainActor +@Observable +final class PlannedStayEditorModel: Identifiable { + enum SaveState: Equatable { + case idle + case saving + case failed(String) + } + + struct Boundary { + enum Selection { + case exact(Date) + case flexible(earliest: Date, latest: Date) + } + + private var selection: Selection + + init(date: Date) { + selection = .exact(date) + } + + init(window: PlannedStay.DateWindow, calendar: Calendar) { + let earliest = window.earliest.startOfDay(in: calendar) + if window.isExact { + selection = .exact(earliest) + } else { + selection = .flexible( + earliest: earliest, + latest: window.latest.startOfDay(in: calendar), + ) + } + } + + var isFlexible: Bool { + get { + switch selection { + case .exact: false + case .flexible: true + } + } + set { + guard newValue != isFlexible else { return } + selection = newValue + ? .flexible(earliest: earliest, latest: earliest) + : .exact(earliest) + } + } + + var earliest: Date { + get { + switch selection { + case let .exact(date): date + case let .flexible(earliest, _): earliest + } + } + set { + switch selection { + case .exact: + selection = .exact(newValue) + case let .flexible(_, latest): + selection = .flexible(earliest: newValue, latest: max(newValue, latest)) + } + } + } + + var latest: Date { + get { + switch selection { + case let .exact(date): date + case let .flexible(_, latest): latest + } + } + set { + switch selection { + case .exact: + selection = .exact(newValue) + case let .flexible(earliest, _): + selection = .flexible(earliest: min(earliest, newValue), latest: newValue) + } + } + } + + func window(in calendar: Calendar) throws -> PlannedStay.DateWindow { + try PlannedStay.DateWindow( + earliest: CalendarDay(from: earliest, in: calendar), + latest: CalendarDay(from: latest, in: calendar), + ) + } + } + + let report: YearReportModel + nonisolated let stayID: PlannedStay.ID + let regionSelection: PlanningRegionSelectionModel + let isEditing: Bool + var region: Region? + var arrival: Boundary + var departure: Boundary + private(set) var saveState: SaveState = .idle + + init(report: YearReportModel, stay: PlannedStay?, initialRegion: Region?) { + self.report = report + regionSelection = PlanningRegionSelectionModel(report: report) + stayID = stay?.id ?? PlannedStay.ID(rawValue: UUID()) + isEditing = stay != nil + region = stay?.region ?? initialRegion + if let stay { + arrival = Boundary(window: stay.arrival, calendar: report.calendar) + departure = Boundary(window: stay.departure, calendar: report.calendar) + } else { + let today = report.calendar.startOfDay(for: report.referenceDate) + arrival = Boundary(date: today) + departure = Boundary(date: today) + } + } + + var isSaving: Bool { + saveState == .saving + } + + nonisolated var id: PlannedStay.ID { + stayID + } + + var validationMessage: String? { + guard region != nil else { + return String(localized: .plannedStayEditorDestinationRequired) + } + switch draft { + case .success: return nil + case .failure: return String(localized: .plannedStayEditorInvalidDates) + } + } + + var canSave: Bool { + !isSaving && validationMessage == nil + } + + var draft: Result { + Result { + guard let region else { throw DraftError.missingRegion } + return try PlannedStay( + id: stayID, + region: region, + arrival: arrival.window(in: report.calendar), + departure: departure.window(in: report.calendar), + ) + } + } + + var overlaps: [PlannedStayOverlap] { + guard case let .success(stay) = draft else { return [] } + let snapshot = PlanningSnapshot( + stays: report.forecasts.planning.stays.filter { $0.id != stayID } + [stay], + homeRegion: report.forecasts.planning.homeRegion, + ) + return snapshot.overlaps( + asOf: CalendarDay(from: report.referenceDate, in: report.calendar), + ).filter { $0.firstStayID == stayID || $0.secondStayID == stayID } + } + + var hasDefiniteOverlap: Bool { + overlaps.contains { $0.certainRange != nil } + } + + var hasPossibleOverlap: Bool { + overlaps.contains { $0.certainRange == nil } + } + + func load() async { + if !report.forecasts.hasLoaded { await report.forecasts.refresh() } + await regionSelection.load() + } + + /// Returns success only after the independent revision has committed. + func save() async -> Bool { + guard canSave else { return false } + saveState = .saving + do { + let stay = try draft.get() + if isEditing { + try await report.forecasts.update(stay: stay) + } else { + try await report.forecasts.create(stay: stay) + } + saveState = .idle + return true + } catch { + saveState = .failed(error.localizedDescription) + return false + } + } + + func delete() async -> Bool { + guard isEditing, !isSaving else { return false } + saveState = .saving + do { + try await report.forecasts.delete(stayID: stayID) + saveState = .idle + return true + } catch { + saveState = .failed(error.localizedDescription) + return false + } + } + + private enum DraftError: Error { + case missingRegion + } +} diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStayLocationStatusRow.swift b/Where/WhereUI/Sources/Forecasting/PlannedStayLocationStatusRow.swift deleted file mode 100644 index 645830280..000000000 --- a/Where/WhereUI/Sources/Forecasting/PlannedStayLocationStatusRow.swift +++ /dev/null @@ -1,73 +0,0 @@ -import RegionKit -import SFSafeSymbols -import SwiftUI -import WhereCore - -/// Current-location verification shown beneath the planned-stay date entry. -struct PlannedStayLocationStatusRow: View { - let check: LocationForecastModel.PlannedStayLocationCheck? - - @Environment(\.stylesheet) private var stylesheet - - var body: some View { - switch check?.status { - case .checking: - Label { - Text(String(localized: .locationForecastEditorLocationChecking)) - } icon: { - ProgressView() - } - .font(.subheadline) - case .outside: - if let check { - let style = stylesheet.plannedStayWarningStamp - StampBanner( - systemSymbol: .exclamationmarkTriangleFill, - style: style, - showsAccessory: false, - ) { - Text(WhereFormat.plannedStayOutsideLocation( - region: check.region, - driftThreshold: check.driftThreshold, - )) - .font(style.detailFont) - .foregroundStyle(.primary) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) - } - case .unavailable: - Label( - String(localized: .locationForecastEditorLocationUnavailable), - systemSymbol: .locationSlash, - ) - .font(.subheadline) - .foregroundStyle(.secondary) - case .accepted, nil: - EmptyView() - } - } -} - -#if DEBUG - #Preview { - Form { - PlannedStayLocationStatusRow(check: .init( - region: .newYork, - driftThreshold: .km1, - status: .checking, - )) - PlannedStayLocationStatusRow(check: .init( - region: .newYork, - driftThreshold: .km1, - status: .outside, - )) - PlannedStayLocationStatusRow(check: .init( - region: .newYork, - driftThreshold: .km1, - status: .unavailable, - )) - } - } -#endif diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStaySummaryRow.swift b/Where/WhereUI/Sources/Forecasting/PlannedStaySummaryRow.swift new file mode 100644 index 000000000..4e54725e4 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStaySummaryRow.swift @@ -0,0 +1,73 @@ +import Foundation +import RegionKit +import SFSafeSymbols +import SwiftUI +import WhereCore + +/// A stay's destination and separately labeled arrival/departure windows. +struct PlannedStaySummaryRow: View { + let stay: PlannedStay + let calendar: Calendar + let hasDefiniteOverlap: Bool + let hasPossibleOverlap: Bool + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + + var body: some View { + VStack(alignment: .leading, spacing: stylesheet.spacing.small) { + HStack(spacing: stylesheet.spacing.small) { + Text(regionStyles.style(for: stay.region).emoji) + .accessibilityHidden(true) + Text(stay.region.localizedName) + .font(.headline) + Spacer(minLength: 0) + Image(systemSymbol: .pencil) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + LabeledContent(String(localized: .plannedStayEditorArrival)) { + Text(WhereFormat.plannedStayWindow(stay.arrival, calendar: calendar)) + } + LabeledContent(String(localized: .plannedStayEditorLastDay)) { + Text(WhereFormat.plannedStayWindow(stay.departure, calendar: calendar)) + } + if hasDefiniteOverlap { + Label( + String(localized: .plannedStaysDefiniteOverlap), + systemSymbol: .rectangleOnRectangle, + ) + .font(.caption) + .foregroundStyle(.secondary) + } + if hasPossibleOverlap { + Label( + String(localized: .plannedStaysPossibleOverlap), + systemSymbol: .rectangleDashed, + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } +} + +#if DEBUG + #Preview { + let report = PreviewSupport.itineraryYearReportModel() + if let stay = report.forecasts.planning.stays.first { + List { + PlannedStaySummaryRow( + stay: stay, + calendar: report.calendar, + hasDefiniteOverlap: false, + hasPossibleOverlap: true, + ) + } + .whereBroadwayRoot() + } + } +#endif diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStaysDestination.swift b/Where/WhereUI/Sources/Forecasting/PlannedStaysDestination.swift new file mode 100644 index 000000000..0bf9b1ec0 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStaysDestination.swift @@ -0,0 +1,39 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Shared sheet routes for itinerary management and individual stay editing. +enum PlannedStaysDestination: Hashable, Identifiable { + case list + case new(Region) + case edit(PlannedStay) + + var id: Self { + self + } +} + +struct PlannedStaysDestinationView: View { + let destination: PlannedStaysDestination + let report: YearReportModel + + var body: some View { + switch destination { + case .list: + PlannedStaysView(report: report) + case let .new(region): + PlannedStayEditor(report: report, initialRegion: region) + case let .edit(stay): + PlannedStayEditor(report: report, stay: stay) + } + } +} + +#if DEBUG + #Preview { + PlannedStaysDestinationView( + destination: .list, + report: PreviewSupport.plannedStayYearReportModel(), + ) + } +#endif diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStaysModel.swift b/Where/WhereUI/Sources/Forecasting/PlannedStaysModel.swift new file mode 100644 index 000000000..5d69cae1b --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStaysModel.swift @@ -0,0 +1,89 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// Planner presentation state over the report's shared planning snapshot. +@MainActor +@Observable +final class PlannedStaysModel { + enum SaveState: Equatable { + case idle + case saving + case failed(String) + } + + let report: YearReportModel + let regionSelection: PlanningRegionSelectionModel + private let initialRegion: Region? + var editor: PlannedStayEditorModel? + var showsPast = false + private(set) var saveState: SaveState = .idle + + init(report: YearReportModel, initialRegion: Region?) { + self.report = report + self.initialRegion = initialRegion + regionSelection = PlanningRegionSelectionModel(report: report) + } + + var upcoming: [PlannedStay] { + sortedStays.filter { $0.departure.latest >= report.forecasts.today } + } + + var past: [PlannedStay] { + Array(sortedStays.filter { $0.departure.latest < report.forecasts.today }.reversed()) + } + + var overlaps: [PlannedStayOverlap] { + report.forecasts.planning.overlaps(asOf: report.forecasts.today) + } + + var isSaving: Bool { + saveState == .saving + } + + private var sortedStays: [PlannedStay] { + report.forecasts.planning.stays.sorted { + if $0.arrival.earliest != $1.arrival.earliest { + return $0.arrival.earliest < $1.arrival.earliest + } + return $0.id.rawValue.uuidString < $1.id.rawValue.uuidString + } + } + + func add() { + editor = PlannedStayEditorModel(report: report, stay: nil, initialRegion: initialRegion) + } + + func edit(_ stay: PlannedStay) { + editor = PlannedStayEditorModel(report: report, stay: stay, initialRegion: nil) + } + + func usesDefiniteOverlap(_ stay: PlannedStay) -> Bool { + overlaps.contains { + ($0.firstStayID == stay.id || $0.secondStayID == stay.id) && $0.certainRange != nil + } + } + + func usesPossibleOverlap(_ stay: PlannedStay) -> Bool { + overlaps.contains { + ($0.firstStayID == stay.id || $0.secondStayID == stay.id) && $0.certainRange == nil + } + } + + func usePastTravelPattern() async { + guard !isSaving, report.forecasts.planning.homeRegion != nil else { return } + saveState = .saving + do { + try await report.forecasts.setHomeRegion(nil) + saveState = .idle + } catch { + saveState = .failed(error.localizedDescription) + } + } + + func load() async { + if !report.forecasts.hasLoaded { await report.forecasts.refresh() } + await regionSelection.load() + } +} diff --git a/Where/WhereUI/Sources/Forecasting/PlannedStaysView.swift b/Where/WhereUI/Sources/Forecasting/PlannedStaysView.swift new file mode 100644 index 000000000..b84ea979a --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlannedStaysView.swift @@ -0,0 +1,213 @@ +import RegionKit +import SFSafeSymbols +import SnapshotKit +import SwiftUI +import WhereCore + +/// Independently editable itinerary and the policy for unplanned future days. +struct PlannedStaysView: View { + @State private var model: PlannedStaysModel + @Environment(\.dismiss) private var dismiss + + init(report: YearReportModel, initialRegion: Region? = nil) { + self.init(model: PlannedStaysModel( + report: report, + initialRegion: initialRegion, + )) + } + + init(model: PlannedStaysModel) { + _model = State(initialValue: model) + } + + var body: some View { + @Bindable var model = model + + NavigationStack { + List { + if let message = model.report.forecasts.loadFailure { + Section { + Label(message, systemSymbol: .exclamationmarkTriangleFill) + .foregroundStyle(.red) + Button(String(localized: .commonRetry)) { + Task { await model.report.forecasts.refresh() } + } + } + } + + Section { + if !model.report.forecasts.hasLoaded, + model.report.forecasts.loadFailure == nil + { + ProgressView() + } else if model.report.forecasts.hasLoaded, model.upcoming.isEmpty { + Text(String(localized: .plannedStaysEmpty)) + .foregroundStyle(.secondary) + } else { + ForEach(model.upcoming) { stay in + stayRow(stay) + } + } + Button( + String(localized: .plannedStaysAdd), + systemSymbol: .plus, + action: model.add, + ) + } header: { + Text(String(localized: .plannedStaysUpcoming)) + } footer: { + Text(String(localized: .plannedStaysFooter)) + } + + if model.report.forecasts.hasLoaded { + gapSection + } + + if !model.past.isEmpty { + Section { + DisclosureGroup( + String(localized: .plannedStaysPast), + isExpanded: $model.showsPast, + ) { + ForEach(model.past) { stay in + stayRow(stay) + } + } + } footer: { + Text(String(localized: .plannedStaysPastFooter)) + } + } + } + .navigationTitle(String(localized: .plannedStaysTitle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: .commonDone), action: dismiss.callAsFunction) + } + } + } + .sheet(item: $model.editor) { editor in + PlannedStayEditor(model: editor) + } + .task { await model.load() } + } + + private var gapSection: some View { + Section { + Button { + Task { await model.usePastTravelPattern() } + } label: { + HStack { + Label( + String(localized: .plannedStaysPastPattern), + systemSymbol: .clockArrowTriangleheadCounterclockwiseRotate90, + ) + .foregroundStyle(.primary) + Spacer(minLength: 0) + if model.report.forecasts.planning.homeRegion == nil { + Image(systemSymbol: .checkmark) + .foregroundStyle(.tint) + } + } + } + .accessibilityAddTraits(model.report.forecasts.planning + .homeRegion == nil ? [.isSelected] : []) + + NavigationLink { + PlanningRegionPickerView( + model: model.regionSelection, + title: String(localized: .plannedStaysHomeRegion), + selectedRegion: model.report.forecasts.planning.homeRegion, + ) { region in + try await model.report.forecasts.setHomeRegion(region) + } + } label: { + LabeledContent { + Text(model.report.forecasts.planning.homeRegion?.localizedName + ?? String(localized: .plannedStayEditorChooseRegion)) + } label: { + Label(String(localized: .plannedStaysHomeRegion), systemSymbol: .house) + } + } + .accessibilityAddTraits(model.report.forecasts.planning + .homeRegion != nil ? [.isSelected] : []) + + if case let .failed(message) = model.saveState { + Label(message, systemSymbol: .exclamationmarkTriangleFill) + .foregroundStyle(.red) + } + } header: { + Text(String(localized: .plannedStaysUnplannedDays)) + } footer: { + Text(String(localized: model.report.forecasts.planning.homeRegion == nil + ? .plannedStaysPastPatternFooter + : .plannedStaysHomeRegionFooter)) + } + .disabled(model.isSaving) + } + + private func stayRow(_ stay: PlannedStay) -> some View { + Button { + model.edit(stay) + } label: { + PlannedStaySummaryRow( + stay: stay, + calendar: model.report.calendar, + hasDefiniteOverlap: model.usesDefiniteOverlap(stay), + hasPossibleOverlap: model.usesPossibleOverlap(stay), + ) + } + .buttonStyle(.plain) + .accessibilityHint(String(localized: .plannedStaysEditHint)) + } +} + +#if DEBUG + extension PlannedStaysView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Empty", configurations: .fullContentPhoneLightDark) { + PlannedStaysView(report: PreviewSupport.loadedYearReportModel()) + } + whereSnapshot(name: "Itinerary", configurations: .fullContentScreenDefaults) { + PlannedStaysView(report: PreviewSupport.itineraryYearReportModel()) + } + whereSnapshot(name: "PastTravelPattern", configurations: .fullContentPhoneLightDark) { + PlannedStaysView(report: PreviewSupport.itineraryYearReportModel(homeRegion: nil)) + } + whereSnapshot( + name: "PastExpanded", + configurations: .fullContentPhoneLightDark + + SnapshotConfiguration.combinations( + devices: [.iPhoneFullContent], + snapshotTypes: [.accessibility], + ), + ) { + PlannedStaysView(model: expandedPastModel()) + } + } + } + + extension PlannedStaysView { + fileprivate static func expandedPastModel() -> PlannedStaysModel { + let model = PlannedStaysModel( + report: PreviewSupport.itineraryYearReportModel(), + initialRegion: nil, + ) + model.showsPast = true + return model + } + } + + #Preview { PlannedStaysView.snapshotPreviews } + + extension PlannedStaysView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.hosted( + PlannedStaysView.self, + title: "Planned Stays", + navigationContainer: .none, + routes: [.modal(to: PlannedStayEditor.flyoverID)], + ) { world in + PlannedStaysView(report: world.report) + } + } +#endif diff --git a/Where/WhereUI/Sources/Forecasting/PlanningRegionPickerView.swift b/Where/WhereUI/Sources/Forecasting/PlanningRegionPickerView.swift new file mode 100644 index 000000000..4583e7777 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlanningRegionPickerView.swift @@ -0,0 +1,119 @@ +import RegionKit +import SFSafeSymbols +import SnapshotKit +import SwiftUI + +/// Shared searchable, grouped single-region picker for a stay or Home. +struct PlanningRegionPickerView: View { + @Bindable var model: PlanningRegionSelectionModel + let title: String + let selectedRegion: Region? + let onSelect: @MainActor (Region) async throws -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.regionStyles) private var regionStyles + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + List { + if case let .failed(message) = model.loadState { + Section { + Label(message, systemSymbol: .exclamationmarkTriangle) + .foregroundStyle(.secondary) + Button(String(localized: .commonRetry)) { + Task { await model.load() } + } + } + } + if case let .failed(message) = model.selectionState { + Section { + Label(message, systemSymbol: .exclamationmarkTriangle) + .foregroundStyle(.red) + } + } + if model.isSearching { + ForEach(model.filteredRegions, id: \.self, content: regionRow) + } else { + GroupedRegionSections(grouping: model.grouping, row: regionRow) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $model.searchText, prompt: String(localized: .regionPickerSearchPrompt)) + .disabled(model.selectionState == .saving) + .overlay { + if model.isSearching, model.filteredRegions.isEmpty { + ContentUnavailableView.search(text: model.searchText) + } + } + .task { + if case .idle = model.loadState { await model.load() } + } + } + + private func regionRow(_ region: Region) -> some View { + Button { + Task { + if await model.select(region, commit: onSelect) { dismiss() } + } + } label: { + HStack(spacing: stylesheet.spacing.medium) { + Text(regionStyles.style(for: region).emoji) + .accessibilityHidden(true) + Text(region.localizedName) + .foregroundStyle(.primary) + Spacer(minLength: 0) + if selectedRegion == region { + Image(systemSymbol: .checkmark) + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .contentShape(.rect) + } + .accessibilityAddTraits(selectedRegion == region ? [.isSelected] : []) + } +} + +#if DEBUG + extension PlanningRegionPickerView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + let homeModel = PlanningRegionSelectionModel(report: PreviewSupport + .loadedYearReportModel()) + whereSnapshot( + name: "Home", + configurations: .fullContentScreenDefaults, + onReadyToMeasure: { await homeModel.load() }, + ) { + NavigationStack { + PlanningRegionPickerView( + model: homeModel, + title: String(localized: .plannedStaysHomeRegion), + selectedRegion: .california, + onSelect: { _ in }, + ) + } + } + whereSnapshot(name: "Search", configurations: .fullContentPhoneLightDark) { + NavigationStack { + PlanningRegionPickerView( + model: searchModel(), + title: String(localized: .plannedStayEditorDestination), + selectedRegion: .newYork, + onSelect: { _ in }, + ) + } + } + } + } + + extension PlanningRegionPickerView { + fileprivate static func searchModel() -> PlanningRegionSelectionModel { + let model = PlanningRegionSelectionModel(report: PreviewSupport.loadedYearReportModel()) + model.searchText = "New" + return model + } + } + + #Preview { PlanningRegionPickerView.snapshotPreviews } +#endif diff --git a/Where/WhereUI/Sources/Forecasting/PlanningRegionSelectionModel.swift b/Where/WhereUI/Sources/Forecasting/PlanningRegionSelectionModel.swift new file mode 100644 index 000000000..8ab807e69 --- /dev/null +++ b/Where/WhereUI/Sources/Forecasting/PlanningRegionSelectionModel.swift @@ -0,0 +1,94 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// Selects one supported destination without modifying automatic tracking. +@MainActor +@Observable +final class PlanningRegionSelectionModel { + enum LoadState { + case idle + case loading + case loaded([PrimaryRegion]) + case failed(String) + } + + enum SelectionState: Equatable { + case idle + case saving + case failed(String) + } + + let report: YearReportModel + let available = PrimaryRegionSelectionModel.usRegions + var searchText = "" + private(set) var loadState: LoadState = .idle + private(set) var selectionState: SelectionState = .idle + + private static let logger = WhereLog.session(PlanningRegionSelectionModelLog.self) + + init(report: YearReportModel) { + self.report = report + } + + var trackedRegions: [Region]? { + guard case let .loaded(regions) = loadState else { return nil } + return regions.map(\.region) + } + + var grouping: RegionGrouping { + RegionGrouping( + available: available, + primary: trackedRegions ?? [], + usedThisYear: Set(report.report?.totals.keys.map(\.self) ?? []), + ) + } + + var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var filteredRegions: [Region] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return available.filter { $0.localizedName.localizedCaseInsensitiveContains(query) } + } + + func load() async { + if case .loading = loadState { return } + loadState = .loading + do { + let regions = try await report.services.primaryRegions() + guard !Task.isCancelled else { + loadState = .idle + return + } + loadState = .loaded(regions) + } catch { + guard !Task.isCancelled else { + loadState = .idle + return + } + Self.logger(attachments: [.error(error, name: "planning-regions-error")]) { + .loadFailed(description: error.localizedDescription) + } + loadState = .failed(error.localizedDescription) + } + } + + func select( + _ region: Region, + commit: @MainActor (Region) async throws -> Void, + ) async -> Bool { + guard selectionState != .saving else { return false } + selectionState = .saving + do { + try await commit(region) + selectionState = .idle + return true + } catch { + selectionState = .failed(error.localizedDescription) + return false + } + } +} diff --git a/Where/WhereUI/Sources/Logging/PlanningRegionSelectionModelLog.swift b/Where/WhereUI/Sources/Logging/PlanningRegionSelectionModelLog.swift new file mode 100644 index 000000000..154ff0ce0 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/PlanningRegionSelectionModelLog.swift @@ -0,0 +1,18 @@ +import PeriscopeCore + +enum PlanningRegionSelectionModelLog: LogEvent { + case loadFailed(description: String) + + static let eventName = "PlanningRegions" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(description): + "Failed to load tracked regions for planning: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 0c66cca2a..beea403d6 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -29,18 +29,10 @@ struct MainTabs: View { let isEnabled: Bool } - private struct PlannedStayEditorTarget: Identifiable { - let region: Region - - var id: Region { - region - } - } - @State private var report: YearReportModel @State private var recordingWarning: RecordingConfigurationWarningModel @State private var welcome: LocationWelcomeModel - @State private var plannedStayEditorTarget: PlannedStayEditorTarget? + @State private var planningDestination: PlannedStaysDestination? @State private var selection: TabID = .locations @Environment(\.scenePhase) private var scenePhase @Environment(\.stylesheet) private var stylesheet @@ -134,12 +126,8 @@ struct MainTabs: View { break } } - .sheet(item: $plannedStayEditorTarget) { target in - PlannedStayEditor( - region: target.region, - model: report.forecasts, - driftThreshold: report.driftThreshold, - ) + .sheet(item: $planningDestination) { destination in + PlannedStaysDestinationView(destination: destination, report: report) } } @@ -169,28 +157,21 @@ struct MainTabs: View { withAnimation(stylesheet.locationWelcome.motion.departure.animation) { welcome.dismiss() } completion: { - plannedStayEditorTarget = PlannedStayEditorTarget(region: region) + planningDestination = .new(region) } } #if DEBUG private init( session: WhereSession, - initialDetails: YearReportDetails?, - selectedYear: Int, + report: YearReportModel, welcome: LocationWelcomeModel, selection: TabID, ) { let recordingWarningSource = RecordingConfigurationWarningModel.Source(session: session) self.recordingWarningSource = recordingWarningSource allowsWelcomeLookup = false - _report = State(initialValue: YearReportModel( - services: session.services, - details: initialDetails, - selectedYear: selectedYear, - preferences: session.preferences, - now: session.now, - )) + _report = State(initialValue: report) _recordingWarning = State(initialValue: RecordingConfigurationWarningModel( preferences: recordingWarningSource.preferences, )) @@ -209,105 +190,106 @@ struct MainTabs: View { let largeTypeConfigurations = [ SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), ] - return [ - whereSnapshot( - name: "WelcomeLocations", - configurations: fastConfigurations, - measurementReadiness: .immediate, - ) { - welcomeSnapshot(selection: .locations) - }, - whereSnapshot( - name: "WelcomeYear", - configurations: fastConfigurations, - measurementReadiness: .immediate, - ) { - welcomeSnapshot(selection: .year) - }, - whereSnapshot( - name: "WelcomeLocating", - configurations: fastConfigurations, - measurementReadiness: .immediate, - ) { - accessorySnapshot(actionRequired: false) - }, - whereSnapshot( - name: "WelcomeActionRequired", - configurations: fastConfigurations, - measurementReadiness: .immediate, - ) { - accessorySnapshot(actionRequired: true) - }, - whereSnapshot( - name: "WelcomeLocations", - configurations: largeTypeConfigurations, - measurementReadiness: .immediate, + return fastConfigurations.flatMap { configuration in + snapshotCases(configurations: [configuration], settle: .settled) + } + largeTypeConfigurations.flatMap { configuration in + snapshotCases( + configurations: [configuration], settle: .settledAtLeast(minDuration: 1.0), - ) { - welcomeSnapshot(selection: .locations) + ) + } + } + + private static func snapshotCases( + configurations: [SnapshotConfiguration], + settle: SnapshotSettle, + ) -> [SnapshotCase] { + [ + snapshot( + name: "WelcomeLocations", + configurations: configurations, + settle: settle, + selection: .locations, + ) { welcome in + welcome.presentForTesting(region: .newYork, greeting: .returnVisit) }, - whereSnapshot( + snapshot( name: "WelcomeYear", - configurations: largeTypeConfigurations, - measurementReadiness: .immediate, - settle: .settledAtLeast(minDuration: 1.0), - ) { - welcomeSnapshot(selection: .year) + configurations: configurations, + settle: settle, + selection: .year, + ) { welcome in + welcome.presentForTesting(region: .newYork, greeting: .returnVisit) }, - whereSnapshot( + snapshot( name: "WelcomeLocating", - configurations: largeTypeConfigurations, - measurementReadiness: .immediate, - settle: .settledAtLeast(minDuration: 1.0), - ) { - accessorySnapshot(actionRequired: false) + configurations: configurations, + settle: settle, + selection: .year, + ) { welcome in + welcome.showLocatingForTesting() }, - whereSnapshot( + snapshot( name: "WelcomeActionRequired", - configurations: largeTypeConfigurations, - measurementReadiness: .immediate, - settle: .settledAtLeast(minDuration: 1.0), - ) { - accessorySnapshot(actionRequired: true) + configurations: configurations, + settle: settle, + selection: .year, + ) { welcome in + welcome.showPreciseLocationActionForTesting() }, ] } - private static func welcomeSnapshot(selection: TabID) -> some View { - snapshot(selection: selection) { welcome in - welcome.presentForTesting(region: .newYork, greeting: .returnVisit) - } - } - - private static func accessorySnapshot(actionRequired: Bool) -> some View { - snapshot(selection: .year) { welcome in - if actionRequired { - welcome.showPreciseLocationActionForTesting() - } else { - welcome.showLocatingForTesting() - } - } - } - private static func snapshot( + name: String, + configurations: [SnapshotConfiguration], + settle: SnapshotSettle, selection: TabID, configure: (LocationWelcomeModel) -> Void, - ) -> some View { - let session = PreviewSupport.loadedSession() + ) -> SnapshotCase { + // Shell snapshots use January to avoid unrelated long-calendar scrolling. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let snapshotNow = calendar.date(from: DateComponents( + year: PreviewSupport.year, + month: 1, + day: 15, + hour: 12, + ))! + let session = PreviewSupport.loadedSession(now: { snapshotNow }) + // Match the fixture store before mounting the tab container so a + // loading transition cannot race the calendar's initial positioning. + let report = YearReportModel( + services: session.services, + details: YearReportDetails( + report: YearReport(year: PreviewSupport.year, days: [], totals: [:]), + primaryRegionLocations: [:], + ), + selectedYear: PreviewSupport.year, + preferences: session.preferences, + now: session.now, + ) let welcome = LocationWelcomeModel( services: session.services, preferences: session.preferences, now: session.now, ) configure(welcome) - return MainTabs( - session: session, - initialDetails: PreviewSupport.sampleYearReportDetails(), - selectedYear: PreviewSupport.year, - welcome: welcome, - selection: selection, - ) - .environment(session) + return whereSnapshot( + name: name, + configurations: configurations, + measurementReadiness: .immediate, + settle: settle, + onReadyToSnapshot: { await report.activate() }, + ) { + MainTabs( + session: session, + report: report, + welcome: welcome, + selection: selection, + ) + .environment(session) + } } } diff --git a/Where/WhereUI/Sources/Model/LocationForecastModel.swift b/Where/WhereUI/Sources/Model/LocationForecastModel.swift index c37beeef5..451b670b8 100644 --- a/Where/WhereUI/Sources/Model/LocationForecastModel.swift +++ b/Where/WhereUI/Sources/Model/LocationForecastModel.swift @@ -3,205 +3,167 @@ 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. +/// Scene-scoped mirror of the synced itinerary. Core owns date projections and +/// estimates; committed store changes are the only production refresh path. @MainActor @Observable final class LocationForecastModel { - struct PlannedStayLocationCheck: Equatable { - enum Status: Equatable { - case checking - case accepted - case outside - case unavailable - } - - let region: Region - let driftThreshold: DriftThreshold - let status: Status - } - - /// The future slice of a planned stay that intersects a displayed year. - struct PlannedInterval: Equatable { - let region: Region - let start: CalendarDay - let end: CalendarDay - - var dayCount: Int { - start.days(through: end).count + private enum LoadState { + case idle + case loading(previous: PlanningSnapshot?) + case loaded(PlanningSnapshot) + case failed(previous: PlanningSnapshot?, message: String) + + var snapshot: PlanningSnapshot? { + switch self { + case .idle: nil + case let .loading(previous), let .failed(previous, _): previous + case let .loaded(snapshot): snapshot + } } } - private(set) var activePlannedStay: PlannedStay? - private(set) var plannedStayLocationCheck: PlannedStayLocationCheck? - + private var loadState: LoadState = .idle + private var refreshSequence: UInt64 = 0 private let services: WhereServices private let calendar: Calendar private let now: @Sendable () -> Date - private var plannedStayLocationCheckSequence: UInt64 = 0 private static let logger = WhereLog.session(LocationForecastModelLog.self) - init( - services: WhereServices, - calendar: Calendar, - now: @escaping @Sendable () -> Date, - ) { + init(services: WhereServices, calendar: Calendar, now: @escaping @Sendable () -> Date) { self.services = services self.calendar = calendar self.now = now } + var planning: PlanningSnapshot { + loadState.snapshot ?? PlanningSnapshot(stays: [], homeRegion: nil) + } + + var hasLoaded: Bool { + loadState.snapshot != nil + } + + var isLoading: Bool { + if case .loading = loadState { return true } + return false + } + + var loadFailure: String? { + guard case let .failed(_, message) = loadState else { return nil } + return message + } + + var today: CalendarDay { + CalendarDay(from: now(), in: calendar) + } + func refresh() async { + guard !Task.isCancelled else { return } + refreshSequence += 1 + let sequence = refreshSequence + let previous = loadState.snapshot + loadState = .loading(previous: previous) + // This read serves the scene, even if its requesting sheet disappears. + // Only a newer read can supersede its success or failure. do { - let stay = try await services.plannedStays.active() - if activePlannedStay != stay { activePlannedStay = stay } + let snapshot = try await services.plannedStays.snapshot() + guard sequence == refreshSequence else { return } + loadState = .loaded(snapshot) } catch { + guard sequence == refreshSequence else { return } Self.logger { .loadFailed(description: error.localizedDescription) } + loadState = .failed(previous: previous, message: error.localizedDescription) } } func forecast(for region: Region, report: YearReport?) -> LocationForecast? { - guard let report else { return nil } + guard let report, hasLoaded else { return nil } return LocationForecast.estimate( region: region, report: report, asOf: now(), calendar: calendar, - plannedStay: activePlannedStay, + planning: planning, ) } - /// 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] { + /// Explicit destinations and Home remain visible even before a recorded + /// visit. Recorded card rankings remain independent of these estimates. + func leadingForecasts(report: YearReport?) -> [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 plannedInterval(intersecting: year) != nil else { return nil } - return activePlannedStay - } - - func plannedInterval(intersecting year: Int) -> PlannedInterval? { - 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) - guard projectedStart <= projectedEnd else { return nil } - return PlannedInterval( - region: stay.region, - start: projectedStart, - end: projectedEnd, - ) - } - - func departureDate(for region: Region) -> Date { - guard let stay = activePlannedStay, stay.region == region else { - return calendar.startOfDay(for: now()) + var regions = Set(report.days.flatMap(\.regions)) + regions.formUnion(planning.stays.filter { + $0.departure.latest > today && $0.arrival.earliest.year <= report.year + }.map(\.region)) + if let home = planning.homeRegion { regions.insert(home) } + regions.remove(.other) + return Region.inCanonicalOrder(regions).compactMap { + forecast(for: $0, report: report) + }.sorted { + if $0.estimatedTotalDays.upper != $1.estimatedTotalDays.upper { + return $0.estimatedTotalDays.upper > $1.estimatedTotalDays.upper + } + return $0.region.rawValue < $1.region.rawValue } - return stay.through.startOfDay(in: calendar) } - var minimumDepartureDate: Date { - calendar.startOfDay(for: now()) + func plannedPresence(on day: CalendarDay) -> PlanningDayPresence { + planning.plannedPresence(on: day, asOf: today) } - func checkCurrentLocation( - for region: Region, - driftThreshold: DriftThreshold, - ) async { - let (sequence, overflow) = plannedStayLocationCheckSequence.addingReportingOverflow(1) - precondition(!overflow, "Planned-stay location check sequence exhausted UInt64.") - plannedStayLocationCheckSequence = sequence - plannedStayLocationCheck = PlannedStayLocationCheck( - region: region, - driftThreshold: driftThreshold, - status: .checking, - ) + func plannedIntervals(intersecting year: Int) -> [PlannedStayInterval] { + planning.stayIntervals(intersecting: year, asOf: today) + } - let result = await services.plannedStayLocation.status( - for: region, - driftThreshold: driftThreshold, - ) - guard !Task.isCancelled, sequence == plannedStayLocationCheckSequence else { return } + func homeIntervals(intersecting year: Int) -> [PlannedHomeInterval] { + planning.homeIntervals(intersecting: year, asOf: today) + } - let status: PlannedStayLocationCheck.Status = switch result { - case .accepted: .accepted - case .outside: .outside - case .unavailable: .unavailable - } - plannedStayLocationCheck = PlannedStayLocationCheck( - region: region, - driftThreshold: driftThreshold, - status: status, - ) + func plannedRegionSummaries(in month: CalendarMonth) -> [PlanningRegionSummary] { + guard let first = month.days.first, let last = month.days.last else { return [] } + let start = CalendarDay(from: first.date, in: calendar) + let end = CalendarDay(from: last.date, in: calendar) + return planning.regionSummaries(in: start ... end, asOf: today) } - func plannedStayLocationCheck( - for region: Region, - driftThreshold: DriftThreshold, - ) -> PlannedStayLocationCheck? { - guard plannedStayLocationCheck?.region == region, - plannedStayLocationCheck?.driftThreshold == driftThreshold - else { - return nil + func create(stay: PlannedStay) async throws { + do { try await services.plannedStays.create(stay) } + catch { + Self.logger { .saveFailed(description: error.localizedDescription) } + throw error } - return plannedStayLocationCheck } - 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 { + func update(stay: PlannedStay) async throws { + do { try await services.plannedStays.update(stay) } + catch { Self.logger { .saveFailed(description: error.localizedDescription) } throw error } } - func clear() async throws { - do { - try await services.plannedStays.clear() - activePlannedStay = nil - } catch { + func delete(stayID: PlannedStay.ID) async throws { + do { try await services.plannedStays.delete(stayID: stayID) } + catch { Self.logger { .clearFailed(description: error.localizedDescription) } throw error } } + + func setHomeRegion(_ region: Region?) async throws { + do { try await services.plannedStays.setHomeRegion(region) } + catch { + Self.logger { .saveFailed(description: error.localizedDescription) } + throw error + } + } } #if DEBUG extension LocationForecastModel { - func setActivePlannedStay(_ stay: PlannedStay?) { - activePlannedStay = stay + @_spi(Testing) public func setPlanning(_ planning: PlanningSnapshot) { + loadState = .loaded(planning) } } #endif diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 057fe64a4..a990be593 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -88,6 +88,18 @@ public final class YearReportModel { } public private(set) var selectedYear: Int + + /// Keeps future itinerary years reachable through the shared year selector. + /// Retain the current selection when plans are hidden or deleted. + var selectableYears: [Int] { + let currentYear = calendar.component(.year, from: referenceDate) + let lastPlannedYear = showsEstimatedTimeAndPlanning + ? forecasts.planning.stays.map(\.departure.latest.year).max() : nil + let firstYear = min(currentYear - 5, selectedYear) + let lastYear = max(currentYear, selectedYear, lastPlannedYear ?? currentYear) + return Array((firstYear ... lastYear).reversed()) + } + private var loadedYear: LoadedYear? public var report: YearReport? { @@ -164,13 +176,10 @@ public final class YearReportModel { } } - /// Enable immediately, or clear the synced plan before hiding every - /// estimated-time surface. A failed clear leaves the preference and UI on. + /// Visibility is local presentation state. Saved stays and the synced Home + /// assumption remain available when estimates are shown again. func setEstimatedTimeAndPlanningEnabled(_ isEnabled: Bool) async throws { guard isEnabled != showsEstimatedTimeAndPlanning else { return } - if !isEnabled { - try await forecasts.clear() - } showsEstimatedTimeAndPlanning = isEnabled } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 18e9422d1..202e6beee 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -83,11 +83,14 @@ /// `UNUserNotificationCenter` permission prompt in previews/tests. @MainActor public static func previewServices() -> WhereServices { - previewServices(locationSource: ScriptedLocationSource()) + previewServices(locationSource: ScriptedLocationSource(), now: { referenceNow }) } @MainActor - private static func previewServices(locationSource: any LocationSource) -> WhereServices { + private static func previewServices( + locationSource: any LocationSource, + now: @escaping @Sendable () -> Date, + ) -> WhereServices { WhereServices( store: try! SwiftDataStore.inMemory(), locationSource: locationSource, @@ -104,7 +107,7 @@ // reference happened to be recorded on July 25, and it had been // silently wrong on every day since — passing only because two // digit glyphs fall under the pixel threshold. - now: { referenceNow }, + now: now, ) } @@ -117,10 +120,16 @@ /// `*YearReportModel()` fixture instead. @MainActor public static func loadedSession() -> WhereSession { + loadedSession(now: { referenceNow }) + } + + /// A fixture whose services and session share the same injected clock. + @MainActor + public static func loadedSession(now: @escaping @Sendable () -> Date) -> WhereSession { WhereSession( - services: previewServices(), + services: previewServices(locationSource: ScriptedLocationSource(), now: now), preferences: previewPreferences(), - now: { referenceNow }, + now: now, ) } @@ -130,6 +139,7 @@ WhereSession( services: previewServices( locationSource: ScriptedLocationSource(authorizationStatus: .whenInUse), + now: { referenceNow }, ), preferences: previewPreferences(), now: { referenceNow }, @@ -313,31 +323,14 @@ /// into `#Preview`. @MainActor public static func loadedYearReportModel() -> YearReportModel { - YearReportModel( - services: previewServices(), - details: sampleYearReportDetails(), - selectedYear: year, - preferences: previewPreferences(), - now: { referenceNow }, - ) - } - - /// Planned-stay editor fixture whose one-shot location result is fixed. - @MainActor - public static func plannedStayEditorYearReportModel( - currentLocation: LocationSample?, - plannedStay: PlannedStay?, - ) -> YearReportModel { - let source = ScriptedLocationSource() - source.setNextRequestedLocation(currentLocation) let model = YearReportModel( - services: previewServices(locationSource: source), + services: previewServices(), details: sampleYearReportDetails(), selectedYear: year, preferences: previewPreferences(), now: { referenceNow }, ) - model.forecasts.setActivePlannedStay(plannedStay) + model.forecasts.setPlanning(PlanningSnapshot(stays: [], homeRegion: nil)) return model } @@ -377,10 +370,67 @@ preferences: preferences, now: { referenceNow }, ) - model.forecasts.setActivePlannedStay(PlannedStay( + model.forecasts.setPlanning(PlanningSnapshot(stays: [plannedStay( region: plannedRegion, + from: today, through: plannedThroughDay, - )) + )], homeRegion: nil)) + return model + } + + /// A validated exact stay for synchronous preview fixtures. + public static func plannedStay( + region: Region, + from: CalendarDay, + through: CalendarDay, + id: UUID = UUID(uuidString: "2FB221D8-F4C7-4B88-9FF5-CC1B4D987370")!, + ) -> PlannedStay { + do { + return try PlannedStay( + id: .init(rawValue: id), + region: region, + arrival: .init(exact: from), + departure: .init(exact: through), + ) + } catch { preconditionFailure("Invalid preview stay: \(error)") } + } + + /// An itinerary with flexible windows, an overlap, and a completed stay. + @MainActor + public static func itineraryYearReportModel(homeRegion: Region? = .california) + -> YearReportModel + { + let model = plannedStayYearReportModel() + do { + let october = try PlannedStay( + id: .init(rawValue: UUID(uuidString: "2FB221D8-F4C7-4B88-9FF5-CC1B4D987371")!), + region: .newYork, + arrival: .init( + earliest: .init(year: year, month: 10, day: 15), + latest: .init(year: year, month: 10, day: 17), + ), + departure: .init( + earliest: .init(year: year, month: 10, day: 30), + latest: .init(year: year, month: 11, day: 4), + ), + ) + let overlap = plannedStay( + region: .california, + from: .init(year: year, month: 11, day: 1), + through: .init(year: year, month: 11, day: 6), + id: UUID(uuidString: "2FB221D8-F4C7-4B88-9FF5-CC1B4D987372")!, + ) + let past = plannedStay( + region: .newYork, + from: .init(year: year, month: 3, day: 1), + through: .init(year: year, month: 3, day: 7), + id: UUID(uuidString: "2FB221D8-F4C7-4B88-9FF5-CC1B4D987373")!, + ) + model.forecasts.setPlanning(PlanningSnapshot( + stays: model.forecasts.planning.stays + [october, overlap, past], + homeRegion: homeRegion, + )) + } catch { preconditionFailure("Invalid itinerary fixture: \(error)") } return model } diff --git a/Where/WhereUI/Sources/Preview/WhereSnapshot.swift b/Where/WhereUI/Sources/Preview/WhereSnapshot.swift index a01d37b9f..721b12c98 100644 --- a/Where/WhereUI/Sources/Preview/WhereSnapshot.swift +++ b/Where/WhereUI/Sources/Preview/WhereSnapshot.swift @@ -8,6 +8,7 @@ /// wrapper. Use this instead of `SnapshotCase(...)` directly when authoring /// WhereUI ``SnapshotProviding`` conformances. /// + /// `onReadyToMeasure` awaits content that changes the Form/List height. /// `onReadyToSnapshot` passes through to ``SnapshotCase``: the capture /// pipeline runs it after the content settles and re-settles its effects — /// the seam for a deterministic completion signal (e.g. awaiting a launch @@ -17,6 +18,7 @@ name: String, configurations: [SnapshotConfiguration], measurementReadiness: SnapshotMeasurementReadiness = .sameAsCapture, + onReadyToMeasure: (@MainActor () async -> Void)? = nil, settle: SnapshotSettle = .settled, onReadyToSnapshot: (@MainActor () async -> Void)? = nil, @ViewBuilder content: @escaping @MainActor () -> some View, @@ -25,6 +27,7 @@ name: name, configurations: configurations, measurementReadiness: measurementReadiness, + onReadyToMeasure: onReadyToMeasure, settle: settle, onReadyToSnapshot: onReadyToSnapshot, ) { diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index a2de4ede4..cf22e6b41 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -20,7 +20,7 @@ struct CalendarContentView: View { @Environment(\.stylesheet) private var stylesheet @State private var monthsLoad: Result<[CalendarMonth], Error>? - @State private var plannedStayEditorTarget: PlannedStayEditorTarget? + @State private var planningDestination: PlannedStaysDestination? @State private var initiallyPositionedYear: Int? @State private var scrollPosition = ScrollPosition(idType: String.self) @@ -35,14 +35,6 @@ struct CalendarContentView: View { let focusedRegion: Region? } - private struct PlannedStayEditorTarget: Identifiable { - let region: Region - - var id: Region { - region - } - } - var body: some View { Group { if let yearReport = report.report { @@ -91,12 +83,20 @@ 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(item: $plannedStayEditorTarget) { target in - PlannedStayEditor( - region: target.region, - model: report.forecasts, - driftThreshold: report.driftThreshold, - ) + .sheet(item: $planningDestination) { destination in + PlannedStaysDestinationView(destination: destination, report: report) + } + .toolbar { + if report.showsEstimatedTimeAndPlanning { + ToolbarItem(placement: .topBarTrailing) { + Button( + String(localized: .plannedStaysTitle), + systemSymbol: .calendarBadgeClock, + ) { + planningDestination = .list + } + } + } } } @@ -146,7 +146,9 @@ struct CalendarContentView: View { month: month, focusedRegion: focusedRegion, dateCalendar: report.calendar, - plannedRegion: displayedPlannedRegion(on:), + plannedPresence: displayedPlannedPresence(on:), + planningSummary: report.showsEstimatedTimeAndPlanning + ? report.forecasts.plannedRegionSummaries(in: month) : [], ) // In chronological flow, the estimate belongs immediately @@ -155,14 +157,8 @@ struct CalendarContentView: View { LocationForecastPanel( forecasts: calendarForecasts, microprintRegions: report.ranking.primary.map(\.region), - plannedStay: report.forecasts.activePlannedStay, - editableRegions: editableForecastRegions, - editAction: { region in - plannedStayEditorTarget = PlannedStayEditorTarget( - region: region, - ) - }, - clearAction: report.forecasts.clear, + homeRegion: report.forecasts.planning.homeRegion, + planningAction: { planningDestination = .list }, ) } } @@ -192,33 +188,12 @@ struct CalendarContentView: View { return report.forecasts.forecast(for: focusedRegion, report: report.report).map { [$0] } ?? [] } - return report.ranking.primary.compactMap { - report.forecasts.forecast(for: $0.region, report: report.report) - } - } - - private var editableForecastRegions: [Region] { - if let focusedRegion { - return report.forecasts.isCurrent(focusedRegion, report: report.report) - ? [focusedRegion] - : [] - } - return report.ranking.primary.map(\.region) + return report.forecasts.leadingForecasts(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? { + private func displayedPlannedPresence(on day: CalendarDay) -> PlanningDayPresence? { guard report.showsEstimatedTimeAndPlanning else { return nil } - guard let year = report.report?.year else { return nil } - guard let stay = report.forecasts.plannedStay(intersecting: year) else { return nil } - guard focusedRegion == nil || focusedRegion == stay.region else { return nil } - return stay - } - - private func displayedPlannedRegion(on day: CalendarDay) -> Region? { - guard report.showsEstimatedTimeAndPlanning else { return nil } - return report.forecasts.plannedRegion(on: day) + return report.forecasts.plannedPresence(on: day) } /// The months to show in chronological order. Future months are omitted @@ -231,12 +206,23 @@ struct CalendarContentView: View { else { return months } - let lastShownMonth = displayedPlannedStay.flatMap { stay in - report.calendar.date(from: DateComponents( - year: stay.through.year, - month: stay.through.month, - day: 1, - )) + let lastPlannedDay = report.showsEstimatedTimeAndPlanning + ? report.forecasts.plannedIntervals(intersecting: report.selectedYear) + .filter { focusedRegion == nil || $0.region == focusedRegion } + .map(\.end).max() + : nil + let showsHome = report.showsEstimatedTimeAndPlanning + && report.forecasts.planning.homeRegion != nil + && (focusedRegion == nil || report.forecasts.planning.homeRegion == focusedRegion) + let finalDay = showsHome ? CalendarDay.lastDay(ofYear: report.selectedYear) : lastPlannedDay + if finalDay == nil, + report.selectedYear > report.calendar.component(.year, from: report.referenceDate) + { + // The year selector retains this selection after plans are hidden or deleted. + return months + } + let lastShownMonth = finalDay.map { + CalendarDay(year: $0.year, month: $0.month, day: 1).startOfDay(in: report.calendar) }.map { max(currentMonthStart, $0) } ?? currentMonthStart return months.filter { $0.startOfMonth <= lastShownMonth } } @@ -249,7 +235,8 @@ private struct MonthGridView: View { /// The region the calendar is focused on, if any — emphasized in the footer. var focusedRegion: Region? let dateCalendar: Calendar - let plannedRegion: (CalendarDay) -> Region? + let plannedPresence: (CalendarDay) -> PlanningDayPresence? + let planningSummary: [PlanningRegionSummary] @Environment(\.stylesheet) private var stylesheet @@ -279,21 +266,7 @@ private struct MonthGridView: View { regionCombinationTotals: month.regionCombinationTotals, needsAttentionDays: month.days.count(where: \.needsAttention), evidenceDays: month.days.count(where: \.hasEvidence), - plannedRegionTotals: plannedRegionTotals, - ) - } - - private var plannedRegionTotals: [RegionDayTally] { - var counts: [Region: Int] = [:] - for day in month.days { - if let region = plannedRegion(on: day) { - counts[region, default: 0] += 1 - } - } - return Region.rankedByDayCount( - counts.map { RegionDayTally(region: $0.key, days: $0.value) }, - days: \.days, - region: \.region, + plannedRegionTotals: [], ) } @@ -312,7 +285,8 @@ private struct MonthGridView: View { ) { ForEach(month.weekdaySymbols, id: \.self) { symbol in Text(symbol) - .font(.caption2) + .font(.system(size: calendar.month.weekdayFontSize)) + .lineLimit(1) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) } @@ -323,13 +297,39 @@ private struct MonthGridView: View { } ForEach(Array(month.days.enumerated()), id: \.element.id) { index, day in - DayCell(day: day, band: bandGeometry(at: index)) + DayCell(day: day, band: bandGeometry(at: index), dateCalendar: dateCalendar) } } if !month.regionTotals.isEmpty { MonthFooter(totals: month.regionTotals, focusedRegion: focusedRegion) } + ForEach( + planningSummary.filter { focusedRegion == nil || $0.region == focusedRegion }, + id: \.region, + ) { summary in + VStack(alignment: .leading, spacing: calendar.month.footerSpacing) { + if summary.plannedDays.upper > 0 { + planningSummaryRow( + region: summary.region, + days: summary.plannedDays, + kind: summary.plannedDays.isExact + ? .planningCalendarPlanned : .planningCalendarPossible, + symbol: summary.plannedDays.isExact ? .lineDiagonal : .circleDashed, + ) + } + if summary.homeDays.upper > 0 { + planningSummaryRow( + region: summary.region, + days: summary.homeDays, + kind: .planningCalendarHome, + symbol: .house, + ) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } } .padding(calendar.month.padding) .foregroundStyle(card.foreground) @@ -342,11 +342,38 @@ private struct MonthGridView: View { cardShape.strokeBorder(card.border, lineWidth: card.borderWidth) } } - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel(monthName) .accessibilityValue(accessibilityValue) } + @ViewBuilder + private func planningSummaryRow( + region: Region, + days: DayBounds, + kind: LocalizedStringResource, + symbol: SFSymbol, + ) -> some View { + let title = String(localized: .planningCalendarMonthSummary( + region.localizedName, + String(localized: kind), + )) + if calendar.month.stacksFooter { + VStack(alignment: .leading, spacing: calendar.month.footerSpacing) { + Label(title, systemSymbol: symbol) + .fixedSize(horizontal: false, vertical: true) + Text(WhereFormat.dayCount(days)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LabeledContent { + Text(WhereFormat.dayCount(days)) + } label: { + Label(title, systemSymbol: symbol) + } + } + } + /// The stay-pill geometry for the day at `index`: a run is contiguous days /// with the identical region set, so its true ends round fully while a run /// spilling across a week boundary rounds subtly (and same-row neighbours @@ -358,22 +385,22 @@ private struct MonthGridView: View { guard !regions.isEmpty else { return .none } let regionSet = Set(regions) - let isPlanned = plannedRegion(on: day) != nil + let projected = memberships(for: day) let column = (month.leadingBlankCount + index) % month.weekdayCount let isRowStart = column == 0 let isRowEnd = column == month.weekdayCount - 1 let joinsLeft = index > 0 && Set(displayedRegions(for: days[index - 1])) == regionSet - && (plannedRegion(on: days[index - 1]) != nil) == isPlanned + && memberships(for: days[index - 1]) == projected let joinsRight = index < days.count - 1 && Set(displayedRegions(for: days[index + 1])) == regionSet - && (plannedRegion(on: days[index + 1]) != nil) == isPlanned + && memberships(for: days[index + 1]) == projected let band = calendar.regionBand let halfGap = calendar.month.gridSpacing / 2 return DayBandGeometry( regions: regions, - isPlanned: isPlanned, + memberships: projected, column: column, leadingRadius: joinsLeft ? (isRowStart ? band.continuationRadius : 0) : band .cornerRadius, @@ -385,18 +412,18 @@ private struct MonthGridView: View { } 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) + Region.inCanonicalOrder(Set(day.regions).union(memberships(for: day).keys)) } - private func plannedRegion(on day: CalendarDayCell) -> Region? { + private func memberships(for day: CalendarDayCell) -> [Region: PlanningDayPresence.Membership] { 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 + guard let presence = plannedPresence(key) else { return [:] } + var regions = presence.possibleRegions + if let home = presence.homeAssumption { regions.insert(home.region) } + if let focusedRegion { regions = regions.intersection([focusedRegion]) } + return Dictionary(uniqueKeysWithValues: regions.compactMap { region in + presence.membership(in: region).map { (region, $0) } + }) } } @@ -405,7 +432,20 @@ private struct MonthGridView: View { /// one connected shape. Empty `regions` means no pill. private struct DayBandGeometry { var regions: [Region] - var isPlanned: Bool + var memberships: [Region: PlanningDayPresence.Membership] + var isPlanned: Bool { + !memberships.isEmpty + } + + var hasPossible: Bool { + memberships.values.contains { membership in + switch membership { + case .planned(.possible), .homeAssumed(.possible): true + case .planned(.certain), .homeAssumed(.certain): false + } + } + } + var column: Int var leadingRadius: CGFloat var trailingRadius: CGFloat @@ -414,7 +454,7 @@ private struct DayBandGeometry { static let none = DayBandGeometry( regions: [], - isPlanned: false, + memberships: [:], column: 0, leadingRadius: 0, trailingRadius: 0, @@ -449,22 +489,31 @@ private struct MonthFooter: View { private func row(for tally: RegionDayTally) -> some View { let isFocused = tally.region == focusedRegion - return HStack(spacing: calendar.month.footerRowSpacing) { - Circle() - .fill(regionStyles.style(for: tally.region).tint) - .frame( - width: calendar.dotSize, - height: calendar.dotSize, - ) - Text(tally.region.localizedName) - .font(.subheadline) - .fontWeight(isFocused ? .semibold : .regular) - Spacer(minLength: 0) + let layout = calendar.month.stacksFooter + ? AnyLayout(VStackLayout(alignment: .leading, spacing: calendar.month.footerRowSpacing)) + : AnyLayout(HStackLayout(spacing: calendar.month.footerRowSpacing)) + return layout { + HStack(spacing: calendar.month.footerRowSpacing) { + Circle() + .fill(regionStyles.style(for: tally.region).tint) + .frame( + width: calendar.dotSize, + height: calendar.dotSize, + ) + Text(tally.region.localizedName) + .font(.subheadline) + .fontWeight(isFocused ? .semibold : .regular) + .fixedSize(horizontal: false, vertical: true) + } + if !calendar.month.stacksFooter { + Spacer(minLength: 0) + } Text(WhereFormat.dayCount(tally.days)) .font(.subheadline) .monospacedDigit() .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) .opacity(focusedRegion == nil || isFocused ? 1 : calendar.month.unfocusedRowOpacity) } } @@ -476,6 +525,7 @@ private struct DayCell: View { let day: CalendarDayCell /// The stay-pill slice for this day (computed by the enclosing month). let band: DayBandGeometry + let dateCalendar: Calendar @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles @@ -487,8 +537,9 @@ private struct DayCell: View { var body: some View { VStack(spacing: calendar.day.numberDotSpacing) { Text("\(day.dayOfMonth)") - .font(.callout) + .font(.system(size: calendar.day.numberFontSize)) .monospacedDigit() + .lineLimit(1) .foregroundStyle(dayNumberColor) .frame(width: calendar.day.numberSize, height: calendar.day.numberSize) .background { @@ -527,6 +578,20 @@ private struct DayCell: View { .background { stayPill } .frame(minHeight: calendar.day.minHeight) .contentShape(Rectangle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(day.date.formatted(Date.FormatStyle( + date: .complete, + time: .omitted, + calendar: dateCalendar, + timeZone: dateCalendar.timeZone, + ))) + .accessibilityValue(band.regions.map { region in + guard let membership = band.memberships[region] else { return region.localizedName } + return String(localized: .planningCalendarMonthSummary( + region.localizedName, + WhereFormat.planningMembership(membership), + )) + }.joined(separator: "; ")) } /// Region-presence dots beneath the day number (one per region the day @@ -537,15 +602,20 @@ private struct DayCell: View { let isCluster = band.regions.count > 1 return HStack(spacing: isCluster ? -calendar.day.dotOverlap : calendar.day.contentSpacing) { ForEach(band.regions, id: \.self) { region in - Circle() - .fill(regionStyles.style(for: region).tint) - .frame(width: calendar.day.dotSize, height: calendar.day.dotSize) - .overlay { - Circle().stroke( - Color(.systemBackground), - lineWidth: calendar.day.dotStrokeWidth, - ) + Group { + switch band.memberships[region] { + case .homeAssumed: + Image(systemSymbol: .houseFill) + .resizable() + .scaledToFit() + case .planned(.possible): + Circle().strokeBorder(lineWidth: calendar.day.dotStrokeWidth) + case .planned(.certain), nil: + Circle() } + } + .foregroundStyle(regionStyles.style(for: region).tint) + .frame(width: calendar.day.dotSize, height: calendar.day.dotSize) } } .frame(height: calendar.day.dotSize) @@ -588,6 +658,16 @@ private struct DayCell: View { .opacity(calendar.regionBand.planned.hatchOpacity) .clipShape(shape) } + if band.hasPossible { + shape.strokeBorder( + band.regions.first + .map { regionStyles.style(for: $0).tint } ?? .accentColor, + style: StrokeStyle( + lineWidth: calendar.regionBand.planned.hatchLineWidth, + dash: [calendar.regionBand.planned.hatchSpacing], + ), + ) + } } .frame( width: proxy.size.width + band.extendLeading + band.extendTrailing, @@ -622,6 +702,11 @@ private struct DayCell: View { #if DEBUG extension CalendarContentView: SnapshotProviding { static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Itinerary", configurations: .fullContentScreenDefaults) { + NavigationStack { + CalendarContentView(report: PreviewSupport.itineraryYearReportModel()) + } + } whereSnapshot(name: "WithData", configurations: .fullContentScreenDefaults) { NavigationStack { CalendarContentView(report: PreviewSupport.loadedYearReportModel()) @@ -637,6 +722,23 @@ private struct DayCell: View { CalendarContentView(report: PreviewSupport.emptyYearReportModel()) } } + whereSnapshot(name: "FutureWithoutPlans", configurations: .fullContentPhoneLightDark) { + let base = PreviewSupport.loadedYearReportModel() + let year = base.selectedYear + 1 + let future = YearReportModel( + services: base.services, + details: YearReportDetails( + report: YearReport(year: year, days: [], totals: [:]), + primaryRegionLocations: [:], + ), + selectedYear: year, + preferences: base.preferences, + now: base.now, + ) + NavigationStack { + CalendarContentView(report: future) + } + } whereSnapshot(name: "MissingDays", configurations: .fullContentPhoneLightDark) { NavigationStack { CalendarContentView(report: PreviewSupport.missingDaysYearReportModel()) diff --git a/Where/WhereUI/Sources/Primary/LocationCardEstimateSticker.swift b/Where/WhereUI/Sources/Primary/LocationCardEstimateSticker.swift index 8390f77f4..9c7635a25 100644 --- a/Where/WhereUI/Sources/Primary/LocationCardEstimateSticker.swift +++ b/Where/WhereUI/Sources/Primary/LocationCardEstimateSticker.swift @@ -1,10 +1,11 @@ import SwiftUI +import WhereCore /// Pairs a primary card's recorded total with its annual estimate, rendered as /// a compact visa-style endorsement that restacks when horizontal room runs out. struct LocationCardEstimateSticker: View { let recordedDays: Int - let estimatedDays: Int + let estimatedDays: DayBounds let regionTint: Color let securityPrintTint: Color let card: WhereStylesheet.CardStyle @@ -28,7 +29,8 @@ struct LocationCardEstimateSticker: View { Text(WhereFormat.dayCount(estimatedDays)) .font(style.valueTypography.font.scaled(by: scale)) .monospacedDigit() - .contentTransition(transition.transition(days: estimatedDays)) + .contentTransition(estimatedDays.isExact ? transition + .transition(days: estimatedDays.lower) : .opacity) } .foregroundStyle(securityPrintTint.opacity(style.contentOpacity)) .padding(.horizontal, style.horizontalPadding * scale) @@ -79,7 +81,7 @@ struct LocationCardEstimateSticker: View { let stylesheet = WhereStylesheet.default LocationCardEstimateSticker( recordedDays: 148, - estimatedDays: 276, + estimatedDays: DayBounds(lower: 269, upper: 276), regionTint: .orange, securityPrintTint: .orange, card: stylesheet.card.regular, diff --git a/Where/WhereUI/Sources/Primary/LocationsPlanningMenu.swift b/Where/WhereUI/Sources/Primary/LocationsPlanningMenu.swift deleted file mode 100644 index 6b98a04b1..000000000 --- a/Where/WhereUI/Sources/Primary/LocationsPlanningMenu.swift +++ /dev/null @@ -1,91 +0,0 @@ -import RegionKit -import SFSafeSymbols -import SwiftUI -import WhereCore - -/// Compact Locations-toolbar access to the single planned stay. -struct LocationsPlanningMenu: View { - let primaryRegions: [Region] - var plannedStay: PlannedStay? - let isClearing: Bool - let editAction: (Region) -> Void - let clearAction: () -> Void - - var body: some View { - if isClearing { - ProgressView() - .frame(minWidth: 44, minHeight: 44) - .accessibilityLabel(String(localized: .locationForecastClearingStay)) - } else { - Menu { - if let plannedStay { - Section(String(localized: .locationsPlanningCurrentSection)) { - Button { - editAction(plannedStay.region) - } label: { - Label( - WhereFormat.locationsPlanningEdit(region: plannedStay.region), - systemSymbol: .checkmark, - ) - } - - Button(role: .destructive, action: clearAction) { - Label( - String(localized: .locationForecastRemovePlan), - systemSymbol: .trash, - ) - } - } - } - - let assignableRegions = primaryRegions.filter { $0 != plannedStay?.region } - if !assignableRegions.isEmpty { - Section(String(localized: .locationsPlanningAssignSection)) { - ForEach(assignableRegions, id: \.self) { region in - Button(WhereFormat.locationsPlanningAssign(region: region)) { - editAction(region) - } - } - } - } - } label: { - Label( - String(localized: .locationsPlanningMenu), - systemSymbol: .calendarBadgeClock, - ) - } - .accessibilityValue(accessibilityValue) - .accessibilityIdentifier("where_planning_menu") - } - } - - private var accessibilityValue: String { - guard let plannedStay else { - return String(localized: .locationsPlanningNoCurrentValue) - } - return WhereFormat.locationsPlanningCurrentValue(region: plannedStay.region) - } -} - -#if DEBUG - #Preview { - NavigationStack { - Color.clear - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - LocationsPlanningMenu( - primaryRegions: [.california, .newYork], - plannedStay: PlannedStay( - region: .newYork, - through: CalendarDay(year: 2026, month: 8, day: 15), - ), - isClearing: false, - editAction: { _ in }, - clearAction: {}, - ) - } - } - } - .whereBroadwayRoot() - } -#endif diff --git a/Where/WhereUI/Sources/Primary/LocationsPlanningModel.swift b/Where/WhereUI/Sources/Primary/LocationsPlanningModel.swift deleted file mode 100644 index e52f12ea5..000000000 --- a/Where/WhereUI/Sources/Primary/LocationsPlanningModel.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Observation - -/// Transactional state for removing the Locations tab's single planned stay. -@MainActor -@Observable -final class LocationsPlanningModel { - private enum OperationState: Equatable { - case idle - case clearing - case failed(String) - } - - private var operationState: OperationState = .idle - - var isClearing: Bool { - operationState == .clearing - } - - var presentedFailure: String? { - guard case let .failed(message) = operationState else { return nil } - return message - } - - var isShowingError: Bool { - get { presentedFailure != nil } - set { - if !newValue, presentedFailure != nil { - operationState = .idle - } - } - } - - func clear(using action: @MainActor () async throws -> Void) async { - guard !isClearing else { return } - operationState = .clearing - do { - try await action() - operationState = .idle - } catch { - operationState = .failed(error.localizedDescription) - } - } -} diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index a57e99e7a..35491ea78 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -13,10 +13,9 @@ struct LocationsView: View { let report: YearReportModel @State private var showingResolution = false - @State private var plannedStayEditorTarget: PlannedStayEditorTarget? + @State private var planningDestination: PlannedStaysDestination? @State private var isCardSurfaceVisible = false @State private var cardPresentation: LocationCardsPresentationModel - @State private var planning = LocationsPlanningModel() /// Drives the region cards' tilt-reactive light sheen. Started/stopped /// with the view's lifecycle; a no-op on hardware without device motion. @@ -33,8 +32,7 @@ struct LocationsView: View { private var isCardSurfaceUncovered: Bool { isCardSurfaceVisible && !showingResolution - && plannedStayEditorTarget == nil - && !planning.isShowingError + && planningDestination == nil } init(report: YearReportModel) { @@ -46,8 +44,6 @@ struct LocationsView: View { } var body: some View { - @Bindable var planning = planning - NavigationStack { screen .navigationBarTitleDisplayMode(.inline) @@ -65,13 +61,13 @@ struct LocationsView: View { } if showsPlanningMenu { - LocationsPlanningMenu( - primaryRegions: primaryRegions, - plannedStay: report.forecasts.activePlannedStay, - isClearing: planning.isClearing, - editAction: editPlannedStay, - clearAction: clearPlannedStay, - ) + Button( + String(localized: .plannedStaysTitle), + systemSymbol: .calendarBadgeClock, + ) { + planningDestination = .list + } + .accessibilityIdentifier("where_planning_menu") } } } @@ -81,21 +77,8 @@ struct LocationsView: View { .sheet(isPresented: $showingResolution) { ResolutionView(report: report) } - .sheet(item: $plannedStayEditorTarget) { target in - PlannedStayEditor( - region: target.region, - model: report.forecasts, - driftThreshold: report.driftThreshold, - ) - } - .alert( - String(localized: .locationsPlanningRemoveErrorTitle), - isPresented: $planning.isShowingError, - presenting: planning.presentedFailure, - ) { _ in - Button(String(localized: .commonOk), role: .cancel) {} - } message: { message in - Text(message) + .sheet(item: $planningDestination) { destination in + PlannedStaysDestinationView(destination: destination, report: report) } // Log View Mode: reveal an inspect badge for the year-report events // backing this screen. A no-op in release. @@ -204,6 +187,8 @@ struct LocationsView: View { } } + estimatePanel + // Fold Elsewhere in at the bottom — only when there's // something in it — as an entry card into the full list. if !report.ranking.secondary.isEmpty { @@ -243,32 +228,13 @@ struct LocationsView: View { private var showsPlanningMenu: Bool { report.showsEstimatedTimeAndPlanning - && (!primaryRegions.isEmpty || report.forecasts.activePlannedStay != nil) } - private func estimatedDays(for region: Region) -> Int? { + private func estimatedDays(for region: Region) -> DayBounds? { guard report.showsEstimatedTimeAndPlanning else { return nil } return report.forecasts.forecast(for: region, report: report.report)?.estimatedTotalDays } - private func editPlannedStay(_ region: Region) { - plannedStayEditorTarget = PlannedStayEditorTarget(region: region) - } - - private func clearPlannedStay() { - Task { - await planning.clear(using: report.forecasts.clear) - } - } - - private struct PlannedStayEditorTarget: Identifiable { - let region: Region - - var id: Region { - region - } - } - /// 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. @@ -281,28 +247,58 @@ struct LocationsView: View { .navigationTransition(.zoom(sourceID: region, in: calendarTransition)) } + @ViewBuilder + private var estimatePanel: some View { + let forecasts = report.forecasts.leadingForecasts(report: report.report) + if report.showsEstimatedTimeAndPlanning, !forecasts.isEmpty { + LocationForecastPanel( + forecasts: forecasts, + microprintRegions: primaryRegions, + homeRegion: report.forecasts.planning.homeRegion, + planningAction: { planningDestination = .list }, + isCollapsible: true, + ) + } + } + private var emptyState: some View { - ContentUnavailableView { - Label(WhereFormat.primaryEmptyTitle(year: report.selectedYear), systemSymbol: .map) - } description: { - Text(String(localized: .primaryEmptyDescription)) + ScrollView { + VStack(spacing: stylesheet.spacing.large) { + ContentUnavailableView { + Label( + WhereFormat.primaryEmptyTitle(year: report.selectedYear), + systemSymbol: .map, + ) + } description: { + Text(String(localized: .primaryEmptyDescription)) + } + estimatePanel + } + .padding() } } private var elsewhereOnlyState: some View { - ContentUnavailableView { - Label(String(localized: .primaryElsewhereOnlyTitle), systemSymbol: .globeAmericas) - } description: { - Text(WhereFormat.primaryElsewhereOnlyDescription(count: report.trackedDayCount)) - } actions: { - // Everything tracked is Elsewhere, so surface the list directly — - // there's no Elsewhere tab to send them to anymore. - if !report.ranking.secondary.isEmpty { - NavigationLink(String(localized: .primaryElsewhereOnlyOpen)) { - ElsewhereView(report: report) + ScrollView { + VStack(spacing: stylesheet.spacing.large) { + ContentUnavailableView { + Label( + String(localized: .primaryElsewhereOnlyTitle), + systemSymbol: .globeAmericas, + ) + } description: { + Text(WhereFormat.primaryElsewhereOnlyDescription(count: report.trackedDayCount)) + } actions: { + if !report.ranking.secondary.isEmpty { + NavigationLink(String(localized: .primaryElsewhereOnlyOpen)) { + ElsewhereView(report: report) + } + .buttonStyle(.borderedProminent) + } } - .buttonStyle(.borderedProminent) + estimatePanel } + .padding() } } } @@ -338,6 +334,13 @@ private struct ResolveToolbarLabel: View { /// material adaptation (seen pre-adaptation once on the equivalent /// pre-split screen) — same mechanism as `RootView.LoggedIn`. static var snapshots: [SnapshotCase] { + whereSnapshot( + name: "Itinerary", + configurations: .fullContentScreenDefaults, + measurementReadiness: .immediate, + ) { + LocationsView(report: PreviewSupport.itineraryYearReportModel()) + } whereSnapshot( name: "Loaded", configurations: .fullContentScreenDefaults, @@ -381,6 +384,15 @@ private struct ResolveToolbarLabel: View { ) { LocationsView(report: PreviewSupport.elsewhereOnlyYearReportModel()) } + whereSnapshot( + name: "ElsewhereWithHome", + configurations: .fullContentPhoneLightDark, + measurementReadiness: .immediate, + ) { + let report = PreviewSupport.elsewhereOnlyYearReportModel() + report.forecasts.setPlanning(PlanningSnapshot(stays: [], homeRegion: .california)) + return LocationsView(report: report) + } whereSnapshot( name: "DotsHidden", configurations: .fullContentPhoneLightDark, @@ -410,6 +422,8 @@ private struct ResolveToolbarLabel: View { .push(to: CalendarContentView.flyoverID), .push(to: ElsewhereView.flyoverID), .modal(to: ResolutionView.flyoverID), + .modal(to: PlannedStaysView.flyoverID), + .modal(to: PlannedStayEditor.flyoverID), ], ) { id, world in let state = WhereFlyoverLocationsState(report: world.report) diff --git a/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyCardContent.swift b/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyCardContent.swift index 429cfd6e5..ce4f5593d 100644 --- a/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyCardContent.swift +++ b/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyCardContent.swift @@ -1,10 +1,12 @@ import SwiftUI +import WhereCore /// Lays out a planned stay as a full row or a compact joined continuation. struct PlannedPresenceJourneyCardContent: View { let regionName: String let dateRange: String - let dayCount: Int + let dayCount: DayBounds + let detailLabel: String let daysInYear: Int let position: PresenceJourneyCardPosition let tint: Color @@ -22,7 +24,7 @@ struct PlannedPresenceJourneyCardContent: View { ? planned.joinedBaseHeight : row.baseHeight let proportionalHeight = baseHeight - + row.yearScaleHeight * CGFloat(dayCount) / CGFloat(daysInYear) + + row.yearScaleHeight * CGFloat(dayCount.upper) / CGFloat(daysInYear) countLayout { if position.isJoinedContinuation { @@ -64,7 +66,7 @@ struct PlannedPresenceJourneyCardContent: View { Text(dateRange) .font(.subheadline) .foregroundStyle(.secondary) - Text(String(localized: .timelinePlannedStay)) + Text(detailLabel) .font(.caption) .foregroundStyle(.secondary) .opacity(planned.labelOpacity) @@ -99,7 +101,8 @@ struct PlannedPresenceJourneyCardContent: View { PlannedPresenceJourneyCardContent( regionName: "New York", dateRange: "Jul 16 – Aug 15", - dayCount: 31, + dayCount: DayBounds(exact: 31), + detailLabel: String(localized: .planningCalendarPlanned), daysInYear: 365, position: .bottom, tint: .blue, @@ -107,7 +110,8 @@ struct PlannedPresenceJourneyCardContent: View { PlannedPresenceJourneyCardContent( regionName: "New York", dateRange: "Jul 16 – Aug 15", - dayCount: 31, + dayCount: DayBounds(exact: 31), + detailLabel: String(localized: .planningCalendarPlanned), daysInYear: 365, position: .standalone, tint: .blue, diff --git a/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyRow.swift b/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyRow.swift index 1e8424357..45813078e 100644 --- a/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyRow.swift +++ b/Where/WhereUI/Sources/Primary/PlannedPresenceJourneyRow.swift @@ -4,10 +4,11 @@ import WhereCore /// A lighter hatched journey row for the future slice of a planned stay. struct PlannedPresenceJourneyRow: View { - let interval: LocationForecastModel.PlannedInterval + let item: PlanningTimelineItem let calendar: Calendar let daysInYear: Int let isFirst: Bool + let isLast: Bool let cardPosition: PresenceJourneyCardPosition @Environment(\.stylesheet) private var stylesheet @@ -18,9 +19,9 @@ struct PlannedPresenceJourneyRow: View { let rail = timeline.rail let row = timeline.row let planned = timeline.planned - let style = regionStyles.style(for: interval.region) - let start = interval.start.startOfDay(in: calendar) - let end = interval.end.startOfDay(in: calendar) + let style = regionStyles.style(for: item.region) + let start = item.start.startOfDay(in: calendar) + let end = item.end.startOfDay(in: calendar) let dateRange = DateRangeFormatting.abbreviated( start: start, end: end, @@ -31,9 +32,10 @@ struct PlannedPresenceJourneyRow: View { .frame(width: rail.nodeSize) PlannedPresenceJourneyCardContent( - regionName: interval.region.localizedName, + regionName: item.region.localizedName, dateRange: dateRange, - dayCount: interval.dayCount, + dayCount: item.dayCount, + detailLabel: WhereFormat.planningMembership(item.membership), daysInYear: daysInYear, position: cardPosition, tint: style.tint, @@ -59,47 +61,37 @@ struct PlannedPresenceJourneyRow: View { tint: style.tint.opacity(planned.labelOpacity), emoji: style.emoji, isFirst: isFirst, - isLast: true, + isLast: isLast, ) } .accessibilityElement(children: .ignore) - .accessibilityLabel( - WhereFormat.timelinePlannedRowAccessibility( - region: interval.region.localizedName, - range: dateRange, - days: interval.dayCount, - ), - ) + .accessibilityLabel([ + item.region.localizedName, + dateRange, + WhereFormat.dayCount(item.dayCount), + WhereFormat.planningMembership(item.membership), + ].joined(separator: ", ")) } } #if DEBUG #Preview { - VStack(spacing: 0) { + let report = PreviewSupport.plannedStayYearReportModel() + if let item = PlanningTimelineItem.items( + planning: report.forecasts.planning, + year: PreviewSupport.year, + today: report.forecasts.today, + ).first { PlannedPresenceJourneyRow( - interval: LocationForecastModel.PlannedInterval( - region: .newYork, - start: CalendarDay(year: 2026, month: 7, day: 16), - end: CalendarDay(year: 2026, month: 8, day: 15), - ), - calendar: Calendar(identifier: .gregorian), + item: item, + calendar: report.calendar, daysInYear: 365, - isFirst: false, + isFirst: true, + isLast: true, cardPosition: .standalone, ) - PlannedPresenceJourneyRow( - interval: LocationForecastModel.PlannedInterval( - region: .newYork, - start: CalendarDay(year: 2026, month: 7, day: 16), - end: CalendarDay(year: 2026, month: 7, day: 24), - ), - calendar: Calendar(identifier: .gregorian), - daysInYear: 365, - isFirst: false, - cardPosition: .bottom, - ) + .padding() + .whereBroadwayRoot() } - .padding() - .whereBroadwayRoot() } #endif diff --git a/Where/WhereUI/Sources/Primary/PlanningTimelineItem.swift b/Where/WhereUI/Sources/Primary/PlanningTimelineItem.swift new file mode 100644 index 000000000..7176abfdb --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PlanningTimelineItem.swift @@ -0,0 +1,71 @@ +import RegionKit +import WhereCore + +/// Orders explicit stays and inferred Home gaps without merging saved identities. +enum PlanningTimelineItem: Hashable, Identifiable { + enum ID: Hashable { + case stay(PlannedStay.ID) + case home(PlannedHomeInterval) + } + + case stay(PlannedStayInterval) + case home(PlannedHomeInterval) + + var id: ID { + switch self { + case let .stay(interval): .stay(interval.stayID) + case let .home(interval): .home(interval) + } + } + + var region: Region { + switch self { + case let .stay(interval): interval.region + case let .home(interval): interval.region + } + } + + var start: CalendarDay { + switch self { + case let .stay(interval): interval.start + case let .home(interval): interval.start + } + } + + var end: CalendarDay { + switch self { + case let .stay(interval): interval.end + case let .home(interval): interval.end + } + } + + var dayCount: DayBounds { + switch self { + case let .stay(interval): interval.dayCount + case let .home(interval): interval.dayCount + } + } + + var membership: PlanningDayPresence.Membership { + switch self { + case let .stay(interval): .planned(interval.dayCount.isExact ? .certain : .possible) + case let .home(interval): .homeAssumed(interval.certainty) + } + } + + static func items(planning: PlanningSnapshot, year: Int, today: CalendarDay) -> [Self] { + let stays = planning.stayIntervals(intersecting: year, asOf: today).map(Self.stay) + let homes = planning.homeIntervals(intersecting: year, asOf: today).map(Self.home) + return (stays + homes).sorted { + if $0.start != $1.start { return $0.start < $1.start } + if $0.region != $1.region { return $0.region.rawValue < $1.region.rawValue } + switch ($0, $1) { + case let (.stay(first), .stay(second)): + return first.stayID.rawValue.uuidString < second.stayID.rawValue.uuidString + case (.stay, .home): return true + case (.home, .stay): return false + case (.home, .home): return $0.end < $1.end + } + } + } +} diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift index d2941c584..69f60d4a0 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift @@ -16,32 +16,29 @@ struct PresenceTimelineList: View { let report: YearReportModel @Environment(\.stylesheet) private var stylesheet - @State private var plannedStayEditorTarget: PlannedStayEditorTarget? - - private struct PlannedStayEditorTarget: Identifiable { - let region: Region - - var id: Region { - region - } - } + @State private var planningDestination: PlannedStaysDestination? var body: some View { let yearReport = report.report let stints = yearReport.map { PresenceTimeline.stints(from: $0) } ?? [] - let plannedInterval = report.showsEstimatedTimeAndPlanning - ? report.forecasts.plannedInterval(intersecting: report.selectedYear) - : nil - let joinsPlannedStay = if let plannedInterval, let currentStint = stints.last { - plannedInterval.region == currentStint.region - && CalendarDay(from: currentStint.end, in: report.calendar).adding(days: 1) - == plannedInterval.start - } else { - false - } + let plannedItems = report.showsEstimatedTimeAndPlanning + ? PlanningTimelineItem.items( + planning: report.forecasts.planning, + year: report.selectedYear, + today: report.forecasts.today, + ) + : [] Group { - if stints.isEmpty, plannedInterval == nil { + if report.report == nil, report.loadState == .loading { + AppIconLoadingView(caption: String(localized: .primaryLoading)) + } else if case let .failed(error) = report.loadState { + ContentUnavailableView( + String(localized: .commonLoadErrorTitle), + systemSymbol: .exclamationmarkIcloud, + description: Text(error.message), + ) + } else if stints.isEmpty, plannedItems.isEmpty { ContentUnavailableView { Label( String(localized: .timelineEmptyTitle), @@ -70,23 +67,34 @@ struct PresenceTimelineList: View { calendar: report.calendar, daysInYear: report.daysInSelectedYear, isFirst: index == stints.startIndex, - isLast: plannedInterval == nil + isLast: plannedItems.isEmpty && index == stints.index(before: stints.endIndex), - cardPosition: joinsPlannedStay - && index == stints.index(before: stints.endIndex) - ? .top - : .standalone, + cardPosition: .standalone, ) } - if let plannedInterval { - PlannedPresenceJourneyRow( - interval: plannedInterval, - calendar: report.calendar, - daysInYear: report.daysInSelectedYear, - isFirst: stints.isEmpty, - cardPosition: joinsPlannedStay ? .bottom : .standalone, - ) + ForEach(plannedItems) { item in + Button { + if case let .stay(interval) = item, + let stay = report.forecasts.planning.stays + .first(where: { $0.id == interval.stayID }) + { + planningDestination = .edit(stay) + } else { + planningDestination = .list + } + } label: { + PlannedPresenceJourneyRow( + item: item, + calendar: report.calendar, + daysInYear: report.daysInSelectedYear, + isFirst: stints.isEmpty && item.id == plannedItems.first? + .id, + isLast: item.id == plannedItems.last?.id, + cardPosition: .standalone, + ) + } + .buttonStyle(.plain) } } @@ -94,13 +102,8 @@ struct PresenceTimelineList: View { LocationForecastPanel( forecasts: timelineForecasts, microprintRegions: report.ranking.primary.map(\.region), - plannedStay: report.forecasts.activePlannedStay, - editableRegions: report.ranking.primary.map(\.region), - editAction: { region in - plannedStayEditorTarget = - PlannedStayEditorTarget(region: region) - }, - clearAction: report.forecasts.clear, + homeRegion: report.forecasts.planning.homeRegion, + planningAction: { planningDestination = .list }, ) } } @@ -127,12 +130,20 @@ struct PresenceTimelineList: View { .id(report.selectedYear) } } - .sheet(item: $plannedStayEditorTarget) { target in - PlannedStayEditor( - region: target.region, - model: report.forecasts, - driftThreshold: report.driftThreshold, - ) + .sheet(item: $planningDestination) { destination in + PlannedStaysDestinationView(destination: destination, report: report) + } + .toolbar { + if report.showsEstimatedTimeAndPlanning { + ToolbarItem(placement: .topBarTrailing) { + Button( + String(localized: .plannedStaysTitle), + systemSymbol: .calendarBadgeClock, + ) { + planningDestination = .list + } + } + } } } @@ -141,9 +152,7 @@ struct PresenceTimelineList: View { } private var timelineForecasts: [LocationForecast] { - report.ranking.primary.compactMap { - report.forecasts.forecast(for: $0.region, report: report.report) - } + report.forecasts.leadingForecasts(report: report.report) } } @@ -151,6 +160,11 @@ struct PresenceTimelineList: View { extension PresenceTimelineList: SnapshotProviding { static var snapshots: [SnapshotCase] { [ + whereSnapshot(name: "Itinerary", configurations: .fullContentScreenDefaults) { + NavigationStack { + PresenceTimelineList(report: PreviewSupport.itineraryYearReportModel()) + } + }, whereSnapshot( name: "WithData", configurations: .fullContentScreenDefaults, diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 50b9edd50..51713c176 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -42,7 +42,7 @@ struct RegionSummaryCard: View { /// The forecasted total rendered behind recorded progress. Locations cards /// supply it when Estimated Time & Planning is visible; other cards omit it. - var estimatedDays: Int? + var estimatedDays: DayBounds? /// The calendar year being summarized, inked onto the entry stamp. Callers /// pass `WhereSession.selectedYear`; the default is only for previews. @@ -111,7 +111,7 @@ struct RegionSummaryCard: View { } private var estimatedFraction: Double? { - estimatedDays.map(fraction) + estimatedDays.map { fraction(for: $0.upper) } } /// Region ink on light cards; a pale derivative on dark cards that remains @@ -351,18 +351,33 @@ struct RegionSummaryCard: View { .frame(height: barHeight) .overlay(alignment: .leading) { GeometryReader { proxy in - Capsule() - .fill(style.tint) - .frame(width: proxy.size.width * recordedFraction) - .background(alignment: .leading) { - if let estimatedFraction { + ZStack(alignment: .leading) { + if let estimatedFraction { + Capsule() + .fill(securityPrintTint.opacity( + cardStyles.estimatedProgressOpacity, + )) + .frame(width: proxy.size.width * estimatedFraction) + if let estimatedDays, !estimatedDays.isExact { Capsule() .fill(securityPrintTint.opacity( cardStyles.estimatedProgressOpacity, )) - .frame(width: proxy.size.width * estimatedFraction) + .frame(width: proxy.size + .width * fraction(for: estimatedDays.lower)) + Rectangle() + .fill(securityPrintTint) + .frame(width: barHeight / 3) + .offset(x: proxy.size + .width * fraction(for: estimatedDays.lower)) } } + Capsule() + .fill(style.tint) + .frame(width: proxy.size.width * recordedFraction) + } + .frame(width: proxy.size.width, height: barHeight, alignment: .leading) + .clipShape(.capsule) } } .frame(height: barHeight) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 89163e7fa..5bcc0edb9 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3294,6 +3294,83 @@ } } }, + "forecast.dateRangeExplanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ranges reflect your earliest and latest dates. Their endpoints can describe different choices and do not add together." + } + } + } + }, + "forecast.homeContribution" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ assumed at Home" + } + } + } + }, + "forecast.patternContribution" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ from past travel" + } + } + } + }, + "forecast.percent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ of the year" + } + } + } + }, + "forecast.plannedContribution" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ planned" + } + } + } + }, + "forecast.rangeDays" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@–%2$@ days" + } + } + } + }, + "forecast.rangePercent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@–%2$@ of the year" + } + } + } + }, "launch.accessibilityLabel" : { "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", "extractionState" : "manual", @@ -4591,6 +4668,446 @@ } } }, + "planned_stay_editor.arrival" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrival" + } + } + } + }, + "planned_stay_editor.boundary_accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "planned_stay_editor.choose_region" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a region" + } + } + } + }, + "planned_stay_editor.dates_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The last day is included. Flexible dates produce an estimate range based on the dates you enter." + } + } + } + }, + "planned_stay_editor.delete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete stay" + } + } + } + }, + "planned_stay_editor.destination" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Destination" + } + } + } + }, + "planned_stay_editor.destination_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan a stay in any supported region. Your current location is not required." + } + } + } + }, + "planned_stay_editor.destination_required" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a destination to save this stay." + } + } + } + }, + "planned_stay_editor.earliest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Earliest" + } + } + } + }, + "planned_stay_editor.edit_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit stay" + } + } + } + }, + "planned_stay_editor.exact_date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + } + } + }, + "planned_stay_editor.flexible_dates" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flexible dates" + } + } + } + }, + "planned_stay_editor.invalid_dates" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The latest arrival must be on or before the earliest last day." + } + } + } + }, + "planned_stay_editor.last_day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last day" + } + } + } + }, + "planned_stay_editor.latest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest" + } + } + } + }, + "planned_stay_editor.new_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a stay" + } + } + } + }, + "planned_stay_editor.untracked" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This region is not tracked automatically. Planning a stay does not change tracking." + } + } + } + }, + "planned_stays.add" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a stay" + } + } + } + }, + "planned_stays.definite_overlap" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overlaps another stay" + } + } + } + }, + "planned_stays.edit_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit this stay independently." + } + } + } + }, + "planned_stays.empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add stays anywhere you plan to visit." + } + } + } + }, + "planned_stays.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Future dates in this year contribute to its estimate. Plans do not change recorded time." + } + } + } + }, + "planned_stays.home_region" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home region" + } + } + } + }, + "planned_stays.home_region_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assume you are at Home on future days without a planned stay. This estimate is available all year and does not change automatic tracking." + } + } + } + }, + "planned_stays.overlap_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can keep overlapping plans. Each day counts once per region." + } + } + } + }, + "planned_stays.past" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Past stays" + } + } + } + }, + "planned_stays.past_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Past plans stay available to edit. They do not add recorded days or fill gaps in your history." + } + } + } + }, + "planned_stays.past_pattern" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Past travel pattern" + } + } + } + }, + "planned_stays.past_pattern_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estimate unplanned future days from your recorded travel this year. These estimates start April 1." + } + } + } + }, + "planned_stays.possible_overlap" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "May overlap another stay" + } + } + } + }, + "planned_stays.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planned stays" + } + } + } + }, + "planned_stays.unplanned_days" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estimate unplanned days" + } + } + } + }, + "planned_stays.upcoming" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current and upcoming" + } + } + } + }, + "planning.calendar.home" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assumed at Home" + } + } + } + }, + "planning.calendar.legend" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Future days" + } + } + } + }, + "planning.calendar.monthSummary" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "planning.calendar.planned" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planned" + } + } + } + }, + "planning.calendar.possible" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Possible" + } + } + } + }, + "planning.calendar.possibleHome" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Possibly at Home" + } + } + } + }, + "planning.dateWindowRange" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@" + } + } + } + }, "primary.calendar" : { "comment" : "Label for the primary calendar.", "extractionState" : "manual", @@ -7170,8 +7687,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Those 9 planned days belong only to New York. Every other region adds no planned days and resumes its own pace on July 11." + "state" : "translated", + "value" : "In Past travel pattern mode, the 9 planned days replace the historical projection. Unplanned days use each region’s own recorded rate." } } } @@ -7258,8 +7775,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Estimated time and planning are hidden. You can turn them on from Appearance." + "state" : "translated", + "value" : "Estimates are hidden. Your stays and Home setting remain saved. Show estimates from Appearance." } } } @@ -7280,8 +7797,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Manage Estimated Time & Planning" + "state" : "translated", + "value" : "Manage planned stays" } } } @@ -7302,8 +7819,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "After three complete months, Where divides each region’s year-to-date days by all elapsed calendar days. It applies that pace to unreserved days left in the year, then rounds the total to the nearest day." + "state" : "translated", + "value" : "After three complete months, Where applies each region’s recorded travel rate to unplanned days left this year. With Home selected, those days go to Home instead." } } } @@ -7324,8 +7841,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "A plan reserves tomorrow through the selected end date, inclusive. Those days count for the chosen region; every other region’s projection pauses, then each region resumes its own historical pace." + "state" : "translated", + "value" : "Add separate stays with exact dates or flexible arrival and departure windows. Choose a Home region to fill unplanned future days, or use your past travel pattern." } } } @@ -7379,8 +7896,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Each region is calculated independently. A recorded travel day can count toward more than one region, and summaries may omit smaller regions, so displayed estimates may not total 365 days." + "state" : "translated", + "value" : "A travel day can count in more than one region. Flexible dates produce independent ranges for each region; adding those ranges does not describe one itinerary." } } } @@ -7401,8 +7918,8 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Estimates appear for the current year after three complete months and enough recorded history to establish a pace." + "state" : "translated", + "value" : "Historical estimates begin April 1. Choose a Home region to estimate earlier. Annual estimates cover the current year; you can plan stays for any future date." } } } diff --git a/Where/WhereUI/Sources/Settings/EstimatedTimeAndPlanningSettingsModel.swift b/Where/WhereUI/Sources/Settings/EstimatedTimeAndPlanningSettingsModel.swift index 998b9a695..de38312d3 100644 --- a/Where/WhereUI/Sources/Settings/EstimatedTimeAndPlanningSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/EstimatedTimeAndPlanningSettingsModel.swift @@ -1,7 +1,7 @@ import Observation /// Transactional presentation state for the Appearance forecast toggle. -/// Disabling stays visually on until the synced planned stay is cleared. +/// Visibility changes preserve the synced itinerary and Home setting. @MainActor @Observable final class EstimatedTimeAndPlanningSettingsModel { diff --git a/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/EstimatedTimeFeaturesView.swift b/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/EstimatedTimeFeaturesView.swift index a7f94d3c7..70ba88345 100644 --- a/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/EstimatedTimeFeaturesView.swift +++ b/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/EstimatedTimeFeaturesView.swift @@ -24,6 +24,7 @@ struct EstimatedTimeFeaturesView: View { /// focus scope does not copy the form's full value onto the stack while a /// navigation push is preparing its destination. private struct EstimatedTimeFeaturesContent: View { + @State private var showingPlanner = false let report: YearReportModel @Environment(\.stylesheet) private var stylesheet @@ -86,7 +87,7 @@ private struct EstimatedTimeFeaturesContent: View { Section { FeatureMarketingPanel { - NavigationLink(value: SettingsRoute(.appearance)) { + Button { showingPlanner = true } label: { Label { Text(String(localized: .settingsExploreEstimatedTimeManage)) .foregroundStyle(.primary) @@ -105,6 +106,7 @@ private struct EstimatedTimeFeaturesContent: View { } .scrollContentBackground(.hidden) .background(FeatureDiscoveryBackground()) + .sheet(isPresented: $showingPlanner) { PlannedStaysView(report: report) } } @ViewBuilder @@ -117,7 +119,7 @@ private struct EstimatedTimeFeaturesContent: View { LocationForecastPanel( forecasts: forecasts, microprintRegions: report.ranking.primary.map(\.region), - plannedStay: report.forecasts.activePlannedStay, + homeRegion: report.forecasts.planning.homeRegion, ) } } diff --git a/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/FeatureEstimatedTimeCalculationExample.swift b/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/FeatureEstimatedTimeCalculationExample.swift index 9d71b3a5b..ae276cec9 100644 --- a/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/FeatureEstimatedTimeCalculationExample.swift +++ b/Where/WhereUI/Sources/Settings/FeaturePreviews/EstimatedTime/FeatureEstimatedTimeCalculationExample.swift @@ -16,6 +16,7 @@ struct FeatureEstimatedTimeCalculationExample: View { Text(String(localized: .settingsExploreEstimatedTimeCalculationIntro)) .font(.subheadline) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) GeometryReader { geometry in let segmentWidth = max( @@ -87,6 +88,7 @@ struct FeatureEstimatedTimeCalculationExample: View { Text(String(localized: .settingsExploreEstimatedTimeCalculationOtherRegions)) .font(.footnote) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Where/WhereUI/Sources/Shared/WhereDatePicker.swift b/Where/WhereUI/Sources/Shared/WhereDatePicker.swift index cd15db8da..a58dbc647 100644 --- a/Where/WhereUI/Sources/Shared/WhereDatePicker.swift +++ b/Where/WhereUI/Sources/Shared/WhereDatePicker.swift @@ -17,6 +17,8 @@ struct WhereDatePicker: View { /// range overloads. `nil` means unbounded on that end. var earliest: Date? var latest: Date? + /// Qualifies repeated date fields without replacing their selected value. + let accessibilityTitle: String? let displayedComponents: DatePickerComponents @Environment(\.isCapturingSnapshot) private var isCapturingSnapshot @@ -26,18 +28,26 @@ struct WhereDatePicker: View { selection: Binding, earliest: Date? = nil, latest: Date? = nil, + accessibilityTitle: String? = nil, displayedComponents: DatePickerComponents, ) { self.title = title _selection = selection self.earliest = earliest self.latest = latest + self.accessibilityTitle = accessibilityTitle self.displayedComponents = displayedComponents } var body: some View { if isCapturingSnapshot { - SnapshotDatePickerStandIn(title: title, selection: standInSelection) + SnapshotDatePickerStandIn( + title: title, + selection: standInSelection, + accessibilityTitle: accessibilityTitle, + ) + } else if let accessibilityTitle { + livePicker.accessibilityLabel(accessibilityTitle) } else { livePicker } @@ -88,7 +98,7 @@ struct WhereDatePicker: View { /// renders differently depending on the day the test runs, and no settle /// window can stabilize it. Captures substitute this row instead: the same /// title + trailing-capsule layout, with the selection rendered in a fixed -/// format and locale so the image is a pure function of the selected value. +/// format and locale, using the same calendar and timezone as the live picker. /// Only the system-drawn value capsule is substituted — the row title and /// surrounding Form chrome stay real — per the `\.isCapturingSnapshot` /// carve-out (see SnapshotKit's `SnapshotCaptureFlag`). @@ -102,8 +112,23 @@ private struct SnapshotDatePickerStandIn: View { let title: String let selection: Selection + let accessibilityTitle: String? + @Environment(\.calendar) private var calendar + @Environment(\.timeZone) private var timeZone var body: some View { + if let accessibilityTitle { + row + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityTitle) + .accessibilityValue(formattedSelection) + .accessibilityAddTraits(.isButton) + } else { + row + } + } + + private var row: some View { LabeledContent(title) { Text(formattedSelection) .foregroundStyle(.primary) @@ -118,26 +143,34 @@ private struct SnapshotDatePickerStandIn: View { private var formattedSelection: String { switch selection { case let .date(date): - date.formatted(Self.dateStyle) + date.formatted(dateStyle) case let .timeOfDay(date): - date.formatted(Self.timeStyle) + date.formatted(timeStyle) } } /// The medium date the live picker prefers ("Jul 15, 2026"), now with a /// fixed locale and no dependence on today's capsule-width reservation. - private static let dateStyle = Date.FormatStyle( - date: .abbreviated, - time: .omitted, - locale: Locale(identifier: "en_US"), - ) + private var dateStyle: Date.FormatStyle { + Date.FormatStyle( + date: .abbreviated, + time: .omitted, + locale: Locale(identifier: "en_US"), + calendar: calendar, + timeZone: timeZone, + ) + } /// Shortened time ("8:00 PM") in the same fixed locale. - private static let timeStyle = Date.FormatStyle( - date: .omitted, - time: .shortened, - locale: Locale(identifier: "en_US"), - ) + private var timeStyle: Date.FormatStyle { + Date.FormatStyle( + date: .omitted, + time: .shortened, + locale: Locale(identifier: "en_US"), + calendar: calendar, + timeZone: timeZone, + ) + } } #if DEBUG diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index f6b78101e..f75df2655 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -41,6 +41,63 @@ enum WhereFormat { String(localized: .commonDayCount(count)) } + /// Exact estimates keep ordinary pluralization; flexible estimates retain both bounds. + static func dayCount(_ bounds: DayBounds) -> String { + guard !bounds.isExact else { return dayCount(bounds.lower) } + return String(localized: .forecastRangeDays( + bounds.lower.formatted(), + bounds.upper.formatted(), + )) + } + + static func forecastPercentage(_ bounds: DayBounds, year: Int) -> String { + let length = Calendar(identifier: .gregorian).dayCount(ofYear: year) + let lower = (Double(bounds.lower) / Double(length)) + .formatted(.percent.precision(.fractionLength(1))) + guard !bounds.isExact else { return String(localized: .forecastPercent(lower)) } + let upper = (Double(bounds.upper) / Double(length)) + .formatted(.percent.precision(.fractionLength(1))) + return String(localized: .forecastRangePercent(lower, upper)) + } + + static func plannedStayWindow(_ window: PlannedStay.DateWindow, calendar: Calendar) -> String { + let format = Date.FormatStyle( + date: .abbreviated, + time: .omitted, + calendar: calendar, + timeZone: calendar.timeZone, + ) + let earliest = window.earliest.startOfDay(in: calendar).formatted(format) + guard !window.isExact else { return earliest } + let latest = window.latest.startOfDay(in: calendar).formatted(format) + return String(localized: .planningDateWindowRange(earliest, latest)) + } + + static func planningMembership(_ membership: PlanningDayPresence.Membership) -> String { + switch membership { + case .planned(.certain): String(localized: .planningCalendarPlanned) + case .planned(.possible): String(localized: .planningCalendarPossible) + case .homeAssumed(.certain): String(localized: .planningCalendarHome) + case .homeAssumed(.possible): String(localized: .planningCalendarPossibleHome) + } + } + + static func locationForecastEstimate(region: Region, days: DayBounds) -> AttributedString { + AttributedString(localized: .locationForecastEstimate(region.localizedName, dayCount(days))) + } + + static func regionDaysEstimatedAccessibility( + region: String, + recordedDays: Int, + estimatedDays: DayBounds, + ) -> String { + String(localized: .commonRegionDaysEstimatedAccessibility( + region, + dayCount(recordedDays), + dayCount(estimatedDays), + )) + } + /// "day" / "days" — the bare unit, when the count is shown separately. static func dayUnit(_ count: Int) -> String { count == 1 ? String(localized: .commonDay) : String(localized: .commonDays) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 09709d18b..2c0412793 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -51,6 +51,10 @@ struct WhereStylesheet: BStylesheet { // Grow day-grid tap targets at accessibility Dynamic Type sizes. if traits.contentSizeCategory.isAccessibilitySize { calendar.day.minHeight = 56 + calendar.day.numberFontSize = 24 + calendar.day.numberSize = 36 + calendar.month.weekdayFontSize = 14 + calendar.month.stacksFooter = true timeline.overview.pinsToViewport = false timeline.row.stacksDayCount = true featureDiscovery.siri.bubble.indent = 0 @@ -1238,6 +1242,8 @@ extension WhereStylesheet { var sectionSpacing: CGFloat /// Spacing between day cells in the grid (both axes). var gridSpacing: CGFloat + /// Compact weekday glyphs stay on one line in the seven-column grid. + var weekdayFontSize: CGFloat var padding: CGFloat var cornerRadius: CGFloat /// Card treatment for a past month — the plain wash + rim. @@ -1252,6 +1258,8 @@ extension WhereStylesheet { var footerSpacing: CGFloat /// Spacing within a footer row (dot ↔ label). var footerRowSpacing: CGFloat + /// Accessibility Dynamic Type places the count below its full label. + var stacksFooter: Bool /// Opacity of an unfocused footer row while a region is focused. var unfocusedRowOpacity: Double @@ -1281,6 +1289,8 @@ extension WhereStylesheet { var minHeight: CGFloat /// Edge of the rounded day-number chip. var numberSize: CGFloat + /// Date glyph size grows with its fixed chip at accessibility sizes. + var numberFontSize: CGFloat /// Vertical gap between the day number and its dots — small so the /// dots tuck up close beneath the date. var numberDotSpacing: CGFloat @@ -1353,6 +1363,7 @@ extension WhereStylesheet { month: MonthStyle( sectionSpacing: 8, gridSpacing: 6, + weekdayFontSize: 11, padding: 16, cornerRadius: 28, plain: MonthStyle.Card( @@ -1374,6 +1385,7 @@ extension WhereStylesheet { footerDividerSpacing: 8, footerSpacing: 4, footerRowSpacing: 6, + stacksFooter: false, unfocusedRowOpacity: 0.55, ), dotSize: 6, @@ -1392,6 +1404,7 @@ extension WhereStylesheet { day: DayStyle( minHeight: 44, numberSize: 26, + numberFontSize: 17, numberDotSpacing: 0, dotSize: 8, dotOverlap: 2, diff --git a/Where/WhereUI/Sources/Shared/YearSelector.swift b/Where/WhereUI/Sources/Shared/YearSelector.swift index 34afa3f2e..a95bb721e 100644 --- a/Where/WhereUI/Sources/Shared/YearSelector.swift +++ b/Where/WhereUI/Sources/Shared/YearSelector.swift @@ -7,14 +7,9 @@ import WhereCore struct YearSelector: View { let report: YearReportModel - private var years: [Int] { - let current = WhereModel.currentYear - return Array((current - 5 ... current).reversed()) - } - var body: some View { Menu { - ForEach(years, id: \.self) { year in + ForEach(report.selectableYears, id: \.self) { year in Button { Task { await report.select(year: year) } } label: { diff --git a/Where/WhereUI/Sources/Year/YearView.swift b/Where/WhereUI/Sources/Year/YearView.swift index b50e6dd88..eb60920fa 100644 --- a/Where/WhereUI/Sources/Year/YearView.swift +++ b/Where/WhereUI/Sources/Year/YearView.swift @@ -72,9 +72,14 @@ private struct YearModePicker: View { @Namespace private var selection @Environment(\.stylesheet) private var stylesheet + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - HStack(spacing: stylesheet.spacing.xxSmall) { + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(spacing: stylesheet.spacing.xxSmall)) + : AnyLayout(HStackLayout(spacing: stylesheet.spacing.xxSmall)) + + layout { ForEach(YearMode.allCases, id: \.self) { candidate in segment(candidate) } diff --git a/Where/WhereUI/Tests/EstimatedTimeAndPlanningSettingsModelTests.swift b/Where/WhereUI/Tests/EstimatedTimeAndPlanningSettingsModelTests.swift index d6a04a093..bdce95b44 100644 --- a/Where/WhereUI/Tests/EstimatedTimeAndPlanningSettingsModelTests.swift +++ b/Where/WhereUI/Tests/EstimatedTimeAndPlanningSettingsModelTests.swift @@ -5,70 +5,34 @@ import Testing @MainActor struct EstimatedTimeAndPlanningSettingsModelTests { - @Test func disablingClearsThePlanBeforePersistingOff() async throws { + @Test func hidingAndShowingEstimatesPreservesEveryStayAndHome() async throws { let store = try TestStore() let preferences = makePreferences() - let report = makeReport(store: store, preferences: preferences) - try await report.forecasts.set( - region: .california, - through: CalendarDay(year: 2027, month: 1, day: 1).startOfDay(in: report.calendar), - ) - let model = EstimatedTimeAndPlanningSettingsModel(report: report) - - await model.setEnabled(false) - - #expect(model.isEnabled == false) - #expect(preferences.showsEstimatedTimeAndPlanning == false) - #expect(report.forecasts.activePlannedStay == nil) - #expect(try await report.services.plannedStays.active() == nil) - } - - @Test func failedClearLeavesTheFeatureOnAndPresentsTheFailure() async throws { - let store = try TestStore() - let preferences = makePreferences() - let report = makeReport(store: store, preferences: preferences) - try await report.forecasts.set( - region: .california, - through: CalendarDay(year: 2027, month: 1, day: 1).startOfDay(in: report.calendar), + let report = YearReportModel( + services: PlanningModelTestSupport.services(store: store), + selectedYear: 2026, + preferences: preferences, + now: { PlanningModelTestSupport.now }, ) + let first = try PlanningModelTestSupport.stay(region: .newYork) + let second = try PlanningModelTestSupport.stay(region: .california) + try await report.forecasts.create(stay: first) + try await report.forecasts.create(stay: second) + try await report.forecasts.setHomeRegion(.california) + await report.forecasts.refresh() + let before = try await report.services.plannedStays.snapshot() await store.failPlannedStays() let model = EstimatedTimeAndPlanningSettingsModel(report: report) await model.setEnabled(false) - - #expect(model.isEnabled) - #expect(preferences.showsEstimatedTimeAndPlanning) - #expect(report.forecasts.activePlannedStay?.region == .california) - #expect(model.presentedFailure != nil) - } - - @Test func enablingDoesNotCreateAPlan() async throws { - let store = try TestStore() - let preferences = makePreferences() - preferences.showsEstimatedTimeAndPlanning = false - let report = makeReport(store: store, preferences: preferences) - let model = EstimatedTimeAndPlanningSettingsModel(report: report) + #expect(!model.isEnabled) + #expect(!preferences.showsEstimatedTimeAndPlanning) + #expect(try await report.services.plannedStays.snapshot() == before) + #expect(report.forecasts.planning == before) + #expect(model.presentedFailure == nil) await model.setEnabled(true) - #expect(model.isEnabled) - #expect(preferences.showsEstimatedTimeAndPlanning) - #expect(try await report.services.plannedStays.active() == nil) - } - - private func makeReport( - store: TestStore, - preferences: WherePreferences, - ) -> YearReportModel { - YearReportModel( - services: WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - ), - selectedYear: 2026, - preferences: preferences, - ) + #expect(try await report.services.plannedStays.snapshot() == before) } } diff --git a/Where/WhereUI/Tests/LocationForecastModelTests.swift b/Where/WhereUI/Tests/LocationForecastModelTests.swift index 1d79e276f..e3d5a181c 100644 --- a/Where/WhereUI/Tests/LocationForecastModelTests.swift +++ b/Where/WhereUI/Tests/LocationForecastModelTests.swift @@ -6,266 +6,202 @@ import Testing @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, - locationSource: any LocationSource = ScriptedLocationSource(), - ) -> WhereServices { - WhereServices( - store: store, - locationSource: locationSource, - 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, + @Test func loadsExplicitDestinationsAndHomeWithoutChangingRecordedRanking() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let stay = try PlanningModelTestSupport.stay(region: .newYork) + try await model.create(stay: stay) + try await model.setHomeRegion(.california) + await model.refresh() + let report = YearReport(year: 2026, days: [], totals: [:]) + + #expect(Set(model.leadingForecasts(report: report).map(\.region)) == [ .newYork, - .canada, + .california, ]) - #expect(RegionRanking(report: report).primary.count == RegionRanking.primaryCount) + #expect(RegionRanking(report: report).primary.isEmpty) + #expect(model.loadFailure == nil) } - @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) - #expect(model.plannedInterval(intersecting: 2026) == .init( - region: .newYork, - start: CalendarDay(year: 2026, month: 7, day: 16), - end: CalendarDay(year: 2026, month: 12, day: 31), - )) - #expect(model.plannedInterval(intersecting: 2026)?.dayCount == 169) + @Test func writesWaitForTheSharedReadPathAndPreserveOtherTrips() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + await model.refresh() + let first = try PlanningModelTestSupport.stay(region: .newYork) + let second = try PlanningModelTestSupport.stay(region: .california) + try await model.create(stay: first) + try await model.create(stay: second) + #expect(model.planning.stays.isEmpty) + + await model.refresh() + #expect(Set(model.planning.stays.map(\.id)) == [first.id, second.id]) + try await model.delete(stayID: first.id) + #expect(model.planning.stays.count == 2) + await model.refresh() + #expect(model.planning.stays == [second]) } - @Test func failedSaveKeepsTheLastGoodValue() async throws { + @Test func failedWritePreservesTheLoadedItinerary() async throws { let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let stay = try PlanningModelTestSupport.stay(region: .newYork) + try await model.create(stay: stay) + await model.refresh() 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) + try await model.delete(stayID: stay.id) } - #expect(model.activePlannedStay == nil) - } - - @Test func currentLocationCheckPublishesAcceptedStatus() async throws { - let source = ScriptedLocationSource() - source.setNextRequestedLocation(Self.sample( - at: Coordinate(latitude: 40.7128, longitude: -74.0060), - )) - let model = try LocationForecastModel( - services: Self.services( - store: SwiftDataStore.inMemory(), - locationSource: source, - ), - calendar: Self.calendar, - now: { Self.now }, - ) - - await model.checkCurrentLocation(for: .newYork, driftThreshold: .km1) - - #expect(model.plannedStayLocationCheck == .init( - region: .newYork, - driftThreshold: .km1, - status: .accepted, - )) + #expect(model.planning.stays == [stay]) } - @Test func currentLocationCheckPublishesOutsideStatus() async throws { - let source = ScriptedLocationSource() - source.setNextRequestedLocation(Self.sample( - at: Coordinate(latitude: 35.6762, longitude: 139.6503), - )) - let model = try LocationForecastModel( - services: Self.services( - store: SwiftDataStore.inMemory(), - locationSource: source, - ), - calendar: Self.calendar, - now: { Self.now }, - ) - - await model.checkCurrentLocation(for: .newYork, driftThreshold: .km50) - - #expect(model.plannedStayLocationCheck?.status == .outside) + @Test func failedReadKeepsLastGoodValuesAndSurfacesFailure() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let stay = try PlanningModelTestSupport.stay(region: .newYork) + try await model.create(stay: stay) + await model.refresh() + await store.failPlanningReads() + await model.refresh() + + #expect(model.hasLoaded) + #expect(model.loadFailure != nil) + #expect(model.planning.stays == [stay]) } - @Test func currentLocationCheckPublishesUnavailableStatus() async throws { - let model = try LocationForecastModel( - services: Self.services(store: SwiftDataStore.inMemory()), - calendar: Self.calendar, - now: { Self.now }, - ) - - await model.checkCurrentLocation(for: .newYork, driftThreshold: .km1) - - #expect(model.plannedStayLocationCheck?.status == .unavailable) + @Test func firstReadFailureDoesNotPublishAnEmptyForecastAsSuccess() async throws { + let store = try TestStore() + await store.failPlanningReads() + let model = PlanningModelTestSupport.model(store: store) + await model.refresh() + + #expect(!model.hasLoaded) + #expect(model.loadFailure != nil) + #expect(model.forecast( + for: .newYork, + report: YearReport(year: 2026, days: [], totals: [:]), + ) == nil) } - @Test func cancelledCurrentLocationCheckDoesNotPublishLateResult() async throws { - let source = GatedCurrentLocationSource() - let model = try LocationForecastModel( - services: Self.services( - store: SwiftDataStore.inMemory(), - locationSource: source, - ), - calendar: Self.calendar, - now: { Self.now }, - ) - let task = Task { - await model.checkCurrentLocation(for: .newYork, driftThreshold: .km1) + @Test(.timeLimit(.minutes(1))) + func cancellingTheNewestInitialRefreshStillPublishesItsSnapshot() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let firstStay = try PlanningModelTestSupport.stay(region: .newYork) + let secondStay = try PlanningModelTestSupport.stay(region: .california) + try await model.create(stay: firstStay) + let firstGate = TestStore.PlanningReadGate() + let secondGate = TestStore.PlanningReadGate() + + await store.gateNextPlanningRead(with: firstGate) + let firstRefresh = Task { await model.refresh() } + await firstGate.waitUntilReached() + do { + try await model.create(stay: secondStay) + } catch { + await firstGate.release() + await firstRefresh.value + throw error } - await source.waitUntilRequestCount(1) - - task.cancel() - await source.resolveRequest( - at: 0, - with: Self.sample(at: Coordinate(latitude: 40.7128, longitude: -74.0060)), - ) - await task.value + await store.gateNextPlanningRead(with: secondGate) + let secondRefresh = Task { await model.refresh() } + await secondGate.waitUntilReached() + + secondRefresh.cancel() + await secondGate.release() + await secondRefresh.value + let publishedSnapshot = model.planning + let hasLoaded = model.hasLoaded + let isLoading = model.isLoading + await firstGate.release() + await firstRefresh.value + + #expect(hasLoaded) + #expect(isLoading == false) + #expect(Set(publishedSnapshot.stays.map(\.id)) == [firstStay.id, secondStay.id]) + #expect(model.planning == publishedSnapshot) + #expect(model.loadFailure == nil) + #expect(model.isLoading == false) + } - #expect(model.plannedStayLocationCheck?.status == .checking) + @Test(.timeLimit(.minutes(1))) + func cancellingTheNewestFailedRefreshRetainsLastGoodSnapshotAndShowsFailure() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let firstStay = try PlanningModelTestSupport.stay(region: .newYork) + let secondStay = try PlanningModelTestSupport.stay(region: .california) + try await model.create(stay: firstStay) + await model.refresh() + let lastGoodSnapshot = model.planning + try await model.create(stay: secondStay) + let firstGate = TestStore.PlanningReadGate() + let secondGate = TestStore.PlanningReadGate() + + await store.gateNextPlanningRead(with: firstGate) + let firstRefresh = Task { await model.refresh() } + await firstGate.waitUntilReached() + await store.failPlanningReads() + await store.gateNextPlanningRead(with: secondGate) + let secondRefresh = Task { await model.refresh() } + await secondGate.waitUntilReached() + + secondRefresh.cancel() + await secondGate.release() + await secondRefresh.value + let failure = model.loadFailure + let isLoading = model.isLoading + await firstGate.release() + await firstRefresh.value + + #expect(failure != nil) + #expect(isLoading == false) + #expect(model.hasLoaded) + #expect(model.planning == lastGoodSnapshot) + #expect(model.loadFailure == failure) + #expect(model.isLoading == false) } - @Test func supersededCurrentLocationCheckDoesNotOverwriteNewerResult() async throws { - let source = GatedCurrentLocationSource() - let model = try LocationForecastModel( - services: Self.services( - store: SwiftDataStore.inMemory(), - locationSource: source, - ), - calendar: Self.calendar, - now: { Self.now }, - ) - let first = Task { - await model.checkCurrentLocation(for: .newYork, driftThreshold: .km1) - } - await source.waitUntilRequestCount(1) - let second = Task { - await model.checkCurrentLocation(for: .california, driftThreshold: .km5) + @Test(.timeLimit(.minutes(1))) + func anAlreadyCancelledRefreshDoesNotSupersedeTheActiveRead() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let stay = try PlanningModelTestSupport.stay(region: .newYork) + try await model.create(stay: stay) + let gate = TestStore.PlanningReadGate() + await store.gateNextPlanningRead(with: gate) + let activeRefresh = Task { await model.refresh() } + await gate.waitUntilReached() + + let cancelledRefresh = Task { + withUnsafeCurrentTask { $0?.cancel() } + await model.refresh() } - await source.waitUntilRequestCount(2) - - await source.resolveRequest( - at: 1, - with: Self.sample(at: Coordinate(latitude: 37.7749, longitude: -122.4194)), - ) - await second.value - await source.resolveRequest( - at: 0, - with: Self.sample(at: Coordinate(latitude: 40.7128, longitude: -74.0060)), - ) - await first.value - - #expect(model.plannedStayLocationCheck == .init( - region: .california, - driftThreshold: .km5, - status: .accepted, - )) + await cancelledRefresh.value + await gate.release() + await activeRefresh.value + + #expect(model.hasLoaded) + #expect(model.planning.stays == [stay]) + #expect(model.isLoading == false) + #expect(model.loadFailure == nil) } - private static func sample(at coordinate: Coordinate) -> LocationSample { - LocationSample( - timestamp: now, - coordinate: coordinate, - horizontalAccuracy: 5, - source: .gpsSignificantChange, + @Test func projectsOnlyTheFutureSliceAcrossYearBoundaries() async throws { + let store = try TestStore() + let model = PlanningModelTestSupport.model(store: store) + let stay = try PlannedStay( + id: .init(rawValue: UUID()), + region: .newYork, + arrival: .init(exact: .init(year: 2026, month: 7, day: 15)), + departure: .init(exact: .init(year: 2027, month: 1, day: 5)), ) + try await model.create(stay: stay) + await model.refresh() + + #expect(model.plannedPresence(on: PlanningModelTestSupport.today).possibleRegions.isEmpty) + #expect(model.plannedIntervals(intersecting: 2025).isEmpty) + #expect(model.plannedIntervals(intersecting: 2026).first?.start == PlanningModelTestSupport + .today.adding(days: 1)) + #expect(model.plannedIntervals(intersecting: 2027).first?.dayCount == DayBounds(exact: 5)) } } diff --git a/Where/WhereUI/Tests/LocationsPlanningModelTests.swift b/Where/WhereUI/Tests/LocationsPlanningModelTests.swift deleted file mode 100644 index 7966c4299..000000000 --- a/Where/WhereUI/Tests/LocationsPlanningModelTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -import RegionKit -import Testing -@_spi(Testing) import WhereCore -@testable import WhereUI - -@MainActor -struct LocationsPlanningModelTests { - @Test func successfulClearRemovesTheActivePlan() async throws { - let (forecasts, _) = try makeForecasts() - try await forecasts.set(region: .newYork, through: Self.departureDate) - let model = LocationsPlanningModel() - - await model.clear(using: forecasts.clear) - - #expect(forecasts.activePlannedStay == nil) - #expect(model.isClearing == false) - #expect(model.presentedFailure == nil) - } - - @Test func failedClearPreservesThePlanAndPresentsTheFailure() async throws { - let (forecasts, store) = try makeForecasts() - try await forecasts.set(region: .newYork, through: Self.departureDate) - await store.failPlannedStays() - let model = LocationsPlanningModel() - - await model.clear(using: forecasts.clear) - - #expect(forecasts.activePlannedStay?.region == .newYork) - #expect(model.isClearing == false) - #expect(model.presentedFailure != nil) - #expect(model.isShowingError) - } - - @Test func dismissingTheFailureReturnsToIdle() async { - let model = LocationsPlanningModel() - let error = NSError( - domain: "LocationsPlanningModelTests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Clear failed"], - ) - - await model.clear { throw error } - model.isShowingError = false - - #expect(model.presentedFailure == nil) - #expect(model.isShowingError == false) - } - - @Test func duplicateClearIsIgnoredWhileTheFirstIsRunning() async { - let model = LocationsPlanningModel() - var starts = 0 - var continuation: CheckedContinuation? - - let firstClear = Task { @MainActor in - await model.clear { - starts += 1 - await withCheckedContinuation { continuation = $0 } - } - } - while continuation == nil { - await Task.yield() - } - - await model.clear { starts += 1 } - - #expect(starts == 1) - continuation?.resume() - await firstClear.value - #expect(model.isClearing == false) - } - - 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 let departureDate = calendar.date( - from: DateComponents(year: 2026, month: 8, day: 15), - )! - - private func makeForecasts() throws -> (LocationForecastModel, TestStore) { - let store = try TestStore() - let services = WhereServices( - store: store, - locationSource: ScriptedLocationSource(), - now: { Self.now }, - ) - return ( - LocationForecastModel( - services: services, - calendar: Self.calendar, - now: { Self.now }, - ), - store, - ) - } -} diff --git a/Where/WhereUI/Tests/PlannedStayEditorModelTests.swift b/Where/WhereUI/Tests/PlannedStayEditorModelTests.swift new file mode 100644 index 000000000..75bc509af --- /dev/null +++ b/Where/WhereUI/Tests/PlannedStayEditorModelTests.swift @@ -0,0 +1,165 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct PlannedStayEditorModelTests { + @Test func newDraftRequiresDestinationAndStartsWithExactDates() throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let model = PlannedStayEditorModel(report: report, stay: nil, initialRegion: nil) + + #expect(!model.canSave) + #expect(model.validationMessage != nil) + #expect(!model.arrival.isFlexible) + #expect(!model.departure.isFlexible) + model.region = .newYork + #expect(model.canSave) + #expect(try model.draft.get().arrival.earliest == PlanningTestSupport.today) + } + + @Test func flexibleArrivalMustFinishBeforeTheEarliestLastDay() throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let model = PlannedStayEditorModel(report: report, stay: nil, initialRegion: .newYork) + model.arrival.isFlexible = true + model.arrival.latest = PlanningTestSupport.today.adding(days: 3) + .startOfDay(in: report.calendar) + + #expect(!model.canSave) + #expect(model.validationMessage != nil) + model.departure.earliest = model.arrival.latest + #expect(model.canSave) + let stay = try model.draft.get() + #expect(stay.arrival.earliest == PlanningTestSupport.today) + #expect(stay.arrival.latest == PlanningTestSupport.today.adding(days: 3)) + #expect(stay.shortestRange.lowerBound == stay.shortestRange.upperBound) + } + + @Test func editingAStayRetainsItsIdentityAndOtherPlans() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let first = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today, + through: PlanningTestSupport.today.adding(days: 7), + ) + let second = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today.adding(days: 30), + through: PlanningTestSupport.today.adding(days: 44), + ) + try await report.forecasts.create(stay: first) + try await report.forecasts.create(stay: second) + let model = PlannedStayEditorModel(report: report, stay: first, initialRegion: nil) + model.departure.earliest = PlanningTestSupport.today.adding(days: 10) + .startOfDay(in: report.calendar) + + #expect(await model.save()) + let snapshot = try await report.services.plannedStays.snapshot() + #expect(snapshot.stays.count == 2) + #expect(snapshot.stays.first { $0.id == second.id } == second) + #expect(snapshot.stays.first { $0.id == first.id }?.departure.latest == PlanningTestSupport + .today.adding(days: 10)) + } + + @Test func savingAnUntrackedFutureDestinationDoesNotRequireLocationOrChangeTracking( + ) async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let primary = [PrimaryRegion(region: .california, appearance: nil, order: 0)] + try await report.services.setPrimaryRegions(primary) + let model = PlannedStayEditorModel(report: report, stay: nil, initialRegion: .newYork) + model.arrival.earliest = PlanningTestSupport.today.adding(days: 40) + .startOfDay(in: report.calendar) + model.departure.earliest = PlanningTestSupport.today.adding(days: 55) + .startOfDay(in: report.calendar) + + #expect(await model.save()) + #expect(try await report.services.primaryRegions() == primary) + let snapshot = try await report.services.plannedStays.snapshot() + #expect(snapshot.stays.count == 1) + #expect(snapshot.stays.first?.region == .newYork) + } + + @Test func deletingOneStayPreservesTheOtherStayAndHome() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let first = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today, + through: PlanningTestSupport.today.adding(days: 7), + ) + let second = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today.adding(days: 30), + through: PlanningTestSupport.today.adding(days: 44), + ) + try await report.forecasts.create(stay: first) + try await report.forecasts.create(stay: second) + try await report.forecasts.setHomeRegion(.california) + let model = PlannedStayEditorModel(report: report, stay: first, initialRegion: nil) + + #expect(await model.delete()) + let snapshot = try await report.services.plannedStays.snapshot() + #expect(snapshot.stays == [second]) + #expect(snapshot.homeRegion == .california) + } + + @Test func failedSaveKeepsTheDraftOpenAndObservable() async throws { + let store = try TestStore() + await store.failPlannedStays() + let report = PlanningTestSupport.report(store: store) + let model = PlannedStayEditorModel(report: report, stay: nil, initialRegion: .newYork) + let draft = try model.draft.get() + + #expect(await model.save() == false) + guard case .failed = model.saveState else { + Issue.record("The save failure must stay visible in the editor") + return + } + #expect(try model.draft.get() == draft) + #expect(model.canSave) + #expect(try await report.services.plannedStays.snapshot().stays.isEmpty) + } + + @Test func overlapWarningsIncludeOtherPlansButNeverTheEditedRevisionItself() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let stay = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today.adding(days: 1), + through: PlanningTestSupport.today.adding(days: 7), + ) + try await report.forecasts.create(stay: stay) + await report.forecasts.refresh() + let model = PlannedStayEditorModel(report: report, stay: stay, initialRegion: nil) + #expect(model.overlaps.isEmpty) + let other = try PlannedStay( + id: PlannedStay.ID(rawValue: UUID()), + region: .california, + arrival: .init( + earliest: PlanningTestSupport.today.adding(days: 5), + latest: PlanningTestSupport.today.adding(days: 10), + ), + departure: .init(exact: PlanningTestSupport.today.adding(days: 14)), + ) + try await report.forecasts.create(stay: other) + await report.forecasts.refresh() + #expect(model.hasPossibleOverlap) + #expect(!model.hasDefiniteOverlap) + #expect(model.canSave) + model.departure.earliest = PlanningTestSupport.today.adding(days: 11) + .startOfDay(in: report.calendar) + #expect(model.hasDefiniteOverlap) + #expect(model.canSave) + } + + @Test(arguments: ["America/New_York", "America/Los_Angeles", "Pacific/Auckland"]) + func calendarDayBoundariesSurviveLocalDatePickerProjection(timeZoneID: String) throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneID)) + let window = try PlannedStay.DateWindow( + earliest: CalendarDay(year: 2028, month: 2, day: 28), + latest: CalendarDay(year: 2028, month: 3, day: 1), + ) + let boundary = PlannedStayEditorModel.Boundary(window: window, calendar: calendar) + #expect(try boundary.window(in: calendar) == window) + } +} diff --git a/Where/WhereUI/Tests/PlannedStaysModelTests.swift b/Where/WhereUI/Tests/PlannedStaysModelTests.swift new file mode 100644 index 000000000..70d64af97 --- /dev/null +++ b/Where/WhereUI/Tests/PlannedStaysModelTests.swift @@ -0,0 +1,61 @@ +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct PlannedStaysModelTests { + @Test func completedStaysAreRetainedInTheCollapsedPastSection() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let completed = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today.adding(days: -10), + through: PlanningTestSupport.today.adding(days: -1), + ) + let current = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today.adding(days: -3), + through: PlanningTestSupport.today, + ) + try await report.forecasts.create(stay: completed) + try await report.forecasts.create(stay: current) + let model = PlannedStaysModel(report: report, initialRegion: nil) + await model.load() + + #expect(model.upcoming == [current]) + #expect(model.past == [completed]) + #expect(!model.showsPast) + model.edit(completed) + #expect(model.editor?.stayID == completed.id) + #expect(model.editor?.canSave == true) + } + + @Test func historicalGapChoiceClearsHomeWithoutDeletingPlans() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let stay = try PlanningTestSupport.stay( + region: .newYork, + from: PlanningTestSupport.today, + through: PlanningTestSupport.today.adding(days: 7), + ) + try await report.forecasts.create(stay: stay) + try await report.forecasts.setHomeRegion(.california) + let model = PlannedStaysModel(report: report, initialRegion: nil) + await model.load() + await model.usePastTravelPattern() + + let snapshot = try await report.services.plannedStays.snapshot() + #expect(snapshot.homeRegion == nil) + #expect(snapshot.stays == [stay]) + } + + @Test func addUsesTheShortcutRegionAndEachDraftHasItsOwnIdentity() throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let model = PlannedStaysModel(report: report, initialRegion: .newYork) + model.add() + let first = try #require(model.editor) + #expect(first.region == .newYork) + model.editor = nil + model.add() + let second = try #require(model.editor) + #expect(second.stayID != first.stayID) + } +} diff --git a/Where/WhereUI/Tests/PlanningRegionSelectionModelTests.swift b/Where/WhereUI/Tests/PlanningRegionSelectionModelTests.swift new file mode 100644 index 000000000..50da5b46c --- /dev/null +++ b/Where/WhereUI/Tests/PlanningRegionSelectionModelTests.swift @@ -0,0 +1,40 @@ +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct PlanningRegionSelectionModelTests { + @Test func destinationSearchIncludesSupportedUntrackedRegions() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + try await report.services.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: nil, order: 0), + ]) + let model = PlanningRegionSelectionModel(report: report) + await model.load() + #expect(model.trackedRegions == [.california]) + #expect(model.available == PrimaryRegionSelectionModel.usRegions) + #expect(model.grouping.primary == [.california]) + #expect(model.grouping.other.contains(.newYork)) + model.searchText = " New York \n" + #expect(model.filteredRegions == [.newYork]) + #expect(try await report.services.primaryRegions().map(\.region) == [.california]) + } + + @Test func failedHomeSelectionRemainsVisibleAndCanBeRetried() async throws { + let report = try PlanningTestSupport.report(store: SwiftDataStore.inMemory()) + let model = PlanningRegionSelectionModel(report: report) + let saved = await model.select(.newYork) { _ in throw PlannedStaySaveFailure() } + #expect(!saved) + guard case .failed = model.selectionState else { + Issue.record("The region picker must keep the failed selection visible") + return + } + let retried = await model.select(.newYork) { region in + try await report.forecasts.setHomeRegion(region) + } + #expect(retried) + #expect(model.selectionState == .idle) + #expect(try await report.services.plannedStays.snapshot().homeRegion == .newYork) + } +} diff --git a/Where/WhereUI/Tests/PlanningTimelineItemTests.swift b/Where/WhereUI/Tests/PlanningTimelineItemTests.swift new file mode 100644 index 000000000..5f4decdb9 --- /dev/null +++ b/Where/WhereUI/Tests/PlanningTimelineItemTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +struct PlanningTimelineItemTests { + @Test func interleavesHomeGapsAndKeepsSeparateStayIdentities() throws { + let today = CalendarDay(year: 2026, month: 12, day: 20) + let first = try PlannedStay( + id: .init(rawValue: UUID()), + region: .newYork, + arrival: .init(exact: today.adding(days: 3)), + departure: .init(exact: today.adding(days: 4)), + ) + let second = try PlannedStay( + id: .init(rawValue: UUID()), + region: .newYork, + arrival: .init(exact: today.adding(days: 4)), + departure: .init(exact: today.adding(days: 5)), + ) + let items = PlanningTimelineItem.items( + planning: .init(stays: [second, first], homeRegion: .california), + year: 2026, + today: today, + ) + #expect(items.map(\.start) == [ + today.adding(days: 1), + first.arrival.earliest, + second.arrival.earliest, + today.adding(days: 6), + ]) + #expect(items[1].id == .stay(first.id)) + #expect(items[2].id == .stay(second.id)) + #expect(items.first?.membership == .homeAssumed(.certain)) + } +} diff --git a/Where/WhereUI/Tests/Support/PlanningModelTestSupport.swift b/Where/WhereUI/Tests/Support/PlanningModelTestSupport.swift new file mode 100644 index 000000000..6ea6f48bd --- /dev/null +++ b/Where/WhereUI/Tests/Support/PlanningModelTestSupport.swift @@ -0,0 +1,35 @@ +import Foundation +import RegionKit +@_spi(Testing) import WhereCore +@testable import WhereUI + +/// Hermetic services and an explicit clock for planning presentation tests. +enum PlanningModelTestSupport { + static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + return calendar + } + + static let today = CalendarDay(year: 2026, month: 7, day: 15) + static var now: Date { + today.startOfDay(in: calendar) + } + + @MainActor static func model(store: any WhereStore) -> LocationForecastModel { + LocationForecastModel(services: services(store: store), calendar: calendar, now: { now }) + } + + static func services(store: any WhereStore) -> WhereServices { + WhereServices(store: store, locationSource: ScriptedLocationSource(), now: { now }) + } + + static func stay(region: Region) throws -> PlannedStay { + try PlannedStay( + id: .init(rawValue: UUID()), + region: region, + arrival: .init(exact: today), + departure: .init(exact: today.adding(days: 7)), + ) + } +} diff --git a/Where/WhereUI/Tests/Support/PlanningTestSupport.swift b/Where/WhereUI/Tests/Support/PlanningTestSupport.swift new file mode 100644 index 000000000..bdc2676d7 --- /dev/null +++ b/Where/WhereUI/Tests/Support/PlanningTestSupport.swift @@ -0,0 +1,47 @@ +import Foundation +import RegionKit +@_spi(Testing) import WhereCore +@testable import WhereUI + +/// Deterministic production-shaped fixtures shared by the planning model tests. +@MainActor +enum PlanningTestSupport { + static let today = CalendarDay(year: 2026, month: 9, day: 13) + + static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + + static var now: Date { + today.startOfDay(in: calendar) + } + + static func report(store: any WhereStore) -> YearReportModel { + let referenceDate = now + return YearReportModel( + services: WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + now: { referenceDate }, + ), + selectedYear: today.year, + preferences: makePreferences(), + now: { referenceDate }, + ) + } + + static func stay( + region: Region, + from arrival: CalendarDay, + through departure: CalendarDay, + ) throws -> PlannedStay { + try PlannedStay( + id: PlannedStay.ID(rawValue: UUID()), + region: region, + arrival: .init(exact: arrival), + departure: .init(exact: departure), + ) + } +} diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index b92d688d1..7fcc5dc66 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -29,6 +29,38 @@ struct RecordingDeviceSaveFailure: Error, Equatable {} /// /// Everything else forwards to the backing store so reads stay deterministic. actor TestStore: WhereStore { + /// Holds one captured planning result until the test chooses its completion order. + actor PlanningReadGate { + private enum State { + case waiting + case suspended(CheckedContinuation) + case released + } + + private var state: State = .waiting + private var hasArrived = false + private var arrival: CheckedContinuation? + + func waitUntilReached() async { + guard !hasArrived else { return } + await withCheckedContinuation { arrival = $0 } + } + + func suspend() async { + precondition(!hasArrived, "A planning read gate must only suspend one read") + hasArrived = true + arrival?.resume() + arrival = nil + if case .released = state { return } + await withCheckedContinuation { state = .suspended($0) } + } + + func release() { + if case let .suspended(continuation) = state { continuation.resume() } + state = .released + } + } + private let backing: SwiftDataStore private var gateFirstSamplesCall = false @@ -41,8 +73,11 @@ actor TestStore: WhereStore { private var recordingDevicesGate: CheckedContinuation? private var recordingDevicesArrival: CheckedContinuation? + private var nextPlanningReadGate: PlanningReadGate? + private var shouldFailManualDay = false private var shouldFailPlannedStay = false + private var shouldFailPlanningRead = false private var shouldFailSamples = false private var shouldFailNextRecordingDeviceWrite = false @@ -92,6 +127,15 @@ actor TestStore: WhereStore { shouldFailManualDay = true } + func failPlanningReads() { + shouldFailPlanningRead = true + } + + func gateNextPlanningRead(with gate: PlanningReadGate) { + precondition(nextPlanningReadGate == nil, "The next planning read already has a gate") + nextPlanningReadGate = gate + } + func failPlannedStays() { shouldFailPlannedStay = true } @@ -281,7 +325,30 @@ actor TestStore: WhereStore { } func plannedStayRecords() async throws -> [PlannedStayRecord] { - try await backing.plannedStayRecords() + let gate = nextPlanningReadGate + nextPlanningReadGate = nil + let result: Result<[PlannedStayRecord], Error> + do { + if shouldFailPlanningRead { throw PlannedStaySaveFailure() } + result = try await .success(backing.plannedStayRecords()) + } catch { + result = .failure(error) + } + if let gate { await gate.suspend() } + return try result.get() + } + + func homeRegionRecords() async throws -> [HomeRegionRecord] { + try await backing.homeRegionRecords() + } + + func replaceHomeRegionRecord(with record: HomeRegionRecord) async throws { + if shouldFailPlannedStay { throw PlannedStaySaveFailure() } + try await backing.replaceHomeRegionRecord(with: record) + } + + func restoreHomeRegionRecord(_ record: HomeRegionRecord) async throws { + try await backing.restoreHomeRegionRecord(record) } func replacePlannedStayRecord(with record: PlannedStayRecord) async throws { diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index b16f3d678..6aa9b9a71 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -10,6 +10,30 @@ import WhereCore /// catalog value. A removed/renamed key is caught by the compiler, so these /// tests focus on the runtime logic, not that every simple symbol exists. struct WhereFormatTests { + @Test func forecastBoundsPreserveExactPluralizationAndBothRangeEndpoints() { + #expect(WhereFormat.dayCount(DayBounds(exact: 1)) == "1 day") + #expect(WhereFormat.dayCount(DayBounds(lower: 94, upper: 105)) == "94–105 days") + #expect(WhereFormat.forecastPercentage(DayBounds(lower: 94, upper: 105), year: 2026) + .contains("25.8%")) + #expect(WhereFormat.forecastPercentage(DayBounds(lower: 94, upper: 105), year: 2026) + .contains("28.8%")) + } + + @Test func plannedWindowsShowTheYearAndRespectTheInjectedTimezone() throws { + let window = try PlannedStay.DateWindow( + earliest: .init(year: 2026, month: 12, day: 31), + latest: .init(year: 2027, month: 1, day: 2), + ) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Pacific/Honolulu")) + let label = WhereFormat.plannedStayWindow(window, calendar: calendar) + #expect(label.contains("2026")) + #expect(label.contains("2027")) + #expect(label.contains("31")) + #expect(WhereFormat.planningMembership(.planned(.possible)) != WhereFormat + .planningMembership(.homeAssumed(.possible))) + } + @Test func generatedSymbolsResolveToCatalogValues() { #expect(String(localized: .tabSettings) == "Settings") #expect(String(localized: .commonOk) == "OK") diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 1670ffabe..8a3a570e6 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -307,6 +307,7 @@ struct WhereStylesheetTests { let day = calendar.day #expect(day.minHeight == 44) #expect(day.numberSize == 26) + #expect(day.numberFontSize == 17) #expect(day.numberDotSpacing == 0) #expect(day.dotSize == 8) #expect(day.dotOverlap == 2) @@ -325,6 +326,8 @@ struct WhereStylesheetTests { let month = calendar.month #expect(month.sectionSpacing == 8) #expect(month.gridSpacing == 6) + #expect(month.weekdayFontSize == 11) + #expect(month.stacksFooter == false) #expect(month.padding == 16) #expect(month.cornerRadius == 28) #expect(month.plain.fill == Color.primary.opacity(0.03)) @@ -810,6 +813,10 @@ struct WhereStylesheetTests { context.traitOverrides.contentSizeCategory = .accessibilityLarge let resolved = try context.stylesheets.get(WhereStylesheet.self) #expect(resolved.calendar.day.minHeight == 56) + #expect(resolved.calendar.day.numberSize == 36) + #expect(resolved.calendar.day.numberFontSize == 24) + #expect(resolved.calendar.month.weekdayFontSize == 14) + #expect(resolved.calendar.month.stacksFooter) #expect(resolved.timeline.overview.pinsToViewport == false) #expect(resolved.timeline.row.stacksDayCount) #expect(resolved.featureDiscovery.siri.bubble.indent == 0) diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index 2fd0c3597..d1c1351d4 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -64,6 +64,36 @@ struct YearReportModelTests { // MARK: - Year load / stale fetches / save errors + @Test func futureItineraryYearsRemainReachableWithoutEnablingAnnualEstimates() async throws { + let store = try TestStore() + let services = PlanningModelTestSupport.services(store: store) + let model = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { PlanningModelTestSupport.now }, + ) + let stay = try PlannedStay( + id: .init(rawValue: UUID()), + region: .newYork, + arrival: .init(exact: CalendarDay(year: 2027, month: 12, day: 20)), + departure: .init(exact: CalendarDay(year: 2028, month: 1, day: 5)), + ) + try await services.plannedStays.create(stay) + await model.forecasts.refresh() + #expect(model.selectableYears == Array((2021 ... 2028).reversed())) + + await model.select(year: 2028) + #expect(model.forecasts.leadingForecasts(report: model.report).isEmpty) + #expect(model.forecasts.plannedIntervals(intersecting: 2028).count == 1) + try await model.setEstimatedTimeAndPlanningEnabled(false) + #expect(model.selectableYears.contains(2028)) + await model.select(year: 2026) + #expect(model.selectableYears.first == 2026) + try await model.setEstimatedTimeAndPlanningEnabled(true) + #expect(model.selectableYears.first == 2028) + } + @Test func staleYearFetchDoesNotOverwriteNewerSelection() async throws { let store = try TestStore() let services = WhereServices(