diff --git a/Docs/2026-05-30-nearby-rightnow-merge.md b/Docs/2026-05-30-nearby-rightnow-merge.md new file mode 100644 index 00000000..450e392a --- /dev/null +++ b/Docs/2026-05-30-nearby-rightnow-merge.md @@ -0,0 +1,99 @@ +# Phase A R*Tree seed fix (kept) + Nearby/Right Now merge prototype (reverted) + +**Date:** 2026-05-30 (Pacific) +**Branch:** `ai-event-summary` +**Continues:** [2026-05-29-ai-guide-right-now-overhaul.md](2026-05-29-ai-guide-right-now-overhaul.md) +**Plan:** `~/.claude/plans/what-were-we-up-zippy-dongarra.md` + +--- + +## Outcome summary + +1. **Kept + committed (`a48ecc1`):** a fix for a production-breaking Phase A bug — the occurrence + R*Tree's `minT/maxT` constraint failed the seed import on the real bundled dataset. The index is + now **spatial-only**. +2. **Prototyped, then reverted (per user decision):** Phase B merged the AI "Right Now" flow into + the Nearby tab. The user opted to **keep Nearby as it was before Phase B** and **keep the AI flow + as the standalone "AI Guide" entry on the More screen** (i.e. the state from the 2026-05-29 + overhaul). All Phase B working-tree changes were reverted; only the rtree fix remains. + +--- + +## Phase A bug fix — occurrence R*Tree made spatial-only (KEPT) + +### Symptom +`iBurnTests/RightNowCandidateTests` (which seed the real bundled data via `DependencyContainer`) +failed at `BRCAppDelegate+Dependencies.swift:28`: + +``` +SQLite error 19: rtree constraint failed: event_occurrence_rtree.(minT<=maxT) + - while executing `INSERT OR REPLACE INTO event_occurrence_rtree (id, minLat, maxLat, minLon, maxLon, minT, maxT)` +``` + +### Root cause +`event_occurrences.start_time`/`end_time` are `TEXT NOT NULL`. The insert trigger computed +`minT = strftime('%s', start_time)`, `maxT = strftime('%s', end_time)`, each `COALESCE(..., 0)`. +For occurrences whose stored date strings don't parse via SQLite `strftime` (or whose end precedes +start), one bound resolved to a large epoch and the other to `0` → `minT > maxT` → constraint +failure → seed transaction rollback → `DependencyContainer` init failure (app ran on an empty DB). +In-memory test fixtures were clean, so it never tripped in tests; on device the failure was +caught/logged while the process still launched, so the prior session's "launch succeeded" was a +false positive on a broken/empty DB. + +The time columns are **never queried** — `occurrenceIDsInRegion` filters spatially only — so the +temporal dimension was dead weight and the sole source of the fragility. + +### Fix (`Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift`, commit `a48ecc1`) +Convert the occurrence rtree to **purely spatial** `rtree(id, minLat, maxLat, minLon, maxLon)`: +- `setupRTreeIndex(_:)`: detect the old 7-column schema via `PRAGMA table_info` → if `minT` present, + drop the two triggers + the table, then recreate spatial-only. The rtree is derived data, so + dropping is safe; this **self-heals the broken install** (the never-completed seed re-runs on next + launch, since `update_info` is still empty). +- Insert trigger + `rebuildOccurrenceRTree(_:)`: index lat/lon only (no `strftime`, no date + decoding, no constraint risk). + +### Verification +- `swift test` in `Packages/PlayaDB`: **145/145** (~148s). +- `iBurnTests/RightNowCandidateTests`: **4/4** (previously errored on DB init). +- Device build/install/launch on BigPhone 17 (iOS 26.5): **SUCCEEDED** — bundled data now seeds. +- App build after revert (sim, iPhone 17 Pro Max, iOS 26.2): **success**, 0 errors, 6 pre-existing + warnings. + +--- + +## Phase B — merge prototype (REVERTED) + +Implemented and verified, then reverted at the user's request. Recorded here for context. + +The merge folded the standalone Right Now screen into the SwiftUI Nearby tab: +- `NearbyViewModel` gained a unified time/place model (`PlaceScope` near-me/area + `selectedDay` + + `TimeOfDay`); events used the Phase-A occurrence R*Tree for region + client-side overlap for the + time window. Replaced the "Warp" time-shift model. +- New `NearbyAISection.swift` (iOS 26+) added the vibe chips / "ask" / "Show me" + curated Now/Next + on top, scoped to the same region+window. +- `RightNowViewModel` was slimmed (region+window passed in), owned by `NearbyListHostingController`; + `RightNowView.swift` deleted; the More-tab "AI Guide" row + `pushAIGuideView` removed; new shared + `PlaceScope.swift`. + +### Why reverted +The merge lives on the `useSwiftUILists` (DEBUG) Nearby path, so removing the release-visible +More-tab entry would have dropped AI Guide for release users; and the user preferred to keep Nearby +unchanged for now. Decision: **revert Nearby to pre-Phase-B; keep the AI flow as the standalone +"AI Guide" on More** (the 2026-05-29 state). + +### Revert mechanics +`git checkout HEAD --` on `NearbyView.swift`, `NearbyViewModel.swift`, +`NearbyListHostingController.swift`, `RightNowViewModel.swift`, `RightNowView.swift`, +`MoreViewController.swift`; `rm` of `NearbyAISection.swift` + `PlaceScope.swift`. Working tree now +matches the rtree-fix commit (`a48ecc1`) for all code. + +--- + +## Notes for a future merge attempt +- The base Nearby still fetches `EventFilter(region:, includeExpired: true)` and filters + "happening now" client-side. Region filtering benefits from the (now spatial-only) occurrence + R*Tree; that path works and seeds correctly post-fix. +- The clean way to drop `RightNowWorkflow`'s camp→event expansion needs an **overlap-aware** + region+window event query (the current DB time filter is start-time-based: + `startTime >= start AND startTime < end`, which drops already-running events). Add that first. +- If a merge is revisited, decide the DEBUG/release gating for AI Guide up front. diff --git a/Docs/2026-06-22-event-overlap-window.md b/Docs/2026-06-22-event-overlap-window.md new file mode 100644 index 00000000..ccca01e0 --- /dev/null +++ b/Docs/2026-06-22-event-overlap-window.md @@ -0,0 +1,87 @@ +# 2026-06-22 — Overlap-aware event time window (`EventFilter.activeWindow`) + +**Date:** 2026-06-22 (Pacific) +**Branch:** `event-window-overlap` (off the now-green `origin/master` @ `59adeda`) +**Master plan:** `~/.claude/plans/what-were-we-up-zippy-dongarra.md` (Phase A2) +**Follows:** [2026-06-19-merge-repair-and-sync.md](2026-06-19-merge-repair-and-sync.md) + +--- + +## Context / why + +The shared event query helper `eventOccurrenceRequest(filter:)` +(`Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift`) filtered the time window by the +occurrence's **start** only: + +```swift +if let startDate = filter.startDate { request = request.filter(startTime >= startDate) } +if let endDate = filter.endDate { request = request.filter(startTime < endDate) } +``` + +So a query for "events in window [from, to)" **dropped events that started before `from` but are +still running** — the classic interval bug. Sibling methods already do it right +(`fetchEvents(from:to:)`: `start_time < endDate && end_time > startDate`). + +This is the documented Phase A2 prerequisite for unifying Nearby / Right Now, and it independently +fixes a real bug in the AI "Right Now" flow (see below). + +## Design decision — additive, not a global flip + +An Explore pass suggested simply flipping `eventOccurrenceRequest` to overlap globally, arguing the +day-tab list is unaffected (true — it clears `startDate`/`endDate` and buckets by start day in +`bucketByDayThenHour`). But a global flip **would** change two release features: + +- `PlayaDBAnnotationDataSource` "today's favorites on map" and `FavoritesViewModel` "today only" + set `startDate = startOfDay, endDate = nextDay`. Under overlap they'd start including events that + *started* on a previous day but run into today — and **multi-day occurrences would appear on + every day they span**, inconsistent with how the day-tab buckets by start day. + +So instead of changing `startDate`/`endDate` semantics, this adds a **distinct, opt-in** field. +Zero behavior change for every existing consumer. + +## Changes + +- **`Filters/EventFilter.swift`** — new `public var activeWindow: DateInterval?`. Added as the + **last** init parameter (`activeWindow: DateInterval? = nil`) — deliberately at the end to avoid + the positional/labeled-arg ordering break that took down `master` last session. `DateInterval` is + `Codable`+`Hashable`, so `EventFilter`'s synthesized conformances still hold. +- **`PlayaDBImpl.eventOccurrenceRequest(filter:)`** — when `activeWindow` is set, apply the overlap + predicate (same form as `fetchEvents(from:to:)`): + ```swift + if let window = filter.activeWindow { + request = request + .filter(EventOccurrence.Columns.startTime < window.end) + .filter(EventOccurrence.Columns.endTime > window.start) + } + ``` + Independent of `startDate`/`endDate`, which keep their start-bounded calendar-day meaning. +- **`iBurn/AISearch/Workflows/RightNowWorkflow.swift`** (`regionQuery`) — switched from + `startDate = max(windowStart, now)` / `endDate = windowEnd` to + `activeWindow = DateInterval(start: floor, end: windowEnd)` (`floor = max(windowStart, now)`), with + a `guard floor < windowEnd else { return [] }` so an empty/past window can't form an inverted + `DateInterval` (preserves the old "empty window → no rows" behavior). **Bug fixed:** the region + query now surfaces events already underway at `floor`; previously the start-bounded SQL prefilter + dropped them *before* the workflow's client-side overlap gate (`occ.startDate < windowEnd && + occ.endDate > windowFloor`) ever saw them. The client-side gate stays — it still unifies the + separately-fetched camp/art-hosted occurrences. + +## Tests + +- **`FilterRequestBuilderTests.testEventOccurrenceRequestActiveWindowKeepsInProgressEvents`** (new) — + inserts ongoing / in-window / ended / later events; asserts start-bounded returns `["in-window"]` + while `activeWindow` returns `["ongoing", "in-window"]`. Documents both behaviors so the + distinction can't silently regress. Uses `try XCTUnwrap(playaDB as? PlayaDBImpl)` (no force-unwrap). + +## Verification + +- PlayaDB `swift test --filter FilterRequestBuilderTests`: **15 passed** (incl. the new test). +- `xcodebuild build` (iBurn, iPhone 17 Pro Max, iOS 26.2): **0 errors**, 6 pre-existing warnings. + +## Not done / follow-ups + +- **NearbyCard / NearbyViewModel** still fetch region events and gate "happening now" client-side. + They *could* adopt `activeWindow` (small payoff — the region is tiny and the window is a point at + `now`), left as a deliberate follow-up. +- The camp/art-hosted-events expansion in `RightNowWorkflow` stays — it covers events whose host GPS + isn't in the R*Tree join, which is a coverage concern orthogonal to the time-window fix. +- Committed `411f7ae`; PR [#250](https://github.com/iBurnApp/iBurn-iOS/pull/250) → `master`. diff --git a/Packages/PlayaDB/Sources/PlayaDB/Filters/EventFilter.swift b/Packages/PlayaDB/Sources/PlayaDB/Filters/EventFilter.swift index 5f57138b..08caaee7 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/Filters/EventFilter.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/Filters/EventFilter.swift @@ -57,6 +57,15 @@ public struct EventFilter: Hashable, Codable { /// When nil, all event types are included. public var eventTypeCodes: Set? + /// Overlap time window: include occurrences whose `[start, end)` interval intersects + /// this interval (`startTime < window.end && endTime > window.start`). + /// + /// Unlike `startDate`/`endDate` — which bound the occurrence's START only (calendar-day + /// bucketing) and therefore drop events already in progress — this keeps events that + /// began before the window opened but are still running. Use it for "what's active in + /// this window" queries (e.g. nearby / right-now), not for day-tab bucketing. + public var activeWindow: DateInterval? + /// Create a new event filter public init( year: Int? = nil, @@ -68,7 +77,8 @@ public struct EventFilter: Hashable, Codable { startingWithinHours: Int? = nil, startDate: Date? = nil, endDate: Date? = nil, - eventTypeCodes: Set? = nil + eventTypeCodes: Set? = nil, + activeWindow: DateInterval? = nil ) { self.year = year self.regionStorage = region.map(FilterRegion.init) @@ -80,6 +90,7 @@ public struct EventFilter: Hashable, Codable { self.startDate = startDate self.endDate = endDate self.eventTypeCodes = eventTypeCodes + self.activeWindow = activeWindow } /// Filter that matches all events (no filtering) diff --git a/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift b/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift index 25f4f07f..d84cc411 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift @@ -1079,6 +1079,15 @@ internal class PlayaDBImpl: PlayaDB { request = request.filter(EventOccurrence.Columns.startTime < endDate) } + // Overlap window: occurrences whose [start, end) interval intersects the window. + // Same predicate form as fetchEvents(from:to:); unlike startDate/endDate this keeps + // events already in progress when the window opened. + if let window = filter.activeWindow { + request = request + .filter(EventOccurrence.Columns.startTime < window.end) + .filter(EventOccurrence.Columns.endTime > window.start) + } + // FTS5 search constraint (UIDs pre-resolved against event_objects_fts) if let uids = matchingEventUIDs { request = request.filter(uids.contains(EventOccurrence.Columns.eventId)) diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift index 60009471..d955bbfe 100644 --- a/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift +++ b/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift @@ -511,6 +511,50 @@ final class FilterRequestBuilderTests: XCTestCase { ) } + /// `activeWindow` must keep events already in progress when the window opens — the case + /// the start-bounded `startDate`/`endDate` filter drops. Documents both behaviors so the + /// distinction can't silently regress. + func testEventOccurrenceRequestActiveWindowKeepsInProgressEvents() async throws { + let now = Date() + let windowStart = now + let windowEnd = now.addingTimeInterval(2 * 3600) // now + 2h + + // Started 2h ago, still running 1h from now — in progress at the window open. + try await insertEvent(uid: "ongoing", name: "Ongoing", year: 2025, + start: now.addingTimeInterval(-2 * 3600), + end: now.addingTimeInterval(3600)) + // Starts inside the window. + try await insertEvent(uid: "in-window", name: "In Window", year: 2025, + start: now.addingTimeInterval(1800), + end: now.addingTimeInterval(5400)) + // Already ended before the window. + try await insertEvent(uid: "ended", name: "Ended", year: 2025, + start: now.addingTimeInterval(-5 * 3600), + end: now.addingTimeInterval(-4 * 3600)) + // Starts after the window closes. + try await insertEvent(uid: "later", name: "Later", year: 2025, + start: now.addingTimeInterval(5 * 3600), + end: now.addingTimeInterval(6 * 3600)) + + let impl = try XCTUnwrap(playaDB as? PlayaDBImpl) + + // Start-bounded filter drops "ongoing" (its start precedes the window). + let startBounded = EventFilter(startDate: windowStart, endDate: windowEnd) + let startResult = try await dbQueue.read { db in + try impl.eventOccurrenceRequest(filter: startBounded).fetchAll(db) + } + XCTAssertEqual(startResult.map(\.eventId), ["in-window"], + "Start-bounded filtering excludes events that began before the window") + + // Overlap window keeps "ongoing" and "in-window", excludes "ended" and "later". + let overlap = EventFilter(activeWindow: DateInterval(start: windowStart, end: windowEnd)) + let overlapResult = try await dbQueue.read { db in + try impl.eventOccurrenceRequest(filter: overlap).fetchAll(db) + } + XCTAssertEqual(overlapResult.map(\.eventId), ["ongoing", "in-window"], + "Overlap window keeps in-progress events and excludes ended/future ones") + } + func testFetchEventsAppliesYearRegionAndSearchFilters() async throws { let now = Date() let region = MKCoordinateRegion( diff --git a/iBurn/AISearch/Workflows/RightNowWorkflow.swift b/iBurn/AISearch/Workflows/RightNowWorkflow.swift index 2176254b..948cc69c 100644 --- a/iBurn/AISearch/Workflows/RightNowWorkflow.swift +++ b/iBurn/AISearch/Workflows/RightNowWorkflow.swift @@ -136,10 +136,14 @@ func gatherRightNowCandidates( // misses when the event's host GPS isn't populated, and is what surfaces "the camp // that's serving coffee right now" as its actual event. func regionQuery(_ typeCodes: Set?) async throws -> [EventObjectOccurrence] { + let floor = max(windowStart, now) + guard floor < windowEnd else { return [] } var filter = EventFilter.all filter.region = region - filter.startDate = max(windowStart, now) - filter.endDate = windowEnd + // Overlap window, not start-bounded: also surfaces events already underway at `floor`. + // The prior startDate/endDate prefilter dropped them before the client-side overlap + // gate below ever saw them. + filter.activeWindow = DateInterval(start: floor, end: windowEnd) filter.eventTypeCodes = typeCodes return try await playaDB.fetchEvents(filter: filter) }