Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
609559c
Add annual location time forecasts
kyleve Aug 5, 2026
41c5e4c
Make planned stay expiry conditional
kyleve Aug 5, 2026
a70174b
Add forecast panel preview
kyleve Aug 5, 2026
f0bad2b
Add planned stay editor preview
kyleve Aug 5, 2026
d841da5
Make location forecasts conversational
kyleve Aug 5, 2026
e5b7183
Show elapsed forecast time once
kyleve Aug 5, 2026
42a03f4
Make location forecasts optional and collapsible
kyleve Aug 5, 2026
9dd1e8d
Merge main into location time forecasts
kyleve Aug 5, 2026
53a1371
Refine location forecast card chrome
kyleve Aug 5, 2026
74a5951
Soften location forecast disclosure
kyleve Aug 5, 2026
fd8f064
Merge remote-tracking branch 'origin/main' into codex/location-time-f…
kyleve Aug 6, 2026
5952bf3
Distinguish planned days in the calendar
kyleve Aug 6, 2026
96f2f14
Align planned stay hatches across calendar cells
kyleve Aug 6, 2026
4f322b2
Restore chronological calendar order
kyleve Aug 6, 2026
16f233b
Return concurrent planned stay from expiry
kyleve Aug 6, 2026
26a6e20
Keep planned stay revisions monotonic
kyleve Aug 6, 2026
30a67f1
Show cross-year planned stays in calendar
kyleve Aug 6, 2026
25ab871
Keep planned stay previews Gregorian
kyleve Aug 6, 2026
358de6a
Refresh Gregorian planned stay snapshots
kyleve Aug 6, 2026
42409e5
Place estimate after current calendar month
kyleve Aug 6, 2026
14bdd50
Add planned stay editor snapshots
kyleve Aug 6, 2026
bbd986f
Give snapshots solid adaptive backgrounds
kyleve Aug 6, 2026
249dece
Merge remote-tracking branch 'origin/main' into codex/location-time-f…
kyleve Aug 9, 2026
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
11 changes: 6 additions & 5 deletions Where/Tools/Tests/upgrade_backup_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ class UpgradeBackupTest < Minitest::Test
def test_v1_adds_current_tables_without_inventing_recording_consent
upgraded = upgrade_manifest(base_manifest(1))

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

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

upgraded = upgrade_manifest(manifest)

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

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

Expand All @@ -83,7 +84,7 @@ def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps
end

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

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

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

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

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

