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 viaextraSections); the portal mounts it from a sidebar gear. Storybook + TSDoc added;npm run docs:checkgreen.
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 fromExerciseDetailView.- App-local dependencies:
focus-metric.tsx(inGroup,FOCUS_CATEGORY_KEY),icons.tsx, anderror-handling/use-error-presenter.ts(via the sheet'spresentError).
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
| Part | Verdict |
|---|---|
| Account, Language, App Settings, In-training guidance, Enode Sensor, Focus metrics list sections | Shared 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 helpers | Promote — coupled only to core domain concepts; already reused twice in tracking. |
use-error-presenter | Promote — dependency of the sheet; already the de-facto shared error surface (see docs/error-handling.md). |
| App version footer | Component shared; the value (NEXT_PUBLIC_APP_VERSION) is per-app → pass as a prop. |
| "Enode Portal" cross-link in Account | Per-app (portal wouldn't link to itself) → small config/slot. |
Developer section + session-weight + tracking-detail-mode | Not 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
- Promote
use-error-presenter→@enode/ui/feedback/; update tracking call sites. Prerequisite for the sheet. (Done first — safe leaf, no UI risk.) - Promote
FocusMetricsSheet+focus-metrichelpers →@enode/ui/focus-metrics/; reconcile the 4 icons against existing@enode/uiicons; repoint tracking's two call sites. Verify focus-metric editing still works end to end. - Promote the drawer: move sections + primitives + shell into
@enode/ui/settings/, carving out theappVersion/crossAppLinkprops and theextraSectionsslot. Tracking keepsDeveloperSectionlocal and passes it in. - Swap tracking to consume
@enode/ui/settings/settings-drawer; delete the migrated app-local files (keep onlyDeveloperSection,session-weight,tracking-detail-mode). - Storybook + TSDoc: stories for
Toggle/NumberRow/ProInfoCardwith mock data; TSDoc on theSettingsDrawerprops (the slot API).SettingsDrawer/FocusMetricsSheetthemselves are coupled to live backend state → legitimate story-skip per the docs rules; note that rather than mocking real APIs. - 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:extractand 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-settingsstores 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.