Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1eb60d4
Add WhereData module: persistence, GPS, evidence, simulated-year tests
kyleve May 25, 2026
0749852
Add swift testing and swift data skills
kyleve May 25, 2026
ba1c9ee
WhereData: address PR review findings
kyleve May 25, 2026
6f17916
WhereCore/WhereData: address PR review questions
kyleve May 25, 2026
f72d05e
AGENTS: don't act on PR comments or poll CI without being asked
kyleve May 25, 2026
d9cf441
WhereCore: bundle US Census state polygons for CA/NY attribution
kyleve May 25, 2026
df1ecc1
AGENTS: prefix anything posted on the user's behalf
kyleve May 25, 2026
c598c00
WhereData: drop data snapshot tests in favor of #expect assertions
kyleve May 25, 2026
13424bc
swiftformat: wrap multi-arg calls/decls at 120 columns
kyleve May 25, 2026
7dd8c74
swiftformat: --allow-partial-wrapping false for one-per-line params
kyleve May 25, 2026
f2a3dd9
swiftformat: tighten --max-width from 120 to 100
kyleve May 25, 2026
56b19f0
AGENTS: gitignore Plans/, remove committed plan
kyleve May 25, 2026
4ed1872
AGENTS: drop the Plans section
kyleve May 25, 2026
32296a0
WhereData: add NY-heavy year tests as mirror of SimulatedYear
kyleve May 26, 2026
84eb9b5
WhereData: add bare-majority CA/NY tests (1-day margin)
kyleve May 26, 2026
bf22f75
WhereData: add back-and-forth ~8-week alternation tests
kyleve May 26, 2026
d09b463
Where: merge WhereData into WhereCore
kyleve May 26, 2026
a83cde4
WhereCore: address remaining PR feedback (groups A–H)
kyleve May 27, 2026
154e91b
AGENTS+ide: --no-open, working-on-plans rule
kyleve May 27, 2026
92f8223
WhereCore: drop InMemoryStore, use SwiftDataStore(.inMemory) in tests
kyleve May 27, 2026
933ef88
WhereCoreTests: drop redundant SwiftDataStoreTests, keep upsert asser…
kyleve May 27, 2026
9c71a25
SwiftDataStore: defer perform counter, rename addSample, add Storage.…
kyleve May 27, 2026
75d8c8b
RegionAttributor: rename to .shared, extract GeoJSON, assert on empty…
kyleve May 27, 2026
0897300
Region.localizedName: switch with literal String(localized:) per case
kyleve May 27, 2026
9adf2b6
Evidence: drop manual Codable, require contentType, add .other label
kyleve May 27, 2026
f6869f1
SwiftDataStore: per-perform peer ModelContext for transactional writes
kyleve May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .agents/external-skills.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
{
"swiftui-pro": {
"repo": "twostraws/swiftui-agent-skill",
"path": "swiftui-pro",
"ref": "61b74001b64b292da8397355464d7c8a4c2c7d89"
"repo": "twostraws/swiftui-agent-skill",
"path": "swiftui-pro",
"ref": "61b74001b64b292da8397355464d7c8a4c2c7d89"
},
"swift-concurrency-pro": {
"repo": "twostraws/Swift-Concurrency-Agent-Skill",
"path": "swift-concurrency-pro",
"ref": "e710f8d577ccbff1ac047aac1594a5683000cb0b"
"repo": "twostraws/Swift-Concurrency-Agent-Skill",
"path": "swift-concurrency-pro",
"ref": "e710f8d577ccbff1ac047aac1594a5683000cb0b"
},
"swift-testing-pro": {
"repo": "twostraws/Swift-Testing-Agent-Skill",
"path": "swift-testing-pro",
"ref": "2d6bba14a3c8bf3694f218b92fffe617c41ae43e"
},
"swiftdata-pro": {
"repo": "twostraws/SwiftData-Agent-Skill",
"path": "swiftdata-pro",
"ref": "922d989473a9914210b41529a1ac5636aff4b8c1"
}
}
2 changes: 2 additions & 0 deletions .agents/skills/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# External skills — fetched via ./sync-agents --install
/swift-concurrency-pro/
/swift-testing-pro/
/swiftdata-pro/
/swiftui-pro/
16 changes: 16 additions & 0 deletions .swiftformat
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@
--indentcase true
--extension-acl on-declarations
--exclude Derived

