Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions Docs/2026-04-12-ai-event-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# AI Summary of Hosted Events on Detail Pages

## Problem
Camp and art detail pages display hosted events (next event + "See all N events" link), but there's no quick summary of what the events collectively offer. Users have to tap through individual events to understand a camp/art's programming.

## Solution
Added AI-generated event collection summaries using Apple Foundation Models (iOS 26+) with the existing workflow pipeline for guardrail/context handling.

## Key Changes

### New Generable Type
- **`iBurn/AISearch/AIAssistantModels.swift`**: Added `GenerableEventCollectionSummary` with single `summary` field

### Reusable Summary Generator
- **`iBurn/AISearch/Workflows/WorkflowUtilities.swift`**: Added `generateEventCollectionSummary(events:hostName:) async -> String?`
- Wraps `withContextWindowRetry` (halves event count on context overflow)
- Inside uses `retryWithCandidateFiltering` (filters individual events that trigger guardrails)
- Formats events with name, type code display name, and truncated description (120 chars)
- Returns `nil` on complete failure for graceful degradation
- Slightly snarky tone per user preference

### New Detail Cell Types
- **`iBurn/Detail/Models/DetailCellType.swift`**: Added `.eventSummaryLoading(hostName:)` and `.eventSummary(String, hostName:)` cases

### Cell Rendering
- **`iBurn/Detail/Views/DetailView.swift`**:
- Added `EventSummaryHeaderView` (shared between detail cells and events list)
- Uses sparkles icon + "AI SUMMARY" header + ProgressView for loading state
- Added rendering cases in `cellContent` switch and `isCellTappable`

### ViewModel Integration
- **`iBurn/Detail/ViewModels/DetailViewModel.swift`**:
- New state: `resolvedEventSummary: String?`, `isGeneratingEventSummary: Bool`
- `generateEventSummaryIfNeeded()` triggers after deferred data loads (Phase 3)
- `generateEventSummaryCells(hostName:)` returns loading/summary/empty cells
- Wired into `generateHostedEventCells` (camp/art), `generatePlayaEventCellTypes`, `generatePlayaEventOccurrenceCellTypes`

### Events List Integration
- **`iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift`**: Added summary header above event list via `.task` modifier

## Three-Phase Loading
1. **Phase 1** (existing): Metadata loads, cells render immediately
2. **Phase 2** (existing): Deferred data loads (host events, images), cells refresh
3. **Phase 3** (new): AI summary generates from resolved events, cells refresh again

## Graceful Degradation
- Pre-iOS 26: `#if canImport(FoundationModels)` + `@available(iOS 26, *)` guards
- All events trigger guardrails: `retryWithCandidateFiltering` tries halves then individuals, returns nil if <2 safe
- Context overflow: `withContextWindowRetry` halves event count down to minimum 2
- Complete LLM failure: Returns nil, no summary cell shown

## Build Verification
- Build succeeds: 0 errors, 6 pre-existing warnings (none from new code)
28 changes: 28 additions & 0 deletions Docs/2026-04-12-show-tab-bar-on-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Show Tab Bar on All Navigation Pushes

## Problem
Several view controllers set `hidesBottomBarWhenPushed = true`, causing the tab bar to disappear when navigating to detail screens, tracks, AI guide, recently viewed, and feature flags. With the iOS 26 Liquid Glass UI, the tab bar should remain visible throughout navigation for a consistent experience.

## Solution
Removed all 5 instances of `hidesBottomBarWhenPushed = true` across 2 files.

## Changes

### `iBurn/Detail/Controllers/DetailHostingController.swift`
- Removed `self.hidesBottomBarWhenPushed = true` from init (line 67) — this affected all detail screens (art, camps, events, mutant vehicles)

### `iBurn/MoreViewController.swift`
- Removed `tracksVC.hidesBottomBarWhenPushed = true` from `pushTracksView()` (line 343)
- Removed `hostingVC.hidesBottomBarWhenPushed = true` from `pushAIGuideView()` (line 392)
- Removed `recentVC.hidesBottomBarWhenPushed = true` from `pushRecentlyViewedView()` (line 416)
- Removed `featureFlagsVC.hidesBottomBarWhenPushed = true` from `pushFeatureFlagsView()` (line 501, DEBUG only)

### Not changed
- `MainMapViewController.swift:183-184` — explicitly re-shows tab bar in `viewWillDisappear`, harmless safety net kept as-is

## Verification
- Build succeeds with 0 errors, 0 warnings
- Test by navigating to detail views, tracks, recently viewed, AI guide — tab bar should remain visible

