The trainee board is the screen at /{slug}/clients. It replaced the old trainee card page. A list on one side, the selected trainee’s detail on the other. Code lives in [organizationSlug]/clients/:

Server load

clients/page.tsx reads ?q=, ?rules=, ?page= and ?client=.
  1. Starts the reads that do not depend on filters at once: studio settings, the SmartSend chat embed config, the organization and the session.
  2. Awaits three small reads in parallel with tryPerformApi: /clients/saved-filters, /clients/filter-state and /clients/tag-options.
  3. Resolves which rules apply (see below).
  4. Loads the page data with tryPerformAll: /clients (page size 20, with search and rules), /coaches, /products, /forms/templates, /program-templates and /program-folders.
  5. Shows ApiLoadError if that group fails.
  6. If ?client= names a trainee who is not on the first page, loads that one trainee with /clients/{id} and passes it as deepLinkedClient.
  7. Attaches coach avatars. Coach rows come from the core API, but the portraits belong to the linked auth users, so they are matched through the organization’s member list by externalUserId, then by email.
The /clients response is Paginated<Client> plus statusCounts.

Which filters open the board

Filters are resolved on the server so the first paint is already the filtered list. Every change the coach makes to the filters stamps ?rules= on the URL, including rules=[]. The board also persists the bar with the saveTraineeFilterState action. The lifecycle chip and the simple picks (health, stage, activity) never reach the URL. The board seeds them from the remembered state on mount (initialDerived) and owns them afterwards.

The filter model

board/trainee-board-logic.ts holds all of it and is covered by trainee-board.test.ts. A FilterRule has an id, a field, an operator and string value slots: value, values (a list), min, max, n and unit (days, weeks or months). The fields and their operators come from FIELD_OPERATORS: Helpers:
  • ruleWithField(rule, field) and ruleWithOperator(rule, operator) reset the value slots to that field’s defaults (FIELD_DEFAULTS), so a leftover age range never filters a date field.
  • The sentinel __none__ backs NO_COACH, NO_PLAN, NO_PLATFORM and NO_VALUE, the “nothing assigned” choice in a multi select. The API matches it verbatim.
  • simpleSelection, withSimpleSelection, simplePlanView and withSimplePlanSelection translate between the quick pickers on the bar and the full rule list, so both edit the same rules.
  • ruleFingerprint(rules) and filterStateFingerprint(state) give a stable string to compare states and avoid redundant saves and reloads.
  • clientListFilterQuery(filters, page) builds the query for /clients: search, rules as JSON, lifecycle unless all, health, stage, activity.
board/trainee-meta.ts defines the derived dimensions: It also holds the thresholds used to compute them: a 7 day check-in window, 30 days for a new trainee and 21 days for “ending soon”.

Saved filters

Per user presets. Actions: createSavedFilter, updateSavedFilter and deleteSavedFilter against /clients/saved-filters. The type is SavedTraineeFilter in perform-types.ts.

The list

TraineeBoard keeps the list in state, seeded from the server page.
  • More rows are loaded with the loadClientsPage(slug, params) action, which returns { items, total }. It returns an empty page when the context cannot be resolved, so the list never throws while scrolling.
  • The detail pane refetches the selected trainee after every write. rememberClient(previous, client) stores that fresh copy in a map, and withRefreshedClients(clients, refreshed) folds the map back over the paginated list. Without this the row would keep showing subscription state from the original server page.

Selection and “select all”

The list loads page by page, so “select all” cannot use the loaded rows.
  • selectAllCount(input) returns how many trainees a select all covers. With a health, stage or activity pick active it uses the precise total from the server. Otherwise it uses the lifecycle count from statusCounts.
  • loadClientSelection(slug, params) calls /clients/selection with the same filter query and returns id, name and firstName for every matching trainee. Bulk actions run over that list.

Bulk actions

board/bulk/use-trainee-bulk-actions.tsx builds the menu shown by the shared BulkActionsMenu: The bar also offers push messages (TraineePushDialog, sendPushMessages), task creation and mailing lists through the shared bulk components. board/bulk/actions.ts is one of the few action files that validates input with Zod. It deduplicates ids, splits them into chunks of BULK_CHUNK_SIZE (500) and runs with a concurrency of BULK_CONCURRENCY (5). Each bulk action returns a BulkOutcome:
“Skipped” covers rows where the request was a no-op, for example freezing a plan that is already frozen (isAlreadyInFreezeState) or a subscription that clashes with an existing one (isSubscriptionConflict). The selection stays checked after an action, so the coach can run a second action on the same set.

The detail pane

TraineeDetail.tsx renders Radix Tabs. The tab ids are TRAINEE_TABS in trainee-meta.ts. getClientBoard(slug, id) loads the detail data. Tracking views in board/tracking/ load on demand through getTrackingSummary, getNutritionTracking, getNutritionDay, getWorkoutsTracking, getWorkoutDetail and getWeightTracking. addWeighIn, addMeasurement and setGoalWeight write.

Activity calls to action

board/activity-cta.ts maps an activity row to a button. activityCta(...) returns a label and a target. isInCardTarget(target) tells whether the target opens inside the board (another tab, a form response) or navigates away (link) or opens a video. Tests are in activity-cta.test.ts.

Creating a trainee

createClient(slug, input) in clients/actions.ts takes CreateClientInput: name, phone, optional plan (productId), coaches (coachIds), onboarding and update forms, check-in cadence, body data and tags. A refusal past the plan’s trainee limit comes back as a value, not a throw, so ClientsView can open PlanLimitDialog. Any other failure still throws. See Billing. Always build trainee links with traineeCardHref(slug, clientId) from modules/shared/lib/trainee-href.ts. It returns /{slug}/clients?client={id}. The old /{slug}/clients/{id} URL still works through a permanent redirect. Editors opened from the board accept ?back=. safeBackHref(query.back, slug) in modules/shared/lib/plan-release.ts only honours an in app path under the same studio.