REGION_MAP = {
Expand Down Expand Up @@ -188,6 +189,7 @@ def upgrade_manifest(manifest)
manifest["recordingDeviceProfiles"] ||= []
manifest["recordingDeviceMetadataChanges"] ||= []
manifest["recordingDeviceRemovals"] ||= []
manifest["plannedStayRecords"] ||= []
upgrade_recording_devices!(manifest, source_version)
manifest.delete("recordingDevices")
manifest.delete("recordingDeviceCheckIns")
Expand Down
3 changes: 3 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ internal shape.
`BackupArchive.currentFormatVersion` and extends
[`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb); never add an
in-code legacy decode fallback.
- **The planned stay is a generation-scoped last-writer register with tombstones.** Resolve
duplicate CloudKit revisions by `updatedAt` then UUID, and clear or expire by writing a newer
`nil` value; deleting the winner can resurrect stale intent (`PlannedStayCoordinatorTests`).
- **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (Y-M-D)
is the timezone-independent identity every stored user record and day
comparison keys on; persisting a `Date` makes a day drift across time-zone
Expand Down
7 changes: 5 additions & 2 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ one it belongs to rather than to a god-object:
(`clearManualDay` / `clearYear` / `eraseAllData`), evidence, and issue
dismissals. Each write commits, then awaits its reminder reconcile + widget
publish so the next reader sees a fully-applied change.
- **`PlannedStayCoordinator`** — the synced, generation-scoped last-writer register behind “I’ll
be here through…”. Clears and expiry write tombstones, and annual forecasts consume its current
value without coupling projection math to persistence.

- **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a
given `WhereServices`: a plausible current year of living in New York with
Expand Down Expand Up @@ -142,7 +145,7 @@ one it belongs to rather than to a god-object:
- **`WidgetSnapshotPublisher`** — republishes the App Group snapshot the widgets
read, with a freshness policy.
- **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins
tables and evidence blobs to one generation-consistent snapshot. Merge preserves queued locations
tables, planned-stay revisions, and evidence blobs to one generation-consistent snapshot. Merge preserves queued locations
and the installation-local recording choice. Replace writes the archive into a new child generation,
retains existing removal tombstones, and preserves the local choice before pending fixes are
discarded. A prepared
Expand All @@ -162,7 +165,7 @@ one it belongs to rather than to a god-object:
`InstallationRecordingContextStoring` keeps the persistence adapter outside
the domain value.
- **`WherePreferences`** — persisted user intent (onboarding,
reminder / summary schedules, Locations-card GPS-dot visibility) plus the
reminder / summary schedules, Locations-card GPS-dot and annual-forecast visibility) plus the
year-keyed Location-card counts used for presentation continuity, behind a
`KeyValueStore`. The store has no
default: production names `UserDefaults.standard` and everything else names
Expand Down
14 changes: 10 additions & 4 deletions Where/WhereCore/Sources/Backup/BackupArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import RegionKit
/// The arrays represent the persisted collections (`SDLocationSample` /
/// `SDEvidence` / `SDManualDay` / `SDDismissedIssue` / `SDTrackedRegion`) via
/// their value-type representations, plus installation profiles, nickname events, the
/// device-removal tombstones. Target-owned check-ins and local recording consent are
/// device-removal tombstones, and planned-stay revisions. Target-owned check-ins and local
/// recording consent are
/// intentionally excluded because a backup cannot restore proof of local physical state.
public struct BackupArchive: Codable, Sendable, Hashable {
/// Bumped whenever the archive's on-disk shape changes in a way older
Expand All @@ -19,11 +20,11 @@ public struct BackupArchive: Codable, Sendable, Hashable {
///
/// v3 adds sample provenance, immutable installation profiles, nickname changes, and archive
/// tombstones. v4 expands device kinds, groups metadata edit payloads, and renames the
/// profile's registration-generation key. There's no in-app decode fallback for an older
/// archive — it is reshaped out of band by
/// profile's registration-generation key; v5 adds `plannedStayRecords`. There's no in-app
/// decode fallback for an older archive — it is reshaped out of band by
/// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see
/// `AGENTS.md`).
public static let currentFormatVersion = 4
public static let currentFormatVersion = 5

public let formatVersion: Int
public let exportedAt: Date
Expand All @@ -48,6 +49,9 @@ public struct BackupArchive: Codable, Sendable, Hashable {
public let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange]
/// Irreversible installation-removal tombstones.
public let recordingDeviceRemovals: [RecordingDeviceRemoval]
/// Revisions of the synced planned-stay register, including its clearing
/// tombstone, so restore cannot resurrect an older active stay.
public let plannedStayRecords: [PlannedStayRecord]
/// One entry per evidence record that has blob bytes in the archive.
/// Evidence without bytes simply has no entry here.
public let assets: [BackupAssetEntry]
Expand All @@ -64,6 +68,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
recordingDeviceProfiles: [RecordingDeviceProfile],
recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange],
recordingDeviceRemovals: [RecordingDeviceRemoval],
plannedStayRecords: [PlannedStayRecord],
assets: [BackupAssetEntry],
) {
self.formatVersion = formatVersion
Expand All @@ -77,6 +82,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
self.recordingDeviceProfiles = recordingDeviceProfiles
self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges
self.recordingDeviceRemovals = recordingDeviceRemovals
self.plannedStayRecords = plannedStayRecords
self.assets = assets
}
}
Expand Down
8 changes: 8 additions & 0 deletions Where/WhereCore/Sources/Backup/BackupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ public actor BackupCoordinator {
recordingDeviceProfiles: store.recordingDeviceProfiles(),
recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(),
recordingDeviceRemovals: store.recordingDeviceRemovals(),
plannedStayRecords: store.plannedStayRecords(),
)
}
let evidence = tables.evidence
Expand Down Expand Up @@ -200,6 +201,7 @@ public actor BackupCoordinator {
recordingDeviceProfiles: tables.recordingDeviceProfiles,
recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges,
recordingDeviceRemovals: tables.recordingDeviceRemovals,
plannedStayRecords: tables.plannedStayRecords,
blobs: snapshot.blobs,
)
}.value
Expand All @@ -220,6 +222,7 @@ public actor BackupCoordinator {
let recordingDeviceProfiles: [RecordingDeviceProfile]
let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange]
let recordingDeviceRemovals: [RecordingDeviceRemoval]
let plannedStayRecords: [PlannedStayRecord]
}

private struct ExportSnapshot {
Expand Down Expand Up @@ -411,6 +414,7 @@ public actor BackupCoordinator {
+ archive.recordingDeviceProfiles.count
+ archive.recordingDeviceMetadataChanges.count
+ archive.recordingDeviceRemovals.count
+ archive.plannedStayRecords.count

// Decode and validate before touching live recording. Once the archive is known-good,
// close ingestion before either merge or replace so a streamed sample cannot cross the
Expand Down Expand Up @@ -476,6 +480,10 @@ public actor BackupCoordinator {
try await store.restoreDismissedIssue(dismissal)
report()
}
for plannedStay in archive.plannedStayRecords {
try await store.restorePlannedStayRecord(plannedStay)
report()
}
for profile in archive.recordingDeviceProfiles {
try await store.addRecordingDeviceProfile(profile)
report()
Expand Down
2 changes: 2 additions & 0 deletions Where/WhereCore/Sources/Backup/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public struct BackupService: Sendable {
recordingDeviceProfiles: [RecordingDeviceProfile],
recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange],
recordingDeviceRemovals: [RecordingDeviceRemoval],
plannedStayRecords: [PlannedStayRecord],
blobs: [UUID: Data],
exportedAt: Date = Date(),
archiveName: String? = nil,
Expand Down Expand Up @@ -139,6 +140,7 @@ public struct BackupService: Sendable {
recordingDeviceProfiles: recordingDeviceProfiles,
recordingDeviceMetadataChanges: recordingDeviceMetadataChanges,
recordingDeviceRemovals: recordingDeviceRemovals,
plannedStayRecords: plannedStayRecords,
assets: assetEntries,
)
try Self.logger.measure(.encodeManifest) {
Expand Down
68 changes: 68 additions & 0 deletions Where/WhereCore/Sources/Forecasting/LocationForecast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation
import RegionKit

/// A region's independently calculated current-year residency estimate.
///
/// This result deliberately contains only the estimate and its inputs. A future
/// residency goal (for example, “55% of the year”) can compare against it
/// without becoming another forecasting policy or changing planned-stay math.
public struct LocationForecast: Hashable, Sendable {
public let region: Region
public let year: Int
public let yearToDateDays: Int
public let elapsedDays: Int
public let plannedDays: Int
public let projectedRemainingDays: Double
public let estimatedTotalDays: Int

public var estimatedFractionOfYear: Double {
let daysInYear = CalendarDay.yearRange(year).lowerBound
.days(through: CalendarDay.lastDay(ofYear: year)).count
guard daysInYear > 0 else { return 0 }
return Double(estimatedTotalDays) / Double(daysInYear)
}

/// Estimate a current year's total once three complete calendar months have
/// elapsed. Returns `nil` before April 1 and for any non-current report.
public static func estimate(
region: Region,
report: YearReport,
asOf date: Date,
calendar: Calendar,
plannedStay: PlannedStay?,
) -> LocationForecast? {
let today = CalendarDay(from: date, in: calendar)
guard report.year == today.year else { return nil }
guard today >= CalendarDay(year: report.year, month: 4, day: 1) else { return nil }

let firstDay = CalendarDay(year: report.year, month: 1, day: 1)
let lastDay = CalendarDay.lastDay(ofYear: report.year)
let elapsedDays = firstDay.days(through: today).count
let yearLength = firstDay.days(through: lastDay).count
guard elapsedDays > 0, yearLength > 0 else { return nil }

let yearToDateDays = report.totals[region, default: 0]
let baselineRate = Double(yearToDateDays) / Double(elapsedDays)
let tomorrow = today.adding(days: 1)

let matchingStay = plannedStay.flatMap { stay in
stay.region == region && stay.through >= today ? stay : nil
}
let plannedEnd = matchingStay.map { min($0.through, lastDay) }
let plannedDays = plannedEnd.map { tomorrow.days(through: $0).count } ?? 0

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Review focus: this is the load-bearing forecast policy. Planned days begin tomorrow, include the selected through-date, and baseline projection resumes the following day. The tests cover the April threshold, matching and non-matching stays, and cross-year stays.

let projectionStart = plannedEnd?.adding(days: 1) ?? tomorrow
let remainingDays = projectionStart.days(through: lastDay).count
let projectedRemainingDays = baselineRate * Double(remainingDays)
let estimated = Double(yearToDateDays + plannedDays) + projectedRemainingDays

return LocationForecast(
region: region,
year: report.year,
yearToDateDays: yearToDateDays,
elapsedDays: elapsedDays,
plannedDays: plannedDays,
projectedRemainingDays: projectedRemainingDays,
estimatedTotalDays: min(yearLength, max(0, Int(estimated.rounded()))),
)
}
}
15 changes: 15 additions & 0 deletions Where/WhereCore/Sources/Forecasting/PlannedStay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation
import RegionKit

/// User intent that the current stay in `region` continues through an inclusive
/// calendar day. The day is timezone-independent so travel cannot move the
/// asserted departure onto a neighboring date.
public struct PlannedStay: Hashable, Sendable, Codable {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also record when this was added? Might be useful if only for debugging and any UI that may show planned stays, especially if we allow entering future stays at some point.

public let region: Region
public let through: CalendarDay

public init(region: Region, through: CalendarDay) {
self.region = region
self.through = through
}
}
77 changes: 77 additions & 0 deletions Where/WhereCore/Sources/Forecasting/PlannedStayCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation
import RegionKit

/// Reads and writes the single CloudKit-synced planned-stay register.
public struct PlannedStayCoordinator: Sendable {
private let store: any WhereStore
private let calendar: Calendar
private let now: @Sendable () -> Date

init(store: any WhereStore, calendar: Calendar, now: @escaping @Sendable () -> Date) {
self.store = store
self.calendar = calendar
self.now = now
}

/// The active stay as of the injected clock. An expired value is replaced
/// with a tombstone before returning so every device converges on “cleared.”
public func active() async throws -> PlannedStay? {
guard let record = try await latestRecord() else { return nil }
guard let stay = record.value else { return nil }
let today = CalendarDay(from: now(), in: calendar)
guard stay.through < today else { return stay }
return try await expireIfLatest(record, asOf: today)
}

/// Replace any prior intent with a stay through the inclusive day.
public func set(region: Region, through: CalendarDay) async throws {
try await write(value: PlannedStay(region: region, through: through))
}

/// Clear the active stay with a synced tombstone.
public func clear() async throws {
try await write(value: nil)
}

private func latestRecord() async throws -> PlannedStayRecord? {
try await store.plannedStayRecords().max { lhs, rhs in
PlannedStayRecord.newer(rhs, than: lhs)
}
}

/// Clear `expiredRecord` only if it is still the winning revision. The
/// transactional re-read prevents a stale `active()` read from erasing a
/// newer stay saved while that read was suspended, and returns that newer
/// stay so the caller cannot replace it with stale `nil` state.
func expireIfLatest(
_ expiredRecord: PlannedStayRecord,
asOf today: CalendarDay,
) async throws -> PlannedStay? {
try await store.perform {
guard let latest = try await latestRecord() else { return nil }
guard latest == expiredRecord else {
guard let stay = latest.value, stay.through >= today else { return nil }
return stay
}
guard let stay = expiredRecord.value, stay.through < today else { return nil }
let tombstone = PlannedStayRecord(
id: UUID(),
value: nil,
updatedAt: max(now(), expiredRecord.updatedAt.addingTimeInterval(0.001)),
)
try await store.replacePlannedStayRecord(with: tombstone)
return nil
}
}

private func write(value: PlannedStay?) async throws {
try await store.perform {
let latest = try await latestRecord()
let timestamp = latest.map {
max(now(), $0.updatedAt.addingTimeInterval(0.001))
} ?? now()
let record = PlannedStayRecord(id: UUID(), value: value, updatedAt: timestamp)
try await store.replacePlannedStayRecord(with: record)
}
}
}
Loading
Loading