## Branch
`show-tab-bar-on-push` from `origin/master`
103 changes: 103 additions & 0 deletions Docs/2026-04-13-event-host-name-location-in-cells.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Add Host Name and Location to Event List Cells

## Problem Statement

When all list row types were unified into `ObjectRowView` (commit `78a1c0c`), the event-specific "host name + location address" row was dropped. The old `EventRowView` had a dedicated Row 2 between the title and thumbnail showing `[Host Name] ... [Location Address]`. Additionally, the DetailViewModel was redundantly re-fetching the host camp/art object that had already been loaded by the list query.

## Solution Overview

1. **GRDB JOIN queries** - Define `EventObject.hostedCamp` and `EventObject.locatedArt` GRDB associations, resolve host data in a single SQL JOIN
2. **`PlaceDataObject` protocol** - Sub-protocol of `DataObject` providing `address` for camps and art, with type-specific fallback chains
3. **Pre-loaded host on `EventObjectOccurrence`** - Store the full host object (`any PlaceDataObject`) from the JOIN, computed `hostName`/`hostAddress` via protocol dispatch
4. **Display host row in `ObjectRowView`** - Optional `hostName`/`hostAddress` parameters with embargo gating
5. **Dead code cleanup** - Removed `ResolvedEventHost` struct and all async `resolvedHosts` machinery from 5 view models

## Architecture

### PlaceDataObject Protocol

**File:** `Packages/PlayaDB/Sources/PlayaDB/DataObject.swift`

```swift
public protocol PlaceDataObject: DataObject {
var address: String? { get }
}
```

- `CampObject`: `address` = `locationString ?? intersection`
- `ArtObject`: `address` = `locationString ?? timeBasedAddress`

### EventObjectOccurrence Host

**File:** `Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift`

```swift
public let host: (any PlaceDataObject)?
public var hostName: String? { host?.name }
public var hostAddress: String? { host?.address }
```

Populated by `EventOccurrenceJoinedRow.toEventObjectOccurrence()` from GRDB JOIN results.

### GRDB Associations

**File:** `Packages/PlayaDB/Sources/PlayaDB/Models/EventObject.swift`

```swift
static let hostedCamp = belongsTo(CampObject.self, key: "hostedCamp", ...)
static let locatedArt = belongsTo(ArtObject.self, key: "locatedArt", ...)
```

Single JOIN query replaces 4 batch queries per event observation.

### DetailViewModel Pre-loading

**File:** `iBurn/Detail/ViewModels/DetailViewModel.swift`

The `.eventOccurrence` case uses the pre-loaded host from JOIN, falling back to async fetch only if not available. Common name/description/address use `PlaceDataObject` protocol dispatch; only `DetailSubject` creation and hosted events query require type-specific branches.

## Embargo Handling

Host address is location data and gated by `BRCEmbargo.allowEmbargoedData()` at each view call site:
```swift
hostAddress: BRCEmbargo.allowEmbargoedData() ? event.object.hostAddress : nil
```

## Files Modified

### PlayaDB Package
- `Packages/PlayaDB/Sources/PlayaDB/DataObject.swift` — `PlaceDataObject` protocol
- `Packages/PlayaDB/Sources/PlayaDB/Models/CampObject.swift` — `PlaceDataObject` conformance
- `Packages/PlayaDB/Sources/PlayaDB/Models/ArtObject.swift` — `PlaceDataObject` conformance
- `Packages/PlayaDB/Sources/PlayaDB/Models/EventObject.swift` — GRDB associations
- `Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift` — `host: (any PlaceDataObject)?`, `EventOccurrenceJoinedRow`
- `Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift` — JOIN queries

### UI Layer
- `iBurn/ListView/ObjectRowView.swift` — `hostName`/`hostAddress` params + conditional row
- `iBurn/ListView/NearbyView.swift` — pass host data
- `iBurn/ListView/EventListView.swift` — pass host data
- `iBurn/ListView/FavoritesView.swift` — pass host data
- `iBurn/ListView/GlobalSearchView.swift` — pass host data
- `iBurn/ListView/RecentlyViewedView.swift` — pass host data
- `iBurn/Detail/ViewModels/DetailViewModel.swift` — use pre-loaded host

### Dead Code Removed
- `ResolvedEventHost` struct and all `resolvedHosts`/`resolveHosts()` from 5 view models

## Performance Impact

