@titan-design/react-ui 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,630 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useMemo, useState } from 'react'
3
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
4
+ import { MesoStatusCard, type MesoStatusCardProps } from './MesoStatusCard'
5
+ import {
6
+ StrengthTrendChart,
7
+ type StrengthTrendDataPoint,
8
+ type StrengthTrendChartMesoBoundary,
9
+ } from './StrengthTrendChart'
10
+ import {
11
+ CapacityBandChart,
12
+ type CapacityBandDataPoint,
13
+ type CapacityBandProjection,
14
+ type WorkoutDot,
15
+ } from './CapacityBandChart'
16
+ import { ExerciseCard, type ExerciseCardProps } from './ExerciseCard'
17
+ import { type SetRowProps } from './SetRow'
18
+ import { VelocityStrip, calculateMeanVelocity, calculateVelocityLoss } from './VelocityStrip'
19
+
20
+ const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
21
+ const BORDER_DEFAULT = 'var(--color-border-default)'
22
+ const BRAND_PRIMARY = '#FF7900'
23
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
24
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
25
+ const TEXT_TERTIARY = 'var(--color-text-tertiary)'
26
+ const PAGE_BG = 'var(--color-background-base)'
27
+
28
+ /** Inner plot width: page width 390 − 16 page padding − 12 card padding on each side. */
29
+ const CHART_WIDTH = 326
30
+ const CHART_HEIGHT = 150
31
+
32
+ /** Which tab of the exercise detail view is showing. */
33
+ export type ExerciseDetailTab = 'progress' | 'history' | 'advanced'
34
+
35
+ /** Tabs in display order. */
36
+ export const EXERCISE_DETAIL_TABS: Array<{ key: ExerciseDetailTab; label: string }> = [
37
+ { key: 'progress', label: 'Progress' },
38
+ { key: 'history', label: 'History' },
39
+ { key: 'advanced', label: 'Advanced' },
40
+ ]
41
+
42
+ /** Identity + headline stats for the exercise being inspected. */
43
+ export interface ExerciseDetailHeader {
44
+ /** Exercise name, e.g. "Barbell Bench Press". */
45
+ name: string
46
+ /** Context line, e.g. "Chest · Barbell". */
47
+ subtitle: string
48
+ /** Unit used across the page. */
49
+ unit: 'lbs' | 'kg'
50
+ /** Current estimated one-rep max, shown as a header pill. */
51
+ currentE1rm?: number
52
+ }
53
+
54
+ /**
55
+ * A single expandable entry — one progression week or one logged session.
56
+ * Projected onto an `ExerciseCard`, so it collapses to a summary and expands
57
+ * to its `SetRow` breakdown.
58
+ */
59
+ export interface ExerciseDetailEntry {
60
+ /** Stable id for inline-expansion selection. */
61
+ id: string
62
+ /** Row title, e.g. "Week 3" or "Mon, Jun 16". */
63
+ title: string
64
+ /** Collapsed summary (sets × reps @ weight). */
65
+ summary?: ExerciseCardProps['summary']
66
+ /** Estimated one-rep max badge. */
67
+ e1rm?: ExerciseCardProps['e1rm']
68
+ /** Flags a personal-record entry. */
69
+ isPR?: boolean
70
+ /** Prescribed tempo, revealed when expanded. */
71
+ tempo?: ExerciseCardProps['tempo']
72
+ /** Per-set breakdown revealed when expanded. */
73
+ sets: SetRowProps[]
74
+ }
75
+
76
+ /** Strength-trend inputs for the Progress tab chart. */
77
+ export interface ExerciseTrend {
78
+ /** Measured e1RM data points, chronological. */
79
+ data: StrengthTrendDataPoint[]
80
+ /** Planned/projected trajectory (dashed line). */
81
+ projection?: StrengthTrendDataPoint[]
82
+ /** Mesocycle boundary guides. */
83
+ mesoBoundaries?: StrengthTrendChartMesoBoundary[]
84
+ }
85
+
86
+ /** One set's velocity trace for the Advanced tab breakdown. */
87
+ export interface ExerciseVbtSet {
88
+ /** Row label, e.g. "Set 1". */
89
+ label: string
90
+ /** Per-rep mean concentric velocities (m/s). */
91
+ velocities: number[]
92
+ }
93
+
94
+ /** Velocity-based-training inputs for the Advanced tab. */
95
+ export interface ExerciseVbt {
96
+ /** Capacity band shape over time. */
97
+ band: CapacityBandDataPoint[]
98
+ /** Sessions plotted against the band. */
99
+ workouts: WorkoutDot[]
100
+ /** Optional forward band projection. */
101
+ projection?: CapacityBandProjection
102
+ /** Per-set velocity traces for the most recent session. */
103
+ sets: ExerciseVbtSet[]
104
+ }
105
+
106
+ /** Page-level roll-up of the logged sessions. */
107
+ export interface ExerciseDetailStats {
108
+ /** Number of logged sessions. */
109
+ sessions: number
110
+ /** Best estimated one-rep max seen, or null if none recorded. */
111
+ bestE1rm: number | null
112
+ /** Count of PR entries. */
113
+ prCount: number
114
+ }
115
+
116
+ /** Aggregate velocity readout for the Advanced tab. */
117
+ export interface VbtSummary {
118
+ /** Mean concentric velocity across every logged rep. */
119
+ meanVelocity: number
120
+ /** Worst within-set velocity loss (%), i.e. the most-fatigued set. */
121
+ velocityLoss: number
122
+ }
123
+
124
+ export interface ExerciseDetailPageProps extends ViewProps {
125
+ /** Exercise identity + headline stats. */
126
+ exercise: ExerciseDetailHeader
127
+ /** Mesocycle context banner (Progress tab). */
128
+ meso: MesoStatusCardProps
129
+ /** Strength trend inputs (Progress tab). */
130
+ trend: ExerciseTrend
131
+ /** Week-by-week progression entries (Progress tab). */
132
+ progression: ExerciseDetailEntry[]
133
+ /** All logged sessions (History tab). */
134
+ history: ExerciseDetailEntry[]
135
+ /** Velocity-based-training inputs (Advanced tab). */
136
+ vbt: ExerciseVbt
137
+ className?: string
138
+ }
139
+
140
+ /** Roll up logged sessions into the page summary (pure, testable). */
141
+ export function deriveExerciseStats(entries: ExerciseDetailEntry[]): ExerciseDetailStats {
142
+ let bestE1rm: number | null = null
143
+ let prCount = 0
144
+ for (const entry of entries) {
145
+ if (entry.e1rm != null && (bestE1rm == null || entry.e1rm.value > bestE1rm)) {
146
+ bestE1rm = entry.e1rm.value
147
+ }
148
+ if (entry.isPR) prCount += 1
149
+ }
150
+ return { sessions: entries.length, bestE1rm, prCount }
151
+ }
152
+
153
+ /** Project an entry onto ExerciseCard props, wiring inline expansion (pure). */
154
+ export function toExerciseCardProps(
155
+ entry: ExerciseDetailEntry,
156
+ expanded: boolean,
157
+ onToggle: () => void
158
+ ): ExerciseCardProps {
159
+ return {
160
+ name: entry.title,
161
+ state: expanded ? 'expanded' : 'collapsed',
162
+ onToggle,
163
+ summary: entry.summary,
164
+ e1rm: entry.e1rm,
165
+ isPR: entry.isPR,
166
+ tempo: entry.tempo,
167
+ sets: expanded ? entry.sets : undefined,
168
+ }
169
+ }
170
+
171
+ /** Aggregate per-set velocity traces into the Advanced readout (pure). */
172
+ export function summarizeVbt(sets: ExerciseVbtSet[]): VbtSummary {
173
+ const allReps = sets.flatMap((set) => set.velocities)
174
+ const meanVelocity = calculateMeanVelocity(allReps)
175
+ let velocityLoss = 0
176
+ for (const set of sets) {
177
+ velocityLoss = Math.max(velocityLoss, calculateVelocityLoss(set.velocities))
178
+ }
179
+ return { meanVelocity, velocityLoss }
180
+ }
181
+
182
+ interface TabBarProps {
183
+ active: ExerciseDetailTab
184
+ onSelect: (tab: ExerciseDetailTab) => void
185
+ }
186
+
187
+ function TabBar({ active, onSelect }: TabBarProps) {
188
+ return (
189
+ <View
190
+ className="flex-row"
191
+ style={{
192
+ borderBottomWidth: 1,
193
+ borderBottomColor: BORDER_DEFAULT,
194
+ }}
195
+ accessibilityRole={'tablist' as ViewProps['accessibilityRole']}
196
+ testID="exercise-detail-page-tabs"
197
+ >
198
+ {EXERCISE_DETAIL_TABS.map(({ key, label }) => {
199
+ const isActive = key === active
200
+ return (
201
+ <Pressable
202
+ key={key}
203
+ onPress={() => onSelect(key)}
204
+ accessibilityRole="tab"
205
+ accessibilityState={{ selected: isActive }}
206
+ style={{
207
+ flex: 1,
208
+ alignItems: 'center',
209
+ paddingVertical: 10,
210
+ borderBottomWidth: 2,
211
+ borderBottomColor: isActive ? BRAND_PRIMARY : 'transparent',
212
+ }}
213
+ testID={`exercise-detail-page-tab-${key}`}
214
+ >
215
+ <Text
216
+ style={{
217
+ fontSize: 13,
218
+ fontFamily: 'Inter, sans-serif',
219
+ fontWeight: isActive ? '700' : '500',
220
+ color: isActive ? TEXT_PRIMARY : TEXT_TERTIARY,
221
+ }}
222
+ >
223
+ {label}
224
+ </Text>
225
+ </Pressable>
226
+ )
227
+ })}
228
+ </View>
229
+ )
230
+ }
231
+
232
+ const STAT_CARDS: Array<{ key: keyof ExerciseDetailStats; label: string }> = [
233
+ { key: 'sessions', label: 'Sessions' },
234
+ { key: 'bestE1rm', label: 'Best e1RM' },
235
+ { key: 'prCount', label: 'PRs' },
236
+ ]
237
+
238
+ function StatStrip({ stats, unit }: { stats: ExerciseDetailStats; unit: 'lbs' | 'kg' }) {
239
+ return (
240
+ <View className="flex-row" style={{ gap: 8 }} testID="exercise-detail-page-stats">
241
+ {STAT_CARDS.map(({ key, label }) => {
242
+ const raw = stats[key]
243
+ const value = key === 'bestE1rm' ? (raw == null ? '—' : `${raw} ${unit}`) : `${raw}`
244
+ return (
245
+ <View
246
+ key={key}
247
+ style={{
248
+ flex: 1,
249
+ padding: 12,
250
+ borderRadius: 10,
251
+ backgroundColor: SURFACE_ELEVATED,
252
+ borderWidth: 1,
253
+ borderColor: BORDER_DEFAULT,
254
+ }}
255
+ testID={`exercise-detail-page-stat-${key}`}
256
+ >
257
+ <Text
258
+ style={{
259
+ fontSize: 18,
260
+ fontFamily: '"Space Grotesk", sans-serif',
261
+ fontWeight: '700',
262
+ color: TEXT_PRIMARY,
263
+ }}
264
+ >
265
+ {value}
266
+ </Text>
267
+ <Text
268
+ style={{
269
+ marginTop: 2,
270
+ fontSize: 10,
271
+ fontFamily: 'Inter, sans-serif',
272
+ fontWeight: '600',
273
+ letterSpacing: 0.4,
274
+ textTransform: 'uppercase',
275
+ color: TEXT_TERTIARY,
276
+ }}
277
+ >
278
+ {label}
279
+ </Text>
280
+ </View>
281
+ )
282
+ })}
283
+ </View>
284
+ )
285
+ }
286
+
287
+ function SectionCard({
288
+ title,
289
+ children,
290
+ testID,
291
+ }: {
292
+ title: string
293
+ children: React.ReactNode
294
+ testID: string
295
+ }) {
296
+ return (
297
+ <View
298
+ style={{
299
+ backgroundColor: SURFACE_ELEVATED,
300
+ borderWidth: 1,
301
+ borderColor: BORDER_DEFAULT,
302
+ borderRadius: 12,
303
+ padding: 12,
304
+ gap: 10,
305
+ }}
306
+ testID={testID}
307
+ >
308
+ <Text
309
+ style={{
310
+ fontSize: 11,
311
+ fontFamily: 'Inter, sans-serif',
312
+ fontWeight: '600',
313
+ letterSpacing: 0.6,
314
+ textTransform: 'uppercase',
315
+ color: TEXT_TERTIARY,
316
+ }}
317
+ >
318
+ {title}
319
+ </Text>
320
+ {children}
321
+ </View>
322
+ )
323
+ }
324
+
325
+ interface EntryListProps {
326
+ entries: ExerciseDetailEntry[]
327
+ expandedId: string | null
328
+ onToggle: (id: string) => void
329
+ emptyLabel: string
330
+ testID: string
331
+ }
332
+
333
+ function EntryList({ entries, expandedId, onToggle, emptyLabel, testID }: EntryListProps) {
334
+ if (entries.length === 0) {
335
+ return (
336
+ <Text
337
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}
338
+ testID={`${testID}-empty`}
339
+ >
340
+ {emptyLabel}
341
+ </Text>
342
+ )
343
+ }
344
+ return (
345
+ <View style={{ gap: 8 }} testID={testID}>
346
+ {entries.map((entry) => (
347
+ <ExerciseCard
348
+ key={entry.id}
349
+ {...toExerciseCardProps(entry, entry.id === expandedId, () => onToggle(entry.id))}
350
+ />
351
+ ))}
352
+ </View>
353
+ )
354
+ }
355
+
356
+ function VbtBreakdown({ sets }: { sets: ExerciseVbtSet[] }) {
357
+ return (
358
+ <View style={{ gap: 10 }} testID="exercise-detail-page-vbt-breakdown">
359
+ {sets.map((set) => {
360
+ const mean = calculateMeanVelocity(set.velocities)
361
+ const loss = calculateVelocityLoss(set.velocities)
362
+ return (
363
+ <View
364
+ key={set.label}
365
+ className="flex-row items-center"
366
+ style={{ gap: 10 }}
367
+ testID="exercise-detail-page-vbt-set"
368
+ >
369
+ <Text
370
+ style={{
371
+ width: 44,
372
+ fontSize: 11,
373
+ fontFamily: 'Inter, sans-serif',
374
+ fontWeight: '600',
375
+ color: TEXT_SECONDARY,
376
+ }}
377
+ >
378
+ {set.label}
379
+ </Text>
380
+ <View className="flex-1" style={{ height: 8, justifyContent: 'center' }}>
381
+ <VelocityStrip velocities={set.velocities} variant="mini" />
382
+ </View>
383
+ <Text
384
+ style={{
385
+ fontSize: 11,
386
+ fontFamily: 'Inter, sans-serif',
387
+ fontVariant: ['tabular-nums'],
388
+ color: TEXT_TERTIARY,
389
+ }}
390
+ >
391
+ {`${mean.toFixed(2)} · -${loss}%`}
392
+ </Text>
393
+ </View>
394
+ )
395
+ })}
396
+ </View>
397
+ )
398
+ }
399
+
400
+ function VbtSummaryRow({ summary }: { summary: VbtSummary }) {
401
+ const cells = [
402
+ { label: 'Mean Velocity', value: `${summary.meanVelocity.toFixed(2)} m/s` },
403
+ { label: 'Peak Vel. Loss', value: `-${summary.velocityLoss}%` },
404
+ ]
405
+ return (
406
+ <View className="flex-row" style={{ gap: 8 }} testID="exercise-detail-page-vbt-summary">
407
+ {cells.map((cell) => (
408
+ <View
409
+ key={cell.label}
410
+ style={{
411
+ flex: 1,
412
+ padding: 12,
413
+ borderRadius: 10,
414
+ backgroundColor: SURFACE_ELEVATED,
415
+ borderWidth: 1,
416
+ borderColor: BORDER_DEFAULT,
417
+ }}
418
+ >
419
+ <Text
420
+ style={{
421
+ fontSize: 16,
422
+ fontFamily: '"Space Grotesk", sans-serif',
423
+ fontWeight: '700',
424
+ color: TEXT_PRIMARY,
425
+ }}
426
+ >
427
+ {cell.value}
428
+ </Text>
429
+ <Text
430
+ style={{
431
+ marginTop: 2,
432
+ fontSize: 10,
433
+ fontFamily: 'Inter, sans-serif',
434
+ fontWeight: '600',
435
+ letterSpacing: 0.4,
436
+ textTransform: 'uppercase',
437
+ color: TEXT_TERTIARY,
438
+ }}
439
+ >
440
+ {cell.label}
441
+ </Text>
442
+ </View>
443
+ ))}
444
+ </View>
445
+ )
446
+ }
447
+
448
+ /**
449
+ * ExerciseDetailPage — a tabbed per-exercise view. A shared header (name,
450
+ * context line, current e1RM pill) sits above a Progress / History / Advanced
451
+ * tab bar. Progress composes the `MesoStatusCard` context banner, a stats
452
+ * strip, a `StrengthTrendChart`, and a week-by-week progression list with
453
+ * inline `ExerciseCard` expansion. History lists every logged session with the
454
+ * same inline expansion. Advanced surfaces VBT data via a `CapacityBandChart`,
455
+ * a velocity summary, and a per-set `VelocityStrip` breakdown. Tab and
456
+ * per-list expansion are page-level state; children keep their own styles.
457
+ *
458
+ * @example
459
+ * <ExerciseDetailPage
460
+ * exercise={exercise}
461
+ * meso={meso}
462
+ * trend={trend}
463
+ * progression={progression}
464
+ * history={history}
465
+ * vbt={vbt}
466
+ * />
467
+ */
468
+ export function ExerciseDetailPage({
469
+ exercise,
470
+ meso,
471
+ trend,
472
+ progression,
473
+ history,
474
+ vbt,
475
+ className,
476
+ ...props
477
+ }: ExerciseDetailPageProps) {
478
+ const [tab, setTab] = useState<ExerciseDetailTab>('progress')
479
+ const [expandedProgressId, setExpandedProgressId] = useState<string | null>(null)
480
+ const [expandedHistoryId, setExpandedHistoryId] = useState<string | null>(null)
481
+
482
+ const stats = useMemo(() => deriveExerciseStats(history), [history])
483
+ const vbtSummary = useMemo(() => summarizeVbt(vbt.sets), [vbt.sets])
484
+
485
+ const toggle =
486
+ (setId: (updater: (current: string | null) => string | null) => void) => (id: string) =>
487
+ setId((current) => (current === id ? null : id))
488
+
489
+ return (
490
+ <View
491
+ className={className}
492
+ style={{ width: 390, backgroundColor: PAGE_BG }}
493
+ accessibilityRole={'main' as ViewProps['accessibilityRole']}
494
+ aria-label={exercise.name}
495
+ testID="exercise-detail-page"
496
+ {...props}
497
+ >
498
+ <View style={{ padding: 16, gap: 14 }} testID="exercise-detail-page-content">
499
+ <View
500
+ className="flex-row items-start justify-between"
501
+ style={{ gap: 8 }}
502
+ testID="exercise-detail-page-header"
503
+ >
504
+ <View style={{ flexShrink: 1 }}>
505
+ <Text
506
+ accessibilityRole="header"
507
+ style={{
508
+ fontSize: 20,
509
+ fontFamily: '"Space Grotesk", sans-serif',
510
+ fontWeight: '700',
511
+ color: TEXT_PRIMARY,
512
+ }}
513
+ testID="exercise-detail-page-title"
514
+ >
515
+ {exercise.name}
516
+ </Text>
517
+ <Text
518
+ style={{
519
+ marginTop: 2,
520
+ fontSize: 12,
521
+ fontFamily: 'Inter, sans-serif',
522
+ color: TEXT_SECONDARY,
523
+ }}
524
+ testID="exercise-detail-page-subtitle"
525
+ >
526
+ {exercise.subtitle}
527
+ </Text>
528
+ </View>
529
+ {exercise.currentE1rm != null && (
530
+ <View
531
+ style={{
532
+ paddingVertical: 4,
533
+ paddingHorizontal: 10,
534
+ borderRadius: 6,
535
+ backgroundColor: 'rgba(255,121,0,0.10)',
536
+ borderWidth: 1,
537
+ borderColor: 'rgba(255,121,0,0.25)',
538
+ }}
539
+ testID="exercise-detail-page-e1rm"
540
+ >
541
+ <Text
542
+ style={{
543
+ fontSize: 9,
544
+ fontFamily: 'Inter, sans-serif',
545
+ fontWeight: '600',
546
+ letterSpacing: 0.4,
547
+ textTransform: 'uppercase',
548
+ color: TEXT_TERTIARY,
549
+ }}
550
+ >
551
+ e1RM
552
+ </Text>
553
+ <Text
554
+ style={{
555
+ fontSize: 15,
556
+ fontFamily: '"Space Grotesk", sans-serif',
557
+ fontWeight: '700',
558
+ color: BRAND_PRIMARY,
559
+ }}
560
+ >
561
+ {`${exercise.currentE1rm} ${exercise.unit}`}
562
+ </Text>
563
+ </View>
564
+ )}
565
+ </View>
566
+
567
+ <TabBar active={tab} onSelect={setTab} />
568
+
569
+ {tab === 'progress' && (
570
+ <View style={{ gap: 14 }} testID="exercise-detail-page-panel-progress">
571
+ <MesoStatusCard {...meso} />
572
+ <StatStrip stats={stats} unit={exercise.unit} />
573
+ <SectionCard title="Strength Trend" testID="exercise-detail-page-trend">
574
+ <StrengthTrendChart
575
+ data={trend.data}
576
+ projection={trend.projection}
577
+ mesoBoundaries={trend.mesoBoundaries}
578
+ width={CHART_WIDTH}
579
+ height={CHART_HEIGHT}
580
+ unit={exercise.unit}
581
+ animateOnMount={false}
582
+ />
583
+ </SectionCard>
584
+ <SectionCard title="Week-by-Week" testID="exercise-detail-page-progression">
585
+ <EntryList
586
+ entries={progression}
587
+ expandedId={expandedProgressId}
588
+ onToggle={toggle(setExpandedProgressId)}
589
+ emptyLabel="No progression logged yet"
590
+ testID="exercise-detail-page-progression-list"
591
+ />
592
+ </SectionCard>
593
+ </View>
594
+ )}
595
+
596
+ {tab === 'history' && (
597
+ <View style={{ gap: 14 }} testID="exercise-detail-page-panel-history">
598
+ <SectionCard title="All Sessions" testID="exercise-detail-page-history">
599
+ <EntryList
600
+ entries={history}
601
+ expandedId={expandedHistoryId}
602
+ onToggle={toggle(setExpandedHistoryId)}
603
+ emptyLabel="No sessions logged yet"
604
+ testID="exercise-detail-page-history-list"
605
+ />
606
+ </SectionCard>
607
+ </View>
608
+ )}
609
+
610
+ {tab === 'advanced' && (
611
+ <View style={{ gap: 14 }} testID="exercise-detail-page-panel-advanced">
612
+ <SectionCard title="Capacity Band" testID="exercise-detail-page-capacity">
613
+ <CapacityBandChart
614
+ band={vbt.band}
615
+ workouts={vbt.workouts}
616
+ projection={vbt.projection}
617
+ width={CHART_WIDTH}
618
+ height={CHART_HEIGHT}
619
+ />
620
+ </SectionCard>
621
+ <VbtSummaryRow summary={vbtSummary} />
622
+ <SectionCard title="Velocity Breakdown" testID="exercise-detail-page-velocity">
623
+ <VbtBreakdown sets={vbt.sets} />
624
+ </SectionCard>
625
+ </View>
626
+ )}
627
+ </View>
628
+ </View>
629
+ )
630
+ }
@@ -0,0 +1,75 @@
1
+ import React from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View } from 'react-native'
4
+ import { InputBar } from './InputBar'
5
+
6
+ const meta: Meta<typeof InputBar> = {
7
+ title: 'Custom/Workout/InputBar',
8
+ component: InputBar,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ canRecord: {
12
+ control: 'boolean',
13
+ description: 'Whether the record button is enabled',
14
+ },
15
+ unit: {
16
+ control: 'select',
17
+ options: ['lbs', 'kg'],
18
+ description: 'Weight unit',
19
+ },
20
+ visible: {
21
+ control: 'boolean',
22
+ description: 'Whether the input bar is visible',
23
+ },
24
+ },
25
+ decorators: [
26
+ (Story) => (
27
+ <View style={{ width: 400, backgroundColor: '#111111' }}>
28
+ <Story />
29
+ </View>
30
+ ),
31
+ ],
32
+ }
33
+
34
+ export default meta
35
+ type Story = StoryObj<typeof InputBar>
36
+
37
+ export const Default: Story = {
38
+ args: {
39
+ exerciseName: 'Bench Press',
40
+ setNumber: 2,
41
+ totalSets: 5,
42
+ reps: '',
43
+ weight: '',
44
+ unit: 'lbs',
45
+ onRepsChange: () => {},
46
+ onWeightChange: () => {},
47
+ onRecord: () => {},
48
+ canRecord: true,
49
+ visible: true,
50
+ },
51
+ }
52
+
53
+ export const Disabled: Story = {
54
+ args: {
55
+ ...Default.args,
56
+ canRecord: false,
57
+ },
58
+ }
59
+
60
+ export const Kilograms: Story = {
61
+ args: {
62
+ ...Default.args,
63
+ unit: 'kg',
64
+ weight: '60',
65
+ reps: '8',
66
+ },
67
+ }
68
+
69
+ export const WithFilledValues: Story = {
70
+ args: {
71
+ ...Default.args,
72
+ reps: '5',
73
+ weight: '225',
74
+ },
75
+ }