Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
6 changes: 4 additions & 2 deletions Where/Tools/upgrade-backup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
# old joined key and recovering any legacy epoch value to a calendar day.
# - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes
# `primaryRegions` from the tracked ids (null appearance, listed order) when
# absent, and sets `formatVersion` to 2 (the current version).
# absent, adds an empty planned-stay register for pre-v3 archives, and sets
# `formatVersion` to 3 (the current version).
#
# Idempotent: re-running on an already-upgraded archive is a no-op (it only
# touches legacy `date` / `key` fields and unmapped region ids).
Expand All @@ -40,7 +41,7 @@
require "set"

MANIFEST_NAME = "manifest.json"
CURRENT_FORMAT_VERSION = 2
CURRENT_FORMAT_VERSION = 3

# Former enum-case region ids -> current catalog ids. `canada` / `other` are
# unchanged but listed so an already-current id passes through untouched.
Expand Down Expand Up @@ -178,6 +179,7 @@ def upgrade_manifest(manifest)
manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index|
{ "region" => id, "appearance" => nil, "order" => index }
end
manifest["plannedStayRecords"] ||= []
manifest["formatVersion"] = CURRENT_FORMAT_VERSION
warnings.uniq.each { |message| warn "warning: #{message}" }
manifest
Expand Down
4 changes: 4 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ internal shape.
The archive is strict synthesized `Codable` — no in-code legacy decode; a
shape change bumps `BackupArchive.currentFormatVersion` and extends
[`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb) instead.
- **The planned stay is a 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.
Guards: `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
14 changes: 10 additions & 4 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ one it belongs to rather than to a god-object:
- **`DayAggregator`** — turns samples + manual overlays into those reports,
carrying the injected `Calendar` (which decides how a `sample.timestamp`
buckets into a `CalendarDay`).
- **`LocationForecast` / `PlannedStayCoordinator`** — annualizes a region's
current-year day count after three complete months, optionally counting one
synced “here through” stay before resuming the year-to-date pace. Forecasts
stay independent per region, so a future residency-percentage goal can
compare against them without changing the estimate.

### Location

Expand Down Expand Up @@ -107,10 +112,11 @@ one it belongs to rather than to a god-object:
`ZIPFoundation`).
- **`RecentActivitySummarizer`** — an on-device Foundation Models narrative over
a selectable look-back `RecentActivityWindow`.
- **`WherePreferences`** — persisted user intent (onboarding, tracking intent,
reminder / summary schedules) 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
- **`WherePreferences`** — persisted user intent (onboarding, tracking and
forecast visibility, reminder / summary schedules) 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
`InMemoryKeyValueStore()`, so no test or preview can reach the host's real
defaults by saying nothing.
- **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the
Expand Down
10 changes: 8 additions & 2 deletions Where/WhereCore/Sources/Backup/BackupArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// `BackupService.readArchive`, which rejects any other version).
///
/// v2 adds `primaryRegions` (each tracked region's picked appearance + pick
/// order). There's no in-app decode fallback for a pre-v2 archive — it's
/// order); v3 adds `plannedStayRecords`. There's no in-app decode fallback
/// for an older archive — it's
/// reshaped out of band by `Tools/upgrade-backup.rb` (which synthesizes
/// `primaryRegions` from `trackedRegions`), matching the module's
/// no-migration-on-read rule (see `AGENTS.md`).
public static let currentFormatVersion = 2
public static let currentFormatVersion = 3