Before: 4 batch queries per event observation + N+1 async queries in view models + 1 fetch per event detail
After: 1 SQL JOIN query, zero async resolution, zero redundant fetches in detail view

## Tests

Added 18 tests across 3 files in `Packages/PlayaDB/Tests/PlayaDBTests/`:

- `PlaceDataObjectTests.swift` (new, 7 tests) — `CampObject.address` and `ArtObject.address` fallback chains, including `timeBasedAddress` formatting.
- `EventObjectOccurrenceTests.swift` (extended, +6 tests) — `EventObjectOccurrence.host`/`hostName`/`hostAddress` delegation; `EventOccurrenceJoinedRow.toEventObjectOccurrence()` precedence (camp wins over art, art fallback, nil-safe).
- `EventHostPreloadingTests.swift` (new, 5 tests) — Integration tests through the public `fetchEvents()` and `fetchEvents(hostedByCampUID:)` APIs covering camp host pre-loading, art host pre-loading, missing-host safety, and batch resolution across multiple hosts.

Style: tests use `@testable import PlayaDB` (no `as!` casts) and `try XCTUnwrap(...)` (no `!` force-unwraps).

Also fixed pre-existing `ListRow<T>` wrapper breakage in `FilterObservationTests.swift` (4 sites) that was preventing the test target from compiling. Full suite: 128 tests pass.
140 changes: 140 additions & 0 deletions Docs/2026-05-09-events-hour-index-and-fts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Events: Hour Quick-Scroll Index + FTS Search (SwiftUI parity)

## High-Level Plan

Two parallel Events implementations sit behind `Preferences.FeatureFlags.useSwiftUILists`. Closing the gap between them surfaces three coupled problems, all rooted in the SwiftUI version short-cutting the data layer.

| Concern | UIKit (legacy) | SwiftUI (current) | Plan |
|---|---|---|---|
| Hour grouping | YapDB view groups by `"YYYY-MM-dd HH"` — sections come pre-shaped from DB | Flat `[ListRow]` from `observeEvents`; VM re-groups in memory via `Calendar` | Move grouping into `PlayaDB.observeEventsByHour` |
| Side index strip | `UITableView.sectionIndexTitles` — bare hour digits, tap + drag-scrub | Absent | Build `EventHourIndexView` (vertical strip, `ScrollViewReader` + `DragGesture`) |
| Search | Separate `searchViewName` YapDB view (FTS) | `EventFilter.searchText` consumed post-fetch by `.lowercased().contains()` in `eventObjectOccurrences` | Apply `.matching(searchText:)` at SQL via FTS5 sub-select; switch the VM to a separate observation when search is active |

**Latent bug**: `PlayaDBImpl.eventObjectOccurrences` (lines 1036-1043) does in-memory `.contains()` filtering for `filter.searchText`, ignoring the existing `event_objects_fts` table. Art/Camp/MV all correctly use `.matching(searchText:)`. Fixing this also benefits the AI-tool callers (`PlayaSearchTools`).

User decisions captured:
- Hide inline section headers; rely on the strip alone.
- Tap + drag-scrub with light haptic.
- Bare hour digits (`12, 1, 2, ...`) — `12` appears twice per Apple's index convention.
- Grouping lives in the DB layer; the VM consumes already-shaped sections.
- Search is a separate DB observation, not a filter applied to the browse stream.
- Trust DB observations — no optimistic UI mutations on toggle/edit.

## Architecture

```
Browse mode (search empty):
EventFilter{day-scoped, no searchText} → observeEventsByHour → [EventHourSection]
sectioned List + hour strip overlay

Search mode (search non-empty):
EventFilter{searchText, all days} → observeEvents → [ListRow<EventObjectOccurrence>]
flat List, no strip
```

The VM owns one task at a time and swaps which observation it consumes when `searchText` crosses the empty/non-empty boundary.

## Technical Details

### A. PlayaDB layer

#### A1. Create `Packages/PlayaDB/Sources/PlayaDB/Models/EventHourSection.swift`

```swift
public struct EventHourSection: Equatable, Sendable {
public let hour: Int // 0-23
public let rows: [ListRow<EventObjectOccurrence>]
public init(hour: Int, rows: [ListRow<EventObjectOccurrence>]) {
self.hour = hour
self.rows = rows
}
}
```

#### A2. Fix events FTS in `PlayaDBImpl.eventOccurrenceRequest(filter:)` (line 963)

`.matching(searchText:)` keys off `RowDecoder.databaseTableName + "_fts"`. The events FTS table is `event_objects_fts` (indexes `EventObject` columns), so we apply the match against `EventObject` and constrain `EventOccurrence.event_id`:

