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/portalon 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)
- 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.
- Reuse before building. If a component already does what we need — shared
(
@enode/ui) or portal-local — use it. Don't reinvent. - Track tracking-app impact. Anything that directly or indirectly touches the
tracking app (i.e. changes to shared
@enode/core/@enode/uithat 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
| Method | Path | Query | Body | Returns |
|---|---|---|---|---|
| GET | /workout_sessions/empty_search | page(1), per(25, max 100) | — | SessionsPaginationReturnDto |
| POST | /workout_sessions/search | from,to (required), page,per | HistoryQueryDto | SessionsPaginationReturnDto |
| GET | /workout_sessions/search/:text | page,per | — | SessionsPaginationReturnDto (free-text) |
| GET | /workout_sessions/:id | — | — | SessionReturnDto (full, with sets) |
| GET | /workout_sessions | from,to (required) | — | SessionReturnDto[] (no paging, desc) |
| GET | /workout_sessions/exercise/:exerciseId | count | — | SessionReturnDto[] |
| GET | /history/sessions/last/:UserID/exercise/:ExerciseID | from (Unix ms) | — | SessionReturnDto[] (≤ 5, desc, created <= from) |
| PUT | /workout_sessions/:id | — | SessionUpdateDto | SessionReturnDto |
| DELETE | /workout_sessions/:id | — | — | 204 |
| GET | /workout_sessions/charts/technique/:id | — | — | SessionTechniqueChartReturnDto[] |
| GET | /workout_sessions/charts/performance/:id | — | — | 200 only (side-effect; no data) |
| GET | /workout_sets/:id | — | — | SetReturnDto |
| PUT | /workout_sets/:id | — | SetUpdateDto | SetReturnDto |
| PUT | /workout_sets/batch | — | SetUpdateDto[] | SetReturnDto[] |
| DELETE | /workout_sets/:id | — | — | 204 |
| DELETE | /workout_sets/batch | — | GenericBatchDeleteDto | 204 |
| GET | /workout_reps/:id | — | — | WorkoutRepReturnDto |
| PUT | /workout_reps/:id | — | WorkoutRepUpdateDto | WorkoutRepReturnDto |
| PUT | /workout_reps/batch | — | WorkoutRepUpdateDto[] | WorkoutRepReturnDto[] |
| DELETE | /workout_reps/batch | — | GenericBatchDeleteDto | 204 |
| GET | /workout_reps/charts/technique/:id | — | — | technique charts (per rep) |
| GET | /videos/download_url | id (videoDefinition) | — | { url } |
| DELETE | /videos/uploads | id? | — | 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 HTML —
chartEntries[].htmlis a chart string permetricKey, grouped byrepID(concentric reps only). Render via a sanitized HTML container, not a charting lib. charts/performancereturns200only — it triggers server-side regeneration and returns no data. Performance charts in the UI are derived client-side from each set/rep'smeasurements(like the oldPerformanceView).- Search/empty_search return FULL sessions with sets (heavy), plus an
ids[]list andmetaData {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
createdis 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
Milestone 1 (AGREED) — Dashboard sessions table + entity search bar
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.tswith the DTO set from the backend reference, reusing existing core types; addTrackedSide/RepPhaseenums. Additive. - M1-2 · Session API —
@enode/core/api/sessions.tswithemptySearchSessions({page,per})andsearchSessions(query,{from,to,page,per})viaapiRequest. - M1-3 · Verify live shape — call
empty_searchon backdev; confirm shape + pin downcreateddate encoding (ms vs ISO); adjust DTOs.
Portal-local shared table primitives (rule 2 — promote from the Users table):
- M1-4 · Extract a portal-local
DataTableintoapps/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>), theTh/Tdcells, the loading / empty / error states, and the "Showing N of M" footer. Refactordashboard/users/page.tsxto consume them (proves reuse, removes duplication). Reuse the already-shared@enode/ui/athlete-avatarfor athlete cells. Session-specific row content stays inline (rule 1); only the generic table chrome is shared.
- min-width
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/storeshape; portal-local. - M1-6 · Sessions table on the dashboard — inline on
dashboard/page.tsx, built on the M1-4DataTable, initialized with empty_search on mount. Rows fromSessionReturnDtoonly: date/time, athlete (avatar +user.name), exercise name, 1RM/load + trend, tags. Acceptance (key): zerogetSessioncalls while rendering (Network shows onlyempty_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-cardabove the table, behaving like the oldEntitySearchbar. Suggestions from the four catalogs, all already in core: athletesuseUsers(@enode/core/users/hooks), exercisesuseExercises, equipment (equipments store), exercise variations (exercise hooks). Selecting entities buildssearchItems[]→runSearchwith a default 1-yr range (search needsfrom/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
MeasurementReturnDtomust change to fit, log it in the impact table). TheDataTableextraction 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.
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). ForMeasurementReturnDto: align with / reuse the existing core type rather than duplicating — log it in the impact table if the existing one must change. Add theTrackedSide/RepPhaseenums (new, additive).packages/core/src/api/sessions.ts— endpoint functions viaapiRequest: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?)
- Verify
createddate encoding against a live backdev response; adjust the DTO numeric handling + any helper accordingly. - Unit-test the pure mappers/helpers added (e.g. a
toSessionUpdateDtomapper if we add one), following theworkout-schedule.test.tspattern. - 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/uidrawer/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 onesave()(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
noteunconditionally → 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:
phaseis 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/Bestidea 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 aconfidence; 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/technique→chartEntries[].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 viagetVideoDownloadUrl. Optionally callregeneratePerformanceCharts.
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/coreuseUnits, 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.
| # | Change | Files | Why | Tracking effect / merge note |
|---|---|---|---|---|
| 1 | Appended History read/update/search DTOs | packages/core/src/api/dtos/sessions.ts | History 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. |
| 2 | Appended session read/search API (emptySearchSessions, searchSessions) | packages/core/src/api/sessions.ts | The two History list endpoints. | Additive; file already held the boss's postWorkoutSessions. Conflict only on co-edit. |
| 3 | Added getAllExerciseVariations() getter | packages/core/src/exercise-variations/store.ts | The 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. |
| 4 | Appended set/rep batch edit API (updateSetsBatch, deleteSetsBatch, updateRepsBatch, deleteRepsBatch) | packages/core/src/api/sessions.ts | The 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. |
| 5 | Appended return→update mappers (toSetUpdateDto, toRepUpdateDto) | packages/core/src/api/mappers/sessions.ts | Build 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.tsxextraction + Users-page refactor, and allapps/portal/src/app/dashboard/*additions (incl.use-session-editor.ts).
Likely future entries to watch for:
- Aligning/extending the existing core
MeasurementReturnDto(orMetricReturnDto) to the fuller history shape — tracking reads measurements, so any field/shape change is a shared change → log it. - Adding
RepPhase/TrackedSideenums to a shared location the tracking app also wants — if tracking later imports them, note it.