diff --git a/Docs/2026-04-12-ai-event-summary.md b/Docs/2026-04-12-ai-event-summary.md new file mode 100644 index 00000000..2fa3dec0 --- /dev/null +++ b/Docs/2026-04-12-ai-event-summary.md @@ -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) diff --git a/Docs/2026-04-12-show-tab-bar-on-push.md b/Docs/2026-04-12-show-tab-bar-on-push.md new file mode 100644 index 00000000..b96ac34b --- /dev/null +++ b/Docs/2026-04-12-show-tab-bar-on-push.md @@ -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` diff --git a/Docs/2026-04-13-event-host-name-location-in-cells.md b/Docs/2026-04-13-event-host-name-location-in-cells.md new file mode 100644 index 00000000..9f38cbe5 --- /dev/null +++ b/Docs/2026-04-13-event-host-name-location-in-cells.md @@ -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` wrapper breakage in `FilterObservationTests.swift` (4 sites) that was preventing the test target from compiling. Full suite: 128 tests pass. diff --git a/Docs/2026-05-09-events-hour-index-and-fts.md b/Docs/2026-05-09-events-hour-index-and-fts.md new file mode 100644 index 00000000..dcf3931d --- /dev/null +++ b/Docs/2026-05-09-events-hour-index-and-fts.md @@ -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] + │ + 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] + public init(hour: Int, rows: [ListRow]) { + 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] = [] +@Published var searchText: String = "" { didSet { restartObservation() } } +var mode: Mode { searchText.isEmpty ? .browse : .search(searchText) } + +func toggleFavorite(_ row: ListRow) 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. diff --git a/Docs/2026-05-16-event-list-day-tab-perf.md b/Docs/2026-05-16-event-list-day-tab-perf.md new file mode 100644 index 00000000..fc714a93 --- /dev/null +++ b/Docs/2026-05-16-event-list-day-tab-perf.md @@ -0,0 +1,94 @@ +# 2026-05-16 — Event List Day-Tab Performance: Filter-Keyed Observation + JOIN + +## High-Level Plan + +### Problem +Day-tab switching in the SwiftUI events list took 2–3 seconds. The old UIKit + YapDatabase implementation was instant. Two structural issues combined: + +1. **Observation churn.** `EventListViewModel.restartObservation()` (iBurn/ListView/EventListViewModel.swift:163) tore down and rebuilt the `ValueObservation` on every `selectedDay.didSet`. +2. **Six sequential queries per fetch.** Inside the fetch closure (PlayaDBImpl.swift:1107): `event_occurrences` range → `event_objects IN(…)` → `camp_objects IN(…)` → `art_objects IN(…)` → `object_metadata IN(…)` → `thumbnail_colors IN(…)`. With 500–1000 occurrences/day each IN clause carried hundreds of UIDs. + +YapDB pre-computed group membership at write time, so day-switching was a group-visibility toggle. GRDB was doing far more work per tap than necessary. + +### Solution +**Separate "what triggers an observation restart" from "what slices the result":** + +- Observation is keyed on the **filter** (favorites, event types, year, search) — *not* on the day. Filter changes restart with new SQL `WHERE`. Result is the full filter-matching set across all 7 festival days. +- Day-switching is a pure UI slice over an in-memory `[Date: [EventHourSection]]` cache built by the observation. Zero DB work per tab tap. +- A single JOIN replaces the 4 sequential entity queries, decoded through the existing `EventOccurrenceJoinedRow`. +- Remaining Swift filters (favorites EXISTS, year, eventTypeCodes) pushed into SQL. Region/bbox stays Swift-side. +- `bucketByDayThenHour` does a single sequential split (no `Dictionary(grouping:)` hashing) since the JOIN result is already `ORDER BY start_time`. + +Mirrors YapDB behavior: filter-change rebuilds the filtered view, day-change is a group-visibility toggle. + +### Why this approach over alternatives +- **DB-driven filter table** (storing UI state in a singleton DB row so the observation re-fires on every day change): would not actually solve the perf problem — fetches still rerun per tap. Mixes UI state into schema, adds feedback-loop fragility. Rejected. +- **Per-day caching with map of past results**: solves repeat-tap latency but every cold tap still costs the full fetch. More moving parts than the full-festival load. Rejected. +- **Stored `start_hour`/`start_day` columns**: timezone footgun (`Calendar.current` vs SQLite UTC `strftime`), microseconds saved at this scale. Skipped, reserved as contingency if profiling later shows grouping >50ms. +- **Additional composite index**: existing `idx_event_occurrences_start_time` already covers the sort. Skipped pending profile data. + +## Technical Details + +### Files modified +- `Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift` — added `eventObjectOccurrencesJoined(filter:db:)`, `observeEventsByDayThenHour(...)`, `bucketByDayThenHour(_:)`. Tracked regions broadened to include `ObjectMetadata` + `ThumbnailColors` so favorite/color writes re-emit. +- `Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift` — added `observeEventsByDayThenHour` protocol method. +- `Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift` — `EventOccurrenceJoinedRow` gained explicit memberwise `init(...)` (preserves existing test usage) and `init(row:)` that decodes nested association scopes. Camp/art scopes are nested under the `event` scope because the associations chain off `EventObject`. +- `iBurn/ListView/EventDataProvider.swift` — added `observeObjectsByDayThenHour(filter:)` AsyncStream wrapper. +- `iBurn/ListView/EventListViewModel.swift` — `selectedDay.didSet` no longer restarts observation; added `dayBuckets: [Date: [EventHourSection]]` published storage; `browseSections` became a computed property over `dayBuckets[selectedDay]`; `browseFilter` no longer injects `startDate`/`endDate`; `restartObservation` subscribes to the day-then-hour stream in browse mode. +- `Packages/PlayaDB/Tests/PlayaDBTests/EventListBucketObservationTests.swift` — new test file (4 tests). + +### Key code paths + +**JOIN composition** (PlayaDBImpl.swift): +```swift +let eventAssociation = EventOccurrence.event.forKey("event") +var request = eventOccurrenceRequest(filter: filter, matchingEventUIDs: matchingEventUIDs) + .including(required: eventAssociation + .including(optional: EventObject.hostedCamp) + .including(optional: EventObject.locatedArt)) +``` +`.forKey("event")` overrides GRDB's default scope key (destination type name) so the decoded row exposes the EventObject row under `row.scopes["event"]`. + +**Single-pass bucketing**: rows arrive pre-sorted by `start_time` from the JOIN, so a sequential walk into `currentDay`/`currentHour` accumulators replaces `Dictionary(grouping:).sorted()`. + +**Tracked observation regions** for `observeEventsByDayThenHour`: +```swift +[EventOccurrence.all(), EventObject.all(), ObjectMetadata.all(), ThumbnailColors.all()] +``` +Intentionally excludes `camp_objects` / `art_objects` — host edits don't reshuffle the event list (parity with existing `observeEvents`). + +### Tests added +- `testBucketGroupsByDayThenHour` — multi-day fixtures bucket cleanly; sections ordered by hour; rows within a section preserve start_time order. +- `testJoinedFetchResolvesHostCamp` — `hostName` is populated from the JOIN, not a separate query. +- `testFavoriteToggleReEmits` — writing to `object_metadata` re-fires the observation. +- `testOnlyFavoritesFilterAppliesAtSqlLevel` — `onlyFavorites = true` pushed into SQL via EXISTS; non-favorited rows excluded. + +## Context Preservation + +### Debugging incidents +1. **"Missing 'event' scope in joined row"** — GRDB defaults the scope key on a `belongsTo` to the destination type's name (`"eventObject"`), not the static property name (`event`). Fix: `.forKey("event")` on the association. +2. **Host camp not resolved in JOIN** — first attempt looked for `hostedCamp` at the top-level `row.scopes`. The scope is actually nested under the `event` scope because `EventObject.hostedCamp` chains off `EventObject`, not `EventOccurrence`. Fix: `eventRow.scopes["hostedCamp"]`. +3. **Favorite toggle didn't re-emit** — first version mirrored the existing `observeEvents`' tracked-region set (`[EventOccurrence.all(), EventObject.all()]`), which excludes `ObjectMetadata`. Fix: include `ObjectMetadata.all()` and `ThumbnailColors.all()` in the new observation's tracked regions. + +### What changed in EventListViewModel +- `browseSections` is no longer `@Published` storage — it's a computed view over `dayBuckets[selectedDay]`. SwiftUI re-renders correctly because both `dayBuckets` and `selectedDay` are `@Published`. +- `selectedDay.didSet` is empty — the observation does not restart. Day-tab switching becomes O(1) in memory. +- `browseFilter()` drops `startDate`/`endDate` — the observation now produces the full festival, sliced by the UI. + +### What is NOT changed +- The legacy `observeEvents` / `observeEventsByHour` / `eventObjectOccurrences` (non-JOIN) helpers are still in place. They're used by `fetchEvents`, `fetchEvents(on:)`, etc. Cleanup is deferred to a follow-up PR — risk-free now since they share none of the modified code paths. +- The search (FTS) mode in `EventListViewModel` is unchanged. It still flows through `observeObjects(filter:)` → flat `searchResults`. + +## Expected Outcomes + +After this change: +- Day-tab switching performs **zero DB queries** (verified by the observation only being restarted on filter changes). +- Filter changes run one JOIN query instead of six sequential queries (verified by inspection of `eventObjectOccurrencesJoined`). +- Favorite toggles still update the UI (verified by `testFavoriteToggleReEmits`). +- All 136 existing PlayaDB tests + 4 new tests pass. + +## Cross-References + +- Plan file: `/Users/chrisbal/.claude/plans/okay-i-want-to-tingly-bee.md` +- Related: `Docs/2025-08-03-playadb-event-occurrence-redesign.md` (introduced `EventObjectOccurrence` composite) +- Related: `Docs/2025-07-09-grdb-transition-complete.md` (initial GRDB migration context) diff --git a/Docs/2026-05-17-event-list-day-tab-perf-round-2.md b/Docs/2026-05-17-event-list-day-tab-perf-round-2.md new file mode 100644 index 00000000..18750749 --- /dev/null +++ b/Docs/2026-05-17-event-list-day-tab-perf-round-2.md @@ -0,0 +1,67 @@ +# 2026-05-17 — Event List Day-Tab Performance, Round 2: SwiftUI Render Layer + +## High-Level Plan + +### Problem +Round 1 (see `Docs/2026-05-16-event-list-day-tab-perf.md`) collapsed the DB hot path: single long-lived observation, single JOIN, in-memory day-then-hour bucket. Tests passed and the DB layer was confirmed zero-work on tap. **But on a real device (BigPhone 17), day-tab switching was still felt as 2–3s.** + +### Investigation +Added DEBUG-only `print`-based timing at the hot points and captured a console log via `xcrun devicectl device process launch --console`. Three concrete findings: + +1. **Day-swap was 442–610ms**, not 2–3s — but still a perceptible freeze. Pattern: `EventListView.body` fires within ~2ms of `selectedDay.didSet`, then a 400–600ms gap before the first `row.body`. That gap is SwiftUI's `List` setting up internal structure for 1267–1654 rows. +2. **Startup had a metadata feedback loop**: ObjectMetadata was added to the new observation's tracked regions (so favorite toggles re-emit), and `observeListRows` always spawns a `Task { ensureMetadata(...) }` after each emission. Initial fetch inserted ~8000 blank metadata rows, which the ObjectMetadata region observed and re-fired the JOIN. Two full fetches at startup (220ms + 758ms ≈ 1s wasted). +3. **`bucketByDayThenHour` regressed to 1306ms** (was 22ms) due to ~16,712 `Calendar.startOfDay`/`component` calls under device load. + +### Fix +Three changes, layered: + +1. **Replace `List` with `ScrollView { LazyVStack { ... } }`** in `EventListView.body`. SwiftUI's `List` pre-processes all row identities on diff/recreate, costing O(rows) on every day swap regardless of laziness. `LazyVStack` only materializes rows entering the viewport. Lost: List's separators (replaced with `Divider()`) and swipe actions (rows didn't use either). **This is the win that landed the perceptual fix.** +2. **Cache `Calendar` boundaries** in `bucketByDayThenHour`. Rows arrive sorted by `start_time`, so consecutive rows nearly always share day + hour; only call `Calendar.startOfDay`/`component` when crossing a cached boundary. Cut ~16k Calendar calls to ~30. Bucket time: 1306ms → 4.8ms. +3. **`skipEnsureMetadata: true`** on the new `observeEventsByDayThenHour` observation. Adds an opt-out parameter to `observeListRows`. The fetch already tolerates nil metadata via `metaByID[uid]`, so blank pre-population is unnecessary for events. Eliminates the startup feedback loop. Initial fetch count: 2 → 1. + +### Why not other approaches +- **`.id(selectedDay)` on the List** — tried it; made things marginally worse. Teardown-recreate costs ~the same as the diff because both are O(rows). Reverted. +- **Pre-compute display strings at bucket time / cache DateFormatters** — measured impact was negligible (per-render formatter cost was a few ms total, not the bottleneck). Skipped. +- **Collapse multiple occurrences per event into one row** — would have reduced row count 3-5×, but a UX change. Not needed once LazyVStack eliminated the SwiftUI cost. + +## Technical Details + +### Files modified +- `iBurn/ListView/EventListView.swift` — replaced `List { … }` + `.listStyle(.plain)` + `.listRowInsets(…)` with `ScrollView { LazyVStack(alignment: .leading, spacing: 0) { … .padding(insets) ; Divider() } }`. `ScrollViewReader`, `.searchable`, and the `EventHourIndexView` overlay all retained — they wrap the ScrollView the same way they wrapped List. +- `Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift` + - `bucketByDayThenHour(_:)` — added cached `currentDayEnd` (exclusive day upper bound) and `currentHourStart`/`currentHourEnd`. Day-boundary and hour-boundary checks skip `Calendar` calls when the row falls within the cached interval. Behavior unchanged; existing tests cover correctness. + - `observeListRows` — added `skipEnsureMetadata: Bool = false` parameter; gates the post-emission `Task { ensureMetadata(...) }` side effect. + - `observeEventsByDayThenHour(...)` — passes `skipEnsureMetadata: true`. Comment notes the feedback-loop rationale. + +### Measurements (BigPhone 17, Debug build) +| | Before round 2 | After round 2 | +| --- | --- | --- | +| Initial JOIN count | 2 (220ms + 758ms) | 1 (~220ms) | +| `bucketByDayThenHour` (8356 rows) | 1306ms | 4.8ms | +| Day-tab swap (didSet → first row body) | 442–610ms | felt instant per user; LazyVStack bounds per-swap work to visible row count | + +### Investigation infrastructure +Console-streamed device logs via `xcrun devicectl device process launch --console --terminate-existing --device `. Captured stdout to `/tmp/iburn-console.log`, grepped for `[perf]` lines. Cleaner than Xcode console-window juggling, gives the same data. + +DEBUG-gated `print`-based timing helper (`PerfTimer`) was used during diagnosis and removed before commit (along with all `[perf]` call sites). Easy to re-introduce if perf issues recur. + +## Context Preservation + +### Debugging incidents +1. **First attempt at the fix** added `.id(viewModel.selectedDay)` to the List. Measured worse (509-659ms vs 442-610ms baseline) — teardown-recreate isn't cheaper than diff at this row count. Reverted. +2. **Bucket regression** (1306ms) was initially mysterious — same algorithm as the 22ms run in tests. Root cause: `Calendar.startOfDay`/`component` are not free on device under thermal load, especially when called per row in a tight loop on the GRDB callback thread. +3. **Metadata feedback loop** was a regression from round 1 — adding `ObjectMetadata.all()` to tracked regions (to make favorite toggles re-emit the list) interacted badly with the always-on `ensureMetadata` Task in `observeListRows`. The fix preserves favorite-toggle re-emission while skipping the bulk write. + +### Why SwiftUI's `List` is slow at this scale +`List` (backed by `UITableView` under the hood, but the SwiftUI shim does additional bookkeeping) pre-processes all row identities on diff/recreate. For ~1500 rows per day this is ~400-600ms even though only ~5 rows are visible. `LazyVStack` does not have this bookkeeping — it materializes views as they enter the viewport. Trade-off is losing some List affordances (separators, swipe actions, table-style cell reuse), but the row already had `Divider()` styling and no swipe actions. + +## Expected Outcomes + +- Day-tab switching feels instant on real devices. +- Initial load is bounded by a single JOIN (~220ms on BigPhone 17). +- All 136 PlayaDB tests pass; 4 `EventListBucketObservationTests` continue to cover the observation behavior unchanged. + +## Cross-References + +- Round 1 doc: `Docs/2026-05-16-event-list-day-tab-perf.md` (DB layer) +- Plan file: `~/.claude/plans/okay-i-want-to-tingly-bee.md` (updated with round 2 strategy) diff --git a/Docs/2026-05-29-ai-guide-right-now-overhaul.md b/Docs/2026-05-29-ai-guide-right-now-overhaul.md new file mode 100644 index 00000000..5e4ee85c --- /dev/null +++ b/Docs/2026-05-29-ai-guide-right-now-overhaul.md @@ -0,0 +1,120 @@ +# 2026-05-29 — AI Guide Overhaul: Single "Right Now" Flow + +## High-Level Plan + +### Problem +The AI Guide (More tab → "AI Guide") shipped on the `ai-event-summary` branch as 8 duplicative workflows +(catalog → per-workflow config screen) + a separate Chat surface + an orphaned "assistant" path. The flows +overlap heavily (For You and Surprise Me are the *same* `SerendipityWorkflow`; Adventure/Camp Crawl/Golden +Hour all return a `RouteResult`; Day Planner/Schedule Optimizer both produce schedules) and the top-level +input is thin. The user judged it "pretty rough." + +### Direction (from the user, locked) +Collapse everything into **one flow** focused on **immediacy**: *"discover what's near you happening now, +and what to do next."* Route planning, day/schedule planning, the retrospective "What did I miss," the +chat, and the dead assistant path are all **cut**. Entry is a single "ask" screen: free-text + suggestion +chips ("Coffee", …) + a compact filter bar (time-of-day picker + place, including a **map-area picker**). +Place defaults to current location. Tone concise / no-snark. + +### Decision trail (how we got here) +1. "Improve the AI guide, it's rough" → explored; surface is huge. +2. Focus = top-level UX; flows are duplicative; want suggestion chips + structured inputs (time of day, + start location, region of city). Scope = comprehensive overhaul; AI Guide workflows first. +3. "Route planning is useless… same with time planning. Needs immediacy." → single ask screen; fold chat; + region = map-area picker. +4. "Don't keep What did I miss." "Delete the dropped code." Time-of-day = sunrise/morning/midday/ + afternoon/evening/night/late-night. +5. "One flow" → discover what's near you happening now + what to do next. → 8 workflows collapse to **1**. + +## Architecture + +### One workflow: `RightNowWorkflow` +Given a vibe (+ time-of-day window + place), returns `RightNowResult { intro, now, next }`: +- **Now near you** = currently-happening events + nearby art/camps/MVs matching the vibe. +- **What to do next** = upcoming events (starting within the window) matching the vibe. +Steps: read taste (favorites, honor `lean`) → gather now/next candidates (place + time aware) → +one LLM curation+pitch call (wrapped in `withContextWindowRetry` + `retryWithCandidateFiltering`) → +resolve UIDs. Non-LLM steps kept as pure functions for tests. Walk time annotated via `playaWalkMinutes`. + +### Engine changes +- `WorkflowContext` (`WorkflowProtocol.swift`) gains `region: MKCoordinateRegion?`, `windowStart/End`, + `vibe`, `lean: DiscoveryLean`. +- `AgentOrchestrator.execute` threads `region/window/vibe/lean`; "now" anchored on `Date.present`. +- `TimeOfDay` + `dateWindow(for:now:)` (pure Swift, ungated): `.now` (default) → (now, now+2h); named + periods (sunrise … lateNight) → BRC-local windows, lateNight spans into next-day early AM; clamped to + `YearSettings.eventStart…eventEnd`. +- `Vibe`/`SuggestionChip` (ungated): chip catalog + `eventTypeCodes(forVibe:)`. Chips map to `(vibe, lean)`. +- `DiscoveryLean` (ungated): `.personalized/.surprise/.balanced`. + +### UI +- `RightNowView` (new) replaces `AIGuideView` + `WorkflowDetailView` and folds chat. Free-text + chips + + time-of-day Picker + place ("Near me" / "Pick area on map…") + Go + Now/Next results. +- `AIGuideViewModel` rewritten into the single-screen VM (concise honest error copy). +- `AreaPickerView` + `AreaPickerRepresentable` (new) on the `TimeShiftMapView` pattern; visible bounds → + `MKCoordinateRegion`. +- `MoreViewController.pushAIGuideView()` pushes `RightNowView`. + +### Must-keep (verified) +- `WorkflowUtilities.swift` + `GenerableEventCollectionSummary`/`GenerableFactCheck` → used by + `DetailViewModel` for camp/art event summaries (a non-AI-Guide feature). +- `AISearchService.search` + `AISearchServiceFactory.create` → used by `GlobalSearchViewModel`. +- `playaWalkMinutes`. + +### Delete (Phase 4) +7 dropped workflows + `WhatDidIMissWorkflow` + `GeneralChatWorkflow`; chat (`ChatView`/`Bubble`/`ViewModel`, +`ConversationManager`, `IntentClassifier`); catalog (`AIGuideView`/`WorkflowDetailView`/`WorkflowCatalog`); +assistant path (`AIAssistantView`/`AIAssistantViewModel`, `AIAssistantService` methods + `…createAssistant`). +Trim dead types from `AIAssistantModels`/`AISearchService`/`DependencyContainer`/`PlayaProgressMessages`/ +`WorkflowProtocol`. Move `AIAssistantViewModel.ResolvedObject` → `AIResolvedObject`. + +## Key Facts Discovered +- **Xcode 16 synchronized file groups** (`PBXFileSystemSynchronizedRootGroup`): `iBurn/` and `iBurnTests/` + auto-include new files / auto-drop deleted ones. No `project.pbxproj` edits needed. (Only `iBurn-Info.plist` + + `DetailActionCoordinatorTests.swift`/`Info.plist` are membership exceptions.) +- PlayaDB query API: `fetchEvents(filter: EventFilter(region:startDate:endDate:happeningNow:startingWithinHours:eventTypeCodes:includeExpired:))`, + `fetchCurrentEvents(_ now:)`, `fetchUpcomingEvents(within:from:)`, `fetchObjects(in: MKCoordinateRegion) -> [any DataObject]`, + `searchObjects(_) -> [any DataObject]`, `getFavorites() -> [any DataObject]`, `ArtFilter`/`CampFilter` have `region` + (MutantVehicleFilter does not — MVs have no GPS). +- `[any DataObject]` exposes `uid`/`name`/`objectType`; concrete casts (`as? ArtObject`) needed for GPS. +- `Date.present` (`Date+iBurn.swift`) is the app-wide injectable "now". `TimeZone.burningManTimeZone` exists. +- Result-resolution helper `resolveObject(uid:playaDB:)` / `resolveDiscoveryItems` in `WorkflowUtilities.swift`. + +## Progress — COMPLETE (pending review/commit) +- [x] Exploration + plan approved (`~/.claude/plans/what-were-we-up-zippy-dongarra.md`). +- [x] Phase 1: engine (`WorkflowContext`/orchestrator), `TimeOfDay`, `Vibe`, `RightNowWorkflow`, `AIResolvedObject`. +- [x] Phase 2: `RightNowView` + new `RightNowViewModel` + `MoreViewController`/`DependencyContainer` rewire. +- [x] Phase 3: `AreaPickerView` + `AreaPickerMapRepresentable` + sheet wiring. +- [x] Phase 4: deleted 21 files; trimmed `AISearchService`/`AIAssistantModels`/`DependencyContainer`/ + `WorkflowProtocol`/`WorkflowUtilities`; preserved progress types in `WorkflowProgressTypes.swift`. + No `project.pbxproj` edits needed (synchronized groups). +- [x] Phase 5: tests — `TimeOfDayTests` (6), `VibeTests` (8), `AreaRegionTests` (2), + `RightNowCandidateTests` (4); pruned dead assistant tests from `AISearchToolTests`. + +### Files added +`TimeOfDay.swift`, `Vibe.swift`, `AIResolvedObject.swift`, `WorkflowProgressTypes.swift`, +`Workflows/RightNowWorkflow.swift`, `RightNowViewModel.swift`, `RightNowView.swift`, `AreaPickerView.swift`; +tests `TimeOfDayTests.swift`, `VibeTests.swift`, `AreaRegionTests.swift`, `RightNowCandidateTests.swift`. + +### Results +- App builds clean (`iBurn` scheme): **0 errors**. Remaining warnings are all pre-existing files + (MainMapViewController, DataUpdatesView, DetailViewModel, NearbyListHostingController) — none from this work. +- Full `iBurnTests`: **87 run, 86 pass, 1 fail**. All AI tests pass; `GlobalSearchViewModelTests` (10) pass, + confirming the kept semantic-search path. The one failure — + `ObjectListViewModelTests.testToggleFavoriteCallsProvider` (line 212, ~1.05s timeout) — is a + **pre-existing, deterministic** failure in `ListView/ObjectListViewModel` (generic VM + mock provider, + observation stream not reflecting a favorite toggle in the harness). It is outside the AI Guide surface + (no files I changed touch it) and fails identically in isolation. Not caused by this work; left as-is. + +### Decisions worth remembering +- `RightNowWorkflow` deliberately does NOT set `EventFilter.includeExpired = false` for the "next" pool — + `startDate = max(windowStart, now)` already excludes started/ended events, and `includeExpired` filters + against the *real* current date (breaks past-window queries / tests / off-season). +- Event type codes follow `EventTypeInfo` (DB codes: `tea`, `live`, `medt`, `prde`, `sprt`, …), NOT the + PlayaAPI `EventType` raw values. +- Pure helpers (`TimeOfDay`/`dateWindow`, `Vibe`/`eventTypeCodes`, `coordinateRegion`, + `gatherRightNowCandidates`) are kept LLM-free so they're unit-testable on any simulator. + +## Verification +- Build per phase: `xcodebuild -workspace iBurn.xcworkspace -scheme iBurn -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2,arch=arm64' 2>&1 | xcsift -f toon -w`. +- Tests: `iBurnTests`, `PlayaKitTests`. +- Regression: global semantic search; camp/art detail event summaries. diff --git a/Docs/2026-05-30-nearby-card-on-map.md b/Docs/2026-05-30-nearby-card-on-map.md new file mode 100644 index 00000000..658270ca --- /dev/null +++ b/Docs/2026-05-30-nearby-card-on-map.md @@ -0,0 +1,90 @@ +# 2026-05-30 — Nearby Card on the Main Map + +## High-Level Plan + +### Problem / Need +The main map (`MainMapViewController`) shows pins but has no glanceable "what is right +here next to me" affordance. We want a compact, swipeable card pinned near the bottom of +the map that appears when the user is physically near art/camps/events, opens detail on tap, +favorites items, plays audio tours where present, and minimizes into a badged FAB with a +Liquid Glass morph. + +### Solution Overview +A SwiftUI card hosted as a **proper child view controller** of the UIKit map (added via +`addChild`/`didMove(toParent:)`, not by extracting the inner `UIView`). It reuses the app's +existing data layer (data providers, `NearbyItem`, `RowAssetsLoader`, `AudioTourButton`, +`DetailViewControllerFactory`, `CoreLocationProvider`) — only the UI is new. + +### Decisions (from user) +- **Proximity radius**: ~100 m (tight, "what's right here"). +- **Types**: any object with GPS → art, camps, events. Mutant vehicles excluded (no GPS). +- **Card content**: image, title, one-line description, event timing, audio play button + (audio-tour art only), and a favorite heart. Minimal. +- **Deployment target is iOS 16.6** → iOS 26 Liquid Glass APIs gated behind + `#if canImport(FoundationModels)` + `if #available(iOS 26.0, *)`, with a + `.ultraThinMaterial` + `matchedGeometryEffect` fallback (mirrors the codebase's existing + iOS-26 gating pattern in `DependencyContainer.makeAIGuideViewModel`). + +### Prerequisite (done) +Rebased the worktree branch `claude/fervent-knuth-adf76a` onto `ai-event-summary` +(fast-forward to `a455b2e` — "PlayaDB: index event region queries with a spatio-temporal +R*Tree"). This makes `EventFilter(region:)` queries R*Tree-backed (fast + correct: events +hosted by in-region camps now return). No public API change; the card just builds on it. + +This card is **separate** from the planned "merge Nearby + Right Now" work +(`~/.claude/plans/what-were-we-up-zippy-dongarra.md`, Phase B). It reuses the same providers +and `NearbyItem` so the two stay consistent. + +## Technical Details + +### Files created (under `iBurn/Map/NearbyCard/` — Xcode 16 synchronized group, auto-included) +- `NearbyCardViewModel.swift` — `@MainActor ObservableObject`. Observes + `Art/Camp/EventDataProvider.observeObjects(filter:)` over a ~100 m region + consumes + `CoreLocationProvider.locationStream`. Emits a flat, ordered `[NearbyItem]`: + - Events first (happening now / starting soon at `now`, by start time), then art + camps + merged by distance; gated to `nearbyRadius` (100 m), de-duped by id, capped to 12. + - Ordering extracted into pure `static func orderedItems(...)` for unit testing. + - `selectedID` tracked by `NearbyItem.id` (not index) and preserved across rebuilds so + location ticks don't yank the user mid-swipe; `isMinimized`; `now` refresh timer (30 s). + - Region recenters only after the user moves ≥ 25 m; small moves just re-sort/re-gate. +- `NearbyCardView.swift` — paged card (`TabView` `.page` style, custom dots), compact + `NearbyCardContentView` (thumbnail via `RowAssetsLoader`, 1-line title/description, event + timing via `EventObjectOccurrence.timeDescription(now:)`, heart, `AudioTourButton` when + `assets.audioURL != nil`), minimize button, badged FAB. `GlassSurface` modifier applies + `.glassEffect(.regular.interactive(), …)` + shared `.glassEffectID("nearbyCard", …)` in a + `GlassEffectContainer` on iOS 26 (morph between card and FAB), else material + matched + geometry. Stable `cardWidth = min(380, screen − 32)`. +- `NearbyCardHostingController.swift` — `UIHostingController`, owns the VM, + `view.backgroundColor = .clear` + `sizingOptions = [.intrinsicContentSize]` (hosting view + hugs the card/FAB, so the rest of the map stays interactive), pushes + `DetailViewControllerFactory.create(with: subject, playaDB:)` on tap. + +### Files modified +- `iBurn/DependencyContainer.swift` — added `makeNearbyCardViewModel()`. +- `iBurn/MainMapViewController.swift` — store `dependencies`, add `setupNearbyCard()` which + `addChild`s the hosting controller bottom-centered (`autoAlignAxis(.vertical)` + + `autoPinEdge(toSuperviewMargin: .bottom)` −12) and calls `didMove(toParent:)`. + +### Tests +- `iBurnTests/NearbyCardViewModelTests.swift` — 7 tests against `orderedItems(...)`: + events-first (even when farther), radius exclusion, art/camp distance sort, ended-event + exclusion, dedup by id, cap to maxItems, drop no-GPS objects. Uses real PlayaDB model + inits (no DB), `@MainActor`, no force-unwraps. + +## Outcomes / Verification +- `xcodebuild build` (iBurn, iPhone 17 Pro Max, OS 26.2): **0 errors, 0 warnings** — the + Liquid Glass path compiles against the iOS 26.2 SDK. +- `xcodebuild test -only-testing:iBurnTests/NearbyCardViewModelTests`: **7 passed, 0 failed**. +- Note: SPM resolution requires network, so builds were run with the command sandbox + disabled. + +### Not yet done +- On-device/simulator visual confirmation of the card + glass morph. Location data is + **embargoed until gates open** (current date 2026-05-30), so art/camp coordinates are + withheld and a quick simulator run likely surfaces no nearby objects. Visual verification + needs un-embargoed/dev data + a simulated GPS fix inside Black Rock City. + +### Possible follow-ups +- Pause the VM's location polling / refresh timer when the map tab isn't visible (battery). +- Minor: on narrow screens the centered card can slightly overlap the (currently + non-functional) bottom-left `SidebarButtonsView`. diff --git a/Packages/PlayaDB/Sources/PlayaDB/DataObject.swift b/Packages/PlayaDB/Sources/PlayaDB/DataObject.swift index 72c101ba..78835389 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/DataObject.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/DataObject.swift @@ -49,4 +49,9 @@ public extension DataObject { var hasDescription: Bool { description != nil && !description!.isEmpty } +} + +/// Data object that represents a physical place with a playa address. +public protocol PlaceDataObject: DataObject { + var address: String? { get } } \ No newline at end of file diff --git a/Packages/PlayaDB/Sources/PlayaDB/Filters/FilterRegion.swift b/Packages/PlayaDB/Sources/PlayaDB/Filters/FilterRegion.swift index 94d967bb..636bc4fe 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/Filters/FilterRegion.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/Filters/FilterRegion.swift @@ -41,4 +41,10 @@ public struct FilterRegion: Hashable, Codable { ) ) } + + /// Bounding box derived from center ± span/2. + public var bounds: (minLat: Double, maxLat: Double, minLon: Double, maxLon: Double) { + (centerLatitude - latitudeDelta / 2, centerLatitude + latitudeDelta / 2, + centerLongitude - longitudeDelta / 2, centerLongitude + longitudeDelta / 2) + } } diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/ArtObject.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/ArtObject.swift index a9cbff41..d6efd518 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/Models/ArtObject.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/ArtObject.swift @@ -242,6 +242,12 @@ public extension ArtObject { } } +// MARK: - PlaceDataObject + +extension ArtObject: PlaceDataObject { + public var address: String? { locationString ?? timeBasedAddress } +} + // MARK: - Relationships public extension ArtObject { diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/CampObject.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/CampObject.swift index ad57d409..d3b555ac 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/Models/CampObject.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/CampObject.swift @@ -250,6 +250,12 @@ public extension CampObject { } } +// MARK: - PlaceDataObject + +extension CampObject: PlaceDataObject { + public var address: String? { locationString ?? intersection } +} + // MARK: - Relationships public extension CampObject { diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/EventHourSection.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/EventHourSection.swift new file mode 100644 index 00000000..8d98b595 --- /dev/null +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/EventHourSection.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A group of event-occurrence rows sharing the same hour-of-day, +/// shaped at the data layer so the UI consumes already-grouped sections. +public struct EventHourSection { + /// 0...23 — stable identifier for `ScrollViewReader`. + public let hour: Int + + public let rows: [ListRow] + + public init(hour: Int, rows: [ListRow]) { + self.hour = hour + self.rows = rows + } +} diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/EventObject.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/EventObject.swift index e5474339..0c535181 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/Models/EventObject.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/EventObject.swift @@ -226,7 +226,15 @@ public extension EventObject { public extension EventObject { /// Define relationship to event occurrences static let occurrences = hasMany(EventOccurrence.self, using: ForeignKey(["event_id"])) - + + /// Define relationship to hosting camp + static let hostedCamp = belongsTo(CampObject.self, key: "hostedCamp", + using: ForeignKey(["hosted_by_camp"], to: ["uid"])) + + /// Define relationship to hosting art installation + static let locatedArt = belongsTo(ArtObject.self, key: "locatedArt", + using: ForeignKey(["located_at_art"], to: ["uid"])) + /// Associated occurrences request var occurrences: QueryInterfaceRequest { request(for: EventObject.occurrences) diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift index b57ca431..cdb3f2c6 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/EventObjectOccurrence.swift @@ -1,5 +1,6 @@ import Foundation import CoreLocation +import GRDB /// Composite object that combines an EventObject with a specific EventOccurrence /// This provides backward compatibility with existing code that expects individual event objects with start/end dates @@ -8,16 +9,26 @@ public struct EventObjectOccurrence: DataObject { /// The base event data public let event: EventObject - + /// The specific occurrence/timing data public let occurrence: EventOccurrence - + + /// Pre-resolved host object (camp or art), populated during JOIN query + public let host: (any PlaceDataObject)? + // MARK: - Initialization - - public init(event: EventObject, occurrence: EventOccurrence) { + + public init(event: EventObject, occurrence: EventOccurrence, host: (any PlaceDataObject)? = nil) { self.event = event self.occurrence = occurrence + self.host = host } + + /// Host name for display in list cells + public var hostName: String? { host?.name } + + /// Host address for display in list cells + public var hostAddress: String? { host?.address } // MARK: - DataObject Protocol Conformance @@ -277,4 +288,54 @@ public extension EventObjectOccurrence { formatter.dateFormat = "EEEE" return formatter.string(from: startDate) } +} + +// MARK: - GRDB Joined Row + +/// Decode struct for EventOccurrence JOIN EventObject LEFT JOIN CampObject LEFT JOIN ArtObject. +/// Driving table columns decode from the top-level row; associations decode from named scopes +/// matching the association `key:` values ("event", "hostedCamp", "locatedArt"). +struct EventOccurrenceJoinedRow: FetchableRecord { + var occurrence: EventOccurrence + var event: EventObject + var hostedCamp: CampObject? + var locatedArt: ArtObject? + + init( + occurrence: EventOccurrence, + event: EventObject, + hostedCamp: CampObject? = nil, + locatedArt: ArtObject? = nil + ) { + self.occurrence = occurrence + self.event = event + self.hostedCamp = hostedCamp + self.locatedArt = locatedArt + } + + init(row: Row) throws { + self.occurrence = try EventOccurrence(row: row) + guard let eventRow = row.scopes["event"] else { + throw DatabaseError(message: "Missing 'event' scope in joined row") + } + self.event = try EventObject(row: eventRow) + + // Camp/art scopes are nested under the event scope because the associations + // chain off EventObject (event → camp/art), not off the driving EventOccurrence. + if let campRow = eventRow.scopes["hostedCamp"], campRow["uid"] != nil { + self.hostedCamp = try CampObject(row: campRow) + } else { + self.hostedCamp = nil + } + if let artRow = eventRow.scopes["locatedArt"], artRow["uid"] != nil { + self.locatedArt = try ArtObject(row: artRow) + } else { + self.locatedArt = nil + } + } + + func toEventObjectOccurrence() -> EventObjectOccurrence { + let host: (any PlaceDataObject)? = hostedCamp ?? locatedArt + return EventObjectOccurrence(event: event, occurrence: occurrence, host: host) + } } \ No newline at end of file diff --git a/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift b/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift index ac797ab7..fe22c05d 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift @@ -124,7 +124,28 @@ public protocol PlayaDB { onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken - + + /// Observe event occurrences pre-grouped by hour-of-day. Sections are sorted + /// ascending by hour; rows within a section preserve the underlying ordering + /// from `observeEvents` (start time). + @discardableResult + func observeEventsByHour( + filter: EventFilter, + onChange: @escaping ([EventHourSection]) -> Void, + onError: @escaping (Error) -> Void + ) -> PlayaDBObservationToken + + /// Observe event occurrences bucketed by start-day then hour-of-day. The day key is + /// the device-calendar `startOfDay` for each occurrence's start time. Use this for the + /// browse list: subscribe once with a full-festival filter, then slice the result by + /// day in the UI so day-tab switching never re-hits the database. + @discardableResult + func observeEventsByDayThenHour( + filter: EventFilter, + onChange: @escaping ([Date: [EventHourSection]]) -> Void, + onError: @escaping (Error) -> Void + ) -> PlayaDBObservationToken + // MARK: - Single Object Fetch /// Fetch a single art object by UID diff --git a/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift b/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift index fd3ac045..25f4f07f 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift @@ -277,6 +277,14 @@ internal class PlayaDBImpl: PlayaDB { // Create R-Tree spatial index for geographic queries try setupRTreeIndex(db) + + // Backfill the occurrence index for installs whose DB predates it (existing users + // don't re-import; PlayaDBSeeder only imports when update_info is empty). + let occRtreeCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM event_occurrence_rtree") ?? 0 + let occCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM event_occurrences") ?? 0 + if occRtreeCount == 0, occCount > 0 { + try rebuildOccurrenceRTree(db) + } } } @@ -518,8 +526,91 @@ internal class PlayaDBImpl: PlayaDB { DELETE FROM spatial_objects WHERE object_type = 'event' AND object_uid = OLD.uid; END """) + + // Spatial R*Tree over event occurrences (point index keyed by event_occurrences.id, + // so no mapping table is needed). lat/lon come from the parent event's denormalized + // GPS; this is a pure spatial prefilter for region-scoped event queries. + // + // Migration: an earlier version added minT/maxT time columns. They were never queried + // (occurrenceIDsInRegion is spatial-only) and, for occurrences whose stored date + // strings don't parse via SQLite strftime (or whose end precedes start), produced + // minT > maxT and tripped the rtree's (minT<=maxT) constraint — failing the seed + // import outright. Drop that variant and recreate the index spatial-only. + let rtreeColumns = try Row.fetchAll(db, sql: "PRAGMA table_info(event_occurrence_rtree)") + .compactMap { $0["name"] as String? } + if rtreeColumns.contains("minT") { + try db.execute(sql: "DROP TRIGGER IF EXISTS event_occurrence_rtree_insert") + try db.execute(sql: "DROP TRIGGER IF EXISTS event_occurrence_rtree_delete") + try db.execute(sql: "DROP TABLE IF EXISTS event_occurrence_rtree") + } + try db.execute(sql: """ + CREATE VIRTUAL TABLE IF NOT EXISTS event_occurrence_rtree USING rtree( + id, + minLat, maxLat, + minLon, maxLon + ) + """) + + // Maintain the occurrence index on direct writes (import also rebuilds it wholesale). + // lat/lon come from the parent event's denormalized GPS. + try db.execute(sql: """ + CREATE TRIGGER IF NOT EXISTS event_occurrence_rtree_insert + AFTER INSERT ON event_occurrences + WHEN EXISTS ( + SELECT 1 FROM event_objects e + WHERE e.uid = NEW.event_id + AND e.gps_latitude IS NOT NULL AND e.gps_longitude IS NOT NULL + ) + BEGIN + INSERT OR REPLACE INTO event_occurrence_rtree (id, minLat, maxLat, minLon, maxLon) + SELECT NEW.id, e.gps_latitude, e.gps_latitude, e.gps_longitude, e.gps_longitude + FROM event_objects e WHERE e.uid = NEW.event_id; + END + """) + try db.execute(sql: """ + CREATE TRIGGER IF NOT EXISTS event_occurrence_rtree_delete + AFTER DELETE ON event_occurrences + BEGIN + DELETE FROM event_occurrence_rtree WHERE id = OLD.id; + END + """) } - + + /// Rebuild the occurrence spatial index from current data. Indexes each occurrence whose + /// parent event has GPS, using the event's denormalized coordinate as a point. + func rebuildOccurrenceRTree(_ db: Database) throws { + try db.execute(sql: "DELETE FROM event_occurrence_rtree") + let rows = try Row.fetchAll(db, sql: """ + SELECT o.id AS id, e.gps_latitude AS lat, e.gps_longitude AS lon + FROM event_occurrences o + JOIN event_objects e ON e.uid = o.event_id + WHERE e.gps_latitude IS NOT NULL AND e.gps_longitude IS NOT NULL + """) + for row in rows { + let id: Int64 = row["id"] + let lat: Double = row["lat"] + let lon: Double = row["lon"] + try db.execute(sql: """ + INSERT OR REPLACE INTO event_occurrence_rtree (id, minLat, maxLat, minLon, maxLon) + VALUES (?, ?, ?, ?, ?) + """, arguments: [id, lat, lat, lon, lon]) + } + } + + /// Occurrence ids whose host location falls within `region`, via the spatial R*Tree. + /// Time filtering stays in SQL. Used to push event region filtering into the query + /// instead of filtering rows client-side. + private func occurrenceIDsInRegion(_ db: Database, region: MKCoordinateRegion) throws -> [Int64] { + let minLat = region.center.latitude - region.span.latitudeDelta / 2 + let maxLat = region.center.latitude + region.span.latitudeDelta / 2 + let minLon = region.center.longitude - region.span.longitudeDelta / 2 + let maxLon = region.center.longitude + region.span.longitudeDelta / 2 + return try Int64.fetchAll(db, sql: """ + SELECT id FROM event_occurrence_rtree + WHERE maxLat >= ? AND minLat <= ? AND maxLon >= ? AND minLon <= ? + """, arguments: [minLat, maxLat, minLon, maxLon]) + } + // MARK: - Data Access Methods func fetchArt() async throws -> [ArtObject] { @@ -959,8 +1050,13 @@ internal class PlayaDBImpl: PlayaDB { return request.orderedByName() } - /// Build an event occurrence query from filter options (internal - uses GRDB types) - internal func eventOccurrenceRequest(filter: EventFilter) -> QueryInterfaceRequest { + /// Build an event occurrence query from filter options (internal - uses GRDB types). + /// `matchingEventUIDs` constrains occurrences to events whose UIDs match an FTS query; + /// pass `nil` to skip search filtering. + internal func eventOccurrenceRequest( + filter: EventFilter, + matchingEventUIDs: Set? = nil + ) -> QueryInterfaceRequest { var request = EventOccurrence.all() // Apply time-based filters @@ -983,18 +1079,110 @@ internal class PlayaDBImpl: PlayaDB { request = request.filter(EventOccurrence.Columns.startTime < endDate) } + // FTS5 search constraint (UIDs pre-resolved against event_objects_fts) + if let uids = matchingEventUIDs { + request = request.filter(uids.contains(EventOccurrence.Columns.eventId)) + } + // Default ordering by start time return request.orderedByStartTime() } + /// JOIN-based variant of `eventObjectOccurrences(filter:db:)` that fetches occurrence + + /// parent event + host (camp or art) in a single SQL JOIN. Replaces the prior + /// 4-sequential-query pattern (occurrences → events IN(…) → camps IN(…) → arts IN(…)). + /// + /// Pushes favorites / year / event-type filters into SQL. Region/bbox filter remains + /// client-side (sparse GPS on events; no spatial index payoff). + internal func eventObjectOccurrencesJoined( + filter: EventFilter, + db: Database + ) throws -> [EventObjectOccurrence] { + // FTS5 pre-resolve against event_objects_fts (same pattern as the non-joined helper). + let matchingEventUIDs: Set? + if let searchText = filter.searchText, !searchText.isEmpty { + let uids = try EventObject.all() + .matching(searchText: searchText) + .select(EventObject.Columns.uid, as: String.self) + .fetchAll(db) + matchingEventUIDs = Set(uids) + } else { + matchingEventUIDs = nil + } + + // Base occurrence query (date/time/notExpired/search constraints applied via existing helper). + // `forKey("event")` overrides GRDB's default scope key (destination type name) so the + // joined row exposes the EventObject row under `row.scopes["event"]`, matching + // EventOccurrenceJoinedRow.init(row:). + let eventAssociation = EventOccurrence.event.forKey("event") + var request = eventOccurrenceRequest( + filter: filter, + matchingEventUIDs: matchingEventUIDs + ) + .including(required: eventAssociation + .including(optional: EventObject.hostedCamp) + .including(optional: EventObject.locatedArt)) + + // Push remaining filters into SQL. + if filter.onlyFavorites { + let predicate: SQL = SQL(""" + EXISTS ( + SELECT 1 FROM object_metadata + WHERE object_metadata.object_type = \(DataObjectType.event.rawValue) + AND object_metadata.object_id = event_occurrences.event_id + AND object_metadata.is_favorite = 1 + ) + """) + request = request.filter(predicate) + } + if let year = filter.year { + request = request.joining(required: eventAssociation + .filter(EventObject.Columns.year == year)) + } + if let codes = filter.eventTypeCodes, !codes.isEmpty { + request = request.joining(required: eventAssociation + .filter(codes.contains(EventObject.Columns.eventTypeCode))) + } + // Region → indexed prefilter on occurrence ids (R*Tree), matching the non-joined path. + if let region = filter.region { + let regionIDs = try occurrenceIDsInRegion(db, region: region) + request = request.filter(regionIDs.contains(EventOccurrence.Columns.id)) + } + + let joined = try EventOccurrenceJoinedRow.fetchAll(db, request) + return joined.map { $0.toEventObjectOccurrence() } + } + private func eventObjectOccurrences( filter: EventFilter, db: Database ) throws -> [EventObjectOccurrence] { - let occurrenceRequest = eventOccurrenceRequest(filter: filter) + // Pre-resolve FTS5 search to event UIDs against event_objects_fts (parent table). + // .matching(searchText:) keys off RowDecoder.databaseTableName + "_fts", and the + // events FTS table indexes EventObject columns (name/description/event_type_label/ + // print_description), not EventOccurrence — so the match must run on EventObject. + let matchingEventUIDs: Set? + if let searchText = filter.searchText, !searchText.isEmpty { + let uids = try EventObject.all() + .matching(searchText: searchText) + .select(EventObject.Columns.uid, as: String.self) + .fetchAll(db) + matchingEventUIDs = Set(uids) + } else { + matchingEventUIDs = nil + } + + var occurrenceRequest = eventOccurrenceRequest( + filter: filter, + matchingEventUIDs: matchingEventUIDs + ) + // Region → indexed prefilter on occurrence ids (R*Tree). Time/type stay exact below. + if let region = filter.region { + let regionIDs = try occurrenceIDsInRegion(db, region: region) + occurrenceRequest = occurrenceRequest.filter(regionIDs.contains(EventOccurrence.Columns.id)) + } let occurrences = try occurrenceRequest.fetchAll(db) - // Batch-resolve parent events let pairs = try eventObjectOccurrences(for: occurrences, db: db) let favoriteEventIds: Set @@ -1023,25 +1211,7 @@ internal class PlayaDBImpl: PlayaDB { return false } - if let region = filter.region { - guard let lat = event.gpsLatitude, let lon = event.gpsLongitude else { return false } - let minLat = region.center.latitude - region.span.latitudeDelta / 2 - let maxLat = region.center.latitude + region.span.latitudeDelta / 2 - let minLon = region.center.longitude - region.span.longitudeDelta / 2 - let maxLon = region.center.longitude + region.span.longitudeDelta / 2 - if lat < minLat || lat > maxLat || lon < minLon || lon > maxLon { - return false - } - } - - if let searchText = filter.searchText, !searchText.isEmpty { - let lowerSearch = searchText.lowercased() - let nameMatch = event.name.lowercased().contains(lowerSearch) - let descMatch = event.description?.lowercased().contains(lowerSearch) ?? false - if !nameMatch && !descMatch { - return false - } - } + // Region is filtered in SQL via the occurrence R*Tree prefilter (see above). if let allowedTypes = filter.eventTypeCodes, !allowedTypes.isEmpty { if !allowedTypes.contains(event.eventTypeCode) { @@ -1083,15 +1253,20 @@ internal class PlayaDBImpl: PlayaDB { /// Observe objects as fully-inflated ListRows. Fetches objects, metadata, and /// thumbnail colors in a single read transaction. + /// - Parameter regions: Explicit observation regions. When provided, only changes to these + /// regions trigger re-evaluation. The fetch closure can read from any table freely. + /// When nil, GRDB auto-tracks all tables accessed in the fetch closure. private func observeListRows( type: DataObjectType, ids: @escaping ([T]) -> [String], + regions: [any DatabaseRegionConvertible]? = nil, + skipEnsureMetadata: Bool = false, value: @escaping @Sendable (Database) throws -> [T], onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { let typeRaw = type.rawValue - let observation = ValueObservation.tracking { db -> [ListRow] in + let fetch: @Sendable (Database) throws -> [ListRow] = { db in let objects = try value(db) let objectIDs = ids(objects) guard !objectIDs.isEmpty else { return [] } @@ -1118,14 +1293,23 @@ internal class PlayaDBImpl: PlayaDB { ) } } + + let observation: ValueObservation]>> + if let regions { + observation = ValueObservation.tracking(regions: regions, fetch: fetch) + } else { + observation = ValueObservation.tracking(fetch) + } let cancellable = observation.start( in: dbQueue, onError: onError, onChange: { [weak self] rows in - let identifiers = ids(rows.map(\.object)) - if !identifiers.isEmpty { - Task { - try? await self?.ensureMetadata(for: type, ids: identifiers) + if !skipEnsureMetadata { + let identifiers = ids(rows.map(\.object)) + if !identifiers.isEmpty { + Task { + try? await self?.ensureMetadata(for: type, ids: identifiers) + } } } onChange(rows) @@ -1173,9 +1357,13 @@ internal class PlayaDBImpl: PlayaDB { onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { + // Explicitly scope observation to event tables only. + // The fetch closure also JOINs camp_objects/art_objects for host data, + // but changes to those tables should not trigger re-evaluation. observeListRows( type: .event, ids: { $0.map { $0.event.uid } }, + regions: [EventOccurrence.all(), EventObject.all(), Table("event_occurrence_rtree")], value: { [weak self, filter] db in guard let self else { return [] } return try self.eventObjectOccurrences(filter: filter, db: db) @@ -1202,6 +1390,130 @@ internal class PlayaDBImpl: PlayaDB { ) } + func observeEventsByHour( + filter: EventFilter, + onChange: @escaping ([EventHourSection]) -> Void, + onError: @escaping (Error) -> Void + ) -> PlayaDBObservationToken { + observeEvents(filter: filter, onChange: { rows in + onChange(Self.groupByHour(rows)) + }, onError: onError) + } + + func observeEventsByDayThenHour( + filter: EventFilter, + onChange: @escaping ([Date: [EventHourSection]]) -> Void, + onError: @escaping (Error) -> Void + ) -> PlayaDBObservationToken { + // Tracked regions: event tables drive bucket membership/order; ObjectMetadata is needed + // so favorite toggles refresh the heart UI; ThumbnailColors so cached-color writes refresh + // the row chrome. Camp/art tables are intentionally excluded — host edits don't reshuffle + // the event list. + // skipEnsureMetadata: avoids a startup feedback loop where the first emission + // would write 8000+ blank metadata rows, which the ObjectMetadata region would + // immediately observe and re-fire the JOIN. Fetch already tolerates nil metadata + // via `metaByID[uid]` so blank pre-population is unnecessary. + observeListRows( + type: .event, + ids: { $0.map { $0.event.uid } }, + regions: [ + EventOccurrence.all(), + EventObject.all(), + ObjectMetadata.all(), + ThumbnailColors.all(), + Table("event_occurrence_rtree") + ], + skipEnsureMetadata: true, + value: { [weak self, filter] db in + guard let self else { return [] } + return try self.eventObjectOccurrencesJoined(filter: filter, db: db) + }, + onChange: { rows in + onChange(Self.bucketByDayThenHour(rows)) + }, + onError: onError + ) + } + + /// Groups rows by start-time hour-of-day in the device's current calendar. + /// Sections are sorted ascending; rows within a section preserve input order + /// (which is `orderedByStartTime` from `eventOccurrenceRequest`). + static func groupByHour(_ rows: [ListRow]) -> [EventHourSection] { + let calendar = Calendar.current + return Dictionary(grouping: rows, by: { calendar.component(.hour, from: $0.object.startDate) }) + .sorted { $0.key < $1.key } + .map { EventHourSection(hour: $0.key, rows: $0.value) } + } + + /// Single-pass split of rows pre-sorted by start time into `[Date(startOfDay): [hour sections]]`. + /// Day-tab UI then reads `bucket[selectedDay]` with no DB hit. + /// + /// Calendar boundaries are cached across consecutive rows: since input is sorted by + /// start_time, most rows fall into the same hour as their predecessor, so we only + /// call `Calendar.startOfDay`/`component` when the row crosses a boundary. Avoids + /// ~16k Calendar method calls for a 8k-row dataset (devices show 50–100x latency + /// without this — Calendar isn't free under thermal load). + static func bucketByDayThenHour(_ rows: [ListRow]) -> [Date: [EventHourSection]] { + let calendar = Calendar.current + var result: [Date: [EventHourSection]] = [:] + var currentDay: Date? + var currentDayEnd: Date? // exclusive upper bound (day + 1d) for cheap "same day?" check + var currentHour: Int? + var currentHourStart: Date? // start instant of current hour + var currentHourEnd: Date? // start instant of next hour + var currentRows: [ListRow] = [] + var currentDaySections: [EventHourSection] = [] + + func flushHour() { + guard let h = currentHour, !currentRows.isEmpty else { return } + currentDaySections.append(EventHourSection(hour: h, rows: currentRows)) + currentRows = [] + } + func flushDay() { + flushHour() + if let d = currentDay, !currentDaySections.isEmpty { + result[d] = currentDaySections + } + currentDaySections = [] + } + + for row in rows { + let start = row.object.startDate + + // Day boundary check via cached interval (no Calendar call if same day as previous row). + if let dayEnd = currentDayEnd, start < dayEnd, let day = currentDay, start >= day { + // Same day — fall through to hour check. + } else { + flushDay() + let day = calendar.startOfDay(for: start) + currentDay = day + currentDayEnd = calendar.date(byAdding: .day, value: 1, to: day) ?? day + currentHour = nil + currentHourStart = nil + currentHourEnd = nil + } + + // Hour boundary check via cached interval (no Calendar call if same hour). + if let hourEnd = currentHourEnd, start < hourEnd, let hourStart = currentHourStart, start >= hourStart { + // Same hour — append below. + } else { + flushHour() + let hour = calendar.component(.hour, from: start) + currentHour = hour + if let day = currentDay { + currentHourStart = calendar.date(byAdding: .hour, value: hour, to: day) + currentHourEnd = currentHourStart.flatMap { + calendar.date(byAdding: .hour, value: 1, to: $0) + } + } + } + + currentRows.append(row) + } + flushDay() + return result + } + // MARK: - Thumbnail Colors func saveThumbnailColors(_ colors: ThumbnailColors) async throws { @@ -1328,22 +1640,23 @@ internal class PlayaDBImpl: PlayaDB { // MARK: - Event Occurrence Batch Helpers - /// Batch-fetch occurrences for a set of events in one query, returning EventObjectOccurrence pairs. - /// Replaces per-event `event.occurrences.fetchAll(db)` N+1 pattern. + /// Fetch occurrences for a set of events, batch-resolving host camp/art in the same transaction. private func eventObjectOccurrences(for events: [EventObject], db: Database) throws -> [EventObjectOccurrence] { guard !events.isEmpty else { return [] } let eventsByUID = Dictionary(uniqueKeysWithValues: events.map { ($0.uid, $0) }) let occurrences = try EventOccurrence .filter(eventsByUID.keys.contains(Column("event_id"))) .fetchAll(db) + + let hosts = try batchResolveHosts(for: events, db: db) + return occurrences.compactMap { occ in guard let event = eventsByUID[occ.eventId] else { return nil } - return EventObjectOccurrence(event: event, occurrence: occ) + return EventObjectOccurrence(event: event, occurrence: occ, host: hosts[event.uid]) } } - /// Batch-resolve parent events for a set of occurrences in one query, returning EventObjectOccurrence pairs. - /// Replaces per-occurrence `occurrence.event.fetchOne(db)` N+1 pattern. + /// Fetch parent events + host data for a set of occurrences via batch queries. private func eventObjectOccurrences(for occurrences: [EventOccurrence], db: Database) throws -> [EventObjectOccurrence] { guard !occurrences.isEmpty else { return [] } let eventIDs = Set(occurrences.map(\.eventId)) @@ -1351,12 +1664,48 @@ internal class PlayaDBImpl: PlayaDB { .filter(eventIDs.contains(Column("uid"))) .fetchAll(db) let eventsByUID = Dictionary(uniqueKeysWithValues: events.map { ($0.uid, $0) }) + + let hosts = try batchResolveHosts(for: events, db: db) + return occurrences.compactMap { occ in guard let event = eventsByUID[occ.eventId] else { return nil } - return EventObjectOccurrence(event: event, occurrence: occ) + return EventObjectOccurrence(event: event, occurrence: occ, host: hosts[event.uid]) } } + /// Batch-fetch host camp/art objects for a set of events (2 queries max). + /// Returns a dictionary mapping event UID → PlaceDataObject. + private func batchResolveHosts(for events: [EventObject], db: Database) throws -> [String: any PlaceDataObject] { + let campUIDs = Set(events.compactMap(\.hostedByCamp)) + let artUIDs = Set(events.compactMap(\.locatedAtArt)) + + var campsByUID: [String: CampObject] = [:] + if !campUIDs.isEmpty { + let camps = try CampObject + .filter(campUIDs.contains(Column("uid"))) + .fetchAll(db) + campsByUID = Dictionary(uniqueKeysWithValues: camps.map { ($0.uid, $0) }) + } + + var artsByUID: [String: ArtObject] = [:] + if !artUIDs.isEmpty { + let arts = try ArtObject + .filter(artUIDs.contains(Column("uid"))) + .fetchAll(db) + artsByUID = Dictionary(uniqueKeysWithValues: arts.map { ($0.uid, $0) }) + } + + var hosts: [String: any PlaceDataObject] = [:] + for event in events { + if let campUID = event.hostedByCamp, let camp = campsByUID[campUID] { + hosts[event.uid] = camp + } else if let artUID = event.locatedAtArt, let art = artsByUID[artUID] { + hosts[event.uid] = art + } + } + return hosts + } + func metadata(for object: any DataObject) async throws -> ObjectMetadata { try await ensureMetadata(for: object.objectType, ids: [object.uid]) @@ -1891,6 +2240,9 @@ internal class PlayaDBImpl: PlayaDB { } } + // Step 4d: Rebuild the occurrence spatio-temporal index. + try rebuildOccurrenceRTree(db) + // Step 5: Update import info let now = Date() @@ -2158,11 +2510,16 @@ internal class PlayaDBImpl: PlayaDB { } ) - // Observe event objects with occurrences - let eventObservation = ValueObservation.tracking { [self] db in - let events = try EventObject.fetchAll(db) - return try eventObjectOccurrences(for: events, db: db) - } + // Observe event objects with occurrences. + // Explicit regions: only re-fire on event table changes, not camp/art + // (the fetch closure JOINs camp/art for host data but those shouldn't trigger re-evaluation). + let eventObservation = ValueObservation.tracking( + regions: [EventObject.all(), EventOccurrence.all()], + fetch: { [self] db in + let events = try EventObject.fetchAll(db) + return try eventObjectOccurrences(for: events, db: db) + } + ) let eventCancellable = eventObservation.start( in: dbQueue, onError: { error in diff --git a/Packages/PlayaDB/Sources/PlayaDB/QueryExtensions/QueryInterfaceRequest+DataObject.swift b/Packages/PlayaDB/Sources/PlayaDB/QueryExtensions/QueryInterfaceRequest+DataObject.swift index 40d54c28..247b0428 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/QueryExtensions/QueryInterfaceRequest+DataObject.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/QueryExtensions/QueryInterfaceRequest+DataObject.swift @@ -33,20 +33,6 @@ extension QueryInterfaceRequest where RowDecoder: DataObjectColumnProviding { extension QueryInterfaceRequest where RowDecoder: DataObjectColumnProviding, RowDecoder.ColumnSet: GeoLocatableColumns { private static var geoColumns: RowDecoder.ColumnSet.Type { RowDecoder.columnSet } - /// Geographic filtering using an `MKCoordinateRegion` bounding box. - public func inRegion(_ region: MKCoordinateRegion) -> Self { - let minLat = region.center.latitude - region.span.latitudeDelta / 2 - let maxLat = region.center.latitude + region.span.latitudeDelta / 2 - let minLon = region.center.longitude - region.span.longitudeDelta / 2 - let maxLon = region.center.longitude + region.span.longitudeDelta / 2 - - return self - .filter(Self.geoColumns.gpsLatitude >= minLat) - .filter(Self.geoColumns.gpsLatitude <= maxLat) - .filter(Self.geoColumns.gpsLongitude >= minLon) - .filter(Self.geoColumns.gpsLongitude <= maxLon) - } - /// Only objects with valid GPS coordinates. public func withLocation() -> Self { self @@ -63,6 +49,32 @@ extension QueryInterfaceRequest where RowDecoder: DataObjectColumnProviding, Row } } +// MARK: - Geo-Location via R*Tree + +extension QueryInterfaceRequest where RowDecoder: DataObjectColumnProviding & TableRecord, RowDecoder.ColumnSet: GeoLocatableColumns { + /// Geographic filtering via the point R*Tree (`spatial_index`), keyed by object type + uid. + /// Equivalent to the prior bounding-box filter but served by the spatial index. + public func inRegion(_ region: MKCoordinateRegion) -> Self { + let b = FilterRegion(region).bounds + let type: String + switch RowDecoder.databaseTableName { + case "art_objects": type = "art" + case "camp_objects": type = "camp" + case "event_objects": type = "event" + default: type = "" + } + return filter(sql: """ + uid IN ( + SELECT so.object_uid FROM spatial_objects so + JOIN spatial_index si ON si.id = so.spatial_id + WHERE so.object_type = ? + AND si.maxLat >= ? AND si.minLat <= ? + AND si.maxLon >= ? AND si.minLon <= ? + ) + """, arguments: [type, b.minLat, b.maxLat, b.minLon, b.maxLon]) + } +} + // MARK: - Art-Specific Queries extension QueryInterfaceRequest where RowDecoder == ArtObject { diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/EventHostPreloadingTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/EventHostPreloadingTests.swift new file mode 100644 index 00000000..d519c921 --- /dev/null +++ b/Packages/PlayaDB/Tests/PlayaDBTests/EventHostPreloadingTests.swift @@ -0,0 +1,187 @@ +import XCTest +import Foundation +import GRDB +@testable import PlayaDB + +/// Integration tests for `batchResolveHosts` via the public `fetchEvents` API. +/// Inserts records directly through GRDB so each scenario is small and isolated. +final class EventHostPreloadingTests: XCTestCase { + + private var playaDB: PlayaDBImpl! + + override func setUp() async throws { + try await super.setUp() + playaDB = try PlayaDBImpl(dbPath: ":memory:") + } + + override func tearDown() async throws { + playaDB = nil + try await super.tearDown() + } + + // MARK: - Helpers + + private func insertCamp(uid: String, name: String, locationString: String? = nil, intersection: String? = nil) async throws { + var camp = CampObject( + uid: uid, + name: name, + year: 2025, + locationString: locationString, + intersection: intersection + ) + try await playaDB.dbQueue.write { db in + try camp.insert(db) + } + } + + private func insertArt(uid: String, name: String, locationString: String? = nil, locationHour: Int? = nil, locationMinute: Int? = nil, locationDistance: Int? = nil) async throws { + var art = ArtObject( + uid: uid, + name: name, + year: 2025, + locationString: locationString, + locationHour: locationHour, + locationMinute: locationMinute, + locationDistance: locationDistance + ) + try await playaDB.dbQueue.write { db in + try art.insert(db) + } + } + + private func insertEvent(uid: String, hostedByCamp: String? = nil, locatedAtArt: String? = nil) async throws { + var event = EventObject( + uid: uid, + name: "Event \(uid)", + year: 2025, + eventTypeLabel: "Workshop", + eventTypeCode: "workshop", + hostedByCamp: hostedByCamp, + locatedAtArt: locatedAtArt + ) + try await playaDB.dbQueue.write { db in + try event.insert(db) + } + } + + private func insertOccurrence(eventUID: String) async throws { + let now = Date() + var occurrence = EventOccurrence( + id: nil, + eventId: eventUID, + startTime: now, + endTime: now.addingTimeInterval(3600) + ) + try await playaDB.dbQueue.write { db in + try occurrence.insert(db) + } + } + + // MARK: - Tests + + func testFetchEvents_PopulatesCampHost() async throws { + // Given: a camp with locationString and an event hosted by that camp + try await insertCamp(uid: "camp-1", name: "Camp Awesome", locationString: "7:30 & E") + try await insertEvent(uid: "event-1", hostedByCamp: "camp-1") + try await insertOccurrence(eventUID: "event-1") + + // When: fetch events + let events = try await playaDB.fetchEvents() + + // Then: returned occurrence has the camp pre-loaded as host + XCTAssertEqual(events.count, 1) + let occ = try XCTUnwrap(events.first) + let host = try XCTUnwrap(occ.host) + XCTAssertEqual(host.uid, "camp-1") + XCTAssertEqual(occ.hostName, "Camp Awesome") + XCTAssertEqual(occ.hostAddress, "7:30 & E") + } + + func testFetchEvents_PopulatesArtHost() async throws { + // Given: an art with timeBasedAddress fallback and an event located at that art + try await insertArt(uid: "art-1", name: "Big Art", locationHour: 9, locationMinute: 0, locationDistance: 800) + try await insertEvent(uid: "event-1", locatedAtArt: "art-1") + try await insertOccurrence(eventUID: "event-1") + + let events = try await playaDB.fetchEvents() + + XCTAssertEqual(events.count, 1) + let occ = try XCTUnwrap(events.first) + let host = try XCTUnwrap(occ.host) + XCTAssertEqual(host.uid, "art-1") + XCTAssertEqual(occ.hostName, "Big Art") + XCTAssertEqual(occ.hostAddress, "9:00 & 800'") + } + + func testFetchEvents_HostNilWhenCampMissing() async throws { + // Given: an event references a camp that does not exist in the DB + try await insertEvent(uid: "event-orphan", hostedByCamp: "missing-camp-uid") + try await insertOccurrence(eventUID: "event-orphan") + + let events = try await playaDB.fetchEvents() + + XCTAssertEqual(events.count, 1) + let occ = try XCTUnwrap(events.first) + XCTAssertNil(occ.host) + XCTAssertNil(occ.hostName) + XCTAssertNil(occ.hostAddress) + } + + func testFetchEventsHostedByCampUID_PreloadsHost() async throws { + try await insertCamp(uid: "camp-host", name: "Host Camp", locationString: "3:00 & A") + try await insertEvent(uid: "event-1", hostedByCamp: "camp-host") + try await insertOccurrence(eventUID: "event-1") + + let events = try await playaDB.fetchEvents(hostedByCampUID: "camp-host") + + XCTAssertEqual(events.count, 1) + let occ = try XCTUnwrap(events.first) + let host = try XCTUnwrap(occ.host) + XCTAssertEqual(host.uid, "camp-host") + XCTAssertEqual(occ.hostName, "Host Camp") + XCTAssertEqual(occ.hostAddress, "3:00 & A") + } + + func testFetchEvents_BatchResolvesMultipleHosts() async throws { + // Given: 2 camps, 1 art, 3 events each pointing to a different host, plus one event with no host. + try await insertCamp(uid: "camp-a", name: "Camp A", locationString: "Camp A Loc") + try await insertCamp(uid: "camp-b", name: "Camp B", intersection: "Camp B Intersection") + try await insertArt(uid: "art-x", name: "Art X", locationString: "Art X Loc") + + try await insertEvent(uid: "ev-a", hostedByCamp: "camp-a") + try await insertEvent(uid: "ev-b", hostedByCamp: "camp-b") + try await insertEvent(uid: "ev-x", locatedAtArt: "art-x") + try await insertEvent(uid: "ev-none") + + try await insertOccurrence(eventUID: "ev-a") + try await insertOccurrence(eventUID: "ev-b") + try await insertOccurrence(eventUID: "ev-x") + try await insertOccurrence(eventUID: "ev-none") + + // When: fetch all events in one call + let events = try await playaDB.fetchEvents() + XCTAssertEqual(events.count, 4) + + // Then: each event has its expected host pre-loaded by the batch resolver + let byUID = Dictionary(uniqueKeysWithValues: events.map { ($0.event.uid, $0) }) + + let evA = try XCTUnwrap(byUID["ev-a"]) + let evAHost = try XCTUnwrap(evA.host) + XCTAssertEqual(evAHost.uid, "camp-a") + XCTAssertEqual(evA.hostAddress, "Camp A Loc") + + let evB = try XCTUnwrap(byUID["ev-b"]) + let evBHost = try XCTUnwrap(evB.host) + XCTAssertEqual(evBHost.uid, "camp-b") + // Camp B has no locationString, so address falls back to intersection. + XCTAssertEqual(evB.hostAddress, "Camp B Intersection") + + let evX = try XCTUnwrap(byUID["ev-x"]) + let evXHost = try XCTUnwrap(evX.host) + XCTAssertEqual(evXHost.uid, "art-x") + XCTAssertEqual(evX.hostAddress, "Art X Loc") + + let evNone = try XCTUnwrap(byUID["ev-none"]) + XCTAssertNil(evNone.host) + } +} diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/EventHourSectionTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/EventHourSectionTests.swift new file mode 100644 index 00000000..23282314 --- /dev/null +++ b/Packages/PlayaDB/Tests/PlayaDBTests/EventHourSectionTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import PlayaDB + +final class EventHourSectionTests: XCTestCase { + + private func makeRow(uid: String, hour: Int, minute: Int = 0) throws -> ListRow { + var components = DateComponents() + components.year = 2026 + components.month = 8 + components.day = 24 + components.hour = hour + components.minute = minute + let start = try XCTUnwrap(Calendar.current.date(from: components)) + let end = start.addingTimeInterval(3600) + + let event = EventObject( + uid: uid, + name: "Test \(uid)", + year: 2026, + eventTypeLabel: "Workshop", + eventTypeCode: "work" + ) + let occurrence = EventOccurrence(eventId: uid, startTime: start, endTime: end) + let combined = EventObjectOccurrence(event: event, occurrence: occurrence) + return ListRow(object: combined, metadata: nil, thumbnailColors: nil) + } + + func testGroupByHour_emptyInputProducesEmptyOutput() { + XCTAssertTrue(PlayaDBImpl.groupByHour([]).isEmpty) + } + + func testGroupByHour_singleHourYieldsSingleSection() throws { + let row = try makeRow(uid: "e1", hour: 14) + let sections = PlayaDBImpl.groupByHour([row]) + + XCTAssertEqual(sections.count, 1) + XCTAssertEqual(sections.first?.hour, 14) + XCTAssertEqual(sections.first?.rows.count, 1) + XCTAssertEqual(sections.first?.rows.first?.object.event.uid, "e1") + } + + func testGroupByHour_sortsAscendingAndPreservesRowOrder() throws { + let r23 = try makeRow(uid: "e23", hour: 23) + let r0 = try makeRow(uid: "e0", hour: 0) + let r13a = try makeRow(uid: "e13a", hour: 13, minute: 0) + let r13b = try makeRow(uid: "e13b", hour: 13, minute: 30) + + // Input order intentionally jumbled so the grouper must sort. + let sections = PlayaDBImpl.groupByHour([r23, r0, r13a, r13b]) + + XCTAssertEqual(sections.map(\.hour), [0, 13, 23], "Sections should be sorted ascending by hour") + + let thirteen = try XCTUnwrap(sections.first { $0.hour == 13 }) + XCTAssertEqual( + thirteen.rows.map { $0.object.event.uid }, + ["e13a", "e13b"], + "Row order within a section should match input order" + ) + } +} diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/EventListBucketObservationTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/EventListBucketObservationTests.swift new file mode 100644 index 00000000..0d3904f9 --- /dev/null +++ b/Packages/PlayaDB/Tests/PlayaDBTests/EventListBucketObservationTests.swift @@ -0,0 +1,225 @@ +import XCTest +import CoreLocation +import GRDB +@testable import PlayaDB +import PlayaAPITestHelpers + +/// Tests for `observeEventsByDayThenHour` — the long-lived observation that powers +/// the SwiftUI events list's day-tab + hour-strip browse mode. Day tabs slice the +/// emitted `[Date: [EventHourSection]]` in memory; no DB hit on tab switch. +final class EventListBucketObservationTests: XCTestCase { + private var playaDB: PlayaDBImpl! + + private var dbQueue: DatabaseQueue { playaDB.dbQueue } + + // MARK: - Lifecycle + + override func setUp() async throws { + try await super.setUp() + playaDB = try PlayaDBImpl(dbPath: ":memory:") + try await playaDB.importFromData( + artData: MockAPIData.artJSON, + campData: MockAPIData.campJSON, + eventData: MockAPIData.eventJSON + ) + } + + override func tearDown() async throws { + playaDB = nil + try await super.tearDown() + } + + // MARK: - Helpers + + private func insertCamp(uid: String, name: String, year: Int) async throws { + let camp = CampObject(uid: uid, name: name, year: year) + try await dbQueue.write { db in + var c = camp + try c.insert(db) + } + } + + private func insertEvent( + uid: String, + name: String, + year: Int, + start: Date, + end: Date, + eventTypeCode: String = "work", + hostedByCamp: String? = nil, + locatedAtArt: String? = nil + ) async throws { + let event = EventObject( + uid: uid, + name: name, + year: year, + eventTypeLabel: "Workshop", + eventTypeCode: eventTypeCode, + hostedByCamp: hostedByCamp, + locatedAtArt: locatedAtArt + ) + try await dbQueue.write { db in + var mutableEvent = event + try mutableEvent.insert(db) + var occurrence = EventOccurrence(eventId: uid, startTime: start, endTime: end) + try occurrence.insert(db) + } + } + + private func setFavorite(_ type: DataObjectType, id: String, isFavorite: Bool = true) async throws { + try await dbQueue.write { db in + var metadata = ObjectMetadata( + objectType: type.rawValue, + objectId: id, + isFavorite: isFavorite + ) + try metadata.save(db) + } + } + + private func startOfDay(_ date: Date) -> Date { + Calendar.current.startOfDay(for: date) + } + + // MARK: - Tests + + /// Multi-day fixtures bucket cleanly into per-day, per-hour sections, ordered. + func testBucketGroupsByDayThenHour() async throws { + let year = 2099 + let cal = Calendar.current + guard let dayA = cal.date(from: DateComponents(year: year, month: 8, day: 25, hour: 14)), + let dayB = cal.date(from: DateComponents(year: year, month: 8, day: 26, hour: 9)) else { + return XCTFail("Could not construct fixture dates") + } + + try await insertEvent(uid: "bucket-a1", name: "A 14:30", year: year, + start: dayA.addingTimeInterval(30 * 60), + end: dayA.addingTimeInterval(90 * 60)) + try await insertEvent(uid: "bucket-a2", name: "A 14:00", year: year, + start: dayA, + end: dayA.addingTimeInterval(60 * 60)) + try await insertEvent(uid: "bucket-a3", name: "A 16:00", year: year, + start: dayA.addingTimeInterval(2 * 3600), + end: dayA.addingTimeInterval(3 * 3600)) + try await insertEvent(uid: "bucket-b1", name: "B 09:15", year: year, + start: dayB.addingTimeInterval(15 * 60), + end: dayB.addingTimeInterval(75 * 60)) + + let bucket = try await observeOnce(filter: EventFilter(year: year)) + + let dayAKey = startOfDay(dayA) + let dayBKey = startOfDay(dayB) + + let aSections = try XCTUnwrap(bucket[dayAKey]) + XCTAssertEqual(aSections.map(\.hour), [14, 16], "Sections should be ordered by hour") + XCTAssertEqual(aSections.first?.rows.map(\.object.event.uid), ["bucket-a2", "bucket-a1"], + "Rows within a section should preserve start_time order") + + let bSections = try XCTUnwrap(bucket[dayBKey]) + XCTAssertEqual(bSections.map(\.hour), [9]) + XCTAssertEqual(bSections.first?.rows.map(\.object.event.uid), ["bucket-b1"]) + } + + /// The JOIN-based fetch pre-resolves host camp; `hostName` is populated from a single query. + func testJoinedFetchResolvesHostCamp() async throws { + let year = 2099 + try await insertCamp(uid: "host-camp-1", name: "Camp Sparkle", year: year) + let cal = Calendar.current + let start = try XCTUnwrap(cal.date(from: DateComponents(year: year, month: 8, day: 28, hour: 11))) + try await insertEvent(uid: "evt-hosted-1", name: "Sparkle Hour", year: year, + start: start, end: start.addingTimeInterval(3600), + hostedByCamp: "host-camp-1") + + let bucket = try await observeOnce(filter: EventFilter(year: year)) + let sections = try XCTUnwrap(bucket[startOfDay(start)]) + let row = try XCTUnwrap(sections.first?.rows.first) + XCTAssertEqual(row.object.event.uid, "evt-hosted-1") + XCTAssertEqual(row.object.hostName, "Camp Sparkle") + } + + /// Favoriting an event re-emits because `object_metadata` is in the tracked region set. + func testFavoriteToggleReEmits() async throws { + let year = 2099 + let cal = Calendar.current + let start = try XCTUnwrap(cal.date(from: DateComponents(year: year, month: 8, day: 29, hour: 13))) + try await insertEvent(uid: "evt-fav-1", name: "Fav Me", year: year, + start: start, end: start.addingTimeInterval(3600)) + + let firstEmission = expectation(description: "first emission with event present") + let favoriteEmission = expectation(description: "re-emission after favorite toggle") + + var emissions = 0 + let token = playaDB.observeEventsByDayThenHour( + filter: EventFilter(year: year), + onChange: { bucket in + emissions += 1 + let row = bucket[self.startOfDay(start)]?.first?.rows.first + if emissions == 1, row?.object.event.uid == "evt-fav-1" { + firstEmission.fulfill() + } else if emissions >= 2, row?.isFavorite == true { + favoriteEmission.fulfill() + } + }, + onError: { XCTFail("observation error: \($0)") } + ) + defer { token.cancel() } + + await fulfillment(of: [firstEmission], timeout: 2.0) + try await setFavorite(.event, id: "evt-fav-1") + await fulfillment(of: [favoriteEmission], timeout: 2.0) + } + + /// `onlyFavorites = true` is pushed into SQL via EXISTS; non-favorited rows are excluded. + func testOnlyFavoritesFilterAppliesAtSqlLevel() async throws { + let year = 2099 + let cal = Calendar.current + let start = try XCTUnwrap(cal.date(from: DateComponents(year: year, month: 8, day: 30, hour: 10))) + + try await insertEvent(uid: "evt-favored", name: "Favored", year: year, + start: start, end: start.addingTimeInterval(3600)) + try await insertEvent(uid: "evt-unfavored", name: "Unfavored", year: year, + start: start.addingTimeInterval(3600), end: start.addingTimeInterval(7200)) + try await setFavorite(.event, id: "evt-favored") + + let bucket = try await observeOnce(filter: EventFilter(year: year, onlyFavorites: true)) + let uids = bucket.values.flatMap { $0.flatMap { $0.rows.map(\.object.event.uid) } } + XCTAssertEqual(Set(uids), ["evt-favored"], "Only favored event should be included") + } + + // MARK: - Utilities + + /// Wait for the first emission of the observation and return it. + private func observeOnce(filter: EventFilter) async throws -> [Date: [EventHourSection]] { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[Date: [EventHourSection]], Error>) in + var resumed = false + let lock = NSLock() + var capturedToken: PlayaDBObservationToken? + let token = playaDB.observeEventsByDayThenHour( + filter: filter, + onChange: { bucket in + lock.lock() + if !resumed { + resumed = true + lock.unlock() + capturedToken?.cancel() + continuation.resume(returning: bucket) + } else { + lock.unlock() + } + }, + onError: { error in + lock.lock() + if !resumed { + resumed = true + lock.unlock() + capturedToken?.cancel() + continuation.resume(throwing: error) + } else { + lock.unlock() + } + } + ) + capturedToken = token + } + } +} diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/EventObjectOccurrenceTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/EventObjectOccurrenceTests.swift index 712eaf80..d7f711cc 100644 --- a/Packages/PlayaDB/Tests/PlayaDBTests/EventObjectOccurrenceTests.swift +++ b/Packages/PlayaDB/Tests/PlayaDBTests/EventObjectOccurrenceTests.swift @@ -153,6 +153,150 @@ class EventObjectOccurrenceTests: XCTestCase { } } + // MARK: - Host Pre-loading + + private func makeEvent( + uid: String = "host-test-event", + hostedByCamp: String? = nil, + locatedAtArt: String? = nil + ) -> EventObject { + EventObject( + uid: uid, + name: "Host Test Event", + year: 2025, + eventTypeLabel: "Workshop", + eventTypeCode: "workshop", + hostedByCamp: hostedByCamp, + locatedAtArt: locatedAtArt + ) + } + + private func makeOccurrence(eventUID: String, id: Int64 = 1) -> EventOccurrence { + let now = Date() + return EventOccurrence( + id: id, + eventId: eventUID, + startTime: now, + endTime: now.addingTimeInterval(3600) + ) + } + + func testEventObjectOccurrence_HostNameAndAddress_FromCampHost() throws { + // Given: a camp host with a locationString + let camp = CampObject( + uid: "camp-uid-1", + name: "Camp Foo", + year: 2025, + locationString: "7:30 & E", + intersection: "Esplanade & 6:00" + ) + let event = makeEvent(hostedByCamp: camp.uid) + let occurrence = makeOccurrence(eventUID: event.uid) + + // When: occurrence is constructed with the camp as host + let occ = EventObjectOccurrence(event: event, occurrence: occurrence, host: camp) + + // Then: host name + address delegate to the camp + let hostName = try XCTUnwrap(occ.hostName) + let hostAddress = try XCTUnwrap(occ.hostAddress) + XCTAssertEqual(hostName, "Camp Foo") + XCTAssertEqual(hostAddress, "7:30 & E") + } + + func testEventObjectOccurrence_HostNameAndAddress_FromArtHost() throws { + // Given: an art host with no locationString (falls back to timeBasedAddress) + let art = ArtObject( + uid: "art-uid-1", + name: "Big Art", + year: 2025, + locationString: nil, + locationHour: 9, + locationMinute: 30, + locationDistance: 1200 + ) + let event = makeEvent(locatedAtArt: art.uid) + let occurrence = makeOccurrence(eventUID: event.uid) + + let occ = EventObjectOccurrence(event: event, occurrence: occurrence, host: art) + + let hostName = try XCTUnwrap(occ.hostName) + let hostAddress = try XCTUnwrap(occ.hostAddress) + XCTAssertEqual(hostName, "Big Art") + XCTAssertEqual(hostAddress, "9:30 & 1200'") + } + + func testEventObjectOccurrence_HostNameAndAddress_NilWhenNoHost() { + // Given: no host argument (default nil) + let event = makeEvent() + let occurrence = makeOccurrence(eventUID: event.uid) + + let occ = EventObjectOccurrence(event: event, occurrence: occurrence) + + XCTAssertNil(occ.hostName) + XCTAssertNil(occ.hostAddress) + XCTAssertNil(occ.host) + } + + func testEventOccurrenceJoinedRow_PrefersCampOverArt() throws { + // Given: a joined row with both hostedCamp and locatedArt set + let camp = CampObject(uid: "c1", name: "Camp", year: 2025, locationString: "Camp Loc") + let art = ArtObject(uid: "a1", name: "Art", year: 2025, locationString: "Art Loc") + let event = makeEvent(uid: "ev1", hostedByCamp: camp.uid, locatedAtArt: art.uid) + let occurrence = makeOccurrence(eventUID: event.uid) + + let row = EventOccurrenceJoinedRow( + occurrence: occurrence, + event: event, + hostedCamp: camp, + locatedArt: art + ) + + // When: convert to EventObjectOccurrence + let occ = row.toEventObjectOccurrence() + + // Then: camp wins (hostedCamp ?? locatedArt) + let host = try XCTUnwrap(occ.host) + XCTAssertEqual(host.uid, camp.uid) + XCTAssertEqual(occ.hostName, "Camp") + XCTAssertEqual(occ.hostAddress, "Camp Loc") + } + + func testEventOccurrenceJoinedRow_FallsBackToArt() throws { + let art = ArtObject(uid: "a2", name: "Art Only", year: 2025, locationString: "Deep Playa") + let event = makeEvent(uid: "ev2", locatedAtArt: art.uid) + let occurrence = makeOccurrence(eventUID: event.uid) + + let row = EventOccurrenceJoinedRow( + occurrence: occurrence, + event: event, + hostedCamp: nil, + locatedArt: art + ) + + let occ = row.toEventObjectOccurrence() + let host = try XCTUnwrap(occ.host) + XCTAssertEqual(host.uid, art.uid) + XCTAssertEqual(occ.hostName, "Art Only") + XCTAssertEqual(occ.hostAddress, "Deep Playa") + } + + func testEventOccurrenceJoinedRow_NilWhenNeither() { + let event = makeEvent(uid: "ev3") + let occurrence = makeOccurrence(eventUID: event.uid) + + let row = EventOccurrenceJoinedRow( + occurrence: occurrence, + event: event, + hostedCamp: nil, + locatedArt: nil + ) + + let occ = row.toEventObjectOccurrence() + XCTAssertNil(occ.host) + XCTAssertNil(occ.hostName) + XCTAssertNil(occ.hostAddress) + } + func testEventObjectOccurrenceCompatibilityMethods() { // Given: An event occurrence let now = Date() diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/EventOccurrenceRTreeTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/EventOccurrenceRTreeTests.swift new file mode 100644 index 00000000..24b2707d --- /dev/null +++ b/Packages/PlayaDB/Tests/PlayaDBTests/EventOccurrenceRTreeTests.swift @@ -0,0 +1,177 @@ +import XCTest +import Foundation +import MapKit +import GRDB +@testable import PlayaDB + +/// Tests for the spatio-temporal R*Tree-backed region filtering of event occurrences, +/// and the point-R*Tree-backed `inRegion` for art/camp. Inserts records directly through +/// GRDB (matching EventHostPreloadingTests) and rebuilds the occurrence index explicitly. +final class EventOccurrenceRTreeTests: XCTestCase { + + private var playaDB: PlayaDBImpl! + + // A point near Black Rock City and a region around it. + private let centerLat = 40.7864 + private let centerLon = -119.2065 + // A fixed 2025 instant (~Aug 28) so window math is deterministic and DST-free. + private let windowStart = Date(timeIntervalSince1970: 1_756_400_000) + + override func setUp() async throws { + try await super.setUp() + playaDB = try PlayaDBImpl(dbPath: ":memory:") + } + + override func tearDown() async throws { + playaDB = nil + try await super.tearDown() + } + + // MARK: - Helpers + + private func region(delta: Double = 0.01) -> MKCoordinateRegion { + MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon), + span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta) + ) + } + + private func windowFilter(region: MKCoordinateRegion?, eventTypeCodes: Set? = nil) -> EventFilter { + EventFilter( + region: region, + startDate: windowStart, + endDate: windowStart.addingTimeInterval(7200), + eventTypeCodes: eventTypeCodes + ) + } + + private func insertEvent(uid: String, lat: Double?, lon: Double?, typeCode: String = "workshop", hostedByCamp: String? = nil) async throws { + var event = EventObject( + uid: uid, name: "Event \(uid)", year: 2025, + eventTypeLabel: "Workshop", eventTypeCode: typeCode, + hostedByCamp: hostedByCamp, + gpsLatitude: lat, gpsLongitude: lon + ) + try await playaDB.dbQueue.write { db in try event.insert(db) } + } + + private func insertCamp(uid: String, lat: Double, lon: Double) async throws { + var camp = CampObject(uid: uid, name: "Camp \(uid)", year: 2025, gpsLatitude: lat, gpsLongitude: lon) + try await playaDB.dbQueue.write { db in try camp.insert(db) } + } + + private func insertArt(uid: String, lat: Double, lon: Double) async throws { + var art = ArtObject(uid: uid, name: "Art \(uid)", year: 2025, gpsLatitude: lat, gpsLongitude: lon) + try await playaDB.dbQueue.write { db in try art.insert(db) } + } + + /// Insert an occurrence starting `startOffset` seconds after `windowStart`. + private func insertOccurrence(eventUID: String, startOffset: TimeInterval, durationSeconds: TimeInterval = 3600) async throws { + let start = windowStart.addingTimeInterval(startOffset) + var occ = EventOccurrence(id: nil, eventId: eventUID, startTime: start, endTime: start.addingTimeInterval(durationSeconds)) + try await playaDB.dbQueue.write { db in try occ.insert(db) } + } + + private func rebuildRTree() async throws { + let db = playaDB! + try await db.dbQueue.write { database in try db.rebuildOccurrenceRTree(database) } + } + + // MARK: - Tests + + func testEventInRegionAndWindowReturned() async throws { + try await insertEvent(uid: "e1", lat: centerLat, lon: centerLon) + try await insertOccurrence(eventUID: "e1", startOffset: 1800) // 30 min into the window + try await rebuildRTree() + + let events = try await playaDB.fetchEvents(filter: windowFilter(region: region())) + XCTAssertTrue(events.contains { $0.event.uid == "e1" }) + } + + func testEventOutOfRegionExcluded() async throws { + try await insertEvent(uid: "far", lat: 0.0, lon: 0.0) + try await insertOccurrence(eventUID: "far", startOffset: 1800) + try await rebuildRTree() + + let events = try await playaDB.fetchEvents(filter: windowFilter(region: region())) + XCTAssertFalse(events.contains { $0.event.uid == "far" }) + } + + func testEventOutsideWindowExcluded() async throws { + try await insertEvent(uid: "e1", lat: centerLat, lon: centerLon) + try await insertOccurrence(eventUID: "e1", startOffset: 100_000) // long after the window + try await rebuildRTree() + + let events = try await playaDB.fetchEvents(filter: windowFilter(region: region())) + XCTAssertFalse(events.contains { $0.event.uid == "e1" }) + } + + func testEventTypeCodesRespected() async throws { + try await insertEvent(uid: "yoga", lat: centerLat, lon: centerLon, typeCode: "medt") + try await insertEvent(uid: "party", lat: centerLat, lon: centerLon, typeCode: "prty") + try await insertOccurrence(eventUID: "yoga", startOffset: 1800) + try await insertOccurrence(eventUID: "party", startOffset: 1800) + try await rebuildRTree() + + let events = try await playaDB.fetchEvents(filter: windowFilter(region: region(), eventTypeCodes: ["medt"])) + XCTAssertTrue(events.contains { $0.event.uid == "yoga" }) + XCTAssertFalse(events.contains { $0.event.uid == "party" }) + } + + /// The original bug: an event hosted by an in-region camp (GPS copied from the camp at + /// import) must come back from a region-scoped query. + func testEventHostedByInRegionCampReturned() async throws { + try await insertCamp(uid: "c1", lat: centerLat, lon: centerLon) + try await insertEvent(uid: "e1", lat: centerLat, lon: centerLon, hostedByCamp: "c1") + try await insertOccurrence(eventUID: "e1", startOffset: 1800) + try await rebuildRTree() + + let events = try await playaDB.fetchEvents(filter: windowFilter(region: region())) + let occ = try XCTUnwrap(events.first { $0.event.uid == "e1" }) + XCTAssertEqual(occ.event.hostedByCamp, "c1") + } + + func testNilGpsEventExcludedFromRegionButPresentWithoutRegion() async throws { + try await insertEvent(uid: "noloc", lat: nil, lon: nil) + try await insertOccurrence(eventUID: "noloc", startOffset: 1800) + try await rebuildRTree() + + let regioned = try await playaDB.fetchEvents(filter: windowFilter(region: region())) + XCTAssertFalse(regioned.contains { $0.event.uid == "noloc" }) + + let noRegion = try await playaDB.fetchEvents(filter: windowFilter(region: nil)) + XCTAssertTrue(noRegion.contains { $0.event.uid == "noloc" }) + } + + func testRebuildPopulatesAndIsIdempotent() async throws { + try await insertEvent(uid: "e1", lat: centerLat, lon: centerLon) + try await insertOccurrence(eventUID: "e1", startOffset: 1800) + try await rebuildRTree() + try await rebuildRTree() // INSERT OR REPLACE → idempotent + + let count = try await playaDB.dbQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM event_occurrence_rtree") ?? 0 + } + XCTAssertEqual(count, 1) + } + + // MARK: - Art/Camp inRegion via point R*Tree (trigger-maintained; no rebuild needed) + + func testArtInRegionViaRTree() async throws { + try await insertArt(uid: "near", lat: centerLat, lon: centerLon) + try await insertArt(uid: "far", lat: 0.0, lon: 0.0) + + let art = try await playaDB.fetchArt(filter: ArtFilter(region: region())) + XCTAssertTrue(art.contains { $0.uid == "near" }) + XCTAssertFalse(art.contains { $0.uid == "far" }) + } + + func testCampInRegionViaRTree() async throws { + try await insertCamp(uid: "near", lat: centerLat, lon: centerLon) + try await insertCamp(uid: "far", lat: 0.0, lon: 0.0) + + let camps = try await playaDB.fetchCamps(filter: CampFilter(region: region())) + XCTAssertTrue(camps.contains { $0.uid == "near" }) + XCTAssertFalse(camps.contains { $0.uid == "far" }) + } +} diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/FilterObservationTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/FilterObservationTests.swift index 405995f9..c2d0c7f8 100644 --- a/Packages/PlayaDB/Tests/PlayaDBTests/FilterObservationTests.swift +++ b/Packages/PlayaDB/Tests/PlayaDBTests/FilterObservationTests.swift @@ -126,7 +126,7 @@ final class FilterObservationTests: XCTestCase { let token = playaDB.observeArt( filter: filter, onChange: { art in - if art.contains(where: { $0.uid == "art-observe" }) { + if art.contains(where: { $0.object.uid == "art-observe" }) { expectation.fulfill() } }, @@ -159,7 +159,7 @@ final class FilterObservationTests: XCTestCase { let token = playaDB.observeEvents( filter: filter, onChange: { events in - if events.contains(where: { $0.event.uid == "event-observe" }) { + if events.contains(where: { $0.object.event.uid == "event-observe" }) { expectation.fulfill() } }, @@ -198,7 +198,7 @@ final class FilterObservationTests: XCTestCase { onChange: { objects in emissionCount += 1 if emissionCount >= 2 { - XCTAssertEqual(objects.map(\.uid), [art.uid]) + XCTAssertEqual(objects.map(\.object.uid), [art.uid]) favoritesExpectation.fulfill() } else { XCTAssertTrue(objects.isEmpty, "Initial favorites emission should be empty") @@ -229,7 +229,7 @@ final class FilterObservationTests: XCTestCase { let token = playaDB.observeArt( filter: ArtFilter(year: year, onlyWithEvents: true), onChange: { artObjects in - if artObjects.contains(where: { $0.uid == art.uid }) { + if artObjects.contains(where: { $0.object.uid == art.uid }) { expectation.fulfill() } else { XCTAssertTrue(artObjects.isEmpty, "Initial emission should be empty before event insertion") diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift index bcddf459..60009471 100644 --- a/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift +++ b/Packages/PlayaDB/Tests/PlayaDBTests/FilterRequestBuilderTests.swift @@ -582,4 +582,33 @@ final class FilterRequestBuilderTests: XCTestCase { XCTAssertEqual(events.count, 1, "Only Sunrise Yoga should match all event-level filters") XCTAssertEqual(events.first?.event.uid, "event-match") } + + /// Regression: event search must run through FTS5 (`event_objects_fts`), not in-memory + /// `.lowercased().contains(...)`. Porter stemming means a query "yogas" matches a name + /// containing "Yoga" — substring matching cannot do this. + func testEventSearchUsesFTSStemmingNotSubstring() async throws { + let now = Date() + try await insertEvent( + uid: "event-stem-match", + name: "Yoga Class", + year: 2025, + start: now.addingTimeInterval(3600), + end: now.addingTimeInterval(7200), + description: "Daily flow" + ) + try await insertEvent( + uid: "event-stem-miss", + name: "Bicycle Tour", + year: 2025, + start: now.addingTimeInterval(3600), + end: now.addingTimeInterval(7200), + description: "Group ride" + ) + + let filter = EventFilter(searchText: "yogas", includeExpired: true) + let events = try await playaDB.fetchEvents(filter: filter) + + XCTAssertEqual(events.count, 1, "Stemmed FTS query should match 'Yoga Class' for 'yogas'") + XCTAssertEqual(events.first?.event.uid, "event-stem-match") + } } diff --git a/Packages/PlayaDB/Tests/PlayaDBTests/PlaceDataObjectTests.swift b/Packages/PlayaDB/Tests/PlayaDBTests/PlaceDataObjectTests.swift new file mode 100644 index 00000000..2c2d6cce --- /dev/null +++ b/Packages/PlayaDB/Tests/PlayaDBTests/PlaceDataObjectTests.swift @@ -0,0 +1,107 @@ +import XCTest +import Foundation +@testable import PlayaDB + +/// Unit tests for the `PlaceDataObject.address` fallback chains on `CampObject` and `ArtObject`. +final class PlaceDataObjectTests: XCTestCase { + + // MARK: - CampObject.address + + func testCampAddress_PrefersLocationStringOverIntersection() throws { + let camp = CampObject( + uid: "camp-1", + name: "Test Camp", + year: 2025, + locationString: "7:30 & E", + intersection: "Esplanade & 6:00" + ) + + let address = try XCTUnwrap(camp.address) + XCTAssertEqual(address, "7:30 & E") + } + + func testCampAddress_FallsBackToIntersectionWhenLocationStringNil() throws { + let camp = CampObject( + uid: "camp-2", + name: "Test Camp", + year: 2025, + locationString: nil, + intersection: "Esplanade & 6:00" + ) + + let address = try XCTUnwrap(camp.address) + XCTAssertEqual(address, "Esplanade & 6:00") + } + + func testCampAddress_NilWhenBothNil() { + let camp = CampObject( + uid: "camp-3", + name: "Test Camp", + year: 2025, + locationString: nil, + intersection: nil + ) + + XCTAssertNil(camp.address) + } + + // MARK: - ArtObject.address + + func testArtAddress_PrefersLocationStringOverTimeBasedAddress() throws { + let art = ArtObject( + uid: "art-1", + name: "Test Art", + year: 2025, + locationString: "Deep Playa", + locationHour: 3, + locationMinute: 0, + locationDistance: 500 + ) + + let address = try XCTUnwrap(art.address) + XCTAssertEqual(address, "Deep Playa") + } + + func testArtAddress_FallsBackToTimeBasedAddressWhenLocationStringNil() throws { + let art = ArtObject( + uid: "art-2", + name: "Test Art", + year: 2025, + locationString: nil, + locationHour: 3, + locationMinute: 0, + locationDistance: 500 + ) + + let address = try XCTUnwrap(art.address) + XCTAssertEqual(address, "3:00 & 500'") + } + + func testArtAddress_FallsBackToTimeBasedAddress_HandlesNilMinuteAndDistance() throws { + // Given: only locationHour is provided (minute and distance default to 0 in timeBasedAddress) + let art = ArtObject( + uid: "art-3", + name: "Test Art", + year: 2025, + locationString: nil, + locationHour: 6, + locationMinute: nil, + locationDistance: nil + ) + + let address = try XCTUnwrap(art.address) + XCTAssertEqual(address, "6:00 & 0'") + } + + func testArtAddress_NilWhenLocationStringAndLocationHourBothNil() { + let art = ArtObject( + uid: "art-4", + name: "Test Art", + year: 2025, + locationString: nil, + locationHour: nil + ) + + XCTAssertNil(art.address) + } +} diff --git a/iBurn/AISearch/AIAssistantModels.swift b/iBurn/AISearch/AIAssistantModels.swift index 26b8b174..0cb7d554 100644 --- a/iBurn/AISearch/AIAssistantModels.swift +++ b/iBurn/AISearch/AIAssistantModels.swift @@ -2,97 +2,31 @@ // AIAssistantModels.swift // iBurn // +// Generable types for the camp/art detail-page event-collection summaries. +// (The legacy recommend/day-plan/nearby assistant models were removed with the +// AI Guide overhaul; these two remain because DetailViewModel uses them.) +// // Created by Claude Code on 4/5/26. // Copyright © 2026 Burning Man Earth. All rights reserved. // import Foundation -// MARK: - Public Result Types (always available) - -/// A recommendation from the AI assistant -struct AIRecommendation: Sendable, Identifiable { - let uid: String - let reason: String - var id: String { uid } -} - -/// A scheduled item in an AI-generated day plan -struct AIScheduleItem: Sendable, Identifiable { - let uid: String - let startTime: String - let reason: String - var id: String { uid } -} - -/// An AI-generated day plan -struct AIDayPlan: Sendable { - let schedule: [AIScheduleItem] - let summary: String -} - -/// A nearby highlight from the AI assistant -struct AINearbyHighlight: Sendable, Identifiable { - let uid: String - let reason: String - var id: String { uid } -} - -// MARK: - Generable Types (iOS 26+ only) - #if canImport(FoundationModels) import FoundationModels @available(iOS 26, *) @Generable -struct GenerableRecommendation { - @Guide(description: "Object uid") - var uid: String - @Guide(description: "Why this matches the user's taste, under 12 words") - var reason: String -} - -@available(iOS 26, *) -@Generable -struct GenerableRecommendationResponse { - @Guide(description: "Recommended items", .count(3...8)) - var recommendations: [GenerableRecommendation] -} - -@available(iOS 26, *) -@Generable -struct GenerableScheduleItem { - @Guide(description: "Event uid") - var uid: String - @Guide(description: "Start time like '2:00 PM'") - var startTime: String - @Guide(description: "Why this fits the plan, under 12 words") - var reason: String -} - -@available(iOS 26, *) -@Generable -struct GenerableDayPlanResponse { - @Guide(description: "Events ordered by time", .count(3...10)) - var schedule: [GenerableScheduleItem] - @Guide(description: "One-sentence summary of the day theme") +struct GenerableEventCollectionSummary { + @Guide(description: "1-2 short factual sentences about this host. Only reference provided data. No times or schedules.") var summary: String } @available(iOS 26, *) @Generable -struct GenerableNearbyHighlight { - @Guide(description: "Object uid") - var uid: String - @Guide(description: "Why this is interesting right now, under 12 words") - var reason: String -} - -@available(iOS 26, *) -@Generable -struct GenerableNearbyResponse { - @Guide(description: "Nearby highlights, most interesting first", .count(2...8)) - var highlights: [GenerableNearbyHighlight] +struct GenerableFactCheck { + @Guide(description: "Phrases from the summary that are NOT supported by the source data. Empty if everything is accurate.", .count(0...5)) + var unsupportedClaims: [String] } #endif diff --git a/iBurn/AISearch/AIAssistantView.swift b/iBurn/AISearch/AIAssistantView.swift deleted file mode 100644 index f8b9014a..00000000 --- a/iBurn/AISearch/AIAssistantView.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// AIAssistantView.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -import SwiftUI -import PlayaDB -import UIKit - -struct AIAssistantView: View { - @ObservedObject var viewModel: AIAssistantViewModel - @Environment(\.themeColors) var themeColors - - var body: some View { - VStack(spacing: 0) { - // Feature picker - Picker("Feature", selection: $viewModel.selectedFeature) { - ForEach(AIAssistantViewModel.Feature.allCases) { feature in - Text(feature.rawValue).tag(feature) - } - } - .pickerStyle(.segmented) - .padding() - .onChange(of: viewModel.selectedFeature) { _ in - viewModel.loadCurrentFeature() - } - - // Content - if viewModel.isLoading { - Spacer() - VStack(spacing: 12) { - ProgressView() - .scaleEffect(1.2) - Text("AI is exploring the playa...") - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - Spacer() - } else if let error = viewModel.errorMessage { - Spacer() - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 36)) - .foregroundColor(.orange) - Text(error) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .multilineTextAlignment(.center) - Button("Try Again") { - viewModel.loadCurrentFeature() - } - .buttonStyle(.bordered) - } - .padding() - Spacer() - } else { - featureContent - } - } - } - - @ViewBuilder - private var featureContent: some View { - switch viewModel.selectedFeature { - case .forYou: - recommendationsList - case .dayPlan: - dayPlanView - case .nearby: - nearbyList - } - } - - // MARK: - For You - - private var recommendationsList: some View { - Group { - if viewModel.recommendations.isEmpty { - emptyState( - icon: "sparkles", - title: "Personalized Recommendations", - subtitle: "Favorite some art, camps, or events first, then AI will suggest similar things you might enjoy." - ) - } else { - List(viewModel.recommendations) { rec in - objectRow(uid: rec.uid, reason: rec.reason) - } - .listStyle(.plain) - } - } - } - - // MARK: - Day Plan - - private var dayPlanView: some View { - Group { - if let plan = viewModel.dayPlan, !plan.schedule.isEmpty { - List { - if !plan.summary.isEmpty { - Section { - Text(plan.summary) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - } - Section(header: Text("Schedule")) { - ForEach(plan.schedule) { item in - VStack(alignment: .leading, spacing: 4) { - Text(item.startTime) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(themeColors.detailColor) - objectRow(uid: item.uid, reason: item.reason) - } - } - } - } - .listStyle(.plain) - } else { - emptyState( - icon: "calendar.badge.clock", - title: "AI Day Planner", - subtitle: "Get a personalized schedule based on your interests and what's happening today." - ) - } - } - } - - // MARK: - Nearby - - private var nearbyList: some View { - Group { - if viewModel.nearbyHighlights.isEmpty { - emptyState( - icon: "location.circle", - title: "What's Nearby", - subtitle: "Discover interesting art, camps, and events near your current location." - ) - } else { - List(viewModel.nearbyHighlights) { highlight in - objectRow(uid: highlight.uid, reason: highlight.reason) - } - .listStyle(.plain) - } - } - } - - // MARK: - Shared Components - - @ViewBuilder - private func objectRow(uid: String, reason: String) -> some View { - if let resolved = viewModel.resolvedObjects[uid] { - Button { - navigateToDetail(uid: uid) - } label: { - VStack(alignment: .leading, spacing: 4) { - resolvedRow(uid: uid, resolved: resolved) - HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.caption2) - .foregroundStyle(.purple) - Text(reason) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - .lineLimit(2) - } - } - } - .buttonStyle(.plain) - } else { - VStack(alignment: .leading, spacing: 2) { - Text(uid) - .font(.headline) - .foregroundColor(themeColors.primaryColor) - Text(reason) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - } - } - } - - @ViewBuilder - private func resolvedRow(uid: String, resolved: AIAssistantViewModel.ResolvedObject) -> some View { - let isFavorite = viewModel.favoriteIDs.contains(uid) - let onFavoriteTap: () -> Void = { Task { await viewModel.toggleFavorite(uid) } } - switch resolved { - case .art(let art): - ObjectRowView( - object: art, - subtitle: nil, - rightSubtitle: art.artist, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - case .camp(let camp): - ObjectRowView( - object: camp, - subtitle: nil, - rightSubtitle: camp.hometown, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - case .event(let event): - ObjectRowView( - object: event, - subtitle: nil, - rightSubtitle: event.eventTypeLabel, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - case .mutantVehicle(let mv): - ObjectRowView( - object: mv, - subtitle: nil, - rightSubtitle: mv.artist, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - } - } - - private func navigateToDetail(uid: String) { - guard let resolved = viewModel.resolvedObjects[uid] else { return } - let playaDB = viewModel.playaDB - let detailVC: UIViewController - switch resolved { - case .art(let art): - detailVC = DetailViewControllerFactory.create(with: art, playaDB: playaDB) - case .camp(let camp): - detailVC = DetailViewControllerFactory.create(with: camp, playaDB: playaDB) - case .event(let event): - detailVC = DetailViewControllerFactory.create(with: event, playaDB: playaDB) - case .mutantVehicle(let mv): - detailVC = DetailViewControllerFactory.create(with: mv, playaDB: playaDB) - } - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let navController = window.rootViewController?.findNavigationController() else { - return - } - navController.pushViewController(detailVC, animated: true) - } - - private func emptyState(icon: String, title: String, subtitle: String) -> some View { - VStack(spacing: 12) { - Spacer() - Image(systemName: icon) - .font(.system(size: 48)) - .foregroundColor(themeColors.detailColor) - Text(title) - .font(.headline) - .foregroundColor(themeColors.primaryColor) - Text(subtitle) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .multilineTextAlignment(.center) - Button("Generate") { - viewModel.loadCurrentFeature() - } - .buttonStyle(.borderedProminent) - Spacer() - } - .padding() - } - -} diff --git a/iBurn/AISearch/AIAssistantViewModel.swift b/iBurn/AISearch/AIAssistantViewModel.swift deleted file mode 100644 index 9fcc4c0e..00000000 --- a/iBurn/AISearch/AIAssistantViewModel.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// AIAssistantViewModel.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -import Foundation -import CoreLocation -import PlayaDB - -@MainActor -final class AIAssistantViewModel: ObservableObject { - - enum Feature: String, CaseIterable, Identifiable { - case forYou = "For You" - case dayPlan = "Day Plan" - case nearby = "Nearby" - var id: String { rawValue } - } - - // MARK: - Published State - - @Published var selectedFeature: Feature = .forYou - @Published var recommendations: [AIRecommendation] = [] - @Published var dayPlan: AIDayPlan? - @Published var nearbyHighlights: [AINearbyHighlight] = [] - - @Published var isLoading: Bool = false - @Published var errorMessage: String? - - /// Resolved objects for display (uid -> actual PlayaDB model) - @Published var resolvedObjects: [String: ResolvedObject] = [:] - @Published var favoriteIDs: Set = [] - - enum ResolvedObject { - case art(ArtObject) - case camp(CampObject) - case event(EventObject) - case mutantVehicle(MutantVehicleObject) - - var objectType: DataObjectType { - switch self { - case .art: return .art - case .camp: return .camp - case .event: return .event - case .mutantVehicle: return .mutantVehicle - } - } - } - - // MARK: - Dependencies - - private let aiService: AIAssistantService - let playaDB: PlayaDB - private let locationProvider: LocationProvider - - private var loadTask: Task? - - init(aiService: AIAssistantService, playaDB: PlayaDB, locationProvider: LocationProvider) { - self.aiService = aiService - self.playaDB = playaDB - self.locationProvider = locationProvider - } - - // MARK: - Actions - - func loadCurrentFeature() { - loadTask?.cancel() - isLoading = true - errorMessage = nil - - loadTask = Task { [weak self] in - guard let self else { return } - do { - switch self.selectedFeature { - case .forYou: - let results = try await self.aiService.recommend() - guard !Task.isCancelled else { return } - await self.resolveUIDs(results.map(\.uid)) - self.recommendations = results - - case .dayPlan: - let plan = try await self.aiService.planDay( - date: Date(), - location: self.locationProvider.currentLocation - ) - guard !Task.isCancelled else { return } - await self.resolveUIDs(plan.schedule.map(\.uid)) - self.dayPlan = plan - - case .nearby: - guard let location = self.locationProvider.currentLocation else { - self.errorMessage = "Location not available. Enable location services to use this feature." - self.isLoading = false - return - } - let highlights = try await self.aiService.whatsNearby(location: location) - guard !Task.isCancelled else { return } - await self.resolveUIDs(highlights.map(\.uid)) - self.nearbyHighlights = highlights - } - self.isLoading = false - } catch is CancellationError { - // Ignored - } catch { - guard !Task.isCancelled else { return } - self.errorMessage = "AI is thinking too hard. Try again." - self.isLoading = false - print("AI Assistant error: \(error)") - } - } - } - - /// Fetch actual objects from PlayaDB for display and navigation - private func resolveUIDs(_ uids: [String]) async { - let unresolvedUIDs = uids.filter { resolvedObjects[$0] == nil } - guard !unresolvedUIDs.isEmpty else { return } - - guard let objects = try? await playaDB.fetchObjects(byUIDs: unresolvedUIDs) else { return } - for obj in objects { - if let art = obj as? ArtObject { - resolvedObjects[art.uid] = .art(art) - } else if let camp = obj as? CampObject { - resolvedObjects[camp.uid] = .camp(camp) - } else if let event = obj as? EventObject { - resolvedObjects[event.uid] = .event(event) - } else if let mv = obj as? MutantVehicleObject { - resolvedObjects[mv.uid] = .mutantVehicle(mv) - } - } - - let favorites = (try? await playaDB.getFavorites()) ?? [] - let favUIDs = Set(favorites.map(\.uid)) - for uid in unresolvedUIDs where favUIDs.contains(uid) { - favoriteIDs.insert(uid) - } - } - - func toggleFavorite(_ uid: String) async { - guard let resolved = resolvedObjects[uid] else { return } - do { - switch resolved { - case .art(let art): - try await playaDB.toggleFavorite(art) - let isFav = try await playaDB.isFavorite(art) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .camp(let camp): - try await playaDB.toggleFavorite(camp) - let isFav = try await playaDB.isFavorite(camp) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .event(let event): - try await playaDB.toggleFavorite(event) - let isFav = try await playaDB.isFavorite(event) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .mutantVehicle(let mv): - try await playaDB.toggleFavorite(mv) - let isFav = try await playaDB.isFavorite(mv) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - } - } catch { - print("Error toggling favorite: \(error)") - } - } -} diff --git a/iBurn/AISearch/AIGuideView.swift b/iBurn/AISearch/AIGuideView.swift deleted file mode 100644 index 0cb03276..00000000 --- a/iBurn/AISearch/AIGuideView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// AIGuideView.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import SwiftUI -import PlayaDB -import UIKit - -@available(iOS 26, *) -struct AIGuideView: View { - @ObservedObject var viewModel: AIGuideViewModel - @Environment(\.themeColors) var themeColors - let onSelectWorkflow: (WorkflowInfo) -> Void - - var body: some View { - List { - ForEach(WorkflowSection.allCases, id: \.rawValue) { section in - let workflows = WorkflowCatalog.workflows(for: section) - if !workflows.isEmpty { - Section(header: Text(section.rawValue)) { - ForEach(workflows) { workflow in - Button { - onSelectWorkflow(workflow) - } label: { - WorkflowRow(info: workflow, themeColors: themeColors) - } - } - } - } - } - } - .listStyle(.insetGrouped) - } -} - -// MARK: - Workflow Row - -@available(iOS 26, *) -struct WorkflowRow: View { - let info: WorkflowInfo - let themeColors: ImageColors - - var body: some View { - HStack(spacing: 14) { - Image(systemName: info.icon) - .font(.system(size: 20)) - .foregroundColor(.white) - .frame(width: 38, height: 38) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(colorForWorkflow(info.id)) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(info.title) - .font(.body) - .fontWeight(.medium) - .foregroundColor(themeColors.primaryColor) - Text(info.subtitle) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - .lineLimit(2) - } - } - .padding(.vertical, 4) - } - - private func colorForWorkflow(_ id: WorkflowID) -> Color { - switch id { - case .forYou: return .purple - case .surpriseMe: return .orange - case .whatDidIMiss: return .indigo - case .dayPlanner: return .blue - case .adventure: return .green - case .campCrawl: return .pink - case .goldenHour: return .yellow - case .scheduleOptimizer: return .teal - } - } -} - -#endif diff --git a/iBurn/AISearch/AIGuideViewModel.swift b/iBurn/AISearch/AIGuideViewModel.swift deleted file mode 100644 index 967c7281..00000000 --- a/iBurn/AISearch/AIGuideViewModel.swift +++ /dev/null @@ -1,502 +0,0 @@ -// -// AIGuideViewModel.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Step Progress Model - -struct WorkflowStepProgress: Identifiable { - let id = UUID() - let message: String - var state: StepState - - enum StepState { - case pending - case running - case completed - case failed - } -} - -// MARK: - Workflow Execution State - -enum WorkflowExecutionState { - case idle - case running - case completed - case failed(String) -} - -// MARK: - Workflow Result - -enum WorkflowResultContent { - case discovery(intro: String, items: [ObjectCard]) - case schedule(ScheduleResult) - case adventure(AdventureResult) - case empty(String) -} - -// MARK: - Per-Workflow Cached State - -struct WorkflowState { - var steps: [WorkflowStepProgress] = [] - var executionState: WorkflowExecutionState = .idle - var result: WorkflowResultContent? -} - -// MARK: - View Model - -@available(iOS 26, *) -@MainActor -final class AIGuideViewModel: ObservableObject { - - // MARK: - Published State (for current workflow) - - @Published var steps: [WorkflowStepProgress] = [] - @Published var executionState: WorkflowExecutionState = .idle - @Published var result: WorkflowResultContent? - @Published var resolvedObjects: [String: AIAssistantViewModel.ResolvedObject] = [:] - @Published var favoriteIDs: Set = [] - - // MARK: - Per-Workflow State Cache - - private var workflowStates: [WorkflowID: WorkflowState] = [:] - private(set) var activeWorkflowID: WorkflowID? - - // MARK: - Dependencies - - let playaDB: PlayaDB - let orchestrator: AgentOrchestrator - private var currentTask: Task? - private static let maxRetries = 3 - - init(playaDB: PlayaDB, orchestrator: AgentOrchestrator) { - self.playaDB = playaDB - self.orchestrator = orchestrator - } - - // MARK: - Workflow Lifecycle - - /// Load cached state for a workflow (called when entering a workflow detail view) - func loadWorkflow(_ id: WorkflowID) { - // Save current workflow state - saveCurrentWorkflowState() - - activeWorkflowID = id - let state = workflowStates[id] ?? WorkflowState() - steps = state.steps - executionState = state.executionState - result = state.result - } - - /// Save current workflow state to cache (preserves results across navigation) - private func saveCurrentWorkflowState() { - guard let id = activeWorkflowID else { return } - workflowStates[id] = WorkflowState( - steps: steps, - executionState: executionState, - result: result - ) - } - - /// Check if a workflow has been run before - func hasRun(_ id: WorkflowID) -> Bool { - if let state = workflowStates[id] { - switch state.executionState { - case .completed, .running: return true - default: return false - } - } - return false - } - - // MARK: - Run Workflow - - func run( - _ workflowID: WorkflowID, - theme: String? = nil, - hoursBack: Int? = nil, - startDate: Date? = nil - ) { - currentTask?.cancel() - steps = [] - result = nil - executionState = .running - - currentTask = Task { [weak self] in - guard let self else { return } - await self.executeWithRetry(workflowID, theme: theme, hoursBack: hoursBack, startDate: startDate, attempt: 0) - } - } - - /// Execute with automatic retry on recoverable errors - private func executeWithRetry( - _ workflowID: WorkflowID, - theme: String?, - hoursBack: Int?, - startDate: Date?, - attempt: Int - ) async { - do { - try await executeWorkflow(workflowID, theme: theme, hoursBack: hoursBack, startDate: startDate, attempt: attempt) - executionState = .completed - saveCurrentWorkflowState() - } catch is CancellationError { - // Ignored - } catch { - guard !Task.isCancelled else { return } - print("Workflow error (attempt \(attempt + 1)/\(Self.maxRetries + 1)): \(error)") - - if attempt < Self.maxRetries { - markCurrentStepFailed() - #if DEBUG - addStep("⚠️ \(shortErrorDescription(error))") - #endif - // Clear steps from failed attempt before retrying - steps.removeAll() - addStep(retryMessage(for: error, attempt: attempt)) - await executeWithRetry(workflowID, theme: theme, hoursBack: hoursBack, startDate: startDate, attempt: attempt + 1) - } else { - markCurrentStepFailed() - #if DEBUG - let debugMsg = "\(userFacingMessage(for: error))\n\n[DEBUG: \(shortErrorDescription(error))]" - executionState = .failed(debugMsg) - #else - executionState = .failed(userFacingMessage(for: error)) - #endif - saveCurrentWorkflowState() - } - } - } - - private func shortErrorDescription(_ error: Error) -> String { - let desc = String(describing: error) - // Truncate long error descriptions for readability - if desc.count > 200 { - return String(desc.prefix(200)) + "..." - } - return desc - } - - private func executeWorkflow( - _ workflowID: WorkflowID, - theme: String?, - hoursBack: Int?, - startDate: Date?, - attempt: Int - ) async throws { - let safe = attempt > 0 - switch workflowID { - case .forYou: - try await runRecommendations(safe: safe) - case .surpriseMe: - try await runSerendipity(safe: safe) - case .whatDidIMiss: - try await runWhatDidIMiss(hoursBack: hoursBack ?? 24) - case .dayPlanner: - try await runDayPlan(safe: safe, startDate: startDate) - case .adventure: - try await runAdventure(theme: theme ?? "best of the playa", safe: safe) - case .campCrawl: - try await runCampCrawl(theme: theme ?? "eclectic experience", safe: safe) - case .goldenHour: - try await runGoldenHour() - case .scheduleOptimizer: - try await runScheduleOptimizer() - } - } - - // MARK: - Error Classification & Retry - - private func isRetryableError(_ error: Error) -> Bool { - true - } - - private func retryMessage(for error: Error, attempt: Int) -> String { - let suffix = attempt > 0 ? " (attempt \(attempt + 1)/\(Self.maxRetries + 1))" : "" - if isGuardrailError(error) { - return "Taking a more family-friendly approach...\(suffix)" - } else if isContextWindowError(error) { - return "Simplifying the request...\(suffix)" - } else if case LanguageModelSession.GenerationError.unsupportedLanguageOrLocale = error { - return "Adjusting language settings...\(suffix)" - } else if case LanguageModelSession.GenerationError.rateLimited = error { - return "Waiting a moment...\(suffix)" - } else if String(describing: error).contains("fts5") { - return "Simplifying the search query...\(suffix)" - } else { - return "Dusting off and trying again...\(suffix)" - } - } - - private func userFacingMessage(for error: Error) -> String { - if isGuardrailError(error) { - return "The AI couldn't process this request safely. Try a different theme?" - } else if isContextWindowError(error) { - return "Too much data for the AI to process. Try a more specific theme." - } else if case LanguageModelSession.GenerationError.unsupportedLanguageOrLocale = error { - return "AI features require English language settings on this device." - } else if case LanguageModelSession.GenerationError.rateLimited = error { - return "The AI is temporarily busy. Try again in a moment." - } else if case LanguageModelSession.GenerationError.assetsUnavailable = error { - return "AI model not available. Check that Apple Intelligence is enabled." - } else { - return "Something went wrong. Tap Generate to try again." - } - } - - // MARK: - Step Management - - private func addStep(_ message: String) { - steps.append(WorkflowStepProgress(message: message, state: .running)) - } - - private func completeCurrentStep() { - guard let idx = steps.lastIndex(where: { $0.state == .running }) else { return } - steps[idx].state = .completed - } - - private func markCurrentStepFailed() { - guard let idx = steps.lastIndex(where: { $0.state == .running }) else { return } - steps[idx].state = .failed - } - - // MARK: - Progress Handler - - private func handleProgress(_ progress: WorkflowProgress) { - Task { @MainActor [weak self] in - guard let self else { return } - switch progress { - case .stepStarted(_, let description): - self.addStep(description) - case .stepCompleted: - self.completeCurrentStep() - case .intermediateResult: - break - } - } - } - - // MARK: - Workflow Runners - - private func runRecommendations(safe: Bool = false) async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.tasteProfiling)) - let workflow = SerendipityWorkflow(deliberateRandom: false) - let discoveryResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - await resolveUIDs(discoveryResult.items.map(\.uid)) - result = .discovery( - intro: discoveryResult.intro, - items: discoveryResult.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - } - ) - } - - private func runSerendipity(safe: Bool = false) async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.serendipity)) - let workflow = SerendipityWorkflow(deliberateRandom: true) - let discoveryResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - await resolveUIDs(discoveryResult.items.map(\.uid)) - result = .discovery( - intro: discoveryResult.intro, - items: discoveryResult.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - } - ) - } - - private func runWhatDidIMiss(hoursBack: Int) async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.analyzingTracks)) - let workflow = WhatDidIMissWorkflow() - let discoveryResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - if discoveryResult.items.isEmpty { - result = .empty(discoveryResult.intro) - } else { - await resolveUIDs(discoveryResult.items.map(\.uid)) - result = .discovery( - intro: discoveryResult.intro, - items: discoveryResult.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - } - ) - } - } - - private func runDayPlan(safe: Bool = false, startDate: Date? = nil) async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.tasteProfiling)) - let workflow = DayPlanWorkflow() - let planResult = try await orchestrator.execute(workflow, startDate: startDate) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - if planResult.items.isEmpty { - result = .empty(planResult.summary) - } else { - await resolveUIDs(planResult.items.map(\.uid)) - result = .schedule(ScheduleResult( - entries: planResult.items.map { entry in - ScheduleResultEntry(uid: entry.uid, name: entry.name, startTime: entry.startTime, endTime: entry.endTime, reason: entry.reason, walkMinutesFromPrevious: entry.walkMinutesFromPrevious) - }, - summary: planResult.summary, - conflictsResolved: 0 - )) - } - } - - private func runAdventure(theme: String, safe: Bool = false) async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.searching)) - let workflow = AdventureWorkflow(theme: safe ? "interesting art and camps" : theme) - let routeResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - if routeResult.stops.isEmpty { - result = .empty(routeResult.narrative) - } else { - await resolveUIDs(routeResult.stops.map(\.id)) - result = .adventure(AdventureResult( - narrative: routeResult.narrative, - stops: routeResult.stops.map { stop in - AdventureStop(uid: stop.id, name: stop.name, type: stop.type, tip: stop.reason, walkMinutesFromPrevious: stop.walkMinutesFromPrevious) - }, - totalWalkMinutes: routeResult.totalWalkMinutes - )) - } - } - - private func runCampCrawl(theme: String, safe: Bool = false) async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.searchingCamps)) - let workflow = CampCrawlWorkflow(theme: safe ? "diverse experiences" : theme) - let routeResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - if routeResult.stops.isEmpty { - result = .empty(routeResult.narrative) - } else { - await resolveUIDs(routeResult.stops.map(\.id)) - result = .adventure(AdventureResult( - narrative: routeResult.narrative, - stops: routeResult.stops.map { stop in - AdventureStop(uid: stop.id, name: stop.name, type: stop.type, tip: stop.reason, walkMinutesFromPrevious: stop.walkMinutesFromPrevious) - }, - totalWalkMinutes: routeResult.totalWalkMinutes - )) - } - } - - private func runGoldenHour() async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.goldenHour)) - let workflow = GoldenHourWorkflow() - let routeResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - if routeResult.stops.isEmpty { - result = .empty(routeResult.narrative) - } else { - await resolveUIDs(routeResult.stops.map(\.id)) - result = .adventure(AdventureResult( - narrative: routeResult.narrative, - stops: routeResult.stops.map { stop in - AdventureStop(uid: stop.id, name: stop.name, type: stop.type, tip: stop.reason, walkMinutesFromPrevious: stop.walkMinutesFromPrevious) - }, - totalWalkMinutes: routeResult.totalWalkMinutes - )) - } - } - - private func runScheduleOptimizer() async throws { - addStep(PlayaProgressMessages.random(from: PlayaProgressMessages.conflictDetection)) - let workflow = ScheduleOptimizerWorkflow() - let optimizerResult = try await orchestrator.execute(workflow) { [weak self] progress in - self?.handleProgress(progress) - } - completeCurrentStep() - if optimizerResult.items.isEmpty { - result = .empty(optimizerResult.summary) - } else { - await resolveUIDs(optimizerResult.items.map(\.uid)) - result = .schedule(ScheduleResult( - entries: optimizerResult.items.map { entry in - ScheduleResultEntry(uid: entry.uid, name: entry.name, startTime: entry.startTime, endTime: entry.endTime, reason: entry.reason, walkMinutesFromPrevious: entry.walkMinutesFromPrevious) - }, - summary: optimizerResult.summary, - conflictsResolved: optimizerResult.conflictsResolved - )) - } - } - - // MARK: - Object Resolution - - func resolveUIDs(_ uids: [String]) async { - let unresolvedUIDs = uids.filter { resolvedObjects[$0] == nil } - guard !unresolvedUIDs.isEmpty else { return } - - guard let objects = try? await playaDB.fetchObjects(byUIDs: unresolvedUIDs) else { return } - for obj in objects { - if let art = obj as? ArtObject { - resolvedObjects[art.uid] = .art(art) - } else if let camp = obj as? CampObject { - resolvedObjects[camp.uid] = .camp(camp) - } else if let event = obj as? EventObject { - resolvedObjects[event.uid] = .event(event) - } else if let mv = obj as? MutantVehicleObject { - resolvedObjects[mv.uid] = .mutantVehicle(mv) - } - } - - // Batch check favorites - let favorites = (try? await playaDB.getFavorites()) ?? [] - let favUIDs = Set(favorites.map(\.uid)) - for uid in unresolvedUIDs where favUIDs.contains(uid) { - favoriteIDs.insert(uid) - } - } - - func toggleFavorite(_ uid: String) async { - guard let resolved = resolvedObjects[uid] else { return } - do { - switch resolved { - case .art(let art): - try await playaDB.toggleFavorite(art) - let isFav = try await playaDB.isFavorite(art) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .camp(let camp): - try await playaDB.toggleFavorite(camp) - let isFav = try await playaDB.isFavorite(camp) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .event(let event): - try await playaDB.toggleFavorite(event) - let isFav = try await playaDB.isFavorite(event) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .mutantVehicle(let mv): - try await playaDB.toggleFavorite(mv) - let isFav = try await playaDB.isFavorite(mv) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - } - } catch { - print("Error toggling favorite: \(error)") - } - } -} - -#endif diff --git a/iBurn/AISearch/AIResolvedObject.swift b/iBurn/AISearch/AIResolvedObject.swift new file mode 100644 index 00000000..2ec7b3f8 --- /dev/null +++ b/iBurn/AISearch/AIResolvedObject.swift @@ -0,0 +1,29 @@ +// +// AIResolvedObject.swift +// iBurn +// +// A resolved PlayaDB object (uid → concrete model) used by AI result views for +// display and navigation. Lives in a neutral file so it survives the AI Guide rewrite. +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import Foundation +import PlayaDB + +enum AIResolvedObject { + case art(ArtObject) + case camp(CampObject) + case event(EventObject) + case mutantVehicle(MutantVehicleObject) + + var objectType: DataObjectType { + switch self { + case .art: return .art + case .camp: return .camp + case .event: return .event + case .mutantVehicle: return .mutantVehicle + } + } +} diff --git a/iBurn/AISearch/AISearchService.swift b/iBurn/AISearch/AISearchService.swift index 0f8d751d..6db26e47 100644 --- a/iBurn/AISearch/AISearchService.swift +++ b/iBurn/AISearch/AISearchService.swift @@ -7,7 +7,6 @@ // import Foundation -import CoreLocation @preconcurrency import PlayaDB /// Result from AI-powered semantic search @@ -25,24 +24,12 @@ protocol AISearchService: Sendable { func search(_ query: String) async throws -> [AISearchResult] } -/// Extended protocol for AI assistant features (recommendations, day planner, nearby) -protocol AIAssistantService: AISearchService { - /// Recommend items based on user's favorites - func recommend() async throws -> [AIRecommendation] - - /// Generate a day plan for a specific date - func planDay(date: Date, location: CLLocation?) async throws -> AIDayPlan - - /// Find interesting things happening nearby right now - func whatsNearby(location: CLLocation) async throws -> [AINearbyHighlight] -} - #if canImport(FoundationModels) import FoundationModels -/// AI search and assistant implementation using Apple Foundation Models (iOS 26+) +/// AI semantic search implementation using Apple Foundation Models (iOS 26+) @available(iOS 26, *) -final class FoundationModelSearchService: AIAssistantService { +final class FoundationModelSearchService: AISearchService { private let playaDB: PlayaDB var isAvailable: Bool { @@ -53,8 +40,6 @@ final class FoundationModelSearchService: AIAssistantService { self.playaDB = playaDB } - // MARK: - Search - func search(_ query: String) async throws -> [AISearchResult] { guard isAvailable else { return [] } @@ -85,119 +70,6 @@ final class FoundationModelSearchService: AIAssistantService { AISearchResult(uid: $0.uid, reason: $0.reason) } } - - // MARK: - Recommendations - - func recommend() async throws -> [AIRecommendation] { - guard isAvailable else { return [] } - - let tools: [any Tool] = [ - GetFavoritesTool(playaDB: playaDB), - SearchByKeywordTool(playaDB: playaDB), - FetchArtTool(playaDB: playaDB), - FetchCampsTool(playaDB: playaDB), - FetchMutantVehiclesTool(playaDB: playaDB), - ] - - let session = LanguageModelSession( - tools: tools, - instructions: """ - You are a recommendation engine for a Burning Man festival guide. \ - First call getFavorites to see what the user likes. Analyze the themes, \ - types, and keywords in their favorites. Then search for similar items \ - they haven't favorited yet. Return diverse recommendations across types. - """ - ) - - let response = try await session.respond( - to: Prompt("What should I check out based on my favorites?"), - generating: GenerableRecommendationResponse.self - ) - - return response.content.recommendations.map { - AIRecommendation(uid: $0.uid, reason: $0.reason) - } - } - - // MARK: - Day Planner - - func planDay(date: Date, location: CLLocation?) async throws -> AIDayPlan { - guard isAvailable else { return AIDayPlan(schedule: [], summary: "") } - - var tools: [any Tool] = [ - GetFavoritesTool(playaDB: playaDB), - FetchUpcomingEventsTool(playaDB: playaDB), - ] - if location != nil { - tools.append(FetchNearbyObjectsTool(playaDB: playaDB)) - } - - let formatter = DateFormatter() - formatter.dateFormat = "EEEE, MMMM d" - formatter.timeZone = TimeZone(identifier: "America/Los_Angeles") - let dayString = formatter.string(from: date) - - var prompt = "Plan my day for \(dayString) at Burning Man." - if let loc = location { - prompt += " I'm currently at GPS \(loc.coordinate.latitude), \(loc.coordinate.longitude)." - } - - let session = LanguageModelSession( - tools: tools, - instructions: """ - You are a day planner for Burning Man. Check the user's favorites to \ - understand their interests. Find upcoming events that match. Create a \ - schedule ordered by time. Mix familiar interests with new discoveries. \ - Consider walk time between locations (~10 min across playa). - """ - ) - - let response = try await session.respond( - to: Prompt(prompt), - generating: GenerableDayPlanResponse.self - ) - - let schedule = response.content.schedule.map { - AIScheduleItem(uid: $0.uid, startTime: $0.startTime, reason: $0.reason) - } - return AIDayPlan(schedule: schedule, summary: response.content.summary) - } - - // MARK: - What's Nearby - - func whatsNearby(location: CLLocation) async throws -> [AINearbyHighlight] { - guard isAvailable else { return [] } - - let tools: [any Tool] = [ - GetFavoritesTool(playaDB: playaDB), - FetchUpcomingEventsTool(playaDB: playaDB), - FetchNearbyObjectsTool(playaDB: playaDB), - ] - - let session = LanguageModelSession( - tools: tools, - instructions: """ - You are a Burning Man guide. Find the most interesting things near \ - the user right now. Check their favorites to understand preferences. \ - Prioritize: events starting soon, art matching their taste, and camps \ - they'd enjoy. Highlight why each item is worth visiting right now. - """ - ) - - let prompt = """ - What's interesting near me? I'm at GPS \ - \(location.coordinate.latitude), \(location.coordinate.longitude). - """ - - let response = try await session.respond( - to: Prompt(prompt), - generating: GenerableNearbyResponse.self - ) - - return response.content.highlights.map { - AINearbyHighlight(uid: $0.uid, reason: $0.reason) - } - } } @available(iOS 26, *) @@ -218,7 +90,7 @@ struct AISearchResponse { #endif -/// Factory for creating the appropriate AI service +/// Factory for creating the AI search service enum AISearchServiceFactory { @MainActor static func create(playaDB: PlayaDB) -> AISearchService? { @@ -230,15 +102,4 @@ enum AISearchServiceFactory { #endif return nil } - - @MainActor - static func createAssistant(playaDB: PlayaDB) -> AIAssistantService? { - #if canImport(FoundationModels) - if #available(iOS 26, *) { - let service = FoundationModelSearchService(playaDB: playaDB) - return service.isAvailable ? service : nil - } - #endif - return nil - } } diff --git a/iBurn/AISearch/AgentOrchestrator.swift b/iBurn/AISearch/AgentOrchestrator.swift index 535e698d..45d42692 100644 --- a/iBurn/AISearch/AgentOrchestrator.swift +++ b/iBurn/AISearch/AgentOrchestrator.swift @@ -9,6 +9,7 @@ #if canImport(FoundationModels) import Foundation import CoreLocation +import MapKit import FoundationModels @preconcurrency import PlayaDB @@ -24,12 +25,23 @@ final class AgentOrchestrator: @unchecked Sendable { init(playaDB: PlayaDB, locationProvider: LocationProvider) { self.playaDB = playaDB self.locationProvider = locationProvider + Self.warmUpLanguageModel() } var isAvailable: Bool { SystemLanguageModel.default.isAvailable } + /// Pre-warm the on-device language model with a trivial call so it's + /// already loaded in memory when the user opens a detail page. + private static func warmUpLanguageModel() { + guard SystemLanguageModel.default.isAvailable else { return } + Task.detached(priority: .background) { + let session = LanguageModelSession(instructions: "Reply with OK.") + _ = try? await session.respond(to: Prompt("ping")) + } + } + // MARK: - Step Execution /// Execute a single LLM step with focused tools and instructions @@ -66,15 +78,25 @@ final class AgentOrchestrator: @unchecked Sendable { /// Execute a complete workflow with progress streaming func execute( _ workflow: W, + region: MKCoordinateRegion? = nil, + window: (start: Date, end: Date)? = nil, + vibe: String = "", + lean: DiscoveryLean = .balanced, startDate: Date? = nil, conversationHistory: [String] = [], onProgress: @escaping (WorkflowProgress) -> Void ) async throws -> W.Result { + let now = startDate ?? Date.present let context = WorkflowContext( playaDB: playaDB, location: locationProvider.currentLocation, - date: startDate ?? Date(), - conversationHistory: conversationHistory + date: now, + conversationHistory: conversationHistory, + region: region, + windowStart: window?.start, + windowEnd: window?.end, + vibe: vibe, + lean: lean ) return try await workflow.execute(context: context, onProgress: onProgress) } diff --git a/iBurn/AISearch/AreaPickerView.swift b/iBurn/AISearch/AreaPickerView.swift new file mode 100644 index 00000000..7d9de498 --- /dev/null +++ b/iBurn/AISearch/AreaPickerView.swift @@ -0,0 +1,104 @@ +// +// AreaPickerView.swift +// iBurn +// +// Lets the user pan/zoom the BRC map and capture the visible area as an +// MKCoordinateRegion for scoping AI "Right Now" discovery. +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import SwiftUI +import MapKit +import MapLibre + +/// Holds a weak reference to the live map view so the SwiftUI button can read its +/// current viewport when the user taps "Use this area." +final class AreaMapProxy { + weak var mapView: MLNMapView? + + var currentRegion: MKCoordinateRegion? { + guard let mapView else { return nil } + let bounds = mapView.visibleCoordinateBounds + return coordinateRegion( + swLat: bounds.sw.latitude, swLon: bounds.sw.longitude, + neLat: bounds.ne.latitude, neLon: bounds.ne.longitude + ) + } +} + +/// Convert SW/NE corner coordinates (e.g. a map's visible bounds) to an MKCoordinateRegion. +/// Free function so it can be unit-tested without a live map view. +func coordinateRegion(swLat: Double, swLon: Double, neLat: Double, neLon: Double) -> MKCoordinateRegion { + MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: (swLat + neLat) / 2, longitude: (swLon + neLon) / 2), + span: MKCoordinateSpan(latitudeDelta: abs(neLat - swLat), longitudeDelta: abs(neLon - swLon)) + ) +} + +struct AreaPickerView: View { + let onUseArea: (MKCoordinateRegion) -> Void + let onCancel: () -> Void + + @State private var proxy = AreaMapProxy() + + var body: some View { + NavigationStack { + ZStack { + AreaPickerMapRepresentable(proxy: proxy) + .ignoresSafeArea(edges: .bottom) + + // Center reticle marks the focus of the selected area. + Image(systemName: "plus.viewfinder") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + .allowsHitTesting(false) + + VStack { + Spacer() + Text("Pan & zoom to the area you want") + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.ultraThinMaterial)) + Button { + if let region = proxy.currentRegion { + onUseArea(region) + } + } label: { + Label("Use this area", systemImage: "checkmark.circle.fill") + .font(.headline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding() + } + } + .navigationTitle("Pick an area") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { onCancel() } + } + } + } + } +} + +struct AreaPickerMapRepresentable: UIViewRepresentable { + let proxy: AreaMapProxy + + func makeUIView(context: Context) -> MLNMapView { + let mapView = MLNMapView.brcMapView() + mapView.showsUserLocation = true + // Frame the whole city to start; the user zooms into their area of interest. + mapView.brc_zoomToFullTileSource(animated: false) + proxy.mapView = mapView + return mapView + } + + func updateUIView(_ mapView: MLNMapView, context: Context) { + proxy.mapView = mapView + } +} diff --git a/iBurn/AISearch/ChatBubble.swift b/iBurn/AISearch/ChatBubble.swift deleted file mode 100644 index abaf3053..00000000 --- a/iBurn/AISearch/ChatBubble.swift +++ /dev/null @@ -1,361 +0,0 @@ -// -// ChatBubble.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import SwiftUI -import PlayaDB - -@available(iOS 26, *) -struct ChatBubble: View { - let message: ChatMessage - @ObservedObject var viewModel: ChatViewModel - let onNavigate: (String) -> Void - @Environment(\.themeColors) var themeColors - - var body: some View { - switch message.content { - case .text(let text): - textBubble(text, isUser: message.role == .user) - - case .objectCards(let cards): - objectCardsView(cards) - - case .schedule(let schedule): - scheduleView(schedule) - - case .adventure(let adventure): - adventureView(adventure) - - case .quickActions(let actions): - quickActionsView(actions) - - case .loading(let text): - loadingView(text) - - case .error(let text): - errorView(text) - } - } - - // MARK: - Text Bubble - - private func textBubble(_ text: String, isUser: Bool) -> some View { - HStack { - if isUser { Spacer(minLength: 60) } - Text(text) - .font(.body) - .foregroundColor(isUser ? .white : themeColors.primaryColor) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(isUser ? themeColors.detailColor : Color(.systemGray6)) - ) - if !isUser { Spacer(minLength: 60) } - } - } - - // MARK: - Object Cards - - private func objectCardsView(_ cards: [ObjectCard]) -> some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(cards) { card in - Button { - onNavigate(card.uid) - } label: { - objectCardRow(card) - } - .buttonStyle(.plain) - } - } - } - - private func objectCardRow(_ card: ObjectCard) -> some View { - HStack(spacing: 10) { - // Type badge - Image(systemName: iconForType(card.type)) - .font(.system(size: 14)) - .foregroundColor(.white) - .frame(width: 28, height: 28) - .background(Circle().fill(colorForType(card.type))) - - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(card.name) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(themeColors.primaryColor) - .lineLimit(1) - - if viewModel.favoriteIDs.contains(card.uid) { - Image(systemName: "heart.fill") - .font(.caption2) - .foregroundColor(.red) - } - } - - HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.caption2) - .foregroundStyle(.purple) - Text(card.reason) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - .lineLimit(2) - } - - if let time = card.timeInfo { - Text(time) - .font(.caption2) - .foregroundColor(themeColors.detailColor) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.gray) - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.05), radius: 2, y: 1) - ) - } - - // MARK: - Schedule View - - private func scheduleView(_ schedule: ScheduleResult) -> some View { - VStack(alignment: .leading, spacing: 8) { - if !schedule.summary.isEmpty { - Text(schedule.summary) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .padding(.bottom, 4) - } - - if schedule.conflictsResolved > 0 { - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("\(schedule.conflictsResolved) conflict\(schedule.conflictsResolved == 1 ? "" : "s") resolved") - .font(.caption) - .foregroundColor(.green) - } - } - - ForEach(schedule.entries) { entry in - Button { onNavigate(entry.uid) } label: { - VStack(alignment: .leading, spacing: 4) { - if let walk = entry.walkMinutesFromPrevious, walk > 0 { - HStack(spacing: 4) { - Image(systemName: "figure.walk") - .font(.caption2) - Text("~\(walk) min walk") - .font(.caption2) - } - .foregroundColor(.gray) - .padding(.leading, 4) - } - - HStack(alignment: .top, spacing: 8) { - VStack(spacing: 2) { - Text(entry.startTime) - .font(.caption) - .fontWeight(.bold) - .foregroundColor(themeColors.detailColor) - Text(entry.endTime) - .font(.caption2) - .foregroundColor(.gray) - } - .frame(width: 60) - - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(themeColors.primaryColor) - .lineLimit(1) - Text(entry.reason) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - .lineLimit(2) - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.05), radius: 1, y: 1) - ) - } - } - .buttonStyle(.plain) - } - } - } - - // MARK: - Adventure View - - private func adventureView(_ adventure: AdventureResult) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text(adventure.narrative) - .font(.subheadline) - .foregroundColor(themeColors.primaryColor) - - HStack(spacing: 4) { - Image(systemName: "figure.walk") - Text("Total: ~\(adventure.totalWalkMinutes) min walking") - .font(.caption) - } - .foregroundColor(themeColors.secondaryColor) - - ForEach(Array(adventure.stops.enumerated()), id: \.element.id) { index, stop in - Button { onNavigate(stop.uid) } label: { - VStack(alignment: .leading, spacing: 4) { - if let walk = stop.walkMinutesFromPrevious, walk > 0 { - HStack(spacing: 4) { - Image(systemName: "figure.walk") - .font(.caption2) - Text("~\(walk) min walk") - .font(.caption2) - } - .foregroundColor(.gray) - .padding(.leading, 4) - } - - HStack(spacing: 8) { - Text("\(index + 1)") - .font(.caption) - .fontWeight(.bold) - .foregroundColor(.white) - .frame(width: 24, height: 24) - .background(Circle().fill(themeColors.detailColor)) - - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(stop.name) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(themeColors.primaryColor) - .lineLimit(1) - Image(systemName: iconForType(stop.type)) - .font(.caption2) - .foregroundColor(colorForType(stop.type)) - } - Text(stop.tip) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - .lineLimit(2) - } - - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.gray) - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.05), radius: 1, y: 1) - ) - } - } - .buttonStyle(.plain) - } - } - } - - // MARK: - Quick Actions - - private func quickActionsView(_ actions: [QuickAction]) -> some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(actions) { action in - Button { - viewModel.inputText = action.prompt - viewModel.send() - } label: { - Text(action.label) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(themeColors.detailColor) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule() - .stroke(themeColors.detailColor, lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - } - } - } - - // MARK: - Loading - - private func loadingView(_ text: String) -> some View { - HStack(spacing: 8) { - ProgressView() - .scaleEffect(0.8) - Text(text) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(.systemGray6)) - ) - } - - // MARK: - Error - - private func errorView(_ text: String) -> some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.orange) - Text(text) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(.systemGray6)) - ) - } - - // MARK: - Helpers - - private func iconForType(_ type: DataObjectType) -> String { - switch type { - case .art: return "paintpalette" - case .camp: return "tent" - case .event: return "calendar" - case .mutantVehicle: return "car" - } - } - - private func colorForType(_ type: DataObjectType) -> Color { - switch type { - case .art: return .purple - case .camp: return .orange - case .event: return .blue - case .mutantVehicle: return .green - } - } -} - -#endif diff --git a/iBurn/AISearch/ChatMessage.swift b/iBurn/AISearch/ChatMessage.swift deleted file mode 100644 index 3ba90c28..00000000 --- a/iBurn/AISearch/ChatMessage.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// ChatMessage.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -import Foundation -@preconcurrency import PlayaDB - -// MARK: - Chat Message - -struct ChatMessage: Identifiable, Sendable { - let id: UUID - let role: Role - let content: MessageContent - let timestamp: Date - - init(role: Role, content: MessageContent) { - self.id = UUID() - self.role = role - self.content = content - self.timestamp = Date() - } - - enum Role: Sendable { - case user - case assistant - case system - } - - enum MessageContent: Sendable { - case text(String) - case objectCards([ObjectCard]) - case schedule(ScheduleResult) - case adventure(AdventureResult) - case quickActions([QuickAction]) - case loading(String) - case error(String) - } -} - -// MARK: - Object Card - -struct ObjectCard: Identifiable, Sendable { - var id: String { uid } - let uid: String - let name: String - let type: DataObjectType - let reason: String - let distance: String? - let timeInfo: String? -} - -// MARK: - Schedule Result - -struct ScheduleResult: Sendable { - let entries: [ScheduleResultEntry] - let summary: String - let conflictsResolved: Int -} - -struct ScheduleResultEntry: Identifiable, Sendable { - var id: String { uid } - let uid: String - let name: String - let startTime: String - let endTime: String - let reason: String - let walkMinutesFromPrevious: Int? -} - -// MARK: - Adventure Result - -struct AdventureResult: Sendable { - let narrative: String - let stops: [AdventureStop] - let totalWalkMinutes: Int -} - -struct AdventureStop: Identifiable, Sendable { - var id: String { uid } - let uid: String - let name: String - let type: DataObjectType - let tip: String - let walkMinutesFromPrevious: Int? -} - -// MARK: - Quick Action - -struct QuickAction: Identifiable, Sendable { - let id: UUID - let label: String - let prompt: String - let icon: String - - init(label: String, prompt: String, icon: String) { - self.id = UUID() - self.label = label - self.prompt = prompt - self.icon = icon - } -} - -// MARK: - Quick Start Presets - -extension QuickAction { - static let quickStartActions: [QuickAction] = [ - QuickAction(label: "Surprise Me", prompt: "Surprise me with something unexpected", icon: "dice"), - QuickAction(label: "Plan Adventure", prompt: "Plan a playa adventure for me", icon: "map"), - QuickAction(label: "Optimize Schedule", prompt: "Optimize my schedule and resolve conflicts", icon: "calendar.badge.checkmark"), - QuickAction(label: "Camp Crawl", prompt: "Plan a themed camp crawl", icon: "figure.walk"), - QuickAction(label: "What Did I Miss?", prompt: "What interesting things did I walk past but miss?", icon: "eye.slash"), - QuickAction(label: "Golden Hour Art", prompt: "Plan a golden hour art viewing route", icon: "sun.horizon"), - ] -} diff --git a/iBurn/AISearch/ChatView.swift b/iBurn/AISearch/ChatView.swift deleted file mode 100644 index 5ed079e5..00000000 --- a/iBurn/AISearch/ChatView.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// ChatView.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import SwiftUI -import PlayaDB -import UIKit - -@available(iOS 26, *) -struct ChatView: View { - @ObservedObject var viewModel: ChatViewModel - @Environment(\.themeColors) var themeColors - @FocusState private var isInputFocused: Bool - - var body: some View { - VStack(spacing: 0) { - // Messages area - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - if viewModel.messages.isEmpty { - quickStartSection - } - - ForEach(viewModel.messages) { message in - ChatBubble( - message: message, - viewModel: viewModel, - onNavigate: navigateToDetail - ) - .id(message.id) - } - } - .padding(.horizontal) - .padding(.top, 8) - .padding(.bottom, 8) - } - .onChange(of: viewModel.messages.count) { _ in - if let last = viewModel.messages.last { - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - } - - Divider() - - // Input bar - HStack(spacing: 8) { - TextField("Ask about Burning Man...", text: $viewModel.inputText) - .textFieldStyle(.plain) - .focused($isInputFocused) - .onSubmit { viewModel.send() } - .disabled(viewModel.isProcessing) - - Button { - viewModel.send() - } label: { - Image(systemName: viewModel.isProcessing ? "stop.circle.fill" : "arrow.up.circle.fill") - .font(.system(size: 28)) - .foregroundColor(viewModel.inputText.isEmpty && !viewModel.isProcessing ? .gray : themeColors.detailColor) - } - .disabled(viewModel.inputText.isEmpty && !viewModel.isProcessing) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .navigationTitle("AI Guide") - .navigationBarTitleDisplayMode(.inline) - } - - // MARK: - Quick Start - - private var quickStartSection: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .center, spacing: 8) { - Image(systemName: "sparkles") - .font(.system(size: 40)) - .foregroundColor(themeColors.detailColor) - - Text("Your AI Playa Guide") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(themeColors.primaryColor) - - Text("Ask me anything about Burning Man, or try a quick action below.") - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.top, 20) - - LazyVGrid(columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12), - ], spacing: 12) { - ForEach(QuickAction.quickStartActions) { action in - QuickStartCard(action: action, themeColors: themeColors) { - viewModel.executeQuickAction(action) - } - } - } - .padding(.top, 8) - } - } - - // MARK: - Navigation - - private func navigateToDetail(uid: String) { - guard let resolved = viewModel.resolvedObjects[uid] else { return } - let playaDB = viewModel.playaDB - let detailVC: UIViewController - switch resolved { - case .art(let art): - detailVC = DetailViewControllerFactory.create(with: art, playaDB: playaDB) - case .camp(let camp): - detailVC = DetailViewControllerFactory.create(with: camp, playaDB: playaDB) - case .event(let event): - detailVC = DetailViewControllerFactory.create(with: event, playaDB: playaDB) - case .mutantVehicle(let mv): - detailVC = DetailViewControllerFactory.create(with: mv, playaDB: playaDB) - } - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let navController = window.rootViewController?.findNavigationController() else { - return - } - navController.pushViewController(detailVC, animated: true) - } -} - -// MARK: - Quick Start Card - -@available(iOS 26, *) -struct QuickStartCard: View { - let action: QuickAction - let themeColors: ImageColors - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(spacing: 6) { - Image(systemName: action.icon) - .font(.system(size: 22)) - .foregroundColor(themeColors.detailColor) - Text(action.label) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(themeColors.primaryColor) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(themeColors.detailColor.opacity(0.1)) - ) - } - .buttonStyle(.plain) - } -} - -#endif diff --git a/iBurn/AISearch/ChatViewModel.swift b/iBurn/AISearch/ChatViewModel.swift deleted file mode 100644 index 57ceef62..00000000 --- a/iBurn/AISearch/ChatViewModel.swift +++ /dev/null @@ -1,392 +0,0 @@ -// -// ChatViewModel.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -/// Central coordinator for the AI chat experience. -/// Receives user input, classifies intent, routes to workflows, -/// and streams progress + results to the UI. -@available(iOS 26, *) -@MainActor -final class ChatViewModel: ObservableObject { - - // MARK: - Published State - - @Published var messages: [ChatMessage] = [] - @Published var inputText: String = "" - @Published var isProcessing: Bool = false - @Published var resolvedObjects: [String: AIAssistantViewModel.ResolvedObject] = [:] - @Published var favoriteIDs: Set = [] - - // MARK: - Dependencies - - let playaDB: PlayaDB - private let orchestrator: AgentOrchestrator - private let conversationManager = ConversationManager() - private var currentTask: Task? - - init(playaDB: PlayaDB, orchestrator: AgentOrchestrator) { - self.playaDB = playaDB - self.orchestrator = orchestrator - } - - // MARK: - Send Message - - func send() { - let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - inputText = "" - - // Add user message - messages.append(ChatMessage(role: .user, content: .text(text))) - - currentTask?.cancel() - isProcessing = true - - currentTask = Task { [weak self] in - guard let self else { return } - do { - // Classify intent - let intent = try await IntentClassifier.classify(text) - guard !Task.isCancelled else { return } - - // Route to workflow - try await self.routeIntent(intent, originalMessage: text) - } catch is CancellationError { - // Ignored - } catch { - guard !Task.isCancelled else { return } - self.replaceLoadingWith(.error("Something went wrong. Try again.")) - print("Chat error: \(error)") - } - self.isProcessing = false - } - } - - /// Execute a quick action directly (bypasses intent classification) - func executeQuickAction(_ action: QuickAction) { - inputText = action.prompt - send() - } - - // MARK: - Intent Routing - - private func routeIntent(_ intent: ChatIntent, originalMessage: String) async throws { - switch intent { - case .search(let query): - try await handleSearch(query.isEmpty ? originalMessage : query) - - case .recommend: - try await handleRecommendations() - - case .dayPlan: - try await handleDayPlan() - - case .nearby: - try await handleNearby() - - case .adventure(let theme): - try await handleAdventure(theme: theme ?? originalMessage) - - case .scheduleOptimize: - try await handleScheduleOptimize() - - case .serendipity: - try await handleSerendipity() - - case .campCrawl(let theme): - try await handleCampCrawl(theme: theme ?? originalMessage) - - case .whatDidIMiss: - try await handleWhatDidIMiss() - - case .goldenHour: - try await handleGoldenHour() - - case .general(let query): - try await handleGeneral(query.isEmpty ? originalMessage : query) - } - } - - // MARK: - Workflow Handlers - - private func handleSearch(_ query: String) async throws { - addLoadingMessage("Searching the playa...") - let workflow = GeneralChatWorkflow(query: query, mode: .search) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - conversationManager.recordDiscussedUIDs(result.items.map(\.uid)) - replaceLoadingWith(.objectCards(result.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - })) - if !result.intro.isEmpty { - messages.insert(ChatMessage(role: .assistant, content: .text(result.intro)), at: messages.count - 1) - } - addFollowUpActions(["Search more", "Tell me more about these", "Something different"]) - } - - private func handleRecommendations() async throws { - addLoadingMessage("Analyzing your taste...") - let workflow = SerendipityWorkflow(deliberateRandom: false) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - conversationManager.recordDiscussedUIDs(result.items.map(\.uid)) - replaceLoadingWith(.text(result.intro)) - messages.append(ChatMessage(role: .assistant, content: .objectCards(result.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - }))) - addFollowUpActions(["More like these", "Something completely different", "Surprise me"]) - } - - private func handleDayPlan() async throws { - addLoadingMessage("Planning your day...") - let workflow = DayPlanWorkflow() - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - replaceLoadingWith(.schedule(ScheduleResult( - entries: result.items.map { ScheduleResultEntry(uid: $0.uid, name: $0.name, startTime: $0.startTime, endTime: $0.endTime, reason: $0.reason, walkMinutesFromPrevious: $0.walkMinutesFromPrevious) }, - summary: result.summary, - conflictsResolved: 0 - ))) - addFollowUpActions(["Optimize my schedule", "Add more events", "Plan tomorrow"]) - } - - private func handleNearby() async throws { - guard orchestrator.locationProvider.currentLocation != nil else { - replaceLoadingWith(.error("Location not available. Enable location services.")) - return - } - addLoadingMessage("Looking around you...") - let workflow = GeneralChatWorkflow(query: "What's interesting near me right now?", mode: .nearby) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - replaceLoadingWith(.objectCards(result.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - })) - addFollowUpActions(["What else is nearby?", "Plan a route", "Events starting soon"]) - } - - private func handleAdventure(theme: String) async throws { - addLoadingMessage("Crafting your adventure...") - let workflow = AdventureWorkflow(theme: theme) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.stops.map(\.id)) - replaceLoadingWith(.adventure(AdventureResult( - narrative: result.narrative, - stops: result.stops.map { stop in - AdventureStop(uid: stop.id, name: stop.name, type: stop.type, tip: stop.reason, walkMinutesFromPrevious: stop.walkMinutesFromPrevious) - }, - totalWalkMinutes: result.totalWalkMinutes - ))) - addFollowUpActions(["Different theme", "Add more stops", "Show on map"]) - } - - private func handleScheduleOptimize() async throws { - addLoadingMessage("Analyzing your favorites for conflicts...") - let workflow = ScheduleOptimizerWorkflow() - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - replaceLoadingWith(.schedule(ScheduleResult( - entries: result.items.map { ScheduleResultEntry(uid: $0.uid, name: $0.name, startTime: $0.startTime, endTime: $0.endTime, reason: $0.reason, walkMinutesFromPrevious: $0.walkMinutesFromPrevious) }, - summary: result.summary, - conflictsResolved: result.conflictsResolved - ))) - addFollowUpActions(["Find alternatives", "Add more events", "Plan full day"]) - } - - private func handleSerendipity() async throws { - addLoadingMessage("Rolling the dice...") - let workflow = SerendipityWorkflow(deliberateRandom: true) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - replaceLoadingWith(.text(result.intro)) - messages.append(ChatMessage(role: .assistant, content: .objectCards(result.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - }))) - addFollowUpActions(["Surprise me again", "More like the first one", "Tell me more"]) - } - - private func handleCampCrawl(theme: String) async throws { - addLoadingMessage("Planning your camp crawl...") - let workflow = CampCrawlWorkflow(theme: theme) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.stops.map(\.id)) - replaceLoadingWith(.adventure(AdventureResult( - narrative: result.narrative, - stops: result.stops.map { stop in - AdventureStop(uid: stop.id, name: stop.name, type: stop.type, tip: stop.reason, walkMinutesFromPrevious: stop.walkMinutesFromPrevious) - }, - totalWalkMinutes: result.totalWalkMinutes - ))) - addFollowUpActions(["Different theme", "Add events along the way", "Coffee trail"]) - } - - private func handleWhatDidIMiss() async throws { - addLoadingMessage("Checking your tracks...") - let workflow = WhatDidIMissWorkflow() - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.items.map(\.uid)) - replaceLoadingWith(.text(result.intro)) - messages.append(ChatMessage(role: .assistant, content: .objectCards(result.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - }))) - addFollowUpActions(["Check last 48 hours", "Plan a route to these", "What's nearby now?"]) - } - - private func handleGoldenHour() async throws { - addLoadingMessage("Finding golden hour art...") - let workflow = GoldenHourWorkflow() - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - await resolveUIDs(result.stops.map(\.id)) - replaceLoadingWith(.adventure(AdventureResult( - narrative: result.narrative, - stops: result.stops.map { stop in - AdventureStop(uid: stop.id, name: stop.name, type: stop.type, tip: stop.reason, walkMinutesFromPrevious: stop.walkMinutesFromPrevious) - }, - totalWalkMinutes: result.totalWalkMinutes - ))) - addFollowUpActions(["Sunrise instead", "Deep playa only", "Add music events"]) - } - - private func handleGeneral(_ query: String) async throws { - addLoadingMessage("Thinking...") - let workflow = GeneralChatWorkflow(query: query, mode: .general) - let result = try await orchestrator.execute(workflow, conversationHistory: conversationManager.conversationSummary) { [weak self] progress in - Task { @MainActor in self?.handleProgress(progress) } - } - conversationManager.updateTopic(query) - if result.items.isEmpty { - replaceLoadingWith(.text(result.intro)) - } else { - await resolveUIDs(result.items.map(\.uid)) - conversationManager.recordDiscussedUIDs(result.items.map(\.uid)) - replaceLoadingWith(.text(result.intro)) - messages.append(ChatMessage(role: .assistant, content: .objectCards(result.items.map { item in - ObjectCard(uid: item.uid, name: item.name, type: item.type, reason: item.pitch, distance: nil, timeInfo: nil) - }))) - } - } - - // MARK: - Helpers - - private func addLoadingMessage(_ text: String) { - messages.append(ChatMessage(role: .assistant, content: .loading(text))) - } - - private func replaceLoadingWith(_ content: ChatMessage.MessageContent) { - // Replace the last loading message - if let idx = messages.lastIndex(where: { - if case .loading = $0.content { return true } - return false - }) { - messages[idx] = ChatMessage(role: .assistant, content: content) - } else { - messages.append(ChatMessage(role: .assistant, content: content)) - } - } - - private func handleProgress(_ progress: WorkflowProgress) { - switch progress { - case .stepStarted(_, let description): - // Update the loading message - if let idx = messages.lastIndex(where: { - if case .loading = $0.content { return true } - return false - }) { - messages[idx] = ChatMessage(role: .assistant, content: .loading(description)) - } - case .stepCompleted: - break - case .intermediateResult(let text): - messages.append(ChatMessage(role: .assistant, content: .text(text))) - } - } - - private func addFollowUpActions(_ labels: [String]) { - let actions = labels.map { QuickAction(label: $0, prompt: $0, icon: "arrow.right.circle") } - messages.append(ChatMessage(role: .assistant, content: .quickActions(actions))) - } - - // MARK: - Object Resolution (shared with AIAssistantViewModel) - - func resolveUIDs(_ uids: [String]) async { - let unresolvedUIDs = uids.filter { resolvedObjects[$0] == nil } - guard !unresolvedUIDs.isEmpty else { return } - - guard let objects = try? await playaDB.fetchObjects(byUIDs: unresolvedUIDs) else { return } - for obj in objects { - if let art = obj as? ArtObject { - resolvedObjects[art.uid] = .art(art) - } else if let camp = obj as? CampObject { - resolvedObjects[camp.uid] = .camp(camp) - } else if let event = obj as? EventObject { - resolvedObjects[event.uid] = .event(event) - } else if let mv = obj as? MutantVehicleObject { - resolvedObjects[mv.uid] = .mutantVehicle(mv) - } - } - - let favorites = (try? await playaDB.getFavorites()) ?? [] - let favUIDs = Set(favorites.map(\.uid)) - for uid in unresolvedUIDs where favUIDs.contains(uid) { - favoriteIDs.insert(uid) - } - } - - func toggleFavorite(_ uid: String) async { - guard let resolved = resolvedObjects[uid] else { return } - do { - switch resolved { - case .art(let art): - try await playaDB.toggleFavorite(art) - let isFav = try await playaDB.isFavorite(art) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .camp(let camp): - try await playaDB.toggleFavorite(camp) - let isFav = try await playaDB.isFavorite(camp) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .event(let event): - try await playaDB.toggleFavorite(event) - let isFav = try await playaDB.isFavorite(event) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - case .mutantVehicle(let mv): - try await playaDB.toggleFavorite(mv) - let isFav = try await playaDB.isFavorite(mv) - if isFav { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } - } - } catch { - print("Error toggling favorite: \(error)") - } - } -} - -#endif diff --git a/iBurn/AISearch/ConversationManager.swift b/iBurn/AISearch/ConversationManager.swift deleted file mode 100644 index fcd928d4..00000000 --- a/iBurn/AISearch/ConversationManager.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// ConversationManager.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import FoundationModels - -/// Manages conversation state across multiple turns. -/// Recycles the LanguageModelSession after a configurable number of turns -/// to prevent context window overflow, preserving a summary of the conversation. -@available(iOS 26, *) -final class ConversationManager: @unchecked Sendable { - private let maxTurns = 5 - private var turnCount = 0 - private var discussedUIDs: [String] = [] - private var userPreferences: [String] = [] - private var topicSummary: String = "" - - /// Record that we discussed certain objects - func recordDiscussedUIDs(_ uids: [String]) { - // Keep last 5 UIDs - discussedUIDs.append(contentsOf: uids) - if discussedUIDs.count > 5 { - discussedUIDs = Array(discussedUIDs.suffix(5)) - } - } - - /// Record a user preference stated in conversation - func recordPreference(_ preference: String) { - userPreferences.append(preference) - if userPreferences.count > 3 { - userPreferences = Array(userPreferences.suffix(3)) - } - } - - /// Update topic summary - func updateTopic(_ topic: String) { - topicSummary = topic - } - - /// Increment turn counter and check if session should be recycled - func incrementTurn() -> Bool { - turnCount += 1 - return turnCount >= maxTurns - } - - /// Reset the turn counter (call after recycling session) - func recycle() { - turnCount = 0 - } - - /// Get conversation context summary for a new session - var conversationSummary: [String] { - var parts: [String] = [] - if !topicSummary.isEmpty { - parts.append("Topic: \(topicSummary)") - } - if !discussedUIDs.isEmpty { - parts.append("Recently discussed UIDs: \(discussedUIDs.joined(separator: ", "))") - } - if !userPreferences.isEmpty { - parts.append("User preferences: \(userPreferences.joined(separator: "; "))") - } - return parts - } - - /// Whether we have conversation context from previous turns - var hasContext: Bool { - !discussedUIDs.isEmpty || !userPreferences.isEmpty || !topicSummary.isEmpty - } -} - -#endif diff --git a/iBurn/AISearch/EventSummaryCache.swift b/iBurn/AISearch/EventSummaryCache.swift new file mode 100644 index 00000000..126195cd --- /dev/null +++ b/iBurn/AISearch/EventSummaryCache.swift @@ -0,0 +1,24 @@ +// +// EventSummaryCache.swift +// iBurn +// +// In-memory cache for AI-generated event summaries, keyed by host UID. +// + +import Foundation + +/// Actor-based RAM cache for AI event summaries. +/// Thread-safe and matches the async/await calling pattern used by the workflow pipeline. +actor EventSummaryCache { + static let shared = EventSummaryCache() + + private var cache: [String: EventSummaryContent] = [:] + + func get(_ hostUID: String) -> EventSummaryContent? { + cache[hostUID] + } + + func set(_ hostUID: String, content: EventSummaryContent) { + cache[hostUID] = content + } +} diff --git a/iBurn/AISearch/IntentClassifier.swift b/iBurn/AISearch/IntentClassifier.swift deleted file mode 100644 index 01985d5e..00000000 --- a/iBurn/AISearch/IntentClassifier.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// IntentClassifier.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import FoundationModels - -// MARK: - Intent Types - -@available(iOS 26, *) -enum ChatIntent: Sendable { - case search(query: String) - case recommend - case dayPlan - case nearby - case adventure(theme: String?) - case scheduleOptimize - case serendipity - case campCrawl(theme: String?) - case whatDidIMiss - case goldenHour - case general(query: String) -} - -// MARK: - Generable Intent - -@available(iOS 26, *) -@Generable -struct ClassifiedIntent { - @Guide(description: "One of: search, recommend, dayPlan, nearby, adventure, scheduleOptimize, serendipity, campCrawl, whatDidIMiss, goldenHour, general") - var intent: String - @Guide(description: "Extracted theme, query, or search terms if applicable") - var parameter: String? -} - -// MARK: - Intent Classifier - -@available(iOS 26, *) -struct IntentClassifier { - - private static let instructions = """ - You classify user messages for a Burning Man festival guide app. \ - Determine the user's intent from: \ - search (looking for specific things), \ - recommend (want personalized suggestions), \ - dayPlan (want a day schedule), \ - nearby (what's around me), \ - adventure (want a themed playa tour/adventure), \ - scheduleOptimize (fix schedule conflicts for favorited events), \ - serendipity (surprise me / random discovery), \ - campCrawl (camp-hopping route, e.g. coffee trail, music camps), \ - whatDidIMiss (things I walked past but didn't visit), \ - goldenHour (sunrise/sunset art viewing), \ - general (other questions). \ - Extract the theme or query if present. - """ - - static func classify(_ message: String) async throws -> ChatIntent { - let session = LanguageModelSession(instructions: Self.instructions) - let response = try await session.respond( - to: Prompt(message), - generating: ClassifiedIntent.self - ) - return mapIntent(response.content) - } - - private static func mapIntent(_ classified: ClassifiedIntent) -> ChatIntent { - let param = classified.parameter?.nilIfEmpty - switch classified.intent.lowercased() { - case "search": - return .search(query: param ?? "") - case "recommend": - return .recommend - case "dayplan": - return .dayPlan - case "nearby": - return .nearby - case "adventure": - return .adventure(theme: param) - case "scheduleoptimize": - return .scheduleOptimize - case "serendipity": - return .serendipity - case "campcrawl": - return .campCrawl(theme: param) - case "whatdidimiss": - return .whatDidIMiss - case "goldenhour": - return .goldenHour - default: - return .general(query: param ?? classified.intent) - } - } -} - -private extension String { - var nilIfEmpty: String? { - trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self - } -} - -#endif diff --git a/iBurn/AISearch/PlayaProgressMessages.swift b/iBurn/AISearch/PlayaProgressMessages.swift deleted file mode 100644 index c53527d6..00000000 --- a/iBurn/AISearch/PlayaProgressMessages.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// PlayaProgressMessages.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -import Foundation - -/// Whimsical, playa-themed progress messages for AI workflow steps. -/// Each step type has a pool of messages; one is picked at random. -enum PlayaProgressMessages { - - // MARK: - Understanding Taste - - static let tasteProfiling = [ - "Reading your vibe from favorites...", - "Channeling your inner burner...", - "Consulting the playa oracle about your taste...", - "Analyzing your art appreciation wavelength...", - "Decoding your festival frequency...", - ] - - // MARK: - Searching / Exploring - - static let searching = [ - "Sending scouts across the playa...", - "Asking the dust bunnies for tips...", - "Decoding the What Where When guide...", - "Scanning the horizon for hidden gems...", - "Following the sound of distant bass...", - ] - - static let searchingArt = [ - "Wandering through the art fields...", - "Squinting at the deep playa mirages...", - "Following the glow of neon in the dust...", - "Asking the Man which way to go...", - ] - - static let searchingCamps = [ - "Peeking behind theme camp curtains...", - "Following the smell of fresh pancakes...", - "Checking which camps have their flags up...", - "Knocking on geodesic domes...", - ] - - static let searchingEvents = [ - "Flipping through the event guide by flashlight...", - "Checking who's throwing a party tonight...", - "Asking a stranger on a megaphone...", - "Tuning into the playa grapevine...", - ] - - // MARK: - Curating / Selecting - - static let curating = [ - "Separating the sparkle from the dust...", - "Curating your personal playa gallery...", - "Picking the juiciest experiences...", - "Applying radical inclusion to your options...", - "Gifting you the best of the playa...", - ] - - // MARK: - Route Planning - - static let routing = [ - "Calculating dust-to-dust walking times...", - "Plotting a course through the grid...", - "Optimizing your bike route past porta-potties...", - "Factoring in deep playa sand resistance...", - "Charting a course by the stars (and street signs)...", - ] - - // MARK: - Schedule Optimization - - static let conflictDetection = [ - "Looking for schedule pile-ups...", - "Checking for space-time conflicts...", - "Making sure you can't be in two places at once...", - "Untangling your overlapping desires...", - ] - - static let conflictResolution = [ - "Making the tough calls so you don't have to...", - "Applying playa wisdom to scheduling conflicts...", - "Choosing between two equally awesome things...", - "Consulting the Temple of Hard Decisions...", - ] - - // MARK: - Narrative / Writing - - static let writing = [ - "Crafting your playa story...", - "Channeling the spirit of the burn...", - "Writing with dust-stained fingers...", - "Composing your desert symphony...", - "Weaving tales of radical self-reliance...", - ] - - // MARK: - Serendipity - - static let serendipity = [ - "Rolling the cosmic dice...", - "Embracing radical spontaneity...", - "Shaking the magic 8-ball of the playa...", - "Consulting the chaos butterfly...", - "Letting the dust decide your fate...", - ] - - static let creativeConnections = [ - "Finding the invisible threads between things...", - "Making connections only the playa could...", - "Discovering why the universe put these together...", - "Unearthing the hidden harmony...", - ] - - // MARK: - Location History - - static let analyzingTracks = [ - "Retracing your dusty footsteps...", - "Following your breadcrumb trail through the desert...", - "Reading the story your feet wrote in the playa...", - "Analyzing your wander pattern...", - ] - - static let findingMissed = [ - "Spotting the treasures you walked right past...", - "Discovering what was hiding in plain sight...", - "Finding the gems you didn't know you missed...", - "Checking what was just around the corner...", - ] - - // MARK: - Golden Hour - - static let goldenHour = [ - "Calculating the angle of desert magic...", - "Finding art that glows at golden hour...", - "Scouting silhouettes against the sunset...", - "Mapping where the light hits just right...", - ] - - // MARK: - Camp Crawl - - static let campEvents = [ - "Checking what's on the menu at each camp...", - "Peeking at camp event boards...", - "Asking camp leads what's popping...", - "Scouting the vibes at each stop...", - ] - - // MARK: - General - - static let thinking = [ - "Putting on the thinking goggles...", - "Consulting the desert wisdom database...", - "Processing through the dust filter...", - "Meditating on your question at the Temple...", - ] - - static let finishing = [ - "Dusting off the final results...", - "Putting a bow on your playa package...", - "Ready to blow your mind...", - "Polishing the crystal ball...", - ] - - // MARK: - Helper - - static func random(from pool: [String]) -> String { - pool.randomElement() ?? "Working on it..." - } -} diff --git a/iBurn/AISearch/RightNowView.swift b/iBurn/AISearch/RightNowView.swift new file mode 100644 index 00000000..6bd7511f --- /dev/null +++ b/iBurn/AISearch/RightNowView.swift @@ -0,0 +1,391 @@ +// +// RightNowView.swift +// iBurn +// +// The single AI Guide screen: ask what you're in the mood for (free text or a +// suggestion chip), pick a time-of-day and place, and get "Now near you" + "Next." +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +#if canImport(FoundationModels) +import SwiftUI +import PlayaDB +import UIKit + +@available(iOS 26, *) +struct RightNowView: View { + @ObservedObject var viewModel: RightNowViewModel + let onNavigateToDetail: (UIViewController) -> Void + @Environment(\.themeColors) var themeColors + @FocusState private var isTextFieldFocused: Bool + @State private var showAreaPicker = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + askField + chipRow + filterBar + goButton + + if viewModel.isRunning, !viewModel.steps.isEmpty { + progressSection + } + if case .failed(let message) = viewModel.executionState { + errorView(message) + } + if let result = viewModel.result { + resultSection(result) + } + } + .padding() + } + .onTapGesture { isTextFieldFocused = false } + .scrollDismissesKeyboard(.interactively) + .sheet(isPresented: $showAreaPicker) { + AreaPickerView( + onUseArea: { region in + viewModel.place = .area(region) + showAreaPicker = false + }, + onCancel: { showAreaPicker = false } + ) + } + } + + // MARK: - Ask Field + + private var askField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("What are you in the mood for?") + .font(.headline) + .foregroundColor(themeColors.primaryColor) + TextField("coffee, fire art, live music…", text: $viewModel.queryText) + .textFieldStyle(.roundedBorder) + .focused($isTextFieldFocused) + .submitLabel(.search) + .onSubmit { + isTextFieldFocused = false + viewModel.go() + } + } + } + + // MARK: - Suggestion Chips + + private var chipRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(SuggestionChip.all) { chip in + Button { + isTextFieldFocused = false + viewModel.selectChip(chip) + } label: { + Label(chip.label, systemImage: chip.icon) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule().fill( + viewModel.selectedChipID == chip.id + ? themeColors.detailColor + : Color(.systemGray5) + ) + ) + .foregroundColor( + viewModel.selectedChipID == chip.id ? .white : themeColors.primaryColor + ) + } + .buttonStyle(.plain) + .disabled(viewModel.isRunning) + } + } + .padding(.vertical, 2) + } + } + + // MARK: - Filter Bar (time-of-day + place) + + private var filterBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + dayMenu + timeMenu + placeMenu + } + .padding(.vertical, 2) + } + } + + private var dayMenu: some View { + Menu { + ForEach(YearSettings.festivalDays, id: \.self) { day in + Button { + viewModel.selectedDay = day + } label: { + Label(dayMenuLabel(day), systemImage: isToday(day) ? "calendar.circle.fill" : "calendar") + } + } + } label: { + filterChip(icon: "calendar", text: dayChipLabel(viewModel.selectedDay)) + } + } + + private var timeMenu: some View { + Menu { + ForEach(TimeOfDay.allCases) { option in + Button { + viewModel.timeOfDay = option + } label: { + Label(option.label, systemImage: option.icon) + } + } + } label: { + filterChip(icon: viewModel.timeOfDay.icon, text: viewModel.timeOfDay.label) + } + } + + private var placeMenu: some View { + Menu { + Button { + viewModel.clearArea() + } label: { + Label("Near me", systemImage: "location.fill") + } + Button { + showAreaPicker = true + } label: { + Label("Pick area on map…", systemImage: "map") + } + } label: { + filterChip(icon: placeIcon, text: placeLabel) + } + } + + // MARK: - Day labels + + private func isToday(_ day: Date) -> Bool { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .burningManTimeZone + return calendar.isDate(day, inSameDayAs: Date.present) + } + + private func dayChipLabel(_ day: Date) -> String { + if isToday(day) { return "Today" } + let formatter = DateFormatter() + formatter.timeZone = .burningManTimeZone + formatter.dateFormat = "EEE" + return formatter.string(from: day) + } + + private func dayMenuLabel(_ day: Date) -> String { + let formatter = DateFormatter() + formatter.timeZone = .burningManTimeZone + formatter.dateFormat = "EEE MMM d" + let base = formatter.string(from: day) + return isToday(day) ? "\(base) · Today" : base + } + + private var placeIcon: String { + if case .area = viewModel.place { return "map.fill" } + return "location.fill" + } + + private var placeLabel: String { + if case .area = viewModel.place { return "Selected area" } + return "Near me" + } + + private func filterChip(icon: String, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + Text(text) + Image(systemName: "chevron.down").font(.caption2) + } + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule().fill(Color(.systemGray6))) + .foregroundColor(themeColors.primaryColor) + } + + // MARK: - Go Button + + private var goButton: some View { + Button { + isTextFieldFocused = false + viewModel.go() + } label: { + HStack { + if viewModel.isRunning { + ProgressView().tint(.white).padding(.trailing, 4) + Text("Looking…") + } else { + Image(systemName: "sparkles") + Text("Show me") + } + } + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(viewModel.isRunning ? Color.gray : themeColors.detailColor) + ) + } + .disabled(viewModel.isRunning) + } + + // MARK: - Progress + + private var progressSection: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(viewModel.steps) { step in + HStack(spacing: 10) { + switch step.state { + case .running: ProgressView().scaleEffect(0.6).frame(width: 16) + case .completed: Image(systemName: "checkmark.circle.fill").foregroundColor(.green).frame(width: 16) + case .failed: Image(systemName: "xmark.circle.fill").foregroundColor(.red).frame(width: 16) + case .pending: Circle().stroke(Color.gray.opacity(0.3), lineWidth: 1.5).frame(width: 12, height: 12) + } + Text(step.message) + .font(.caption) + .foregroundColor(themeColors.secondaryColor) + Spacer() + } + } + } + .padding() + .background(RoundedRectangle(cornerRadius: 12).fill(Color(.systemGray6))) + } + + // MARK: - Error + + private func errorView(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle").foregroundColor(.orange) + Text(message).font(.subheadline).foregroundColor(themeColors.secondaryColor) + } + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(Color.orange.opacity(0.1))) + } + + // MARK: - Results + + @ViewBuilder + private func resultSection(_ result: RightNowResult) -> some View { + if result.isEmpty { + VStack(spacing: 12) { + Image(systemName: "wind").font(.system(size: 36)).foregroundColor(.gray) + Text(result.intro) + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else { + VStack(alignment: .leading, spacing: 16) { + if !result.intro.isEmpty { + Text(result.intro) + .font(.subheadline) + .italic() + .foregroundColor(themeColors.primaryColor) + } + if !result.now.isEmpty { + sectionHeader("NOW NEAR YOU") + ForEach(result.now) { itemRow($0) } + } + if !result.next.isEmpty { + sectionHeader("WHAT TO DO NEXT") + ForEach(result.next) { itemRow($0) } + } + } + } + } + + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(themeColors.secondaryColor) + .textCase(.uppercase) + } + + @ViewBuilder + private func itemRow(_ item: RightNowItem) -> some View { + Button { + navigateToDetail(uid: item.uid) + } label: { + VStack(alignment: .leading, spacing: 4) { + if let resolved = viewModel.resolvedObjects[item.uid] { + nativeRow(uid: item.uid, resolved: resolved) + } else { + Text(item.name).font(.body).foregroundColor(themeColors.primaryColor) + } + if !item.pitch.isEmpty { + HStack(spacing: 4) { + Image(systemName: "sparkles").font(.caption2).foregroundStyle(.purple) + Text(item.pitch) + .font(.caption) + .foregroundColor(themeColors.secondaryColor) + .lineLimit(2) + } + .padding(.leading, 4) + } + if let meta = metaLine(item) { + Text(meta).font(.caption2).foregroundColor(.gray).padding(.leading, 4) + } + } + } + .buttonStyle(.plain) + .id(item.uid) + } + + private func metaLine(_ item: RightNowItem) -> String? { + var parts: [String] = [] + if let time = item.timeInfo { parts.append(time) } + if let walk = item.walkMinutes, walk > 0 { parts.append("~\(walk) min walk") } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + @ViewBuilder + private func nativeRow(uid: String, resolved: AIResolvedObject) -> some View { + let isFavorite = viewModel.favoriteIDs.contains(uid) + let onFavoriteTap: () -> Void = { Task { await viewModel.toggleFavorite(uid) } } + switch resolved { + case .art(let art): + ObjectRowView(object: art, subtitle: nil, rightSubtitle: art.artist, + isFavorite: isFavorite, onFavoriteTap: onFavoriteTap) { _ in EmptyView() } + case .camp(let camp): + ObjectRowView(object: camp, subtitle: nil, rightSubtitle: camp.hometown, + isFavorite: isFavorite, onFavoriteTap: onFavoriteTap) { _ in EmptyView() } + case .event(let event): + ObjectRowView(object: event, subtitle: nil, rightSubtitle: event.eventTypeLabel, + isFavorite: isFavorite, onFavoriteTap: onFavoriteTap) { _ in EmptyView() } + case .mutantVehicle(let mv): + ObjectRowView(object: mv, subtitle: nil, rightSubtitle: mv.artist, + isFavorite: isFavorite, onFavoriteTap: onFavoriteTap) { _ in EmptyView() } + } + } + + // MARK: - Navigation + + private func navigateToDetail(uid: String) { + guard let resolved = viewModel.resolvedObjects[uid] else { return } + let playaDB = viewModel.playaDB + let detailVC: UIViewController + switch resolved { + case .art(let art): detailVC = DetailViewControllerFactory.create(with: art, playaDB: playaDB) + case .camp(let camp): detailVC = DetailViewControllerFactory.create(with: camp, playaDB: playaDB) + case .event(let event): detailVC = DetailViewControllerFactory.create(with: event, playaDB: playaDB) + case .mutantVehicle(let mv): detailVC = DetailViewControllerFactory.create(with: mv, playaDB: playaDB) + } + onNavigateToDetail(detailVC) + } +} + +#endif diff --git a/iBurn/AISearch/RightNowViewModel.swift b/iBurn/AISearch/RightNowViewModel.swift new file mode 100644 index 00000000..a6db7cab --- /dev/null +++ b/iBurn/AISearch/RightNowViewModel.swift @@ -0,0 +1,262 @@ +// +// RightNowViewModel.swift +// iBurn +// +// View model for the single AI Guide screen: "what's near you happening now, +// and what to do next." +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +#if canImport(FoundationModels) +import Foundation +import CoreLocation +import MapKit +import FoundationModels +@preconcurrency import PlayaDB + +@available(iOS 26, *) +@MainActor +final class RightNowViewModel: ObservableObject { + + /// Where to look: the user's current location, or a map-selected area. + enum PlaceScope: Equatable { + case nearMe + case area(MKCoordinateRegion) + + static func == (lhs: PlaceScope, rhs: PlaceScope) -> Bool { + switch (lhs, rhs) { + case (.nearMe, .nearMe): return true + case let (.area(a), .area(b)): + return a.center.latitude == b.center.latitude + && a.center.longitude == b.center.longitude + && a.span.latitudeDelta == b.span.latitudeDelta + && a.span.longitudeDelta == b.span.longitudeDelta + default: return false + } + } + } + + // MARK: - Inputs + @Published var queryText: String = "" + @Published var selectedChipID: String? + @Published var timeOfDay: TimeOfDay = .now + @Published var selectedDay: Date = RightNowViewModel.defaultFestivalDay() + @Published var place: PlaceScope = .nearMe + + /// The festival day matching today (in BRC time) if the event is running, otherwise the + /// first festival day. Used as the default for the day-of-week selector. + static func defaultFestivalDay() -> Date { + let now = Date.present + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .burningManTimeZone + if let today = YearSettings.festivalDays.first(where: { calendar.isDate($0, inSameDayAs: now) }) { + return today + } + return YearSettings.festivalDays.first ?? now + } + + // MARK: - Output + @Published var executionState: WorkflowExecutionState = .idle + @Published var steps: [WorkflowStepProgress] = [] + @Published var result: RightNowResult? + @Published var resolvedObjects: [String: AIResolvedObject] = [:] + @Published var favoriteIDs: Set = [] + + // MARK: - Dependencies + let playaDB: PlayaDB + let orchestrator: AgentOrchestrator + private var currentTask: Task? + private static let maxRetries = 2 + + init(playaDB: PlayaDB, orchestrator: AgentOrchestrator) { + self.playaDB = playaDB + self.orchestrator = orchestrator + } + + var isRunning: Bool { + if case .running = executionState { return true } + return false + } + + var hasResult: Bool { result != nil } + + // MARK: - Actions + + /// Tap a suggestion chip: seed the vibe/lean and run immediately. + func selectChip(_ chip: SuggestionChip) { + selectedChipID = chip.id + queryText = chip.vibe + run(vibe: chip.vibe, lean: chip.lean) + } + + /// Run from the free-text field. + func go() { + selectedChipID = nil + run(vibe: queryText.trimmingCharacters(in: .whitespacesAndNewlines), lean: .balanced) + } + + func clearArea() { place = .nearMe } + + private func run(vibe: String, lean: DiscoveryLean) { + currentTask?.cancel() + steps = [] + result = nil + executionState = .running + + let region: MKCoordinateRegion? + switch place { + case .nearMe: region = nil + case .area(let r): region = r + } + let window = timeOfDay.dateWindow(on: selectedDay) + + currentTask = Task { [weak self] in + guard let self else { return } + await self.execute(vibe: vibe, lean: lean, region: region, window: window, attempt: 0) + } + } + + private func execute( + vibe: String, + lean: DiscoveryLean, + region: MKCoordinateRegion?, + window: (start: Date, end: Date), + attempt: Int + ) async { + do { + let res = try await orchestrator.execute( + RightNowWorkflow(), + region: region, + window: window, + vibe: vibe, + lean: lean + ) { [weak self] progress in + self?.handleProgress(progress) + } + guard !Task.isCancelled else { return } + await resolveUIDs(res.now.map(\.uid) + res.next.map(\.uid)) + result = res + executionState = .completed + } catch is CancellationError { + // Ignored + } catch { + guard !Task.isCancelled else { return } + if attempt < Self.maxRetries, isRetryable(error) { + markCurrentStepFailed() + steps.removeAll() + addStep(retryMessage(for: error)) + await execute(vibe: vibe, lean: lean, region: region, window: window, attempt: attempt + 1) + } else { + markCurrentStepFailed() + #if DEBUG + executionState = .failed("\(userFacingMessage(for: error))\n\n[DEBUG: \(error)]") + #else + executionState = .failed(userFacingMessage(for: error)) + #endif + } + } + } + + // MARK: - Error Copy (concise, honest, no snark) + + private func isRetryable(_ error: Error) -> Bool { + isGuardrailError(error) || isContextWindowError(error) + } + + private func retryMessage(for error: Error) -> String { + isContextWindowError(error) ? "Narrowing the search…" : "Trying again…" + } + + private func userFacingMessage(for error: Error) -> String { + if isGuardrailError(error) { + return "Couldn't process that — try a different vibe." + } else if isContextWindowError(error) { + return "Too much to sift through — narrow the area or time." + } else if case LanguageModelSession.GenerationError.unsupportedLanguageOrLocale = error { + return "AI features need English language settings on this device." + } else if case LanguageModelSession.GenerationError.rateLimited = error { + return "The AI is busy. Try again in a moment." + } else if case LanguageModelSession.GenerationError.assetsUnavailable = error { + return "AI model unavailable — enable Apple Intelligence in Settings." + } else { + return "Something went wrong. Try again." + } + } + + // MARK: - Progress + + private func handleProgress(_ progress: WorkflowProgress) { + Task { @MainActor [weak self] in + guard let self else { return } + switch progress { + case .stepStarted(_, let description): self.addStep(description) + case .stepCompleted: self.completeCurrentStep() + case .intermediateResult: break + } + } + } + + private func addStep(_ message: String) { + steps.append(WorkflowStepProgress(message: message, state: .running)) + } + + private func completeCurrentStep() { + guard let idx = steps.lastIndex(where: { $0.state == .running }) else { return } + steps[idx].state = .completed + } + + private func markCurrentStepFailed() { + guard let idx = steps.lastIndex(where: { $0.state == .running }) else { return } + steps[idx].state = .failed + } + + // MARK: - Object Resolution + + func resolveUIDs(_ uids: [String]) async { + let unresolved = uids.filter { resolvedObjects[$0] == nil } + guard !unresolved.isEmpty else { return } + guard let objects = try? await playaDB.fetchObjects(byUIDs: unresolved) else { return } + for obj in objects { + if let art = obj as? ArtObject { + resolvedObjects[art.uid] = .art(art) + } else if let camp = obj as? CampObject { + resolvedObjects[camp.uid] = .camp(camp) + } else if let event = obj as? EventObject { + resolvedObjects[event.uid] = .event(event) + } else if let mv = obj as? MutantVehicleObject { + resolvedObjects[mv.uid] = .mutantVehicle(mv) + } + } + let favorites = (try? await playaDB.getFavorites()) ?? [] + let favUIDs = Set(favorites.map(\.uid)) + for uid in unresolved where favUIDs.contains(uid) { + favoriteIDs.insert(uid) + } + } + + func toggleFavorite(_ uid: String) async { + guard let resolved = resolvedObjects[uid] else { return } + do { + switch resolved { + case .art(let art): + try await playaDB.toggleFavorite(art) + if try await playaDB.isFavorite(art) { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } + case .camp(let camp): + try await playaDB.toggleFavorite(camp) + if try await playaDB.isFavorite(camp) { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } + case .event(let event): + try await playaDB.toggleFavorite(event) + if try await playaDB.isFavorite(event) { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } + case .mutantVehicle(let mv): + try await playaDB.toggleFavorite(mv) + if try await playaDB.isFavorite(mv) { favoriteIDs.insert(uid) } else { favoriteIDs.remove(uid) } + } + } catch { + print("Error toggling favorite: \(error)") + } + } +} + +#endif diff --git a/iBurn/AISearch/TimeOfDay.swift b/iBurn/AISearch/TimeOfDay.swift new file mode 100644 index 00000000..9cc390e4 --- /dev/null +++ b/iBurn/AISearch/TimeOfDay.swift @@ -0,0 +1,114 @@ +// +// TimeOfDay.swift +// iBurn +// +// Time-of-day windows for the AI "Right Now" guide. Pure Swift (no FoundationModels) +// so it stays unit-testable on any simulator. +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import Foundation + +/// Whether discovery leans on the user's favorites, deliberate randomness, or a mix. +enum DiscoveryLean: Sendable, Equatable { + case personalized + case surprise + case balanced +} + +/// A near-term time horizon for "what's good right now / what to do next." +/// `.now` is the immediacy default; named periods anchor to the current festival day. +enum TimeOfDay: String, CaseIterable, Identifiable, Sendable { + case now + case sunrise + case morning + case midday + case afternoon + case evening + case night + case lateNight + + var id: String { rawValue } + + /// Short label for the picker. + var label: String { + switch self { + case .now: return "Now" + case .sunrise: return "Sunrise" + case .morning: return "Morning" + case .midday: return "Midday" + case .afternoon: return "Afternoon" + case .evening: return "Evening" + case .night: return "Night" + case .lateNight: return "Late Night" + } + } + + /// SF Symbol for the picker. + var icon: String { + switch self { + case .now: return "clock" + case .sunrise: return "sunrise" + case .morning: return "sun.max" + case .midday: return "sun.max.fill" + case .afternoon: return "sun.haze" + case .evening: return "sunset" + case .night: return "moon.stars" + case .lateNight: return "moon.zzz" + } + } + + /// Hour offsets from the start of the festival day, in BRC local time. + /// `nil` means "relative to the actual current time" (`.now`). + /// `lateNight` extends past 24 to spill into the next day's early morning. + var hourRange: (start: Double, end: Double)? { + switch self { + case .now: return nil + case .sunrise: return (5.5, 7.5) + case .morning: return (7.5, 11) + case .midday: return (11, 14) + case .afternoon: return (14, 17) + case .evening: return (17, 20.5) // includes sunset (~19:30) + case .night: return (20.5, 24) + case .lateNight: return (24, 28) // 00:00–04:00 of the next day + } + } + + /// Resolve this horizon to a concrete `(start, end)` event window on the given festival + /// `day`, in BRC local time, clamped to the festival's date range so off-season/off-playa + /// queries stay sane. `.now` returns the next two hours when `day` is the current day, + /// otherwise the whole selected day. + func dateWindow(on day: Date, now: Date = Date.present) -> (start: Date, end: Date) { + let low = YearSettings.eventStart + let high = YearSettings.eventEnd + func clamp(_ date: Date) -> Date { min(max(date, low), high) } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .burningManTimeZone + let dayStart = calendar.startOfDay(for: day) + + guard let range = hourRange else { + // .now → the next two hours if we're looking at today, else the whole selected day. + if calendar.isDate(day, inSameDayAs: now) { + return (clamp(now), clamp(now.addingTimeInterval(2 * 3600))) + } + return (clamp(dayStart), clamp(dayStart.addingTimeInterval(24 * 3600))) + } + let start = dayStart.addingTimeInterval(range.start * 3600) + let end = dayStart.addingTimeInterval(range.end * 3600) + return (clamp(start), clamp(end)) + } + + /// Convenience anchored to the festival day containing `now`. + func dateWindow(now: Date = Date.present) -> (start: Date, end: Date) { + dateWindow(on: now, now: now) + } + + /// Whether the window contains the current moment (so "happening now" is meaningful). + func containsNow(_ now: Date = Date.present) -> Bool { + let window = dateWindow(now: now) + return now >= window.start && now <= window.end + } +} diff --git a/iBurn/AISearch/Vibe.swift b/iBurn/AISearch/Vibe.swift new file mode 100644 index 00000000..bbb5d4a5 --- /dev/null +++ b/iBurn/AISearch/Vibe.swift @@ -0,0 +1,106 @@ +// +// Vibe.swift +// iBurn +// +// Suggestion chips and free-text → event-type mapping for the AI "Right Now" guide. +// Pure Swift (no FoundationModels) so it stays unit-testable. Event type codes match +// EventTypeInfo (the codes actually stored in PlayaDB for the bundled dataset). +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import Foundation + +/// A tappable suggestion on the Right Now screen. Tapping seeds the query (`vibe`), +/// a discovery `lean`, and an optional event-type constraint. +struct SuggestionChip: Identifiable, Sendable { + let id: String + let label: String + let icon: String + /// Seed text used as the vibe / keyword search. + let vibe: String + let lean: DiscoveryLean + /// Event type codes to constrain event candidates. nil = no constraint. + let eventTypeCodes: Set? + /// True when results shouldn't be region-scoped (e.g. roaming art cars have no fixed GPS). + let nonRegional: Bool + + init( + id: String, + label: String, + icon: String, + vibe: String, + lean: DiscoveryLean = .balanced, + eventTypeCodes: Set? = nil, + nonRegional: Bool = false + ) { + self.id = id + self.label = label + self.icon = icon + self.vibe = vibe + self.lean = lean + self.eventTypeCodes = eventTypeCodes + self.nonRegional = nonRegional + } +} + +extension SuggestionChip { + /// The chips shown across the top of the Right Now screen. + static let all: [SuggestionChip] = [ + SuggestionChip(id: "coffee", label: "Coffee", icon: "cup.and.saucer.fill", + vibe: "coffee", eventTypeCodes: ["tea", "food"]), + SuggestionChip(id: "music", label: "Live music", icon: "music.note", + vibe: "live music", eventTypeCodes: ["live", "prty"]), + SuggestionChip(id: "dance", label: "Dance", icon: "music.quarternote.3", + vibe: "dance party", eventTypeCodes: ["prty", "perf"]), + SuggestionChip(id: "food", label: "Food", icon: "fork.knife", + vibe: "food", eventTypeCodes: ["food", "tea"]), + SuggestionChip(id: "workshops", label: "Workshops", icon: "graduationcap.fill", + vibe: "workshop class", eventTypeCodes: ["work"]), + SuggestionChip(id: "wellness", label: "Yoga & wellness", icon: "figure.mind.and.body", + vibe: "yoga wellness", eventTypeCodes: ["medt", "hlng", "sprt"]), + SuggestionChip(id: "fire", label: "Fire art", icon: "flame.fill", + vibe: "fire", eventTypeCodes: ["fire"]), + SuggestionChip(id: "artcars", label: "Art cars", icon: "car.fill", + vibe: "art car mutant vehicle", eventTypeCodes: nil, nonRegional: true), + SuggestionChip(id: "chill", label: "Quiet & chill", icon: "moon.zzz.fill", + vibe: "quiet chill ambient relax", eventTypeCodes: ["medt", "hlng"]), + SuggestionChip(id: "surprise", label: "Surprise me", icon: "dice.fill", + vibe: "", lean: .surprise), + ] +} + +/// Map free-text vibe to a set of event type codes, or nil when nothing matches +/// (in which case events fall back to full-text search on the vibe). +/// Codes match EventTypeInfo / the values stored in PlayaDB. +func eventTypeCodes(forVibe vibe: String) -> Set? { + let text = vibe.lowercased() + guard !text.isEmpty else { return nil } + + // keyword -> codes + let rules: [(needles: [String], codes: [String])] = [ + (["coffee", "tea", "chai", "espresso", "latte"], ["tea", "food"]), + (["food", "eat", "snack", "dinner", "breakfast", "brunch", "lunch", "grilled"], ["food", "tea"]), + (["live music", "band", "concert", "jazz", "acoustic"], ["live"]), + (["music", "dj", "rave", "party", "dance", "club", "beats", "disco"], ["prty"]), + (["workshop", "class", "learn", "talk", "lecture", "skill"], ["work"]), + (["yoga", "wellness", "meditat", "breathwork", "movement", "fitness", "stretch"], ["medt"]), + (["massage", "spa", "healing", "reiki", "bodywork"], ["hlng"]), + (["self care", "selfcare", "care"], ["sprt"]), + (["fire", "flame", "burn", "spectacle"], ["fire"]), + (["performance", "show", "theater", "theatre", "circus", "cabaret"], ["perf"]), + (["game", "games", "play", "competition", "tournament"], ["game"]), + (["ritual", "ceremony", "ceremon", "sacred", "temple"], ["cere"]), + (["kid", "kids", "family", "child"], ["kid"]), + (["parade", "procession"], ["prde"]), + (["art", "craft", "make", "create"], ["arts"]), + (["chill", "quiet", "relax", "ambient", "lounge", "calm"], ["medt", "hlng"]), + ] + + var codes = Set() + for rule in rules where rule.needles.contains(where: { text.contains($0) }) { + codes.formUnion(rule.codes) + } + return codes.isEmpty ? nil : codes +} diff --git a/iBurn/AISearch/WorkflowCatalog.swift b/iBurn/AISearch/WorkflowCatalog.swift deleted file mode 100644 index 5d07ae78..00000000 --- a/iBurn/AISearch/WorkflowCatalog.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// WorkflowCatalog.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -import Foundation - -/// Describes a workflow available in the AI Guide -struct WorkflowInfo: Identifiable { - let id: WorkflowID - let title: String - let subtitle: String - let icon: String - let section: WorkflowSection -} - -enum WorkflowID: String, CaseIterable { - case forYou - case surpriseMe - case whatDidIMiss - case dayPlanner - case adventure - case campCrawl - case goldenHour - case scheduleOptimizer -} - -enum WorkflowSection: String, CaseIterable { - case discover = "Discover" - case plan = "Plan" - case optimize = "Optimize" -} - -/// The full catalog of available workflows -enum WorkflowCatalog { - static let all: [WorkflowInfo] = [ - // Discover - WorkflowInfo( - id: .forYou, - title: "For You", - subtitle: "Personalized picks based on your favorites", - icon: "sparkles", - section: .discover - ), - WorkflowInfo( - id: .surpriseMe, - title: "Surprise Me", - subtitle: "Roll the dice and discover something unexpected", - icon: "dice", - section: .discover - ), - WorkflowInfo( - id: .whatDidIMiss, - title: "What Did I Miss?", - subtitle: "Things you walked past but didn't stop at", - icon: "eye.slash", - section: .discover - ), - - // Plan - WorkflowInfo( - id: .dayPlanner, - title: "Day Planner", - subtitle: "AI-optimized schedule with walking routes", - icon: "calendar.badge.clock", - section: .plan - ), - WorkflowInfo( - id: .adventure, - title: "Adventure Generator", - subtitle: "Themed playa tours with stops and tips", - icon: "map", - section: .plan - ), - WorkflowInfo( - id: .campCrawl, - title: "Camp Crawl", - subtitle: "Themed camp-hopping with events at each stop", - icon: "figure.walk", - section: .plan - ), - WorkflowInfo( - id: .goldenHour, - title: "Golden Hour Art", - subtitle: "Art that glows at sunrise or sunset", - icon: "sun.horizon", - section: .plan - ), - - // Optimize - WorkflowInfo( - id: .scheduleOptimizer, - title: "Schedule Optimizer", - subtitle: "Resolve conflicts in your favorited events", - icon: "calendar.badge.checkmark", - section: .optimize - ), - ] - - static func workflows(for section: WorkflowSection) -> [WorkflowInfo] { - all.filter { $0.section == section } - } -} diff --git a/iBurn/AISearch/WorkflowDetailView.swift b/iBurn/AISearch/WorkflowDetailView.swift deleted file mode 100644 index 1155bb52..00000000 --- a/iBurn/AISearch/WorkflowDetailView.swift +++ /dev/null @@ -1,547 +0,0 @@ -// -// WorkflowDetailView.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import SwiftUI -import PlayaDB -import UIKit - -@available(iOS 26, *) -struct WorkflowDetailView: View { - let workflowInfo: WorkflowInfo - @ObservedObject var viewModel: AIGuideViewModel - let onNavigateToDetail: (UIViewController) -> Void - @Environment(\.themeColors) var themeColors - - // MARK: - Workflow-specific configuration - @State private var themeText: String = "" - @State private var hoursBack: Double = 24 - @State private var startDate: Date = YearSettings.dayWithinFestival(Date()) - - @FocusState private var isTextFieldFocused: Bool - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - // Header - headerSection - - // Configuration knobs (workflow-specific) - configSection - - // Generate button - generateButton - - // Progress steps (the sausage being made) - if !viewModel.steps.isEmpty { - progressSection - } - - // Error state - if case .failed(let message) = viewModel.executionState { - errorView(message) - } - - // Results - if let result = viewModel.result { - resultSection(result) - } - } - .padding() - } - .onTapGesture { isTextFieldFocused = false } - .scrollDismissesKeyboard(.interactively) - .task { - // Load cached state for this workflow - viewModel.loadWorkflow(workflowInfo.id) - // Auto-start if never run - if !viewModel.hasRun(workflowInfo.id), !needsUserInput { - runWorkflow() - } - } - } - - /// Whether this workflow needs user input before running - private var needsUserInput: Bool { - switch workflowInfo.id { - case .adventure, .campCrawl, .dayPlanner: - return true - default: - return false - } - } - - // MARK: - Header - - private var headerSection: some View { - HStack(spacing: 12) { - Image(systemName: workflowInfo.icon) - .font(.system(size: 28)) - .foregroundColor(themeColors.detailColor) - Text(workflowInfo.subtitle) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - } - - // MARK: - Configuration Knobs - - @ViewBuilder - private var configSection: some View { - switch workflowInfo.id { - case .adventure: - VStack(alignment: .leading, spacing: 8) { - Text("Adventure Theme") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(themeColors.secondaryColor) - TextField("e.g. fire art, interactive, deep playa...", text: $themeText) - .textFieldStyle(.roundedBorder) - .focused($isTextFieldFocused) - .onSubmit { runWorkflow() } - } - - case .campCrawl: - VStack(alignment: .leading, spacing: 8) { - Text("Crawl Theme") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(themeColors.secondaryColor) - TextField("e.g. coffee, music, workshops...", text: $themeText) - .textFieldStyle(.roundedBorder) - .focused($isTextFieldFocused) - .onSubmit { runWorkflow() } - } - - case .dayPlanner: - VStack(alignment: .leading, spacing: 8) { - Text("Start Time") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(themeColors.secondaryColor) - DatePicker( - "Start", - selection: $startDate, - in: YearSettings.eventStart...YearSettings.eventEnd, - displayedComponents: [.date, .hourAndMinute] - ) - .labelsHidden() - .datePickerStyle(.compact) - } - - case .whatDidIMiss: - VStack(alignment: .leading, spacing: 8) { - Text("Look back \(Int(hoursBack)) hours") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(themeColors.secondaryColor) - Slider(value: $hoursBack, in: 6...48, step: 6) { - Text("Hours") - } - HStack { - Text("6h").font(.caption2).foregroundColor(.gray) - Spacer() - Text("48h").font(.caption2).foregroundColor(.gray) - } - } - - default: - EmptyView() - } - } - - // MARK: - Generate Button - - private var generateButton: some View { - Button { - runWorkflow() - } label: { - HStack { - if case .running = viewModel.executionState { - ProgressView() - .tint(.white) - .padding(.trailing, 4) - Text("Working...") - } else { - Image(systemName: "wand.and.stars") - Text(buttonLabel) - } - } - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isRunning ? Color.gray : themeColors.detailColor) - ) - } - .disabled(isRunning) - } - - private var isRunning: Bool { - if case .running = viewModel.executionState { return true } - return false - } - - private var buttonLabel: String { - switch viewModel.executionState { - case .completed: return "Run Again" - case .failed: return "Try Again" - default: return "Generate" - } - } - - // MARK: - Progress Section - - private var progressSection: some View { - VStack(alignment: .leading, spacing: 0) { - Text("What's happening") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(themeColors.secondaryColor) - .padding(.bottom, 8) - - ForEach(viewModel.steps) { step in - WorkflowStepRow(step: step, themeColors: themeColors) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray6)) - ) - } - - // MARK: - Error View - - private func errorView(_ message: String) -> some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.orange) - Text(message) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - .padding() - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.orange.opacity(0.1)) - ) - } - - // MARK: - Results - - @ViewBuilder - private func resultSection(_ content: WorkflowResultContent) -> some View { - switch content { - case .discovery(let intro, let items): - discoveryResultView(intro: intro, items: items) - case .schedule(let schedule): - scheduleResultView(schedule) - case .adventure(let adventure): - adventureResultView(adventure) - case .empty(let message): - emptyResultView(message) - } - } - - // MARK: - Discovery Results (using native cells) - - private func discoveryResultView(intro: String, items: [ObjectCard]) -> some View { - VStack(alignment: .leading, spacing: 8) { - if !intro.isEmpty { - Text(intro) - .font(.subheadline) - .foregroundColor(themeColors.primaryColor) - .italic() - .padding(.bottom, 4) - } - - ForEach(items) { card in - resolvedObjectRow(uid: card.uid, reason: card.reason) - } - } - } - - // MARK: - Schedule Results - - private func scheduleResultView(_ schedule: ScheduleResult) -> some View { - VStack(alignment: .leading, spacing: 12) { - if !schedule.summary.isEmpty { - Text(schedule.summary) - .font(.subheadline) - .foregroundColor(themeColors.primaryColor) - .italic() - } - - if schedule.conflictsResolved > 0 { - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("\(schedule.conflictsResolved) conflict\(schedule.conflictsResolved == 1 ? "" : "s") resolved") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.green) - } - } - - ForEach(schedule.entries) { entry in - VStack(alignment: .leading, spacing: 4) { - if let walk = entry.walkMinutesFromPrevious, walk > 0 { - HStack(spacing: 4) { - Image(systemName: "figure.walk") - .font(.caption2) - Text("~\(walk) min walk") - .font(.caption2) - } - .foregroundColor(.gray) - .padding(.leading, 4) - } - - HStack(alignment: .top, spacing: 8) { - VStack(spacing: 2) { - Text(entry.startTime) - .font(.caption) - .fontWeight(.bold) - .foregroundColor(themeColors.detailColor) - Text(entry.endTime) - .font(.caption2) - .foregroundColor(.gray) - } - .frame(width: 60) - - VStack(alignment: .leading, spacing: 4) { - resolvedObjectRow(uid: entry.uid, reason: entry.reason) - } - } - } - } - } - } - - // MARK: - Adventure Results - - private func adventureResultView(_ adventure: AdventureResult) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text(adventure.narrative) - .font(.subheadline) - .foregroundColor(themeColors.primaryColor) - .italic() - - HStack(spacing: 4) { - Image(systemName: "figure.walk") - Text("Total: ~\(adventure.totalWalkMinutes) min walking") - .font(.caption) - .fontWeight(.medium) - } - .foregroundColor(themeColors.secondaryColor) - - ForEach(Array(adventure.stops.enumerated()), id: \.element.id) { index, stop in - VStack(alignment: .leading, spacing: 4) { - if let walk = stop.walkMinutesFromPrevious, walk > 0 { - HStack(spacing: 4) { - Image(systemName: "figure.walk") - .font(.caption2) - Text("~\(walk) min walk") - .font(.caption2) - } - .foregroundColor(.gray) - .padding(.leading, 4) - } - - HStack(spacing: 8) { - Text("\(index + 1)") - .font(.caption) - .fontWeight(.bold) - .foregroundColor(.white) - .frame(width: 24, height: 24) - .background(Circle().fill(themeColors.detailColor)) - - VStack(alignment: .leading, spacing: 4) { - resolvedObjectRow(uid: stop.uid, reason: stop.tip) - } - } - } - } - } - } - - // MARK: - Empty Result - - private func emptyResultView(_ message: String) -> some View { - VStack(spacing: 12) { - Image(systemName: "wind") - .font(.system(size: 36)) - .foregroundColor(.gray) - Text(message) - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - } - - // MARK: - Resolved Object Row (uses native cells) - - @ViewBuilder - private func resolvedObjectRow(uid: String, reason: String) -> some View { - if let resolved = viewModel.resolvedObjects[uid] { - Button { navigateToDetail(uid: uid) } label: { - VStack(alignment: .leading, spacing: 4) { - nativeRow(uid: uid, resolved: resolved) - if !reason.isEmpty { - HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.caption2) - .foregroundStyle(.purple) - Text(reason) - .font(.caption) - .foregroundColor(themeColors.secondaryColor) - .lineLimit(2) - } - .padding(.leading, 4) - } - } - } - .buttonStyle(.plain) - .id(uid) - } else { - Text(uid) - .font(.caption) - .foregroundColor(.gray) - } - } - - @ViewBuilder - private func nativeRow(uid: String, resolved: AIAssistantViewModel.ResolvedObject) -> some View { - let isFavorite = viewModel.favoriteIDs.contains(uid) - let onFavoriteTap: () -> Void = { Task { await viewModel.toggleFavorite(uid) } } - - switch resolved { - case .art(let art): - ObjectRowView( - object: art, - subtitle: nil, - rightSubtitle: art.artist, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - case .camp(let camp): - ObjectRowView( - object: camp, - subtitle: nil, - rightSubtitle: camp.hometown, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - case .event(let event): - ObjectRowView( - object: event, - subtitle: nil, - rightSubtitle: event.eventTypeLabel, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - case .mutantVehicle(let mv): - ObjectRowView( - object: mv, - subtitle: nil, - rightSubtitle: mv.artist, - isFavorite: isFavorite, - onFavoriteTap: onFavoriteTap - ) { _ in EmptyView() } - } - } - - // MARK: - Navigation - - private func runWorkflow() { - isTextFieldFocused = false - viewModel.run( - workflowInfo.id, - theme: themeText.isEmpty ? nil : themeText, - hoursBack: Int(hoursBack), - startDate: startDate - ) - } - - private func navigateToDetail(uid: String) { - guard let resolved = viewModel.resolvedObjects[uid] else { return } - let playaDB = viewModel.playaDB - let detailVC: UIViewController - switch resolved { - case .art(let art): - detailVC = DetailViewControllerFactory.create(with: art, playaDB: playaDB) - case .camp(let camp): - detailVC = DetailViewControllerFactory.create(with: camp, playaDB: playaDB) - case .event(let event): - detailVC = DetailViewControllerFactory.create(with: event, playaDB: playaDB) - case .mutantVehicle(let mv): - detailVC = DetailViewControllerFactory.create(with: mv, playaDB: playaDB) - } - onNavigateToDetail(detailVC) - } -} - -// MARK: - Step Progress Row - -@available(iOS 26, *) -struct WorkflowStepRow: View { - let step: WorkflowStepProgress - let themeColors: ImageColors - - var body: some View { - HStack(spacing: 10) { - stepIcon - .frame(width: 20) - - Text(step.message) - .font(.caption) - .foregroundColor(textColor) - .italic(step.state == .running) - - Spacer() - } - .padding(.vertical, 4) - .animation(.easeInOut(duration: 0.3), value: step.state) - } - - @ViewBuilder - private var stepIcon: some View { - switch step.state { - case .pending: - Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 1.5) - .frame(width: 14, height: 14) - case .running: - ProgressView() - .scaleEffect(0.6) - case .completed: - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 14)) - .foregroundColor(.green) - case .failed: - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .foregroundColor(.red) - } - } - - private var textColor: Color { - switch step.state { - case .pending: return .gray - case .running: return themeColors.primaryColor - case .completed: return themeColors.secondaryColor - case .failed: return .red - } - } -} - -#endif diff --git a/iBurn/AISearch/WorkflowProgressTypes.swift b/iBurn/AISearch/WorkflowProgressTypes.swift new file mode 100644 index 00000000..ffade546 --- /dev/null +++ b/iBurn/AISearch/WorkflowProgressTypes.swift @@ -0,0 +1,33 @@ +// +// WorkflowProgressTypes.swift +// iBurn +// +// Lightweight UI state types for AI workflow execution (progress + run state). +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import Foundation + +/// A single step shown in the AI guide progress list. +struct WorkflowStepProgress: Identifiable { + let id = UUID() + let message: String + var state: StepState + + enum StepState { + case pending + case running + case completed + case failed + } +} + +/// Overall execution state of an AI workflow run. +enum WorkflowExecutionState { + case idle + case running + case completed + case failed(String) +} diff --git a/iBurn/AISearch/Workflows/AdventureWorkflow.swift b/iBurn/AISearch/Workflows/AdventureWorkflow.swift deleted file mode 100644 index 1911cf3c..00000000 --- a/iBurn/AISearch/Workflows/AdventureWorkflow.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// AdventureWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableKeywords { - @Guide(description: "Search keywords extracted from the theme", .count(2...5)) - var keywords: [String] -} - -@available(iOS 26, *) -@Generable -struct GenerableSelectedStop { - @Guide(description: "Stop number from the list") - var number: Int - @Guide(description: "Brief reason why this stop was selected") - var reason: String -} - -@available(iOS 26, *) -@Generable -struct GenerableStopSelection { - @Guide(description: "Selected stops for the adventure", .count(3...7)) - var stops: [GenerableSelectedStop] -} - -@available(iOS 26, *) -@Generable -struct GenerableAdventureTip { - @Guide(description: "Name of the stop this tip is for") - var stopName: String - @Guide(description: "One-line visit tip for this stop") - var tip: String -} - -@available(iOS 26, *) -@Generable -struct GenerableAdventureNarrative { - @Guide(description: "Two-sentence adventure intro setting the mood") - var intro: String - @Guide(description: "Visit tips, one per stop", .count(1...8)) - var tips: [GenerableAdventureTip] -} - -// MARK: - Adventure Workflow - -@available(iOS 26, *) -struct AdventureWorkflow: Workflow { - let theme: String - let name = "Adventure Generator" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> RouteResult { - // Step 1: Extract theme keywords via LLM - onProgress(.stepStarted(name: "theme", description: "Understanding your adventure theme...")) - let keywordSession = LanguageModelSession(instructions: """ - Extract search keywords from a Burning Man adventure theme request. \ - Return diverse keywords that would find relevant art, camps, and events. - """) - let keywords = try await keywordSession.respond( - to: Prompt("Theme: \(theme)"), - generating: GenerableKeywords.self - ) - onProgress(.stepCompleted(name: "theme")) - - // Step 2: Parallel DB queries with keywords (brief detail) - onProgress(.stepStarted(name: "search", description: "Exploring the playa...")) - var allCandidates: [Any] = [] - - for keyword in keywords.content.keywords { - let results = try await context.playaDB.searchObjects(keyword) - allCandidates.append(contentsOf: results) - } - - // Deduplicate by UID - var seen = Set() - allCandidates = allCandidates.filter { obj in - guard let uid = objectUID(obj) else { return false } - return seen.insert(uid).inserted - } - onProgress(.stepCompleted(name: "search")) - - guard !allCandidates.isEmpty else { - return RouteResult(stops: [], narrative: "Couldn't find enough items for this adventure theme.", totalWalkMinutes: 0) - } - - // Step 3: LLM selects best stops (numeric IDs to save tokens) - onProgress(.stepStarted(name: "curate", description: "Curating the best stops...")) - let candidateSlice = Array(allCandidates.prefix(18)) - let objIdMap = Dictionary(uniqueKeysWithValues: candidateSlice.enumerated().map { ($0.offset + 1, $0.element) }) - - let selection: GenerableStopSelection = try await retryWithCandidateFiltering( - candidates: Array(candidateSlice.enumerated()), - format: { objectName($0.element) ?? "unknown" } - ) { batch in - let text = batch.map { idx, obj in - "\(idx + 1). \(formatObject(obj, detail: .brief))" - }.joined(separator: "\n") - let session = LanguageModelSession(instructions: """ - Pick 4-7 stops for a "\(theme)" Burning Man adventure. Mix types. Use the numbers. - """) - return try await session.respond( - to: Prompt("Stops:\n\(text)"), - generating: GenerableStopSelection.self - ).content - } - onProgress(.stepCompleted(name: "curate")) - - // Step 4: Build optimized route - onProgress(.stepStarted(name: "route", description: "Optimizing your route...")) - let routeSelections = selection.stops.compactMap { stop -> (uid: String, reason: String, typeOverride: DataObjectType?)? in - guard let obj = objIdMap[stop.number], let uid = objectUID(obj) else { return nil } - return (uid: uid, reason: stop.reason, typeOverride: nil) - } - let route = await buildRoute( - selections: routeSelections, - startLocation: context.location?.coordinate, - playaDB: context.playaDB - ) - onProgress(.stepCompleted(name: "route")) - - // Step 5: Generate narrative with tips - onProgress(.stepStarted(name: "narrative", description: "Writing your adventure...")) - let stopsText = route.stops.enumerated().map { idx, stop in - "\(idx + 1). \(stop.name) (\(stop.type.rawValue))" - }.joined(separator: "\n") - - let narrativeSession = LanguageModelSession(instructions: """ - Write a fun adventure intro and one tip per stop. Theme: "\(theme)". - """) - let narrative = try await narrativeSession.respond( - to: Prompt("Stops:\n\(stopsText)"), - generating: GenerableAdventureNarrative.self - ) - onProgress(.stepCompleted(name: "narrative")) - - // Merge tips into stops - let finalStops = mergeNotesByName( - entries: route.stops, - notes: narrative.content.tips.map { (name: $0.stopName, text: $0.tip) }, - entryName: { $0.name }, - merge: { stop, tip in - RouteStop(id: stop.id, name: stop.name, type: stop.type, reason: tip, - walkMinutesFromPrevious: stop.walkMinutesFromPrevious, - latitude: stop.latitude, longitude: stop.longitude) - } - ) - - return RouteResult( - stops: finalStops, - narrative: narrative.content.intro, - totalWalkMinutes: route.totalWalkMinutes - ) - } -} - -#endif diff --git a/iBurn/AISearch/Workflows/CampCrawlWorkflow.swift b/iBurn/AISearch/Workflows/CampCrawlWorkflow.swift deleted file mode 100644 index b49b2a67..00000000 --- a/iBurn/AISearch/Workflows/CampCrawlWorkflow.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// CampCrawlWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableCampStop { - @Guide(description: "Camp number from the list") - var number: Int - @Guide(description: "Brief visit tip for this camp") - var tip: String -} - -@available(iOS 26, *) -@Generable -struct GenerableCampSelection { - @Guide(description: "Selected camps for the crawl", .count(3...6)) - var camps: [GenerableCampStop] -} - -@available(iOS 26, *) -@Generable -struct GenerableCrawlNarrative { - @Guide(description: "Fun two-sentence intro for the camp crawl") - var intro: String -} - -// MARK: - Camp Crawl Workflow - -@available(iOS 26, *) -struct CampCrawlWorkflow: Workflow { - let theme: String - let name = "Camp Crawl" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> RouteResult { - // Step 1: Parse theme keywords - onProgress(.stepStarted(name: "theme", description: "Planning your camp crawl...")) - let keywordSession = LanguageModelSession(instructions: """ - Extract search keywords for finding themed camps at Burning Man. - """) - let keywords = try await keywordSession.respond( - to: Prompt("Camp crawl theme: \(theme)"), - generating: GenerableKeywords.self - ) - onProgress(.stepCompleted(name: "theme")) - - // Step 2: Search camps by theme - onProgress(.stepStarted(name: "search", description: "Finding camps...")) - var campCandidates: [CampObject] = [] - - for keyword in keywords.content.keywords { - var filter = CampFilter.all - filter.searchText = keyword - let results = try await context.playaDB.fetchCamps(filter: filter) - campCandidates.append(contentsOf: results) - } - - // Deduplicate - var seen = Set() - campCandidates = campCandidates.filter { seen.insert($0.uid).inserted } - onProgress(.stepCompleted(name: "search")) - - guard !campCandidates.isEmpty else { - return RouteResult(stops: [], narrative: "Couldn't find camps matching '\(theme)'.", totalWalkMinutes: 0) - } - - // Step 3: Fetch hosted events for top camps - onProgress(.stepStarted(name: "events", description: "Checking what's happening at each camp...")) - var campEventInfo: [String: String] = [:] - - for camp in campCandidates.prefix(10) { - let events = try await context.playaDB.fetchEvents(hostedByCampUID: camp.uid) - if !events.isEmpty { - let eventNames = events.prefix(3).map(\.event.name).joined(separator: ", ") - campEventInfo[camp.uid] = eventNames - } - } - onProgress(.stepCompleted(name: "events")) - - // Step 4: LLM selects best camps (numeric IDs to save tokens) - onProgress(.stepStarted(name: "curate", description: "Curating the best stops...")) - let candidateSlice = Array(campCandidates.prefix(15)) - let campIdMap = Dictionary(uniqueKeysWithValues: candidateSlice.enumerated().map { ($0.offset + 1, $0.element) }) - - let selection: GenerableCampSelection = try await retryWithCandidateFiltering( - candidates: Array(candidateSlice.enumerated()), - format: { "\($0.element.name)" } - ) { batch in - let text = batch.map { idx, camp in - let events = campEventInfo[camp.uid].map { " events: \($0)" } ?? "" - return "\(idx + 1). \(camp.name)\(events)" - }.joined(separator: "\n") - let session = LanguageModelSession(instructions: """ - Pick 4-6 camps for a "\(theme)" camp crawl. Use the numbers. - """) - return try await session.respond( - to: Prompt("Camps:\n\(text)"), - generating: GenerableCampSelection.self - ).content - } - onProgress(.stepCompleted(name: "curate")) - - // Step 5: Build optimized route - onProgress(.stepStarted(name: "route", description: "Building your route...")) - let routeSelections = selection.camps.compactMap { stop -> (uid: String, reason: String, typeOverride: DataObjectType?)? in - guard let camp = campIdMap[stop.number] else { return nil } - return (uid: camp.uid, reason: stop.tip, typeOverride: .camp) - } - let route = await buildRoute( - selections: routeSelections, - startLocation: context.location?.coordinate, - playaDB: context.playaDB - ) - onProgress(.stepCompleted(name: "route")) - - // Step 6: Generate narrative - onProgress(.stepStarted(name: "narrative", description: "Writing your crawl guide...")) - let stopsText = route.stops.map(\.name).joined(separator: " -> ") - let narrativeSession = LanguageModelSession(instructions: """ - Write a fun, short intro for a "\(theme)" camp crawl. - """) - let narrative = try await narrativeSession.respond( - to: Prompt("Route: \(stopsText)"), - generating: GenerableCrawlNarrative.self - ) - onProgress(.stepCompleted(name: "narrative")) - - return RouteResult( - stops: route.stops, - narrative: narrative.content.intro, - totalWalkMinutes: route.totalWalkMinutes - ) - } - -} - -#endif diff --git a/iBurn/AISearch/Workflows/DayPlanWorkflow.swift b/iBurn/AISearch/Workflows/DayPlanWorkflow.swift deleted file mode 100644 index 563ac22b..00000000 --- a/iBurn/AISearch/Workflows/DayPlanWorkflow.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// DayPlanWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Day Plan Result - -@available(iOS 26, *) -struct DayPlanResult: Sendable { - let items: [DayPlanEntry] - let summary: String -} - -@available(iOS 26, *) -struct DayPlanEntry: Sendable { - let uid: String - let name: String - let startTime: String - let endTime: String - let reason: String - let walkMinutesFromPrevious: Int? -} - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableDayPlanSelection { - @Guide(description: "Selected event numbers from the list, in chronological order", .count(3...6)) - var selectedNumbers: [Int] - @Guide(description: "One-sentence theme for the day") - var dayTheme: String -} - -@available(iOS 26, *) -@Generable -struct GenerableDayPlanNote { - @Guide(description: "Event name this note is for") - var eventName: String - @Guide(description: "One-sentence transition note about what to expect") - var note: String -} - -@available(iOS 26, *) -@Generable -struct GenerableDayPlanNarrative { - @Guide(description: "Transition notes, one per event", .count(1...10)) - var transitionNotes: [GenerableDayPlanNote] - @Guide(description: "Overall day summary, one sentence") - var summary: String -} - -// MARK: - Day Plan Workflow - -@available(iOS 26, *) -struct DayPlanWorkflow: Workflow { - let name = "Day Planner" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> DayPlanResult { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.timeZone = TimeZone(identifier: "America/Los_Angeles") - - // Step 1: Get favorites to understand taste - onProgress(.stepStarted(name: "taste", description: "Understanding your interests...")) - let favorites = try await context.playaDB.getFavorites() - let tasteKeywords = extractKeywords(from: favorites) - onProgress(.stepCompleted(name: "taste")) - - // Step 2: Fetch today's events matching taste - onProgress(.stepStarted(name: "events", description: "Finding today's events...")) - var allCandidates: [EventObjectOccurrence] = [] - - // Fetch upcoming events (next 12 hours) - let upcoming = try await context.playaDB.fetchUpcomingEvents(within: 12, from: context.date) - allCandidates.append(contentsOf: upcoming) - - // Also search by taste keywords - for keyword in tasteKeywords.prefix(3) { - var filter = EventFilter.all - filter.searchText = keyword - filter.startingWithinHours = 12 - let results = try await context.playaDB.fetchEvents(filter: filter) - allCandidates.append(contentsOf: results) - } - - // Deduplicate by event UID - var seen = Set() - allCandidates = allCandidates.filter { seen.insert($0.event.uid).inserted } - onProgress(.stepCompleted(name: "events")) - - guard !allCandidates.isEmpty else { - return DayPlanResult(items: [], summary: "No events found for today.") - } - - // Step 3: Detect conflicts - onProgress(.stepStarted(name: "optimize", description: "Optimizing your schedule...")) - let conflicts = detectConflicts(allCandidates) - - // Step 4: LLM selects best events (auto-reduce candidates on context overflow) - let (selection, idMap) = try await withContextWindowRetry(initialCount: 20, minimumCount: 6) { maxCandidates in - let candidateSlice = Array(allCandidates.prefix(maxCandidates)) - let map = Dictionary(uniqueKeysWithValues: candidateSlice.enumerated().map { ($0.offset + 1, $0.element.event.uid) }) - let text = candidateSlice.enumerated().map { idx, occ in - let time = formatter.string(from: occ.startDate) - return "\(idx + 1). \(occ.event.name) at \(time) (\(occ.event.eventTypeLabel))" - }.joined(separator: "\n") - - var prompt = "Pick 4-6 events for a great day. Use the numbers." - if !conflicts.isEmpty { - let conflictText = conflicts.prefix(2).map { "\($0.0.event.name) vs \($0.1.event.name)" }.joined(separator: ", ") - prompt += " Conflicts: \(conflictText)" - } - if !tasteKeywords.isEmpty { - prompt += " Likes: \(tasteKeywords.prefix(3).joined(separator: ", "))" - } - - let session = LanguageModelSession(instructions: """ - Schedule a day at Burning Man. Pick balanced events. Resolve conflicts. - """) - let result = try await session.respond( - to: Prompt("Events:\n\(text)\n\n\(prompt)"), - generating: GenerableDayPlanSelection.self - ) - return (result, map) - } - onProgress(.stepCompleted(name: "optimize")) - - // Step 5: Calculate walk times - onProgress(.stepStarted(name: "route", description: "Calculating walking routes...")) - let selectedUIDs = selection.content.selectedNumbers.compactMap { idMap[$0] } - let selectedEvents = selectedUIDs.compactMap { uid in - allCandidates.first { $0.event.uid == uid } - } - - var entries: [DayPlanEntry] = [] - var previousCoord: CLLocationCoordinate2D? = context.location?.coordinate - - for event in selectedEvents { - let startTime = formatter.string(from: event.startDate) - let endTime = formatter.string(from: event.endDate) - - var walkMin: Int? = nil - if let prevCoord = previousCoord, - let lat = event.event.gpsLatitude, let lon = event.event.gpsLongitude { - let eventCoord = CLLocationCoordinate2D(latitude: lat, longitude: lon) - walkMin = playaWalkMinutes(from: prevCoord, to: eventCoord) - previousCoord = eventCoord - } - - entries.append(DayPlanEntry( - uid: event.event.uid, - name: event.event.name, - startTime: startTime, - endTime: endTime, - reason: "", - walkMinutesFromPrevious: walkMin - )) - } - onProgress(.stepCompleted(name: "route")) - - // Step 6: LLM generates transition notes - onProgress(.stepStarted(name: "narrative", description: "Writing your day plan...")) - let scheduleText = entries.map { entry in - var line = "\(entry.startTime)-\(entry.endTime): \(entry.name)" - if let walk = entry.walkMinutesFromPrevious { line += " (~\(walk) min walk)" } - return line - }.joined(separator: "\n") - - let narrativeSession = LanguageModelSession(instructions: """ - Write one fun sentence per event and an overall summary. - """) - let narrative = try await narrativeSession.respond( - to: Prompt("Schedule:\n\(scheduleText)"), - generating: GenerableDayPlanNarrative.self - ) - onProgress(.stepCompleted(name: "narrative")) - - // Merge notes into entries by matching event name - let finalEntries = mergeNotesByName( - entries: entries, - notes: narrative.content.transitionNotes.map { (name: $0.eventName, text: $0.note) }, - entryName: { $0.name }, - merge: { entry, note in - DayPlanEntry(uid: entry.uid, name: entry.name, startTime: entry.startTime, - endTime: entry.endTime, reason: note, - walkMinutesFromPrevious: entry.walkMinutesFromPrevious) - } - ) - - return DayPlanResult( - items: finalEntries, - summary: narrative.content.summary.isEmpty ? selection.content.dayTheme : narrative.content.summary - ) - } - - private func extractKeywords(from favorites: [Any]) -> [String] { - extractTasteKeywords(favorites) - } -} - -#endif diff --git a/iBurn/AISearch/Workflows/GeneralChatWorkflow.swift b/iBurn/AISearch/Workflows/GeneralChatWorkflow.swift deleted file mode 100644 index 499d0ace..00000000 --- a/iBurn/AISearch/Workflows/GeneralChatWorkflow.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// GeneralChatWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableChatItem { - @Guide(description: "Object uid from tool results") - var uid: String - @Guide(description: "Brief pitch for why this is relevant, under 12 words") - var pitch: String -} - -@available(iOS 26, *) -@Generable -struct GenerableChatResponse { - @Guide(description: "Natural language response to the user") - var response: String - @Guide(description: "Relevant objects found, if any", .count(0...8)) - var items: [GenerableChatItem] -} - -// MARK: - General Chat Workflow - -@available(iOS 26, *) -struct GeneralChatWorkflow: Workflow { - enum Mode { case search, nearby, general } - - let query: String - let mode: Mode - let name = "General Chat" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> DiscoveryResult { - onProgress(.stepStarted(name: "search", description: "Searching the playa...")) - - var tools: [any Tool] = [ - SearchByKeywordTool(playaDB: context.playaDB, detailLevel: .normal), - FetchArtTool(playaDB: context.playaDB, detailLevel: .normal), - FetchCampsTool(playaDB: context.playaDB, detailLevel: .normal), - FetchMutantVehiclesTool(playaDB: context.playaDB, detailLevel: .normal), - FetchUpcomingEventsTool(playaDB: context.playaDB, detailLevel: .normal), - GetFavoritesTool(playaDB: context.playaDB, detailLevel: .brief), - ] - - if mode == .nearby, context.location != nil { - tools.append(FetchNearbyObjectsTool(playaDB: context.playaDB, detailLevel: .normal)) - } - - var instructions = """ - You are an AI guide for the Burning Man festival. \ - Use the provided tools to find relevant art, camps, events, and vehicles. \ - Answer the user's question naturally and include relevant items. - """ - - if !context.conversationHistory.isEmpty { - instructions += "\nConversation context: \(context.conversationHistory.joined(separator: "; "))" - } - - var prompt = query - if mode == .nearby, let loc = context.location { - prompt += " (I'm at GPS \(loc.coordinate.latitude), \(loc.coordinate.longitude))" - } - - let session = LanguageModelSession(tools: tools, instructions: instructions) - let response = try await session.respond(to: Prompt(prompt), generating: GenerableChatResponse.self) - - onProgress(.stepCompleted(name: "search")) - - // Resolve items to get their names and types - let items = await resolveDiscoveryItems( - picks: response.content.items.map { (uid: $0.uid, pitch: $0.pitch) }, - playaDB: context.playaDB - ) - - return DiscoveryResult( - items: items, - intro: response.content.response - ) - } -} - -#endif diff --git a/iBurn/AISearch/Workflows/GoldenHourWorkflow.swift b/iBurn/AISearch/Workflows/GoldenHourWorkflow.swift deleted file mode 100644 index b6120141..00000000 --- a/iBurn/AISearch/Workflows/GoldenHourWorkflow.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// GoldenHourWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableGoldenHourStop { - @Guide(description: "Art number from the list") - var number: Int - @Guide(description: "Why this art is great at golden hour") - var reason: String -} - -@available(iOS 26, *) -@Generable -struct GenerableGoldenHourSelection { - @Guide(description: "Selected art for golden hour viewing", .count(3...6)) - var stops: [GenerableGoldenHourStop] -} - -@available(iOS 26, *) -@Generable -struct GenerableGoldenHourNarrative { - @Guide(description: "Evocative two-sentence intro about golden hour on the playa") - var intro: String -} - -// MARK: - Golden Hour Workflow - -@available(iOS 26, *) -struct GoldenHourWorkflow: Workflow { - let name = "Golden Hour Planner" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> RouteResult { - // Step 1: Calculate sunrise/sunset - onProgress(.stepStarted(name: "sun", description: "Calculating golden hour...")) - let sunTimes = brcSunTimes(for: context.date) - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.timeZone = TimeZone(identifier: "America/Los_Angeles") - let sunriseStr = formatter.string(from: sunTimes.sunrise) - let sunsetStr = formatter.string(from: sunTimes.sunset) - - // Determine if we're closer to sunrise or sunset - let now = context.date - let toSunrise = sunTimes.sunrise.timeIntervalSince(now) - let toSunset = sunTimes.sunset.timeIntervalSince(now) - let targetTime: String - if toSunrise > 0 && toSunrise < toSunset { - targetTime = "sunrise at \(sunriseStr)" - } else { - targetTime = "sunset at \(sunsetStr)" - } - onProgress(.stepCompleted(name: "sun")) - - // Step 2: Fetch art installations (preferring open playa / large-scale) - onProgress(.stepStarted(name: "art", description: "Finding art for golden hour...")) - let allArt = try await context.playaDB.fetchArt() - - // Prefer art that's likely large-scale or in open playa - // We don't have explicit size data, but we can check location_category - // and whether they have GPS coordinates (placed art) - let artWithGPS = allArt.filter { $0.gpsLatitude != nil && $0.gpsLongitude != nil } - onProgress(.stepCompleted(name: "art")) - - guard !artWithGPS.isEmpty else { - return RouteResult(stops: [], narrative: "No art with location data found.", totalWalkMinutes: 0) - } - - // Step 3: LLM selects art best for golden hour (numeric IDs to save tokens) - onProgress(.stepStarted(name: "curate", description: "Curating golden hour art...")) - let artSlice = Array(artWithGPS.prefix(18)) - let artIdMap = Dictionary(uniqueKeysWithValues: artSlice.enumerated().map { ($0.offset + 1, $0.element) }) - - let selection: GenerableGoldenHourSelection = try await retryWithCandidateFiltering( - candidates: Array(artSlice.enumerated()), - format: { "\($0.element.name)" } - ) { batch in - let text = batch.map { idx, art in - let cat = art.category ?? "" - return "\(idx + 1). \(art.name)\(cat.isEmpty ? "" : " (\(cat))")" - }.joined(separator: "\n") - let session = LanguageModelSession(instructions: """ - Pick art for \(targetTime) viewing. Prefer large sculptures, reflective pieces, fire art. Use numbers. - """) - return try await session.respond( - to: Prompt("Art:\n\(text)"), - generating: GenerableGoldenHourSelection.self - ).content - } - onProgress(.stepCompleted(name: "curate")) - - // Step 4: Calculate route — map numeric IDs back - onProgress(.stepStarted(name: "route", description: "Planning your golden hour route...")) - let routeSelections = selection.stops.compactMap { stop -> (uid: String, reason: String, typeOverride: DataObjectType?)? in - guard let art = artIdMap[stop.number] else { return nil } - return (uid: art.uid, reason: stop.reason, typeOverride: .art) - } - let route = await buildRoute( - selections: routeSelections, - startLocation: context.location?.coordinate, - playaDB: context.playaDB - ) - onProgress(.stepCompleted(name: "route")) - - // Generate narrative - onProgress(.stepStarted(name: "narrative", description: "Setting the mood...")) - let narrativeSession = LanguageModelSession(instructions: """ - Evocative golden hour art tour intro. Target: \(targetTime). Be poetic, concise. - """) - let narrative = try await narrativeSession.respond( - to: Prompt("Art route: \(route.stops.map(\.name).joined(separator: " -> "))"), - generating: GenerableGoldenHourNarrative.self - ) - onProgress(.stepCompleted(name: "narrative")) - - return RouteResult( - stops: route.stops, - narrative: narrative.content.intro + "\n\nLeave ~30 min before \(targetTime). Total walk: ~\(route.totalWalkMinutes) min.", - totalWalkMinutes: route.totalWalkMinutes - ) - } -} - -#endif diff --git a/iBurn/AISearch/Workflows/RightNowWorkflow.swift b/iBurn/AISearch/Workflows/RightNowWorkflow.swift new file mode 100644 index 00000000..2176254b --- /dev/null +++ b/iBurn/AISearch/Workflows/RightNowWorkflow.swift @@ -0,0 +1,445 @@ +// +// RightNowWorkflow.swift +// iBurn +// +// The single AI Guide flow: "what's near you happening now, and what to do next." +// Given a vibe (+ time window + place), returns a "Now near you" set and a "Next" set. +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +#if canImport(FoundationModels) +import Foundation +import CoreLocation +import MapKit +import FoundationModels +@preconcurrency import PlayaDB + +// MARK: - Result Types + +struct RightNowItem: Sendable, Identifiable { + var id: String { uid } + let uid: String + let name: String + let type: DataObjectType + let pitch: String + let walkMinutes: Int? + /// Formatted start time for time-bound events (e.g. "3:00 PM"); nil for art/camps. + let timeInfo: String? +} + +struct RightNowResult: Sendable { + let intro: String + let now: [RightNowItem] + let next: [RightNowItem] + + var isEmpty: Bool { now.isEmpty && next.isEmpty } +} + +// MARK: - Candidate (internal, also used by tests) + +/// A pre-LLM candidate gathered from the DB. +struct RNCandidate: Sendable { + let uid: String + let name: String + let type: DataObjectType + let coordinate: CLLocationCoordinate2D? + /// Event start (nil for timeless art/camps). + let startDate: Date? + /// Formatted start time for events. + let timeInfo: String? + var walkMinutes: Int? +} + +enum RNBucket: Sendable { case now, next } + +/// Sendable curation result, decoupled from the @Generable type so it can cross a TaskGroup. +struct CuratedPick: Sendable { let uid: String; let pitch: String } +struct Curated: Sendable { let intro: String; let now: [CuratedPick]; let next: [CuratedPick] } + +/// Run `work`, returning nil if it doesn't finish within `seconds` (or returns nil itself). +/// The on-device model can be slow or stall on first use; this keeps the UI from hanging. +func runWithTimeout(seconds: Double, _ work: @Sendable @escaping () async -> T?) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { await work() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } +} + +// MARK: - Candidate Gathering (pure, no LLM — unit testable) + +/// Gather "now" and "next" candidates from the database. No LLM involved so this is +/// deterministic and testable with an in-memory PlayaDB. +/// +/// Event-first: "now" = events happening right now (when the window covers the present), +/// "next" = events starting within the window. Camps/art are only used as a fallback when +/// no events match (so the screen is never just a pile of camps when events exist). +/// +/// - `includeHappeningNow`: when true (the window contains the present), currently-happening +/// events are pulled into the "now" bucket. +func gatherRightNowCandidates( + playaDB: PlayaDB, + region: MKCoordinateRegion?, + origin: CLLocationCoordinate2D, + now: Date, + windowStart: Date, + windowEnd: Date, + vibe: String, + lean: DiscoveryLean, + favoriteUIDs: Set, + includeHappeningNow: Bool, + perBucketCap: Int = 10 +) async throws -> (now: [RNCandidate], next: [RNCandidate]) { + let codes = eventTypeCodes(forVibe: vibe) + let trimmedVibe = vibe.trimmingCharacters(in: .whitespacesAndNewlines) + let excludeFavorites = (lean == .surprise) + let formatter = brcTimeFormatter() + + func keep(_ uid: String) -> Bool { + excludeFavorites ? !favoriteUIDs.contains(uid) : true + } + func walk(_ coord: CLLocationCoordinate2D?) -> Int? { + guard let coord else { return nil } + return playaWalkMinutes(from: origin, to: coord) + } + func eventCandidate(_ occ: EventObjectOccurrence) -> RNCandidate { + let coord = eventCoordinate(occ) + return RNCandidate( + uid: occ.event.uid, name: occ.event.name, type: .event, + coordinate: coord, startDate: occ.startDate, + timeInfo: formatter.string(from: occ.startDate), walkMinutes: walk(coord) + ) + } + + // Camps & art matching the vibe in the area. They serve double duty: they're event + // hosts (we expand them to their events below) and the fallback when no events exist. + var artFilter = ArtFilter.all + artFilter.region = region + if !trimmedVibe.isEmpty { artFilter.searchText = trimmedVibe } + let artInArea = try await playaDB.fetchArt(filter: artFilter) + + var campFilter = CampFilter.all + campFilter.region = region + if !trimmedVibe.isEmpty { campFilter.searchText = trimmedVibe } + let campsInArea = try await playaDB.fetchCamps(filter: campFilter) + + // Collect candidate event occurrences from multiple sources: + // (a) the region-scoped event query (catches events whose host GPS is in the join), + // (b) events hosted by the matched camps / art — this catches events the region query + // 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] { + var filter = EventFilter.all + filter.region = region + filter.startDate = max(windowStart, now) + filter.endDate = windowEnd + filter.eventTypeCodes = typeCodes + return try await playaDB.fetchEvents(filter: filter) + } + var occurrences: [EventObjectOccurrence] = [] + var regionOccs = try await regionQuery(codes) + if regionOccs.isEmpty, codes != nil { regionOccs = try await regionQuery(nil) } + occurrences += regionOccs + for camp in campsInArea.prefix(10) { + occurrences += (try? await playaDB.fetchEvents(hostedByCampUID: camp.uid)) ?? [] + } + for art in artInArea.prefix(10) { + occurrences += (try? await playaDB.fetchEvents(locatedAtArtUID: art.uid)) ?? [] + } + + // Classify occurrences that overlap the window (and haven't ended) into now / next. + let windowFloor = max(windowStart, now) + var nowEvents: [RNCandidate] = [] + var nextEvents: [RNCandidate] = [] + var seenEvents = Set() + for occ in occurrences.sorted(by: { $0.startDate < $1.startDate }) { + let uid = occ.event.uid + guard keep(uid), seenEvents.insert(uid).inserted else { continue } + guard occ.startDate < windowEnd, occ.endDate > windowFloor else { continue } + if includeHappeningNow, occ.startDate <= now, occ.endDate > now { + nowEvents.append(eventCandidate(occ)) + } else { + nextEvents.append(eventCandidate(occ)) + } + } + + // Events lead. When any exist, return only events — no camps/art mixed in. + if !nowEvents.isEmpty || !nextEvents.isEmpty { + return (Array(nowEvents.prefix(perBucketCap)), Array(nextEvents.prefix(perBucketCap))) + } + + // --- Fallback: no events in the window → the matched places themselves. --- + var places: [RNCandidate] = [] + var seenPlaces = Set() + for obj in artInArea { + guard keep(obj.uid), seenPlaces.insert(obj.uid).inserted else { continue } + let coord = coordinate(lat: obj.gpsLatitude, lon: obj.gpsLongitude) + places.append(RNCandidate(uid: obj.uid, name: obj.name, type: .art, + coordinate: coord, startDate: nil, timeInfo: nil, walkMinutes: walk(coord))) + } + for obj in campsInArea { + guard keep(obj.uid), seenPlaces.insert(obj.uid).inserted else { continue } + let coord = coordinate(lat: obj.gpsLatitude, lon: obj.gpsLongitude) + places.append(RNCandidate(uid: obj.uid, name: obj.name, type: .camp, + coordinate: coord, startDate: nil, timeInfo: nil, walkMinutes: walk(coord))) + } + if vibeMentionsVehicles(trimmedVibe) || (trimmedVibe.isEmpty && lean == .surprise) { + var mvFilter = MutantVehicleFilter.all + if !trimmedVibe.isEmpty { mvFilter.searchText = trimmedVibe } + for obj in try await playaDB.fetchMutantVehicles(filter: mvFilter) { + guard keep(obj.uid), seenPlaces.insert(obj.uid).inserted else { continue } + places.append(RNCandidate(uid: obj.uid, name: obj.name, type: .mutantVehicle, + coordinate: nil, startDate: nil, timeInfo: nil, walkMinutes: nil)) + } + } + + places.sort { ($0.walkMinutes ?? Int.max) < ($1.walkMinutes ?? Int.max) } + return (Array(places.prefix(perBucketCap)), []) +} + +// MARK: - Helpers + +private func coordinate(lat: Double?, lon: Double?) -> CLLocationCoordinate2D? { + guard let lat, let lon else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) +} + +private func eventCoordinate(_ occ: EventObjectOccurrence) -> CLLocationCoordinate2D? { + coordinate(lat: occ.event.gpsLatitude, lon: occ.event.gpsLongitude) +} + +private func vibeMentionsVehicles(_ vibe: String) -> Bool { + let t = vibe.lowercased() + return t.contains("art car") || t.contains("mutant") || t.contains("vehicle") || t.contains("car") +} + +private func brcTimeFormatter() -> DateFormatter { + let f = DateFormatter() + f.dateFormat = "h:mm a" + f.timeZone = .burningManTimeZone + return f +} + +extension MKCoordinateRegion { + /// Simple bounding-box containment check. + func contains(_ coord: CLLocationCoordinate2D) -> Bool { + abs(coord.latitude - center.latitude) <= span.latitudeDelta / 2 && + abs(coord.longitude - center.longitude) <= span.longitudeDelta / 2 + } +} + +// MARK: - Generable Output + +@available(iOS 26, *) +@Generable +struct GenerableRightNowPick { + @Guide(description: "The exact uid from the candidate list") + var uid: String + @Guide(description: "Why it's worth it right now, under 15 words, concrete and grounded in the data") + var pitch: String +} + +@available(iOS 26, *) +@Generable +struct GenerableRightNowResponse { + @Guide(description: "One short, concrete opening line. No hype.") + var intro: String + @Guide(description: "Best picks happening now or to go see near the user", .count(0...5)) + var now: [GenerableRightNowPick] + @Guide(description: "Best picks coming up soon", .count(0...5)) + var next: [GenerableRightNowPick] +} + +// MARK: - Workflow + +@available(iOS 26, *) +struct RightNowWorkflow: Workflow { + let name = "RightNow" + + func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> RightNowResult { + // Step 1: taste + onProgress(.stepStarted(name: "taste", description: "Reading your favorites")) + let favorites = try await context.playaDB.getFavorites() + let favoriteUIDs = Set(favorites.map(\.uid)) + let tasteProfile: String + if context.lean == .surprise || favorites.isEmpty { + tasteProfile = "" + } else { + tasteProfile = favorites.prefix(8).map { "\($0.objectType.rawValue): \($0.name)" }.joined(separator: "\n") + } + onProgress(.stepCompleted(name: "taste")) + + // Step 2: gather candidates + onProgress(.stepStarted(name: "gather", description: "Finding what's around you")) + let origin = context.region?.center ?? context.location?.coordinate ?? YearSettings.manCenterCoordinate + let filterRegion = context.region ?? context.location.map { + MKCoordinateRegion(center: $0.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012)) + } + let includeNow = context.windowStart <= context.date && context.date <= context.windowEnd + + let (nowCands, nextCands) = try await gatherRightNowCandidates( + playaDB: context.playaDB, + region: filterRegion, + origin: origin, + now: context.date, + windowStart: context.windowStart, + windowEnd: context.windowEnd, + vibe: context.vibe, + lean: context.lean, + favoriteUIDs: favoriteUIDs, + includeHappeningNow: includeNow + ) + onProgress(.stepCompleted(name: "gather")) + + guard !nowCands.isEmpty || !nextCands.isEmpty else { + return RightNowResult( + intro: "Nothing matching nearby right now — try a wider area or a different vibe.", + now: [], next: [] + ) + } + + // Step 3: LLM curation + pitch — best-effort, bounded by a timeout. If the model + // stalls / is filtered / is still loading, fall back to the gathered candidates so + // the screen never hangs. + onProgress(.stepStarted(name: "pick", description: "Picking the best")) + let candidateByUID = Dictionary( + (nowCands + nextCands).map { ($0.uid, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let tagged: [(cand: RNCandidate, bucket: RNBucket)] = + nowCands.map { ($0, .now) } + nextCands.map { ($0, .next) } + + let curated: Curated? = await runWithTimeout(seconds: Self.curationTimeoutSeconds) { [self] in + do { + return try await curate(tagged: tagged, vibe: context.vibe, taste: tasteProfile) + } catch { + #if DEBUG + print("[RightNow] curation failed: \(error)") + #endif + return nil + } + } + onProgress(.stepCompleted(name: "pick")) + + var used = Set() + let nowItems: [RightNowItem] + let nextItems: [RightNowItem] + let intro: String + if let curated, !(curated.now.isEmpty && curated.next.isEmpty) { + // Resolve picks back against the candidate set (drops any hallucinated uids). + nowItems = items(from: curated.now, byUID: candidateByUID, used: &used) + nextItems = items(from: curated.next, byUID: candidateByUID, used: &used) + intro = curated.intro.isEmpty ? "Here's what's around you." : curated.intro + } else { + // No AI picks (timed out / filtered / model unavailable) — show the candidates. + nowItems = fallbackItems(nowCands, used: &used) + nextItems = fallbackItems(nextCands, used: &used) + intro = "Here's what's around you right now." + } + + if nowItems.isEmpty && nextItems.isEmpty { + return RightNowResult(intro: "Nothing stood out — try a different vibe or area.", + now: [], next: []) + } + return RightNowResult(intro: intro, now: nowItems, next: nextItems) + } + + private static let curationTimeoutSeconds: Double = 22 + + /// Run the guarded LLM curation and map it to a Sendable `Curated`. + private func curate( + tagged: [(cand: RNCandidate, bucket: RNBucket)], + vibe: String, + taste: String + ) async throws -> Curated { + let response: GenerableRightNowResponse = try await withContextWindowRetry( + initialCount: min(tagged.count, 12), + minimumCount: 4 + ) { maxCount in + try await retryWithCandidateFiltering( + candidates: Array(tagged.prefix(maxCount)), + minimumCount: 2, + format: { candidateLine($0.cand) } + ) { batch in + try await self.generate(batch: batch, vibe: vibe, taste: taste) + } + } + return Curated( + intro: response.intro, + now: response.now.map { CuratedPick(uid: $0.uid, pitch: $0.pitch) }, + next: response.next.map { CuratedPick(uid: $0.uid, pitch: $0.pitch) } + ) + } + + /// Resolve LLM picks to display items, skipping unknown/duplicate uids. + private func items( + from picks: [CuratedPick], + byUID: [String: RNCandidate], + used: inout Set + ) -> [RightNowItem] { + picks.compactMap { pick in + guard let c = byUID[pick.uid], used.insert(pick.uid).inserted else { return nil } + return RightNowItem(uid: c.uid, name: c.name, type: c.type, pitch: pick.pitch, + walkMinutes: c.walkMinutes, timeInfo: c.timeInfo) + } + } + + /// Present gathered candidates directly (no AI pitch) when curation is unavailable. + private func fallbackItems( + _ candidates: [RNCandidate], + used: inout Set, + limit: Int = 5 + ) -> [RightNowItem] { + var out: [RightNowItem] = [] + for c in candidates where used.insert(c.uid).inserted { + out.append(RightNowItem(uid: c.uid, name: c.name, type: c.type, pitch: "", + walkMinutes: c.walkMinutes, timeInfo: c.timeInfo)) + if out.count >= limit { break } + } + return out + } + + private func generate( + batch: [(cand: RNCandidate, bucket: RNBucket)], + vibe: String, + taste: String + ) async throws -> GenerableRightNowResponse { + let nowText = batch.filter { $0.bucket == .now }.map { candidateLine($0.cand) }.joined(separator: "\n") + let nextText = batch.filter { $0.bucket == .next }.map { candidateLine($0.cand) }.joined(separator: "\n") + + var prompt = "Vibe: \(vibe.isEmpty ? "anything good" : vibe)\n" + if !taste.isEmpty { prompt += "\nUser tends to like:\n\(taste)\n" } + prompt += "\nNOW (happening or to go see near them):\n\(nowText.isEmpty ? "(none)" : nowText)" + prompt += "\n\nNEXT (coming up soon):\n\(nextText.isEmpty ? "(none)" : nextText)" + + let session = LanguageModelSession(instructions: """ + You are a concise Burning Man guide. From the candidates, pick the best few for NOW \ + and the best few for NEXT. Each candidate is tagged with its type (event, camp, art, \ + mutantVehicle) — describe it accurately and never call a camp or art piece an "event". \ + Use only the exact uids provided. Keep each pitch under 15 words, concrete and grounded \ + in the data — no hype, no invented details. + """) + return try await session.respond( + to: Prompt(prompt), + generating: GenerableRightNowResponse.self + ).content + } +} + +/// One-line candidate description for the LLM prompt. +func candidateLine(_ c: RNCandidate) -> String { + var s = "uid:\(c.uid) — \(c.type.rawValue): \(c.name)" + if let t = c.timeInfo { s += " @ \(t)" } + if let w = c.walkMinutes { s += " (~\(w)min walk)" } + return s +} + +#endif diff --git a/iBurn/AISearch/Workflows/ScheduleOptimizerWorkflow.swift b/iBurn/AISearch/Workflows/ScheduleOptimizerWorkflow.swift deleted file mode 100644 index 85245e6b..00000000 --- a/iBurn/AISearch/Workflows/ScheduleOptimizerWorkflow.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// ScheduleOptimizerWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Schedule Optimizer Result - -@available(iOS 26, *) -struct ScheduleOptimizerResult: Sendable { - let items: [DayPlanEntry] - let summary: String - let conflictsResolved: Int -} - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableConflictResolution { - @Guide(description: "UID of the event to keep") - var keepUID: String - @Guide(description: "Brief reason for keeping this one over the other") - var reason: String -} - -@available(iOS 26, *) -@Generable -struct GenerableScheduleNote { - @Guide(description: "Event name this note is for") - var eventName: String - @Guide(description: "Brief note about what to expect at this event") - var note: String -} - -@available(iOS 26, *) -@Generable -struct GenerableScheduleSummary { - @Guide(description: "One-sentence summary of the optimized schedule") - var summary: String - @Guide(description: "Notes about events", .count(1...10)) - var notes: [GenerableScheduleNote] -} - -// MARK: - Schedule Optimizer Workflow - -@available(iOS 26, *) -struct ScheduleOptimizerWorkflow: Workflow { - let name = "Schedule Optimizer" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> ScheduleOptimizerResult { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.timeZone = TimeZone(identifier: "America/Los_Angeles") - - // Step 1: Fetch all favorited events with occurrences - onProgress(.stepStarted(name: "favorites", description: "Loading your favorited events...")) - let favoriteEvents = try await context.playaDB.fetchFavoriteEvents() - onProgress(.stepCompleted(name: "favorites")) - - guard !favoriteEvents.isEmpty else { - return ScheduleOptimizerResult( - items: [], - summary: "No favorited events found. Favorite some events first!", - conflictsResolved: 0 - ) - } - - // Step 2: Detect time conflicts (pure Swift) - onProgress(.stepStarted(name: "conflicts", description: "Analyzing schedule conflicts...")) - let conflicts = detectConflicts(favoriteEvents) - onProgress(.stepCompleted(name: "conflicts")) - - // Step 3: Calculate walk times between consecutive events - onProgress(.stepStarted(name: "walkTimes", description: "Calculating walking routes...")) - let sortedEvents = favoriteEvents.sorted { $0.startDate < $1.startDate } - onProgress(.stepCompleted(name: "walkTimes")) - - // Step 4: Resolve conflicts via LLM - var removedUIDs = Set() - var conflictsResolved = 0 - - if !conflicts.isEmpty { - onProgress(.stepStarted(name: "resolve", description: "Resolving \(conflicts.count) conflict(s)...")) - - for (eventA, eventB) in conflicts { - // Skip if one was already removed - guard !removedUIDs.contains(eventA.event.uid) && !removedUIDs.contains(eventB.event.uid) else { continue } - - let timeA = formatter.string(from: eventA.startDate) - let timeB = formatter.string(from: eventB.startDate) - - let conflictPrompt = """ - Overlap: A) \(eventA.event.name) at \(timeA) (\(eventA.event.eventTypeLabel)) uid:\(eventA.event.uid) - B) \(eventB.event.name) at \(timeB) (\(eventB.event.eventTypeLabel)) uid:\(eventB.event.uid) - """ - - let session = LanguageModelSession(instructions: """ - Pick the more unique/time-sensitive event to keep. - """) - let resolution = try await session.respond( - to: Prompt(conflictPrompt), - generating: GenerableConflictResolution.self - ) - - let removeUID = resolution.content.keepUID == eventA.event.uid ? eventB.event.uid : eventA.event.uid - removedUIDs.insert(removeUID) - conflictsResolved += 1 - } - onProgress(.stepCompleted(name: "resolve")) - } - - // Build final schedule (filter out removed events) - let finalEvents = sortedEvents.filter { !removedUIDs.contains($0.event.uid) } - - // Calculate walk times for final schedule - var entries: [DayPlanEntry] = [] - var previousCoord: CLLocationCoordinate2D? = context.location?.coordinate - - for event in finalEvents { - let startTime = formatter.string(from: event.startDate) - let endTime = formatter.string(from: event.endDate) - - var walkMin: Int? = nil - if let prevCoord = previousCoord, - let lat = event.event.gpsLatitude, let lon = event.event.gpsLongitude { - let eventCoord = CLLocationCoordinate2D(latitude: lat, longitude: lon) - walkMin = playaWalkMinutes(from: prevCoord, to: eventCoord) - previousCoord = eventCoord - } - - entries.append(DayPlanEntry( - uid: event.event.uid, - name: event.event.name, - startTime: startTime, - endTime: endTime, - reason: "", - walkMinutesFromPrevious: walkMin - )) - } - - // Step 5: Generate summary via LLM - onProgress(.stepStarted(name: "summary", description: "Writing your optimized schedule...")) - let scheduleText = entries.map { "\($0.startTime): \($0.name)" }.joined(separator: "\n") - - let summarySession = LanguageModelSession(instructions: """ - Summarize an optimized Burning Man schedule. Note what to expect at each event. - """) - let summary = try await summarySession.respond( - to: Prompt("Schedule:\n\(scheduleText)\n\nConflicts resolved: \(conflictsResolved)"), - generating: GenerableScheduleSummary.self - ) - onProgress(.stepCompleted(name: "summary")) - - // Merge notes into entries by matching event name - let finalEntries = mergeNotesByName( - entries: entries, - notes: summary.content.notes.map { (name: $0.eventName, text: $0.note) }, - entryName: { $0.name }, - merge: { entry, note in - DayPlanEntry(uid: entry.uid, name: entry.name, startTime: entry.startTime, - endTime: entry.endTime, reason: note, - walkMinutesFromPrevious: entry.walkMinutesFromPrevious) - } - ) - - return ScheduleOptimizerResult( - items: finalEntries, - summary: summary.content.summary, - conflictsResolved: conflictsResolved - ) - } -} - -#endif diff --git a/iBurn/AISearch/Workflows/SerendipityWorkflow.swift b/iBurn/AISearch/Workflows/SerendipityWorkflow.swift deleted file mode 100644 index 1b935914..00000000 --- a/iBurn/AISearch/Workflows/SerendipityWorkflow.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// SerendipityWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import FoundationModels -@preconcurrency import PlayaDB - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableSerendipityPitch { - @Guide(description: "Object uid") - var uid: String - @Guide(description: "Creative pitch for why this unexpected item is worth visiting, under 15 words") - var pitch: String -} - -@available(iOS 26, *) -@Generable -struct GenerableSerendipityResponse { - @Guide(description: "Opening line about the unexpected discoveries") - var intro: String - @Guide(description: "Curated unexpected items with pitches", .count(3...5)) - var picks: [GenerableSerendipityPitch] -} - -// MARK: - Serendipity Workflow - -@available(iOS 26, *) -struct SerendipityWorkflow: Workflow { - /// If true, uses random sampling. If false, uses taste-based recommendations. - let deliberateRandom: Bool - let name = "Serendipity" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> DiscoveryResult { - // Step 1: Get favorites to understand taste - onProgress(.stepStarted(name: "taste", description: "Reading your vibe...")) - let favorites = try await context.playaDB.getFavorites() - let favoriteUIDs = Set(favorites.map(\.uid)) - - let tasteProfile: String - if favorites.isEmpty { - tasteProfile = "No favorites yet - recommend diverse items across all types." - } else { - tasteProfile = favorites.prefix(8).map { obj in - "\(obj.objectType.rawValue): \(obj.name)" - }.joined(separator: "\n") - } - onProgress(.stepCompleted(name: "taste")) - - // Step 2: Get random candidates (deliberate randomness for serendipity) - onProgress(.stepStarted(name: "discover", description: deliberateRandom ? "Rolling the dice..." : "Finding hidden gems...")) - - var candidates: [Any] = [] - - if deliberateRandom { - // Fetch all and sample randomly - let allArt = try await context.playaDB.fetchArt() - let allCamps = try await context.playaDB.fetchCamps() - let allMVs = try await context.playaDB.fetchMutantVehicles() - - var pool: [Any] = [] - pool.append(contentsOf: allArt.filter { !favoriteUIDs.contains($0.uid) }) - pool.append(contentsOf: allCamps.filter { !favoriteUIDs.contains($0.uid) }) - pool.append(contentsOf: allMVs.filter { !favoriteUIDs.contains($0.uid) }) - pool.shuffle() - candidates = Array(pool.prefix(15)) - } else { - // Use taste keywords to find non-favorited items - let keywords = extractKeywords(from: favorites) - for keyword in keywords.prefix(3) { - let results = try await context.playaDB.searchObjects(keyword) - let filtered = results.filter { !favoriteUIDs.contains($0.uid) } - candidates.append(contentsOf: filtered) - } - // Deduplicate - var seen = Set() - candidates = candidates.filter { obj in - guard let uid = objectUID(obj) else { return false } - return seen.insert(uid).inserted - } - candidates = Array(candidates.prefix(15)) - } - onProgress(.stepCompleted(name: "discover")) - - guard !candidates.isEmpty else { - return DiscoveryResult(items: [], intro: "The playa is quiet... try favoriting some items first.") - } - - // Step 3 & 4: LLM finds creative connections and generates pitches - onProgress(.stepStarted(name: "pitch", description: "Finding the magic connections...")) - let candidateText = candidates.map { obj in - formatObject(obj, detail: .brief) - }.joined(separator: "\n") - - let session = LanguageModelSession(instructions: """ - Whimsical Burning Man guide. Pick 3-5 surprising items and write playful pitches. - """) - - let prompt = """ - Taste: \(tasteProfile) - Candidates: - \(candidateText) - """ - - let response = try await session.respond( - to: Prompt(prompt), - generating: GenerableSerendipityResponse.self - ) - onProgress(.stepCompleted(name: "pitch")) - - // Resolve UIDs to objects - let items = await resolveDiscoveryItems( - picks: response.content.picks.map { (uid: $0.uid, pitch: $0.pitch) }, - playaDB: context.playaDB - ) - - return DiscoveryResult( - items: items, - intro: response.content.intro - ) - } - - - private func extractKeywords(from objects: [Any]) -> [String] { - var keywords: [String] = [] - for obj in objects.prefix(10) { - if let art = obj as? ArtObject, let cat = art.category { keywords.append(cat) } - if let event = obj as? EventObject { keywords.append(event.eventTypeLabel) } - if let mv = obj as? MutantVehicleObject, let tags = mv.tagsText { - keywords.append(contentsOf: tags.split(separator: " ").map(String.init)) - } - } - return Array(Set(keywords)).prefix(5).map { $0 } - } -} - -#endif diff --git a/iBurn/AISearch/Workflows/WhatDidIMissWorkflow.swift b/iBurn/AISearch/Workflows/WhatDidIMissWorkflow.swift deleted file mode 100644 index b852f92f..00000000 --- a/iBurn/AISearch/Workflows/WhatDidIMissWorkflow.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// WhatDidIMissWorkflow.swift -// iBurn -// -// Created by Claude Code on 4/5/26. -// Copyright © 2026 Burning Man Earth. All rights reserved. -// - -#if canImport(FoundationModels) -import Foundation -import CoreLocation -import MapKit -import FoundationModels -import GRDB -@preconcurrency import PlayaDB - -// MARK: - Generable Types - -@available(iOS 26, *) -@Generable -struct GenerableMissedItemPick { - @Guide(description: "Object uid") - var uid: String - @Guide(description: "Why this is worth going back for, under 15 words") - var pitch: String -} - -@available(iOS 26, *) -@Generable -struct GenerableMissedResponse { - @Guide(description: "Opening sentence about what was missed") - var intro: String - @Guide(description: "Most interesting missed items", .count(2...6)) - var picks: [GenerableMissedItemPick] -} - -// MARK: - What Did I Miss Workflow - -@available(iOS 26, *) -struct WhatDidIMissWorkflow: Workflow { - let name = "What Did I Miss" - - func execute(context: WorkflowContext, onProgress: @escaping (WorkflowProgress) -> Void) async throws -> DiscoveryResult { - // Step 1: Fetch breadcrumbs from last 24h - onProgress(.stepStarted(name: "tracks", description: "Loading your location history...")) - guard let storage = LocationStorage.shared else { - return DiscoveryResult(items: [], intro: "Location history is not available.") - } - - let since = context.date.addingTimeInterval(-24 * 3600) - let breadcrumbs: [Breadcrumb] = try await storage.dbQueue.read { db in - try Breadcrumb - .filter(Column("timestamp") >= since) - .order(Column("timestamp").asc) - .fetchAll(db) - } - - guard !breadcrumbs.isEmpty else { - return DiscoveryResult(items: [], intro: "No location history found in the last 24 hours.") - } - onProgress(.stepCompleted(name: "tracks")) - - // Step 2: Cluster breadcrumbs by distance - onProgress(.stepStarted(name: "cluster", description: "Analyzing your path...")) - let coords = breadcrumbs.map { (lat: $0.coordinate.latitude, lon: $0.coordinate.longitude, timestamp: $0.timestamp) } - let clusters = clusterCoordinates(coords, thresholdMeters: 200) - onProgress(.stepCompleted(name: "cluster")) - - // Step 3: For each cluster centroid, find nearby objects - onProgress(.stepStarted(name: "nearby", description: "Finding things near your path...")) - var nearbyObjects: [Any] = [] - var seenUIDs = Set() - - for cluster in clusters.prefix(8) { - guard let first = cluster.first else { continue } - let center = CLLocationCoordinate2D(latitude: first.lat, longitude: first.lon) - let region = MKCoordinateRegion( - center: center, - span: MKCoordinateSpan(latitudeDelta: 0.003, longitudeDelta: 0.003) - ) - let objects = try await context.playaDB.fetchObjects(in: region) - for obj in objects where seenUIDs.insert(obj.uid).inserted { - nearbyObjects.append(obj) - } - } - onProgress(.stepCompleted(name: "nearby")) - - // Step 4: Filter out favorited/viewed objects - onProgress(.stepStarted(name: "filter", description: "Filtering what you already know...")) - let favorites = try await context.playaDB.getFavorites() - let favoriteUIDs = Set(favorites.map(\.uid)) - let recentlyViewed = try await context.playaDB.fetchRecentlyViewed(limit: 50) - let viewedUIDs = Set(recentlyViewed.map(\.uid)) - - let missedObjects = nearbyObjects.filter { obj in - guard let uid = objectUID(obj) else { return false } - return !favoriteUIDs.contains(uid) && !viewedUIDs.contains(uid) - } - onProgress(.stepCompleted(name: "filter")) - - guard !missedObjects.isEmpty else { - return DiscoveryResult(items: [], intro: "You've been thorough! Nothing notable was missed nearby.") - } - - // Step 5: LLM curates the most interesting missed items - onProgress(.stepStarted(name: "curate", description: "Finding hidden gems you walked past...")) - let candidateText = missedObjects.prefix(15).map { obj in - formatObject(obj, detail: .brief) - }.joined(separator: "\n") - - let session = LanguageModelSession(instructions: """ - Pick the most interesting missed items. Write compelling reasons to go back. - """) - - let response = try await session.respond( - to: Prompt("Items near the user's path that they didn't visit:\n\(candidateText)"), - generating: GenerableMissedResponse.self - ) - onProgress(.stepCompleted(name: "curate")) - - // Resolve UIDs - let items = await resolveDiscoveryItems( - picks: response.content.picks.map { (uid: $0.uid, pitch: $0.pitch) }, - playaDB: context.playaDB - ) - - return DiscoveryResult( - items: items, - intro: response.content.intro - ) - } - -} - -#endif diff --git a/iBurn/AISearch/Workflows/WorkflowProtocol.swift b/iBurn/AISearch/Workflows/WorkflowProtocol.swift index 8c2b56c6..83da71f8 100644 --- a/iBurn/AISearch/Workflows/WorkflowProtocol.swift +++ b/iBurn/AISearch/Workflows/WorkflowProtocol.swift @@ -9,6 +9,8 @@ #if canImport(FoundationModels) import Foundation import CoreLocation +import MapKit +import FoundationModels @preconcurrency import PlayaDB // Note: iBurn has a class named DataObject that shadows PlayaDB's DataObject protocol. @@ -35,11 +37,36 @@ struct WorkflowContext { let date: Date var conversationHistory: [String] - init(playaDB: PlayaDB, location: CLLocation? = nil, date: Date = Date(), conversationHistory: [String] = []) { + /// Optional map-selected area to scope discovery. When nil, fall back to `location`. + let region: MKCoordinateRegion? + /// Time window for events ("now or soon" by default). + let windowStart: Date + let windowEnd: Date + /// Free-text or chip-derived vibe ("" = none). + let vibe: String + /// Personalized vs surprise lean. + let lean: DiscoveryLean + + init( + playaDB: PlayaDB, + location: CLLocation? = nil, + date: Date = Date(), + conversationHistory: [String] = [], + region: MKCoordinateRegion? = nil, + windowStart: Date? = nil, + windowEnd: Date? = nil, + vibe: String = "", + lean: DiscoveryLean = .balanced + ) { self.playaDB = playaDB self.location = location self.date = date self.conversationHistory = conversationHistory + self.region = region + self.windowStart = windowStart ?? date + self.windowEnd = windowEnd ?? date.addingTimeInterval(2 * 3600) + self.vibe = vibe + self.lean = lean } } @@ -52,60 +79,10 @@ enum WorkflowProgress: Sendable { case intermediateResult(text: String) } -// MARK: - Workflow Result Types - -/// Result from adventure/crawl workflows that include a route -struct RouteResult: Sendable { - let stops: [RouteStop] - let narrative: String - let totalWalkMinutes: Int -} - -struct RouteStop: Sendable, Identifiable { - let id: String // uid - let name: String - let type: DataObjectType - let reason: String - let walkMinutesFromPrevious: Int? - let latitude: Double? - let longitude: Double? -} - -/// Result from schedule optimization -struct OptimizedSchedule: Sendable { - let items: [ScheduleEntry] - let summary: String - let conflictsResolved: Int -} - -struct ScheduleEntry: Sendable, Identifiable { - var id: String { uid } - let uid: String - let name: String - let startTime: String - let endTime: String - let reason: String - let walkMinutesFromPrevious: Int? -} - -/// Result from serendipity/recommendation workflows -struct DiscoveryResult: Sendable { - let items: [DiscoveryItem] - let intro: String -} - -struct DiscoveryItem: Sendable, Identifiable { - var id: String { uid } - let uid: String - let name: String - let type: DataObjectType - let pitch: String -} - // MARK: - Utility: Distance Calculation -/// Calculate walking time between two coordinates on the playa -/// Assumes ~4 km/h walking speed on playa dust +/// Calculate walking time between two coordinates on the playa. +/// Assumes ~4 km/h walking speed on playa dust. func playaWalkMinutes(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> Int { let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude) let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude) @@ -113,68 +90,9 @@ func playaWalkMinutes(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) return Int(ceil(meters / 67.0)) // ~4 km/h } -/// Simple nearest-neighbor route optimizer -func optimizeRoute(from start: CLLocationCoordinate2D?, stops: [(uid: String, coord: CLLocationCoordinate2D)]) -> [(uid: String, coord: CLLocationCoordinate2D)] { - guard stops.count > 1 else { return stops } - - var remaining = stops - var ordered: [(uid: String, coord: CLLocationCoordinate2D)] = [] - var current = start ?? stops.first!.coord +// MARK: - Utility: Object Field Access (DataObject name-conflict workaround) - while !remaining.isEmpty { - let nearest = remaining.enumerated().min(by: { a, b in - let distA = CLLocation(latitude: current.latitude, longitude: current.longitude) - .distance(from: CLLocation(latitude: a.element.coord.latitude, longitude: a.element.coord.longitude)) - let distB = CLLocation(latitude: current.latitude, longitude: current.longitude) - .distance(from: CLLocation(latitude: b.element.coord.latitude, longitude: b.element.coord.longitude)) - return distA < distB - })! - ordered.append(remaining.remove(at: nearest.offset)) - current = ordered.last!.coord - } - return ordered -} - -/// Detect time conflicts between event occurrences -func detectConflicts(_ events: [EventObjectOccurrence]) -> [(EventObjectOccurrence, EventObjectOccurrence)] { - var conflicts: [(EventObjectOccurrence, EventObjectOccurrence)] = [] - let sorted = events.sorted { $0.startDate < $1.startDate } - for i in 0.. [[(lat: Double, lon: Double, timestamp: Date)]] { - var clusters: [[(lat: Double, lon: Double, timestamp: Date)]] = [] - for point in coords { - var added = false - for i in 0.. String? { if let art = obj as? ArtObject { return art.uid } if let camp = obj as? CampObject { return camp.uid } @@ -183,7 +101,7 @@ func objectUID(_ obj: Any) -> String? { return nil } -/// Extract name from any PlayaDB object +/// Extract name from any PlayaDB object. func objectName(_ obj: Any) -> String? { if let art = obj as? ArtObject { return art.name } if let camp = obj as? CampObject { return camp.name } @@ -192,36 +110,7 @@ func objectName(_ obj: Any) -> String? { return nil } -/// Extract taste keywords from a list of favorited objects -func extractTasteKeywords(_ objects: [Any]) -> [String] { - var keywords: [String] = [] - for obj in objects.prefix(10) { - if let art = obj as? ArtObject, let cat = art.category { keywords.append(cat) } - if let event = obj as? EventObject { keywords.append(event.eventTypeLabel) } - if let mv = obj as? MutantVehicleObject, let tags = mv.tagsText { - keywords.append(contentsOf: tags.split(separator: " ").map(String.init)) - } - } - return Array(Set(keywords)).prefix(5).map { $0 } -} - -/// Calculate sunrise/sunset for Black Rock City coordinates -/// Returns (sunrise, sunset) as Dates for the given date -func brcSunTimes(for date: Date) -> (sunrise: Date, sunset: Date) { - // BRC coordinates: 40.7864, -119.2065 - // Approximate solar times for late August at BRC: - // Sunrise ~6:15 AM, Sunset ~7:30 PM PDT - // This is a simplified calculation; for production, use a proper solar algorithm - let calendar = Calendar.current - var components = calendar.dateComponents(in: TimeZone(identifier: "America/Los_Angeles")!, from: date) - components.hour = 6 - components.minute = 15 - let sunrise = calendar.date(from: components)! - components.hour = 19 - components.minute = 30 - let sunset = calendar.date(from: components)! - return (sunrise, sunset) -} +// MARK: - Retry: Candidate Filtering /// Retry an LLM generation call with progressive candidate filtering. /// On failure with the full set, tries each half, then individual items to isolate problematic ones. diff --git a/iBurn/AISearch/Workflows/WorkflowUtilities.swift b/iBurn/AISearch/Workflows/WorkflowUtilities.swift index 37da7740..281297b8 100644 --- a/iBurn/AISearch/Workflows/WorkflowUtilities.swift +++ b/iBurn/AISearch/Workflows/WorkflowUtilities.swift @@ -2,8 +2,8 @@ // WorkflowUtilities.swift // iBurn // -// Shared utilities for AI workflows to eliminate duplication across -// route building, object resolution, candidate formatting, and note merging. +// Shared utilities for AI workflows: context-window/guardrail retry helpers and the +// camp/art detail-page event-collection summary pipeline (used by DetailViewModel). // #if canImport(FoundationModels) @@ -12,197 +12,332 @@ import CoreLocation import FoundationModels @preconcurrency import PlayaDB -// MARK: - Object Resolution - -/// Resolved metadata for any PlayaDB object — common fields needed by workflows. -struct ResolvedObject { - let uid: String - let name: String - let type: DataObjectType - let latitude: Double? - let longitude: Double? +// MARK: - Context Window Management - var coordinate: CLLocationCoordinate2D? { - guard let lat = latitude, let lon = longitude else { return nil } - return CLLocationCoordinate2D(latitude: lat, longitude: lon) - } +/// Check if an error is a context window overflow. +@available(iOS 26, *) +func isContextWindowError(_ error: Error) -> Bool { + if case LanguageModelSession.GenerationError.exceededContextWindowSize = error { return true } + return false } -/// Resolve a single UID to its object metadata by trying each type. -func resolveObject(uid: String, playaDB: PlayaDB) async -> ResolvedObject? { - if let art = try? await playaDB.fetchArt(uid: uid) { - return ResolvedObject(uid: uid, name: art.name, type: .art, latitude: art.gpsLatitude, longitude: art.gpsLongitude) - } else if let camp = try? await playaDB.fetchCamp(uid: uid) { - return ResolvedObject(uid: uid, name: camp.name, type: .camp, latitude: camp.gpsLatitude, longitude: camp.gpsLongitude) - } else if let event = try? await playaDB.fetchEvent(uid: uid) { - return ResolvedObject(uid: uid, name: event.name, type: .event, latitude: event.gpsLatitude, longitude: event.gpsLongitude) - } else if let mv = try? await playaDB.fetchMutantVehicle(uid: uid) { - return ResolvedObject(uid: uid, name: mv.name, type: .mutantVehicle, latitude: nil, longitude: nil) - } - return nil +/// Check if an error is a guardrail violation. +@available(iOS 26, *) +func isGuardrailError(_ error: Error) -> Bool { + if case LanguageModelSession.GenerationError.guardrailViolation = error { return true } + return false } -/// Batch resolve UIDs to objects. -func resolveObjects(uids: [String], playaDB: PlayaDB) async -> [String: ResolvedObject] { - var result: [String: ResolvedObject] = [:] - for uid in uids { - if let obj = await resolveObject(uid: uid, playaDB: playaDB) { - result[uid] = obj +/// Execute an LLM call, automatically reducing candidate count on context overflow. +/// The `attempt` closure receives the max candidate count to use. +/// Starts at `initialCount` and halves on each overflow, down to `minimumCount`. +@available(iOS 26, *) +func withContextWindowRetry( + initialCount: Int = 20, + minimumCount: Int = 5, + attempt: (_ maxCandidates: Int) async throws -> R +) async throws -> R { + var count = initialCount + while count >= minimumCount { + do { + return try await attempt(count) + } catch let error where isContextWindowError(error) { + let newCount = max(minimumCount, count / 2) + print("Context window exceeded with \(count) candidates, retrying with \(newCount)") + if newCount == count { throw error } // Already at minimum + count = newCount } } - return result + return try await attempt(minimumCount) } -/// Resolve LLM picks (uid + pitch) into DiscoveryItems. -/// Works with any type that has `uid` and `pitch` string properties. -func resolveDiscoveryItems(picks: [(uid: String, pitch: String)], playaDB: PlayaDB) async -> [DiscoveryItem] { - var result: [DiscoveryItem] = [] - for pick in picks { - if let obj = await resolveObject(uid: pick.uid, playaDB: playaDB) { - result.append(DiscoveryItem(uid: pick.uid, name: obj.name, type: obj.type, pitch: pick.pitch)) +// MARK: - Schedule Tip Generator (Pure Swift — No LLM) + +/// Build factual schedule tips from actual event occurrence data. +/// Groups occurrences by event name (merges duplicate EventObjects), detects recurrence, +/// sorts by day-of-week, marks expired events. Returns up to 5 ScheduleTips. +func buildScheduleTips(from events: [EventObjectOccurrence]) -> [ScheduleTip] { + guard !events.isEmpty else { return [] } + + let now = Date() + + // Formatters in BRC timezone + var brcCalendar = Calendar(identifier: .gregorian) + brcCalendar.timeZone = TimeZone.burningManTimeZone + + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "EEE" + dayFormatter.timeZone = TimeZone.burningManTimeZone + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mma" + timeFormatter.timeZone = TimeZone.burningManTimeZone + timeFormatter.amSymbol = "am" + timeFormatter.pmSymbol = "pm" + + func shortTime(_ date: Date) -> String { + let minute = brcCalendar.component(.minute, from: date) + if minute == 0 { + let hourFormatter = DateFormatter() + hourFormatter.dateFormat = "ha" + hourFormatter.timeZone = TimeZone.burningManTimeZone + hourFormatter.amSymbol = "am" + hourFormatter.pmSymbol = "pm" + return hourFormatter.string(from: date) } + return timeFormatter.string(from: date) } - return result -} -// MARK: - Route Building + // Group by event NAME to merge duplicate EventObject records + struct OccurrenceInfo { + let firstEventUID: String // for navigation + let typeEmoji: String + let typeName: String + var occurrences: [(day: String, startTime: String, endTime: String, startDate: Date, endDate: Date)] + } -/// Built route with stops and total walking time. -struct BuiltRoute { - let stops: [RouteStop] - let totalWalkMinutes: Int -} + var grouped: [String: OccurrenceInfo] = [:] + var order: [String] = [] -/// Build an optimized walking route from selected UIDs with reasons/tips. -/// Resolves coordinates, optimizes order, and calculates walk times. -func buildRoute( - selections: [(uid: String, reason: String, typeOverride: DataObjectType?)], - startLocation: CLLocationCoordinate2D?, - playaDB: PlayaDB -) async -> BuiltRoute { - let resolved = await resolveObjects(uids: selections.map(\.uid), playaDB: playaDB) - let reasonMap = Dictionary(selections.map { ($0.uid, $0.reason) }, uniquingKeysWith: { first, _ in first }) - let typeOverrides = Dictionary(selections.compactMap { s -> (String, DataObjectType)? in - guard let t = s.typeOverride else { return nil } - return (s.uid, t) - }, uniquingKeysWith: { first, _ in first }) - - // Collect stops with coordinates for route optimization - var stopsWithCoords: [(uid: String, coord: CLLocationCoordinate2D)] = [] - for sel in selections { - if let obj = resolved[sel.uid], let coord = obj.coordinate { - stopsWithCoords.append((uid: sel.uid, coord: coord)) + for event in events { + let key = event.name + if grouped[key] == nil { + grouped[key] = OccurrenceInfo( + firstEventUID: event.event.uid, + typeEmoji: EventTypeInfo.emoji(for: event.eventTypeCode), + typeName: EventTypeInfo.displayName(for: event.eventTypeCode), + occurrences: [] + ) + order.append(key) } + let day = dayFormatter.string(from: event.startDate) + let start = shortTime(event.startDate) + let end = shortTime(event.endDate) + grouped[key]?.occurrences.append((day: day, startTime: start, endTime: end, startDate: event.startDate, endDate: event.endDate)) } - // Optimize route order, append items without coordinates at the end - let optimized = optimizeRoute(from: startLocation, stops: stopsWithCoords) - let optimizedUIDs = optimized.map(\.uid) + selections.map(\.uid).filter { uid in - !optimized.contains(where: { $0.uid == uid }) + // Deduplicate identical occurrences within each group (same day + same time range) + for key in order { + guard var info = grouped[key] else { continue } + var seen = Set() + info.occurrences = info.occurrences.filter { occ in + let fingerprint = "\(occ.day)|\(occ.startTime)|\(occ.endTime)" + return seen.insert(fingerprint).inserted + } + grouped[key] = info } - // Build stops with walk times - var totalWalkMinutes = 0 - var routeStops: [RouteStop] = [] - var previousCoord: CLLocationCoordinate2D? = startLocation + // Sort by earliest occurrence's day-of-week (Sun=1 → Sat=7) + let sorted = order.sorted { a, b in + let startA = grouped[a]?.occurrences.first?.startDate ?? .distantFuture + let startB = grouped[b]?.occurrences.first?.startDate ?? .distantFuture + return startA < startB + } - for uid in optimizedUIDs { - let obj = resolved[uid] - let coord = optimized.first(where: { $0.uid == uid })?.coord + // Build tips + var tips: [ScheduleTip] = [] + for name in sorted.prefix(5) { + guard let info = grouped[name] else { continue } - var walkMin: Int? = nil - if let prev = previousCoord, let curr = coord { - walkMin = playaWalkMinutes(from: prev, to: curr) - totalWalkMinutes += walkMin ?? 0 - previousCoord = curr + let schedule: String + if info.occurrences.count == 1 { + let occ = info.occurrences[0] + schedule = "\(occ.day) \(occ.startTime)-\(occ.endTime)" + } else { + let timeRanges = Set(info.occurrences.map { "\($0.startTime)-\($0.endTime)" }) + if timeRanges.count == 1, let timeRange = timeRanges.first { + let days = info.occurrences.map(\.day).joined(separator: "/") + schedule = "\(days) \(timeRange)" + } else { + let parts = info.occurrences.map { "\($0.day) \($0.startTime)-\($0.endTime)" } + schedule = parts.joined(separator: ", ") + } } - routeStops.append(RouteStop( - id: uid, - name: obj?.name ?? uid, - type: typeOverrides[uid] ?? obj?.type ?? .art, - reason: reasonMap[uid] ?? "", - walkMinutesFromPrevious: walkMin, - latitude: coord?.latitude, - longitude: coord?.longitude + let allExpired = info.occurrences.allSatisfy { $0.endDate < now } + let earliest = info.occurrences.map(\.startDate).min() ?? .distantFuture + + tips.append(ScheduleTip( + text: "\(name) (\(info.typeEmoji) \(info.typeName)) — \(schedule)", + eventUID: info.firstEventUID, + isExpired: allExpired, + earliestStart: earliest )) } - return BuiltRoute(stops: routeStops, totalWalkMinutes: totalWalkMinutes) + return tips } -// MARK: - Numeric Candidate Formatting - -/// Format candidates as a numbered list and build an ID map for token-efficient LLM prompts. -/// Returns the formatted text and a map from 1-based numbers back to objects. -func buildNumberedList( - candidates: [T], - maxCount: Int = 18, - format: (Int, T) -> String -) -> (text: String, idMap: [Int: T]) { - let slice = Array(candidates.prefix(maxCount)) - let idMap = Dictionary(uniqueKeysWithValues: slice.enumerated().map { ($0.offset + 1, $0.element) }) - let text = slice.enumerated().map { idx, item in - format(idx + 1, item) - }.joined(separator: "\n") - return (text, idMap) +// MARK: - Event Collection Summary + +/// Generate schedule tips (pure Swift) and an AI overview (LLM) for a host's events. +/// Tips are always factual. The LLM overview may fail — tips alone are returned in that case. +@available(iOS 26, *) +func generateEventCollectionSummary( + events: [EventObjectOccurrence], + hostName: String, + hostUID: String, + hostDescription: String? = nil +) async -> EventSummaryContent? { + guard !events.isEmpty else { return nil } + + // Check cache first + if let cached = await EventSummaryCache.shared.get(hostUID) { + return cached + } + + // Step 1: Build factual tips from real data (instant, no LLM) + let tips = buildScheduleTips(from: events) + + // Step 2: Generate overview via LLM (may fail — that's OK) + let overview = await generateEventOverview(events: events, hostName: hostName, hostDescription: hostDescription) + + // Only return content if we have something to show + guard overview != nil || !tips.isEmpty else { return nil } + + let content = EventSummaryContent(summary: overview, tips: tips) + await EventSummaryCache.shared.set(hostUID, content: content) + return content } -// MARK: - Context Window Management +// MARK: - Source Data Assembly -/// Check if an error is a context window overflow. -@available(iOS 26, *) -func isContextWindowError(_ error: Error) -> Bool { - if case LanguageModelSession.GenerationError.exceededContextWindowSize = error { return true } - return false +/// Build a ground-truth string from event data + host description for fact-checking. +private func buildSourceDataString( + events: [EventObjectOccurrence], + hostName: String, + hostDescription: String? +) -> String { + var parts: [String] = [] + if let desc = hostDescription, !desc.isEmpty { + parts.append("Camp description: \(desc)") + } + parts.append("Events:") + for event in events.prefix(20) { + let type = EventTypeInfo.displayName(for: event.eventTypeCode) + let desc = event.description.map { " - \($0)" } ?? "" + parts.append(" \(event.name) [\(type)]\(desc)") + } + return parts.joined(separator: "\n") } -/// Check if an error is a guardrail violation. -@available(iOS 26, *) -func isGuardrailError(_ error: Error) -> Bool { - if case LanguageModelSession.GenerationError.guardrailViolation = error { return true } - return false +/// Clean up text after phrase removal: fix double spaces, dangling punctuation. +private func cleanStrippedText(_ text: String) -> String? { + var result = text + // Remove double+ spaces + while result.contains(" ") { + result = result.replacingOccurrences(of: " ", with: " ") + } + // Remove dangling ", and" / ", all" patterns left after stripping + result = result.replacingOccurrences(of: ", and ,", with: ",") + result = result.replacingOccurrences(of: ", ,", with: ",") + result = result.replacingOccurrences(of: ",,", with: ",") + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + // Remove trailing comma + if result.hasSuffix(",") { result = String(result.dropLast()).trimmingCharacters(in: .whitespaces) } + // Too short after stripping = not useful + if result.count < 20 { return nil } + return result } -/// Execute an LLM call, automatically reducing candidate count on context overflow. -/// The `attempt` closure receives the max candidate count to use. -/// Starts at `initialCount` and halves on each overflow, down to `minimumCount`. +// MARK: - Validation Pipeline + +/// Validate LLM-generated overview against source data. +/// Uses withContextWindowRetry for context overflow and retries on guardrail errors. +/// Returns cleaned text with unsupported claims removed, or nil on failure. @available(iOS 26, *) -func withContextWindowRetry( - initialCount: Int = 20, - minimumCount: Int = 5, - attempt: (_ maxCandidates: Int) async throws -> R -) async throws -> R { - var count = initialCount - while count >= minimumCount { - do { - return try await attempt(count) - } catch let error where isContextWindowError(error) { - let newCount = max(minimumCount, count / 2) - print("Context window exceeded with \(count) candidates, retrying with \(newCount)") - if newCount == count { throw error } // Already at minimum - count = newCount +private func validateOverview(overview: String, sourceData: String) async -> String? { + do { + let factCheck: GenerableFactCheck = try await withContextWindowRetry( + initialCount: 1, // single item, but sourceData may overflow + minimumCount: 1 + ) { _ in + let session = LanguageModelSession(instructions: """ + You are a fact-checker. Compare the summary against the source data below. \ + List any specific phrases or claims in the summary that are NOT directly \ + supported by the source data. Only flag fabricated or embellished details, \ + not reasonable inferences from the data. + """) + return try await session.respond( + to: Prompt("Summary to check:\n\(overview)\n\nSource data:\n\(sourceData)"), + generating: GenerableFactCheck.self + ).content } + + if factCheck.unsupportedClaims.isEmpty { + return overview // Passed validation + } + + // Strip flagged phrases + var cleaned = overview + for claim in factCheck.unsupportedClaims { + cleaned = cleaned.replacingOccurrences(of: claim, with: "") + } + return cleanStrippedText(cleaned) + } catch { + print("Validation failed, discarding unverified overview: \(error)") + return nil } - return try await attempt(minimumCount) } -// MARK: - Note Merging - -/// Merge LLM-generated notes into entries by matching on lowercased name. -func mergeNotesByName( - entries: [Entry], - notes: [(name: String, text: String)], - entryName: (Entry) -> String, - merge: (Entry, String) -> Entry -) -> [Entry] { - let noteMap = Dictionary(notes.map { ($0.name.lowercased(), $0.text) }, uniquingKeysWith: { first, _ in first }) - return entries.map { entry in - if let note = noteMap[entryName(entry).lowercased()] { - return merge(entry, note) +// MARK: - Overview Generation (Generate → Validate → Strip) + +/// LLM-generated overview with two-pass validation. +/// Each step (generation, validation) handles its own retries via +/// withContextWindowRetry / retryWithCandidateFiltering. +@available(iOS 26, *) +private func generateEventOverview( + events: [EventObjectOccurrence], + hostName: String, + hostDescription: String? = nil +) async -> String? { + // Pass 1: Generate (retries handled by withContextWindowRetry + retryWithCandidateFiltering) + let rawOverview: String? + do { + rawOverview = try await withContextWindowRetry( + initialCount: min(events.count, 20), + minimumCount: 2 + ) { maxCount in + let slice = Array(events.prefix(maxCount)) + + let result: GenerableEventCollectionSummary = try await retryWithCandidateFiltering( + candidates: slice, + minimumCount: 2, + format: { $0.name } + ) { batch in + let text = batch.enumerated().map { idx, event in + let type = EventTypeInfo.displayName(for: event.eventTypeCode) + let desc = event.description.map { String($0.prefix(120)) } ?? "" + return "\(idx + 1). \(event.name) [\(type)]\(desc.isEmpty ? "" : " - \(desc)")" + }.joined(separator: "\n") + + var prompt = "Events hosted by \(hostName):\n\(text)" + if let hostDesc = hostDescription, !hostDesc.isEmpty { + prompt += "\n\nCamp description: \(String(hostDesc.prefix(200)))" + } + + let session = LanguageModelSession(instructions: """ + Summarize what \(hostName) offers in 1-2 short sentences. \ + Only reference details from the provided data. Do NOT infer \ + or assume anything not explicitly stated. No times or schedules. + """) + return try await session.respond( + to: Prompt(prompt), + generating: GenerableEventCollectionSummary.self + ).content + } + return result.summary } - return entry + } catch { + print("Event overview generation failed: \(error)") + rawOverview = nil } + + guard let rawOverview else { return nil } + + // Pass 2: Validate (retries handled by withContextWindowRetry inside validateOverview) + let sourceData = buildSourceDataString(events: events, hostName: hostName, hostDescription: hostDescription) + return await validateOverview(overview: rawOverview, sourceData: sourceData) } #endif diff --git a/iBurn/DependencyContainer.swift b/iBurn/DependencyContainer.swift index 575616ac..ba81ed3e 100644 --- a/iBurn/DependencyContainer.swift +++ b/iBurn/DependencyContainer.swift @@ -61,11 +61,6 @@ class DependencyContainer { AISearchServiceFactory.create(playaDB: playaDB) }() - /// AI assistant service for recommendations, day planner, nearby (nil if unavailable) - private(set) lazy var aiAssistantService: AIAssistantService? = { - AISearchServiceFactory.createAssistant(playaDB: playaDB) - }() - // MARK: - Initialization /// Initialize the dependency container @@ -180,31 +175,32 @@ class DependencyContainer { ) } - /// Create an AIGuideViewModel for the unified AI Guide (nil if AI not available) + /// Create the AI Guide "Right Now" view model (nil if AI not available) func makeAIGuideViewModel() -> AnyObject? { #if canImport(FoundationModels) if #available(iOS 26, *) { let orchestrator = AgentOrchestrator(playaDB: playaDB, locationProvider: locationProvider) guard orchestrator.isAvailable else { return nil } - return AIGuideViewModel(playaDB: playaDB, orchestrator: orchestrator) + return RightNowViewModel(playaDB: playaDB, orchestrator: orchestrator) } #endif return nil } - /// Create an AIAssistantViewModel (nil if AI not available) — legacy, kept for backward compat - func makeAIAssistantViewModel() -> AIAssistantViewModel? { - guard let aiService = aiAssistantService else { return nil } - return AIAssistantViewModel( - aiService: aiService, + /// Create a NearbyViewModel with injected dependencies + func makeNearbyViewModel() -> NearbyViewModel { + NearbyViewModel( playaDB: playaDB, + artProvider: artDataProvider, + campProvider: campDataProvider, + eventProvider: eventDataProvider, locationProvider: locationProvider ) } - /// Create a NearbyViewModel with injected dependencies - func makeNearbyViewModel() -> NearbyViewModel { - NearbyViewModel( + /// Create a NearbyCardViewModel for the on-map nearby card overlay + func makeNearbyCardViewModel() -> NearbyCardViewModel { + NearbyCardViewModel( playaDB: playaDB, artProvider: artDataProvider, campProvider: campDataProvider, diff --git a/iBurn/Detail/Controllers/DetailHostingController.swift b/iBurn/Detail/Controllers/DetailHostingController.swift index 45c31b81..77b068fa 100644 --- a/iBurn/Detail/Controllers/DetailHostingController.swift +++ b/iBurn/Detail/Controllers/DetailHostingController.swift @@ -64,7 +64,6 @@ class DetailHostingController: UIHostingController, DynamicViewContr super.init(rootView: DetailView(viewModel: viewModel)) self.title = titleText - self.hidesBottomBarWhenPushed = true } @MainActor required dynamic init?(coder aDecoder: NSCoder) { diff --git a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift index fdc3b59a..910a8b59 100644 --- a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift +++ b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift @@ -6,11 +6,12 @@ import PlayaDB /// Used for the "See all N events" tap from the PlayaDB event detail screen. @MainActor class PlayaHostedEventsViewController: UIHostingController { - init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB) { + init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB, eventSummary: EventSummaryContent? = nil) { let view = PlayaHostedEventsView( events: events, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: eventSummary ) super.init(rootView: view) self.title = "Events - \(hostName)" @@ -25,26 +26,40 @@ struct PlayaHostedEventsView: View { let events: [EventObjectOccurrence] let hostName: String let playaDB: PlayaDB + let eventSummary: EventSummaryContent? @Environment(\.themeColors) var themeColors @State private var favoriteIDs: Set = [] @State private var now = Date() var body: some View { - List(events, id: \.uid) { event in - ObjectRowView( - object: event, - rightSubtitle: event.timeDescription(now: now), - isFavorite: favoriteIDs.contains(event.uid), - onFavoriteTap: { - Task { await toggleFavorite(event) } + List { + // AI Summary as first scrollable row + if let eventSummary { + EventSummaryHeaderView(content: eventSummary, isLoading: false) { tip in + // Navigate to the tapped event + if let occ = events.first(where: { $0.event.uid == tip.eventUID }) { + pushDetail(for: occ) + } } - ) { _ in - Text(EventTypeInfo.emoji(for: event.eventTypeCode)) - .font(.subheadline) + .listRowBackground(themeColors.backgroundColor) + } + + ForEach(events, id: \.uid) { event in + ObjectRowView( + object: event, + rightSubtitle: event.timeDescription(now: now), + isFavorite: favoriteIDs.contains(event.uid), + onFavoriteTap: { + Task { await toggleFavorite(event) } + } + ) { _ in + Text(EventTypeInfo.emoji(for: event.eventTypeCode)) + .font(.subheadline) + } + .contentShape(Rectangle()) + .onTapGesture { pushDetail(for: event) } + .listRowBackground(themeColors.backgroundColor) } - .contentShape(Rectangle()) - .onTapGesture { pushDetail(for: event) } - .listRowBackground(themeColors.backgroundColor) } .listStyle(.plain) .task { await loadFavorites() } @@ -83,4 +98,3 @@ struct PlayaHostedEventsView: View { navController.pushViewController(detailVC, animated: true) } } - diff --git a/iBurn/Detail/Models/DetailCellType.swift b/iBurn/Detail/Models/DetailCellType.swift index 56407119..20c02689 100644 --- a/iBurn/Detail/Models/DetailCellType.swift +++ b/iBurn/Detail/Models/DetailCellType.swift @@ -39,6 +39,8 @@ enum DetailCellType { case eventRelationship(count: Int, hostName: String, onTap: (() -> Void)?) case nextHostEvent(title: String, scheduleText: String, hostName: String, onTap: (() -> Void)?) case allHostEvents(count: Int, hostName: String, onTap: (() -> Void)?) + case eventSummaryLoading(hostName: String) + case eventSummary(EventSummaryContent, hostName: String, onTipTap: ((ScheduleTip) -> Void)?) case playaAddress(String, tappable: Bool) case distance(CLLocationDistance) case travelTime(CLLocationDistance) @@ -52,6 +54,23 @@ enum DetailCellType { case viewHistory(firstViewed: Date?, lastViewed: Date?) } +// MARK: - Event Summary Content + +/// A single schedule tip built from real event occurrence data. +struct ScheduleTip: Identifiable { + let id = UUID() + let text: String // formatted display text + let eventUID: String // base event uid (for navigation) + let isExpired: Bool // all occurrences have ended + let earliestStart: Date // for day-of-week sorting +} + +/// AI-generated summary of a host's events, with Swift-generated schedule tips. +struct EventSummaryContent { + let summary: String? // LLM overview (may be nil if generation failed) + let tips: [ScheduleTip] // Swift-generated schedule tips (always factual) +} + // MARK: - Supporting Types /// Text styling options for detail cells diff --git a/iBurn/Detail/ViewModels/DetailViewModel.swift b/iBurn/Detail/ViewModels/DetailViewModel.swift index c8295895..e97fcf47 100644 --- a/iBurn/Detail/ViewModels/DetailViewModel.swift +++ b/iBurn/Detail/ViewModels/DetailViewModel.swift @@ -72,6 +72,12 @@ class DetailViewModel: ObservableObject { private var resolvedHostEvents: [EventObjectOccurrence] = [] /// Resolved occurrences for an EventObject (used by .event detail to show schedule) private var resolvedEventOccurrences: [EventObjectOccurrence] = [] + /// Swift-generated schedule tips (available instantly when events load) + private var resolvedEventTips: [ScheduleTip] = [] + /// LLM-generated vibe overview (arrives async) + private var resolvedEventOverview: String? + /// Whether LLM overview generation is in progress + private var isGeneratingEventOverview = false // MARK: - Initialization @@ -368,21 +374,25 @@ class DetailViewModel: ObservableObject { case .eventOccurrence(let occ): guard let playaDB else { break } try? await playaDB.setLastViewed(Date(), for: occ) - if let campUID = occ.hostedByCamp, - let camp = try? await playaDB.fetchCamp(uid: campUID) { - resolvedHostName = camp.name - resolvedHostSubject = .camp(camp) - resolvedHostDescription = camp.description - resolvedHostLocation = camp.locationString ?? camp.intersection - resolvedHostEvents = (try? await playaDB.fetchEvents(hostedByCampUID: campUID)) ?? [] - needsRefresh = true - } else if let artUID = occ.locatedAtArt, - let art = try? await playaDB.fetchArt(uid: artUID) { - resolvedHostName = art.name - resolvedHostSubject = .art(art) - resolvedHostDescription = art.description - resolvedHostLocation = art.locationString ?? art.timeBasedAddress - resolvedHostEvents = (try? await playaDB.fetchEvents(locatedAtArtUID: artUID)) ?? [] + // Use pre-loaded host from JOIN, fall back to fetch + var host: (any PlaceDataObject)? = occ.host + if host == nil, let campUID = occ.hostedByCamp { + host = try? await playaDB.fetchCamp(uid: campUID) + } + if host == nil, let artUID = occ.locatedAtArt { + host = try? await playaDB.fetchArt(uid: artUID) + } + if let host { + resolvedHostName = host.name + resolvedHostDescription = host.description + resolvedHostLocation = host.address + if let camp = host as? CampObject { + resolvedHostSubject = .camp(camp) + resolvedHostEvents = (try? await playaDB.fetchEvents(hostedByCampUID: camp.uid)) ?? [] + } else if let art = host as? ArtObject { + resolvedHostSubject = .art(art) + resolvedHostEvents = (try? await playaDB.fetchEvents(locatedAtArtUID: art.uid)) ?? [] + } needsRefresh = true } @@ -396,6 +406,11 @@ class DetailViewModel: ObservableObject { if needsRefresh { self.cells = generateCells() } + + // Phase 3: Generate AI summary of hosted events + if !resolvedHostEvents.isEmpty { + await generateEventSummaryIfNeeded() + } } func toggleFavorite() async { @@ -1065,11 +1080,13 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } )) + cellTypes.append(contentsOf: generateEventSummaryCells(hostName: hostName)) } // Schedule (from resolved occurrences) @@ -1202,11 +1219,13 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } )) + cellTypes.append(contentsOf: generateEventSummaryCells(hostName: hostName)) } // Schedule with color-coded time @@ -1844,6 +1863,91 @@ class DetailViewModel: ObservableObject { return cells } + /// Returns AI summary cell based on current state. + /// Tips are always available when events are loaded. Overview arrives async. + private func generateEventSummaryCells(hostName: String) -> [DetailCellType] { + let hasTips = !resolvedEventTips.isEmpty + let hasOverview = resolvedEventOverview != nil + + if hasTips || hasOverview { + let content = EventSummaryContent( + summary: resolvedEventOverview, + tips: resolvedEventTips + ) + let onTipTap: ((ScheduleTip) -> Void)? = { [weak self] tip in + guard let self, let playaDB else { return } + // Find the first matching occurrence for this event + if let occ = self.resolvedHostEvents.first(where: { $0.event.uid == tip.eventUID }) { + let vc = DetailViewControllerFactory.create(with: occ, playaDB: playaDB) + self.coordinator.handle(.navigateToViewController(vc)) + } + } + return [.eventSummary(content, hostName: hostName, onTipTap: onTipTap)] + } else if isGeneratingEventOverview { + return [.eventSummaryLoading(hostName: hostName)] + } + return [] + } + + /// Compute schedule tips (sync) and kick off LLM overview (async). + private func generateEventSummaryIfNeeded() async { + guard !resolvedHostEvents.isEmpty, + resolvedEventTips.isEmpty, + !isGeneratingEventOverview else { return } + + let hostName: String + let hostUID: String + switch subject { + case .art(let art): hostName = art.name; hostUID = art.uid + case .camp(let camp): hostName = camp.name; hostUID = camp.uid + case .event(let event): + hostName = resolvedHostName ?? "this host" + hostUID = event.hostedByCamp ?? event.locatedAtArt ?? event.uid + case .eventOccurrence(let occ): + hostName = resolvedHostName ?? "this host" + hostUID = occ.hostedByCamp ?? occ.locatedAtArt ?? occ.event.uid + default: return + } + + // Check cache first — show immediately without loading spinner + if let cached = await EventSummaryCache.shared.get(hostUID) { + resolvedEventTips = cached.tips + resolvedEventOverview = cached.summary + self.cells = generateCells() + return + } + + // Step 1: Compute tips instantly from real data (pure Swift) + #if canImport(FoundationModels) + if #available(iOS 26, *) { + resolvedEventTips = buildScheduleTips(from: resolvedHostEvents) + } + #endif + self.cells = generateCells() // Show tips immediately + + // Step 2: Generate LLM overview asynchronously + #if canImport(FoundationModels) + if #available(iOS 26, *) { + isGeneratingEventOverview = true + + let content = await generateEventCollectionSummary( + events: resolvedHostEvents, + hostName: hostName, + hostUID: hostUID, + hostDescription: resolvedHostDescription + ) + + isGeneratingEventOverview = false + if let content { + resolvedEventOverview = content.summary + // Update tips from cache if they differ (shouldn't, but be safe) + if !content.tips.isEmpty { resolvedEventTips = content.tips } + } + self.cells = generateCells() + } + #endif + } + /// Generate hosted event cells (next event + all events) for a camp/art detail screen. private func generateHostedEventCells(hostName: String) -> [DetailCellType] { guard let playaDB, !resolvedHostEvents.isEmpty else { return [] } @@ -1877,12 +1981,16 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } )) + // AI summary of hosted events + cells.append(contentsOf: generateEventSummaryCells(hostName: hostName)) + return cells } diff --git a/iBurn/Detail/Views/DetailView.swift b/iBurn/Detail/Views/DetailView.swift index 4f5e1fb6..57d311e5 100644 --- a/iBurn/Detail/Views/DetailView.swift +++ b/iBurn/Detail/Views/DetailView.swift @@ -213,7 +213,13 @@ struct DetailCellView: View { case .allHostEvents(let count, let hostName, _): DetailAllHostEventsCell(count: count, hostName: hostName) - + + case .eventSummaryLoading: + EventSummaryHeaderView(content: nil, isLoading: true) + + case .eventSummary(let content, _, let onTipTap): + EventSummaryHeaderView(content: content, isLoading: false, onTipTap: onTipTap) + case .schedule(let attributedString): DetailScheduleCell(attributedString: attributedString) @@ -271,7 +277,7 @@ struct DetailCellView: View { return onTap != nil case .playaAddress(_, let tappable): return tappable - case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType: + case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType, .eventSummaryLoading, .eventSummary(_, _, _): return false case .image: return true @@ -705,6 +711,67 @@ struct DetailAllHostEventsCell: View { } } +/// Shared view for AI event summary — used by both DetailView cells and PlayaHostedEventsView. +struct EventSummaryHeaderView: View { + let content: EventSummaryContent? + let isLoading: Bool + var onTipTap: ((ScheduleTip) -> Void)? + @Environment(\.themeColors) var themeColors + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("AI SLOP SUMMARY", systemImage: "sparkles") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(themeColors.detailColor) + .textCase(.uppercase) + + if isLoading { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.7) + Text("Summarizing events...") + .font(.caption) + .foregroundColor(themeColors.secondaryColor) + } + } else if let content { + if let summary = content.summary { + Text(summary) + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) + .fixedSize(horizontal: false, vertical: true) + } + + if !content.tips.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(content.tips) { tip in + let expired = tip.isExpired && !YearSettings.isEventOver + if onTipTap != nil { + Button { + onTipTap?(tip) + } label: { + Text("• \(tip.text)") + .font(.caption) + .foregroundColor(expired ? themeColors.secondaryColor : themeColors.detailColor) + .strikethrough(expired) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("• \(tip.text)") + .font(.caption) + .foregroundColor(expired ? themeColors.secondaryColor : themeColors.detailColor) + .strikethrough(expired) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + } + } +} + struct DetailLandmarkCell: View { let landmark: String @Environment(\.themeColors) var themeColors diff --git a/iBurn/ListView/EventDataProvider.swift b/iBurn/ListView/EventDataProvider.swift index f717d61b..05af8a33 100644 --- a/iBurn/ListView/EventDataProvider.swift +++ b/iBurn/ListView/EventDataProvider.swift @@ -37,6 +37,40 @@ class EventDataProvider: ObjectListDataProvider { } } + /// Observe events grouped into hour-of-day sections at the data layer. + /// Use this for browse mode (sectioned list + hour quick-scroll strip); + /// use `observeObjects` for search mode (flat results). + func observeObjectsByHour(filter: EventFilter) -> AsyncStream<[EventHourSection]> { + AsyncStream { continuation in + let token = playaDB.observeEventsByHour(filter: filter) { sections in + continuation.yield(sections) + } onError: { error in + print("Event observation error: \(error)") + } + + continuation.onTermination = { @Sendable _ in + token.cancel() + } + } + } + + /// Observe events bucketed by day then hour. Use this in browse mode with a full-festival + /// filter (no startDate/endDate). The view model slices `dict[selectedDay]` in memory so + /// day-tab taps perform zero DB work. + func observeObjectsByDayThenHour(filter: EventFilter) -> AsyncStream<[Date: [EventHourSection]]> { + AsyncStream { continuation in + let token = playaDB.observeEventsByDayThenHour(filter: filter) { bucket in + continuation.yield(bucket) + } onError: { error in + print("Event observation error: \(error)") + } + + continuation.onTermination = { @Sendable _ in + token.cancel() + } + } + } + func toggleFavorite(_ object: EventObjectOccurrence) async throws { try await playaDB.toggleFavorite(object) } diff --git a/iBurn/ListView/EventHourIndexView.swift b/iBurn/ListView/EventHourIndexView.swift new file mode 100644 index 00000000..1dee3f96 --- /dev/null +++ b/iBurn/ListView/EventHourIndexView.swift @@ -0,0 +1,156 @@ +import SwiftUI +import UIKit +import PlayaDB + +/// Vertical hour quick-scroll strip overlay for the SwiftUI Events list. +/// Mirrors the legacy `UITableView.sectionIndexTitles` strip used in `EventListViewController`: +/// bare hour digits (`12, 1, 2, …`) where `12` appears at midnight + noon. Supports tap and +/// continuous drag-scrub with light haptic feedback on hour transitions, plus a floating +/// scrubber bubble that fades in during a drag and tracks the finger above the touch point. +struct EventHourIndexView: View { + let sections: [EventHourSection] + /// Receives the section's hour (0...23) when the user taps or scrubs onto it. + let onScrollTo: (Int) -> Void + + @Environment(\.themeColors) private var themeColors + @State private var activeHour: Int? + @State private var lastActiveHour: Int? + @State private var labelFrames: [Int: CGRect] = [:] + @State private var fingerY: CGFloat = 0 + @State private var stripWidth: CGFloat = 30 + + private static let bubbleSize = CGSize(width: 64, height: 40) + private static let horizontalGap: CGFloat = 8 + private static let verticalGap: CGFloat = 48 + private static let bubbleOpacity: CGFloat = 0.85 + private static let stripActiveOpacity: CGFloat = 0.7 + /// Invisible leading-edge tap area extension so the collapsed strip is comfortably + /// tappable without expanding its visible footprint. Must stay ≤ EventListView's + /// browseRowTrailingInset minus the strip's idle visual width to avoid overlapping + /// row content's tap zone. + private static let hiddenTapPadding: CGFloat = 16 + + private var isScrubbing: Bool { activeHour != nil } + + var body: some View { + VStack(spacing: 2) { + ForEach(sections, id: \.hour) { section in + Text(Self.stripLabel(for: section.hour)) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(themeColors.primaryColor) + .frame(width: 18, height: 14) + .background( + GeometryReader { geo in + Color.clear.preference( + key: HourFramePreferenceKey.self, + value: [section.hour: geo.frame(in: .named("eventHourStrip"))] + ) + } + ) + } + } + .padding(.vertical, isScrubbing ? 10 : 0) + .padding(.leading, isScrubbing ? 8 : 0) + .padding(.trailing, isScrubbing ? 8 : 0) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.ultraThinMaterial) + .opacity(isScrubbing ? Self.stripActiveOpacity : 0) + ) + .background( + GeometryReader { geo in + Color.clear.preference( + key: StripWidthPreferenceKey.self, + value: geo.size.width + ) + } + ) + .coordinateSpace(name: "eventHourStrip") + .onPreferenceChange(HourFramePreferenceKey.self) { labelFrames = $0 } + .onPreferenceChange(StripWidthPreferenceKey.self) { stripWidth = $0 } + .padding(.leading, Self.hiddenTapPadding) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + fingerY = value.location.y + handleDrag(at: value.location.y) + } + .onEnded { _ in + withAnimation(.easeOut(duration: 0.18)) { + activeHour = nil + } + } + ) + .animation(.easeInOut(duration: 0.15), value: activeHour) + .overlay(alignment: .topTrailing) { + scrubberBubble + .offset( + x: -(stripWidth + Self.horizontalGap), + y: fingerY - Self.bubbleSize.height - Self.verticalGap + ) + .opacity(isScrubbing ? Self.bubbleOpacity : 0) + .allowsHitTesting(false) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Event hour index") + } + + private var scrubberBubble: some View { + Text(lastActiveHour.map(Self.scrubberLabel) ?? "") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(themeColors.primaryColor) + .frame(width: Self.bubbleSize.width, height: Self.bubbleSize.height) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.ultraThinMaterial) + ) + } + + /// Snap to the digit whose center is closest to the touch Y. Touches inside the + /// strip's padding or in spacing gaps between digits don't fall into any + /// `labelFrames` rect, so a strict-contains test would silently miss them once + /// `.contentShape(Rectangle())` widens the gesture's hit area. + private func handleDrag(at y: CGFloat) { + guard !labelFrames.isEmpty else { return } + let closest = labelFrames.min { lhs, rhs in + abs(y - lhs.value.midY) < abs(y - rhs.value.midY) + } + guard let hit = closest?.key else { return } + if hit != activeHour { + activeHour = hit + lastActiveHour = hit + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onScrollTo(hit) + } + } + + /// `12, 1, 2, …, 11, 12, 1, …` — matches the legacy UIKit transform at + /// `EventListViewController.swift:112-123` (`hour % 12`, with 0 → 12). + private static func stripLabel(for hour: Int) -> String { + let display = hour % 12 == 0 ? 12 : hour % 12 + return "\(display)" + } + + private static func scrubberLabel(for hour: Int) -> String { + let display = hour % 12 == 0 ? 12 : hour % 12 + let ampm = hour >= 12 ? "PM" : "AM" + return "\(display) \(ampm)" + } +} + +private struct HourFramePreferenceKey: PreferenceKey { + static var defaultValue: [Int: CGRect] = [:] + static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +/// Published by `EventHourIndexView` so a parent (e.g. `EventListView`) can +/// auto-size row trailing insets to the strip's actual measured frame. +struct StripWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} diff --git a/iBurn/ListView/EventListHostingController.swift b/iBurn/ListView/EventListHostingController.swift index 50cbf110..dba0ef53 100644 --- a/iBurn/ListView/EventListHostingController.swift +++ b/iBurn/ListView/EventListHostingController.swift @@ -35,10 +35,10 @@ class EventListHostingController: UIHostingController { // MARK: - Navigation private func showDetail(for event: EventObjectOccurrence) { - let pageItems = viewModel.filteredItems.map { row in + let pageItems = viewModel.visibleRows.map { row in DetailPageItem(subject: .eventOccurrence(row.object), metadata: row.metadata, thumbnailColors: row.thumbnailColors) } - guard let index = viewModel.filteredItems.firstIndex(where: { $0.object.event.uid == event.event.uid && $0.object.occurrence.startTime == event.occurrence.startTime }) else { return } + guard let index = viewModel.visibleRows.firstIndex(where: { $0.object.event.uid == event.event.uid && $0.object.occurrence.startTime == event.occurrence.startTime }) else { return } let dataSource = DetailPagingDataSource(items: pageItems, playaDB: playaDB) self.pagingDataSource = dataSource let pageVC = dataSource.makePageViewController(initialIndex: index) diff --git a/iBurn/ListView/EventListView.swift b/iBurn/ListView/EventListView.swift index a468ed11..71ae1478 100644 --- a/iBurn/ListView/EventListView.swift +++ b/iBurn/ListView/EventListView.swift @@ -1,7 +1,10 @@ import SwiftUI import PlayaDB -/// SwiftUI view for displaying a list of events grouped by hour within a selected day. +/// SwiftUI view for displaying a list of events. +/// - Browse mode (search empty): day picker + sectioned list grouped by hour-of-day +/// with a tappable + drag-scrubbable hour quick-scroll strip on the trailing edge. +/// - Search mode (search non-empty): flat FTS-backed results, no day picker, no strip. struct EventListView: View { @StateObject private var viewModel: EventListViewModel @State private var showingFilterSheet = false @@ -9,6 +12,28 @@ struct EventListView: View { private let onSelect: (EventObjectOccurrence) -> Void private let onShowMap: ([EventObjectOccurrence]) -> Void + private static let rowVerticalInset: CGFloat = 11 + private static let rowLeadingInset: CGFloat = 20 + private static let rowDefaultTrailingInset: CGFloat = 20 + /// Fixed trailing inset for browse rows. Sized for the strip's *active* visual + /// footprint (digit 18 + 8 leading + 8 trailing pad + 2 gap = 36) so the table + /// doesn't reflow when the strip expands on scrub-active. + private static let browseRowTrailingInset: CGFloat = 36 + + private static let browseRowInsets = EdgeInsets( + top: rowVerticalInset, + leading: rowLeadingInset, + bottom: rowVerticalInset, + trailing: browseRowTrailingInset + ) + + private static let searchRowInsets = EdgeInsets( + top: rowVerticalInset, + leading: rowLeadingInset, + bottom: rowVerticalInset, + trailing: rowDefaultTrailingInset + ) + init( viewModel: EventListViewModel, onSelect: @escaping (EventObjectOccurrence) -> Void = { _ in }, @@ -22,34 +47,60 @@ struct EventListView: View { var body: some View { ZStack { VStack(spacing: 0) { - // Day picker at top - EventDayPickerView( - days: viewModel.festivalDays, - selectedDay: $viewModel.selectedDay - ) - - Divider() - - // Event list grouped by hour - List { - ForEach(viewModel.groupedItems, id: \.header) { group in - Section(header: Text(group.header)) { - ForEach(group.items, id: \.object.uid) { row in - Button { - onSelect(row.object) - } label: { - eventRow(for: row) + if case .browse = viewModel.mode { + EventDayPickerView( + days: viewModel.festivalDays, + selectedDay: $viewModel.selectedDay + ) + Divider() + } + + ScrollViewReader { proxy in + // ScrollView+LazyVStack instead of List: SwiftUI's List eagerly processes + // all row identities on diff/recreate, which costs 400-600ms for ~1500 + // event rows per day. LazyVStack only materializes visible rows, so day + // swaps are bounded by visible row count, not total row count. + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + switch viewModel.mode { + case .browse: + ForEach(viewModel.browseSections, id: \.hour) { section in + ForEach(section.rows, id: \.object.uid) { row in + let isFirstInSection = row.object.uid == section.rows.first?.object.uid + rowButton(for: row, scrollAnchorHour: isFirstInSection ? section.hour : nil) + .padding(Self.browseRowInsets) + Divider() + } + } + case .search: + ForEach(viewModel.searchResults, id: \.object.uid) { row in + Button { + onSelect(row.object) + } label: { + eventRow(for: row) + .contentShape(Rectangle()) + .padding(Self.searchRowInsets) + } + .buttonStyle(.plain) + Divider() + } + } + } + } + .searchable( + text: $viewModel.searchText, + prompt: "Search events" + ) + .overlay(alignment: .trailing) { + if case .browse = viewModel.mode, !viewModel.browseSections.isEmpty { + EventHourIndexView(sections: viewModel.browseSections) { hour in + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(hour, anchor: .top) } - .buttonStyle(.plain) } } } } - .listStyle(.plain) - .searchable( - text: $viewModel.searchText, - prompt: "Search events" - ) } .navigationTitle("Events") .navigationBarTitleDisplayMode(.large) @@ -72,7 +123,7 @@ struct EventListView: View { } // Loading overlay - if viewModel.isLoading && viewModel.items.isEmpty { + if viewModel.isLoading && viewModel.isEmpty { VStack(spacing: 16) { ProgressView() .scaleEffect(1.5) @@ -83,43 +134,74 @@ struct EventListView: View { } // Empty state - if !viewModel.isLoading && viewModel.filteredItems.isEmpty { - VStack(spacing: 16) { - Image(systemName: "calendar") - .font(.system(size: 64)) - .foregroundColor(themeColors.detailColor) + if !viewModel.isLoading && viewModel.isEmpty { + emptyState + } + } + } - if viewModel.searchText.isEmpty { - Text("No events found") - .font(.headline) - .foregroundColor(themeColors.primaryColor) + // MARK: - Empty State - Text("Try adjusting your filters or selecting a different day") - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - .multilineTextAlignment(.center) - } else { - Text("No results for \"\(viewModel.searchText)\"") - .font(.headline) - .foregroundColor(themeColors.primaryColor) + @ViewBuilder + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: viewModel.searchText.isEmpty ? "calendar" : "magnifyingglass") + .font(.system(size: 64)) + .foregroundColor(themeColors.detailColor) - Text("Try a different search term") - .font(.subheadline) - .foregroundColor(themeColors.secondaryColor) - } - } - .padding() + if viewModel.searchText.isEmpty { + Text("No events found") + .font(.headline) + .foregroundColor(themeColors.primaryColor) + + Text("Try adjusting your filters or selecting a different day") + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) + .multilineTextAlignment(.center) + } else { + Text("No results for \"\(viewModel.searchText)\"") + .font(.headline) + .foregroundColor(themeColors.primaryColor) + + Text("Try a different search term") + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) } } + .padding() } // MARK: - Row Builder + /// Wraps the tappable row with a conditional `.id(hour)` anchor so + /// `ScrollViewReader` can target the first row of each section. + @ViewBuilder + private func rowButton( + for row: ListRow, + scrollAnchorHour: Int? + ) -> some View { + let button = Button { + onSelect(row.object) + } label: { + eventRow(for: row) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if let hour = scrollAnchorHour { + button.id(hour) + } else { + button + } + } + private func eventRow(for row: ListRow) -> some View { return ObjectRowView( object: row.object, subtitle: viewModel.distanceAttributedString(for: row.object), rightSubtitle: row.object.timeDescription(now: viewModel.now), + hostName: row.object.hostName, + hostAddress: BRCEmbargo.allowEmbargoedData() ? row.object.hostAddress : nil, isFavorite: row.isFavorite, thumbnailColors: row.thumbnailColors, onFavoriteTap: { @@ -143,6 +225,6 @@ struct EventListView: View { } private func showMap() { - onShowMap(viewModel.filteredItems.map(\.object)) + onShowMap(viewModel.visibleObjects) } } diff --git a/iBurn/ListView/EventListViewModel.swift b/iBurn/ListView/EventListViewModel.swift index 3c418e08..83e3c23f 100644 --- a/iBurn/ListView/EventListViewModel.swift +++ b/iBurn/ListView/EventListViewModel.swift @@ -3,20 +3,24 @@ import Dispatch import Foundation import PlayaDB -/// Resolved host information for an event (camp or art installation). -struct ResolvedEventHost { - let name: String - let address: String? - let description: String? - let thumbnailObjectID: String? - let isArt: Bool -} - @MainActor final class EventListViewModel: ObservableObject { + + enum Mode: Equatable { + case browse + case search(String) + } + // MARK: - Published - @Published var items: [ListRow] = [] + /// Full-festival browse results, bucketed by start-of-day then hour. Built once per + /// filter/search change and re-emitted only when underlying data changes (favorites, + /// imports). Day-tab switching is a pure in-memory dictionary lookup over this map — + /// no observation restart, no DB hit. + @Published private(set) var dayBuckets: [Date: [EventHourSection]] = [:] + + /// Flat results for search mode (FTS). Empty when not searching. + @Published var searchResults: [ListRow] = [] @Published var filter: EventFilter { didSet { @@ -25,20 +29,23 @@ final class EventListViewModel: ObservableObject { } } - @Published var searchText: String = "" + @Published var searchText: String = "" { + didSet { restartObservation() } + } + @Published var isLoading: Bool = true @Published var currentLocation: CLLocation? - /// Resolved host data for events (event UID → host info) - @Published private(set) var resolvedHosts: [String: ResolvedEventHost] = [:] - /// Currently selected day (drives day-scoped observation) - @Published var selectedDay: Date { - didSet { restartObservation() } - } + /// Currently selected day. Does NOT trigger an observation restart — the browse + /// observation produces all days; the UI slices `dayBuckets` by this value. + @Published var selectedDay: Date /// Current time, updated every 60s for status indicators @Published var now: Date = .present + /// Browse vs. search; derived from `searchText`. + var mode: Mode { searchText.isEmpty ? .browse : .search(searchText) } + // MARK: - Dependencies private let dataProvider: EventDataProvider @@ -79,7 +86,7 @@ final class EventListViewModel: ObservableObject { self.currentLocation = locationProvider.currentLocation - startObserving() + restartObservation() startLocationUpdates() startRefreshTimer() } @@ -93,113 +100,111 @@ final class EventListViewModel: ObservableObject { // MARK: - Derived - func isFavorite(_ object: EventObjectOccurrence) -> Bool { - items.first(where: { $0.object.uid == object.uid })?.isFavorite ?? false - } - func distanceAttributedString(for object: EventObjectOccurrence) -> AttributedString? { dataProvider.distanceAttributedString(from: currentLocation, to: object) } - /// Returns the resolved host for an event, or nil if no host was resolved. - func resolvedHost(for event: EventObjectOccurrence) -> ResolvedEventHost? { - resolvedHosts[event.event.uid] + /// Sections for the currently selected day — pure in-memory dict lookup. + /// Returns `[]` for days the user hasn't generated content for. + var browseSections: [EventHourSection] { + let key = Calendar.current.startOfDay(for: selectedDay) + return dayBuckets[key] ?? [] } - /// Returns the resolved location string for an event, or nil if no location. - func locationString(for event: EventObjectOccurrence) -> String? { - if let resolved = resolvedHosts[event.event.uid] { - return resolved.name + /// Flat list of all currently visible rows (sections flattened in browse mode). + /// Used by the hosting controller for detail-paging order. + var visibleRows: [ListRow] { + switch mode { + case .browse: + return browseSections.flatMap { $0.rows } + case .search: + return searchResults } - return event.event.hasOtherLocation ? event.event.otherLocation : nil } - var filteredItems: [ListRow] { - guard !searchText.isEmpty else { return items } - let q = searchText.lowercased() - return items.filter { - $0.object.name.lowercased().contains(q) || - $0.object.description?.lowercased().contains(q) == true || - $0.object.eventTypeLabel.lowercased().contains(q) == true || - $0.object.hostedByCamp?.lowercased().contains(q) == true - } + /// Flat list of all currently visible event objects, for the "Show map" action. + var visibleObjects: [EventObjectOccurrence] { + visibleRows.map(\.object) } - /// Items grouped by hour for sectioned display - var groupedItems: [(header: String, items: [ListRow])] { - let filtered = filteredItems - guard !filtered.isEmpty else { return [] } - - let calendar = Calendar.current - let grouped = Dictionary(grouping: filtered) { row -> Int in - calendar.component(.hour, from: row.object.startDate) + var isEmpty: Bool { + switch mode { + case .browse: return browseSections.isEmpty + case .search: return searchResults.isEmpty } - return grouped - .sorted { $0.key < $1.key } - .map { (hour, rows) in - let displayHour = hour % 12 == 0 ? 12 : hour % 12 - let ampm = hour >= 12 ? "PM" : "AM" - return (header: "\(displayHour) \(ampm)", items: rows) - } } // MARK: - Actions + /// Toggle favorite. The DB observation re-emits the updated rows; + /// no optimistic in-memory mutation here. func toggleFavorite(_ row: ListRow) async { - let originalRow = row - if let idx = items.firstIndex(where: { $0.object.uid == row.object.uid }) { - var updatedMeta = row.metadata - updatedMeta?.isFavorite = !row.isFavorite - items[idx] = ListRow(object: row.object, metadata: updatedMeta, thumbnailColors: row.thumbnailColors) - } do { try await dataProvider.toggleFavorite(row.object) } catch { - if let idx = items.firstIndex(where: { $0.object.uid == originalRow.object.uid }) { - items[idx] = originalRow - } print("Error toggling favorite for \(row.object.name): \(error)") } } // MARK: - Observation - /// Build the effective filter by merging selectedDay into the user's filter - private func effectiveFilter() -> EventFilter { + /// Browse mode filter: user filters across the full festival. No day scoping (the UI + /// slices `dayBuckets[selectedDay]` in memory) and no searchText. + private func browseFilter() -> EventFilter { var f = filter - let calendar = Calendar.current - let startOfDay = calendar.startOfDay(for: selectedDay) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! - f.startDate = startOfDay - f.endDate = endOfDay + f.startDate = nil + f.endDate = nil + f.searchText = nil return f } - private func startObserving() { + /// Search mode filter: user filters + searchText, all days (no date scope). + private func searchFilter(query: String) -> EventFilter { + var f = filter + f.startDate = nil + f.endDate = nil + f.searchText = query + return f + } + + private func restartObservation() { observationTask?.cancel() loadingGateTask?.cancel() - isLoading = true - let filterForObservation = effectiveFilter() - - observationTask = Task { [weak self] in - guard let self else { return } - var didReceiveFirstEmission = false - for await rows in self.dataProvider.observeObjects(filter: filterForObservation) { - didReceiveFirstEmission = true - await MainActor.run { - self.items = rows - if !rows.isEmpty { - self.isLoading = false + switch mode { + case .browse: + searchResults = [] + let f = browseFilter() + observationTask = Task { [weak self] in + guard let self else { return } + var didReceiveFirstEmission = false + for await bucket in self.dataProvider.observeObjectsByDayThenHour(filter: f) { + didReceiveFirstEmission = true + await MainActor.run { + self.dayBuckets = bucket + if !bucket.isEmpty { + self.isLoading = false + } + } + if didReceiveFirstEmission, !bucket.isEmpty { + await MainActor.run { self.loadingGateTask?.cancel() } + } else if didReceiveFirstEmission, bucket.isEmpty { + startLoadingGateIfNeeded() } - self.resolveHosts(for: rows.map(\.object)) } + } - if didReceiveFirstEmission, !rows.isEmpty { - loadingGateTask?.cancel() - } else if didReceiveFirstEmission, rows.isEmpty { - startLoadingGateIfNeeded() + case .search(let query): + dayBuckets = [:] + let f = searchFilter(query: query) + observationTask = Task { [weak self] in + guard let self else { return } + for await rows in self.dataProvider.observeObjects(filter: f) { + await MainActor.run { + self.searchResults = rows + self.isLoading = false + } } } } @@ -250,57 +255,6 @@ final class EventListViewModel: ObservableObject { } } - private func restartObservation() { - startObserving() - } - - // MARK: - Host Resolution - - /// Resolve host camp/art data for events that reference them by UID. - private func resolveHosts(for events: [EventObjectOccurrence]) { - let needsResolution = events.filter { event in - let uid = event.event.uid - if resolvedHosts[uid] != nil { return false } - return event.event.isHostedByCamp || event.event.isLocatedAtArt - } - guard !needsResolution.isEmpty else { return } - - Task { [weak self, dataProvider] in - guard let self else { return } - var newHosts: [String: ResolvedEventHost] = [:] - - for event in needsResolution { - let eventUID = event.event.uid - if let campUID = event.event.hostedByCamp { - if let camp = try? await dataProvider.playaDB.fetchCamp(uid: campUID) { - newHosts[eventUID] = ResolvedEventHost( - name: camp.name, - address: camp.locationString, - description: camp.description, - thumbnailObjectID: campUID, - isArt: false - ) - } - } else if let artUID = event.event.locatedAtArt { - if let art = try? await dataProvider.playaDB.fetchArt(uid: artUID) { - newHosts[eventUID] = ResolvedEventHost( - name: art.name, - address: art.locationString ?? art.timeBasedAddress, - description: art.description, - thumbnailObjectID: artUID, - isArt: true - ) - } - } - } - - guard !newHosts.isEmpty else { return } - await MainActor.run { - self.resolvedHosts.merge(newHosts) { _, new in new } - } - } - } - // MARK: - Refresh Timer private func startRefreshTimer() { @@ -318,10 +272,11 @@ final class EventListViewModel: ObservableObject { // MARK: - Filter Persistence private func saveFilter() { - // Don't persist startDate/endDate (those come from selectedDay) + // Don't persist startDate/endDate (those come from selectedDay) or searchText. var persistFilter = filter persistFilter.startDate = nil persistFilter.endDate = nil + persistFilter.searchText = nil guard let data = try? JSONEncoder().encode(persistFilter) else { return } UserDefaults.standard.set(data, forKey: filterStorageKey) } diff --git a/iBurn/ListView/FavoritesView.swift b/iBurn/ListView/FavoritesView.swift index a373ac3d..d24886ed 100644 --- a/iBurn/ListView/FavoritesView.swift +++ b/iBurn/ListView/FavoritesView.swift @@ -162,6 +162,8 @@ struct FavoritesView: View { object: event.object, subtitle: viewModel.distanceAttributedString(for: .event(event)), rightSubtitle: event.object.timeDescription(now: viewModel.now), + hostName: event.object.hostName, + hostAddress: BRCEmbargo.allowEmbargoedData() ? event.object.hostAddress : nil, isFavorite: event.isFavorite, thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(.event(event)) } } diff --git a/iBurn/ListView/FavoritesViewModel.swift b/iBurn/ListView/FavoritesViewModel.swift index 79b95466..a72ad873 100644 --- a/iBurn/ListView/FavoritesViewModel.swift +++ b/iBurn/ListView/FavoritesViewModel.swift @@ -25,9 +25,6 @@ final class FavoritesViewModel: ObservableObject { /// Current time, updated every 60s for event status indicators @Published var now: Date = .present - /// Resolved host data for events (event UID → host info) - @Published private(set) var resolvedHosts: [String: ResolvedEventHost] = [:] - // MARK: - Dependencies private let artProvider: ArtDataProvider @@ -180,17 +177,6 @@ final class FavoritesViewModel: ObservableObject { } } - func resolvedHost(for event: EventObjectOccurrence) -> ResolvedEventHost? { - resolvedHosts[event.event.uid] - } - - func locationString(for event: EventObjectOccurrence) -> String? { - if let resolved = resolvedHosts[event.event.uid] { - return resolved.name - } - return event.event.hasOtherLocation ? event.event.otherLocation : nil - } - // MARK: - Actions func toggleFavorite(_ item: FavoriteItem) async { @@ -289,7 +275,6 @@ final class FavoritesViewModel: ObservableObject { await MainActor.run { self.eventItems = items self.markReceived("event") - self.resolveHosts(for: items.map(\.object)) } } } @@ -376,49 +361,4 @@ final class FavoritesViewModel: ObservableObject { } } - // MARK: - Host Resolution - - private func resolveHosts(for events: [EventObjectOccurrence]) { - let needsResolution = events.filter { event in - let uid = event.event.uid - if resolvedHosts[uid] != nil { return false } - return event.event.isHostedByCamp || event.event.isLocatedAtArt - } - guard !needsResolution.isEmpty else { return } - - Task { [weak self, eventProvider] in - guard let self else { return } - var newHosts: [String: ResolvedEventHost] = [:] - - for event in needsResolution { - let eventUID = event.event.uid - if let campUID = event.event.hostedByCamp { - if let camp = try? await eventProvider.playaDB.fetchCamp(uid: campUID) { - newHosts[eventUID] = ResolvedEventHost( - name: camp.name, - address: camp.locationString, - description: camp.description, - thumbnailObjectID: campUID, - isArt: false - ) - } - } else if let artUID = event.event.locatedAtArt { - if let art = try? await eventProvider.playaDB.fetchArt(uid: artUID) { - newHosts[eventUID] = ResolvedEventHost( - name: art.name, - address: art.locationString ?? art.timeBasedAddress, - description: art.description, - thumbnailObjectID: artUID, - isArt: true - ) - } - } - } - - guard !newHosts.isEmpty else { return } - await MainActor.run { - self.resolvedHosts.merge(newHosts) { _, new in new } - } - } - } } diff --git a/iBurn/ListView/GlobalSearchView.swift b/iBurn/ListView/GlobalSearchView.swift index 6b6fa3a1..8d184108 100644 --- a/iBurn/ListView/GlobalSearchView.swift +++ b/iBurn/ListView/GlobalSearchView.swift @@ -124,6 +124,8 @@ struct GlobalSearchView: View { ObjectRowView( object: event, rightSubtitle: event.timeDescription(now: Date()), + hostName: event.hostName, + hostAddress: BRCEmbargo.allowEmbargoedData() ? event.hostAddress : nil, isFavorite: false, onFavoriteTap: { } ) { _ in diff --git a/iBurn/ListView/GlobalSearchViewModel.swift b/iBurn/ListView/GlobalSearchViewModel.swift index 15fa48cb..b1611e4b 100644 --- a/iBurn/ListView/GlobalSearchViewModel.swift +++ b/iBurn/ListView/GlobalSearchViewModel.swift @@ -11,7 +11,6 @@ final class GlobalSearchViewModel: ObservableObject { @Published var sections: [SearchResultSection] = [] @Published var isSearching: Bool = false - @Published private(set) var resolvedHosts: [String: ResolvedEventHost] = [:] /// UIDs of results that came from AI semantic search (not FTS5) @Published var aiSuggestedUIDs: Set = [] @@ -217,48 +216,4 @@ final class GlobalSearchViewModel: ObservableObject { return sections } - // MARK: - Event Host Resolution - - func resolvedHost(for event: EventObjectOccurrence) -> ResolvedEventHost? { - resolvedHosts[event.event.uid] - } - - func resolveHosts(for events: [EventObjectOccurrence]) { - let needsResolution = events.filter { resolvedHosts[$0.event.uid] == nil } - guard !needsResolution.isEmpty else { return } - - Task { [weak self] in - guard let self else { return } - var newHosts: [String: ResolvedEventHost] = [:] - - for event in needsResolution { - let eventUID = event.event.uid - if let campUID = event.event.hostedByCamp { - if let camp = try? await playaDB.fetchCamp(uid: campUID) { - newHosts[eventUID] = ResolvedEventHost( - name: camp.name, - address: camp.locationString, - description: camp.description, - thumbnailObjectID: campUID, - isArt: false - ) - } - } else if let artUID = event.event.locatedAtArt { - if let art = try? await playaDB.fetchArt(uid: artUID) { - newHosts[eventUID] = ResolvedEventHost( - name: art.name, - address: art.locationString ?? art.timeBasedAddress, - description: art.description, - thumbnailObjectID: artUID, - isArt: true - ) - } - } - } - - await MainActor.run { - self.resolvedHosts.merge(newHosts) { _, new in new } - } - } - } } diff --git a/iBurn/ListView/NearbyView.swift b/iBurn/ListView/NearbyView.swift index 6c4288e4..864e689d 100644 --- a/iBurn/ListView/NearbyView.swift +++ b/iBurn/ListView/NearbyView.swift @@ -202,6 +202,8 @@ struct NearbyView: View { object: event.object, subtitle: viewModel.distanceString(for: .event(event)), rightSubtitle: event.object.timeDescription(now: viewModel.now), + hostName: event.object.hostName, + hostAddress: BRCEmbargo.allowEmbargoedData() ? event.object.hostAddress : nil, isFavorite: event.isFavorite, thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(.event(event)) } } diff --git a/iBurn/ListView/NearbyViewModel.swift b/iBurn/ListView/NearbyViewModel.swift index dc0c2209..07a196ff 100644 --- a/iBurn/ListView/NearbyViewModel.swift +++ b/iBurn/ListView/NearbyViewModel.swift @@ -28,7 +28,6 @@ final class NearbyViewModel: ObservableObject { @Published var isLoading: Bool = true @Published var now: Date = .present - @Published private(set) var resolvedHosts: [String: ResolvedEventHost] = [:] // MARK: - Dependencies @@ -179,56 +178,6 @@ final class NearbyViewModel: ObservableObject { } } - // MARK: - Host Resolution - - func resolvedHost(for event: EventObjectOccurrence) -> ResolvedEventHost? { - resolvedHosts[event.event.uid] - } - - private func resolveHosts(for events: [EventObjectOccurrence]) { - let needsResolution = events.filter { event in - let uid = event.event.uid - if resolvedHosts[uid] != nil { return false } - return event.event.isHostedByCamp || event.event.isLocatedAtArt - } - guard !needsResolution.isEmpty else { return } - - Task { [weak self, playaDB] in - guard let self else { return } - var newHosts: [String: ResolvedEventHost] = [:] - - for event in needsResolution { - let eventUID = event.event.uid - if let campUID = event.event.hostedByCamp { - if let camp = try? await playaDB.fetchCamp(uid: campUID) { - newHosts[eventUID] = ResolvedEventHost( - name: camp.name, - address: camp.locationString, - description: camp.description, - thumbnailObjectID: campUID, - isArt: false - ) - } - } else if let artUID = event.event.locatedAtArt { - if let art = try? await playaDB.fetchArt(uid: artUID) { - newHosts[eventUID] = ResolvedEventHost( - name: art.name, - address: art.locationString ?? art.timeBasedAddress, - description: art.description, - thumbnailObjectID: artUID, - isArt: true - ) - } - } - } - - guard !newHosts.isEmpty else { return } - await MainActor.run { - self.resolvedHosts.merge(newHosts) { _, new in new } - } - } - } - // MARK: - Favorites func toggleFavorite(_ item: NearbyItem) async { @@ -330,7 +279,6 @@ final class NearbyViewModel: ObservableObject { await MainActor.run { self.eventItems = items self.markReceived("event") - self.resolveHosts(for: items.map(\.object)) } } } diff --git a/iBurn/ListView/ObjectRowView.swift b/iBurn/ListView/ObjectRowView.swift index 02ec010c..e0d90c14 100644 --- a/iBurn/ListView/ObjectRowView.swift +++ b/iBurn/ListView/ObjectRowView.swift @@ -32,6 +32,8 @@ struct ObjectRowView: View { let object: Object let subtitle: AttributedString? let rightSubtitle: String? + let hostName: String? + let hostAddress: String? let isFavorite: Bool let thumbnailColors: ThumbnailColors? let onFavoriteTap: () -> Void @@ -46,6 +48,8 @@ struct ObjectRowView: View { object: Object, subtitle: AttributedString? = nil, rightSubtitle: String? = nil, + hostName: String? = nil, + hostAddress: String? = nil, isFavorite: Bool, thumbnailColors: ThumbnailColors? = nil, onFavoriteTap: @escaping () -> Void, @@ -54,6 +58,8 @@ struct ObjectRowView: View { self.object = object self.subtitle = subtitle self.rightSubtitle = rightSubtitle + self.hostName = hostName + self.hostAddress = hostAddress self.isFavorite = isFavorite self.thumbnailColors = thumbnailColors self.onFavoriteTap = onFavoriteTap @@ -74,66 +80,77 @@ struct ObjectRowView: View { return themeColors }() - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(object.name) - .font(.headline) - .foregroundColor(colors.primaryColor) - .lineLimit(1) - - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + favoriteIcon(colors: colors) - actions(assets) - } + Text(object.name) + .font(.headline) + .foregroundColor(colors.primaryColor) + .lineLimit(1) - HStack(alignment: .top, spacing: 8) { - thumbnailView - .frame(width: thumbnailSize, height: thumbnailSize) + Spacer(minLength: 0) - Text(object.description ?? "") - .font(.subheadline) - .foregroundColor(colors.detailColor) - .lineLimit(nil) - .truncationMode(.tail) - .frame(height: thumbnailSize, alignment: .topLeading) - } - .padding(.top, 4) + actions(assets) + } - HStack(alignment: .firstTextBaseline, spacing: 8) { - if let subtitle { - Text(subtitle) - .font(.subheadline) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) - } else { - Text("🚶🏽 ? min 🚴🏽 ? min") + if hostName != nil || hostAddress != nil { + HStack(spacing: 8) { + if let hostName { + Text(hostName) .font(.subheadline) .foregroundColor(colors.secondaryColor) .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) } - Spacer(minLength: 0) - - if let rightSubtitle, !rightSubtitle.isEmpty { - Text(rightSubtitle) + if let hostAddress { + Text(hostAddress) .font(.subheadline) - .foregroundColor(colors.secondaryColor) + .foregroundColor(colors.detailColor) .lineLimit(1) } } - .padding(.top, 8) } - Button(action: onFavoriteTap) { - Image(systemName: isFavorite ? "heart.fill" : "heart") - .foregroundColor(isFavorite ? .pink : colors.detailColor) - .imageScale(.large) + HStack(alignment: .top, spacing: 8) { + thumbnailView + .frame(width: thumbnailSize, height: thumbnailSize) + + Text(object.description ?? "") + .font(.subheadline) + .foregroundColor(colors.detailColor) + .lineLimit(nil) + .truncationMode(.tail) + .frame(maxHeight: thumbnailSize, alignment: .topLeading) } - .buttonStyle(.plain) + .padding(.top, 4) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let subtitle { + Text(subtitle) + .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + } else { + Text("🚶🏽 ? min 🚴🏽 ? min") + .font(.subheadline) + .foregroundColor(colors.secondaryColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + } + + Spacer(minLength: 0) + + if let rightSubtitle, !rightSubtitle.isEmpty { + Text(rightSubtitle) + .font(.subheadline) + .foregroundColor(colors.secondaryColor) + .lineLimit(1) + } + } + .padding(.top, 8) } .padding(.vertical, 0) .listRowBackground(listRowBackground) @@ -144,6 +161,17 @@ struct ObjectRowView: View { } } + /// Leading-edge favorite icon. Uses `Image + onTapGesture` (not `Button`) + /// so it doesn't conflict with an outer row-level `Button` for selection. + private func favoriteIcon(colors: ImageColors) -> some View { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .foregroundColor(isFavorite ? .pink : colors.detailColor) + .imageScale(.medium) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onFavoriteTap() } + } + private var listRowBackground: some View { ZStack { themeColors.backgroundColor diff --git a/iBurn/ListView/RecentlyViewedView.swift b/iBurn/ListView/RecentlyViewedView.swift index 1f534c9b..74fe510d 100644 --- a/iBurn/ListView/RecentlyViewedView.swift +++ b/iBurn/ListView/RecentlyViewedView.swift @@ -180,6 +180,8 @@ struct RecentlyViewedView: View { object: event, subtitle: subtitle, rightSubtitle: event.timeDescription(now: Date()), + hostName: event.hostName, + hostAddress: BRCEmbargo.allowEmbargoedData() ? event.hostAddress : nil, isFavorite: isFav, onFavoriteTap: favAction ) { _ in @@ -188,7 +190,6 @@ struct RecentlyViewedView: View { } .contentShape(Rectangle()) .onTapGesture { onSelectEvent(event) } - .onAppear { viewModel.resolveHosts(for: [event]) } case .mutantVehicle(let mv, _): ObjectRowView( diff --git a/iBurn/ListView/RecentlyViewedViewModel.swift b/iBurn/ListView/RecentlyViewedViewModel.swift index ae4aef5d..3bda7d8f 100644 --- a/iBurn/ListView/RecentlyViewedViewModel.swift +++ b/iBurn/ListView/RecentlyViewedViewModel.swift @@ -8,7 +8,6 @@ final class RecentlyViewedViewModel: ObservableObject { @Published var items: [RecentlyViewedItem] = [] @Published var favoriteIDs: Set = [] - @Published private(set) var resolvedHosts: [String: ResolvedEventHost] = [:] @Published var selectedTypeFilter: RecentlyViewedTypeFilter = .all @Published var sortOrder: RecentlyViewedSortOrder = .recentFirst @@ -257,51 +256,6 @@ final class RecentlyViewedViewModel: ObservableObject { return annotations } - // MARK: - Event Host Resolution - - func resolvedHost(for event: EventObjectOccurrence) -> ResolvedEventHost? { - resolvedHosts[event.event.uid] - } - - func resolveHosts(for events: [EventObjectOccurrence]) { - let needsResolution = events.filter { resolvedHosts[$0.event.uid] == nil } - guard !needsResolution.isEmpty else { return } - - Task { [weak self] in - guard let self else { return } - var newHosts: [String: ResolvedEventHost] = [:] - - for event in needsResolution { - let eventUID = event.event.uid - if let campUID = event.event.hostedByCamp { - if let camp = try? await playaDB.fetchCamp(uid: campUID) { - newHosts[eventUID] = ResolvedEventHost( - name: camp.name, - address: camp.locationString, - description: camp.description, - thumbnailObjectID: campUID, - isArt: false - ) - } - } else if let artUID = event.event.locatedAtArt { - if let art = try? await playaDB.fetchArt(uid: artUID) { - newHosts[eventUID] = ResolvedEventHost( - name: art.name, - address: art.locationString ?? art.timeBasedAddress, - description: art.description, - thumbnailObjectID: artUID, - isArt: true - ) - } - } - } - - await MainActor.run { - self.resolvedHosts.merge(newHosts) { _, new in new } - } - } - } - // MARK: - Location private func startLocationUpdates() { diff --git a/iBurn/MainMapViewController.swift b/iBurn/MainMapViewController.swift index ca869400..07d4924e 100644 --- a/iBurn/MainMapViewController.swift +++ b/iBurn/MainMapViewController.swift @@ -26,6 +26,9 @@ public class MainMapViewController: BaseMapViewController, ListButtonHelper { private let globalSearchController: UISearchController private let globalSearchHostingController: GlobalSearchHostingController private let filteredDataSource: FilteredMapDataSource + private let dependencies: DependencyContainer + /// Compact on-map card showing art/camps/events within ~100m of the user. + private lazy var nearbyCardController = NearbyCardHostingController(dependencies: dependencies) var userMapViewAdapter: UserMapViewAdapter? { return mapViewAdapter as? UserMapViewAdapter } @@ -42,6 +45,7 @@ public class MainMapViewController: BaseMapViewController, ListButtonHelper { public init() { let dependencies = BRCAppDelegate.shared.dependencies + self.dependencies = dependencies uiConnection = BRCDatabaseManager.shared.uiConnection writeConnection = BRCDatabaseManager.shared.readWriteConnection sidebarButtons = SidebarButtonsView() @@ -116,8 +120,23 @@ public class MainMapViewController: BaseMapViewController, ListButtonHelper { setupSearchButton() setupListButton() setupFilterButton() + setupNearbyCard() definesPresentationContext = true - + + } + + /// Embeds the nearby card as a proper child view controller, bottom-centered. The + /// hosting controller uses intrinsic content sizing, so the card/FAB defines its own + /// frame and the rest of the map stays interactive around it. + private func setupNearbyCard() { + addChild(nearbyCardController) + let card = nearbyCardController.view! + view.addSubview(card) + card.translatesAutoresizingMaskIntoConstraints = false + card.autoAlignAxis(toSuperviewAxis: .vertical) + let bottom = card.autoPinEdge(toSuperviewMargin: .bottom) + bottom.constant = -12 + nearbyCardController.didMove(toParent: self) } private func setupSidebarButtons() { diff --git a/iBurn/Map/NearbyCard/NearbyCardHostingController.swift b/iBurn/Map/NearbyCard/NearbyCardHostingController.swift new file mode 100644 index 00000000..dc5d67a2 --- /dev/null +++ b/iBurn/Map/NearbyCard/NearbyCardHostingController.swift @@ -0,0 +1,54 @@ +// +// NearbyCardHostingController.swift +// iBurn +// +// Created by Claude Code on 5/30/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// +// Hosts `NearbyCardView` so it can be embedded as a child view controller of the +// main map (added via addChild/didMove, not by extracting the inner UIView). Owns +// the view model and pushes detail views on tap through the map's navigation stack. +// + +import SwiftUI +import UIKit +import PlayaDB + +@MainActor +final class NearbyCardHostingController: UIHostingController { + private let playaDB: PlayaDB + let viewModel: NearbyCardViewModel + + init(dependencies: DependencyContainer) { + self.playaDB = dependencies.playaDB + let vm = dependencies.makeNearbyCardViewModel() + self.viewModel = vm + super.init(rootView: NearbyCardView(viewModel: vm)) + self.rootView = NearbyCardView( + viewModel: vm, + onSelect: { [weak self] subject in + self?.showDetail(subject) + } + ) + // The hosting view should only occupy (and intercept touches over) the card/FAB, + // leaving the rest of the map interactive. Clear background + intrinsic sizing. + view.backgroundColor = .clear + if #available(iOS 16.0, *) { + sizingOptions = [.intrinsicContentSize] + } + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + } + + private func showDetail(_ subject: DetailSubject) { + let detailVC = DetailViewControllerFactory.create(with: subject, playaDB: playaDB) + navigationController?.pushViewController(detailVC, animated: true) + } +} diff --git a/iBurn/Map/NearbyCard/NearbyCardView.swift b/iBurn/Map/NearbyCard/NearbyCardView.swift new file mode 100644 index 00000000..0e137f75 --- /dev/null +++ b/iBurn/Map/NearbyCard/NearbyCardView.swift @@ -0,0 +1,375 @@ +// +// NearbyCardView.swift +// iBurn +// +// Created by Claude Code on 5/30/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// +// The on-map "nearby card": a compact, swipeable card pinned near the bottom of +// the main map showing what's within ~100m of the user. Events come first, then +// art + camps by distance. Tapping a card opens its detail view; a minimize +// button collapses the card into a badged FAB with a Liquid Glass morph (iOS 26), +// falling back to a material card + matched-geometry morph on earlier OSes. +// + +import SwiftUI +import PlayaDB + +struct NearbyCardView: View { + @ObservedObject var viewModel: NearbyCardViewModel + let onSelect: (DetailSubject) -> Void + + private let audioPlayer: any AudioPlayerProtocol + @Namespace private var glassNS + @Environment(\.themeColors) private var themeColors + + private let glassID = "nearbyCard" + private let cardCornerRadius: CGFloat = 22 + + /// A stable, device-appropriate card width. Fixed (not content-driven) so the card + /// doesn't jitter as you swipe between items with different text lengths, and capped + /// to the screen so it never overflows on small devices. Combined with the hosting + /// controller's intrinsic-content sizing, this keeps the touch area to just the card. + private var cardWidth: CGFloat { + min(380, UIScreen.main.bounds.width - 32) + } + + init( + viewModel: NearbyCardViewModel, + onSelect: @escaping (DetailSubject) -> Void = { _ in }, + audioPlayer: any AudioPlayerProtocol = BRCAudioPlayer.sharedInstance + ) { + self.viewModel = viewModel + self.onSelect = onSelect + self.audioPlayer = audioPlayer + } + + var body: some View { + glassContainer { + Group { + if viewModel.items.isEmpty { + // Collapses to zero intrinsic size so the host view doesn't block the map. + Color.clear.frame(width: 0, height: 0) + } else if viewModel.isMinimized { + fab + } else { + card + } + } + } + .animation(.spring(response: 0.42, dampingFraction: 0.82), value: viewModel.isMinimized) + .animation(.easeInOut(duration: 0.25), value: viewModel.items.isEmpty) + } + + // MARK: - Expanded card + + private var card: some View { + VStack(spacing: 6) { + TabView(selection: $viewModel.selectedID) { + ForEach(viewModel.items) { item in + NearbyCardContentView( + item: item, + now: viewModel.now, + isFavorite: item.isFavorite, + audioPlayer: audioPlayer, + onFavoriteTap: { Task { await viewModel.toggleFavorite(item) } }, + onTap: { onSelect(item.detailSubject) } + ) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .tag(item.id as String?) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 84) + + if viewModel.count > 1 { + pageDots + .padding(.bottom, 8) + } + } + .frame(width: cardWidth) + .overlay(alignment: .topTrailing) { minimizeButton } + .modifier(GlassSurface(namespace: glassNS, glassID: glassID, shape: .roundedRect(cardCornerRadius))) + } + + private var minimizeButton: some View { + Button { + viewModel.isMinimized = true + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(themeColors.secondaryColor) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(6) + .accessibilityLabel("Minimize nearby card") + } + + private var pageDots: some View { + HStack(spacing: 6) { + ForEach(viewModel.items) { item in + Circle() + .fill(item.id == viewModel.selectedID + ? themeColors.primaryColor + : themeColors.detailColor.opacity(0.35)) + .frame(width: 6, height: 6) + } + } + } + + // MARK: - Minimized FAB + + private var fab: some View { + Button { + viewModel.isMinimized = false + } label: { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(themeColors.primaryColor) + .frame(width: 56, height: 56) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .overlay(alignment: .topTrailing) { countBadge } + .modifier(GlassSurface(namespace: glassNS, glassID: glassID, shape: .circle)) + .accessibilityLabel("Show \(viewModel.count) nearby") + } + + private var countBadge: some View { + Text("\(viewModel.count)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .frame(minWidth: 18, minHeight: 18) + .padding(.horizontal, 3) + .background(Circle().fill(Color.red)) + .overlay(Circle().stroke(Color(.systemBackground), lineWidth: 1.5)) + .offset(x: 6, y: -4) + } + + // MARK: - Glass container + + @ViewBuilder + private func glassContainer(@ViewBuilder _ content: () -> Content) -> some View { + #if canImport(FoundationModels) + if #available(iOS 26.0, *) { + GlassEffectContainer { content() } + } else { + content() + } + #else + content() + #endif + } +} + +// MARK: - Glass surface modifier + +/// Applies the Liquid Glass surface on iOS 26 (with a shared `glassEffectID` so the +/// card<->FAB transition morphs), and a `.ultraThinMaterial` + `matchedGeometryEffect` +/// fallback on earlier OSes / SDKs. +private struct GlassSurface: ViewModifier { + enum SurfaceShape { + case roundedRect(CGFloat) + case circle + } + + let namespace: Namespace.ID + let glassID: String + let shape: SurfaceShape + + @ViewBuilder + func body(content: Content) -> some View { + #if canImport(FoundationModels) + if #available(iOS 26.0, *) { + switch shape { + case .roundedRect(let radius): + content + .glassEffect(.regular.interactive(), + in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + .glassEffectID(glassID, in: namespace) + case .circle: + content + .glassEffect(.regular.interactive(), in: Circle()) + .glassEffectID(glassID, in: namespace) + } + } else { + fallback(content) + } + #else + fallback(content) + #endif + } + + @ViewBuilder + private func fallback(_ content: Content) -> some View { + switch shape { + case .roundedRect(let radius): + let s = RoundedRectangle(cornerRadius: radius, style: .continuous) + content + .background(.ultraThinMaterial, in: s) + .overlay(s.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) + .matchedGeometryEffect(id: glassID, in: namespace) + .shadow(color: .black.opacity(0.18), radius: 10, x: 0, y: 4) + case .circle: + content + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) + .matchedGeometryEffect(id: glassID, in: namespace) + .shadow(color: .black.opacity(0.18), radius: 10, x: 0, y: 4) + } + } +} + +// MARK: - Single card content + +private struct NearbyCardContentView: View { + let item: NearbyItem + let now: Date + let isFavorite: Bool + let audioPlayer: any AudioPlayerProtocol + let onFavoriteTap: () -> Void + let onTap: () -> Void + + @StateObject private var assets: RowAssetsLoader + @Environment(\.themeColors) private var themeColors + + init( + item: NearbyItem, + now: Date, + isFavorite: Bool, + audioPlayer: any AudioPlayerProtocol, + onFavoriteTap: @escaping () -> Void, + onTap: @escaping () -> Void + ) { + self.item = item + self.now = now + self.isFavorite = isFavorite + self.audioPlayer = audioPlayer + self.onFavoriteTap = onFavoriteTap + self.onTap = onTap + _assets = StateObject(wrappedValue: RowAssetsLoader(objectID: item.thumbnailObjectID)) + } + + var body: some View { + HStack(spacing: 12) { + thumbnail + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(themeColors.primaryColor) + .lineLimit(1) + + if let description = item.detailDescription, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(themeColors.secondaryColor) + .lineLimit(1) + } + + if let timeText = item.eventTimeText(now: now) { + Text(timeText) + .font(.caption2.weight(.medium)) + .foregroundStyle(themeColors.detailColor) + .lineLimit(1) + } + } + + Spacer(minLength: 4) + + VStack(spacing: 10) { + favoriteIcon + if let track = audioTrack { + AudioTourButton(track: track, audioPlayer: audioPlayer) + } + } + } + .contentShape(Rectangle()) + .onTapGesture { onTap() } + } + + private var thumbnail: some View { + let shape = RoundedRectangle(cornerRadius: 10, style: .continuous) + return ZStack { + shape.fill(Color.black.opacity(0.06)) + if let image = assets.thumbnail { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: item.placeholderSymbol) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + .frame(width: 60, height: 60) + .clipShape(shape) + .overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) + } + + /// Uses `Image + onTapGesture` (not `Button`) so it doesn't swallow the card tap. + private var favoriteIcon: some View { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .foregroundStyle(isFavorite ? Color.pink : themeColors.detailColor) + .imageScale(.medium) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onFavoriteTap() } + } + + /// Audio tours exist for art only, and only when the file is present on disk. + private var audioTrack: BRCAudioTourTrack? { + guard let art = item.artForAudio, let audioURL = assets.audioURL else { return nil } + return BRCAudioTourTrack( + uid: art.uid, + title: art.name, + artist: art.artist, + audioURL: audioURL, + artworkURL: BRCMediaDownloader.localMediaURL("\(art.uid).jpg") + ) + } +} + +// MARK: - NearbyItem display helpers + +private extension NearbyItem { + /// Object id used for thumbnail/audio lookup (events fall back to host camp/art). + var thumbnailObjectID: String { + switch self { + case .art(let r): r.object.thumbnailObjectID + case .camp(let r): r.object.thumbnailObjectID + case .event(let r): r.object.thumbnailObjectID + } + } + + var detailDescription: String? { + switch self { + case .art(let r): r.object.description + case .camp(let r): r.object.description + case .event(let r): r.object.description + } + } + + /// Live event timing line (events only). + func eventTimeText(now: Date) -> String? { + if case .event(let r) = self { return r.object.timeDescription(now: now) } + return nil + } + + /// Underlying art object, for building an audio-tour track (art only). + var artForAudio: ArtObject? { + if case .art(let r) = self { return r.object } + return nil + } + + var placeholderSymbol: String { + switch self { + case .art: "photo" + case .camp: "tent" + case .event: "calendar" + } + } +} diff --git a/iBurn/Map/NearbyCard/NearbyCardViewModel.swift b/iBurn/Map/NearbyCard/NearbyCardViewModel.swift new file mode 100644 index 00000000..b7b522fc --- /dev/null +++ b/iBurn/Map/NearbyCard/NearbyCardViewModel.swift @@ -0,0 +1,321 @@ +// +// NearbyCardViewModel.swift +// iBurn +// +// Created by Claude Code on 5/30/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// +// Backing model for the on-map "nearby card" overlay. Emits a single flat, +// ordered, de-duped list of objects within a tight radius of the user, with +// events (happening now / starting soon) prioritized first, then art + camps +// by distance. This is NOT the full-screen Nearby list (NearbyViewModel) — it +// reuses the same data providers and `NearbyItem`, but produces a compact feed +// for the map card. +// + +import CoreLocation +import Foundation +import MapKit +import PlayaDB + +@MainActor +final class NearbyCardViewModel: ObservableObject { + // MARK: - Published + + /// Ordered, de-duped nearby items: events first, then art + camps by distance. + @Published private(set) var items: [NearbyItem] = [] + + /// Currently paged item, tracked by `NearbyItem.id` (not index) so that location + /// updates re-ordering the feed don't yank the user mid-swipe. + @Published var selectedID: String? + + /// Whether the card is collapsed into its FAB. + @Published var isMinimized: Bool = false + + /// Live "now" for event timing display; refreshed on a timer. + @Published var now: Date = .present + + // MARK: - Tuning + + /// Only surface objects within this radius of the user (meters). + let nearbyRadius: CLLocationDistance = 100 + + /// Re-center the DB region query after the user moves at least this far (meters). + private let regionRecenterThreshold: CLLocationDistance = 25 + + /// Cap the pager so it stays light. + private let maxItems = 12 + + // MARK: - Dependencies + + private let playaDB: PlayaDB + private let artProvider: ArtDataProvider + private let campProvider: CampDataProvider + private let eventProvider: EventDataProvider + private let locationProvider: LocationProvider + + // MARK: - State + + private var rawLocation: CLLocation? + private var lastRegionLocation: CLLocation? + private var artItems: [ListRow] = [] + private var campItems: [ListRow] = [] + private var eventItems: [ListRow] = [] + + // MARK: - Tasks + + private var artTask: Task? + private var campTask: Task? + private var eventTask: Task? + private var locationTask: Task? + private var timerTask: Task? + + // MARK: - Computed + + var currentLocation: CLLocation? { rawLocation } + + var count: Int { items.count } + + /// A small region centered on the user for the DB region queries. Sized a bit + /// larger than `nearbyRadius` (a square bbox circumscribing the circle) so the + /// exact per-item distance filter in `rebuildItems()` is the precise gate. + var searchRegion: MKCoordinateRegion? { + guard let location = currentLocation else { return nil } + return MKCoordinateRegion( + center: location.coordinate, + latitudinalMeters: nearbyRadius * 2, + longitudinalMeters: nearbyRadius * 2 + ) + } + + // MARK: - Init + + init( + playaDB: PlayaDB, + artProvider: ArtDataProvider, + campProvider: CampDataProvider, + eventProvider: EventDataProvider, + locationProvider: LocationProvider + ) { + self.playaDB = playaDB + self.artProvider = artProvider + self.campProvider = campProvider + self.eventProvider = eventProvider + self.locationProvider = locationProvider + + self.rawLocation = locationProvider.currentLocation + self.lastRegionLocation = rawLocation + + startLocationUpdates() + startRefreshTimer() + restartObservations() + } + + deinit { + artTask?.cancel() + campTask?.cancel() + eventTask?.cancel() + locationTask?.cancel() + timerTask?.cancel() + } + + // MARK: - Favorites + + func toggleFavorite(_ item: NearbyItem) async { + do { + switch item { + case .art(let row): try await artProvider.toggleFavorite(row.object) + case .camp(let row): try await campProvider.toggleFavorite(row.object) + case .event(let row): try await eventProvider.toggleFavorite(row.object) + } + } catch { + // Favorite state is driven by DB observation; a failed toggle is a no-op. + } + } + + // MARK: - Item assembly + + /// Recompute `items` from the latest observations + location. Events that are + /// happening now or starting soon come first (by start time); then art + camps + /// merged by distance. Everything is gated to `nearbyRadius`, de-duped by id, + /// and capped to `maxItems`. + private func rebuildItems() { + guard let location = currentLocation else { + items = [] + reconcileSelection() + return + } + items = Self.orderedItems( + art: artItems, + camps: campItems, + events: eventItems, + from: location, + now: now, + radius: nearbyRadius, + maxItems: maxItems + ) + reconcileSelection() + } + + /// Pure ordering used by `rebuildItems()`, exposed for unit testing. + /// + /// Events that are happening now or starting soon come first (by start time); + /// then art + camps merged by distance. All gated to `radius`, de-duped by id, + /// capped to `maxItems`. Objects without a location are dropped. + static func orderedItems( + art: [ListRow], + camps: [ListRow], + events: [ListRow], + from location: CLLocation, + now: Date, + radius: CLLocationDistance, + maxItems: Int + ) -> [NearbyItem] { + let orderedEvents = events + .filter { row in + guard let loc = row.object.location, + location.distance(from: loc) <= radius else { return false } + return row.object.isCurrentlyHappening(now) || row.object.isStartingSoon(now) + } + .sorted { $0.object.startDate < $1.object.startDate } + .map { NearbyItem.event($0) } + + var others: [(item: NearbyItem, distance: CLLocationDistance)] = [] + for row in art { + guard let loc = row.object.location else { continue } + let distance = location.distance(from: loc) + if distance <= radius { others.append((.art(row), distance)) } + } + for row in camps { + guard let loc = row.object.location else { continue } + let distance = location.distance(from: loc) + if distance <= radius { others.append((.camp(row), distance)) } + } + let sortedOthers = others.sorted { $0.distance < $1.distance }.map(\.item) + + var seen = Set() + var combined: [NearbyItem] = [] + for item in orderedEvents + sortedOthers { + guard seen.insert(item.id).inserted else { continue } + combined.append(item) + if combined.count >= maxItems { break } + } + return combined + } + + /// Keep the paged selection stable across rebuilds; only reset when the + /// selected item is no longer present. + private func reconcileSelection() { + if let selectedID, items.contains(where: { $0.id == selectedID }) { + return + } + selectedID = items.first?.id + } + + // MARK: - Observations + + private func restartObservations() { + startArtObservation() + startCampObservation() + startEventObservation() + } + + private func startArtObservation() { + artTask?.cancel() + guard let region = searchRegion else { + artItems = [] + rebuildItems() + return + } + let filter = ArtFilter(region: region) + artTask = Task { [weak self] in + guard let self else { return } + for await rows in self.artProvider.observeObjects(filter: filter) { + await MainActor.run { + self.artItems = rows + self.rebuildItems() + } + } + } + } + + private func startCampObservation() { + campTask?.cancel() + guard let region = searchRegion else { + campItems = [] + rebuildItems() + return + } + let filter = CampFilter(region: region) + campTask = Task { [weak self] in + guard let self else { return } + for await rows in self.campProvider.observeObjects(filter: filter) { + await MainActor.run { + self.campItems = rows + self.rebuildItems() + } + } + } + } + + private func startEventObservation() { + eventTask?.cancel() + guard let region = searchRegion else { + eventItems = [] + rebuildItems() + return + } + // Region-filtered (R*Tree-backed). Fetch all in-region occurrences and apply the + // exact happening-now / starting-soon gate client-side at `now` (the region is + // tiny, and this also captures starting-soon events that `happeningNow` would drop). + let filter = EventFilter(includeExpired: true, region: region) + eventTask = Task { [weak self] in + guard let self else { return } + for await rows in self.eventProvider.observeObjects(filter: filter) { + await MainActor.run { + self.eventItems = rows + self.rebuildItems() + } + } + } + } + + // MARK: - Location + + private func startLocationUpdates() { + locationTask?.cancel() + locationTask = Task { [weak self] in + guard let self else { return } + for await location in self.locationProvider.locationStream { + guard let location else { continue } + await MainActor.run { + self.rawLocation = location + if let last = self.lastRegionLocation, + location.distance(from: last) < self.regionRecenterThreshold { + // Small move: re-sort / re-gate against the existing observations. + self.rebuildItems() + } else { + // First fix or meaningful move: recenter the region queries. + self.lastRegionLocation = location + self.restartObservations() + } + } + } + } + } + + // MARK: - Refresh timer + + private func startRefreshTimer() { + timerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30_000_000_000) // 30s + guard let self else { return } + await MainActor.run { + self.now = .present + self.rebuildItems() + } + } + } + } +} diff --git a/iBurn/MoreViewController.swift b/iBurn/MoreViewController.swift index c30f472f..8f17f0f2 100644 --- a/iBurn/MoreViewController.swift +++ b/iBurn/MoreViewController.swift @@ -340,7 +340,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel func pushTracksView() { let tracksVC = TracksViewController() - tracksVC.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(tracksVC, animated: true) } @@ -383,37 +382,19 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel func pushAIGuideView() { #if canImport(FoundationModels) if #available(iOS 26, *) { - guard let vm = BRCAppDelegate.shared.dependencies.makeAIGuideViewModel() as? AIGuideViewModel else { return } - let view = AIGuideView(viewModel: vm) { [weak self] workflow in - self?.pushWorkflowDetail(workflow: workflow, viewModel: vm) + guard let vm = BRCAppDelegate.shared.dependencies.makeAIGuideViewModel() as? RightNowViewModel else { return } + let view = RightNowView(viewModel: vm) { [weak self] detailVC in + self?.navigationController?.pushViewController(detailVC, animated: true) } let hostingVC = UIHostingController(rootView: view) hostingVC.title = "AI Guide" - hostingVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(hostingVC, animated: true) } #endif } - #if canImport(FoundationModels) - @available(iOS 26, *) - private func pushWorkflowDetail(workflow: WorkflowInfo, viewModel: AIGuideViewModel) { - let view = WorkflowDetailView( - workflowInfo: workflow, - viewModel: viewModel, - onNavigateToDetail: { [weak self] detailVC in - self?.navigationController?.pushViewController(detailVC, animated: true) - } - ) - let hostingVC = UIHostingController(rootView: view) - hostingVC.title = workflow.title - navigationController?.pushViewController(hostingVC, animated: true) - } - #endif - func pushRecentlyViewedView() { let recentVC = RecentlyViewedHostingController(dependencies: BRCAppDelegate.shared.dependencies) - recentVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(recentVC, animated: true) } @@ -498,7 +479,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel #if DEBUG func pushFeatureFlagsView() { let featureFlagsVC = FeatureFlagsHostingController() - featureFlagsVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(featureFlagsVC, animated: true) } #endif diff --git a/iBurnTests/AISearchToolTests.swift b/iBurnTests/AISearchToolTests.swift index e510a39d..41a7cbb8 100644 --- a/iBurnTests/AISearchToolTests.swift +++ b/iBurnTests/AISearchToolTests.swift @@ -238,39 +238,6 @@ final class AISearchToolTests: XCTestCase { XCTAssertEqual(result, "Nothing found nearby.") } - // MARK: - AIAssistantService Tests - - func testAssistantService_AvailabilityCheck() { - let service = FoundationModelSearchService(playaDB: playaDB) - // Just verify it doesn't crash - _ = service.isAvailable - } - - func testAssistantService_RecommendWhenUnavailable() async throws { - let service = FoundationModelSearchService(playaDB: playaDB) - if !service.isAvailable { - let results = try await service.recommend() - XCTAssertTrue(results.isEmpty, "Should return empty when AI unavailable") - } - } - - func testAssistantService_WhatsNearbyWhenUnavailable() async throws { - let service = FoundationModelSearchService(playaDB: playaDB) - if !service.isAvailable { - let loc = CLLocation(latitude: 40.7864, longitude: -119.2065) - let results = try await service.whatsNearby(location: loc) - XCTAssertTrue(results.isEmpty, "Should return empty when AI unavailable") - } - } - - func testAssistantService_PlanDayWhenUnavailable() async throws { - let service = FoundationModelSearchService(playaDB: playaDB) - if !service.isAvailable { - let plan = try await service.planDay(date: Date(), location: nil) - XCTAssertTrue(plan.schedule.isEmpty, "Should return empty when AI unavailable") - } - } - // MARK: - Fixture Data private static let artJSON = """ diff --git a/iBurnTests/AreaRegionTests.swift b/iBurnTests/AreaRegionTests.swift new file mode 100644 index 00000000..1854a266 --- /dev/null +++ b/iBurnTests/AreaRegionTests.swift @@ -0,0 +1,35 @@ +// +// AreaRegionTests.swift +// iBurnTests +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import XCTest +import MapKit +@testable import iBurn +@testable import PlayaDB + +final class AreaRegionTests: XCTestCase { + + func testCoordinateRegionFromBounds() { + let region = coordinateRegion(swLat: 40.0, swLon: -120.0, neLat: 41.0, neLon: -119.0) + XCTAssertEqual(region.center.latitude, 40.5, accuracy: 1e-9) + XCTAssertEqual(region.center.longitude, -119.5, accuracy: 1e-9) + XCTAssertEqual(region.span.latitudeDelta, 1.0, accuracy: 1e-9) + XCTAssertEqual(region.span.longitudeDelta, 1.0, accuracy: 1e-9) + } + + func testFilterRegionRoundTrip() { + let region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 40.7864, longitude: -119.2065), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.04) + ) + let round = FilterRegion(region).coordinateRegion + XCTAssertEqual(round.center.latitude, region.center.latitude, accuracy: 1e-6) + XCTAssertEqual(round.center.longitude, region.center.longitude, accuracy: 1e-6) + XCTAssertEqual(round.span.latitudeDelta, region.span.latitudeDelta, accuracy: 1e-6) + XCTAssertEqual(round.span.longitudeDelta, region.span.longitudeDelta, accuracy: 1e-6) + } +} diff --git a/iBurnTests/NearbyCardViewModelTests.swift b/iBurnTests/NearbyCardViewModelTests.swift new file mode 100644 index 00000000..0ee642a0 --- /dev/null +++ b/iBurnTests/NearbyCardViewModelTests.swift @@ -0,0 +1,156 @@ +// +// NearbyCardViewModelTests.swift +// iBurnTests +// +// Created by Claude Code on 5/30/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// +// Unit tests for the on-map nearby card ordering: events first (happening now / +// starting soon, by start time), then art + camps by distance, gated to the +// radius, de-duped by id, capped to maxItems. +// + +import XCTest +import CoreLocation +import PlayaDB +@testable import iBurn + +@MainActor +final class NearbyCardViewModelTests: XCTestCase { + + // User reference point and a shared longitude so distance varies only by latitude. + private let baseLat = 40.0 + private let baseLon = -119.0 + private lazy var userLocation = CLLocation(latitude: baseLat, longitude: baseLon) + + // ~111,320 m per degree of latitude near the equator/BRC, so these are roughly: + private let lat33m = 40.0003 // ~33 m north + private let lat67m = 40.0006 // ~67 m north + private let lat89m = 40.0008 // ~89 m north + private let lat133m = 40.0012 // ~133 m north (outside a 100 m radius) + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + // MARK: - Builders + + private func artRow(_ uid: String, lat: Double) -> ListRow { + ListRow( + object: ArtObject(uid: uid, name: uid, year: 2025, gpsLatitude: lat, gpsLongitude: baseLon), + metadata: nil, + thumbnailColors: nil + ) + } + + private func campRow(_ uid: String, lat: Double) -> ListRow { + ListRow( + object: CampObject(uid: uid, name: uid, year: 2025, gpsLatitude: lat, gpsLongitude: baseLon), + metadata: nil, + thumbnailColors: nil + ) + } + + private func eventRow(_ uid: String, lat: Double, start: Date, end: Date) -> ListRow { + let event = EventObject( + uid: uid, + name: uid, + year: 2025, + eventTypeLabel: "Party", + eventTypeCode: "prty", + gpsLatitude: lat, + gpsLongitude: baseLon + ) + let occurrence = EventOccurrence(eventUid: uid, startTime: start, endTime: end, year: 2025) + let combined = EventObjectOccurrence(event: event, occurrence: occurrence, host: nil) + return ListRow(object: combined, metadata: nil, thumbnailColors: nil) + } + + private func order( + art: [ListRow] = [], + camps: [ListRow] = [], + events: [ListRow] = [], + radius: CLLocationDistance = 100, + maxItems: Int = 12 + ) -> [NearbyItem] { + NearbyCardViewModel.orderedItems( + art: art, + camps: camps, + events: events, + from: userLocation, + now: now, + radius: radius, + maxItems: maxItems + ) + } + + // MARK: - Tests + + func testEventsComeFirstThenArtAndCampsByDistance() throws { + // Event is the farthest of the in-radius items but must still come first. + let happeningEvent = eventRow("E", lat: lat89m, + start: now.addingTimeInterval(-600), + end: now.addingTimeInterval(3000)) + let items = order( + art: [artRow("A", lat: lat33m)], + camps: [campRow("C", lat: lat67m)], + events: [happeningEvent] + ) + + XCTAssertEqual(items.count, 3) + XCTAssertTrue(items[0].id.hasPrefix("event-"), "Events must be prioritized first") + XCTAssertEqual(items[1].id, "art-A", "Then nearest non-event (33 m)") + XCTAssertEqual(items[2].id, "camp-C", "Then next nearest (67 m)") + } + + func testItemsBeyondRadiusAreExcluded() throws { + let items = order( + art: [artRow("near", lat: lat33m), artRow("far", lat: lat133m)] + ) + + XCTAssertEqual(items.map(\.id), ["art-near"]) + XCTAssertFalse(items.contains { $0.id == "art-far" }) + } + + func testArtAndCampsSortedByDistance() throws { + let items = order( + art: [artRow("artFar", lat: lat67m)], + camps: [campRow("campNear", lat: lat33m)] + ) + + XCTAssertEqual(items.map(\.id), ["camp-campNear", "art-artFar"]) + } + + func testEndedEventIsExcluded() throws { + let endedEvent = eventRow("ended", lat: lat33m, + start: now.addingTimeInterval(-7200), + end: now.addingTimeInterval(-3600)) + let items = order(events: [endedEvent]) + + XCTAssertTrue(items.isEmpty, "Events that have ended should not appear") + } + + func testDuplicateIdsAreDeduped() throws { + let items = order(art: [artRow("dup", lat: lat33m), artRow("dup", lat: lat67m)]) + + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.id, "art-dup") + } + + func testResultIsCappedToMaxItems() throws { + let art = (0..<10).map { artRow("art\($0)", lat: lat33m) } + let items = order(art: art, maxItems: 4) + + XCTAssertEqual(items.count, 4) + } + + func testNoLocationObjectsAreDropped() throws { + // An art object with no GPS (location == nil) must be dropped. + let noGPS = ListRow( + object: ArtObject(uid: "noGPS", name: "noGPS", year: 2025), + metadata: nil, + thumbnailColors: nil + ) + let items = order(art: [noGPS, artRow("withGPS", lat: lat33m)]) + + XCTAssertEqual(items.map(\.id), ["art-withGPS"]) + } +} diff --git a/iBurnTests/RightNowCandidateTests.swift b/iBurnTests/RightNowCandidateTests.swift new file mode 100644 index 00000000..01fa41fe --- /dev/null +++ b/iBurnTests/RightNowCandidateTests.swift @@ -0,0 +1,155 @@ +// +// RightNowCandidateTests.swift +// iBurnTests +// +// Tests the pure (no-LLM) candidate gathering for the Right Now flow. +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import XCTest +import CoreLocation +import MapKit +@preconcurrency @testable import iBurn +@testable import PlayaDB + +#if canImport(FoundationModels) + +@available(iOS 26, *) +@MainActor +final class RightNowCandidateTests: XCTestCase { + + private var playaDB: PlayaDB! + + private let artUID = "a2IVI000000yWeZ2AU" + private let eventUID = "78ZvNxSeeZQbaeHuughD" + private let artCoord = CLLocationCoordinate2D(latitude: 40.79179890754886, longitude: -119.1976993927176) + + override func setUp() async throws { + try await super.setUp() + playaDB = try PlayaDBImpl(dbPath: ":memory:") + try await playaDB.importFromData( + artData: Self.artJSON, + campData: Self.campJSON, + eventData: Self.eventJSON, + mvData: Self.mvJSON + ) + } + + override func tearDown() async throws { + playaDB = nil + try await super.tearDown() + } + + private func region(around c: CLLocationCoordinate2D, delta: Double = 0.02) -> MKCoordinateRegion { + MKCoordinateRegion(center: c, span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)) + } + + private func brcDate(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date { + var components = DateComponents() + components.year = year; components.month = month; components.day = day + components.hour = hour; components.minute = minute + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .burningManTimeZone + return calendar.date(from: components) ?? Date.distantPast + } + + func testTimelessArtAppearsInNowWhenInRegion() async throws { + let now = brcDate(2025, 8, 28, 11, 30) + let result = try await gatherRightNowCandidates( + playaDB: playaDB, + region: region(around: artCoord), + origin: artCoord, + now: now, + windowStart: now, + windowEnd: now.addingTimeInterval(2 * 3600), + vibe: "", + lean: .balanced, + favoriteUIDs: [], + includeHappeningNow: true + ) + XCTAssertTrue(result.now.contains { $0.uid == artUID }, + "Art within the region should be a 'now' candidate") + } + + func testArtOutsideRegionIsExcluded() async throws { + let now = brcDate(2025, 8, 28, 11, 30) + let far = region(around: CLLocationCoordinate2D(latitude: 0, longitude: 0)) + let result = try await gatherRightNowCandidates( + playaDB: playaDB, + region: far, + origin: far.center, + now: now, + windowStart: now, + windowEnd: now.addingTimeInterval(2 * 3600), + vibe: "", + lean: .balanced, + favoriteUIDs: [], + includeHappeningNow: true + ) + XCTAssertFalse(result.now.contains { $0.uid == artUID }, + "Art outside the region should be excluded") + } + + func testUpcomingEventAppearsInNext() async throws { + let now = brcDate(2025, 8, 28, 11, 30) + // region nil → city-wide (event host has no GPS, so a region filter would drop it). + let result = try await gatherRightNowCandidates( + playaDB: playaDB, + region: nil, + origin: artCoord, + now: now, + windowStart: now, + windowEnd: brcDate(2025, 8, 28, 14, 0), + vibe: "", + lean: .balanced, + favoriteUIDs: [], + includeHappeningNow: false + ) + XCTAssertTrue(result.next.contains { $0.uid == eventUID }, + "Event starting within the window should be a 'next' candidate") + } + + func testSurpriseLeanExcludesFavorites() async throws { + let art = try await playaDB.fetchArt() + let first = try XCTUnwrap(art.first) + try await playaDB.toggleFavorite(first) + + let now = brcDate(2025, 8, 28, 11, 30) + let result = try await gatherRightNowCandidates( + playaDB: playaDB, + region: region(around: artCoord), + origin: artCoord, + now: now, + windowStart: now, + windowEnd: now.addingTimeInterval(2 * 3600), + vibe: "", + lean: .surprise, + favoriteUIDs: [first.uid], + includeHappeningNow: true + ) + XCTAssertFalse(result.now.contains { $0.uid == first.uid }, + "Surprise lean should exclude favorited items") + } + + // MARK: - Fixtures + + private static let artJSON = """ + [{"uid":"a2IVI000000yWeZ2AU","name":"Burning Questions","year":2025,"url":null,"contact_email":null,"hometown":"San Francisco, CA","description":"An interactive art installation exploring curiosity and wonder.","artist":"Jane Smith","category":"Open Playa","program":"Honorarium","donation_link":null,"location":{"hour":12,"minute":0,"distance":2500,"category":"Open Playa","gps_latitude":40.79179890754886,"gps_longitude":-119.1976993927176},"location_string":"12:00 2500', Open Playa","images":[],"guided_tours":false,"self_guided_tour_map":true}] + """.data(using: .utf8)! + + private static let campJSON = """ + [{"uid":"a1XVI000008zSaf2AE","name":"Camp ASL Support Services HUB","year":2025,"url":null,"contact_email":null,"hometown":"All over","description":"American sign language Support services.","landmark":"ASL sign","location":{"frontage":"Esplanade","intersection":"6:30","intersection_type":"&","dimensions":"75 x 110","exact_location":"Mid-block facing 10:00"},"location_string":"Esplanade & 6:30","images":[]}] + """.data(using: .utf8)! + + private static let eventJSON = """ + [{"uid":"78ZvNxSeeZQbaeHuughD","title":"Fairycore Tarot Meetup","event_id":51138,"description":"First time picking up cards? All levels welcome","event_type":{"label":"Class/Workshop","abbr":"work"},"year":2025,"print_description":"","slug":"78ZvNxSeeZQbaeHuughD-fairycore-tarot-meetup","hosted_by_camp":"a1XVI000009t6XR2AY","located_at_art":null,"other_location":"","check_location":false,"url":null,"all_day":false,"contact":null,"occurrence_set":[{"start_time":"2025-08-28T12:00:00-07:00","end_time":"2025-08-28T13:30:00-07:00"}]}] + """.data(using: .utf8)! + + private static let mvJSON = """ + [{"uid":"a6BVI000000Xf1r3BC","name":"Dragon Wagon","year":2025,"description":"A fire-breathing dragon on wheels","artist":"Fire Arts Collective","hometown":"Portland, OR","url":null,"contact_email":null,"donation_link":null,"images":[],"tags":["Fire","Dragon"]}] + """.data(using: .utf8)! +} + +#endif diff --git a/iBurnTests/TimeOfDayTests.swift b/iBurnTests/TimeOfDayTests.swift new file mode 100644 index 00000000..4e137461 --- /dev/null +++ b/iBurnTests/TimeOfDayTests.swift @@ -0,0 +1,97 @@ +// +// TimeOfDayTests.swift +// iBurnTests +// +// Created by Claude Code on 5/29/26. +// Copyright © 2026 Burning Man Earth. All rights reserved. +// + +import XCTest +@testable import iBurn + +final class TimeOfDayTests: XCTestCase { + + private var brcCalendar: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = .burningManTimeZone + return c + } + + /// A moment comfortably inside the festival (~2.5 days after gates). + private var midFestival: Date { + YearSettings.eventStart.addingTimeInterval(2.5 * 86400) + } + + func testNowWindowIsNextTwoHours() { + let now = midFestival + let window = TimeOfDay.now.dateWindow(now: now) + XCTAssertEqual(window.start, now) + XCTAssertEqual(window.end.timeIntervalSince(now), 2 * 3600, accuracy: 1) + } + + func testAllPeriodsHaveStartBeforeOrEqualEnd() { + let now = midFestival + for tod in TimeOfDay.allCases { + let window = tod.dateWindow(now: now) + XCTAssertLessThanOrEqual(window.start, window.end, "\(tod) start should be <= end") + } + } + + func testNamedPeriodsAreOrderedAcrossTheDay() { + let now = midFestival + let order: [TimeOfDay] = [.sunrise, .morning, .midday, .afternoon, .evening, .night] + let starts = order.map { $0.dateWindow(now: now).start } + for i in 1..= 3 ? days[2] : days.first) + let window = TimeOfDay.morning.dateWindow(on: interior) + XCTAssertEqual(brcCalendar.startOfDay(for: window.start), + brcCalendar.startOfDay(for: interior), + "Morning window should fall on the selected day") + } + + func testWindowsOnConsecutiveDaysAreOrdered() { + let days = YearSettings.festivalDays + guard days.count >= 2 else { return } + for i in 1..