From 7dd31aeba89878eb85d9dd050b71afe96c68f551 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 14:51:26 -0400 Subject: [PATCH 1/6] Scaffold RegionKit module (RegionLog, package target, docs) Groundwork only: adds the RegionKit SPM library (LogKit dep) with its RegionLog logging facade and module README/AGENTS. No behavior change; nothing depends on it yet. --- Package.swift | 10 ++++ Where/RegionKit/AGENTS.md | 44 +++++++++++++++ Where/RegionKit/README.md | 72 +++++++++++++++++++++++++ Where/RegionKit/Sources/RegionLog.swift | 29 ++++++++++ 4 files changed, 155 insertions(+) create mode 100644 Where/RegionKit/AGENTS.md create mode 100644 Where/RegionKit/README.md create mode 100644 Where/RegionKit/Sources/RegionLog.swift diff --git a/Package.swift b/Package.swift index 16cfce53..2c0d4cc2 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( .library(name: "LogKit", targets: ["LogKit"]), .library(name: "LogViewerUI", targets: ["LogViewerUI"]), .library(name: "SwiftDataInspector", targets: ["SwiftDataInspector"]), + .library(name: "RegionKit", targets: ["RegionKit"]), .library(name: "WhereCore", targets: ["WhereCore"]), .library(name: "WhereUI", targets: ["WhereUI"]), .library(name: "WhereTesting", targets: ["WhereTesting"]), @@ -59,10 +60,18 @@ let package = Package( name: "SwiftDataInspector", path: "Shared/SwiftDataInspector/Sources", ), + .target( + name: "RegionKit", + dependencies: [ + .target(name: "LogKit"), + ], + path: "Where/RegionKit/Sources", + ), .target( name: "WhereCore", dependencies: [ .target(name: "LogKit"), + .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], path: "Where/WhereCore/Sources", @@ -77,6 +86,7 @@ let package = Package( .target(name: "LifecycleKit"), .target(name: "LogKit"), .target(name: "LogViewerUI"), + .target(name: "RegionKit"), .target(name: "SwiftDataInspector"), ], path: "Where/WhereUI/Sources", diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md new file mode 100644 index 00000000..8010f988 --- /dev/null +++ b/Where/RegionKit/AGENTS.md @@ -0,0 +1,44 @@ +# RegionKit – Module Shape + +RegionKit is the geometry and region-lookup engine for the Where feature: +coordinate-to-`Region` attribution over bundled GeoJSON polygons, plus the +geometry primitives and the developer-viewer geometry catalog. See +[`README.md`](README.md) for the public API and usage. + +This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature +[`Where/AGENTS.md`](../AGENTS.md). Read those first. + +## Scope & dependencies + +- **Pure Swift + Foundation**, plus [`LogKit`](../../Shared/LogKit). It must + **not** import SwiftUI, UIKit, SwiftData, CoreLocation, or `WhereCore` — it is + the lowest layer of the feature, and `WhereCore` depends on *it*, never the + reverse. +- Library target in [`Package.swift`](../../Package.swift) + (`Where/RegionKit/Sources`). Bundled region polygons and the region-name + string catalog ship in `Sources/Resources/`. + +## Invariants + +- **`Region.geometrySource` is the single source of truth** for where a region's + polygons come from; `Region.localizedName` and `geometrySource` are exhaustive + switches, so adding a `Region` case is a compile error until its name and + geometry are declared (see [README](README.md#adding-a-region)). +- **`Region.allCases` order fixes attribution priority** — `RegionAttributor` + checks regions in declaration order and the first polygon match wins (regions + are mutually exclusive at our resolution); it also drives + `Region.declarationOrder`, the app's ranking tiebreak. +- **Attribution loads once, lazily** (`RegionAttributor.shared`) and is UI-free: + `BoundingBox` / `LongitudeSpan` expose the min/max math, but MapKit conversion + lives in the UI layer. +- **Missing/corrupt bundled geometry is a programmer error** — the loader logs a + `fault` via `RegionLog` *and* `assertionFailure`s (debug), degrading to + `.other` in release rather than crashing. +- **Logging goes through `RegionLog.channel(_:)`** (subsystem + `com.stuff.regionkit`), never `WhereLog` — RegionKit owns its own channel and + in-memory store. + +## Testing + +Swift Testing in [`Tests/`](Tests) (`RegionKitTests`), hosted in +`StuffTestHost`. Internal types are reached via `@testable import RegionKit`. diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md new file mode 100644 index 00000000..e7033623 --- /dev/null +++ b/Where/RegionKit/README.md @@ -0,0 +1,72 @@ +# RegionKit + +The geometry and region-lookup engine behind the Where app. Given a WGS84 +`Coordinate`, RegionKit answers *which tracked `Region` is it in?* — backed by +bundled GeoJSON polygons loaded once at process start. It is pure Swift + +Foundation (no SwiftUI, UIKit, SwiftData, or CoreLocation), so it can be reused +and unit-tested in isolation. + +RegionKit is the lowest layer of the Where feature: `WhereCore` (and, through +it, `WhereUI`, the widgets, and the RegionViewer) depend on RegionKit and call +into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit). + +## What you get + +- **`Region`** — the tracked-region enum (`.california`, `.newYork`, `.canada`, + `.europeanUnion`, `.other`), with a `localizedName` (from RegionKit's own + string catalog) and a `geometrySource` describing where its polygons come + from. `Region+Ordering` provides the app's canonical "most days first, stable + tiebreak" ranking. +- **`Coordinate`** — a plain WGS84 latitude/longitude value type (no + CoreLocation), plus geometry primitives `GeoPolygon`, `BoundingBox`, and the + antimeridian-aware `LongitudeSpan`. +- **`RegionAttributor`** — `region(at:)` maps a coordinate to its `Region` + (bounding-box pre-pass, then an even-odd ray-cast), and `distanceToBoundary` + measures nearness to a region's edge. `RegionAttributor.shared` loads the + bundled polygons lazily. +- **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the + developer region-map viewer (`.attribution` vs `.source` geometry). +- **`RegionLog`** — RegionKit's LogKit facade (subsystem `com.stuff.regionkit`). + +## Installation + +`RegionKit` is a local SPM library in this repo (`Where/RegionKit`). Add it to a +target's dependencies in [`Package.swift`](../../Package.swift): + +```swift +.target(name: "YourModule", dependencies: [.target(name: "RegionKit")]) +``` + +## Quick start + +```swift +import RegionKit + +let region = RegionAttributor.shared.region(at: Coordinate(latitude: 37.77, longitude: -122.42)) +// -> .california +print(region.localizedName) // "California" +``` + +## Bundled data + +Region polygons ship in `Sources/Resources/*.geojson`; see +[`Sources/Resources/README.md`](Sources/Resources/README.md) for provenance and +fidelity notes. Region names resolve through RegionKit's own +`Localizable.xcstrings` (`Region.localizedName`, `bundle: .module`). + +## Adding a region + +Add the `Region` case, then resolve the two compile errors it forces: a +`region.` entry in `Sources/Resources/Localizable.xcstrings` (for +`localizedName`) and a `Region.geometrySource` case — either +`.usStateFeature(name:)` (a feature already in `us-states.geojson`, no new file) +or `.bundledFile` with a new `.geojson` in `Sources/Resources/`. Add a +`RegionAttributorTests` spot-check. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` (so +`Bundle.module` resolves the GeoJSON at runtime). Attribution, geometry +(point-in-polygon, bounding box, longitude span), GeoJSON decoding, and the +geometry catalog are covered here; internal types (`GeoJSON`, `GeoPolygon`, +`RegionPolygons`) are reached via `@testable import RegionKit`. diff --git a/Where/RegionKit/Sources/RegionLog.swift b/Where/RegionKit/Sources/RegionLog.swift new file mode 100644 index 00000000..41f2e480 --- /dev/null +++ b/Where/RegionKit/Sources/RegionLog.swift @@ -0,0 +1,29 @@ +import LogKit + +/// Logging facade for `RegionKit`. Every logger site routes through +/// ``channel(_:)`` so messages reach both Apple unified logging (Console.app, +/// subsystem `com.stuff.regionkit`) and the shared in-memory ``LogStore`` a +/// log viewer can read (DEBUG builds only). +/// +/// RegionKit owns its own subsystem and store rather than borrowing the Where +/// app's `WhereLog`: it's a standalone lower-level module that must not depend +/// on app code. Categories are a typed enum rather than raw strings so a new +/// logger can't silently typo into an untracked category. +public enum RegionLog { + /// The subsystem every RegionKit log shares. + public static let subsystem = "com.stuff.regionkit" + + /// Process-wide buffer feeding an in-app log viewer. Logging is inherently + /// process-global, so a single shared store is the natural home. + public static let store = LogStore() + + public enum Category: String, CaseIterable, Sendable { + case attributor = "RegionAttributor" + case geometryCatalog = "RegionGeometryCatalog" + } + + /// A logging channel for `category`, wired to the shared buffer. + public static func channel(_ category: Category) -> LogChannel { + LogChannel(subsystem: subsystem, category: category.rawValue, store: store) + } +} From 385e4b6f0ffd01b6aa7c2ea82de6c573d5de04e7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 15:08:11 -0400 Subject: [PATCH 2/6] Move GeoJSON/geometry/region lookup into RegionKit Relocate Coordinate, GeoPolygon/BoundingBox, LongitudeSpan, GeoJSON, Region, Region+Ordering, RegionAttributor, and RegionGeometryCatalog (plus the bundled *.geojson and the region.* string catalog entries) from WhereCore into the new lower-layer RegionKit module. WhereCore now depends on RegionKit and calls into it for region lookup; every consumer (WhereCore, WhereUI, WhereWidgets, the app, RegionViewer, and their tests) imports RegionKit explicitly. RegionAttributor now logs via RegionLog (subsystem com.stuff.regionkit) instead of WhereLog; the now-unused WhereLog.regionAttributor category is removed and the region-map viewer's failure log moves to RegionLog too. The seven region test files move to RegionKit/Tests; a RegionKitTests bundle is wired in Project.swift (registered in the Stuff-iOS-Tests scheme) and StuffTestHost plus WhereCoreTests/WhereUITests depend on RegionKit so Bundle.module resolves the GeoJSON at runtime. Closes plan to-dos: move-sources and move-tests. --- Package.swift | 3 + Project.swift | 34 ++++++++--- .../Sources/Coordinate.swift | 0 .../Sources/GeoJSON.swift | 7 +-- .../Sources/GeoPolygon.swift | 2 +- .../Sources/LongitudeSpan.swift | 2 +- .../Sources/Region+Ordering.swift | 0 .../Sources/Region.swift | 2 +- .../Sources/RegionAttributor.swift | 4 +- .../Sources/RegionGeometryCatalog.swift | 0 .../Sources/Resources/Localizable.xcstrings | 61 +++++++++++++++++++ .../Sources/Resources/README.md | 0 .../Sources/Resources/canada.geojson | 0 .../Sources/Resources/europeanUnion.geojson | 0 .../Sources/Resources/us-states.geojson | 0 .../Tests/CoordinateTests.swift | 2 +- .../Tests/GeoPolygonTests.swift | 2 +- .../Tests/LongitudeSpanTests.swift | 2 +- .../Tests/RegionAttributorTests.swift | 2 +- .../Tests/RegionGeometryCatalogTests.swift | 2 +- .../Tests/RegionOrderingTests.swift | 24 +++++--- .../Tests/RegionTests.swift | 2 +- .../Sources/DailySummaryReconciler.swift | 1 + .../DataResolution/BorderDriftDetector.swift | 1 + .../DataResolution/BorderDriftIssue.swift | 1 + .../Sources/DataResolution/DataIssue.swift | 1 + .../DataResolution/DataIssueDetector.swift | 1 + .../DataResolution/DataIssueScanner.swift | 1 + Where/WhereCore/Sources/DayAggregator.swift | 1 + Where/WhereCore/Sources/DayJournal.swift | 1 + Where/WhereCore/Sources/DayPresence.swift | 1 + .../WhereCore/Sources/Evidence/Evidence.swift | 1 + .../Sources/Location/CoreLocationSource.swift | 1 + Where/WhereCore/Sources/LocationSample.swift | 1 + .../WhereCore/Sources/ManualEntryAudit.swift | 1 + .../Sources/Persistence/SwiftDataStore.swift | 1 + .../WhereCore/Sources/PresenceCalendar.swift | 1 + .../RecentActivitySummarizer.swift | 1 + .../Sources/RegionDayLocations.swift | 1 + Where/WhereCore/Sources/ReportReader.swift | 1 + .../Sources/Resources/Localizable.xcstrings | 55 ----------------- Where/WhereCore/Sources/WhereLog.swift | 1 - Where/WhereCore/Sources/WhereServices.swift | 1 + .../Sources/WidgetSnapshotPublisher.swift | 1 + .../Sources/Widgets/WidgetDataReader.swift | 1 + Where/WhereCore/Sources/YearReport.swift | 1 + .../Tests/BackupCoordinatorTests.swift | 1 + .../WhereCore/Tests/BackupServiceTests.swift | 1 + .../Tests/BorderDriftDetectorTests.swift | 1 + .../Tests/DataIssueDetectorTestSupport.swift | 1 + .../WhereCore/Tests/DayAggregatorTests.swift | 1 + Where/WhereCore/Tests/DayJournalTests.swift | 1 + .../Tests/LocationIngestorTests.swift | 1 + .../WhereCore/Tests/LocationOutboxTests.swift | 1 + .../Tests/NewYorkHeavyYearTests.swift | 1 + .../Tests/RecentActivitySummarizerTests.swift | 1 + .../Tests/ReminderReconcilerTests.swift | 1 + Where/WhereCore/Tests/ReportReaderTests.swift | 1 + Where/WhereCore/Tests/SimulatedYear.swift | 1 + .../WhereCore/Tests/SimulatedYearTests.swift | 1 + .../WhereCore/Tests/SwiftDataStoreTests.swift | 1 + Where/WhereCore/Tests/WhereCoreTests.swift | 1 + Where/WhereCore/Tests/WhereLogTests.swift | 2 +- .../WhereCore/Tests/WhereServicesTests.swift | 1 + .../Tests/WidgetDataReaderTests.swift | 1 + .../Tests/WidgetSnapshotPublisherTests.swift | 1 + .../Tests/WidgetSnapshotStoreTests.swift | 1 + .../Sources/Developer/RegionMapView.swift | 3 +- .../Sources/Model/PresenceTimeline.swift | 1 + .../WhereUI/Sources/Model/RegionRanking.swift | 1 + Where/WhereUI/Sources/Model/RegionStyle.swift | 1 + .../Sources/Model/YearReportModel.swift | 1 + .../Sources/Preview/PreviewSupport.swift | 1 + .../Sources/Primary/CalendarView.swift | 1 + .../WhereUI/Sources/Primary/PrimaryView.swift | 1 + .../Resolution/AbruptChangeDetailView.swift | 1 + .../Sources/Resolution/ResolutionView.swift | 1 + .../Sources/Resolution/ResolveModel.swift | 1 + .../Sources/Secondary/DayRelabelView.swift | 1 + .../Sources/Secondary/RegionDaysView.swift | 1 + .../Sources/Secondary/SecondaryView.swift | 1 + .../Sources/Shared/Coordinate+MapKit.swift | 1 + .../Sources/Shared/LocationNamer.swift | 1 + .../Sources/Shared/RegionSelectionState.swift | 1 + Where/WhereUI/Sources/Shared/Strings.swift | 1 + .../Sources/Widgets/TodayAccessoryViews.swift | 1 + .../Sources/Widgets/TodayWidgetView.swift | 1 + .../Widgets/WidgetSnapshot+Ranking.swift | 1 + .../Tests/RecentActivityModelTests.swift | 1 + Where/WhereUI/Tests/RegionRankingTests.swift | 1 + Where/WhereUI/Tests/ScreenHostingTests.swift | 1 + Where/WhereUI/Tests/StringsTests.swift | 1 + Where/WhereUI/Tests/WidgetViewsTests.swift | 1 + .../Sources/WidgetPreviewFixtures.swift | 1 + .../Sources/WidgetSnapshotFixtures.swift | 1 + 95 files changed, 193 insertions(+), 88 deletions(-) rename Where/{WhereCore => RegionKit}/Sources/Coordinate.swift (100%) rename Where/{WhereCore => RegionKit}/Sources/GeoJSON.swift (95%) rename Where/{WhereCore => RegionKit}/Sources/GeoPolygon.swift (99%) rename Where/{WhereCore => RegionKit}/Sources/LongitudeSpan.swift (97%) rename Where/{WhereCore => RegionKit}/Sources/Region+Ordering.swift (100%) rename Where/{WhereCore => RegionKit}/Sources/Region.swift (97%) rename Where/{WhereCore => RegionKit}/Sources/RegionAttributor.swift (98%) rename Where/{WhereCore => RegionKit}/Sources/RegionGeometryCatalog.swift (100%) create mode 100644 Where/RegionKit/Sources/Resources/Localizable.xcstrings rename Where/{WhereCore => RegionKit}/Sources/Resources/README.md (100%) rename Where/{WhereCore => RegionKit}/Sources/Resources/canada.geojson (100%) rename Where/{WhereCore => RegionKit}/Sources/Resources/europeanUnion.geojson (100%) rename Where/{WhereCore => RegionKit}/Sources/Resources/us-states.geojson (100%) rename Where/{WhereCore => RegionKit}/Tests/CoordinateTests.swift (94%) rename Where/{WhereCore => RegionKit}/Tests/GeoPolygonTests.swift (98%) rename Where/{WhereCore => RegionKit}/Tests/LongitudeSpanTests.swift (98%) rename Where/{WhereCore => RegionKit}/Tests/RegionAttributorTests.swift (99%) rename Where/{WhereCore => RegionKit}/Tests/RegionGeometryCatalogTests.swift (99%) rename Where/{WhereCore => RegionKit}/Tests/RegionOrderingTests.swift (71%) rename Where/{WhereCore => RegionKit}/Tests/RegionTests.swift (98%) diff --git a/Package.swift b/Package.swift index 2c0d4cc2..879b3557 100644 --- a/Package.swift +++ b/Package.swift @@ -66,6 +66,9 @@ let package = Package( .target(name: "LogKit"), ], path: "Where/RegionKit/Sources", + resources: [ + .process("Resources"), + ], ), .target( name: "WhereCore", diff --git a/Project.swift b/Project.swift index a0662add..59681e76 100644 --- a/Project.swift +++ b/Project.swift @@ -97,6 +97,7 @@ let project = Project( dependencies: [ .package(product: "LifecycleKit"), .package(product: "LogKit"), + .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), .target(name: "WhereWidgets"), @@ -130,6 +131,7 @@ let project = Project( entitlements: whereAppGroupEntitlements, dependencies: [ .package(product: "LogKit"), + .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), ], @@ -151,9 +153,10 @@ let project = Project( sources: ["Where/RegionViewer/Sources/**"], resources: ["Where/RegionViewer/Resources/**"], // No App Group entitlement — the viewer only reads bundled GeoJSON - // (embedded via the WhereCore dependency), never the app's store. + // (embedded via the RegionKit dependency), never the app's store. dependencies: [ .package(product: "LogKit"), + .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), ], @@ -256,13 +259,15 @@ let project = Project( ]), ]), sources: ["Shared/StuffTestHost/Sources/**"], - // Hosted Swift Testing bundles run inside StuffTestHost, so WhereCore's + // Hosted Swift Testing bundles run inside StuffTestHost, so a package's // `Bundle.module` resolves against the host app's main bundle at runtime. - // Depending on WhereCore here makes Tuist embed `Stuff_WhereCore.bundle` - // (its GeoJSON region data) into the host, so code the tests touch — e.g. - // the lazy `RegionAttributor.shared` — finds its resources instead of - // trapping in the `Bundle.module` accessor. + // Depending on RegionKit and WhereCore here makes Tuist embed their + // resource bundles (`Stuff_RegionKit.bundle`, which holds the GeoJSON + // region data, and `Stuff_WhereCore.bundle`) into the host, so code the + // tests touch — e.g. the lazy `RegionAttributor.shared` — finds its + // resources instead of trapping in the `Bundle.module` accessor. dependencies: [ + .package(product: "RegionKit"), .package(product: "WhereCore"), ], ), @@ -296,18 +301,30 @@ let project = Project( productDependency: "SwiftDataInspector", sources: ["Shared/SwiftDataInspector/Tests/**"], ), + unitTests( + name: "RegionKitTests", + bundleIdSuffix: "regionkit", + productDependency: "RegionKit", + sources: ["Where/RegionKit/Tests/**"], + ), unitTests( name: "WhereCoreTests", bundleIdSuffix: "wherecore", productDependency: "WhereCore", sources: ["Where/WhereCore/Tests/**"], + extraPackageProducts: ["RegionKit"], ), unitTests( name: "WhereUITests", bundleIdSuffix: "whereui", productDependency: "WhereUI", sources: ["Where/WhereUI/Tests/**"], - extraPackageProducts: ["LifecycleKit", "LogViewerUI", "SwiftDataInspector"], + extraPackageProducts: [ + "LifecycleKit", + "LogViewerUI", + "RegionKit", + "SwiftDataInspector", + ], ), ], // Tuist's autogeneration doesn't emit working standalone test actions for @@ -347,6 +364,7 @@ let project = Project( "LogKitTests", "LogViewerUITests", "SwiftDataInspectorTests", + "RegionKitTests", "WhereCoreTests", "WhereTests", "WhereUITests", @@ -357,6 +375,7 @@ let project = Project( "LogKitTests", "LogViewerUITests", "SwiftDataInspectorTests", + "RegionKitTests", "WhereCoreTests", "WhereTests", "WhereUITests", @@ -374,6 +393,7 @@ let project = Project( testScheme(name: "LogKitTests"), testScheme(name: "LogViewerUITests"), testScheme(name: "SwiftDataInspectorTests"), + testScheme(name: "RegionKitTests"), testScheme(name: "WhereCoreTests"), testScheme(name: "WhereTests"), testScheme(name: "WhereUITests"), diff --git a/Where/WhereCore/Sources/Coordinate.swift b/Where/RegionKit/Sources/Coordinate.swift similarity index 100% rename from Where/WhereCore/Sources/Coordinate.swift rename to Where/RegionKit/Sources/Coordinate.swift diff --git a/Where/WhereCore/Sources/GeoJSON.swift b/Where/RegionKit/Sources/GeoJSON.swift similarity index 95% rename from Where/WhereCore/Sources/GeoJSON.swift rename to Where/RegionKit/Sources/GeoJSON.swift index 6edca71b..fa2aaa77 100644 --- a/Where/WhereCore/Sources/GeoJSON.swift +++ b/Where/RegionKit/Sources/GeoJSON.swift @@ -1,15 +1,14 @@ import Foundation /// Thin `Decodable` views over the slice of the GeoJSON 1.0 spec that -/// `WhereCore` actually consumes: a `FeatureCollection` of `Feature`s +/// `RegionKit` actually consumes: a `FeatureCollection` of `Feature`s /// whose geometry is a `Polygon` or `MultiPolygon`. Anything else in /// the spec (`Point`, `LineString`, foreign members, etc.) is ignored /// at decode time. /// /// Kept separate from `RegionAttributor` so the attributor can stay /// focused on "coordinate -> Region" — it consumes GeoJSON, but GeoJSON -/// itself is its own thing and could be lifted into a shared module -/// later without dragging the attributor along. +/// itself is its own concern. enum GeoJSON { /// Top-level GeoJSON document we load from `Resources/*.geojson`. struct FeatureCollection: Decodable { @@ -70,7 +69,7 @@ enum GeoJSON { } } - /// One decoded feature reduced to what `WhereCore` consumes: its + /// One decoded feature reduced to what `RegionKit` consumes: its /// optional `NAME` plus the exterior-ring polygons it contributes. struct NamedPolygons { let name: String? diff --git a/Where/WhereCore/Sources/GeoPolygon.swift b/Where/RegionKit/Sources/GeoPolygon.swift similarity index 99% rename from Where/WhereCore/Sources/GeoPolygon.swift rename to Where/RegionKit/Sources/GeoPolygon.swift index 702d9cb5..8ed5f531 100644 --- a/Where/WhereCore/Sources/GeoPolygon.swift +++ b/Where/RegionKit/Sources/GeoPolygon.swift @@ -8,7 +8,7 @@ import Foundation /// Public so the developer region-map viewer can frame its camera's /// latitude from the same min/max math (via `enclosing(_:)`); longitude /// is framed separately with `LongitudeSpan` because it can wrap the -/// antimeridian. WhereCore stays UI-free, so the MapKit conversion +/// antimeridian. RegionKit stays UI-free, so the MapKit conversion /// happens in the UI layer. public struct BoundingBox: Hashable, Sendable { public let minLatitude: Double diff --git a/Where/WhereCore/Sources/LongitudeSpan.swift b/Where/RegionKit/Sources/LongitudeSpan.swift similarity index 97% rename from Where/WhereCore/Sources/LongitudeSpan.swift rename to Where/RegionKit/Sources/LongitudeSpan.swift index fd748179..c1db1392 100644 --- a/Where/WhereCore/Sources/LongitudeSpan.swift +++ b/Where/RegionKit/Sources/LongitudeSpan.swift @@ -10,7 +10,7 @@ import Foundation /// /// Used by the developer region-map viewer to frame its map camera. /// Latitude has no such wrap, so callers pair this with `BoundingBox` for -/// the latitude extent; WhereCore stays UI-free, so the MapKit conversion +/// the latitude extent; RegionKit stays UI-free, so the MapKit conversion /// happens in the UI layer. public struct LongitudeSpan: Hashable, Sendable { /// Center longitude of the arc, normalized to [−180°, 180°]. diff --git a/Where/WhereCore/Sources/Region+Ordering.swift b/Where/RegionKit/Sources/Region+Ordering.swift similarity index 100% rename from Where/WhereCore/Sources/Region+Ordering.swift rename to Where/RegionKit/Sources/Region+Ordering.swift diff --git a/Where/WhereCore/Sources/Region.swift b/Where/RegionKit/Sources/Region.swift similarity index 97% rename from Where/WhereCore/Sources/Region.swift rename to Where/RegionKit/Sources/Region.swift index 19af914a..0005e3b7 100644 --- a/Where/WhereCore/Sources/Region.swift +++ b/Where/RegionKit/Sources/Region.swift @@ -18,7 +18,7 @@ public enum Region: String, Codable, Sendable, Hashable, CaseIterable { case europeanUnion case other - /// User-facing name for this region, read from the `WhereCore` + /// User-facing name for this region, read from the `RegionKit` /// string catalog (`Resources/Localizable.xcstrings`). /// /// Uses `String(localized:)` with a literal key per case (rather diff --git a/Where/WhereCore/Sources/RegionAttributor.swift b/Where/RegionKit/Sources/RegionAttributor.swift similarity index 98% rename from Where/WhereCore/Sources/RegionAttributor.swift rename to Where/RegionKit/Sources/RegionAttributor.swift index 3af5cfdf..8e4579d4 100644 --- a/Where/WhereCore/Sources/RegionAttributor.swift +++ b/Where/RegionKit/Sources/RegionAttributor.swift @@ -29,7 +29,7 @@ public struct RegionAttributor: Sendable { /// The loaded polygons per region, exposed for the developer /// region-map viewer (`RegionGeometryCatalog`). Internal on purpose: - /// callers outside `WhereCore` consume drawable outlines via + /// callers outside `RegionKit` consume drawable outlines via /// `RegionGeometryCatalog.outlines(for:)`, never raw `RegionPolygons`. var loadedRegionPolygons: [RegionPolygons] { regionPolygons @@ -65,7 +65,7 @@ public struct RegionAttributor: Sendable { /// `Logger` from `os` — used in `loadFromBundle` to surface /// missing/unparseable bundled resources as Console.app faults /// alongside the debug-build `assertionFailure`. - private static let logger = WhereLog.channel(.regionAttributor) + private static let logger = RegionLog.channel(.attributor) private static func loadFromBundle() -> RegionAttributor { // Every Census `NAME` any `.usStateFeature` region wants, so the diff --git a/Where/WhereCore/Sources/RegionGeometryCatalog.swift b/Where/RegionKit/Sources/RegionGeometryCatalog.swift similarity index 100% rename from Where/WhereCore/Sources/RegionGeometryCatalog.swift rename to Where/RegionKit/Sources/RegionGeometryCatalog.swift diff --git a/Where/RegionKit/Sources/Resources/Localizable.xcstrings b/Where/RegionKit/Sources/Resources/Localizable.xcstrings new file mode 100644 index 00000000..826da5c0 --- /dev/null +++ b/Where/RegionKit/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,61 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "region.california" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "California" + } + } + } + }, + "region.canada" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canada" + } + } + } + }, + "region.europeanUnion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "European Union" + } + } + } + }, + "region.newYork" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New York" + } + } + } + }, + "region.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Where/WhereCore/Sources/Resources/README.md b/Where/RegionKit/Sources/Resources/README.md similarity index 100% rename from Where/WhereCore/Sources/Resources/README.md rename to Where/RegionKit/Sources/Resources/README.md diff --git a/Where/WhereCore/Sources/Resources/canada.geojson b/Where/RegionKit/Sources/Resources/canada.geojson similarity index 100% rename from Where/WhereCore/Sources/Resources/canada.geojson rename to Where/RegionKit/Sources/Resources/canada.geojson diff --git a/Where/WhereCore/Sources/Resources/europeanUnion.geojson b/Where/RegionKit/Sources/Resources/europeanUnion.geojson similarity index 100% rename from Where/WhereCore/Sources/Resources/europeanUnion.geojson rename to Where/RegionKit/Sources/Resources/europeanUnion.geojson diff --git a/Where/WhereCore/Sources/Resources/us-states.geojson b/Where/RegionKit/Sources/Resources/us-states.geojson similarity index 100% rename from Where/WhereCore/Sources/Resources/us-states.geojson rename to Where/RegionKit/Sources/Resources/us-states.geojson diff --git a/Where/WhereCore/Tests/CoordinateTests.swift b/Where/RegionKit/Tests/CoordinateTests.swift similarity index 94% rename from Where/WhereCore/Tests/CoordinateTests.swift rename to Where/RegionKit/Tests/CoordinateTests.swift index ea65f5a9..79de9281 100644 --- a/Where/WhereCore/Tests/CoordinateTests.swift +++ b/Where/RegionKit/Tests/CoordinateTests.swift @@ -1,5 +1,5 @@ +@testable import RegionKit import Testing -@testable import WhereCore struct CoordinateTests { @Test func ringNeedsAtLeastThreeVertices() { diff --git a/Where/WhereCore/Tests/GeoPolygonTests.swift b/Where/RegionKit/Tests/GeoPolygonTests.swift similarity index 98% rename from Where/WhereCore/Tests/GeoPolygonTests.swift rename to Where/RegionKit/Tests/GeoPolygonTests.swift index a78af6ea..8432ec67 100644 --- a/Where/WhereCore/Tests/GeoPolygonTests.swift +++ b/Where/RegionKit/Tests/GeoPolygonTests.swift @@ -1,5 +1,5 @@ +@testable import RegionKit import Testing -@testable import WhereCore struct GeoPolygonTests { /// A 1° × 1° square centered on (37.5, -122.5): lat 37–38, lng -123 to -122. diff --git a/Where/WhereCore/Tests/LongitudeSpanTests.swift b/Where/RegionKit/Tests/LongitudeSpanTests.swift similarity index 98% rename from Where/WhereCore/Tests/LongitudeSpanTests.swift rename to Where/RegionKit/Tests/LongitudeSpanTests.swift index 7241f857..77369883 100644 --- a/Where/WhereCore/Tests/LongitudeSpanTests.swift +++ b/Where/RegionKit/Tests/LongitudeSpanTests.swift @@ -1,5 +1,5 @@ +@testable import RegionKit import Testing -@testable import WhereCore struct LongitudeSpanTests { @Test func emptyIsNil() { diff --git a/Where/WhereCore/Tests/RegionAttributorTests.swift b/Where/RegionKit/Tests/RegionAttributorTests.swift similarity index 99% rename from Where/WhereCore/Tests/RegionAttributorTests.swift rename to Where/RegionKit/Tests/RegionAttributorTests.swift index 1d64f9d7..08dcf9d5 100644 --- a/Where/WhereCore/Tests/RegionAttributorTests.swift +++ b/Where/RegionKit/Tests/RegionAttributorTests.swift @@ -1,5 +1,5 @@ +import RegionKit import Testing -import WhereCore struct RegionAttributorTests { let attributor = RegionAttributor.shared diff --git a/Where/WhereCore/Tests/RegionGeometryCatalogTests.swift b/Where/RegionKit/Tests/RegionGeometryCatalogTests.swift similarity index 99% rename from Where/WhereCore/Tests/RegionGeometryCatalogTests.swift rename to Where/RegionKit/Tests/RegionGeometryCatalogTests.swift index 157967b4..750f5331 100644 --- a/Where/WhereCore/Tests/RegionGeometryCatalogTests.swift +++ b/Where/RegionKit/Tests/RegionGeometryCatalogTests.swift @@ -1,5 +1,5 @@ +@testable import RegionKit import Testing -@testable import WhereCore struct RegionGeometryCatalogTests { // MARK: - Attribution diff --git a/Where/WhereCore/Tests/RegionOrderingTests.swift b/Where/RegionKit/Tests/RegionOrderingTests.swift similarity index 71% rename from Where/WhereCore/Tests/RegionOrderingTests.swift rename to Where/RegionKit/Tests/RegionOrderingTests.swift index 77ff166a..17e5a163 100644 --- a/Where/WhereCore/Tests/RegionOrderingTests.swift +++ b/Where/RegionKit/Tests/RegionOrderingTests.swift @@ -1,7 +1,15 @@ +import RegionKit import Testing -import WhereCore struct RegionOrderingTests { + /// A minimal ranking row, standing in for the app's real row types + /// (`RegionDayTally`, `RegionDays`, …) that live in higher layers — the + /// generic `rankedByDayCount` only needs a `region` and a `days` accessor. + private struct Tally { + let region: Region + let days: Int + } + // MARK: - declarationOrder @Test func declarationOrderMatchesAllCasesIndices() { @@ -16,9 +24,9 @@ struct RegionOrderingTests { @Test func rankedByDayCountOrdersByDaysDescending() { let ranked = Region.rankedByDayCount( [ - RegionDayTally(region: .newYork, days: 3), - RegionDayTally(region: .california, days: 10), - RegionDayTally(region: .canada, days: 7), + Tally(region: .newYork, days: 3), + Tally(region: .california, days: 10), + Tally(region: .canada, days: 7), ], days: \.days, region: \.region, @@ -32,10 +40,10 @@ struct RegionOrderingTests { // `other`. let ranked = Region.rankedByDayCount( [ - RegionDayTally(region: .canada, days: 10), - RegionDayTally(region: .california, days: 10), - RegionDayTally(region: .other, days: 5), - RegionDayTally(region: .newYork, days: 5), + Tally(region: .canada, days: 10), + Tally(region: .california, days: 10), + Tally(region: .other, days: 5), + Tally(region: .newYork, days: 5), ], days: \.days, region: \.region, diff --git a/Where/WhereCore/Tests/RegionTests.swift b/Where/RegionKit/Tests/RegionTests.swift similarity index 98% rename from Where/WhereCore/Tests/RegionTests.swift rename to Where/RegionKit/Tests/RegionTests.swift index ad754652..c01ec3f2 100644 --- a/Where/WhereCore/Tests/RegionTests.swift +++ b/Where/RegionKit/Tests/RegionTests.swift @@ -1,5 +1,5 @@ +import RegionKit import Testing -import WhereCore struct RegionTests { // MARK: - localizedName diff --git a/Where/WhereCore/Sources/DailySummaryReconciler.swift b/Where/WhereCore/Sources/DailySummaryReconciler.swift index 0cd1220a..51aaf24b 100644 --- a/Where/WhereCore/Sources/DailySummaryReconciler.swift +++ b/Where/WhereCore/Sources/DailySummaryReconciler.swift @@ -1,5 +1,6 @@ import Foundation import LogKit +import RegionKit /// Owns the daily summary recap intent and the reconciliation that recomputes /// the year-to-date recap text and pushes it to the summary scheduler. diff --git a/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift b/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift index e83168a0..3921f3ae 100644 --- a/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift +++ b/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Detects days carrying an `.other` attribution whose GPS coordinates actually /// sit just outside a primary region's border — likely GPS jitter near a diff --git a/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift b/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift index 7f74e967..0c97b516 100644 --- a/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift +++ b/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit public struct BorderDriftIssue: DataIssue, Hashable { public let day: DayPresence diff --git a/Where/WhereCore/Sources/DataResolution/DataIssue.swift b/Where/WhereCore/Sources/DataResolution/DataIssue.swift index 2bfff7aa..85ed6cfe 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssue.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssue.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit public enum DataIssueCategory: Sendable, Hashable, CaseIterable { case missingDays diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift index 452e6d27..bdb3cb5e 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Type-erased entry point so a heterogeneous detector list runs uniformly. public protocol DataIssueDetecting: Sendable { diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index 54c5d647..e50b7124 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Reads the persisted year + dismissals, runs the pure detectors, and returns /// the sorted, not-yet-dismissed issues — throttling repeat scans of the same diff --git a/Where/WhereCore/Sources/DayAggregator.swift b/Where/WhereCore/Sources/DayAggregator.swift index 3959d7f6..2c4ad800 100644 --- a/Where/WhereCore/Sources/DayAggregator.swift +++ b/Where/WhereCore/Sources/DayAggregator.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Pure rules for turning `LocationSample`s and manual day entries into /// `DayPresence` values and `YearReport`s. No I/O. diff --git a/Where/WhereCore/Sources/DayJournal.swift b/Where/WhereCore/Sources/DayJournal.swift index 4bacd529..fa3e34eb 100644 --- a/Where/WhereCore/Sources/DayJournal.swift +++ b/Where/WhereCore/Sources/DayJournal.swift @@ -1,5 +1,6 @@ import Foundation import LogKit +import RegionKit /// Owns the user-sourced writes into the store — sample ingestion, manual-day /// overlays, range backfills, year/all clears, evidence, and data-resolution diff --git a/Where/WhereCore/Sources/DayPresence.swift b/Where/WhereCore/Sources/DayPresence.swift index 3d6e3ba9..66407878 100644 --- a/Where/WhereCore/Sources/DayPresence.swift +++ b/Where/WhereCore/Sources/DayPresence.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// The set of regions the user was in on a particular calendar day. /// diff --git a/Where/WhereCore/Sources/Evidence/Evidence.swift b/Where/WhereCore/Sources/Evidence/Evidence.swift index b213ab94..5f4e269c 100644 --- a/Where/WhereCore/Sources/Evidence/Evidence.swift +++ b/Where/WhereCore/Sources/Evidence/Evidence.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// What kind of evidence is attached (a boarding pass, hotel receipt, etc.). /// Used to render an appropriate icon in the UI and to bucket evidence in diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index 66c42df5..b22103bb 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -1,5 +1,6 @@ import CoreLocation import Foundation +import RegionKit /// `LocationSource` driven by `CLLocationManager` using the two low-power /// signals appropriate for "what state am I in today" tracking: diff --git a/Where/WhereCore/Sources/LocationSample.swift b/Where/WhereCore/Sources/LocationSample.swift index e9e8dd84..9240c0a2 100644 --- a/Where/WhereCore/Sources/LocationSample.swift +++ b/Where/WhereCore/Sources/LocationSample.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Where each `LocationSample` came from. Recorded so reports can distinguish /// passive GPS data from user-asserted history. diff --git a/Where/WhereCore/Sources/ManualEntryAudit.swift b/Where/WhereCore/Sources/ManualEntryAudit.swift index a8760db0..2d1780b7 100644 --- a/Where/WhereCore/Sources/ManualEntryAudit.swift +++ b/Where/WhereCore/Sources/ManualEntryAudit.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Audit metadata attached to a user-made manual day entry (a backfill or an /// authoritative override), retained so a residency/day-count audit can later diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 7ff0403a..93f7fedc 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -1,5 +1,6 @@ import Foundation import LogKit +import RegionKit import SwiftData /// CloudKit-synced `WhereStore` backed by SwiftData. The `@Model` types are diff --git a/Where/WhereCore/Sources/PresenceCalendar.swift b/Where/WhereCore/Sources/PresenceCalendar.swift index e914fdbc..398a53f5 100644 --- a/Where/WhereCore/Sources/PresenceCalendar.swift +++ b/Where/WhereCore/Sources/PresenceCalendar.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit public enum PresenceCalendarError: Error, Equatable { case missingWeekdayRange diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift index 73fd5442..35662601 100644 --- a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift +++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift @@ -1,5 +1,6 @@ import Foundation import LogKit +import RegionKit /// One attributed reading in a recent-activity window: when the device was /// somewhere, which tracked region that coordinate fell in, and the raw diff --git a/Where/WhereCore/Sources/RegionDayLocations.swift b/Where/WhereCore/Sources/RegionDayLocations.swift index 20994322..526dc57b 100644 --- a/Where/WhereCore/Sources/RegionDayLocations.swift +++ b/Where/WhereCore/Sources/RegionDayLocations.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// One recorded point inside a region: its coordinate plus the originating /// fix's horizontal accuracy in meters, so the UI can draw a GPS uncertainty diff --git a/Where/WhereCore/Sources/ReportReader.swift b/Where/WhereCore/Sources/ReportReader.swift index ed95dc92..403b4d9c 100644 --- a/Where/WhereCore/Sources/ReportReader.swift +++ b/Where/WhereCore/Sources/ReportReader.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// The pure read path over a `WhereStore`: turns persisted samples + manual /// days into the `YearReport` and location projections the UI, reminders, and diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index e229cb07..fb580873 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -23,61 +23,6 @@ } } }, - "region.california" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "California" - } - } - } - }, - "region.canada" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Canada" - } - } - } - }, - "region.europeanUnion" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "European Union" - } - } - } - }, - "region.newYork" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New York" - } - } - } - }, - "region.other" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Other" - } - } - } - }, "reminder.notification.body" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereCore/Sources/WhereLog.swift b/Where/WhereCore/Sources/WhereLog.swift index fe3d2f56..6ac01392 100644 --- a/Where/WhereCore/Sources/WhereLog.swift +++ b/Where/WhereCore/Sources/WhereLog.swift @@ -30,7 +30,6 @@ public enum WhereLog { case loggingReminderScheduler = "LoggingReminderScheduler" case model = "WhereModel" case recentActivitySummarizer = "RecentActivitySummarizer" - case regionAttributor = "RegionAttributor" case reminderReconciler = "ReminderReconciler" case session = "WhereSession" case swiftDataStore = "SwiftDataStore" diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 9491f9c5..499c084b 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import SwiftData /// The Where feature's service layer: a small `Sendable` container of the diff --git a/Where/WhereCore/Sources/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/WidgetSnapshotPublisher.swift index 0465034b..01a713fc 100644 --- a/Where/WhereCore/Sources/WidgetSnapshotPublisher.swift +++ b/Where/WhereCore/Sources/WidgetSnapshotPublisher.swift @@ -1,5 +1,6 @@ import Foundation import LogKit +import RegionKit /// Owns the published widget snapshot and the policy for when to rebuild it. /// diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index 37066ed4..6749e776 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Everything the Where widgets render, captured as one `Sendable` value: /// which regions the snapshot's day already counts for, plus the per-region diff --git a/Where/WhereCore/Sources/YearReport.swift b/Where/WhereCore/Sources/YearReport.swift index 9a7f096a..5994d957 100644 --- a/Where/WhereCore/Sources/YearReport.swift +++ b/Where/WhereCore/Sources/YearReport.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Aggregated presence for a whole year. Days are sorted ascending by date /// on init so callers (and the per-month rollup test helpers) can rely on a diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 31b9f1d1..f0948041 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index da6c4e50..92ea8449 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/BorderDriftDetectorTests.swift b/Where/WhereCore/Tests/BorderDriftDetectorTests.swift index 47002d2a..2c503f14 100644 --- a/Where/WhereCore/Tests/BorderDriftDetectorTests.swift +++ b/Where/WhereCore/Tests/BorderDriftDetectorTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift index ea94872a..e4745460 100644 --- a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift +++ b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import WhereCore /// Shared fixtures for the per-detector test files (`MissingDaysDetectorTests`, diff --git a/Where/WhereCore/Tests/DayAggregatorTests.swift b/Where/WhereCore/Tests/DayAggregatorTests.swift index 3d8a31d3..06b2994f 100644 --- a/Where/WhereCore/Tests/DayAggregatorTests.swift +++ b/Where/WhereCore/Tests/DayAggregatorTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index 13ce9795..1b4b3535 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index afe17fe3..ca70f99a 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @_spi(Testing) @testable import WhereCore diff --git a/Where/WhereCore/Tests/LocationOutboxTests.swift b/Where/WhereCore/Tests/LocationOutboxTests.swift index 8f0cbb4f..9fa82723 100644 --- a/Where/WhereCore/Tests/LocationOutboxTests.swift +++ b/Where/WhereCore/Tests/LocationOutboxTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/NewYorkHeavyYearTests.swift b/Where/WhereCore/Tests/NewYorkHeavyYearTests.swift index c5d92ac2..eb72aac9 100644 --- a/Where/WhereCore/Tests/NewYorkHeavyYearTests.swift +++ b/Where/WhereCore/Tests/NewYorkHeavyYearTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift b/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift index 1c210a10..e8152f9f 100644 --- a/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift +++ b/Where/WhereCore/Tests/RecentActivitySummarizerTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/ReminderReconcilerTests.swift b/Where/WhereCore/Tests/ReminderReconcilerTests.swift index 99ff2ea9..80b56132 100644 --- a/Where/WhereCore/Tests/ReminderReconcilerTests.swift +++ b/Where/WhereCore/Tests/ReminderReconcilerTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/ReportReaderTests.swift b/Where/WhereCore/Tests/ReportReaderTests.swift index 094cc041..98b1d59a 100644 --- a/Where/WhereCore/Tests/ReportReaderTests.swift +++ b/Where/WhereCore/Tests/ReportReaderTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/SimulatedYear.swift b/Where/WhereCore/Tests/SimulatedYear.swift index ea9cf894..5b9114a5 100644 --- a/Where/WhereCore/Tests/SimulatedYear.swift +++ b/Where/WhereCore/Tests/SimulatedYear.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import WhereCore /// Scripts a year-long sequence of `LocationSample`s, manual day entries, diff --git a/Where/WhereCore/Tests/SimulatedYearTests.swift b/Where/WhereCore/Tests/SimulatedYearTests.swift index 7d27ad89..cff44a26 100644 --- a/Where/WhereCore/Tests/SimulatedYearTests.swift +++ b/Where/WhereCore/Tests/SimulatedYearTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 1bd13453..af24b25d 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @_spi(Testing) @testable import WhereCore diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index 708bd50d..255b729a 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/WhereLogTests.swift b/Where/WhereCore/Tests/WhereLogTests.swift index e7446493..9494e961 100644 --- a/Where/WhereCore/Tests/WhereLogTests.swift +++ b/Where/WhereCore/Tests/WhereLogTests.swift @@ -24,7 +24,7 @@ func categoryRawValuesMatchTypeNames() { #expect(WhereLog.Category.swiftDataStore.rawValue == "SwiftDataStore") #expect(WhereLog.Category.widgetRefresher.rawValue == "WidgetRefresher") #expect(WhereLog.Category.recentActivitySummarizer.rawValue == "RecentActivitySummarizer") - #expect(WhereLog.Category.allCases.count == 18) + #expect(WhereLog.Category.allCases.count == 17) } @Test diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index 9f51bb75..defd37d4 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/WidgetDataReaderTests.swift b/Where/WhereCore/Tests/WidgetDataReaderTests.swift index bb04e0a7..ca54796d 100644 --- a/Where/WhereCore/Tests/WidgetDataReaderTests.swift +++ b/Where/WhereCore/Tests/WidgetDataReaderTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift index d34e842f..c304a625 100644 --- a/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift +++ b/Where/WhereCore/Tests/WidgetSnapshotPublisherTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore diff --git a/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift b/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift index e680f00d..5cb502ae 100644 --- a/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift +++ b/Where/WhereCore/Tests/WidgetSnapshotStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index d08e19e7..ad283088 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -1,5 +1,6 @@ import LogKit import MapKit +import RegionKit import SwiftUI import WhereCore @@ -148,7 +149,7 @@ public struct RegionMapView: View { // Keep the failure observable in both the UI (the `.failure` // state renders an error) and the logs, rather than silently // showing an empty map. - WhereLog.channel(.regionAttributor) + RegionLog.channel(.geometryCatalog) .warning("Region map viewer failed to load \(kind.rawValue) geometry: \(error)") outlines = .failure(error) } diff --git a/Where/WhereUI/Sources/Model/PresenceTimeline.swift b/Where/WhereUI/Sources/Model/PresenceTimeline.swift index 23c975b4..f6085764 100644 --- a/Where/WhereUI/Sources/Model/PresenceTimeline.swift +++ b/Where/WhereUI/Sources/Model/PresenceTimeline.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import WhereCore /// A maximal run of consecutive calendar days the user was present in one diff --git a/Where/WhereUI/Sources/Model/RegionRanking.swift b/Where/WhereUI/Sources/Model/RegionRanking.swift index 78cdf481..87f9e8dc 100644 --- a/Where/WhereUI/Sources/Model/RegionRanking.swift +++ b/Where/WhereUI/Sources/Model/RegionRanking.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import WhereCore /// A region paired with the number of calendar days the user was present in diff --git a/Where/WhereUI/Sources/Model/RegionStyle.swift b/Where/WhereUI/Sources/Model/RegionStyle.swift index f9a3079f..b0bd3fe4 100644 --- a/Where/WhereUI/Sources/Model/RegionStyle.swift +++ b/Where/WhereUI/Sources/Model/RegionStyle.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 6eb32987..3e8764fe 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -1,6 +1,7 @@ import Foundation import LogKit import Observation +import RegionKit import WhereCore /// The scene-scoped presentation model for the selected year: the loaded diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index ea90a52c..224222ba 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -1,5 +1,6 @@ #if DEBUG import Foundation + import RegionKit import WhereCore /// Preview/test fixtures for `WhereUI`. Provides a synchronous sample diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index daea6457..ec4302ab 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 9aac274a..fbb64d17 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index dd02a10d..bf694233 100644 --- a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift +++ b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index 378a6acf..f58f160d 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index 2747bb53..95f4f794 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -1,6 +1,7 @@ import Foundation import LogKit import Observation +import RegionKit import WhereCore /// View-scoped model for the Resolve tab: the full list of unresolved diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index 27c2f74c..c2429dde 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index 681a5e04..710bfc0f 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -1,4 +1,5 @@ import MapKit +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 634bbe5b..fe69c857 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift b/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift index 010ac4f9..be954345 100644 --- a/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift +++ b/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift @@ -1,4 +1,5 @@ import CoreLocation +import RegionKit import WhereCore extension Coordinate { diff --git a/Where/WhereUI/Sources/Shared/LocationNamer.swift b/Where/WhereUI/Sources/Shared/LocationNamer.swift index dfbb5320..5855975b 100644 --- a/Where/WhereUI/Sources/Shared/LocationNamer.swift +++ b/Where/WhereUI/Sources/Shared/LocationNamer.swift @@ -1,6 +1,7 @@ import CoreLocation import Foundation import MapKit +import RegionKit import WhereCore /// The human-readable pieces of a reverse-geocoded coordinate, and the rule diff --git a/Where/WhereUI/Sources/Shared/RegionSelectionState.swift b/Where/WhereUI/Sources/Shared/RegionSelectionState.swift index 122d4711..5e0cf5c5 100644 --- a/Where/WhereUI/Sources/Shared/RegionSelectionState.swift +++ b/Where/WhereUI/Sources/Shared/RegionSelectionState.swift @@ -1,4 +1,5 @@ import Observation +import RegionKit import WhereCore /// One bindable toggle row in a region-selection form. diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index e9c50b34..ffb1b578 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import WhereCore /// Localized, catalog-backed strings for WhereUI. diff --git a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift index 7fd96edd..70793e63 100644 --- a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift +++ b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore import WidgetKit diff --git a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift index ed906ebf..4fe6555f 100644 --- a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import WhereCore diff --git a/Where/WhereUI/Sources/Widgets/WidgetSnapshot+Ranking.swift b/Where/WhereUI/Sources/Widgets/WidgetSnapshot+Ranking.swift index ff9c7390..cf94bcbe 100644 --- a/Where/WhereUI/Sources/Widgets/WidgetSnapshot+Ranking.swift +++ b/Where/WhereUI/Sources/Widgets/WidgetSnapshot+Ranking.swift @@ -1,3 +1,4 @@ +import RegionKit import WhereCore extension WidgetSnapshot { diff --git a/Where/WhereUI/Tests/RecentActivityModelTests.swift b/Where/WhereUI/Tests/RecentActivityModelTests.swift index 5f9753e3..7b1cadb5 100644 --- a/Where/WhereUI/Tests/RecentActivityModelTests.swift +++ b/Where/WhereUI/Tests/RecentActivityModelTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing import WhereCore @testable import WhereUI diff --git a/Where/WhereUI/Tests/RegionRankingTests.swift b/Where/WhereUI/Tests/RegionRankingTests.swift index a160770a..67b6239c 100644 --- a/Where/WhereUI/Tests/RegionRankingTests.swift +++ b/Where/WhereUI/Tests/RegionRankingTests.swift @@ -1,3 +1,4 @@ +import RegionKit import Testing import WhereCore import WhereUI diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 85d4d5d7..c1d77cf1 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -1,4 +1,5 @@ import LogViewerUI +import RegionKit import SwiftUI import Testing import WhereCore diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift index 283d2508..304b5458 100644 --- a/Where/WhereUI/Tests/StringsTests.swift +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -1,3 +1,4 @@ +import RegionKit import Testing import WhereCore @testable import WhereUI diff --git a/Where/WhereUI/Tests/WidgetViewsTests.swift b/Where/WhereUI/Tests/WidgetViewsTests.swift index a017eb07..958c7b3b 100644 --- a/Where/WhereUI/Tests/WidgetViewsTests.swift +++ b/Where/WhereUI/Tests/WidgetViewsTests.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import Testing import WhereCore diff --git a/Where/WhereWidgets/Sources/WidgetPreviewFixtures.swift b/Where/WhereWidgets/Sources/WidgetPreviewFixtures.swift index 77f0fa0a..33adbc2c 100644 --- a/Where/WhereWidgets/Sources/WidgetPreviewFixtures.swift +++ b/Where/WhereWidgets/Sources/WidgetPreviewFixtures.swift @@ -1,5 +1,6 @@ #if DEBUG import Foundation + import RegionKit import WhereCore /// Sample timeline entries for the widget `#Preview`s (DEBUG-only). diff --git a/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift b/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift index be3c3ae0..59ea9f05 100644 --- a/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift +++ b/Where/WhereWidgets/Sources/WidgetSnapshotFixtures.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import WhereCore /// Shared calendar and snapshot builders for the widget extension. Keeps From 1c8bf8732e43ee938fdbd4f0a9d29c060dcb602b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 15:10:57 -0400 Subject: [PATCH 3/6] Docs: document RegionKit module and its wiring Update root AGENTS (libraries, package products, hosted test bundles), Where/AGENTS (module list, RegionKit/WhereCore layering, localization split, new-region steps now pointing at RegionKit), and the RegionViewer + WhereWidgets README/AGENTS to reflect the new RegionKit dependency. RegionKit's own README/AGENTS shipped with the scaffold commit. Closes plan to-do: docs-verify. --- AGENTS.md | 6 +++--- Where/AGENTS.md | 25 +++++++++++++++++-------- Where/RegionViewer/AGENTS.md | 7 ++++--- Where/RegionViewer/README.md | 4 ++-- Where/WhereWidgets/AGENTS.md | 2 +- Where/WhereWidgets/README.md | 5 +++-- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50628b90..22149d65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ | SwiftFormat | 0.60.1 | `.mise.toml` | | Swift PM | 6.2 | `Package.swift` (`swift-tools-version`) | -**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles. +**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **RegionKit**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles. Tuist manifests live at the repo root ([`Project.swift`](Project.swift), [`Tuist.swift`](Tuist.swift)). `Project.swift` references `Package.local(path: .relativeToRoot("."))` and declares the **Where** app, **StuffTestHost**, and unit-test targets that depend on package products. @@ -53,8 +53,8 @@ by `./sync-agents`. ## Targets -- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/). -- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**. +- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **RegionKit** ([`Where/RegionKit/Sources/`](Where/RegionKit/Sources/), the geometry + GeoJSON + region-lookup engine WhereCore builds on) / **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/). +- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **RegionKitTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper; macOS test bundles are declared directly, like `ForemanCoreTests`). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI schemes**: the workspace mixes iOS and macOS targets, which no single xcodebuild destination can build, so CI runs two explicit shared schemes — **Stuff-iOS-Tests** (all iOS bundles) and **Foreman-macOS-Tests** (Foreman app + ForemanCoreTests). New iOS test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. diff --git a/Where/AGENTS.md b/Where/AGENTS.md index f3d794ab..d25a3111 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -17,6 +17,7 @@ system, formatting, and global conventions. Read that first. ``` Where/ Where/ App target – SwiftUI entry point (WhereApp → RootView) + RegionKit/ SPM library – geometry, GeoJSON, Region model + lookup (WhereCore depends on it) WhereCore/ SPM library – domain model, persistence, GPS, aggregation WhereUI/ SPM library – SwiftUI views + view models (depends on WhereCore) WhereTesting/ SPM library – iOS test host helpers (show(), waitFor, ...) @@ -27,9 +28,14 @@ Where/ - **App target** `Where` is intentionally tiny: it wires `RootView` from `WhereUI` into a `WindowGroup`. Add domain behavior to `WhereCore`, presentation and view-model wiring to `WhereUI`. +- **`RegionKit`** is the lowest layer: the `Region` model, coordinate geometry + (`Coordinate`, `GeoPolygon`, `BoundingBox`, `LongitudeSpan`), GeoJSON + decoding, and coordinate-to-`Region` lookup (`RegionAttributor`). Pure Swift + + Foundation + LogKit; the bundled region polygons (`Resources/*.geojson`) and + the region-name catalog ship here. See [`RegionKit/AGENTS.md`](RegionKit/AGENTS.md). - **`WhereCore`** is the domain layer: pure Swift + Foundation + SwiftData + - CoreLocation; it must **not** import SwiftUI or UIKit. Bundled region - polygons (`Resources/*.geojson`) ship here. + CoreLocation; it must **not** import SwiftUI or UIKit. It depends on + **`RegionKit`** and calls into it for region lookup. - **`WhereUI`** is the SwiftUI layer: views plus `@Observable` view models — the app-level `WhereModel`, the always-on `WhereSession` coordinator (no presentation state), and its scope-tiered children (scene-scoped @@ -98,8 +104,10 @@ views or thrown errors. - **WhereUI:** funnel every string through `Strings.swift` (keys in the module `Localizable.xcstrings`, `bundle: .module`). Counts use catalog plural variations; years use a grouping-free number style ("2026", not "2,026"). -- **WhereCore:** user-visible errors and region names use static +- **WhereCore:** user-visible errors use static `String(localized:bundle: .module)` keys in its own catalog. +- **RegionKit:** region names (`Region.localizedName`) use static + `String(localized:bundle: .module)` keys in RegionKit's own catalog. - **DEBUG-only UI** still gets catalog entries — don't bypass localization because a surface is dev-only. - **WhereWidgets:** gallery name/description live in the extension's own @@ -150,11 +158,12 @@ manual-entry forms. - **New library target:** add to root [`Package.swift`](../Package.swift) under `Where//Sources`, then wire a hosted test bundle in [`Project.swift`](../Project.swift) via the `unitTests` helper. -- **New region:** add the `Region` case, then resolve the two compile errors - it forces: a `localizedName` catalog entry and a `Region.geometrySource` - case (`.usStateFeature(name:)` or `.bundledFile` with a new - `.geojson` in WhereCore's `Resources/`). Add a - `RegionAttributorTests` spot-check. +- **New region:** add the `Region` case in **`RegionKit`**, then resolve the two + compile errors it forces: a `region.` entry in RegionKit's + `Resources/Localizable.xcstrings` (for `localizedName`) and a + `Region.geometrySource` case (`.usStateFeature(name:)` or `.bundledFile` with a + new `.geojson` in RegionKit's `Resources/`). Add a + `RegionAttributorTests` spot-check (in `RegionKit/Tests`). - **New evidence kind / sample source:** add the case and follow the compile errors through the exhaustive switches. - **New app icon:** run `./icons --add` (see the root diff --git a/Where/RegionViewer/AGENTS.md b/Where/RegionViewer/AGENTS.md index 801d875a..87194e3e 100644 --- a/Where/RegionViewer/AGENTS.md +++ b/Where/RegionViewer/AGENTS.md @@ -10,9 +10,10 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & rules - **Tuist app target** (bundle ID `com.stuff.regionviewer`), depending on - **WhereUI**, **WhereCore** (geometry + GeoJSON, embedded transitively), and - **LogKit**. The `@main` body is `WindowGroup { NavigationStack { - RegionMapView() } }` — that's the whole target. + **WhereUI**, **WhereCore**, **RegionKit** (geometry + GeoJSON, whose resource + bundle is embedded for `RegionGeometryCatalog`), and **LogKit**. The `@main` + body is `WindowGroup { NavigationStack { RegionMapView() } }` — that's the + whole target. - **Shell only, session-less.** No domain logic, SwiftData, App Group, or `WhereSession` here; `RegionMapView` is self-contained on purpose. If a feature needs more, add it in `WhereUI`/`WhereCore`. diff --git a/Where/RegionViewer/README.md b/Where/RegionViewer/README.md index 86a58d9f..2fbd9b00 100644 --- a/Where/RegionViewer/README.md +++ b/Where/RegionViewer/README.md @@ -66,8 +66,8 @@ CI is unaffected — it runs on `macos-26` with the matching Xcode. `RegionViewerApp` is just a `@main App` with a `WindowGroup { NavigationStack { RegionMapView() } }`. It has **no** `WhereSession`, SwiftData store, or App Group — `RegionMapView` reads geometry -from `WhereCore`'s public `RegionGeometryCatalog`, which only needs the bundled -GeoJSON (embedded via the WhereCore dependency). The catalog decodes off the +from `RegionKit`'s public `RegionGeometryCatalog`, which only needs the bundled +GeoJSON (embedded via the RegionKit dependency). The catalog decodes off the main thread, so the heavy source parse never blocks the UI. ## Limitations diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 877515b1..375b1aa8 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -11,7 +11,7 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where.widgets`), depending on **WhereCore**, - **WhereUI**, and **LogKit**. + **WhereUI**, **RegionKit**, and **LogKit**. - Must **not** import SwiftData, open the user's store, or duplicate aggregation logic — the app publishes; the extension only reads and renders. - No test bundle; behavior is covered from **WhereCore** and **WhereUI**. diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 81dd24cc..054c57d3 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -43,8 +43,9 @@ app never wakes. `WhereWidgets` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`). -It depends on **WhereCore**, **WhereUI**, and **LogKit**. The main **Where** -app embeds the extension and shares the App Group entitlement. +It depends on **WhereCore**, **WhereUI**, **RegionKit** (for the `Region` model +its snapshot fixtures use), and **LogKit**. The main **Where** app embeds the +extension and shares the App Group entitlement. ## Previews From f5dc2a6d1b783e3155792aa705addd40c575b404 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 15:19:35 -0400 Subject: [PATCH 4/6] Surface RegionKit logs in the in-app viewer Generalize LogViewerUI to observe multiple LogStores and merge them chronologically (LogViewerConfiguration/LogViewerModel gain a stores: array; the single-store store: init stays as a convenience). The Where Settings log viewer now passes [WhereLog.store, RegionLog.store] so RegionKit's RegionLog entries show alongside the app's in one merged, filterable stream. Adds LogViewerModel merge/clear/live-observe tests across two stores and a multi-store hosting test. Docs updated (LogViewerUI README/AGENTS, Where/AGENTS). --- Shared/LogViewerUI/AGENTS.md | 10 ++- Shared/LogViewerUI/README.md | 44 ++++++---- Shared/LogViewerUI/Sources/LogViewer.swift | 2 +- .../Sources/LogViewerConfiguration.swift | 27 +++++-- .../LogViewerUI/Sources/LogViewerModel.swift | 56 ++++++++++--- .../Tests/LogViewerHostingTests.swift | 29 +++++++ .../Tests/LogViewerModelTests.swift | 80 +++++++++++++++++++ Where/AGENTS.md | 6 +- .../Sources/Settings/SettingsView.swift | 10 ++- 9 files changed, 222 insertions(+), 42 deletions(-) diff --git a/Shared/LogViewerUI/AGENTS.md b/Shared/LogViewerUI/AGENTS.md index 04965d8f..534ee318 100644 --- a/Shared/LogViewerUI/AGENTS.md +++ b/Shared/LogViewerUI/AGENTS.md @@ -1,7 +1,7 @@ # LogViewerUI – Module Shape -LogViewerUI is an app-agnostic SwiftUI **log viewer** over a -[`LogKit`](../LogKit) `LogStore`: hand it a `LogViewerConfiguration` (store, +LogViewerUI is an app-agnostic SwiftUI **log viewer** over one or more +[`LogKit`](../LogKit) `LogStore`s: hand it a `LogViewerConfiguration` (stores, title, category display names) and `LogViewer` renders entries newest-first with filtering, share, copy, and clear. See [`README.md`](README.md) for the narrative and API. @@ -22,7 +22,11 @@ build system, formatting, and global conventions. Read that first. - **Read-only mirror.** Recording lives in `LogKit`; this module only consumes snapshots on the main actor (the one write is `clear()`, which - updates `entries` synchronously so the list empties immediately). + empties every configured store and updates `entries` synchronously so the + list clears immediately). +- **Multiple stores merge by timestamp.** Each store is observed concurrently + and its latest snapshot kept per-store; `entries` is the re-merged, date-sorted + union, so several modules' buffers read as one chronological stream. - **Newest-first for display, oldest-first for export** — a shared/copied log reads chronologically. - **The level filter is driven by `LogLevel.allCases`**, so a new level in diff --git a/Shared/LogViewerUI/README.md b/Shared/LogViewerUI/README.md index 795b763d..031394b2 100644 --- a/Shared/LogViewerUI/README.md +++ b/Shared/LogViewerUI/README.md @@ -1,14 +1,15 @@ # LogViewerUI -A small, app-agnostic SwiftUI **log viewer** over a [`LogKit`](../LogKit) -`LogStore`. Point it at a buffer and it renders captured entries newest-first -with a level badge, category, timestamp, and message, plus live search, -level/category filtering, share, copy, and clear — for *any* `LogStore`, with no -per-app code. +A small, app-agnostic SwiftUI **log viewer** over one or more [`LogKit`](../LogKit) +`LogStore`s. Point it at a buffer (or several) and it renders captured entries +newest-first with a level badge, category, timestamp, and message, plus live +search, level/category filtering, share, copy, and clear — for *any* `LogStore`, +with no per-app code. Multiple buffers are merged chronologically, so a host can +surface several modules' logs (each with its own subsystem/category) in one view. It's built for **developer / DEBUG surfaces** (think a hidden "Logs" row in a Settings screen): it reads whatever the app's loggers wrote into the shared -buffer this session. +buffer(s) this session. LogViewerUI depends only on **SwiftUI + Observation + UIKit (pasteboard) + LogKit** — no app code. @@ -60,6 +61,13 @@ screen. ```swift public struct LogViewerConfiguration: Sendable { + public init( + stores: [LogStore], + title: String = "Logs", + categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, + ) + + /// Convenience for the common single-buffer case. public init( store: LogStore, title: String = "Logs", @@ -68,32 +76,34 @@ public struct LogViewerConfiguration: Sendable { } ``` -- **`store`** — the `LogKit` buffer to read and observe. +- **`stores`** — the `LogKit` buffer(s) to read and observe, merged + chronologically. The `store:` init is a convenience for the single-buffer case. - **`title`** — the viewer's navigation title. - **`categoryDisplayName`** — maps a raw `LogEntry.category` to a friendly name (e.g. an app's typed-category enum → a label). Defaults to identity. ## How it works -`LogViewer` owns a `@MainActor @Observable LogViewerModel` that mirrors the store -into `entries` and derives cached `filteredEntries` (newest-first, after +`LogViewer` owns a `@MainActor @Observable LogViewerModel` that mirrors the +store(s) into `entries` and derives cached `filteredEntries` (newest-first, after level/category/search). Observation starts in the model's `init` and iterates -`LogStore.changes()` until the model is deallocated — so the list stays live -without the view touching the lock-guarded store directly. Recording stays off -the main actor in `LogKit`; this module only consumes snapshots on the main -actor for display. +each store's `LogStore.changes()` concurrently (in a task group) until the model +is deallocated, remerging the per-store snapshots by timestamp on every change — +so the list stays live without the view touching the lock-guarded stores +directly. Recording stays off the main actor in `LogKit`; this module only +consumes snapshots on the main actor for display. ## Example: adopting it in an app (Where) -The Where app exposes it behind a DEBUG-only entry in Settings, pointed at the -process-wide buffer its `WhereLog` facade writes to, mapping raw categories -through its typed enum: +The Where app exposes it behind a DEBUG-only entry in Settings, pointed at both +process-wide buffers — `WhereLog` (the app/WhereCore facade) and `RegionLog` +(RegionKit) — merged into one list: ```swift #if DEBUG NavigationLink { LogViewer(configuration: LogViewerConfiguration( - store: WhereLog.store, + stores: [WhereLog.store, RegionLog.store], title: Strings.settingsDebugLogsTitle, )) } label: { diff --git a/Shared/LogViewerUI/Sources/LogViewer.swift b/Shared/LogViewerUI/Sources/LogViewer.swift index 0d23f255..21dd0c56 100644 --- a/Shared/LogViewerUI/Sources/LogViewer.swift +++ b/Shared/LogViewerUI/Sources/LogViewer.swift @@ -16,7 +16,7 @@ public struct LogViewer: View { public init(configuration: LogViewerConfiguration) { self.configuration = configuration _model = State(initialValue: LogViewerModel( - store: configuration.store, + stores: configuration.stores, categoryDisplayName: configuration.categoryDisplayName, )) } diff --git a/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift b/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift index ff1e7271..09e2c9ec 100644 --- a/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift +++ b/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift @@ -2,11 +2,17 @@ import LogKit import SwiftUI /// Host-supplied configuration for ``LogViewer``. Keeps the viewer generic: the -/// host points it at a ``LogStore`` and supplies display strings (e.g. a title -/// and a mapping from raw category identifiers to human-readable names). +/// host points it at one or more ``LogStore``s and supplies display strings +/// (e.g. a title and a mapping from raw category identifiers to human-readable +/// names). +/// +/// Multiple stores let a host surface buffers from several modules (each with +/// its own subsystem/category) in one viewer; entries are merged +/// chronologically. Each `LogEntry` carries its `subsystem`/`category`, so the +/// category filter still tells them apart. public struct LogViewerConfiguration: Sendable { - /// The buffer to read and observe. - public var store: LogStore + /// The buffers to read and observe, merged chronologically for display. + public var stores: [LogStore] /// Navigation title for the viewer. public var title: String @@ -15,12 +21,21 @@ public struct LogViewerConfiguration: Sendable { public var categoryDisplayName: @Sendable (String) -> String public init( - store: LogStore, + stores: [LogStore], title: String = "Logs", categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, ) { - self.store = store + self.stores = stores self.title = title self.categoryDisplayName = categoryDisplayName } + + /// Convenience for the common single-buffer case. + public init( + store: LogStore, + title: String = "Logs", + categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, + ) { + self.init(stores: [store], title: title, categoryDisplayName: categoryDisplayName) + } } diff --git a/Shared/LogViewerUI/Sources/LogViewerModel.swift b/Shared/LogViewerUI/Sources/LogViewerModel.swift index d6759fc8..9b0571c6 100644 --- a/Shared/LogViewerUI/Sources/LogViewerModel.swift +++ b/Shared/LogViewerUI/Sources/LogViewerModel.swift @@ -24,10 +24,14 @@ private final class ObservationHandle: @unchecked Sendable { @MainActor @Observable final class LogViewerModel { - private let store: LogStore + private let stores: [LogStore] private let categoryDisplayName: @Sendable (String) -> String @ObservationIgnored private let observation = ObservationHandle() + /// The most recent snapshot from each store, kept in `stores` order so a + /// change to one store re-merges without re-reading the others. + @ObservationIgnored private var latestSnapshots: [[LogEntry]] + private(set) var entries: [LogEntry] private var cachedCategories: [String]? @@ -47,29 +51,58 @@ final class LogViewerModel { } init( - store: LogStore, + stores: [LogStore], categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, ) { - self.store = store + self.stores = stores self.categoryDisplayName = categoryDisplayName - entries = store.snapshot() + latestSnapshots = stores.map { $0.snapshot() } + entries = Self.merged(latestSnapshots) observation.start { [weak self] in await self?.observe() } } + /// Convenience for the common single-buffer case. + convenience init( + store: LogStore, + categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, + ) { + self.init(stores: [store], categoryDisplayName: categoryDisplayName) + } + deinit { observation.cancel() } - /// Observe the store until this task is cancelled. + /// Observe every store concurrently until this task is cancelled, remerging + /// whenever one of them changes. func observe() async { - for await snapshot in store.changes() { - entries = snapshot - invalidateEntryCache() + await withTaskGroup(of: Void.self) { group in + for index in stores.indices { + let store = stores[index] + group.addTask { [weak self] in + for await snapshot in store.changes() { + await self?.apply(snapshot, at: index) + } + } + } } } + private func apply(_ snapshot: [LogEntry], at index: Int) { + latestSnapshots[index] = snapshot + entries = Self.merged(latestSnapshots) + invalidateEntryCache() + } + + /// Flatten every store's snapshot into one oldest-first list. Each store's + /// snapshot is already chronological; sorting by `date` interleaves them. + private static func merged(_ snapshots: [[LogEntry]]) -> [LogEntry] { + guard snapshots.count > 1 else { return snapshots.first ?? [] } + return snapshots.flatMap(\.self).sorted { $0.date < $1.date } + } + /// Distinct categories present in the buffer, sorted for a stable filter. var categories: [String] { if let cachedCategories { @@ -107,8 +140,11 @@ final class LogViewerModel { } func clear() { - store.clear() - entries = store.snapshot() + for store in stores { + store.clear() + } + latestSnapshots = stores.map { $0.snapshot() } + entries = Self.merged(latestSnapshots) invalidateEntryCache() } diff --git a/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift b/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift index 68a02042..70e67251 100644 --- a/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift +++ b/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift @@ -40,4 +40,33 @@ struct LogViewerHostingTests { try waitFor { isViewHosted(controller) } } } + + @Test func viewerHostsWithMultipleStores() throws { + // Mirrors the Settings entry, which merges WhereLog + RegionLog buffers. + let appStore = LogStore() + appStore.record(LogEntry( + level: .info, + subsystem: "app", + category: "Session", + message: "hi", + )) + let regionStore = LogStore() + regionStore.record(LogEntry( + level: .error, + subsystem: "region", + category: "RegionAttributor", + message: "boom", + )) + + let rootView = NavigationStack { + LogViewer(configuration: LogViewerConfiguration( + stores: [appStore, regionStore], + title: "Logs", + )) + } + let hosted = UIHostingController(rootView: rootView) + try show(hosted) { controller in + try waitFor { isViewHosted(controller) } + } + } } diff --git a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift b/Shared/LogViewerUI/Tests/LogViewerModelTests.swift index 63be6d9f..0ea5b743 100644 --- a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift +++ b/Shared/LogViewerUI/Tests/LogViewerModelTests.swift @@ -140,4 +140,84 @@ struct LogViewerModelTests { #expect(model.entries.map(\.message) == ["live"]) } + + // MARK: - Multiple stores + + @Test + func mergesMultipleStoresChronologically() { + let base = Date(timeIntervalSince1970: 1000) + let appStore = LogStore() + appStore.record(LogEntry( + date: base, + level: .info, + subsystem: "app", + category: "Session", + message: "app 1", + )) + appStore.record(LogEntry( + date: base.addingTimeInterval(2), + level: .info, + subsystem: "app", + category: "Session", + message: "app 2", + )) + let regionStore = LogStore() + regionStore.record(LogEntry( + date: base.addingTimeInterval(1), + level: .info, + subsystem: "region", + category: "RegionAttributor", + message: "region 1", + )) + + let model = LogViewerModel(stores: [appStore, regionStore]) + // Entries interleave by date (oldest-first); display reverses to newest-first. + #expect(model.entries.map(\.message) == ["app 1", "region 1", "app 2"]) + #expect(model.filteredEntries.map(\.message) == ["app 2", "region 1", "app 1"]) + // Categories from every store show up in the filter. + #expect(model.categories == ["RegionAttributor", "Session"]) + } + + @Test + func clearEmptiesEveryStore() { + let appStore = LogStore() + appStore.record(LogEntry(level: .info, subsystem: "app", category: "C", message: "a")) + let regionStore = LogStore() + regionStore.record(LogEntry(level: .info, subsystem: "region", category: "C", message: "b")) + + let model = LogViewerModel(stores: [appStore, regionStore]) + #expect(model.entries.count == 2) + + model.clear() + #expect(model.isEmpty) + #expect(appStore.snapshot().isEmpty) + #expect(regionStore.snapshot().isEmpty) + } + + @Test + func observeReflectsUpdatesFromEveryStore() async { + let appStore = LogStore() + let regionStore = LogStore() + let model = LogViewerModel(stores: [appStore, regionStore]) + + appStore.record(LogEntry( + level: .info, + subsystem: "app", + category: "C", + message: "from app", + )) + regionStore.record(LogEntry( + level: .info, + subsystem: "region", + category: "C", + message: "from region", + )) + + let deadline = Date(timeIntervalSinceNow: 1) + while model.entries.count < 2, Date() < deadline { + await Task.yield() + } + + #expect(Set(model.entries.map(\.message)) == ["from app", "from region"]) + } } diff --git a/Where/AGENTS.md b/Where/AGENTS.md index d25a3111..74f52775 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -76,7 +76,11 @@ Rules the code enforces and agents must preserve: `WhereLog.Category` case, never a raw string. Messages log as `.public`, so keep PII out. `info` = success of an important operation, `warning` = degraded-but-handled, `error`/`fault` = outright failure; hot paths - (per-sample persist, widget throttle) stay quiet by design. + (per-sample persist, widget throttle) stay quiet by design. **RegionKit** logs + through its own `RegionLog` facade (subsystem `com.stuff.regionkit`, separate + store) since it can't see `WhereLog`; the DEBUG Settings log viewer is + configured with **both** buffers (`[WhereLog.store, RegionLog.store]`) so it + shows a single merged stream. - **Location comes through the `LocationSource` protocol** — production is `CoreLocationSource`; tests and previews use `ScriptedLocationSource`. Besides the passive `sampleStream`, it offers a best-effort one-shot diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 34911518..8f8b312f 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import LifecycleKit import LogViewerUI +import RegionKit #if DEBUG import SwiftDataInspector #endif @@ -424,14 +425,15 @@ struct SettingsView: View { #if DEBUG /// Developer-only tools, compiled out of release: the in-app log viewer - /// over the shared `WhereLog` buffer every logger writes to, plus — when - /// the live session can vend a SwiftData container — the generic - /// SwiftData inspector (previews and non-SwiftData fakes don't show it). + /// over both process buffers — `WhereLog` (the app/WhereCore facade) and + /// `RegionLog` (RegionKit) — merged chronologically, plus — when the live + /// session can vend a SwiftData container — the generic SwiftData + /// inspector (previews and non-SwiftData fakes don't show it). private var developerSection: some View { Section { NavigationLink { LogViewer(configuration: LogViewerConfiguration( - store: WhereLog.store, + stores: [WhereLog.store, RegionLog.store], title: Strings.settingsDebugLogsTitle, )) } label: { From 4ca896d5d3fd68545ce90d081f3fbe8358ea23e2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 15:39:01 -0400 Subject: [PATCH 5/6] Make LogViewerModel deinit while observing; doc nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogViewerModel observed its stores by calling an instance method (await self?.observe()), which keeps self alive for the streams' whole lifetime — so the model (and its observation task) never deinit while the viewer's stores stay quiet. Mirror YearReportModel instead: spawn one task per store that iterates the stream with a per-iteration 'guard let self else { break }' and holds only a weak reference between log lines, so deinit runs and cancels every task. ObservationHandle now tracks/cancels multiple tasks. Adds a deinitsWhileObservingStores test, and fixes doc nits (LogViewerModel type comment now reflects multiple stores; RegionKit's Region+Ordering doc no longer names higher-layer types). --- .../LogViewerUI/Sources/LogViewerModel.swift | 46 +++++++++---------- .../Tests/LogViewerModelTests.swift | 15 ++++++ Where/RegionKit/Sources/Region+Ordering.swift | 15 +++--- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/Shared/LogViewerUI/Sources/LogViewerModel.swift b/Shared/LogViewerUI/Sources/LogViewerModel.swift index 9b0571c6..732a9b14 100644 --- a/Shared/LogViewerUI/Sources/LogViewerModel.swift +++ b/Shared/LogViewerUI/Sources/LogViewerModel.swift @@ -7,20 +7,23 @@ private let exportTimestampFormatter = Date.ISO8601FormatStyle( ) private final class ObservationHandle: @unchecked Sendable { - private var task: Task? + private var tasks: [Task] = [] func start(_ operation: @escaping @MainActor () async -> Void) { - task = Task { await operation() } + tasks.append(Task { await operation() }) } func cancel() { - task?.cancel() + for task in tasks { + task.cancel() + } } } -/// Drives ``LogViewer``: mirrors a ``LogStore`` into observable state and -/// applies the active filters. Recording happens off the main actor in the -/// store; this model only consumes snapshots on the main actor for display. +/// Drives ``LogViewer``: mirrors one or more ``LogStore``s into observable +/// state (merged chronologically) and applies the active filters. Recording +/// happens off the main actor in the store(s); this model only consumes +/// snapshots on the main actor for display. @MainActor @Observable final class LogViewerModel { @@ -58,8 +61,20 @@ final class LogViewerModel { self.categoryDisplayName = categoryDisplayName latestSnapshots = stores.map { $0.snapshot() } entries = Self.merged(latestSnapshots) - observation.start { [weak self] in - await self?.observe() + // Observe each store on its own task. Each loop re-promotes `self` per + // iteration (`guard let self else { break }`), so between log lines the + // tasks hold only a weak reference: the model can deinit while parked in + // `for await`, and `deinit` then cancels every task. (An instance + // `observe()` call would instead keep `self` alive for the streams' + // whole lifetime — see `YearReportModel.observeDataChanges()`.) + for index in stores.indices { + let store = stores[index] + observation.start { [weak self] in + for await snapshot in store.changes() { + guard let self else { break } + apply(snapshot, at: index) + } + } } } @@ -75,21 +90,6 @@ final class LogViewerModel { observation.cancel() } - /// Observe every store concurrently until this task is cancelled, remerging - /// whenever one of them changes. - func observe() async { - await withTaskGroup(of: Void.self) { group in - for index in stores.indices { - let store = stores[index] - group.addTask { [weak self] in - for await snapshot in store.changes() { - await self?.apply(snapshot, at: index) - } - } - } - } - } - private func apply(_ snapshot: [LogEntry], at index: Int) { latestSnapshots[index] = snapshot entries = Self.merged(latestSnapshots) diff --git a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift b/Shared/LogViewerUI/Tests/LogViewerModelTests.swift index 0ea5b743..3abd920e 100644 --- a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift +++ b/Shared/LogViewerUI/Tests/LogViewerModelTests.swift @@ -194,6 +194,21 @@ struct LogViewerModelTests { #expect(regionStore.snapshot().isEmpty) } + /// Guards against a retain cycle through the long-lived observation tasks: + /// they capture `[weak self]` and `deinit` cancels them, so dropping the last + /// strong reference deallocates the model even while the tasks are parked in + /// `for await` (quiet stores emit nothing on their own). + @Test + func deinitsWhileObservingStores() { + weak var weakModel: LogViewerModel? + do { + let model = LogViewerModel(stores: [LogStore(), LogStore()]) + weakModel = model + #expect(weakModel != nil) + } + #expect(weakModel == nil) + } + @Test func observeReflectsUpdatesFromEveryStore() async { let appStore = LogStore() diff --git a/Where/RegionKit/Sources/Region+Ordering.swift b/Where/RegionKit/Sources/Region+Ordering.swift index c16a18cc..22ce40ee 100644 --- a/Where/RegionKit/Sources/Region+Ordering.swift +++ b/Where/RegionKit/Sources/Region+Ordering.swift @@ -12,15 +12,14 @@ extension Region { ) /// Order `elements` by their day count, descending, breaking ties by - /// `declarationOrder`. The single home for the app's "most days first, stable - /// order" ranking — shared by the year ranking and Primary tab - /// (`RegionRanking`), the widgets, the calendar month footers - /// (`PresenceCalendar`), and the daily-summary notification — so the rule - /// can't quietly drift between them. + /// `declarationOrder`. The single home for the "most days first, stable + /// order" ranking, so every surface that ranks regions (year reports, + /// widgets, calendar footers, notifications) shares one rule that can't + /// quietly drift between them. /// - /// `Element` is whatever row the caller already has (a `RegionDays`, a - /// `RegionDayTally`, a `[Region: Int]` entry, …); supply the two accessors - /// and get the same collection back, sorted. + /// `Element` is whatever row the caller already has (a small day-count + /// struct, a `[Region: Int]` entry, …); supply the two accessors and get the + /// same collection back, sorted. public static func rankedByDayCount( _ elements: some Sequence, days: (Element) -> Int, From b83f68b42419c56ac7664340f1a628d96efc94e7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 7 Jul 2026 17:29:58 -0400 Subject: [PATCH 6/6] Move region ordering up into WhereCore Per PR review: RegionKit should stay about regions and geofencing, so the day-count ranking (Region.declarationOrder / rankedByDayCount) belongs in WhereCore alongside the presence/day-count domain. Move Region+Ordering.swift and its test out of RegionKit into WhereCore (the extension imports RegionKit for the Region type); nothing in RegionKit referenced them. The test uses the real WhereCore RegionDayTally row again. Docs updated to point ordering at WhereCore. --- Where/RegionKit/AGENTS.md | 4 ++-- Where/RegionKit/README.md | 4 ++-- .../Sources/Region+Ordering.swift | 4 ++++ .../Tests/RegionOrderingTests.swift | 23 +++++++------------ 4 files changed, 16 insertions(+), 19 deletions(-) rename Where/{RegionKit => WhereCore}/Sources/Region+Ordering.swift (87%) rename Where/{RegionKit => WhereCore}/Tests/RegionOrderingTests.swift (71%) diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index 8010f988..6b2e68dc 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -26,8 +26,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature geometry are declared (see [README](README.md#adding-a-region)). - **`Region.allCases` order fixes attribution priority** — `RegionAttributor` checks regions in declaration order and the first polygon match wins (regions - are mutually exclusive at our resolution); it also drives - `Region.declarationOrder`, the app's ranking tiebreak. + are mutually exclusive at our resolution). (Day-count ranking of regions lives + in `WhereCore`'s `Region+Ordering`, not here.) - **Attribution loads once, lazily** (`RegionAttributor.shared`) and is UI-free: `BoundingBox` / `LongitudeSpan` expose the min/max math, but MapKit conversion lives in the UI layer. diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index e7033623..d03d6615 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -15,8 +15,8 @@ into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit). - **`Region`** — the tracked-region enum (`.california`, `.newYork`, `.canada`, `.europeanUnion`, `.other`), with a `localizedName` (from RegionKit's own string catalog) and a `geometrySource` describing where its polygons come - from. `Region+Ordering` provides the app's canonical "most days first, stable - tiebreak" ranking. + from. (Day-count *ranking* of regions lives in `WhereCore`, not here — + RegionKit stays about regions and geofencing.) - **`Coordinate`** — a plain WGS84 latitude/longitude value type (no CoreLocation), plus geometry primitives `GeoPolygon`, `BoundingBox`, and the antimeridian-aware `LongitudeSpan`. diff --git a/Where/RegionKit/Sources/Region+Ordering.swift b/Where/WhereCore/Sources/Region+Ordering.swift similarity index 87% rename from Where/RegionKit/Sources/Region+Ordering.swift rename to Where/WhereCore/Sources/Region+Ordering.swift index 22ce40ee..016c356e 100644 --- a/Where/RegionKit/Sources/Region+Ordering.swift +++ b/Where/WhereCore/Sources/Region+Ordering.swift @@ -1,5 +1,9 @@ import Foundation +import RegionKit +/// Day-count ranking of `Region`s. Lives in `WhereCore` — not `RegionKit` — +/// because it's about the app's presence/day-count domain, not region geometry +/// or lookup; `RegionKit` stays focused on regions and geofencing. extension Region { /// Each region's position in `Region.allCases`. This declaration order is /// the app's canonical tiebreak: whenever two regions compare equal on some diff --git a/Where/RegionKit/Tests/RegionOrderingTests.swift b/Where/WhereCore/Tests/RegionOrderingTests.swift similarity index 71% rename from Where/RegionKit/Tests/RegionOrderingTests.swift rename to Where/WhereCore/Tests/RegionOrderingTests.swift index 17e5a163..aa61c30b 100644 --- a/Where/RegionKit/Tests/RegionOrderingTests.swift +++ b/Where/WhereCore/Tests/RegionOrderingTests.swift @@ -1,15 +1,8 @@ import RegionKit import Testing +import WhereCore struct RegionOrderingTests { - /// A minimal ranking row, standing in for the app's real row types - /// (`RegionDayTally`, `RegionDays`, …) that live in higher layers — the - /// generic `rankedByDayCount` only needs a `region` and a `days` accessor. - private struct Tally { - let region: Region - let days: Int - } - // MARK: - declarationOrder @Test func declarationOrderMatchesAllCasesIndices() { @@ -24,9 +17,9 @@ struct RegionOrderingTests { @Test func rankedByDayCountOrdersByDaysDescending() { let ranked = Region.rankedByDayCount( [ - Tally(region: .newYork, days: 3), - Tally(region: .california, days: 10), - Tally(region: .canada, days: 7), + RegionDayTally(region: .newYork, days: 3), + RegionDayTally(region: .california, days: 10), + RegionDayTally(region: .canada, days: 7), ], days: \.days, region: \.region, @@ -40,10 +33,10 @@ struct RegionOrderingTests { // `other`. let ranked = Region.rankedByDayCount( [ - Tally(region: .canada, days: 10), - Tally(region: .california, days: 10), - Tally(region: .other, days: 5), - Tally(region: .newYork, days: 5), + RegionDayTally(region: .canada, days: 10), + RegionDayTally(region: .california, days: 10), + RegionDayTally(region: .other, days: 5), + RegionDayTally(region: .newYork, days: 5), ], days: \.days, region: \.region,