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
15 changes: 10 additions & 5 deletions .agents/skills/upgrade-where-backup/scripts/upgrade_and_verify.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class UpgradeAndVerify
PRESERVED_COLLECTIONS = %w[
samples evidence manualDays dismissedIssues trackedRegions
recordingDeviceProfiles recordingDeviceMetadataChanges recordingDeviceRemovals assets
plannedStayRecords sampleAttributionRevisions
].freeze

def initialize(argv)
Expand Down Expand Up @@ -148,6 +149,8 @@ def verification_configuration(manifest)
deviceProfilesCount: manifest.fetch("recordingDeviceProfiles").length,
deviceChangesCount: manifest.fetch("recordingDeviceMetadataChanges").length,
deviceRemovalsCount: manifest.fetch("recordingDeviceRemovals").length,
plannedStayRecordsCount: manifest.fetch("plannedStayRecords").length,
sampleAttributionRevisionsCount: manifest.fetch("sampleAttributionRevisions").length,
assetsCount: manifest.fetch("assets").length,
}
end
Expand Down Expand Up @@ -177,9 +180,11 @@ def fail_with(message)
end
end

begin
UpgradeAndVerify.new(ARGV).run
rescue OptionParser::ParseError, RuntimeError => error
warn "error: #{error.message}"
exit 1
if $PROGRAM_NAME == __FILE__
begin
UpgradeAndVerify.new(ARGV).run
rescue OptionParser::ParseError, RuntimeError => error
warn "error: #{error.message}"
exit 1
end
end
5 changes: 4 additions & 1 deletion .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ and retained as the closest lower-level pattern.
`where.store_transaction_boundary` requires calls to the mutating `WhereStore`
surface through `store` or `self.store` to be lexically contained by
`store.perform { ... }` or `store.performInCurrentGeneration { ... }`.
Attribution revisions use this same boundary; their coordinator also requires
the reviewed data generation and reassesses before writing.
Guard: `sample attribution revisions require a guarded transaction`.

The checked methods are `add`, `write`, `setManualDay`, `clearManualDay`,
`clear`, `clearAll`, `setIssueDismissed`, `restoreDismissedIssue`,
`setTrackedRegion`, and `setPrimaryRegions`.
`setTrackedRegion`, `setPrimaryRegions`, and `addSampleAttributionRevision`.

## App Shortcuts provider ownership

Expand Down
1 change: 1 addition & 0 deletions .bumper/Sources/WhereProjectRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ private let whereStoreMutatingMethods: Set<String> = [
"restoreDismissedIssue",
"setTrackedRegion",
"setPrimaryRegions",
"addSampleAttributionRevision",
]

