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 card | Routes |
|---|---|
| Train | Schedule, Coaching, Workouts, Movements, Messages, Programs |
| Track | Overview, Members, Payments, Plans, Analytics, Forms, Tasks, Join links |
| Thrive | Leads, Minisite, Automations |
| Other | Settings, 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 markupOverviewV2renders, under a small section-name eyebrow (data-testid="section-greeting",-eyebrow,-title). An optionalactionrenders 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 layoutSectionGreetingdoesn’t need.StatsStrip— a row of the dashboard’s standardStatCardtiles (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 atlg:grid-cols-4, 5 uselg:grid-cols-3 xl:grid-cols-5. The program detail page keeps its own compactProgramStatCard(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.setdefaultsstatustopublishedwhen 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 isWeeklyCalendarnow; 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-deleteDELETEendpoint, which already flipsisActiverather 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_sessionsbelonging to the program’s class types withstatus = 'published',starts_at > now(), not soft-deleted. - Coaching mode —
activeEnrollments:program_enrollmentswithis_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.