Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesDashboard ShellBehavior — dashboard shell

Behavior — dashboard shell

Schedule, Coaching, Ways to train, Workouts posted-by-day, and Spotter week-building are route-level behavior. None of it changes dashboard chrome: DashboardSidebar and the original header render for every organization.

Flat navigation

DashboardSidebar shows all permitted destinations at once, under the existing Clients, Training, Business, and Grow labels. It remains responsible for role, tier, reports, and organization-type visibility. There is no Train / Track / Thrive switcher, no permanent alternate top bar, and no dashboard-entry redirect to Home.

Training contains only enabled Schedule and Cohorts destinations, plus Workouts and Movements. Courses is intentionally hidden, and Programs has no sidebar entry: /dashboard/programs forwards to Ways to train.

Placement map

apps/web/src/config/dashboard-sections.ts (ROUTE_PLACEMENT) classifies every top-level /dashboard/* route for the optional Home cards and the route inventory spec. It does not control sidebar visibility.

Home cardRoutes
TrainSchedule, Coaching, Workouts, Movements, Messages, Programs
TrackOverview, Members, Payments, Plans, Analytics, Forms, Tasks, Join links
ThriveLeads, Minisite, Automations
OtherSettings, Audit log, Courses, Home

programming also has a placement entry (train) purely so the route inventory spec keeps covering it — the route itself only ever redirects (see Schedule below).

Section greeting and stats strip

Schedule and Coaching each open with the same two building blocks, shared via apps/web/src/components/dashboard-shell/section-greeting.tsx and apps/web/src/components/programs/stats-strip.tsx:

  • SectionGreeting — the Overview greeting (time-of-day + first name + localized date), byte-for-byte the same markup OverviewV2 renders, under a small section-name eyebrow (data-testid="section-greeting", -eyebrow, -title). An optional action renders at the row’s end (Coaching’s “New coaching plan” button). Overview keeps its own copy of this markup rather than importing the shared component — it has no eyebrow and its header carries surrounding layout SectionGreeting doesn’t need.
  • StatsStrip — a row of the dashboard’s standard StatCard tiles (apps/web/src/components/overview/stat-card.tsx, the same tile Members and Payments use), one per metric ({id, icon, value, label, subtitle}), each fading/sliding in with a small stagger; while loading, same-height skeletons hold the row. Column count tracks tile count: 4 tiles cap at lg:grid-cols-4, 5 use lg:grid-cols-3 xl:grid-cols-5. The program detail page keeps its own compact ProgramStatCard (apps/web/src/components/programs/program-stat-card.tsx).

Schedule

/dashboard/schedule — the class-schedule way to train. One destination, one surface: the existing schedule grid (WeeklyCalendar), with its existing expanded and condensed views and its existing Set up timetable menu. No Timetable/Programming tabs, no program selector — an org has at most one active schedule program, so ScheduleScreen (apps/web/src/app/[lang]/(protected)/dashboard/schedule/schedule-screen.tsx) just resolves that program and renders WeeklyCalendar programId={that program's id}. No schedule program: redirects to Workouts, same emptystate pattern as Coaching’s.

Above the grid: <SectionGreeting eyebrow={t.title} />, then a StatsStrip for the week the calendar is currently showing (useWeekUrlState, same ?weekStart= param the calendar itself reads). Four tiles sourced from GET .../programs/:id?weekStart= (the same detail-stats endpoint the program page uses): Sessions, Bookings, Members, Fill rate. A fifth tile, WODs posted ({published}/{classDays} for the week): classDays counts the week’s board cells with state !== 'no_session', published counts state === 'published', read off the same board query (path + query key) WeeklyCalendar already fires for its WOD chips, so react-query serves both from one network call.

/dashboard/schedule (schedule/page.tsx) renders ScheduleScreen directly. Links that still carry ?programId= land on the same screen; the param is ignored, and ?weekStart= / ?session= are read as usual.

The WOD week board, folded into the condensed view

There is no standalone Programming board or route — the programming week board is folded into WeeklyCalendar’s condensed view directly:

  • The class-day workout shows on the sessions themselves: the expanded card’s workout line and, on the condensed row, a muted workout name (data-testid="condensed-session-workout", “+N” when a day carries more than one). There is no separate WOD layer on Schedule — no chips, dots or day sheet; the workout is set from the session detail sheet with its day / session scope, and the week’s coverage is the “Workouts set” stat tile above the grid.
  • On a schedule class day, posting is publishing: DailyProgrammingService.set defaults status to published when the class type’s program is schedule-mode (coaching class types keep the draft default). Drafts remain a coaching-plan concept.
  • The toolbar is one row in both views: week navigation at the start; at the end, Build with Spotter (the one accent button), the “This week” menu (Copy last week — sessions only — apply/save/manage timetable templates, Class types, and a Drafts group with Publish draft sessions / Delete sessions plus a draft-session count badge on the trigger), and the expanded/condensed toggle. There is no WOD publish action on Schedule: a WOD posted from the day sheet is published the moment it is posted, so drafts only exist for coaching plans. Each condensed day column ends with a ghost “Add a session” row (visible on hover, always visible on empty days and touch screens) that opens the create sheet for that day. The Spotter handler lives in use-programming-week-actions.ts (apps/web/src/hooks/) — its only caller is WeeklyCalendar now; the hook’s copy-week/publish-week mutations were removed along with the standalone board that used to call them.

This only happens when WeeklyCalendar is rendered with enableClassStudioExtras — a prop only ScheduleScreen ever passes. Every other WeeklyCalendar caller (the program detail page’s Schedule tab) omits it and stays on the schedule-only surface, never fetching the board.

There is no /dashboard/programming route.

Class types

SetupTimetableMenu gets a “Class types” item (data-testid="schedule-setup-class-types", shown only via a showClassTypes prop the Schedule screen passes) that opens a sheet rendering the existing ProgramClassTypesTab for the schedule program — managing a schedule program’s class types no longer requires the old program detail page.

Coaching

/dashboard/coaching — the coaching way to train, shown in Train only when a coaching-mode program exists. Header is <SectionGreeting eyebrow={t.title} action={<New plan button>} />, then a StatsStrip for the current week sourced from GET /organizations/:orgId/programs/coaching-summary?weekStart= (new endpoint — see below): Plans (totals.plans), Enrolled (totals.enrolled), Workouts this week (totals.workoutsThisWeek), Completion (totals.completionRatePct, ”—” when null).

Below that, always a list: every active coaching plan (name, active state, data-testid="coaching-plan-row"), plus “New coaching plan” (coaching-new-plan) — a name-only creation (a 1:1 plan and a group plan are the same thing here, just a different enrolled count, so there is no kind picker). Each row’s muted line (data-testid="coaching-plan-stats") reads “{enrolled} · {workouts this week} · {completion}%” straight off the summary payload’s per-program stats — the row no longer fires its own /enrollments fetch (coaching-plan-enrolled-count still marks the enrolled segment). Opening a plan goes to the existing program detail page (/dashboard/programs/:id), which keeps every one of its tabs for a coaching program exactly as it renders on main — there is no separate “operational view” on the Coaching screen itself.

GET .../programs/coaching-summary

ProgramsService.coachingSummary(orgId, clerkId, weekStart?): every active coaching-mode program in the org, each run through the same coachingStats(orgId, programId, weekStart) helper findByIdWithStats uses for a single program’s coaching branch (extracted as a private method, no behavior change), in parallel. Returns { weekStart, programs: [{ id, name, isActive, stats }], totals: { plans, enrolled, workoutsThisWeek, completionRatePct } }. totals.enrolled and totals.workoutsThisWeek are plain sums; totals.completionRatePct is the enrolled-weighted mean over programs with a non-null rate (a program with no assignments that week contributes to neither side), null when no program qualifies. Controller route is registered in the literal sub-routes block, before :id, same convention as my-enrollments.

Announcements has no sidebar entry of its own: it is a tab of Messages (Train), and Messages’ badge sums unread conversations and unread announcements. Talking to members is Train by the placement rule; Thrive holds only what talks to people who are not members yet. The messages page still reads ?tab=announcements once at mount for deep links.

Ways to train

Owner/admins configure “Ways to train” in Settings > General (WaysToTrainSettingsSection); /dashboard/programs has no list and forwards there. Enabled Schedule and Cohorts modes become the corresponding Training sidebar destinations.

  • Schedule — on when the org has an active schedule-mode program (at most one). Turning it on creates one (a default name from the dictionary, “Classes”) if the org has never run one, or reactivates the existing inactive one otherwise. Turning it off checks whether the program is still in use before asking for confirmation (see “Off must refuse while still in use” below); if it’s clear, confirmation asks (sessions are kept, the program just deactivates) then deactivates it (PATCH .../programs/:id { isActive: false } — via the existing soft-delete DELETE endpoint, which already flips isActive rather than hard-deleting).
  • Cohorts — on when the org has at least one active coaching-mode program. Turning it on with none creates the first plan (default name “Coaching”) and sends the user to Coaching to rename it; with an existing inactive one, reactivates that instead of creating a new one. Turning it off checks every active coaching program for blockers first; if none are found, confirmation names the count, then deactivates every active coaching program.

Both switches go through the existing programs API only — POST /organizations/:orgId/programs to create, PATCH /organizations/:orgId/programs/:id { isActive } to reactivate (the DTO gained an optional isActive field for this; no schema change, the programs.is_active column already existed and already backed the soft DELETE), and the same DELETE to deactivate. Every mutation invalidates the /programs queries, so Home’s Train card picks up the changed delivery modes without a reload.

Off must refuse while still in use

Deactivating a program used to just flip isActive — hiding it from staff while members carried on regardless: published future sessions stayed bookable, enrolled members kept their plan. Off now refuses while the way is still in use, and says what to clear first.

ProgramsService.getDeactivationBlockers(orgId, programId) (API, in programs.service.ts) counts what still depends on a program, keyed by delivery mode:

  • Schedule mode — futurePublishedSessions: class_sessions belonging to the program’s class types with status = 'published', starts_at > now(), not soft-deleted.
  • Coaching mode — activeEnrollments: program_enrollments with is_active = true.

remove() (the DELETE endpoint) and update() (when a PATCH flips isActive from true to false) both call it and throw 409 Conflict with { code: 'program_in_use', futurePublishedSessions, activeEnrollments } when either count is nonzero. GET /organizations/:orgId/programs/:id/deactivation-blockers (owner/admin, same membership gate as the other program routes) exposes the same counts so the UI can check before ever asking for confirmation.

On the Ways to train page, flipping a switch off first fetches blockers for the affected program(s) — the one schedule program, or every active coaching program summed for Cohorts. A nonzero count opens a blocking “Still in use” dialog instead of the confirm dialog: the counts (pluralized — “N upcoming published sessions” / “N enrolled members”), a line saying what to clear first, a primary button that navigates to Schedule or Coaching, and Close. Zero counts fall through to the existing confirm-and-deactivate flow. If the counts raced stale and the DELETE itself comes back 409, the page shows the same blocked dialog from the error body rather than a raw error.

The program detail page (/dashboard/programs/:id) keeps the same tabs for both delivery modes. Deleting a program from it lands on Coaching.

Home cards by role

sectionsForRole in dashboard-sections.ts controls only the optional Home cards:

  • coach → ['train'].
  • owner / admin → ['train', 'track', 'thrive'].
  • everything else (including member) → []; members never reach the dashboard shell, enforced upstream in (protected)/layout.tsx.

Home

/dashboard/home is a direct, optional work-area landing. It is never an automatic dashboard-entry redirect: /dashboard/overview always renders Overview, and deep links are untouched. Its cards use sectionsForRole and the placement map only to select card content; the dashboard sidebar remains flat regardless of the route or the remembered Home preference.

Where state lives

No JSON preferences column exists on users yet, so “remember my choice” and “continue where I left off” are device-local: apps/web/src/lib/dashboard-shell/home-prefs.ts reads/writes localStorage, keyed taikan:dashboard-home:<userId>:<orgId> — both, so one user’s choice at one gym never leaks into another gym they also belong to, and one browser’s choice never crosses devices.

Record shape: { rememberedSection, lastSection, lastTrainRoute }. These values are used only when the user opens /dashboard/home; they do not alter dashboard entry or sidebar navigation. Schedule’s own condensed/expanded density preference (taikan:schedule-view) lives separately in localStorage.

Dashboard chrome

The sidebar header holds the org switcher. The dashboard header holds the sidebar trigger, breadcrumb, Ask Spotter, and profile menu; Settings remains a flat sidebar destination. Audit log remains reachable from Settings > Admin. The sidebar footer retains the existing profile menu and member-view link.

There is no guided-tour entry in dashboard chrome. New-org guidance remains the onboarding setup guide, which renders on the optional Home page for owners and admins.