private let storeTransactionBoundaryRule = Rules.files(
Expand Down
16 changes: 16 additions & 0 deletions .bumper/Tests/WhereProjectRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ struct WhereProjectRulesTests {
#expect(violation.path == rejectedPath)
}

@Test
func `sample attribution revisions require a guarded transaction`() throws {
let allowed = try evaluate(
path: "Where/WhereCore/Sources/Corrections.swift",
component: .whereCore,
source: "func apply() async throws { try await store.perform(expectedDataGenerationID: generation) { try await store.addSampleAttributionRevision(revision) } }",
)
let rejected = try evaluate(
path: "Where/WhereCore/Sources/UnguardedCorrections.swift",
component: .whereCore,
source: "func apply() async throws { try await store.addSampleAttributionRevision(revision) }",
)
#expect(allowed.violations.isEmpty)
#expect(rejected.violations.map(\.rule.id) == ["where.store_transaction_boundary"])
}

@Test
func `AppShortcutsProvider stays in the app target`() throws {
let allowed = try evaluate(
Expand Down
2 changes: 1 addition & 1 deletion Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The item format and the placement rule live in the root
- fix(WhereCore) [needs-design]: The retry queue evicts FIFO at its 1000-sample capacity and drops samples with a warning only (`LocationIngestor.swift:502-507`, capacity default at `:112`, event case at `LocationIngestorLog.swift:53` with its `warning` level at `:64-65` and message at `:91-92` — the previously cited `:76-77` was a different event's message). Decide the capacity policy and whether eviction warrants user-visible degradation, then document it. (audit 2026-07-26; re-verified 2026-08-30)
- fix(WhereUI) [quick-win]: `PresenceTimelineList` returns `[]` whenever `report.report` is nil (`PresenceTimelineList.swift:30-31`, empty-state branch at `:43`), so the Timeline segment of Your Year renders the "no stays" empty state while the year is still loading (and during a year switch) — unlike the Calendar segment beside it, which gates on `loadState` (`CalendarContentView.swift:64-66`). PR #307 moved the branch while adding joined planned stays and the estimate panel but left the missing load gate unchanged. (audit 2026-07-26; re-verified 2026-09-06 after PR #307)
- refactor(WhereUI) [needs-design]: Extract a shared `ReportLoadGate`. The same `YearReportModel.loadState` gate is copy-pasted across `LocationsView.swift:151-176`, `ElsewhereView.swift:51-70`, `ResolutionView.swift:59-89`, and `CalendarContentView.swift:64-74`, and `PresenceTimelineList` skips it entirely (above). One gate view would cover all five. (audit 2026-07-26; re-verified 2026-09-06)
- fix(WhereUI) [quick-win]: The Elsewhere entry card renders raw inflection markup instead of an agreed region count — it shows literally `^[3 region](inflect: true)`. `locations.elsewhere.subtitle` is authored for automatic grammar agreement (`^[%lld region](inflect: true)`), but the string-catalog compiler passes that markup through **verbatim** into the compiled `Localizable.strings` (unlike a real plural such as `primary.elsewhereOnly.description`, which compiles to an `NSStringLocalizedFormatKey` dict), and flattening the resource to a `String` never runs the inflection engine. Pre-existing — the catalog entry is byte-identical on `main` and predates the String Catalog symbol migration. Fix by either rendering the resource directly so SwiftUI applies inflection (`Text(.locationsElsewhereSubtitle(regionCount))` in `ElsewhereSummaryCard.swift:29`, dropping the `WhereFormat.elsewhereCardSubtitle` hop at `WhereFormat.swift:49-50`) or replacing the markup with an explicit plural variation. `WhereFormatTests.elsewhereCardSubtitleInflectsTheRegionCount` (`WhereFormatTests.swift:92-101`, the `withKnownIssue` at `:98` — moved when PR #302 added format tests above it) pins the expected output behind `withKnownIssue`, so it trips as soon as this is fixed. The bug is also baked into the `locations.Loaded_iPad.png` reference (ledgered in the broken-snapshots cluster below) — PR #302 re-recorded the Locations references for the sticker redesign with the broken hop untouched, so re-record that image again when this lands. (agent; citations refreshed 2026-09-06)
- fix(WhereUI) [quick-win]: The Elsewhere entry card renders raw inflection markup instead of an agreed region count — it shows literally `^[3 region](inflect: true)`. `locations.elsewhere.subtitle` is authored for automatic grammar agreement (`^[%lld region](inflect: true)`), but the string-catalog compiler passes that markup through **verbatim** into the compiled `Localizable.strings` (unlike a real plural such as `primary.elsewhereOnly.description`, which compiles to an `NSStringLocalizedFormatKey` dict), and flattening the resource to a `String` never runs the inflection engine. Pre-existing — the catalog entry is byte-identical on `main` and predates the String Catalog symbol migration. Fix by either rendering the resource directly so SwiftUI applies inflection (`Text(.locationsElsewhereSubtitle(regionCount))` in `ElsewhereSummaryCard.swift:29`, dropping the `WhereFormat.elsewhereCardSubtitle` hop at `WhereFormat.swift:49-50`) or replacing the markup with an explicit plural variation. `WhereFormatTests.elsewhereCardSubtitleInflectsTheRegionCount` (`WhereFormatTests.swift:92-101`, the `withKnownIssue` at `:98` — moved when PR #302 added format tests above it) pins the expected output behind `withKnownIssue`, so it trips as soon as this is fixed. The bug is also baked into the `locations.Loaded_iPad.png` reference (ledgered in the broken-snapshots cluster below) — PR #302 re-recorded the Locations references for the sticker redesign with the broken hop untouched, so re-record that image again when this lands. The flight review also reproduced the literal `^[3 region](inflect: true)` in `WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone.png`; the existing catalog key and formatter were unchanged by the flight work. (agent; citations refreshed 2026-09-06; flight snapshot evidence 2026-09-13)
- refactor(WhereUI) [needs-design]: Split `WhereSession` into an always-on coordinator + a presentation view-model whose lifetime scopes its subscriptions. **Partial progress (July 2026):** `YearReportModel` is now scene-scoped in `MainTabs` — `activate()` / `deactivate()` on `scenePhase` drive `observeDataChanges()` and refresh, closing the headless-relaunch rescan leak that previously wired the subscription through launch `syncAuth`. `ResolveModel`, `BackupModel`, `RemindersSettingsModel`, and now `DevicesSettingsModel` are view-scoped. Remaining: the coordinator is **still exactly 636 lines** as of 2026-09-06 (`Model/WhereSession.swift`) — it grew past the ~460 recorded when this was filed because PR #160's multi-device recording landed on it, and it has now held at 636 through three windows of privacy, theming, forecasting, ranking-motion, endorsement, and demo-launch work, so it is neither growing nor being worked down — and still mixes recording runtime, authorization, reset, the launch-time notification reconcile, region-style mirrors, and device rejoin (its own header comment inventories them at `WhereSession.swift:6-30`). Finish extracting presentation collaborators and drive any leftover reactive work from scene lifetime. (agent; re-measured 2026-09-06)
- test(WhereUI) [quick-win]: `ManualDayView`'s range mode has no test coverage — including its capture-only code. The deleted `manualDayViewHostsAddModes` hosted a *range-prefilled* add (two `DatePicker`s), but the `addPrefill` snapshot case is still a single day (`ManualDayView.swift:510-512`, `start == end` → `dayCount: 1`), so no test ever renders the `.range` branch (`:199-212`) — live or stand-in. The range stand-in code has never executed, and the From/Through picker row rendering is unpinned. Fix: add an `AddRange` snapshot case with a multi-day `MissingDayRange` prefill (the Resolve backfill flow the deleted test existed for). (From the July 2026 snapshot-testing PR review.)
- test(WhereUI) [needs-design]: `RegionMapView`'s live `Map` branch is no longer constructed by any test. The deleted `regionMapViewHosts` mounted the real MapKit `Map` (polygon building via `clLocationCoordinates`, `mapStyle`); under capture the view always takes the `SnapshotMapStandIn` branch (`RegionMapView.swift:248-253`, the live `Map` at `:255-262`), so a crash or regression in the production map path — which every real user sees — would ship untested. The stand-in substitution is what the framework carve-out sanctions; the gap is purely coverage. Fix: keep one lightweight hosting test for the live branch in `WhereUITests` (this specific surface is the exception the "no hosting smoke tests" rule shouldn't swallow) — until then, this entry records the accepted gap. (From the July 2026 snapshot-testing PR review.)
Expand Down
58 changes: 53 additions & 5 deletions Where/Tools/Tests/upgrade_backup_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

require "minitest/autorun"
require_relative "../upgrade-backup"
require_relative "../../../.agents/skills/upgrade-where-backup/scripts/upgrade_and_verify"

class UpgradeBackupTest < Minitest::Test
def test_v1_adds_current_tables_without_inventing_recording_consent
upgraded = upgrade_manifest(base_manifest(1))

assert_equal 5, upgraded.fetch("formatVersion")
assert_equal 6, upgraded.fetch("formatVersion")
assert_equal [], upgraded.fetch("recordingDeviceProfiles")
assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges")
assert_equal [], upgraded.fetch("recordingDeviceRemovals")
assert_equal [], upgraded.fetch("plannedStayRecords")
assert_equal [], upgraded.fetch("sampleAttributionRevisions")
assert_nil upgraded.fetch("samples").first.fetch("motion")
assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID")
end

Expand Down Expand Up @@ -54,7 +57,7 @@ def test_v3_reshapes_recording_device_data

upgraded = upgrade_manifest(manifest)

assert_equal 5, upgraded.fetch("formatVersion")
assert_equal 6, upgraded.fetch("formatVersion")
assert_equal({
"kind" => { "other" => {} },
"registrationGenerationID" => "generation-id",
Expand All @@ -67,11 +70,56 @@ def test_v3_reshapes_recording_device_data
}, upgraded.fetch("recordingDeviceMetadataChanges").last)
end

def test_v5_is_idempotent
once = upgrade_manifest(base_manifest(5))
def test_v6_is_idempotent
once = upgrade_manifest(base_manifest(6))
assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once)))
end

def test_v5_gains_empty_corrections_and_unknown_motion_without_altering_raw_fixes
manifest = base_manifest(5)
manifest["samples"] = [{ "id" => "sample", "timestamp" => 1000.25, "coordinate" => { "latitude" => 40, "longitude" => -100 } }]
original = Marshal.load(Marshal.dump(manifest["samples"].first))

upgraded = upgrade_manifest(manifest)

assert_equal original.merge("recordingDeviceID" => nil, "motion" => nil), upgraded.fetch("samples").first
assert_equal [], upgraded.fetch("sampleAttributionRevisions")
end

def test_v6_preserves_motion_and_every_correction_revision_including_tombstones
motion = {
"speed" => { "metersPerSecond" => 240, "accuracyMetersPerSecond" => 2 },
"altitude" => { "meters" => 11000, "accuracyMeters" => 12 },
}
revisions = [nil, [], ["us-NY"]].each_with_index.map do |regions, index|
{ "id" => "revision-#{index}", "sampleID" => "sample", "updatedAt" => 1000 + index, "replacementRegions" => regions }
end
manifest = base_manifest(6).merge("sampleAttributionRevisions" => revisions)
manifest["samples"].first["motion"] = motion

upgraded = upgrade_manifest(manifest)

assert_equal motion, upgraded.fetch("samples").first.fetch("motion")
assert_equal revisions, upgraded.fetch("sampleAttributionRevisions")
assert_equal upgraded, upgrade_manifest(Marshal.load(Marshal.dump(upgraded)))
end

def test_verification_driver_counts_planned_stays_and_correction_tombstones
manifest = upgrade_manifest(base_manifest(6))
manifest["plannedStayRecords"] = [{ "id" => "stay", "value" => nil, "updatedAt" => 1000 }]
manifest["sampleAttributionRevisions"] = [{ "id" => "reset", "sampleID" => "sample", "updatedAt" => 1001 }]
driver = UpgradeAndVerify.new(["input.zip", "output.zip"])
counts = driver.send(:expected_counts, manifest)
configuration = driver.send(:verification_configuration, manifest)

assert_equal 1, counts.fetch("plannedStayRecords")
assert_equal 1, counts.fetch("sampleAttributionRevisions")
assert_equal 1, configuration.fetch(:plannedStayRecordsCount)
assert_equal 1, configuration.fetch(:sampleAttributionRevisionsCount)
manifest["sampleAttributionRevisions"] = []
assert_raises(RuntimeError) { driver.send(:verify_counts!, manifest, counts) }
end

def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps
manifest = base_manifest(1)
manifest["exportedAt"] = "2023-11-14T22:13:20Z"
Expand All @@ -84,7 +132,7 @@ def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps
end

def test_rejects_branch_only_or_future_formats
error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(6)) }
error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(7)) }
assert_equal 1, error.status
end