```swift
if let searchText = filter.searchText, !searchText.isEmpty {
let matchedEvents = EventObject
.matching(searchText: searchText)
.select(EventObject.Columns.uid)
request = request.filter(matchedEvents.contains(EventOccurrence.Columns.eventId))
}
```

Then **delete** the in-memory `searchText` block in `eventObjectOccurrences(filter:db:)` (lines 1036-1043).

#### A3-A4. Add `observeEventsByHour` to PlayaDB protocol + impl

```swift
@discardableResult
func observeEventsByHour(
filter: EventFilter,
onChange: @escaping ([EventHourSection]) -> Void,
onError: @escaping (Error) -> Void
) -> PlayaDBObservationToken
```

Implementation delegates to `observeEvents` and groups before emitting via a `static internal` `groupByHour` helper (testable).

### B. App data-provider

`iBurn/ListView/EventDataProvider.swift` gains `observeObjectsByHour` (AsyncStream wrapper, same cancellation pattern as existing `observeObjects`).

### C. ViewModel

`iBurn/ListView/EventListViewModel.swift`:

```swift
enum Mode: Equatable { case browse, case search(String) }

@Published var browseSections: [EventHourSection] = []
@Published var searchResults: [ListRow<EventObjectOccurrence>] = []
@Published var searchText: String = "" { didSet { restartObservation() } }
var mode: Mode { searchText.isEmpty ? .browse : .search(searchText) }

func toggleFavorite(_ row: ListRow<EventObjectOccurrence>) async {
do { try await dataProvider.toggleFavorite(row.object) }
catch { print("Error toggling favorite for \(row.object.name): \(error)") }
}
```

`restartObservation()` cancels current task, starts the right one for current mode (browse subscribes to `observeObjectsByHour`; search subscribes to `observeObjects` with day-unscoped filter). No optimistic UI mutations — trust the DB observation.

### D. View

`iBurn/ListView/EventHourIndexView.swift` (new) — vertical strip, ScrollViewReader integration, `DragGesture(minimumDistance: 0)` for tap+drag-scrub, light haptic on hour change. Uses `PreferenceKey` to track each label's frame in a named coordinate space, so drag location maps to hour.

`iBurn/ListView/EventListView.swift` branches on `viewModel.mode`:
- Browse: day picker + flat-rendered List (rows from each section, with `.id(section.hour)` only on the first row of each section) + `EventHourIndexView` overlay.
- Search: hides day picker, shows flat results, no strip.

### E. Tests

- `EventHourSectionTests.swift` — covers `groupByHour` ordering / empty / single-hour edge cases.
- Update `FilterRequestBuilderTests.testEventRequestAppliesAllFilters` to assert FTS5 SQL behavior (e.g., stemming match) instead of in-memory `.contains()`.

## Cross-References

- Plan file: `~/.claude/plans/we-still-have-a-sorted-prism.md`
- Memory: `feedback_trust_db_observations.md`, `feedback_fts_for_search.md`, `feedback_fully_inflated_data_objects.md`
- Prior SwiftUI Events work: `Docs/2026-04-12-ai-event-summary.md`, `Docs/2026-04-13-event-host-name-location-in-cells.md`
- Reused patterns:
- `iBurn/ListView/EventDayPickerView.swift:21-61` — ScrollViewReader pattern (horizontal version of what we're building)
- `iBurn/EventListViewController.swift:18-19, 37-43, 112-123` — UIKit two-view (browse/search) precedent + hour-digit transform
- `Packages/PlayaDB/Sources/PlayaDB/QueryExtensions/QueryInterfaceRequest+DataObject.swift:114-133` — generic `.matching(searchText:)`
- `Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift:887-941` — Art/Camp/MV correct FTS application (mirror for events)

## Expected Outcomes

1. Events list (SwiftUI build) shows a tappable + drag-scrubbable hour strip on the right edge with bare hour digits matching UIKit's strip exactly.
2. Inline `12 AM`/`1 AM` headers no longer render between rows (UIKit parity).
3. Typing in the search bar switches the list to FTS-backed flat results (stemming/unicode match), hides the day picker and strip.
4. Clearing the search returns to browse mode with the prior selected day intact.
5. Favorite toggles don't flicker — the DB observation is the source of truth.
6. AI-tool event search (`PlayaSearchTools`) gains FTS tokenization for free.
7. UIKit code path stays untouched and continues to work when `useSwiftUILists` is OFF.
Loading
Loading