public let formatVersion: Int
public let exportedAt: Date
Expand All @@ -40,6 +41,9 @@ public struct BackupArchive: Codable, Sendable, Hashable {
/// brings back the *look*, not just the region set. Import restores from
/// this; `trackedRegions` is the derived id list.
public let primaryRegions: [PrimaryRegion]
/// 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 @@ -53,6 +57,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
dismissedIssues: [DismissedIssue],
trackedRegions: [Region],
primaryRegions: [PrimaryRegion],
plannedStayRecords: [PlannedStayRecord] = [],
assets: [BackupAssetEntry],
) {
self.formatVersion = formatVersion
Expand All @@ -63,6 +68,7 @@ public struct BackupArchive: Codable, Sendable, Hashable {
self.dismissedIssues = dismissedIssues
self.trackedRegions = trackedRegions
self.primaryRegions = primaryRegions
self.plannedStayRecords = plannedStayRecords
self.assets = assets
}
}
Expand Down
11 changes: 10 additions & 1 deletion Where/WhereCore/Sources/Backup/BackupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ public actor BackupCoordinator {
/// here and jump to `1` once the archive file exists.
private static let exportBlobLoadFraction = 0.8

/// Serialize the entire store (all four tables plus evidence blobs) to a
/// Serialize the entire store (including planned-stay revisions and
/// evidence blobs) to a
/// `.zip` in a fresh temporary directory and return its URL, first purging
/// the previous export's directory. The caller shares the file; the next
/// export (or process exit) reclaims the disk.
Expand Down Expand Up @@ -117,6 +118,7 @@ public actor BackupCoordinator {
manualDays: store.allManualDays(),
dismissedIssues: store.allDismissedIssues(),
primaryRegions: store.primaryRegions(),
plannedStayRecords: store.plannedStayRecords(),
)
}
let evidence = tables.evidence
Expand Down Expand Up @@ -145,6 +147,7 @@ public actor BackupCoordinator {
// The bare ids ride alongside the primary regions for older readers.
trackedRegions: tables.primaryRegions.map(\.region),
primaryRegions: tables.primaryRegions,
plannedStayRecords: tables.plannedStayRecords,
blobs: blobs,
)
}.value
Expand All @@ -162,6 +165,7 @@ public actor BackupCoordinator {
let manualDays: [DayPresence]
let dismissedIssues: [DismissedIssue]
let primaryRegions: [PrimaryRegion]
let plannedStayRecords: [PlannedStayRecord]
}

/// Delete the most recent export's staging directory now, rather than
Expand Down Expand Up @@ -228,6 +232,7 @@ public actor BackupCoordinator {
let blobs = result.blobs
let total = archive.samples.count + archive.evidence.count
+ archive.manualDays.count + archive.dismissedIssues.count
+ archive.plannedStayRecords.count

try await Self.logger.measure(.importWrite) {
try await store.perform {
Expand Down Expand Up @@ -263,6 +268,10 @@ public actor BackupCoordinator {
try await store.restoreDismissedIssue(dismissal)
report()
}
for plannedStay in archive.plannedStayRecords {
try await store.restorePlannedStayRecord(plannedStay)
report()
}
// Primary regions (with their picked looks) round-trip like any
// other data. On `.replace` the store was cleared above, so write
// the archive's set exactly; on `.merge` union it into the current
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 @@ -82,6 +82,7 @@ public struct BackupService: Sendable {
dismissedIssues: [DismissedIssue] = [],
trackedRegions: [Region] = [],
primaryRegions: [PrimaryRegion] = [],
plannedStayRecords: [PlannedStayRecord] = [],
blobs: [UUID: Data],
exportedAt: Date = Date(),
archiveName: String? = nil,
Expand Down Expand Up @@ -116,6 +117,7 @@ public struct BackupService: Sendable {
dismissedIssues: dismissedIssues,
trackedRegions: trackedRegions,
primaryRegions: primaryRegions,
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 {
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)
}
}
}
23 changes: 23 additions & 0 deletions Where/WhereCore/Sources/Forecasting/PlannedStayRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// One revision of the single synced planned-stay register. A `nil` value is a
/// tombstone, retained so a delayed CloudKit import cannot resurrect an older
/// active stay after it was cleared or expired.
public struct PlannedStayRecord: Hashable, Sendable, Codable, Identifiable {
public let id: UUID
public let value: PlannedStay?
public let updatedAt: Date

public init(id: UUID, value: PlannedStay?, updatedAt: Date) {
self.id = id
self.value = value
self.updatedAt = updatedAt
}

/// Deterministic last-writer ordering for duplicate rows produced by
/// eventually-consistent CloudKit writes.
public static func newer(_ lhs: PlannedStayRecord, than rhs: PlannedStayRecord) -> Bool {
if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt }
return lhs.id.uuidString > rhs.id.uuidString
}
}
Loading
Loading