Expand Down
9 changes: 6 additions & 3 deletions Where/Tools/upgrade-backup.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Reshapes a legacy Where backup into the current v5 manifest. The automatic-recording feature
# Reshapes a legacy Where backup into the current v6 manifest. The automatic-recording feature
# was not shipped in v1 or v2, so upgrading adds the recording tables empty; it never invents an
# installation or recording consent. v4 expands device kinds and groups metadata edit payloads;
# v5 adds an empty planned-stay register when the source predates it.
# v6 preserves optional motion readings and adds per-sample attribution history.

require "json"
require "tmpdir"
Expand All @@ -13,7 +14,7 @@
require "set"

MANIFEST_NAME = "manifest.json"
CURRENT_FORMAT_VERSION = 5
CURRENT_FORMAT_VERSION = 6
SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze

REGION_MAP = {
Expand All @@ -32,7 +33,7 @@

DATE_KEYS = %w[
exportedAt timestamp capturedAt dismissedAt registeredAt changedAt removedAt recordedAt
lastSeenAt auditRecordedAt auditLocationTimestamp
lastSeenAt auditRecordedAt auditLocationTimestamp updatedAt
].to_set.freeze

def die(message)
Expand Down Expand Up @@ -184,12 +185,14 @@ def upgrade_manifest(manifest)
end
Array(manifest["samples"]).each do |sample|
sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID")
sample["motion"] = nil unless sample.key?("motion")
end

manifest["recordingDeviceProfiles"] ||= []
manifest["recordingDeviceMetadataChanges"] ||= []
manifest["recordingDeviceRemovals"] ||= []
manifest["plannedStayRecords"] ||= []
manifest["sampleAttributionRevisions"] ||= []
upgrade_recording_devices!(manifest, source_version)
manifest.delete("recordingDevices")
manifest.delete("recordingDeviceCheckIns")
Expand Down
Loading
Loading