diff --git a/Where/TODOs.md b/Where/TODOs.md index d2bf0cbc..eef5e8e4 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -64,6 +64,7 @@ The item format and the placement rule live in the root - 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) +- design(WhereUI) [needs-design]: Add a non-color heatmap encoding — `YearHeatmapChart` maps each day exclusively through `foregroundStyle` (`WhereUI/Sources/Year/YearHeatmapChart.swift:69`–`:71`), while the heatmap stylesheet exposes no Differentiate Without Color variant (`WhereUI/Sources/Shared/WhereStylesheet.swift:677`–`:721`). Similar user-selected region tints and the accessibility setting make the year impossible to scan without inspecting individual cells. Resolve a trait-derived pattern, stroke, or shape variant in `WhereStylesheet`, preserve one accessible mark for multi-region cells, and add snapshot coverage with Differentiate Without Color enabled. (review 2026-08-01) - 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) - refactor(WhereUI) [needs-design]: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. (agent) - refactor(WhereUI) [needs-design]: Split `YearReportModel` further. Post-split it still fuses several roles for the selected year: the loaded report + everything derived from it (ranking, missing days, calendar inputs, tracked-day count), the Resolve badge *count*, the day-write intents (`setManualDay(s)`, `overrideDay`, `clearManualDay`, `clearSelectedYear`), and the Elsewhere drill-in reads (`days(in:)`, `locations(in:)`, `representativeCoordinates()`). The read-only presentation state and the write-intent/drill-in surface could be separate collaborators so a view only holds what it uses. Follow-up from the `WhereSession` split. (agent) diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index c8a4acc5..e2dee5b2 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -107,7 +107,8 @@ one it belongs to rather than to a god-object: - **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, - reminder / summary schedules) behind a `KeyValueStore`. The store has no + reminder / summary schedules, and the preferred Your Year lens) behind a + `KeyValueStore`. The store has no default: production names `UserDefaults.standard` and everything else names `InMemoryKeyValueStore()`, so no test or preview can reach the host's real defaults by saying nothing. diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index ddaf769b..f1b9d70f 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -1,8 +1,9 @@ import Foundation /// The app's persisted user intent — onboarding completion, background-tracking -/// intent, and the reminder / daily-summary schedules — behind a `KeyValueStore` -/// so production uses `UserDefaults` and tests use an in-memory double. +/// intent, reminder / daily-summary schedules, and lightweight UI choices — +/// behind a `KeyValueStore` so production uses `UserDefaults` and tests use an +/// in-memory double. /// /// `store` is deliberately not defaulted: defaulting it to /// `UserDefaults.standard` made the real, process-wide defaults the thing you @@ -99,9 +100,25 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.driftThresholdMeters.rawValue) } } + /// The last lens selected on the Your Year screen. An absent or unknown + /// value falls back to Calendar, matching the screen's first-install state. + public var yearViewMode: YearViewMode { + get { + guard + let rawValue = store.object(forKey: Keys.yearViewMode.rawValue) as? String, + let mode = YearViewMode(rawValue: rawValue) + else { + return .calendar + } + return mode + } + set { store.set(newValue.rawValue, forKey: Keys.yearViewMode.rawValue) } + } + /// Clear every persisted preference so the next launch behaves like a fresh /// install: onboarding shows again, background tracking returns to its - /// default intent, and the reminder/summary schedules revert to defaults. + /// default intent, reminder/summary schedules revert to defaults, and Your + /// Year returns to Calendar. /// Removing the keys (rather than writing `false`/`0`) lets the /// default-valued getters report first-install state again. public func reset() { @@ -124,5 +141,6 @@ public final class WherePreferences { case summaryMinute = "where.summaryMinute" case issueAlertsEnabled = "where.issueAlertsEnabled" case driftThresholdMeters = "where.driftThresholdMeters" + case yearViewMode = "where.yearViewMode" } } diff --git a/Where/WhereCore/Sources/Preferences/YearViewMode.swift b/Where/WhereCore/Sources/Preferences/YearViewMode.swift new file mode 100644 index 00000000..c1c63628 --- /dev/null +++ b/Where/WhereCore/Sources/Preferences/YearViewMode.swift @@ -0,0 +1,12 @@ +import Foundation + +/// The user's preferred lens for the Your Year screen. +/// +/// Raw values are persisted in ``WherePreferences`` and are therefore stable +/// storage identifiers: rename a Swift case only while preserving its raw value. +public enum YearViewMode: String, CaseIterable, Hashable, Sendable { + case calendar + case timeline + case breakdown + case heatmap +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift new file mode 100644 index 00000000..785f6099 --- /dev/null +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import WhereCore + +struct WherePreferencesTests { + @Test func yearViewModeDefaultsToCalendar() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + + #expect(preferences.yearViewMode == .calendar) + } + + @Test func yearViewModeRoundTripsThroughARecreatedPreferencesValue() { + let store = InMemoryKeyValueStore() + let first = WherePreferences(store: store) + first.yearViewMode = .heatmap + + let recreated = WherePreferences(store: store) + + #expect(recreated.yearViewMode == .heatmap) + } + + @Test func unknownYearViewModeFallsBackToCalendar() { + let store = InMemoryKeyValueStore() + store.set("future-mode", forKey: "where.yearViewMode") + let preferences = WherePreferences(store: store) + + #expect(preferences.yearViewMode == .calendar) + } + + @Test func resetClearsYearViewMode() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.yearViewMode = .breakdown + + preferences.reset() + + #expect(preferences.yearViewMode == .calendar) + } +} diff --git a/Where/WhereCore/Tests/YearViewModeTests.swift b/Where/WhereCore/Tests/YearViewModeTests.swift new file mode 100644 index 00000000..91fddd79 --- /dev/null +++ b/Where/WhereCore/Tests/YearViewModeTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import WhereCore + +struct YearViewModeTests { + @Test(arguments: [ + (YearViewMode.calendar, "calendar"), + (.timeline, "timeline"), + (.breakdown, "breakdown"), + (.heatmap, "heatmap"), + ]) + func rawValuesAreStable(argument: (mode: YearViewMode, rawValue: String)) { + #expect(argument.mode.rawValue == argument.rawValue) + } +} diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4e0daddd..c61844b7 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -34,6 +34,11 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and the hosted UI test. +- **`YearView`** — four persisted lenses over the selected report: the detailed + Calendar, chronological Timeline, whole-year Breakdown donut, and a 12×31 + Heatmap with day inspection. The two visualizations share `YearOverview`, + whose mutually exclusive day states always sum to 365/366 without double- + counting travel days. - **Developer tools** — DEBUG-only logging, span, region-map, Flyover, and next-launch Inspector controls. The global launcher's accordion only updates `InspectorModeController`; the current regular runtime continues until the diff --git a/Where/WhereUI/SnapshotTests/YearBreakdownViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/YearBreakdownViewSnapshotTests.swift new file mode 100644 index 00000000..90b1eddd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/YearBreakdownViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct YearBreakdownViewSnapshotTests { + @Test func yearBreakdown() async { + await assertSnapshots(of: YearBreakdownView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/YearHeatmapViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/YearHeatmapViewSnapshotTests.swift new file mode 100644 index 00000000..20d2d519 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/YearHeatmapViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct YearHeatmapViewSnapshotTests { + @Test func yearHeatmap() async { + await assertSnapshots(of: YearHeatmapView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad.png new file mode 100644 index 00000000..d0921b92 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d737563a38de5b1b097bbe7728bb0f598500472f31807849428e2ff8b38e1107 +size 269796 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_accessibility.png new file mode 100644 index 00000000..cfb45a4a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:797efc98c59324185302e5379bbf566e6109d64ba8ef1d523aa0626022f156a8 +size 475037 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_ax5.png new file mode 100644 index 00000000..e6e53399 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a37e3dd90ab1bd0edd4eda57a13599685c47be8ad2e8eefcaab4da4a93211c4f +size 316494 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_contrast.png new file mode 100644 index 00000000..45b80f91 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3c78bc48f1cbcdb4a55c93fac8b32965365eb9502ce3e2212ad91fd90ae4d34 +size 274736 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_dark.png new file mode 100644 index 00000000..671d524c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66cc08d63600b0526e12c5f279250baae8676e446d885263f63c19e4d6663cab +size 297284 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone.png new file mode 100644 index 00000000..77391ea3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e05f9eabbe770ab3140adf0bd7186f3dcb03967ca4ee15e088601bcee9325baa +size 159261 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_accessibility.png new file mode 100644 index 00000000..eab63972 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c280f196a52aebbdff2a7af9be5c7b92df611e46ac30b171bdd2910a943726f +size 330400 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_ax5.png new file mode 100644 index 00000000..8e1a0950 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db21e4ad2c5aa2cd806a14adb1335da1c05ea31cc47e8fcd0ca40ffbc4a04000 +size 204643 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_contrast.png new file mode 100644 index 00000000..e49c5d4f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:02da5be7ef183766af0ff7b23a62e4e1ccb8a8126b9fc69eba98becbf0ca5e8a +size 164302 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_dark.png new file mode 100644 index 00000000..a8a102e9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.Loaded_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:263bd61992859c54aaad05bc2223c3b597bb2395ed7557733835859be28776a2 +size 173670 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MissingDays_iPhone.png new file mode 100644 index 00000000..94c2d537 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MissingDays_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c01b0fadfed22bdfb29e07de76bba18363c2ce80545eeea43fe7a06e8daf1dd6 +size 153244 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MissingDays_iPhone_dark.png new file mode 100644 index 00000000..bb3f8ebc --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MissingDays_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae6cfd6ef8cf3950102c7460f144ad15facd360c0223d9c2a2bdcf96b5c5ffea +size 164841 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MultiRegion_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MultiRegion_iPhone.png new file mode 100644 index 00000000..d3c25a5b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MultiRegion_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fef3d1ee710431b8510cd40e957e0240423b11de709c55840fb38efc500b130b +size 170548 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MultiRegion_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MultiRegion_iPhone_dark.png new file mode 100644 index 00000000..25e9d317 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearBreakdownViewSnapshotTests/yearBreakdown.MultiRegion_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b1e654ee73673b4cc8d2651b87f28f08c78ff0624ca14f2cfb7bba5fb269d01 +size 182706 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad.png new file mode 100644 index 00000000..26b890bb --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:473aff233dc5ad89d1fe45daec7a72f9b18d5eb82d189f559754596802fb9ccd +size 330571 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_accessibility.png new file mode 100644 index 00000000..68218b8c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbf26a09a9a78727f2df18140489c99b9c915fb10adb74bfd6efbcde5c0efd2f +size 3361165 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_ax5.png new file mode 100644 index 00000000..9b1e1b29 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cd263860ff536fc305f2ac5fb9a60da8562be87ca156c289ac24206311abda2 +size 381293 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_contrast.png new file mode 100644 index 00000000..e8e5c9b7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d78859bbda8abaa720df19d43b9ae2c4334bca723492c036041cafee2d0460f +size 338318 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_dark.png new file mode 100644 index 00000000..f8a868d4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46ae6a42501194a412620d15087975258068b768ee1ff7fb3b332c8e7cba9d7b +size 355976 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone.png new file mode 100644 index 00000000..a5f63bca --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15ec68b59286ddfe9c1633eb09f8db19524cf14af0b0bca990bda953d743b4f3 +size 176240 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_accessibility.png new file mode 100644 index 00000000..b3a40357 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f50e4fd638d611cb457973050741e634711704b0dc09bec6977900492e936c +size 2502932 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_ax5.png new file mode 100644 index 00000000..81e4e3b3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dcf662bdac0f86efbcea5cfa4d6a3300ce0e27628a93ac7826b980d7933aac6d +size 220945 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_contrast.png new file mode 100644 index 00000000..c637e59d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53c63a438b980495283615b2c8112e1df01005272c8a5f519a5581b11faf06b9 +size 182489 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_dark.png new file mode 100644 index 00000000..7644e5cf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Loaded_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:694927faaef01887cb0a8722e0656f1bb8211d6914c8e86c4fd6db3d51c7b2f2 +size 192206 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.MultiRegion_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.MultiRegion_iPhone.png new file mode 100644 index 00000000..5c169bc7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.MultiRegion_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f686c724a75794ed9d9d59a770363719adae7ffacfb500265be1e869855bd2 +size 196106 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.MultiRegion_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.MultiRegion_iPhone_dark.png new file mode 100644 index 00000000..60250a41 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.MultiRegion_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c361eec9c61f4390870f03ba49f549c827d5b0c8443874e5c801f0ade8a9327f +size 207033 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Selected_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Selected_iPhone.png new file mode 100644 index 00000000..f282ba52 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Selected_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd6bd5c33a00a18323c9d379f27523423989d747d76cacfb4319865ceef03a43 +size 184209 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Selected_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Selected_iPhone_dark.png new file mode 100644 index 00000000..9b4e0c6f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearHeatmapViewSnapshotTests/yearHeatmap.Selected_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ec21a221baa32caaad658e662df83e92734fc50a4d7426291f350ea26103208 +size 197836 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index 38ab407d..3010a89c 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:7c36ff8383cc51f788115b9ae0aa7fe33662ae6389e079ef3e85269491f780e2 -size 266947 +oid sha256:e1b2e115fc576651eaecb0612db9f06673a27dda4120242e58ea3ca1a77ed90b +size 267577 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 1be8b55a..e28d48f1 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:54e9f3fca9658a369823756b32fab42927ff44529e81a19f9e2e7a133ffb8a89 -size 260456 +oid sha256:822b40af3ced5be17b718c02977e828926c8bc4a970bcdb69219770d1a621193 +size 267615 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png index 7a0e86b0..8f0b751a 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:c99bdee6dd2c23aa4c25792063d4114be45113089ece2e48ba29ffe791ab881d -size 483436 +oid sha256:036820f31d253cc91f69b8fee1d1764b3014595493a5563ad8ffc71b4005dafc +size 526333 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 b654909f..d73833af 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:653af5d0c915e52ae50c26baa4adb49c706de25869f4fc9d8c63be2fa297bfd5 -size 343215 +oid sha256:2eaa9ce14f7530caf6a68c42c4f8d864e9300c2c1ab15c0b4a16e0394ceab02d +size 388971 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 86e34693..5aaa74f7 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:0b964570700ba0a5ce9846fe2aba0345924135e8ad9d81e5b9cad1c03c48705e -size 601893 +oid sha256:6fc75a867b1a1999d60e805c4f9ffb101f7d98ffc3bee2b4113a2ddf2f32a9a8 +size 521632 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 4bb80560..bf02b110 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:ef3286cdee7f78af8860ccfc7d594e351359f5a2fb0e43c8bb73785b573305a7 -size 476706 +oid sha256:a6b3cfe848f01e6b350d46c79db285a5afdb4814292ace81879de91fc7c999f2 +size 510683 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 fbc90744..3b9181c0 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:d74f4fe06ec8e51f754bc26ac5c9e29e68bfe4eba7972ca6c4790489df28b09f -size 478250 +oid sha256:d6b8d8e20e039066457b254933272eabc9bbe3a9cb015919f748be356c7981e1 +size 544008 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png index f0b1645c..f45e6e06 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:b64ba2fd768f0b7252d5c35f94c5f180a77e4d35bbea26658ec131a34628b580 -size 276870 +oid sha256:e41b1c8c7b57f0bfa66b1832f349ab8b46e43cb9564ade94fbd00c5a5f8ecbec +size 288399 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 aba4ca34..0d08d765 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:40f16d6973b20c931e77ec24c733e2e9f61d2d7b33d9e921d1d15f029b99f7ac -size 209468 +oid sha256:03a8c74216a232644e77ae8ff29da96310e68091c668ced0e1978e7cf2d2b07c +size 231378 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 268fc3ef..b9e66a3b 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:8d4fee3229f9a7c904144c8960b2422b196c29c04edc71c85443179ece3a850a -size 267395 +oid sha256:4d760922795efe4ba45f7e3dce9341d24ac20849d75e009ab418c2a2a9ea66c2 +size 273456 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 d006cd43..cc291042 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:55beddd007619b7500ecf1b24e62595805f7b4ebafd7da914f39b2d740761665 -size 269184 +oid sha256:ebb32c358c2e6576964a1197979af27ea8a4357c1c7bc788e4e117a80225b02f +size 278224 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 cfb8bf13..51155399 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:b2105f5870233277326e52e2cb4150aeeac5547206a7c48c841404b543161fed -size 280470 +oid sha256:42fb73ffa637f50c2d20475fa966140fd69c17c4225f9b6658722cb10edc128e +size 294271 diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index f51ef345..43754a40 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -76,6 +76,8 @@ YearView.flyoverData, CalendarContentView.yearFlyoverData, PresenceTimelineList.flyoverData, + YearBreakdownView.flyoverData, + YearHeatmapView.flyoverData, RecentActivitySummaryView.flyoverData, ] } diff --git a/Where/WhereUI/Sources/Model/YearOverview.swift b/Where/WhereUI/Sources/Model/YearOverview.swift new file mode 100644 index 00000000..67f8fbcb --- /dev/null +++ b/Where/WhereUI/Sources/Model/YearOverview.swift @@ -0,0 +1,146 @@ +import Foundation +import RegionKit +import WhereCore + +/// A complete, mutually exclusive classification of every calendar day in one +/// year, shared by the Your Year breakdown and heatmap visualizations. +struct YearOverview { + /// One day in the year and the single state it can display as. + struct Day: Hashable, Identifiable { + enum Kind: Hashable { + case region(Region) + case multipleLocations([Region]) + case unrecorded + case remaining + + var sliceID: Slice.ID { + switch self { + case let .region(region): .region(region) + case .multipleLocations: .multipleLocations + case .unrecorded: .unrecorded + case .remaining: .remaining + } + } + + var isRecorded: Bool { + switch self { + case .region, .multipleLocations: true + case .unrecorded, .remaining: false + } + } + } + + let id: CalendarDay + let kind: Kind + } + + /// One mutually exclusive donut segment. Region segments are followed by + /// the special categories in a stable order. + struct Slice: Hashable, Identifiable { + enum ID: Hashable { + case region(Region) + case multipleLocations + case unrecorded + case remaining + } + + let id: ID + let days: Int + } + + let year: Int + let days: [Day] + let slices: [Slice] + let regions: [Region] + let recordedDayCount: Int + + private let daysByID: [CalendarDay: Day] + + var dayCount: Int { + days.count + } + + init(report: YearReport, referenceDate: Date, calendar: Calendar) { + year = report.year + let referenceDay = CalendarDay(from: referenceDate, in: calendar) + let presenceByDay = Dictionary( + uniqueKeysWithValues: report.days.map { ($0.day, $0) }, + ) + + let classifiedDays = CalendarDay.yearRange(report.year).lowerBound + .days(through: CalendarDay.yearRange(report.year).upperBound) + .map { day in + Day(id: day, kind: Self.kind( + for: day, + presence: presenceByDay[day], + referenceDay: referenceDay, + )) + } + + days = classifiedDays + daysByID = Dictionary(uniqueKeysWithValues: classifiedDays.map { ($0.id, $0) }) + regions = Region.inCanonicalOrder(Set(classifiedDays.flatMap { day -> [Region] in + switch day.kind { + case let .region(region): return [region] + case let .multipleLocations(regions): return regions + case .unrecorded, .remaining: return [] + } + })) + recordedDayCount = classifiedDays.count(where: { $0.kind.isRecorded }) + slices = Self.makeSlices(from: classifiedDays) + } + + func day(month: Int, dayOfMonth: Int) -> Day? { + daysByID[CalendarDay(year: year, month: month, day: dayOfMonth)] + } + + private static func kind( + for day: CalendarDay, + presence: DayPresence?, + referenceDay: CalendarDay, + ) -> Day.Kind { + // A future entry is not elapsed time. Keeping it Remaining makes the + // overview honest even if malformed/imported data contains one. + guard day <= referenceDay else { return .remaining } + + let regions = Region.inCanonicalOrder(presence?.regions ?? []) + if let region = regions.first, regions.count == 1 { + return .region(region) + } + if regions.count > 1 { + return .multipleLocations(regions) + } + + // Today is still in progress; existing missing-day rules likewise wait + // until tomorrow before treating it as an unrecorded day. + return day == referenceDay ? .remaining : .unrecorded + } + + private static func makeSlices(from days: [Day]) -> [Slice] { + var totals: [Slice.ID: Int] = [:] + for day in days { + totals[day.kind.sliceID, default: 0] += 1 + } + + let regionSlices = totals.compactMap { id, count -> Slice? in + guard case .region = id, count > 0 else { return nil } + return Slice(id: id, days: count) + }.sorted { lhs, rhs in + if lhs.days != rhs.days { return lhs.days > rhs.days } + guard case let .region(lhsRegion) = lhs.id, + case let .region(rhsRegion) = rhs.id + else { + return false + } + return Region.declarationOrder[lhsRegion, default: 0] + < Region.declarationOrder[rhsRegion, default: 0] + } + + let specialOrder: [Slice.ID] = [.multipleLocations, .unrecorded, .remaining] + let specialSlices = specialOrder.compactMap { id -> Slice? in + guard let count = totals[id], count > 0 else { return nil } + return Slice(id: id, days: count) + } + return regionSlices + specialSlices + } +} diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 0d965182..96e23690 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -310,6 +310,62 @@ ) } + /// The shared loaded year classification rendered by the Breakdown and + /// Heatmap previews/snapshots. + @MainActor + static func loadedYearOverview() -> YearOverview { + yearOverview(from: loadedYearReportModel()) + } + + /// A sparse current-year classification with real Unrecorded and + /// Remaining cells for visualization edge-state coverage. + @MainActor + static func missingDaysYearOverview() -> YearOverview { + yearOverview(from: missingDaysYearReportModel()) + } + + /// A sparse current-year classification containing a two-region travel + /// day, used to guard the donut bucket and abrupt heatmap split fill. + @MainActor + static func multiRegionYearOverview() -> YearOverview { + let model = missingDaysYearReportModel() + let report = YearReport( + year: year, + days: [ + DayPresence( + day: CalendarDay(year: year, month: 1, day: 1), + regions: [.california], + ), + DayPresence( + day: CalendarDay(year: year, month: 1, day: 2), + regions: [.newYork, .california], + ), + DayPresence( + day: CalendarDay(year: year, month: 1, day: 3), + regions: [.newYork], + ), + ], + totals: [:], + ) + return YearOverview( + report: report, + referenceDate: model.referenceDate, + calendar: model.calendar, + ) + } + + @MainActor + private static func yearOverview(from model: YearReportModel) -> YearOverview { + guard let report = model.report else { + preconditionFailure("A year-overview preview fixture must carry a report") + } + return YearOverview( + report: report, + referenceDate: model.referenceDate, + calendar: model.calendar, + ) + } + // MARK: - Resolve model (Resolve tab) /// One data-resolution issue per category, for Resolve tab previews/tests. diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 95c54b18..7f14435f 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -4675,6 +4675,127 @@ } } }, + "year.breakdown.accessibilityLabel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year breakdown" + } + } + } + }, + "year.breakdown.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breakdown" + } + } + } + }, + "year.heatmap.accessibilityHint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap or drag across the grid to inspect a day." + } + } + } + }, + "year.heatmap.accessibilityLabel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year heatmap" + } + } + } + }, + "year.heatmap.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heatmap" + } + } + } + }, + "year.overview.day.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + } + } + }, + "year.overview.multipleLocations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiple locations" + } + } + } + }, + "year.overview.multipleLocations.detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiple locations: %@" + } + } + } + }, + "year.overview.recorded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld of %2$lld days recorded" + } + } + } + }, + "year.overview.remaining" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remaining" + } + } + } + }, + "year.overview.unrecorded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unrecorded" + } + } + } + }, "year.segmentPicker" : { "comment" : "Accessibility label for the Your Year tab's segmented control.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 64f33d23..748d76bc 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -132,6 +132,61 @@ enum WhereFormat { String(localized: .widgetYearTitle(yearText(year))) } + // MARK: Year overview + + static func yearOverviewRecorded(recorded: Int, total: Int) -> String { + String(localized: .yearOverviewRecorded(recorded, total)) + } + + static func yearHeatmapMonthSymbol( + month: Int, + year: Int, + calendar: Calendar, + locale: Locale, + ) -> String { + CalendarDay(year: year, month: month, day: 1) + .startOfDay(in: calendar) + .formatted( + Date.FormatStyle( + locale: locale, + calendar: calendar, + timeZone: calendar.timeZone, + ) + .month(.abbreviated), + ) + } + + static func yearOverviewSliceName(_ id: YearOverview.Slice.ID) -> String { + switch id { + case let .region(region): region.localizedName + case .multipleLocations: String(localized: .yearOverviewMultipleLocations) + case .unrecorded: String(localized: .yearOverviewUnrecorded) + case .remaining: String(localized: .yearOverviewRemaining) + } + } + + static func yearOverviewKindName(_ kind: YearOverview.Day.Kind) -> String { + switch kind { + case let .region(region): return region.localizedName + case let .multipleLocations(regions): + let names = regions.map(\.localizedName).formatted(.list(type: .and)) + return String(localized: .yearOverviewMultipleLocationsDetail(names)) + case .unrecorded: return String(localized: .yearOverviewUnrecorded) + case .remaining: return String(localized: .yearOverviewRemaining) + } + } + + static func yearOverviewDayAccessibility( + date: Date, + kind: YearOverview.Day.Kind, + ) -> String { + let day = date.formatted(.dateTime.weekday(.wide).month(.wide).day().year()) + return String(localized: .yearOverviewDayAccessibility( + day, + yearOverviewKindName(kind), + )) + } + // MARK: Regions static func secondaryRegionCurrent(regions: String) -> String { diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 7b5cf3bf..42e38dbe 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -16,6 +16,7 @@ struct WhereStylesheet: BStylesheet { var size = Size() var card = CardStyles.standard var calendar = CalendarStyle.standard + var yearOverview = YearOverviewStyle.standard var appIcon = AppIconStyle.standard var timeline = TimelineStyle.standard var regionMap = RegionMapStyle.standard @@ -54,6 +55,8 @@ struct WhereStylesheet: BStylesheet { if traits.accessibility.isReduceMotionEnabled { card.dayCount = .reducedMotion developerOverlay.menu.motion = .reduced + yearOverview.picker.selectionAnimation = .easeInOut(duration: 0.18) + yearOverview.picker.contentAnimation = .easeInOut(duration: 0.18) } } @@ -647,6 +650,87 @@ extension WhereStylesheet { } } +// MARK: - Year overview + +extension WhereStylesheet { + /// Appearance for the Your Year Breakdown, Heatmap, and their shared mode + /// picker. Region colors remain in `RegionStyle`; this group owns only the + /// special day categories and visualization geometry. + struct YearOverviewStyle: Equatable { + var multipleLocationsColor: Color + var unrecordedColor: Color + var remainingColor: Color + var breakdown: Breakdown + var heatmap: Heatmap + var picker: Picker + + struct Breakdown: Equatable { + var maxChartSize: CGFloat + var innerRadiusRatio: CGFloat + var centerContentWidthRatio: CGFloat + var angularInset: CGFloat + var chartLegendSpacing: CGFloat + var legendRowSpacing: CGFloat + var legendSwatchSize: CGFloat + } + + struct Heatmap: Equatable { + var plotAspectRatio: CGFloat + var cellCornerRadius: CGFloat + var cellWidthRatio: CGFloat + var cellHeightRatio: CGFloat + var selectionWidthRatio: CGFloat + var selectionHeightRatio: CGFloat + var calloutCornerRadius: CGFloat + var calloutPadding: CGFloat + var legendMinItemWidth: CGFloat + var legendSpacing: CGFloat + } + + struct Picker: Equatable { + var segmentMinSize: CGFloat + var horizontalPadding: CGFloat + var verticalPadding: CGFloat + var selectionAnimation: Animation + var contentAnimation: Animation + } + + static let standard = YearOverviewStyle( + multipleLocationsColor: .primary, + unrecordedColor: .red, + remainingColor: Color.secondary.opacity(0.22), + breakdown: Breakdown( + maxChartSize: 360, + innerRadiusRatio: 0.62, + centerContentWidthRatio: 0.8, + angularInset: 1.5, + chartLegendSpacing: 24, + legendRowSpacing: 10, + legendSwatchSize: 12, + ), + heatmap: Heatmap( + plotAspectRatio: 31 / 12, + cellCornerRadius: 2, + cellWidthRatio: 0.84, + cellHeightRatio: 0.78, + selectionWidthRatio: 0.98, + selectionHeightRatio: 0.92, + calloutCornerRadius: 14, + calloutPadding: 12, + legendMinItemWidth: 132, + legendSpacing: 10, + ), + picker: Picker( + segmentMinSize: 44, + horizontalPadding: 12, + verticalPadding: 8, + selectionAnimation: .snappy(duration: 0.28), + contentAnimation: .default, + ), + ) + } +} + // MARK: - App Icon extension WhereStylesheet { diff --git a/Where/WhereUI/Sources/Year/YearBreakdownLegendRow.swift b/Where/WhereUI/Sources/Year/YearBreakdownLegendRow.swift new file mode 100644 index 00000000..7e30ec9e --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearBreakdownLegendRow.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// A breakdown legend item that moves its count below the title when both +/// values cannot remain readable on one line. +struct YearBreakdownLegendRow: View { + let title: String + let dayCount: String + let symbol: String + let color: Color + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: stylesheet.spacing.large) { + Image(systemName: symbol) + .foregroundStyle(color) + .frame( + minWidth: stylesheet.yearOverview.breakdown.legendSwatchSize, + minHeight: stylesheet.yearOverview.breakdown.legendSwatchSize, + ) + .accessibilityHidden(true) + Text(title) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: stylesheet.spacing.large) + Text(dayCount) + .foregroundStyle(.secondary) + .monospacedDigit() + .fixedSize(horizontal: true, vertical: false) + } + + HStack(alignment: .top, spacing: stylesheet.spacing.large) { + Image(systemName: symbol) + .foregroundStyle(color) + .frame( + minWidth: stylesheet.yearOverview.breakdown.legendSwatchSize, + minHeight: stylesheet.yearOverview.breakdown.legendSwatchSize, + ) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: stylesheet.spacing.small) { + Text(title) + Text(dayCount) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +#if DEBUG + #Preview { + YearBreakdownLegendRow( + title: "California", + dayCount: "148 days", + symbol: "sun.max.fill", + color: .orange, + ) + .padding() + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearBreakdownView.swift b/Where/WhereUI/Sources/Year/YearBreakdownView.swift new file mode 100644 index 00000000..cc2f17a3 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearBreakdownView.swift @@ -0,0 +1,126 @@ +import Charts +import SnapshotKit +import SwiftUI + +/// A whole-year donut whose mutually exclusive slices always total exactly the +/// selected year's 365 or 366 calendar days. +struct YearBreakdownView: View { + let overview: YearOverview + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + + private var style: WhereStylesheet.YearOverviewStyle { + stylesheet.yearOverview + } + + var body: some View { + ScrollView { + LazyVStack(spacing: style.breakdown.chartLegendSpacing) { + ZStack { + Chart(overview.slices) { slice in + SectorMark( + angle: .value("Days", slice.days), + innerRadius: .ratio(style.breakdown.innerRadiusRatio), + angularInset: style.breakdown.angularInset, + ) + .foregroundStyle(color(for: slice.id)) + .accessibilityLabel(WhereFormat.yearOverviewSliceName(slice.id)) + .accessibilityValue(WhereFormat.dayCount(slice.days)) + } + .chartLegend(.hidden) + .accessibilityLabel(String(localized: .yearBreakdownAccessibilityLabel)) + + VStack(spacing: stylesheet.spacing.small) { + Text(WhereFormat.yearText(overview.year)) + .font(.headline) + Text(WhereFormat.yearOverviewRecorded( + recorded: overview.recordedDayCount, + total: overview.dayCount, + )) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + .frame(maxWidth: style.breakdown.maxChartSize + * style.breakdown.innerRadiusRatio + * style.breakdown.centerContentWidthRatio) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .accessibilityElement(children: .combine) + } + .frame(maxWidth: style.breakdown.maxChartSize) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + + LazyVStack(spacing: style.breakdown.legendRowSpacing) { + ForEach(overview.slices) { slice in + YearBreakdownLegendRow( + title: WhereFormat.yearOverviewSliceName(slice.id), + dayCount: WhereFormat.dayCount(slice.days), + symbol: symbol(for: slice.id), + color: color(for: slice.id), + ) + } + } + } + .padding() + } + } + + private func color(for id: YearOverview.Slice.ID) -> Color { + switch id { + case let .region(region): regionStyles.style(for: region).tint + case .multipleLocations: style.multipleLocationsColor + case .unrecorded: style.unrecordedColor + case .remaining: style.remainingColor + } + } + + private func symbol(for id: YearOverview.Slice.ID) -> String { + switch id { + case let .region(region): regionStyles.style(for: region).symbolName + case .multipleLocations: "arrow.triangle.branch" + case .unrecorded: "exclamationmark.circle.fill" + case .remaining: "clock.fill" + } + } +} + +#if DEBUG + extension YearBreakdownView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Loaded", configurations: .screenDefaults) { + NavigationStack { + YearBreakdownView(overview: PreviewSupport.loadedYearOverview()) + .navigationTitle(String(localized: .yearBreakdownTitle)) + } + } + whereSnapshot(name: "MissingDays", configurations: .phoneLightDark) { + NavigationStack { + YearBreakdownView(overview: PreviewSupport.missingDaysYearOverview()) + .navigationTitle(String(localized: .yearBreakdownTitle)) + } + } + whereSnapshot(name: "MultiRegion", configurations: .phoneLightDark) { + NavigationStack { + YearBreakdownView(overview: PreviewSupport.multiRegionYearOverview()) + .navigationTitle(String(localized: .yearBreakdownTitle)) + } + } + } + } + + #Preview { + YearBreakdownView.snapshotPreviews + } +#endif + +#if DEBUG + extension YearBreakdownView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.snapshots( + YearBreakdownView.self, + title: "Breakdown", + ) + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearHeatmapChart.swift b/Where/WhereUI/Sources/Year/YearHeatmapChart.swift new file mode 100644 index 00000000..3ef236a0 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearHeatmapChart.swift @@ -0,0 +1,178 @@ +import Charts +import RegionKit +import SwiftUI +import WhereCore + +/// The dense 12×31 plot and its coordinate-to-day selection gesture. +struct YearHeatmapChart: View { + let overview: YearOverview + let calendar: Calendar + @Binding var selectedDayID: CalendarDay? + + @Environment(\.locale) private var locale + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + + private var style: WhereStylesheet.YearOverviewStyle { + stylesheet.yearOverview + } + + var body: some View { + Chart { + ForEach(overview.days) { day in + if selectedDayID == day.id { + RectangleMark( + xStart: .value( + "Selection start", + Double(day.id.day) + - Double(style.heatmap.selectionWidthRatio) / 2, + ), + xEnd: .value( + "Selection end", + Double(day.id.day) + + Double(style.heatmap.selectionWidthRatio) / 2, + ), + yStart: .value( + "Selection row start", + row(for: day.id.month) + - Double(style.heatmap.selectionHeightRatio) / 2, + ), + yEnd: .value( + "Selection row end", + row(for: day.id.month) + + Double(style.heatmap.selectionHeightRatio) / 2, + ), + ) + .foregroundStyle(Color.primary) + .cornerRadius(style.heatmap.cellCornerRadius) + .accessibilityHidden(true) + } + + RectangleMark( + xStart: .value( + "Day start", + Double(day.id.day) - Double(style.heatmap.cellWidthRatio) / 2, + ), + xEnd: .value( + "Day end", + Double(day.id.day) + Double(style.heatmap.cellWidthRatio) / 2, + ), + yStart: .value( + "Month row start", + row(for: day.id.month) - Double(style.heatmap.cellHeightRatio) / 2, + ), + yEnd: .value( + "Month row end", + row(for: day.id.month) + Double(style.heatmap.cellHeightRatio) / 2, + ), + ) + .foregroundStyle(fill(for: day.kind)) + .cornerRadius(style.heatmap.cellCornerRadius) + .accessibilityLabel(dayAccessibility(day)) + } + } + .chartLegend(.hidden) + .chartXScale(domain: 0.5 ... 31.5) + .chartYScale(domain: 0.5 ... 12.5) + .chartXAxis { + AxisMarks(values: [1, 5, 10, 15, 20, 25, 31]) { _ in + AxisValueLabel() + } + } + .chartYAxis { + AxisMarks(position: .leading, values: Array(1 ... 12)) { value in + AxisValueLabel { + if let position = value.as(Int.self) { + Text(monthSymbol(forRow: position)) + } + } + } + } + // The plot has twelve fixed-height rows. Keep its supplementary axis + // labels compact while the selectable callout and legend continue to + // honor the user's full Dynamic Type size. + .dynamicTypeSize(...DynamicTypeSize.large) + .aspectRatio(style.heatmap.plotAspectRatio, contentMode: .fit) + .chartGesture { proxy in + DragGesture(minimumDistance: 0) + .onChanged { value in + select(at: value.location, proxy: proxy) + } + } + .accessibilityLabel(String(localized: .yearHeatmapAccessibilityLabel)) + .accessibilityHint(String(localized: .yearHeatmapAccessibilityHint)) + } + + private func row(for month: Int) -> Double { + Double(13 - month) + } + + private func monthSymbol(forRow row: Int) -> String { + WhereFormat.yearHeatmapMonthSymbol( + month: 13 - row, + year: overview.year, + calendar: calendar, + locale: locale, + ) + } + + private func select(at location: CGPoint, proxy: ChartProxy) { + guard let (dayValue, rowValue) = proxy.value( + at: location, + as: (Double, Double).self, + ) else { + selectedDayID = nil + return + } + let dayOfMonth = Int(dayValue.rounded()) + let month = 13 - Int(rowValue.rounded()) + selectedDayID = overview.day(month: month, dayOfMonth: dayOfMonth)?.id + } + + private func fill(for kind: YearOverview.Day.Kind) -> AnyShapeStyle { + switch kind { + case let .region(region): + return AnyShapeStyle(regionStyles.style(for: region).tint) + case let .multipleLocations(regions): + let count = CGFloat(regions.count) + let stops = regions.enumerated().flatMap { index, region in + let start = CGFloat(index) / count + let end = CGFloat(index + 1) / count + let color = regionStyles.style(for: region).tint + return [ + Gradient.Stop(color: color, location: start), + Gradient.Stop(color: color, location: end), + ] + } + return AnyShapeStyle(LinearGradient( + gradient: Gradient(stops: stops), + startPoint: .leading, + endPoint: .trailing, + )) + case .unrecorded: + return AnyShapeStyle(style.unrecordedColor) + case .remaining: + return AnyShapeStyle(style.remainingColor) + } + } + + private func dayAccessibility(_ day: YearOverview.Day) -> String { + WhereFormat.yearOverviewDayAccessibility( + date: day.id.startOfDay(in: calendar), + kind: day.kind, + ) + } +} + +#if DEBUG + #Preview { + let model = PreviewSupport.loadedYearReportModel() + YearHeatmapChart( + overview: PreviewSupport.loadedYearOverview(), + calendar: model.calendar, + selectedDayID: .constant(CalendarDay(year: 2026, month: 1, day: 1)), + ) + .padding() + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearHeatmapLegend.swift b/Where/WhereUI/Sources/Year/YearHeatmapLegend.swift new file mode 100644 index 00000000..17df1766 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearHeatmapLegend.swift @@ -0,0 +1,67 @@ +import RegionKit +import SwiftUI + +/// The non-color legend for heatmap regions and special day states. +struct YearHeatmapLegend: View { + let overview: YearOverview + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + private var style: WhereStylesheet.YearOverviewStyle { + stylesheet.yearOverview + } + + var body: some View { + LazyVGrid( + columns: columns, + alignment: .leading, + spacing: style.heatmap.legendSpacing, + ) { + ForEach(overview.regions, id: \.self) { region in + YearHeatmapLegendLabel( + title: region.localizedName, + symbol: regionStyles.style(for: region).symbolName, + color: regionStyles.style(for: region).tint, + ) + } + if hasSlice(.unrecorded) { + YearHeatmapLegendLabel( + title: String(localized: .yearOverviewUnrecorded), + symbol: "exclamationmark.circle.fill", + color: style.unrecordedColor, + ) + } + if hasSlice(.remaining) { + YearHeatmapLegendLabel( + title: String(localized: .yearOverviewRemaining), + symbol: "clock.fill", + color: style.remainingColor, + ) + } + } + } + + private var columns: [GridItem] { + if dynamicTypeSize.isAccessibilitySize { + return [GridItem(.flexible(), alignment: .leading)] + } + return [GridItem( + .adaptive(minimum: style.heatmap.legendMinItemWidth), + alignment: .leading, + )] + } + + private func hasSlice(_ id: YearOverview.Slice.ID) -> Bool { + overview.slices.contains { $0.id == id } + } +} + +#if DEBUG + #Preview { + YearHeatmapLegend(overview: PreviewSupport.loadedYearOverview()) + .padding() + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearHeatmapLegendLabel.swift b/Where/WhereUI/Sources/Year/YearHeatmapLegendLabel.swift new file mode 100644 index 00000000..ed5c0b55 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearHeatmapLegendLabel.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// One symbol-plus-title item in the heatmap legend. +struct YearHeatmapLegendLabel: View { + let title: String + let symbol: String + let color: Color + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + HStack(spacing: stylesheet.spacing.medium) { + Image(systemName: symbol) + .foregroundStyle(color) + .accessibilityHidden(true) + Text(title) + } + .accessibilityElement(children: .combine) + } +} + +#if DEBUG + #Preview { + YearHeatmapLegendLabel( + title: "California", + symbol: "sun.max.fill", + color: .orange, + ) + .padding() + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearHeatmapSelectionCard.swift b/Where/WhereUI/Sources/Year/YearHeatmapSelectionCard.swift new file mode 100644 index 00000000..c1b72f22 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearHeatmapSelectionCard.swift @@ -0,0 +1,74 @@ +import RegionKit +import SwiftUI + +/// The compact textual readout for the heatmap's selected day. +struct YearHeatmapSelectionCard: View { + let day: YearOverview.Day + let calendar: Calendar + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + + private var style: WhereStylesheet.YearOverviewStyle { + stylesheet.yearOverview + } + + var body: some View { + HStack(spacing: stylesheet.spacing.large) { + Image(systemName: symbol) + .foregroundStyle(color) + .imageScale(.large) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { + Text( + day.id.startOfDay(in: calendar), + format: .dateTime.weekday(.wide).month(.wide).day().year(), + ) + .font(.headline) + Text(WhereFormat.yearOverviewKindName(day.kind)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(style.heatmap.calloutPadding) + .background(Color.primary.opacity(0.05), in: .rect( + cornerRadius: style.heatmap.calloutCornerRadius, + )) + .accessibilityElement(children: .ignore) + .accessibilityLabel(WhereFormat.yearOverviewDayAccessibility( + date: day.id.startOfDay(in: calendar), + kind: day.kind, + )) + } + + private var color: Color { + switch day.kind { + case let .region(region): regionStyles.style(for: region).tint + case .multipleLocations: style.multipleLocationsColor + case .unrecorded: style.unrecordedColor + case .remaining: style.remainingColor + } + } + + private var symbol: String { + switch day.kind { + case let .region(region): regionStyles.style(for: region).symbolName + case .multipleLocations: "arrow.triangle.branch" + case .unrecorded: "exclamationmark.circle.fill" + case .remaining: "clock.fill" + } + } +} + +#if DEBUG + #Preview { + let model = PreviewSupport.loadedYearReportModel() + let overview = PreviewSupport.loadedYearOverview() + if let day = overview.day(month: 1, dayOfMonth: 1) { + YearHeatmapSelectionCard(day: day, calendar: model.calendar) + .padding() + .whereBroadwayRoot() + } + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearHeatmapView.swift b/Where/WhereUI/Sources/Year/YearHeatmapView.swift new file mode 100644 index 00000000..973a62b5 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearHeatmapView.swift @@ -0,0 +1,103 @@ +import SnapshotKit +import SwiftUI +import WhereCore + +/// A GitHub-style whole-year grid: one row per month and aligned day-of-month +/// columns, with tap/drag inspection that stays on the same screen. +struct YearHeatmapView: View { + let overview: YearOverview + let calendar: Calendar + + @State private var selectedDayID: CalendarDay? + + @Environment(\.stylesheet) private var stylesheet + + init( + overview: YearOverview, + calendar: Calendar, + initialSelection: CalendarDay? = nil, + ) { + self.overview = overview + self.calendar = calendar + _selectedDayID = State(initialValue: initialSelection) + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: stylesheet.spacing.xxLarge) { + YearHeatmapChart( + overview: overview, + calendar: calendar, + selectedDayID: $selectedDayID, + ) + + if let selectedDay { + YearHeatmapSelectionCard(day: selectedDay, calendar: calendar) + .transition(.opacity) + } + + YearHeatmapLegend(overview: overview) + } + .padding() + } + .animation(stylesheet.yearOverview.picker.contentAnimation, value: selectedDayID) + .onChange(of: overview.year) { _, _ in selectedDayID = nil } + } + + private var selectedDay: YearOverview.Day? { + guard let selectedDayID else { return nil } + return overview.day(month: selectedDayID.month, dayOfMonth: selectedDayID.day) + } +} + +#if DEBUG + extension YearHeatmapView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Loaded", configurations: .screenDefaults) { + let model = PreviewSupport.loadedYearReportModel() + NavigationStack { + YearHeatmapView( + overview: PreviewSupport.loadedYearOverview(), + calendar: model.calendar, + ) + .navigationTitle(String(localized: .yearHeatmapTitle)) + } + } + whereSnapshot(name: "Selected", configurations: .phoneLightDark) { + let model = PreviewSupport.missingDaysYearReportModel() + NavigationStack { + YearHeatmapView( + overview: PreviewSupport.missingDaysYearOverview(), + calendar: model.calendar, + initialSelection: CalendarDay(year: 2026, month: 1, day: 4), + ) + .navigationTitle(String(localized: .yearHeatmapTitle)) + } + } + whereSnapshot(name: "MultiRegion", configurations: .phoneLightDark) { + let model = PreviewSupport.missingDaysYearReportModel() + NavigationStack { + YearHeatmapView( + overview: PreviewSupport.multiRegionYearOverview(), + calendar: model.calendar, + initialSelection: CalendarDay(year: 2026, month: 1, day: 2), + ) + .navigationTitle(String(localized: .yearHeatmapTitle)) + } + } + } + } + + #Preview { + YearHeatmapView.snapshotPreviews + } +#endif + +#if DEBUG + extension YearHeatmapView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.snapshots( + YearHeatmapView.self, + title: "Heatmap", + ) + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearModeContent.swift b/Where/WhereUI/Sources/Year/YearModeContent.swift new file mode 100644 index 00000000..1011bdb5 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearModeContent.swift @@ -0,0 +1,56 @@ +import SwiftUI +import WhereCore + +/// The single report-state gate for every Your Year lens. Calendar keeps its +/// own gate because it is also hosted outside `YearView`. +struct YearModeContent: View { + let report: YearReportModel + let mode: YearViewMode + + var body: some View { + Group { + if let yearReport = report.report { + let overview = YearOverview( + report: yearReport, + referenceDate: report.referenceDate, + calendar: report.calendar, + ) + switch mode { + case .calendar: + CalendarContentView(report: report) + .transition(.opacity) + case .timeline: + PresenceTimelineList(report: report) + .transition(.opacity) + case .breakdown: + YearBreakdownView(overview: overview) + .transition(.opacity) + case .heatmap: + YearHeatmapView(overview: overview, calendar: report.calendar) + .transition(.opacity) + } + } else if case let .failed(error) = report.loadState { + ContentUnavailableView { + Label( + String(localized: .commonLoadErrorTitle), + systemImage: "exclamationmark.icloud", + ) + } description: { + Text(error.message) + } + } else { + AppIconLoadingView(caption: String(localized: .primaryLoading)) + } + } + } +} + +#if DEBUG + #Preview { + YearModeContent( + report: PreviewSupport.loadedYearReportModel(), + mode: .breakdown, + ) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearModePicker.swift b/Where/WhereUI/Sources/Year/YearModePicker.swift new file mode 100644 index 00000000..7d703749 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearModePicker.swift @@ -0,0 +1,26 @@ +import SwiftUI +import WhereCore + +/// The adaptive Liquid Glass picker at the bottom of Your Year. It keeps full +/// icon-and-title segments where they fit, falling back to icon-only segments on +/// compact widths while retaining the same accessible labels. +struct YearModePicker: View { + @Binding var mode: YearViewMode + + var body: some View { + ViewThatFits(in: .horizontal) { + YearModeSegments(mode: $mode, showsTitles: true) + YearModeSegments(mode: $mode, showsTitles: false) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: .yearSegmentPicker)) + } +} + +#if DEBUG + #Preview { + @Previewable @State var mode = YearViewMode.calendar + YearModePicker(mode: $mode) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearModeSegments.swift b/Where/WhereUI/Sources/Year/YearModeSegments.swift new file mode 100644 index 00000000..558575e3 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearModeSegments.swift @@ -0,0 +1,72 @@ +import SwiftUI +import WhereCore + +/// One complete picker candidate used by `ViewThatFits`: labeled on roomy +/// widths or icon-only on compact widths. +struct YearModeSegments: View { + @Binding var mode: YearViewMode + let showsTitles: Bool + + @Namespace private var selection + @Environment(\.stylesheet) private var stylesheet + + private var style: WhereStylesheet.YearOverviewStyle.Picker { + stylesheet.yearOverview.picker + } + + var body: some View { + HStack(spacing: stylesheet.spacing.xxSmall) { + ForEach(YearViewMode.allCases, id: \.self) { candidate in + Button { + select(candidate) + } label: { + if showsTitles { + Label(candidate.title, systemImage: candidate.systemImage) + .labelStyle(.titleAndIcon) + .fixedSize() + } else { + Label(candidate.title, systemImage: candidate.systemImage) + .labelStyle(.iconOnly) + } + } + .imageScale(.large) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + .frame(minWidth: style.segmentMinSize, minHeight: style.segmentMinSize) + .foregroundStyle( + candidate == mode ? Color(.systemBackground) : Color.primary, + ) + .contentShape(.capsule) + .buttonStyle(.plain) + .matchedGeometryEffect(id: candidate, in: selection, isSource: true) + .accessibilityAddTraits(candidate == mode ? [.isSelected] : []) + } + } + // Like a system segmented control, the picker keeps fixed icon geometry; + // every segment still exposes its full accessibility label. + .dynamicTypeSize(...DynamicTypeSize.large) + .padding(stylesheet.spacing.small) + .background { + Capsule() + .fill(Color.primary) + .matchedGeometryEffect(id: mode, in: selection, isSource: false) + } + .background { + Color.clear.glassEffect(.regular, in: .capsule) + } + } + + private func select(_ candidate: YearViewMode) { + guard candidate != mode else { return } + withAnimation(style.selectionAnimation) { mode = candidate } + } +} + +#if DEBUG + #Preview { + @Previewable @State var mode = YearViewMode.breakdown + YearModeSegments(mode: $mode, showsTitles: false) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearModeSelection.swift b/Where/WhereUI/Sources/Year/YearModeSelection.swift new file mode 100644 index 00000000..2dd011a8 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearModeSelection.swift @@ -0,0 +1,25 @@ +import Observation +import WhereCore + +/// Scene-local selection for the Your Year lenses, initialized from and written +/// back to the injected preferences store. +@MainActor +@Observable +final class YearModeSelection { + private let preferences: WherePreferences + + var mode: YearViewMode { + didSet { + guard oldValue != mode else { return } + preferences.yearViewMode = mode + } + } + + /// `initialMode` is a preview/Flyover seam. It controls the initial display + /// without overwriting the persisted user choice merely by constructing a + /// fixture; a later user-driven change still persists normally. + init(preferences: WherePreferences, initialMode: YearViewMode? = nil) { + self.preferences = preferences + mode = initialMode ?? preferences.yearViewMode + } +} diff --git a/Where/WhereUI/Sources/Year/YearView.swift b/Where/WhereUI/Sources/Year/YearView.swift index b19ec049..8ea4c589 100644 --- a/Where/WhereUI/Sources/Year/YearView.swift +++ b/Where/WhereUI/Sources/Year/YearView.swift @@ -2,56 +2,56 @@ import SnapshotKit import SwiftUI import WhereCore -/// Your Year tab: the selected year's calendar and timeline for the same data. -/// A floating Liquid Glass pill at the bottom (Photos-style) zooms between the -/// calendar (month detail) and the timeline (year overview); the activity -/// summary sits in the toolbar. +/// Your Year tab: four lenses over the same selected-year report — Calendar, +/// Timeline, Breakdown, and Heatmap. The selected lens persists through the +/// injected preferences store; the activity summary stays in the toolbar. struct YearView: View { let report: YearReportModel - @State private var mode: YearMode + @State private var selection: YearModeSelection @State private var showingRecentActivity = false @Environment(\.stylesheet) private var stylesheet - init(report: YearReportModel, initialMode: YearMode = .calendar) { + init(report: YearReportModel, initialMode: YearViewMode? = nil) { self.report = report - _mode = State(initialValue: initialMode) + _selection = State(initialValue: YearModeSelection( + preferences: report.preferences, + initialMode: initialMode, + )) } var body: some View { + @Bindable var selection = selection + NavigationStack { - Group { - switch mode { - case .calendar: - CalendarContentView(report: report) - case .timeline: - PresenceTimelineList(report: report) + YearModeContent(report: report, mode: selection.mode) + // Crossfade between lenses rather than hard-cutting. + .animation(stylesheet.yearOverview.picker.contentAnimation, value: selection.mode) + .navigationTitle(selection.mode.title) + .navigationBarTitleDisplayMode(.inline) + // Keep the bar background on at all times. The calendar auto-scrolls + // under the bar (so its scroll-edge material is showing) while the + // timeline starts at the top; without pinning it, switching between + // them animates that material in/out — reading as a toolbar fade. + .toolbarBackground(.visible, for: .navigationBar) + .safeAreaInset(edge: .bottom, alignment: .center) { + YearModePicker(mode: $selection.mode) + .padding(.bottom, stylesheet.spacing.xLarge) } - } - // Crossfade between the two views rather than hard-cutting. - .animation(.default, value: mode) - .navigationTitle(mode.title) - .navigationBarTitleDisplayMode(.inline) - // Keep the bar background on at all times. The calendar auto-scrolls - // under the bar (so its scroll-edge material is showing) while the - // timeline starts at the top; without pinning it, switching between - // them animates that material in/out — reading as a toolbar fade. - .toolbarBackground(.visible, for: .navigationBar) - .safeAreaInset(edge: .bottom, alignment: .center) { - YearModePicker(mode: $mode) - .padding(.bottom, stylesheet.spacing.xLarge) - } - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - showingRecentActivity = true - } label: { - Label(String(localized: .primaryRecentActivity), systemImage: "sparkles") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showingRecentActivity = true + } label: { + Label( + String(localized: .primaryRecentActivity), + systemImage: "sparkles", + ) + } + .accessibilityIdentifier("where_recent_activity_button") } - .accessibilityIdentifier("where_recent_activity_button") } - } } .sheet(isPresented: $showingRecentActivity) { RecentActivitySummaryView(report: report) @@ -59,81 +59,6 @@ struct YearView: View { } } -/// The two lenses on the selected year the bottom pill zooms between. -enum YearMode: String, Hashable, CaseIterable { - case calendar - case timeline - - var title: String { - switch self { - case .calendar: String(localized: .primaryCalendar) - case .timeline: String(localized: .primaryTimeline) - } - } - - var systemImage: String { - switch self { - case .calendar: "calendar" - case .timeline: "calendar.day.timeline.left" - } - } -} - -/// A floating Liquid Glass pill (Photos-style) switching the Your Year view -/// between calendar and timeline, with the selection sliding between segments. -private struct YearModePicker: View { - @Binding var mode: YearMode - - @Namespace private var selection - @Environment(\.stylesheet) private var stylesheet - - var body: some View { - HStack(spacing: stylesheet.spacing.xxSmall) { - ForEach(YearMode.allCases, id: \.self) { candidate in - segment(candidate) - } - } - .padding(stylesheet.spacing.small) - // A single selection capsule that follows the selected segment's frame, - // so changing selection slides it across (rather than a per-segment - // capsule fading in/out). Real black in light mode / white in dark — it - // sits *above* the glass layer (below) so the glass doesn't frost it grey. - .background { - Capsule() - .fill(Color.primary) - .matchedGeometryEffect(id: mode, in: selection, isSource: false) - } - .background { - Color.clear.glassEffect(.regular, in: .capsule) - } - .accessibilityElement(children: .contain) - .accessibilityLabel(String(localized: .yearSegmentPicker)) - } - - private func segment(_ candidate: YearMode) -> some View { - let isSelected = candidate == mode - return Button { - withAnimation(.snappy(duration: 0.28)) { mode = candidate } - } label: { - Label(candidate.title, systemImage: candidate.systemImage) - .labelStyle(.titleAndIcon) - .imageScale(.large) - .font(.subheadline.weight(.medium)) - // Keep the label at its intrinsic width so it never truncates - // while the segment's frame animates. - .fixedSize() - .padding(.horizontal, stylesheet.spacing.medium) - .padding(.vertical, stylesheet.spacing.small) - // Contrast against the black/white selection capsule. - .foregroundStyle(isSelected ? Color(.systemBackground) : Color.primary) - .contentShape(.capsule) - } - .buttonStyle(.plain) - .matchedGeometryEffect(id: candidate, in: selection, isSource: true) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } -} - #if DEBUG extension YearView: SnapshotProviding { /// Timeline rendering is owned by `PresenceTimelineList`'s matrix; the @@ -184,6 +109,20 @@ private struct YearModePicker: View { ) { YearView(report: world.report, initialMode: .timeline) }, + WhereFlyoverData.hostedVariant( + id: "breakdown", + title: "Breakdown", + world: world, + ) { + YearView(report: world.report, initialMode: .breakdown) + }, + WhereFlyoverData.hostedVariant( + id: "heatmap", + title: "Heatmap", + world: world, + ) { + YearView(report: world.report, initialMode: .heatmap) + }, ], ) } diff --git a/Where/WhereUI/Sources/Year/YearViewMode+Presentation.swift b/Where/WhereUI/Sources/Year/YearViewMode+Presentation.swift new file mode 100644 index 00000000..6b711647 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearViewMode+Presentation.swift @@ -0,0 +1,21 @@ +import WhereCore + +extension YearViewMode { + var title: String { + switch self { + case .calendar: String(localized: .primaryCalendar) + case .timeline: String(localized: .primaryTimeline) + case .breakdown: String(localized: .yearBreakdownTitle) + case .heatmap: String(localized: .yearHeatmapTitle) + } + } + + var systemImage: String { + switch self { + case .calendar: "calendar" + case .timeline: "calendar.day.timeline.left" + case .breakdown: "chart.pie.fill" + case .heatmap: "square.grid.3x3.fill" + } + } +} diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index c37de898..698ab543 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -96,6 +96,27 @@ struct WhereFormatTests { #expect(WhereFormat.loggedDaysTitle(year: 2026) == "Logged Days · 2026") } + @Test func heatmapMonthUsesDisplayLocaleAndInjectedTimeZone() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 14 * 60 * 60)) + + let english = WhereFormat.yearHeatmapMonthSymbol( + month: 1, + year: 2026, + calendar: calendar, + locale: Locale(identifier: "en_US"), + ) + let french = WhereFormat.yearHeatmapMonthSymbol( + month: 1, + year: 2026, + calendar: calendar, + locale: Locale(identifier: "fr_FR"), + ) + + #expect(english == "Jan") + #expect(french.lowercased().hasPrefix("janv")) + } + /// The widget entry views render from the app's catalog, so a missing key /// there ships a raw identifier to the Home Screen. @Test func widgetStringsResolve() { diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 8545bb95..d4d8a599 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -163,6 +163,41 @@ struct WhereStylesheetTests { #expect(month.unfocusedRowOpacity == 0.55) } + @Test func yearOverviewStyle() { + let overview = style.yearOverview + #expect(overview.multipleLocationsColor == .primary) + #expect(overview.unrecordedColor == .red) + #expect(overview.remainingColor == Color.secondary.opacity(0.22)) + + let breakdown = overview.breakdown + #expect(breakdown.maxChartSize == 360) + #expect(breakdown.innerRadiusRatio == 0.62) + #expect(breakdown.centerContentWidthRatio == 0.8) + #expect(breakdown.angularInset == 1.5) + #expect(breakdown.chartLegendSpacing == 24) + #expect(breakdown.legendRowSpacing == 10) + #expect(breakdown.legendSwatchSize == 12) + + let heatmap = overview.heatmap + #expect(abs(heatmap.plotAspectRatio - 31.0 / 12.0) < 0.000_001) + #expect(heatmap.cellCornerRadius == 2) + #expect(heatmap.cellWidthRatio == 0.84) + #expect(heatmap.cellHeightRatio == 0.78) + #expect(heatmap.selectionWidthRatio == 0.98) + #expect(heatmap.selectionHeightRatio == 0.92) + #expect(heatmap.calloutCornerRadius == 14) + #expect(heatmap.calloutPadding == 12) + #expect(heatmap.legendMinItemWidth == 132) + #expect(heatmap.legendSpacing == 10) + + let picker = overview.picker + #expect(picker.segmentMinSize == 44) + #expect(picker.horizontalPadding == 12) + #expect(picker.verticalPadding == 8) + #expect(picker.selectionAnimation == .snappy(duration: 0.28)) + #expect(picker.contentAnimation == .default) + } + @Test func appIconStyle() { let appIcon = style.appIcon #expect(appIcon.gridMax == 180) @@ -368,6 +403,12 @@ struct WhereStylesheetTests { #expect(resolved.card.dayCount == .reducedMotion) #expect(resolved.developerOverlay.menu.motion == .reduced) #expect(resolved.developerOverlay.menu.motion.usesSpatialMotion == false) + #expect( + resolved.yearOverview.picker.selectionAnimation == .easeInOut(duration: 0.18), + ) + #expect( + resolved.yearOverview.picker.contentAnimation == .easeInOut(duration: 0.18), + ) } } diff --git a/Where/WhereUI/Tests/YearModeSelectionTests.swift b/Where/WhereUI/Tests/YearModeSelectionTests.swift new file mode 100644 index 00000000..c35021a3 --- /dev/null +++ b/Where/WhereUI/Tests/YearModeSelectionTests.swift @@ -0,0 +1,35 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct YearModeSelectionTests { + @Test func restoresThePersistedMode() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.yearViewMode = .heatmap + + let selection = YearModeSelection(preferences: preferences) + + #expect(selection.mode == .heatmap) + } + + @Test func changingModePersistsAcrossARecreatedSelection() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let first = YearModeSelection(preferences: preferences) + first.mode = .breakdown + + let recreated = YearModeSelection(preferences: preferences) + + #expect(recreated.mode == .breakdown) + } + + @Test func explicitInitialModeDoesNotOverwriteThePreference() { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.yearViewMode = .heatmap + + let selection = YearModeSelection(preferences: preferences, initialMode: .timeline) + + #expect(selection.mode == .timeline) + #expect(preferences.yearViewMode == .heatmap) + } +} diff --git a/Where/WhereUI/Tests/YearOverviewTests.swift b/Where/WhereUI/Tests/YearOverviewTests.swift new file mode 100644 index 00000000..f1c90d20 --- /dev/null +++ b/Where/WhereUI/Tests/YearOverviewTests.swift @@ -0,0 +1,139 @@ +import Foundation +import RegionKit +import Testing +import WhereCore +@testable import WhereUI + +struct YearOverviewTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + @Test(arguments: [(2024, 366), (2025, 365)]) + func containsEveryDayInTheYear(argument: (year: Int, count: Int)) throws { + let referenceDate = try #require(calendar.date(from: DateComponents( + year: argument.year, + month: 12, + day: 31, + ))) + let overview = YearOverview( + report: YearReport(year: argument.year, days: [], totals: [:]), + referenceDate: referenceDate, + calendar: calendar, + ) + + #expect(overview.dayCount == argument.count) + #expect(overview.slices.reduce(0) { $0 + $1.days } == argument.count) + } + + @Test func classifiesRecordedMissingTodayAndFutureDays() throws { + let referenceDate = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 15, + ))) + let report = YearReport( + year: 2026, + days: [ + presence(2026, 1, 1, regions: [.california]), + presence(2026, 1, 2, regions: [.newYork, .california]), + presence(2026, 7, 16, regions: [.newYork]), + ], + totals: [:], + ) + let overview = YearOverview( + report: report, + referenceDate: referenceDate, + calendar: calendar, + ) + + #expect(overview.day(month: 1, dayOfMonth: 1)?.kind == .region(.california)) + #expect( + overview.day(month: 1, dayOfMonth: 2)?.kind + == .multipleLocations([.california, .newYork]), + ) + #expect(overview.day(month: 7, dayOfMonth: 14)?.kind == .unrecorded) + #expect(overview.day(month: 7, dayOfMonth: 15)?.kind == .remaining) + #expect(overview.day(month: 7, dayOfMonth: 16)?.kind == .remaining) + #expect(overview.recordedDayCount == 2) + } + + @Test func emptyRegionPresenceIsUnrecorded() throws { + let referenceDate = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 2, + ))) + let overview = YearOverview( + report: YearReport( + year: 2026, + days: [presence(2026, 1, 1, regions: [])], + totals: [:], + ), + referenceDate: referenceDate, + calendar: calendar, + ) + + #expect(overview.day(month: 1, dayOfMonth: 1)?.kind == .unrecorded) + #expect(overview.recordedDayCount == 0) + } + + @Test func pastYearHasNoRemainingDays() throws { + let referenceDate = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 15, + ))) + let overview = YearOverview( + report: YearReport(year: 2025, days: [], totals: [:]), + referenceDate: referenceDate, + calendar: calendar, + ) + + #expect(overview.slices == [.init(id: .unrecorded, days: 365)]) + } + + @Test func slicesRankRegionsThenAppendSpecialCategories() throws { + let referenceDate = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 5, + ))) + let overview = YearOverview( + report: YearReport( + year: 2026, + days: [ + presence(2026, 1, 1, regions: [.newYork]), + presence(2026, 1, 2, regions: [.california]), + presence(2026, 1, 3, regions: [.newYork, .california]), + ], + totals: [:], + ), + referenceDate: referenceDate, + calendar: calendar, + ) + + #expect(overview.slices.map(\.id) == [ + .region(.california), + .region(.newYork), + .multipleLocations, + .unrecorded, + .remaining, + ]) + #expect(overview.slices.reduce(0) { $0 + $1.days } == 365) + } + + private func presence( + _ year: Int, + _ month: Int, + _ day: Int, + regions: Set, + ) -> DayPresence { + DayPresence( + day: CalendarDay(year: year, month: month, day: day), + regions: regions, + ) + } +}