Podcast Overview
/p/[slug] is the app's landing surface for a show. It is a triage screen: a ranked queue of what needs the viewer, the production pipeline, the next recording, performance, and a per-podcast activity feed.
Everything it renders is derived server-side into one view model. The page decides layout; it never decides eligibility.
Shape
src/lib/overview/types.ts OverviewModel — the wire contract
src/lib/overview/derive.ts pure derivations (state, queue, pipeline, downloads, feed)
src/lib/overview/actions.ts the two inline mutations the page can run
src/lib/components/overview/ the card kit (tone.ts, icons.ts, 12 components)
src/routes/(app)/p/[slug]/+page.server.ts the reads
src/routes/(app)/dashboard/ the guest-only variant (state A)Icons cross the load boundary as names (OverviewIcon), not components: a Svelte component cannot be serialised through a load. components/overview/icons.ts maps every name to a lucide component and a test asserts the union and the map stay in lockstep.
The nine states
resolveState() names which composition a viewer is looking at. Order matters — a suspended feed outranks everything, and the co-host view outranks the content-driven states because it changes what MAY be shown rather than what there is to show.
| State | Condition |
|---|---|
A | guest-only user (no podcast membership) → /dashboard |
S | overage_suspended_at set, or status = pending_deletion |
M | role = 'member' (co-host) |
D/DU | hosting_type = 'external'; DU when an episode is unmatched |
E | no episodes and the setup checklist is unfinished |
B | published episodes exist, no directory submissions in flight |
CLEAR | established, empty queue |
C | established, non-empty queue |
The state id is carried on the model for tests and analytics. No card branches on it: each decides its own visibility from its own data.
Where each figure comes from
Production pipeline
| Stage | Source | Opens |
|---|---|---|
| Booked | bookings pending+confirmed, future, distinct session_id | /p/{slug}/bookings |
| To edit | episodes.status = 'draft' | /p/{slug}/e?status=draft |
| Processing | media_jobs + ai_jobs in (pending, running), deduped by episode | /p/{slug}/e?processing=1 |
| Scheduled | episodes.status = 'scheduled' | /p/{slug}/e?status=scheduled |
| Published | episodes.status = 'published' | /p/{slug}/e?status=published |
The caption beside the stages counts the three mutually exclusive episode statuses only. Booked counts sessions rather than episodes, and Processing overlaps whichever status the episode already holds, so summing all five would report a draft with a running transcription twice and call the result a number of episodes.
"Processing" is a state of the job planes, not of episodes.status, so the episodes list gained a ?processing=1 filter that resolves the two job tables to a set of episode ids and intersects it with the other filters. An empty set uses the impossible-id sentinel rather than silently dropping the filter, and the page counts it as an active filter so an empty result reads "No episodes match" rather than "No episodes yet".
Needs-you queue
buildQueue() takes a QueueSignals object of already-scoped rows and returns a ranked QueueItem[]. Rank is "what blocks a release first":
- booking requests (the only rows that expire on their own)
- episodes blocked from publishing
- directory submission blocked
- failed transcription
- unconfirmed episodes
- failed automations
- mentions (rank 0 for a co-host — the only thing addressed to them)
- unread episode chat
- guests who have never opened their portal
- drafts with no booking
- AI credits at or above 80%
Staff-only rows are gated on role !== 'member' inside the builder, so a co-host cannot see a booking request even if a query leaked one.
"Blocked from publishing" is scoped to scheduled episodes and uses the episode editor's own buildReadinessItems(), so the queue and the editor agree on what "ready" means. Drafts are not blocked, they are unfinished, and the pipeline already counts those.
Downloads
Owner-only, not staff-only. The aggregate tables' RLS is is_podcast_owner (20260808110000_team_roles_and_episode_roster.sql), so a Producer reads an empty set with no error. Gating the card on isStaff would render a confident zero instead of no card, so the reads and the card follow role === 'owner'.
60 days of analytics_podcast_daily split into the current 30 and the previous 30, with absent days filled to zero so both series are the same length and the comparison line is not stretched over the gaps. Both are drawn against one shared maximum.
The "strongest recent episode" is a single indexed read of analytics_episode_totals filtered to first_day inside the window: one row per episode, ranked by what it has earned since going live. An all-time ranking would return the same old episode every month.
The card is omitted entirely for externally hosted shows and for co-hosts.
Episode links (external hosting)
episodes.external_link_status ∈ pending | linked | unmatched, counted into the three rows the card renders. The unmatched row is the only one with an action, and only for staff.
What changed
notifications for (podcast_id, user_id), newest ACTIVITY_FEED_LIMIT, grouped into Today / Yesterday / Earlier in the viewer's timezone. activityIcon() maps the notification type onto an icon by pattern and falls back rather than breaking on an unknown type.
The card is a preview, so the row limit is a display length and NOT a tally: the "N new" badge is a separate exact count of everything unread, and the footer strip's "Open notifications" is the overflow path. One constant serves the Overview and the guest-only home so the two cannot drift.
"Mark all read" posts to /api/notifications/read-all?podcastId=…. The scope was added for this card; the mutation still filters user_id = user.id, so a foreign or garbage podcast id matches nothing rather than reaching another tenant's rows.
Inline mutations
Only two, both in $lib/overview/actions.ts:
runQueueIntent()— accept or decline booking requests. Bulk rows fan out over their ids and fail as a unit, so a partial failure reverts the row rather than showing "Accepted" over a half-applied change.MAX_BULK_BOOKINGSis applied in the BUILDER, so the ids, the label and the request describe the same set; the action throws on a longer list rather than silently resolving a subset.ActionQueueCardalso drops its optimistic resolved id after revalidating, because row ids are stable across loads and a lingering one would show "Accepted" over requests the fresh data says are still open.markPodcastActivityRead()— the feed's bulk clear.
Both take the access token from the browser Supabase client. Everything else on the surface is a link.
Two reading orders from one DOM
Desktop is a 1fr / 420px grid with the pipeline spanning both columns above it. Mobile is a single stack in triage order — needs you, pipeline, next recording, downloads, what changed — which interleaves the two columns.
The column wrappers therefore go display: contents below lg, making every card a direct flex item that order-* can place, and become real columns again at lg where order-none hands placement back to the grid. The DOM order is the desktop order.
The mobile section jumper ($lib/components/shared/SectionJumper.svelte) lists the sections in visual order and is the navigation path for the reflow. It is the same component the settings screens use; the shared scrollspy lives in shared/scrollspy.svelte.ts and the scoped scroller in shared/scroll.ts.
Setup checklist ownership
A brand-new show renders the seven-step checklist as the main event. It is computeChecklist(), but over this podcast's signals rather than the shell's: the shell checklist is account-wide (its episode, guest and booking counts span every membership) and graduates permanently the first time a user completes it, so a second, empty show in an established account would get no checklist AND a pipeline of five zeros — exactly the state the design replaces. Only the two genuinely user-level signals, the calendar connection and the profile, are borrowed from the shell. The four podcast-scoped head counts are read only when the show has no episodes.
Two checklists on one screen is one too many, so (app)/+layout.svelte suppresses the shell widget whenever $page.data.overview?.checklist is set, and shows it everywhere else.
Archived is a real episode status, so "has no episodes" counts it: an archived-only show is established, not new.
Guest-only home
/dashboard redirects anyone with a podcast to that show's Overview. What is left is the guest-only viewer, who gets state A at the same address: their episodes (merged from the linked-account and unclaimed-email reads, deduped by episode), an expected-of-you queue, their next recording with prep rows, and the account-wide activity feed. They cannot bulk-clear the feed.
Accessibility
Every card is a <section> with an aria-labelledby heading. Mobile targets are 44px. Small quiet text uses text-muted-foreground (not a further alpha step) and small text on tinted chips uses text-foreground/80, both of which clear AA; the brand purple is lightened to purple-400 in dark mode for TEXT roles only, because the app's dark --primary sits below AA on bg-secondary (a pre-existing token issue the settings screens share — backgrounds are untouched here).