Skip to content

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 idCompletion signalDeep linkDocs path (docsUrl())
create-podcastpodcastCount > 0/p/new/getting-started/create-your-podcast
connect-calendarAny valid calendar connection for the USER (user-level, managed from podcast settings)/p/{slug}/settings/calendars/settings/calendar-integration
create-booking-linkbookingLinkCount > 0/p/{slug}/booking-links/new/booking/create-booking-link
create-episodeepisodeCount > 0 across membership podcasts/p/{slug}/e/new/getting-started/publish-first-episode
invite-guestguestCount > 0 (episode guests)/p/{slug}/guests/guests/invite-guests
complete-profileName (display or full) AND bio AND avatar (isProfileComplete)/settings/settings/account
activate-automationenabledAutomationCount > 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 carries upsellKey: '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), or undefined (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 exactly 0 produces 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').
  • afterNavigate in src/routes/(app)/+layout.svelte calls invalidate('app:onboarding') after every client-side navigation (skipping type === '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 $effect needed.

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.ts asserts every step carries an absolute docs.podcasterplus.com link.
  • 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 under docs-public/src via 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) and drawer (top-of-drawer, expands inline as an accordion).
  • Expand/minimise toggles and Escape both persist through the same PATCH.
  • Dismiss persists dismissed and toasts "re-open it anytime from Settings"; the re-open control lives on src/routes/(app)/settings/+page.svelte and PATCHes the state back.
  • The widget hides entirely when allComplete or dismissed.

Internal documentation - Not for public distribution