Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
99 changes: 99 additions & 0 deletions Docs/2026-05-30-nearby-rightnow-merge.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 87 additions & 0 deletions Docs/2026-06-22-event-overlap-window.md
Original file line number Diff line number Diff line change
@@ -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`.
13 changes: 12 additions & 1 deletion Packages/PlayaDB/Sources/PlayaDB/Filters/EventFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ public struct EventFilter: Hashable, Codable {
/// When nil, all event types are included.
public var eventTypeCodes: Set<String>?

/// 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,
Expand All @@ -68,7 +77,8 @@ public struct EventFilter: Hashable, Codable {
startingWithinHours: Int? = nil,
startDate: Date? = nil,
endDate: Date? = nil,
eventTypeCodes: Set<String>? = nil
eventTypeCodes: Set<String>? = nil,
activeWindow: DateInterval? = nil
) {
self.year = year
self.regionStorage = region.map(FilterRegion.init)
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions iBurn/AISearch/Workflows/RightNowWorkflow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>?) 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)
}
Expand Down
Loading