Skip to main content

Status (2026-06-12): all phases landed. The drawer + focus-metrics editor

  • error presenter live in @enode/ui; tracking consumes the shared drawer (Developer tools via extraSections); the portal mounts it from a sidebar gear. Storybook + TSDoc added; npm run docs:check green.

Promote the Settings drawer to @enode/ui

The tracking app's account-settings drawer is built on data that is shared at the account level: UserAppSettingReturnDto and its stores (@enode/core/user-app-settings, display, metrics) and its persistence (putUserAppSettings) all already live in @enode/core and are consumed by both the tracking app and the portal. Both apps therefore want the same settings screen, not divergent compositions. This plan promotes the drawer — and its nested focus-metrics editor — into the shared @enode/ui package so the portal can mount it with near-zero code.

Source today

  • apps/tracking/src/app/workouts/today/SettingsDrawer.tsx (~1,000 lines): the drawer shell + every section + the row primitives.
  • apps/tracking/src/app/workouts/today/FocusMetricsSheet.tsx (~590 lines): the nested per-exercise-group focus-metrics editor, opened from Settings and from ExerciseDetailView.
  • App-local dependencies: focus-metric.tsx (inGroup, FOCUS_CATEGORY_KEY), icons.tsx, and error-handling/use-error-presenter.ts (via the sheet's presentError).

Why the whole drawer is promotable

@enode/ui already depends on @enode/core and several existing components (auth/delete-account-section.tsx, sheet.tsx) reach into core stores/hooks, so there is no architectural barrier to a fully-wired shared component. Package exports are wildcard (./*./src/*), so new files are exported as @enode/ui/... with no package.json change.

What's shared vs. the app-specific seam

PartVerdict
Account, Language, App Settings, In-training guidance, Enode Sensor, Focus metrics list sectionsShared verbatim — all bind to the account-level DTO + core stores.
Buffered drawer shell (mount-gate, DrawerStack, pending-save, Save/discard)Shared — stays typed to the real shared DTO.
Row primitives (Toggle, ToggleRow, NumberRow, LinkRow, ProInfoCard)Move with the drawer; worth exporting individually.
FocusMetricsSheet + focus-metric helpersPromote — coupled only to core domain concepts; already reused twice in tracking.
use-error-presenterPromote — dependency of the sheet; already the de-facto shared error surface (see docs/error-handling.md).
App version footerComponent shared; the value (NEXT_PUBLIC_APP_VERSION) is per-app → pass as a prop.
"Enode Portal" cross-link in AccountPer-app (portal wouldn't link to itself) → small config/slot.
Developer section + session-weight + tracking-detail-modeNot promoted. Tracking-only and explicitly temporary ("rip the section once the API exposes the real value"); uses bare unwrapped strings. Inject via an extraSections slot.

The seam between the two apps is therefore tiny: app version value, one cross-link, and an optional extra-sections slot.

Nested components

FocusMetricsSheet is the headline nested promote. Its own internal pieces (SortableSelectedSlot, MetricCategoryChip, DisplayBaseRow, EmptySlot) move with it — they are implementation detail, not independently promotable. It is already opened from two tracking call sites (Settings drawer + ExerciseDetailView), which reinforces sharing it.

Proposed shared API

// tracking — passes the temporary dev tooling + its version
<SettingsDrawer
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
appVersion={process.env.NEXT_PUBLIC_APP_VERSION}
crossAppLink={{ label: t("Enode Portal"), href: PORTAL_URL }}
extraSections={<DeveloperSection />}
/>

// portal — just the shared account settings
<SettingsDrawer open={open} onClose={close} appVersion={appVersion} />

Target @enode/ui layout

packages/ui/src/
settings/
settings-drawer.tsx # public entry: shell + buffered save + sections
settings-sections.tsx # Account, Language, AppSettings, Guidance, Sensor, FocusMetricsList
settings-rows.tsx # Toggle, ToggleRow, NumberRow, LinkRow, ProInfoCard
focus-metrics/
focus-metrics-sheet.tsx
focus-metric.ts # inGroup, FOCUS_CATEGORY_KEY
feedback/
use-error-presenter.ts # promoted from the app (Phase 1)

Phased migration

  1. Promote use-error-presenter@enode/ui/feedback/; update tracking call sites. Prerequisite for the sheet. (Done first — safe leaf, no UI risk.)
  2. Promote FocusMetricsSheet + focus-metric helpers@enode/ui/focus-metrics/; reconcile the 4 icons against existing @enode/ui icons; repoint tracking's two call sites. Verify focus-metric editing still works end to end.
  3. Promote the drawer: move sections + primitives + shell into @enode/ui/settings/, carving out the appVersion / crossAppLink props and the extraSections slot. Tracking keeps DeveloperSection local and passes it in.
  4. Swap tracking to consume @enode/ui/settings/settings-drawer; delete the migrated app-local files (keep only DeveloperSection, session-weight, tracking-detail-mode).
  5. Storybook + TSDoc: stories for Toggle / NumberRow / ProInfoCard with mock data; TSDoc on the SettingsDrawer props (the slot API). SettingsDrawer / FocusMetricsSheet themselves are coupled to live backend state → legitimate story-skip per the docs rules; note that rather than mocking real APIs.
  6. Portal wire-up (follow-up): mount <SettingsDrawer> from the portal's sidebar/account menu.

Risks

  • i18n: moving files changes extraction source paths — run npm run i18n:extract and commit the regenerated baseline, or the pre-push hook blocks.
  • "use client" must travel with every moved file; keep server-only portal code from importing them directly.
  • Account divergence: only the cross-link differs — confirm the portal wants the rest of Account (name/email) identical (it should — same account).
  • Portal store preload: the drawer assumes the display / metrics / user-app-settings stores are preloaded after login. Confirm the portal's login cascade preloads them, or the Focus metrics list/sheet render empty there. Check before Phase 6.