Skip to main content

History feature — implementation plan

Rebuild the old portal's History feature (browse/search completed workout sessions → drill into sets → reps → measurements → performance/technique charts

  • video → edit / delete / tag) in apps/portal on top of @enode/core.

Branch: history (stacked on calendar). Backend source of truth: ~/Workspace/enode_backend_v1. Old portal reference: ~/Workspace/enode-portal (pages/History/index.jsx + hooks/useCompletedSessionsService.js).


Ground rules (apply to every phase)

  1. Build inline first. Put new UI directly in the page (like the Planner did), not in extracted components — the feature shape isn't settled and will change. Extracting/cleanup is the LAST phase.
  2. Reuse before building. If a component already does what we need — shared (@enode/ui) or portal-local — use it. Don't reinvent.
  3. Track tracking-app impact. Anything that directly or indirectly touches the tracking app (i.e. changes to shared @enode/core / @enode/ui that tracking consumes, not just additive new files) gets logged in the Tracking-app impact log at the bottom of this doc, so it can be referenced when merging.

Backend reference (authoritative — from enode_backend_v1)

Path / call convention

Backend route groups: /api/workout_sessions, /api/workout_sets, /api/workout_reps. Single-entity routes use path params (:SessionID / :SetID / :RepID) — NOT ?id= (that was the old portal's proxy rewrite). The portal's apiRequest base URL already includes /api, so call the bare paths, e.g. apiRequest("/workout_sessions/{id}"). All routes are token-protected (the existing apiRequest auth applies).

Endpoints

MethodPathQueryBodyReturns
GET/workout_sessions/empty_searchpage(1), per(25, max 100)SessionsPaginationReturnDto
POST/workout_sessions/searchfrom,to (required), page,perHistoryQueryDtoSessionsPaginationReturnDto
GET/workout_sessions/search/:textpage,perSessionsPaginationReturnDto (free-text)
GET/workout_sessions/:idSessionReturnDto (full, with sets)
GET/workout_sessionsfrom,to (required)SessionReturnDto[] (no paging, desc)
GET/workout_sessions/exercise/:exerciseIdcountSessionReturnDto[]
GET/history/sessions/last/:UserID/exercise/:ExerciseIDfrom (Unix ms)SessionReturnDto[] (≤ 5, desc, created <= from)
PUT/workout_sessions/:idSessionUpdateDtoSessionReturnDto
DELETE/workout_sessions/:id204
GET/workout_sessions/charts/technique/:idSessionTechniqueChartReturnDto[]
GET/workout_sessions/charts/performance/:id200 only (side-effect; no data)
GET/workout_sets/:idSetReturnDto
PUT/workout_sets/:idSetUpdateDtoSetReturnDto
PUT/workout_sets/batchSetUpdateDto[]SetReturnDto[]
DELETE/workout_sets/:id204
DELETE/workout_sets/batchGenericBatchDeleteDto204
GET/workout_reps/:idWorkoutRepReturnDto
PUT/workout_reps/:idWorkoutRepUpdateDtoWorkoutRepReturnDto
PUT/workout_reps/batchWorkoutRepUpdateDto[]WorkoutRepReturnDto[]
DELETE/workout_reps/batchGenericBatchDeleteDto204
GET/workout_reps/charts/technique/:idtechnique charts (per rep)
GET/videos/download_urlid (videoDefinition){ url }
DELETE/videos/uploadsid?204

History itself uses: empty_search, search, :id (detail), the set/rep batch mutations, charts/technique, and videos/download_url. The rest exist for completeness / future use.

The session drawer's Summary tab uses history/sessions/last/… — not workout_sessions/exercise/… — because a reviewed session must be compared against the sessions that came before it. It passes from = the viewed session's created − 1 ms (exclusive, so the session isn't one of its own five). workout_sessions/exercise/… stays the right call where "now" is the anchor: the tracking app's in-training Overview tab and the live athlete drawer.

Enums

type TrackedSide = "unilateralLeft" | "unilateralRight" | "bilateral";
type RepPhase = "unknown" | "concentric" | "eccentric";
// CompletionState already exists in @enode/core (string union):
// "unknown" | "template" | "scheduled" | "recurring" | "completed"

Request DTOs (to add to @enode/core/api/dtos/sessions.ts)

type SearchItem = {
tableId: "user" | "exercise" | "equipment" | "exerciseVariation";
searchTerm: string;
};
type HistoryQueryDto = { searchItems: SearchItem[] };

type MeasurementCreateDto = {
id: UUID;
created?: number | null;
value: number;
valueUser: number;
confidence: number;
metricID: UUID;
};

type SessionUpdateDto = {
id: UUID;
note?: string | null;
exerciseID?: UUID | null;
measurements?: MeasurementCreateDto[] | null;
assignedTagIDs?: UUID[] | null;
}; // NOTE: cannot update sets via session update.

type SetUpdateDto = {
id: UUID;
work?: boolean | null;
side?: TrackedSide | null;
note?: string | null;
sessionID?: UUID | null;
measurements?: MeasurementCreateDto[] | null;
assignedTagIDs?: UUID[] | null;
}; // NOTE: cannot update reps via set update.

type WorkoutRepUpdateDto = {
id: UUID;
valid?: boolean | null;
phase?: RepPhase | null;
setID?: UUID | null;
note?: string | null;
assignedTagIDs?: UUID[] | null;
};

type GenericBatchDeleteDto = { idsToDelete: UUID[] };

Return DTOs

type PageMetadata = { page: number; per: number; total: number };

type SessionsPaginationReturnDto = {
ids: UUID[];
pagedEntities: SessionReturnDto[];
metaData: PageMetadata;
};

type SessionReturnDto = {
id: UUID;
created?: number | null; // Date — CONFIRM encoding (ms vs ISO) on backdev
order: number;
suborder?: number | null;
note?: string | null;
user?: UserIdNameReturnDto | null;
exercise?: ExerciseMappedReturnDto | null; // exists in core
exerciseID?: UUID | null;
sets?: SetReturnDto[] | null;
measurements?: MeasurementReturnDto[] | null; // exists in core (align — see impact log)
models?: RegressionModelReturnDto[] | null; // exists in core
assignedTags?: EntityTagReturnDto[] | null; // exists in core
};

type SetReturnDto = {
id: UUID;
created?: number | null;
order: number;
state: CompletionState;
work: boolean;
sessionID?: UUID | null;
trackingSourceID?: UUID | null;
reps?: WorkoutRepReturnDto[] | null;
measurements?: MeasurementReturnDto[] | null;
side: TrackedSide;
note?: string | null;
assignedTags?: EntityTagReturnDto[] | null;
video?: VideoReturnDto | null;
};

type WorkoutRepReturnDto = {
id: UUID;
created?: number | null;
valid: boolean;
order: number;
phase: RepPhase;
dataPackages?: DataPackageReturnDto[] | null; // raw sensor — likely not needed in UI
measurements?: MeasurementReturnDto[] | null;
setID?: UUID | null;
note?: string | null;
assignedTags?: EntityTagReturnDto[] | null;
};

type UserIdNameReturnDto = { id: UUID; name?: string | null };

type VideoReturnDto = {
id: UUID; name?: string | null; state: string; size?: number | null;
setID?: UUID | null; uploadedAt?: number | null; createdAt?: number | null;
exerciseDefinitionID?: UUID | null; firstFrameDate?: number | null;
};

// Raw sensor packet — treat as opaque; probably unused by the History UI.
type DataPackageReturnDto = {
id: UUID; created: number; userID: UUID; repID?: UUID | null;
workoutLiveSessionRepID?: UUID | null; workoutDataID?: UUID | null;
dataPackageTypeID: UUID; /* data: base64 blob — omit/ignore */
};

type SessionTechniqueChartReturnDto = { repID: UUID; chartEntries: ChartEntryReturnDto[] };
type ChartEntryReturnDto = { metricKey: string; html: string };

Gotchas (don't get surprised)

  • Technique charts are pre-rendered HTMLchartEntries[].html is a chart string per metricKey, grouped by repID (concentric reps only). Render via a sanitized HTML container, not a charting lib.
  • charts/performance returns 200 only — it triggers server-side regeneration and returns no data. Performance charts in the UI are derived client-side from each set/rep's measurements (like the old PerformanceView).
  • Search/empty_search return FULL sessions with sets (heavy), plus an ids[] list and metaData {page, per, total}.
  • Editing is strictly layered: session-meta (SessionUpdateDto) vs set (SetUpdateDto) vs rep (WorkoutRepUpdateDto), each via its own endpoint, saved in batches. A session update cannot change sets; a set update cannot change reps.
  • Date encoding of created is unconfirmed — the old portal read it as a unix number; the workouts DTOs use ms. Verify against a live backdev response in Phase 1 and pick the matching numeric handling.

Phased plan

First concrete deliverable. Realizes Phase 1 (a subset of the core layer) + the read/search slice of Phase 2. Scope: the sessions table shown directly on the dashboard (apps/portal/src/app/dashboard/page.tsx), initialized with empty_search (like the old portal), rows rendered purely from the returned SessionReturnDto with no per-row fetch and no row interaction (pure display). A fully entity-powered search bar sits on top.

Core (backend subset):

  • M1-1 · Session DTOs@enode/core/api/dtos/sessions.ts with the DTO set from the backend reference, reusing existing core types; add TrackedSide / RepPhase enums. Additive.
  • M1-2 · Session API@enode/core/api/sessions.ts with emptySearchSessions({page,per}) and searchSessions(query,{from,to,page,per}) via apiRequest.
  • M1-3 · Verify live shape — call empty_search on backdev; confirm shape + pin down created date encoding (ms vs ISO); adjust DTOs.

Portal-local shared table primitives (rule 2 — promote from the Users table):

  • M1-4 · Extract a portal-local DataTable into apps/portal/src/components/ from the inline Users-table pieces already flagged "extract later": the rounded card-table shell (border + shadow-prominent + scrollbar-none overflow-x-auto
    • min-width <table>), the Th / Td cells, the loading / empty / error states, and the "Showing N of M" footer. Refactor dashboard/users/page.tsx to consume them (proves reuse, removes duplication). Reuse the already-shared @enode/ui/athlete-avatar for athlete cells. Session-specific row content stays inline (rule 1); only the generic table chrome is shared.

Sessions table (display, inline):

  • M1-5 · Sessions list store/hook (portal-local) — current page (pagedEntities, metaData, loading/error) + loadEmptySearch(page) / runSearch(searchItems, range, page). Mirror the @enode/core/workouts/store shape; portal-local.
  • M1-6 · Sessions table on the dashboard — inline on dashboard/page.tsx, built on the M1-4 DataTable, initialized with empty_search on mount. Rows from SessionReturnDto only: date/time, athlete (avatar + user.name), exercise name, 1RM/load + trend, tags. Acceptance (key): zero getSession calls while rendering (Network shows only empty_search/search); rows non-interactive.
  • M1-7 · Pagination — control driven by metaData {page, per, total}.

Entity search bar (fully entity-powered):

  • M1-8 · Search bar@enode/ui/search-and-filter-card above the table, behaving like the old EntitySearchbar. Suggestions from the four catalogs, all already in core: athletes useUsers (@enode/core/users/hooks), exercises useExercises, equipment (equipments store), exercise variations (exercise hooks). Selecting entities builds searchItems[]runSearch with a default 1-yr range (search needs from/to); empty selection → empty_search. Logic kept inline in the page for now (rule 1); extraction is Phase 6.

Cross-cutting:

  • M1-9 · Impact + validation — core additions are additive (new files → no tracking impact; if MeasurementReturnDto must change to fit, log it in the impact table). The DataTable extraction is portal-only (no tracking impact). Typecheck + serve; verify the no-per-row-fetch acceptance in DevTools.

Deferred past M1: date-range picker UI (M1 uses a fixed default range), row interaction / session detail (Phase 3), editing (Phase 4), charts/video (Phase 5).

Phase 1 — @enode/core backend communication layer ← START HERE

Pure additive new files in core (no UI). Mirror the existing api/workouts.ts + api/dtos/workouts.ts conventions.

  1. packages/core/src/api/dtos/sessions.ts — all request + return DTOs above. Reuse existing core types where they already exist (ExerciseMappedReturnDto, EntityTagReturnDto, RegressionModelReturnDto, UUID, CompletionState). For MeasurementReturnDto: align with / reuse the existing core type rather than duplicating — log it in the impact table if the existing one must change. Add the TrackedSide / RepPhase enums (new, additive).
  2. packages/core/src/api/sessions.ts — endpoint functions via apiRequest:
    • searchSessions(query, { from, to, page?, per? }), emptySearchSessions({ page?, per? })
    • getSession(id), getSessionsInRange(from, to), updateSession(id, dto), deleteSession(id)
    • getSessionTechniqueCharts(id), regeneratePerformanceCharts(id) (returns void)
    • getSet(id), updateSet(id, dto), updateSetsBatch(dtos), deleteSet(id), deleteSetsBatch(ids)
    • getRep(id), updateRep(id, dto), updateRepsBatch(dtos), deleteRep(id), deleteRepsBatch(ids), getRepTechniqueCharts(id)
    • getVideoDownloadUrl(videoDefinitionId), deleteVideoUpload(id?)
  3. Verify created date encoding against a live backdev response; adjust the DTO numeric handling + any helper accordingly.
  4. Unit-test the pure mappers/helpers added (e.g. a toSessionUpdateDto mapper if we add one), following the workout-schedule.test.ts pattern.
  5. Typecheck + tests green.

Acceptance: core compiles; a throwaway call to emptySearchSessions against backdev returns a parsed page (verify in Phase 2 wiring or a quick script).

Phase 2 — History page scaffold (inline)

  • Route apps/portal/src/app/dashboard/history/page.tsx (+ sidebar entry).
  • A portal-local sessions store + hooks for pagination/caching (mirror @enode/core/workouts/store + workouts/hooks, but portal-local for now).
  • List view (inline): rows = date, athlete, exercise, 1RM + trend, tags. Loading / empty / error states. Pagination control.
  • Search/filter: entity search (athlete/exercise/equipment/variation) → searchItems, date range → from/to. Empty search = browse. Reuse existing pickers/inputs where possible (rule 2).

Phase 3 — Session detail (read)

  • Expand-row inline sets table + a detail sheet (reuse @enode/ui drawer/sheet).
  • Render sets → reps → measurements (read-only). Reuse measurement formatting / unit helpers from core (useUnits, metric helpers) and the shared workout-item content if it fits.

Phase 4 — Editing

R2 (DONE) — structural editing + batched save:

  • useSessionEditor (apps/portal/src/app/dashboard/use-session-editor.ts) holds a draft set/rep tree, tracks changed + staged-for-deletion entities, and persists through the 4 batch endpoints in one save() (delete reps → delete sets → update reps → update sets; set delete cascades, so its reps aren't deleted individually). Commits locally on success (no refetch — chose local merge).
  • Reps table: per-rep ⋯ menu (mark valid/invalid, delete/restore) and per-set ⋯ menu (working/warm-up, delete/restore) via a local RowMenu. Pending-delete rows are struck through + muted; restore un-stages.
  • Drawer: unsaved-changes footer (Discard / Save changes + error) shown whenever the editor is dirty; close is guarded by a confirm when dirty.
  • Backend gotcha encoded in the mappers (impact-log #5): batch-update rewrites note unconditionally → echo the current note back; other fields are nil-safe.

R2b (deferred) — remaining controls the model already supports:

  • Tracked side editing, tags (set + rep).
  • Set-measurement value editing (editable inputs) + input validation against metric range/dimension (reuse the validation pattern). Per-rep measurements stay display-only (matches old portal). Session tags via updateSession.

Not editable: phase is set at creation only — never sent in an update.

R3 (deferred) — Result column, Best highlight, rep media:

OPEN TODO — fill the Result column. Content/design NOT yet defined. The Result cells are intentionally empty. The screenshot is not the finished design, so the column's content — and the OK / Needs review / Best idea sketched below — is a placeholder to be reworked once the real design lands. Do not treat the screenshot as authoritative.

  • Result column content — status per rep once the design is defined. Tentative semantics (to confirm): Needs review = low-confidence reps (each measurement has a confidence; threshold TBD) or a backend flag; Best = best rep by the rank-1 focus metric; OK = otherwise.
  • Best-set / best-rep highlight — row tint + star on the best entry. Same "best by rank-1 focus metric" definition as the Summary tab's "Best set" → define the ranking once as a shared pure helper and reuse.
  • ▷ Play → rep media — wire the placeholder play button to the rep's video (getVideoDownloadUrl) and/or technique (charts/techniquechartEntries[].html). Likely deep-links into the Technique tab (Phase 5) focused on that rep, so this may land with Phase 5 rather than before it.

Phase 5 — Performance + Technique

  • Performance: client-side metric charts from measurements, category filter.
  • Technique: render chartEntries[].html (sanitized), set/rep selector, video via getVideoDownloadUrl. Optionally call regeneratePerformanceCharts.

Phase 6 — Cleanup (LAST)

  • Extract the settled inline pieces into portal-local components (as we did for the Planner). Decide promotion candidates for @enode/ui/@enode/core — but do NOT promote yet if it widens the merge surface; note candidates.
  • Update this doc + the impact log; run npm run docs:check.

Reuse candidates (rule 2)

  • Drawers/sheets: @enode/ui/drawer-stack, @enode/ui/bottom-sheet-card.
  • Tags: @enode/ui/tags-card, @enode/ui/tag-pill.
  • Athlete pill: @enode/ui/athlete-chip. User picker: @enode/ui/user-picker.
  • Units/metrics: @enode/core useUnits, metric/normative helpers, text-content display-name hooks.
  • Workout item content (sets/reps rows): @enode/ui/workout-builder/workout-item-content — evaluate fit before building a new row renderer.
  • Store/hooks pattern: copy @enode/core/workouts/{store,hooks} shape.

Tracking-app impact log (rule 3)

Append a row whenever a change touches shared code the tracking app consumes (modifying an existing @enode/core/@enode/ui file, changing a shared type, moving/renaming shared code). New additive files do NOT need a row.

#ChangeFilesWhyTracking effect / merge note
1Appended History read/update/search DTOspackages/core/src/api/dtos/sessions.tsHistory needs the return/update/search counterparts of the boss's create-side session DTOs.Additive only — no existing export/type changed; reuses MeasurementCreateDto/TrackedSide/RepPhase. RepPhase is NOT widened (read-side uses a local RepPhaseValue = RepPhase | "unknown"). Conflict risk only if another branch edits this file.
2Appended session read/search API (emptySearchSessions, searchSessions)packages/core/src/api/sessions.tsThe two History list endpoints.Additive; file already held the boss's postWorkoutSessions. Conflict only on co-edit.
3Added getAllExerciseVariations() getterpackages/core/src/exercise-variations/store.tsThe entity search bar needs the full variation list for suggestions (store only had by-id).Additive new export; no behaviour change. Conflict only on co-edit.
4Appended set/rep batch edit API (updateSetsBatch, deleteSetsBatch, updateRepsBatch, deleteRepsBatch)packages/core/src/api/sessions.tsThe Reps-tab editor (R2) persists changes via PUT|DELETE /workout_sets|workout_reps/batch.Additive new exports; postWorkoutSessions + the M1 list reads untouched. Conflict only on co-edit.
5Appended return→update mappers (toSetUpdateDto, toRepUpdateDto)packages/core/src/api/mappers/sessions.tsBuild the batch-PUT payloads from the edited return DTOs. Gotcha encoded here: the backend's batch-update handler rewrites note unconditionally (setNilIfEmpty), so the current note is echoed back to avoid clearing it; work/side/valid/tags are nil-safe (no-change).Additive new exports next to toSessionCreateDto; that mapper untouched. Conflict only on co-edit.

Portal-only (no tracking impact, not logged): the apps/portal/src/components/data-table.tsx extraction + Users-page refactor, and all apps/portal/src/app/dashboard/* additions (incl. use-session-editor.ts).

Likely future entries to watch for:

  • Aligning/extending the existing core MeasurementReturnDto (or MetricReturnDto) to the fuller history shape — tracking reads measurements, so any field/shape change is a shared change → log it.
  • Adding RepPhase / TrackedSide enums to a shared location the tracking app also wants — if tracking later imports them, note it.