# SwiftFormat can't enforce "wrap when there are >= N arguments", only
# "wrap when the unwrapped line exceeds --max-width". 100 columns is
# tight enough that almost every 2+ arg function/call wraps. A few
# long single-arg `logger.fault("…")` lines get chain-split as a
# nudge to extract the message.
--max-width 100
--wrap-arguments before-first
--wrap-parameters before-first
--wrap-collections before-first

# Default is `true`, which lets SwiftFormat keep some args on the same
# line when wrapping (e.g. `originLat: Double, originLng: Double,`).
# `false` makes wrapping all-or-nothing: either everything on one
# line, or one arg/parameter per line.
--allow-partial-wrapping false
73 changes: 68 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Tuist manifests live at the repo root ([`Project.swift`](Project.swift), [`Tuist

Run `./ide` (or `./ide -i` to also install dependencies) to regenerate the
Xcode project, install external agent skills, and point Git at `.githooks/`.
Pass `--no-open` (e.g. `./ide --no-open`) to regenerate without launching
Xcode — useful from a terminal where you don't want the IDE to grab focus.

Root dev scripts: `ide`, `swiftformat` (runs SwiftFormat via mise), and
`sync-agents` (keeps Claude Code–oriented files in sync with `AGENTS.md`).
Expand Down Expand Up @@ -72,8 +74,69 @@ Shared/<TargetName>/
- **Swift Testing** (`import Testing`) for all unit tests – do not use XCTest.
- Generated `.xcodeproj` and `Derived/` are git-ignored; never commit them.
- Bundle IDs follow `com.stuff.<suffix>`.

## Plans

Implementation plans go in `Plans/` and are named
`<NNN>-<YYYY-MM-DD>-<slug>.md`.
- Prefer small named structs over tuples for any value with more than
one field or that escapes a single function — tuples are fine as
ad-hoc inline returns but should not appear in property types,
collection element types, or public API.

## Generating the Xcode project

Agents must never open Xcode on the user's machine — it steals focus and
disrupts the user's session. Always pass `--no-open` when regenerating:

- `./ide --no-open` instead of `./ide`
- `mise exec -- tuist generate --no-open` instead of `tuist generate`

`tuist test` / `tuist build` are CLI-only and do not open Xcode, so no
flag is needed there.

## Working on plans

When implementing a multi-step plan (e.g. the to-do list produced by a
`/plan` invocation), commit after each step once its local checks
pass — one commit per to-do, with the test/lint run baked into the
"definition of done" for that step. This keeps history bisectable and
lets the plan land piecewise if a later step regresses.

The loop for each to-do is: mark `in_progress`, implement the change,
run the relevant local checks, commit, mark `completed`, move on.

- Required pre-commit checks: `./swiftformat --lint` and the matching
`tuist test` scheme(s). A red bar means the step is not done — fix
it before committing, do not stage a broken tree.
- If a step is pure groundwork (no behavior change, nothing
meaningful to test), still commit it on its own so the next step's
diff stays focused; say so in the commit body.
- Each commit message should name the plan step it closes (matching
the to-do title is fine) so the chain is readable end-to-end.
- Do not push until the user asks, unless the plan explicitly says
otherwise.

## Working on PR feedback

Do **not** proactively act on PR review comments (bot or human). When new
comments appear, summarize what's there and ask which ones to address
before reading more context, editing files, or pushing commits. Wait for
explicit go-ahead — either the user asking you to handle the feedback,
or you asking and getting permission first.

## Waiting on CI

Do **not** block the main conversation polling for CI to finish (GitHub
Actions checks, PR mergeability, etc.). After pushing, report what's
running and hand the turn back. If CI genuinely needs to be watched to
completion before continuing, delegate it to a background subagent so
the main conversation stays responsive.

This rule is specific to remote CI. Local commands — `tuist test`,
`swift build`, `./swiftformat --lint`, etc. — should still be awaited
inline in the main conversation.

## Posting on the user's behalf

Anything an agent posts under the user's identity (GitHub PR replies,
issue comments, review responses, Slack messages, etc.) must be
prefixed so the reader knows it was AI-generated, not the user
speaking. Use a short tag like `> _Posted by an AI agent on $USER's
behalf._` as the first line, then the actual content. Do not omit the
prefix even when the comment is short or factual.
42 changes: 42 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PackageDescription

let package = Package(
name: "Stuff",
defaultLocalization: "en",
platforms: [
.iOS(.v26),
],
Expand All @@ -12,6 +13,9 @@ let package = Package(
.library(name: "WhereUI", targets: ["WhereUI"]),
.library(name: "WhereTesting", targets: ["WhereTesting"]),
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.19.2"),
],
targets: [
.target(
name: "StuffCore",
Expand All @@ -20,6 +24,9 @@ let package = Package(
.target(
name: "WhereCore",
path: "Where/WhereCore/Sources",
resources: [
.process("Resources"),
],
),
.target(
name: "WhereUI",
Expand Down
Empty file removed Plans/.gitkeep
Empty file.
24 changes: 16 additions & 8 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,33 @@ import ProjectDescription
let destinations: Destinations = [.iPhone, .iPad]
let deployment: DeploymentTargets = .iOS("26.0")

/// Local Swift package (see root `Package.swift`) for StuffCore, WhereCore, WhereUI, and WhereTesting.
/// Local Swift package (see root `Package.swift`) for StuffCore, WhereCore, WhereUI, and
/// WhereTesting.
private let stuffPackage = Package.local(path: .relativeToRoot("."))

func unitTests(
name: String,
bundleIdSuffix: String,
productDependency: String,
sources: ProjectDescription.SourceFilesList,
extraPackageProducts: [String] = [],
) -> Target {
.target(
var dependencies: [TargetDependency] = [
.package(product: productDependency),
.package(product: "WhereTesting"),
.target(name: "StuffTestHost"),
]
for product in extraPackageProducts {
dependencies.append(.package(product: product))
}
return .target(
name: name,
destinations: destinations,
product: .unitTests,
bundleId: "com.stuff.\(bundleIdSuffix).tests",
deploymentTargets: deployment,
sources: sources,
dependencies: [
.package(product: productDependency),
.package(product: "WhereTesting"),
.target(name: "StuffTestHost"),
],
dependencies: dependencies,
)
}

Expand Down Expand Up @@ -77,7 +83,9 @@ let project = Project(
"UIWindowSceneSessionRoleApplication": .array([
.dictionary([
"UISceneConfigurationName": .string("Default Configuration"),
"UISceneDelegateClassName": .string("$(PRODUCT_MODULE_NAME).SceneDelegate"),
"UISceneDelegateClassName": .string(
"$(PRODUCT_MODULE_NAME).SceneDelegate",
),
]),
]),
]),
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ Shared/StuffTestHost/ Shared iOS unit-test host app (Sources/)
Where/ Where iOS app, modules, and tests
```

## Acknowledgements

The Where module bundles offline region polygons under
[`Where/WhereCore/Sources/Resources/`](Where/WhereCore/Sources/Resources/).
US state boundaries come from
[eric.clst.org/tech/usgeojson](https://eric.clst.org/tech/usgeojson/)
(`gz_2010_us_040_00_5m.json`), converted from the
[US Census Bureau Cartographic Boundary Files](https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html);
US Government works are in the public domain. See
[`Where/WhereCore/Sources/Resources/README.md`](Where/WhereCore/Sources/Resources/README.md)
for per-file provenance.

## License

Apache 2.0 – see [LICENSE](LICENSE).
14 changes: 14 additions & 0 deletions Where/WhereCore/Sources/Coordinate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

/// A WGS84 latitude/longitude pair. Kept as a plain value type so the
/// pure model layer never has to link CoreLocation — the `Location/`
/// adapter converts from `CLLocation` at the boundary.
public struct Coordinate: Hashable, Codable, Sendable {
public let latitude: Double
public let longitude: Double

public init(latitude: Double, longitude: Double) {
self.latitude = latitude
self.longitude = longitude
}
}
88 changes: 88 additions & 0 deletions Where/WhereCore/Sources/DayAggregator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation

/// Pure rules for turning `LocationSample`s and manual day entries into
/// `DayPresence` values and `YearReport`s. No I/O.
///
/// Rule: a day "counts" for a region if **any** sample in that calendar day
/// fell inside the region. A single day can therefore belong to multiple
/// regions (e.g. a CA→NY same-day flight). Manual day entries union with
/// GPS-derived attributions for the same day.
public struct DayAggregator: Sendable {
public let calendar: Calendar
public let timeZone: TimeZone

public init(
calendar: Calendar = Calendar(identifier: .gregorian),
timeZone: TimeZone = .current,
) {
var cal = calendar
cal.timeZone = timeZone
self.calendar = cal
self.timeZone = timeZone
}

public func aggregate(
samples: [LocationSample],
attributor: RegionAttributor,
) -> [DayPresence] {
var dayRegions: [Date: Set<Region>] = [:]
for sample in samples {
let dayStart = calendar.startOfDay(for: sample.timestamp)
let region = attributor.region(at: sample.coordinate)
dayRegions[dayStart, default: []].insert(region)
}
return dayRegions
.map { DayPresence(date: $0.key, regions: $0.value) }
.sorted { $0.date < $1.date }
}

public func report(
for year: Int,
samples: [LocationSample],
manualDays: [DayPresence] = [],
attributor: RegionAttributor,
) -> YearReport {
var dayRegions: [Date: Set<Region>] = [:]
for day in aggregate(samples: samples, attributor: attributor) {
dayRegions[day.date, default: []].formUnion(day.regions)
}
for day in manualDays {
let dayStart = calendar.startOfDay(for: day.date)
dayRegions[dayStart, default: []].formUnion(day.regions)
}

let yearDays = dayRegions
.filter { calendar.component(.year, from: $0.key) == year }
.map { DayPresence(date: $0.key, regions: $0.value) }
.sorted { $0.date < $1.date }

var totals: [Region: Int] = [:]
for day in yearDays {
for region in day.regions {
totals[region, default: 0] += 1
}
}

return YearReport(year: year, days: yearDays, totals: totals)
}

/// Half-open `DateInterval` spanning the requested calendar year in this
/// aggregator's calendar and timezone: `start` is the first instant of
/// `year`, `end` is the first instant of `year + 1`. `WhereStore`
/// implementations must therefore filter as `timestamp >= start &&
/// timestamp < end` so the first instant of the next year is excluded
/// (and not double-counted by the next year's report).
public func yearInterval(year: Int) -> DateInterval {
var startComponents = DateComponents()
startComponents.year = year
startComponents.month = 1
startComponents.day = 1
let start = calendar.date(from: startComponents) ?? Date(timeIntervalSince1970: 0)
var endComponents = DateComponents()
endComponents.year = year + 1
endComponents.month = 1
endComponents.day = 1
let end = calendar.date(from: endComponents) ?? start
return DateInterval(start: start, end: end)
Comment thread
kyleve marked this conversation as resolved.
}
}
Loading
Loading