Status (2026-06-12): Tier 1 + Tier 2 done — all hand-rolled charts live in
@enode/ui/charts/(bar-fill,technique-line-chart,technique-trajectory-chart,performance-chart,metric-bar). Storybook + TSDoc added;npm run docs:checkgreen. The Phase 5 cleanup (deleting the dead Chart.js iframe pair) is intentionally left undone per request.
Promote the hand-rolled charts to @enode/ui
The tracking app renders its data visualizations with a mix of techniques. The
hand-rolled charts (no third-party library) are good shared-library
candidates because their data layer already lives in @enode/core and their
only dependencies are @enode/core + @enode/ui. This plan promotes them so a
future portal session-review / athlete-performance view can reuse them.
Out of scope: the Chart.js iframe charts (technique-chart.tsx,
technique-bar-path.tsx) — they are dead code (zero importers), superseded
by the native-SVG technique charts. They should be deleted, not promoted
(see Cleanup).
Why it's feasible
- The data layer is already shared.
set-chart-service.tsandrep-chart-service.tslive inpackages/core/src/training/. Only the rendering moves. - Every dependency is already shareable. The charts import only
@enode/core(types,useUnits,useTextContent,useMetricCategory, metric category colors, display/metrics stores) and@enode/ui(LottieCircle).@enode/uialready depends on@enode/core. - No i18n, no app glue. None carry
t()(entity names come fromuseTextContent; axis labels are raw unit symbols). Cross-chart crosshair sync is prop-driven lifted state, not infrastructure coupling.
Inventory & tiers
Tier 1 — pure SVG, zero coupling (lift-and-shift)
| Component | File | Lines | Depends only on |
|---|---|---|---|
repBarFillStyle | today/rep-bar-fill.ts | 33 | RepPhase type |
TechniqueLineChart | today/technique-line-chart.tsx | 854 | ChartPoint/PhaseSegment types + phaseLabel |
TechniqueTrajectoryChart | today/technique-trajectory-chart.tsx | 528 | TrajectoryPoint type |
Both SVG charts receive their data and the shared crosshairTime +
onCrosshairChange via props; the parent (technique-tab.tsx) owns the crosshair
state. So they move with no app-local import fixes — only the importer repoints.
Tier 2 — core-hook-coupled render components (shareable, more care)
| Component | File | Coupling |
|---|---|---|
MetricBar | today/focus-metric.tsx | None — already pure (bars/marker/max props) |
FocusMetricHeader | today/focus-metric.tsx | useUnits/useTextContent/useMetricCategory + LottieCircle |
PerformanceChart | today/performance-chart.tsx | same core hooks + colors + LottieCircle |
PerformanceTab | today/performance-tab.tsx | useFocusMetrics (reads userAppSettings) |
The only app-flavored piece is useFocusMetrics — the selection of which
metrics are "focus" (reads userAppSettings). That is a data/config concern, not
rendering.
Design principle: promote rendering, parameterize selection
Mirrors the settings-drawer promotion. The render components move to @enode/ui;
the container keeps choosing the session/metrics and feeds the already-computed
service output (MetricPerformanceData[], focus IDs, LineSeriesSpec[]) in.
PerformanceTab's useFocusMetrics selection stays app-side (or becomes an
opt-in hook).
Target layout
packages/ui/src/charts/
bar-fill.ts # repBarFillStyle (from rep-bar-fill.ts)
technique-line-chart.tsx
technique-trajectory-chart.tsx
performance-chart.tsx # + PerformanceMode (Tier 2)
metric-bar.tsx # MetricBar + FocusMetricHeader, render only (Tier 2)
*.stories.tsx
Wildcard exports (./* → ./src/*) mean no package.json change.
Phases
- Tier 1 (foundation):
bar-fill.ts+ the two SVG technique charts →@enode/ui/charts/; repointtechnique-tab.tsxand therep-bar-fillimporters. Lowest risk, highest LOC. - Tier 2a —
PerformanceChart→@enode/ui/charts/;PerformanceTabkeeps the focus selection and renders the shared chart. - Tier 2b —
MetricBar+FocusMetricHeader: split the render components out offocus-metric.tsx(7 importers — same re-export care as the earlierFOCUS_CATEGORY_KEYextraction), leavinguseFocusMetrics/useFocusMetricin tracking. - Storybook + TSDoc: charts are ideal Storybook subjects (prop-driven, mockable). Build fixture data (no live APIs) for each; TSDoc the exported props. Required by the docs rules.
- Cleanup (separate): delete the dead Chart.js iframe pair —
technique-chart.tsx,technique-bar-path.tsx, andpublic/technique-*.html(~3,500 lines).
Risks
focus-metric.tsxis a 7-importer multi-export file — theMetricBar/FocusMetricHeadersplit needs careful re-exports (as done forFOCUS_CATEGORY_KEY).- Storybook fixtures are the bulk of Phase 4 — the chart data types need hand-built mock data (straightforward since the services are pure).
- No portal consumer yet: this is promotion for reuse-readiness, not an immediate feature. The shared charts must stay prop-driven so the portal can feed its own session data.