AI summary: speakers without a job title#45
Open
sethlaw wants to merge 15 commits into
Open
Conversation
Speakers list rows now show a one-line AI-generated summary in place
of the missing job-title/subtitle when:
- the user has Apple Intelligence summaries enabled (Settings →
AI Summaries — same toggle that drives talk summaries)
- the speaker's `title` is nil or whitespace-only
- the speaker's bio is ≥ 100 chars (the cache's existing
minDescriptionChars guard — short bios aren't worth summarizing)
The sparkle + caption layout matches EventCell and ContentCell so
the affordance reads consistently across the three list types.
Speakers with a real title keep showing that title verbatim — no
overlap with AI text.
Implementation:
- `SummarizableTalk` protocol gains `summaryCacheKey: String` and
`summaryKind: SummaryKind` with default implementations. Speaker
overrides both to namespace its cache entries ("speaker:\(id)")
and select the speaker-flavored prompt. Content + Event keep the
defaults so existing cache entries continue to resolve under their
bare integer keys.
- TalkSummaryCache storage migrates from `[Int: Entry]` to
`[String: Entry]`. Legacy persisted payloads (the previous
`[Int: Entry]` JSON) are detected and folded forward in `load()`
so users don't lose their existing talk summaries.
- New prompt variant for `.speaker`: optimized for "what is this
person known for, in 10 words" rather than the talk prompt's
"what will the audience learn".
Co-Authored-By: WOZCODE <contact@withwoz.com>
Subtitle fallback chain in SpeakerRow is now:
1. Speaker has a real title → show title.
2. No title, bio under 100 chars → show bio verbatim (no AI
needed, no toggle dependency).
3. No title, bio ≥ 100 chars, AI Summaries on, cache has a summary
→ show the sparkle + AI summary.
4. Otherwise → just the name, no subtitle.
The 100-char threshold matches TalkSummaryCache.minDescriptionChars
so short bios are never invisible (the cache wouldn't summarize them
anyway). The warm-the-cache gate now also requires bio.count ≥ 100
so we don't burn battery generating summaries for bios that already
fit as subtitles.
Co-Authored-By: WOZCODE <contact@withwoz.com>
The sparkle + summary line in EventCell, ContentCell, and SpeakerRow used .foregroundStyle(.secondary), which on dark themed cardSurfaces (Hacker Green, Synthwave, Vegas, DEF CON Red dark variants) faded into illegibility — .secondary derives from system label tones that were calibrated for system backgrounds, not the theme palette. Match the speaker-title gray that's been working fine on every themed surface: .foregroundColor(.gray) — a fixed mid-gray that stays readable across all light/dark theme variants without adapting itself out of contrast. Co-Authored-By: WOZCODE <contact@withwoz.com>
NotificationUtility.swift — `UNNotificationRequest` isn't yet annotated Sendable in UserNotifications. The non-Sendable capture in `addNotification`'s closure tripped strict-concurrency diagnostics. Add `@preconcurrency` to the import so the framework's Sendable diagnostics downgrade to warnings until Apple ships the annotations. Theme.swift — `applyNavBarAppearance` touches `UINavigationBar.appearance()`, `UINavigationBarAppearance.init`, and the various `*Appearance` mutators which are all `@MainActor`-isolated under Swift 6. Mark `ThemeManager` `@MainActor` so its static + instance methods can call those APIs cleanly. Every call site is already on the main actor (SwiftUI view bodies, `@State` init, the App's `init()` which runs under the `@MainActor`-isolated `App` protocol). Co-Authored-By: WOZCODE <contact@withwoz.com>
# Hidden gate The "AI Summaries" toggle in Settings is the primary on/off for talk summaries. Speaker bios get a separate, hidden gate so the feature ships behind a discoverable chord rather than as a top-level toggle: - New @AppStorage("speakerAISummaries") bool, default false. - AISummarySettingsView gains a 7-tap chord on the main row label. Once triggered (or whenever speakerAISummaries is already true), a "Speaker bios (experimental)" Toggle appears below the main caption with its own footer copy. - SpeakerRow's AI summary branch now requires aiSummaries AND speakerAISummaries — and the cache-warming task uses the same combined gate so we never generate summaries that won't display. # Tag chip rollup SpeakerRow now rolls up the unique tag IDs across every event a speaker is associated with, then renders them through the existing ShowEventCellTags component. Speakers list now reads as the same design family as schedule / content cells: leading color stripe, name + subtitle, then a chip strip showing Event Category, Organizer, etc. — whichever browsable tags the speaker's work falls under. Skipped when the speaker has no events yet (cold load, or a speaker disconnected from the catalog). Performance: SpeakerData uses lazy materialization so only visible rows compute their tag rollup. Pool is bounded by viewModel.events which is already in-memory. # Filter pass — planned, not implemented Speakers list filter is on the followup list: floating filter circle on SpeakersView, sheet sharing MatchModePickerRow + FilterMatchCountLabel chrome, chip pool derived from the union of tag IDs across all speakers' events (much smaller pool than the schedule's), new @AppStorage("filterMatchModeSpeakers") so modes stay independent, Has Notes pseudo-tag (no Bookmarks — n/a to speakers). Co-Authored-By: WOZCODE <contact@withwoz.com>
Closes the filter portion of the speakers polish that was previously
just a plan.
# What ships
- Floating filter circle on SpeakersView (leading side of the bottom
overlay, matching Schedule's affordance placement). Icon switches
to filled when any chip is selected.
- New SpeakerFiltersSheet — same chrome as EventFilters
(MatchModePickerRow + FilterMatchCountLabel + tagtype-grouped chip
grid + Clear/Done toolbar). Distinct because it talks to a
separate state store.
- Speakers list is now filtered through the same Any/All semantics
used by Schedule + All Content. Each speaker's "rolled-up" tag
IDs (union across their events, same set that drives the chip
strip) is checked against the selected filter set.
- Tag pool is pre-narrowed to only tags that at least one visible
speaker is connected to — a smaller pool than the schedule's
filter sheet, because speakers are bounded by participation.
# Plumbing
- New `SpeakerFiltersStore: ObservableObject` (distinct class from
`Filters` so both can sit in the SwiftUI environment without type
collision). Holds `Set<Int>` of selected tag IDs.
- Injected as `@StateObject` in ContentView and threaded through
every existing `.environmentObject(filters)` site.
- New `@AppStorage("filterMatchModeSpeakers")` so the speakers
filter mode is independent of the schedule's.
- New `SpeakerFilterRow` chip view that reads `SpeakerFiltersStore`
directly — separate struct from `FilterRow` (which reads
`Filters`) so EnvironmentObject typing stays unambiguous.
# Not yet wired
Pseudo-tag chips (Bookmarks / Has Notes / Custom Events) — none
currently apply to speakers. Bookmarks aren't a speaker concept;
Notes aren't authored on speakers; CustomEvents are user-created
schedule entries. Easy to add per-pseudo-tag later when one becomes
meaningful for this list.
Co-Authored-By: WOZCODE <contact@withwoz.com>
These tagtypes live on events (a talk has a skill level, a modality — hybrid / online / in-person), but they don't read as useful speaker metadata. A speaker isn't "Beginner" or "Hybrid"; their talk is. Surface only the categorical / organizational tags (Event Category, Organizer, etc.) on the speakers list. Added `SpeakerListConfig.excludedTagTypeLabels` next to `SpeakerFiltersStore` in Filters.swift so all three call sites share the same exclusion list: - SpeakerRow's chip rollup - SpeakersView's availableTagTypes (filter sheet chip pool) - SpeakersView's tagIds(for:) (filter pipeline match) Exclusion happens by tagtype.label rather than id, because tagtype ids vary across conferences but the canonical labels are stable in the Firestore data. Co-Authored-By: WOZCODE <contact@withwoz.com>
# Perf
`tagIds(for:)` was re-scanning `viewModel.events.filter { ... }`
for every speaker on every render — O(speakers × events) per body
evaluation. With ~100 speakers × ~1000 events that's 100K scans per
keystroke and per chip toggle.
Precompute a `[speaker.id: Set<tag.id>]` map in @State, rebuilt only
when the underlying lists actually change (via
`.task(id: "<counts>")`). filteredSpeakers, availableTagTypes, and
the chip-pool union all read from the cached map — O(speakers) per
render, O(1) lookup per speaker.
Also exposed `InfoViewModel.eventsById` as `private(set)` so the
chip-rollup in `SpeakerRow` can do O(1) event lookups instead of
O(speaker.eventIds × all events) `viewModel.events.filter` scans
for each visible row.
# "Tool" chip cleanup
Speakers were sometimes showing a "Tool" chip — either a tagtype
labeled "Tool" or a stray tag from a tagtype not displayed by the
filter sheet. Two defenses:
1. Added "Tool" to `SpeakerListConfig.excludedTagTypeLabels`. If
"Tool" is a tagtype label, it's now dropped at the source.
2. Both the chip rollup (SpeakerRow) and the filter pipeline
(SpeakersView) now intersect speaker tag IDs against the SAME
eligibility set (browsable + category=="content" + not in
excluded labels) the filter sheet uses to render chips. Rogue
tag IDs from non-displayed tagtypes can't leak into either
surface — chips and filter stay in lock-step.
Co-Authored-By: WOZCODE <contact@withwoz.com>
ShowEventCellTags used a LazyVGrid with adaptive 100pt columns,
which on typical card widths yielded a 2- or 3-chip grid that
wrapped longer labels into multi-row stacks of unequal height
("Hands-on Workshop" jumping to its own row, then half a chip
of "Cloud Village CTF" wrapping below, etc.).
Switch to a single-row layout: HStack inside a horizontal ScrollView.
Every chip is naturally sized to its label width via lineLimit(1) +
fixedSize, so chips stay tight to their text. If a row has more
chips than fit, the row scrolls horizontally — touch + drag,
indicator hidden so it reads as a clean visual rail.
Used by EventCell (Schedule), ContentCell (All Content), and
SpeakerRow (Speakers list) — same component renders all three.
The `minWidth` parameter on the component is retained for source
compatibility but is now a no-op; the new layout doesn't need a
column width.
Co-Authored-By: WOZCODE <contact@withwoz.com>
# Blue chips SpeakerRow's NavigationLink wrapper (iPhone path) was missing `.buttonStyle(.plain)`. Without it, NavigationLink tints every child view with the system accent color (system blue) — which painted the chip dots blue regardless of each tag's actual colorBackground. ContentListView + EventsView wrap their NavigationLinks with .buttonStyle(.plain) for exactly this reason. Match them. # Blank filter sheet `availableTagTypes` was reading the precomputed `speakerTagIdsMap`, which rebuilds asynchronously via `.task(id:)`. When the user opens the filter sheet before the map has populated (race during cold load, or a fast tap), the chip pool is empty → no tagtypes render → blank sheet. Decouple: compute the chip pool directly from raw data with `viewModel.eventsById` for O(1) lookups instead of the cached map. This computation only runs when the sheet actually opens, so the O(speakers × avgEvents) cost is one-shot, not per-render — no perf regression. The cached map is still used by `filteredSpeakers` per keystroke / chip toggle, where avoiding the rescan was the actual hot path. Co-Authored-By: WOZCODE <contact@withwoz.com>
Each speaker row now shows the events they're presenting between
the subtitle (job title / short bio / AI summary) and the tag chip
strip. Layout:
• SF Symbol "calendar" + comma-joined unique event titles
• Caption font + gray (matches the chip + AI summary treatment)
• lineLimit(2) so a speaker with many talks doesn't blow up the
row height
Duplicate titles are de-duped (a speaker with two slots of the same
"Hands-on Workshop" shouldn't list it twice). Order preserved from
`speaker.eventIds`. Whitespace-only titles filtered out.
Reads `viewModel.eventsById` for O(speaker.eventIds) lookup — no
viewModel.events scan per row.
Co-Authored-By: WOZCODE <contact@withwoz.com>
Speaker subtitle, AI summary line, event-titles line, and tag chip labels were all .foregroundColor(.gray) (~50% mid-gray). Reading muted on most surfaces but inconsistent — chip labels sometimes looked dimmer than the subtitle in the same row. New `ThemeColors.muted = Color.primary.opacity(0.75)`: - Brighter than .gray (~75% of primary vs ~50% fixed gray). - Adapts to theme — primary is the system label color, so muted reads lighter on dark themes and darker on light themes. - Still clearly subordinate to the row title (primary). Applied to: - SpeakerRow title / short bio / AI summary sparkle+text / event titles sparkle+text (6 sites) - ChowEventCellTags chip text label (1 site, affects all three list types) - EventCell AI summary sparkle+text (2 sites) - ContentCell AI summary sparkle+text (2 sites) Co-Authored-By: WOZCODE <contact@withwoz.com>
A user searching the Speakers list for "BadgeLife" should find the speakers presenting that talk even if their name + bio don't mention it. Extend `[Speaker].search(text:)` to also match against the speaker's event titles when caller passes in an `eventsById` lookup. SpeakersView's filteredSpeakers now calls the new overload with viewModel.eventsById. GlobalSearchView still uses the single-arg form since it has its own per-list grouping and shouldn't double- match speakers via talk titles. Co-Authored-By: WOZCODE <contact@withwoz.com>
Merch size selections lived in ProductsView's @State, so they reset on every tab switch. Hoist into a new MerchFiltersStore injected from ContentView (same pattern as Filters / SpeakerFiltersStore) so the selection survives tab switches. All three filter stores (Filters / SpeakerFiltersStore / MerchFiltersStore) now persist via UserDefaults JSON on every @published change and rehydrate on init, so chip selections survive cold launches. Merch also gets its own filterMatchModeMerch AppStorage key — it was sharing filterMatchMode with Schedule + All Content, which meant toggling Any/All on one list silently flipped it on the others. Co-Authored-By: WOZCODE <contact@withwoz.com>
Each Settings row was a flat VStack with a trailing Divider. Wrap them in a shared .settingsCard(themeManager) modifier that mirrors the schedule / content cell styling (themed cardSurface, 10pt corners, 8/3 outer padding) so the Settings tab reads as the same card-based surface the rest of the app uses. Drops the row Dividers since the cards provide visual separation; folds the Notifications heading, stepper, and "remove all" button into one card. Co-Authored-By: WOZCODE <contact@withwoz.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
titleAND the user has Apple Intelligence summaries enabledSummarizableTalkprotocol growssummaryCacheKey+summaryKindso Speaker can opt in without colliding with Content/Event id space[Int: Entry]to[String: Entry]with a one-shot legacy decode path so users keep their existing talk summariesTest plan
🧙 Built with WOZCODE