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