Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 21 additions & 3 deletions Where/WhereCore/Sources/Preferences/WherePreferences.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -124,5 +141,6 @@ public final class WherePreferences {
case summaryMinute = "where.summaryMinute"
case issueAlertsEnabled = "where.issueAlertsEnabled"
case driftThresholdMeters = "where.driftThresholdMeters"
case yearViewMode = "where.yearViewMode"
}
}
12 changes: 12 additions & 0 deletions Where/WhereCore/Sources/Preferences/YearViewMode.swift
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions Where/WhereCore/Tests/WherePreferencesTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 14 additions & 0 deletions Where/WhereCore/Tests/YearViewModeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions Where/WhereUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Where/WhereUI/SnapshotTests/YearBreakdownViewSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SnapshotKitTesting
import Testing
@testable import WhereUI

@MainActor
struct YearBreakdownViewSnapshotTests {
@Test func yearBreakdown() async {
await assertSnapshots(of: YearBreakdownView.self)
}
}
10 changes: 10 additions & 0 deletions Where/WhereUI/SnapshotTests/YearHeatmapViewSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SnapshotKitTesting
import Testing
@testable import WhereUI

@MainActor
struct YearHeatmapViewSnapshotTests {
@Test func yearHeatmap() async {
await assertSnapshots(of: YearHeatmapView.self)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
YearView.flyoverData,
CalendarContentView.yearFlyoverData,
PresenceTimelineList.flyoverData,
YearBreakdownView.flyoverData,
YearHeatmapView.flyoverData,
RecentActivitySummaryView.flyoverData,
]
}
Expand Down
Loading
Loading