Get Started Checklist
Step completion is 100% DERIVED from real data at read time. The (app) layout load gathers raw signals and src/lib/onboarding/checklist.ts turns them into renderable steps. Steps auto-complete retroactively and can regress if the underlying state does (for example a calendar connection going invalid). The only persisted piece is the widget's UI preference on user_profiles.onboarding_checklist (20260711130000_onboarding_checklist_state.sql).
The seven steps
computeChecklist() builds this fixed list:
| Step id | Completion signal | Deep link | Docs path (docsUrl()) |
|---|---|---|---|
create-podcast | podcastCount > 0 | /p/new | /getting-started/create-your-podcast |
connect-calendar | Any valid calendar connection for the USER (user-level, managed from podcast settings) | /p/{slug}/settings/calendars | /settings/calendar-integration |
create-booking-link | bookingLinkCount > 0 | /p/{slug}/booking-links/new | /booking/create-booking-link |
create-episode | episodeCount > 0 across membership podcasts | /p/{slug}/e/new | /getting-started/publish-first-episode |
invite-guest | guestCount > 0 (episode guests) | /p/{slug}/guests | /guests/invite-guests |
complete-profile | Name (display or full) AND bio AND avatar (isProfileComplete) | /settings | /settings/account |
activate-automation | enabledAutomationCount > 0 (ENABLED rules only; seeded disabled starters do not complete it) | /p/{slug}/automations | /automation/flow-builder |
Signals (ChecklistSignals) are gathered in src/routes/(app)/+layout.server.ts as head-counts across the user's membership podcasts, plus the profile fields already loaded for the layout. The whole load is fail-open: loadOnboardingChecklist() wraps everything in a try/catch returning null, and the checklist state column is read inside that guard so a code deploy that outruns the db push can never break the profile path.
Locked steps
Podcast-scoped steps before the first podcast exists render with href: null and lockedReason: 'Create your podcast first'; the component shows them disabled with a lock icon. create-podcast and complete-profile are always actionable.
The upsell step (automation cap 0)
The layout load also resolves the account's effective automation_rules_per_account cap and passes it through:
0(Free without slot overrides): activating a starter is impossible without a plan change, so the step carriesupsellKey: 'automation_rules_per_account'and the component renders it as a click-time upgrade surface (showUpgradePrompt(buildLimitGate(...))) instead of a link.- A positive number,
null(unlimited), orundefined(cap not loaded) all render the normal link; the API's 409 is the backstop for the unresolved case. The test suite pins that only exactly0produces the upsell.
Staleness: re-derivation after navigations and toggles
The checklist derives from the (app) layout load, which SvelteKit only reruns when a tracked dependency changes. Same-podcast navigations do not rerun it, so a step completed on one page (for example creating a booking link) would render stale until a hard refresh. Two mechanisms close this:
- The load declares
depends('app:onboarding'). afterNavigateinsrc/routes/(app)/+layout.sveltecallsinvalidate('app:onboarding')after every client-side navigation (skippingtype === 'enter', since the initial load is fresh from SSR) while the checklist is visible. Once dismissed or complete the load short-circuits and the invalidation costs nothing.- The widget's own toggles PATCH the state and then
invalidate('app:onboarding'), so fresh server data replaces the optimistic override. The override is keyed on the server value it overrode: once fresh data lands, the base no longer matches and the override is ignored, with no$effectneeded.
Docs links and the lockstep contract
Every step carries a docsHref built with docsUrl() from src/lib/constants/docs.ts (https://docs.podcasterplus.com + path); the component renders it as a help icon that opens in a new tab. Two CI tests keep help links honest:
src/lib/onboarding/__tests__/checklist.test.tsasserts every step carries an absolutedocs.podcasterplus.comlink.- The docs-program enforcement suite (
src/lib/tooltips/__tests__/registry.test.ts) verifies that every docs-public path claimed by the coverage ledger (docs/planning/docs-ledger.md) resolves to a real file underdocs-public/srcvia the cleanUrls 1:1 mapping, so claimed paths cannot rot silently. The checklist's docs paths participate through their ledger rows.
The constants file's own contract: keep paths in lockstep with the docs-public sidebar (docs-public/src/.vitepress/config.ts).
Stored UI state and the API
OnboardingChecklistState is expanded | minimised | dismissed, parsed defensively (parseChecklistState defaults anything malformed to expanded).
PATCH /api/user/onboarding (src/api/routes/user/onboarding.ts, requireAuth() + zod enum) writes { state } to the caller's own user_profiles row. Because requireAuth() provides a service-role client, the update is explicitly scoped to eq('id', user.id).
Widget behavior (GetStartedChecklist.svelte):
- Two variants:
sidebar(collapsed nag with a progress bar; the expanded panel overlays the sidebar items above it) anddrawer(top-of-drawer, expands inline as an accordion). - Expand/minimise toggles and Escape both persist through the same PATCH.
- Dismiss persists
dismissedand toasts "re-open it anytime from Settings"; the re-open control lives onsrc/routes/(app)/settings/+page.svelteand PATCHes the state back. - The widget hides entirely when
allCompleteor dismissed.