Skip to content

AI summary: speakers without a job title#45

Open
sethlaw wants to merge 15 commits into
mainfrom
speaker-ai-summary
Open

AI summary: speakers without a job title#45
sethlaw wants to merge 15 commits into
mainfrom
speaker-ai-summary

Conversation

@sethlaw

@sethlaw sethlaw commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Speakers list rows now show a one-line AI-generated summary in the subtitle slot when the speaker has no title AND the user has Apple Intelligence summaries enabled
  • Same 100-char min, same sparkle styling, same caching as talk cells
  • SummarizableTalk protocol grows summaryCacheKey + summaryKind so Speaker can opt in without colliding with Content/Event id space
  • Cache storage migrates from [Int: Entry] to [String: Entry] with a one-shot legacy decode path so users keep their existing talk summaries

Test plan

  • iOS 26 device with Apple Intelligence enabled + AI Summaries toggled on: speakers without a title show the sparkle summary line
  • Speakers with a title show the title (no sparkle)
  • Speakers with a bio shorter than 100 chars show neither
  • AI Summaries toggled off: no sparkle anywhere, even for title-less speakers
  • Existing talk summaries still resolve after the cache migration

🧙 Built with WOZCODE

sethlaw and others added 15 commits June 20, 2026 21:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant