@titan-design/react-ui 0.2.7 → 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 (82) 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-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/{chunk-3VLOBS6O.mjs → chunk-P7TSQUN6.mjs} +3 -3
  12. package/dist/{chunk-3VLOBS6O.mjs.map → chunk-P7TSQUN6.mjs.map} +1 -1
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +375 -249
  16. package/dist/index.d.ts +375 -249
  17. package/dist/index.js +1259 -918
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +1314 -2107
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +260 -0
  22. package/dist/theme/index.d.ts +260 -0
  23. package/dist/theme/index.js +122 -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 +447 -0
  39. package/src/components/custom/Workout/BodyMap.tsx +5 -6
  40. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +7 -8
  41. package/src/components/custom/Workout/CapacityBandChart.tsx +1 -1
  42. package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
  43. package/src/components/custom/Workout/ExerciseCard.tsx +28 -15
  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 +630 -0
  47. package/src/components/custom/Workout/InputBar.tsx +6 -7
  48. package/src/components/custom/Workout/MesoCard.tsx +4 -2
  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 +5 -5
  52. package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
  53. package/src/components/custom/Workout/PrHistoryModal.tsx +5 -5
  54. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  55. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  56. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  57. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -8
  58. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  59. package/src/components/custom/Workout/RestTimer.tsx +11 -9
  60. package/src/components/custom/Workout/SetRow.tsx +13 -19
  61. package/src/components/custom/Workout/StatusDot.tsx +1 -0
  62. package/src/components/custom/Workout/StrengthTrendChart.tsx +5 -5
  63. package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
  64. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  65. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  66. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
  68. package/src/components/custom/Workout/VelocityStrip.tsx +16 -15
  69. package/src/components/custom/Workout/WeekRow.tsx +1 -1
  70. package/src/components/custom/Workout/WorkoutCard.tsx +5 -3
  71. package/src/components/custom/Workout/index.ts +71 -32
  72. package/src/theme/config.completeness.test.ts +77 -0
  73. package/src/theme/config.ts +151 -0
  74. package/src/theme/manifest/design-freeze.test.ts +155 -0
  75. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  76. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  77. package/src/theme/tokens-css.ts +5 -3
  78. package/src/theme/workout-tokens.ts +12 -14
  79. package/src/utils/index.ts +10 -0
  80. package/src/utils/workout-format.test.ts +81 -0
  81. package/src/utils/workout-format.ts +83 -0
  82. package/dist/chunk-5SSKGS6E.mjs.map +0 -1
@@ -100,6 +100,30 @@ describe('ExerciseCard', () => {
100
100
  expect(headers).toHaveTextContent('RPE')
101
101
  })
102
102
 
103
+ it('shows the KG weight header when the unit is kg', () => {
104
+ render(
105
+ <ExerciseCard
106
+ {...expandedProps}
107
+ summary={{ sets: 3, reps: 8, weight: 84, unit: 'kg' }}
108
+ />,
109
+ )
110
+ const headers = screen.getByTestId('exercise-card-column-headers')
111
+ expect(headers).toHaveTextContent('KG')
112
+ expect(headers).not.toHaveTextContent('LBS')
113
+ })
114
+
115
+ it('derives the weight header from the sets unit when summary is absent', () => {
116
+ render(
117
+ <ExerciseCard
118
+ name="Squat"
119
+ state="expanded"
120
+ onToggle={vi.fn()}
121
+ sets={[{ mode: 'completed', setNumber: 1, reps: 8, weight: 60, unit: 'kg' }]}
122
+ />,
123
+ )
124
+ expect(screen.getByTestId('exercise-card-column-headers')).toHaveTextContent('KG')
125
+ })
126
+
103
127
  it('renders SetRow components', () => {
104
128
  render(<ExerciseCard {...expandedProps} />)
105
129
  const setRows = screen.getAllByTestId('set-row')
@@ -1,13 +1,13 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useState } from 'react'
3
3
  import { View, Text, Pressable } from 'react-native'
4
- import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
4
  import { VelocityStrip } from './VelocityStrip'
6
5
  import { PlaceholderStrip } from './PlaceholderStrip'
7
6
  import { WeightBadge } from './WeightBadge'
8
7
  import { PrBadge } from './PrBadge'
9
8
  import { TempoDisplay } from './TempoDisplay'
10
9
  import { SetRow, type SetRowProps } from './SetRow'
10
+ import { roundWeight } from '../../../utils/workout-format'
11
11
 
12
12
  export type ExerciseCardState = 'collapsed' | 'expanded' | 'upcoming'
13
13
 
@@ -35,13 +35,24 @@ export interface ExerciseCardProps {
35
35
  }
36
36
 
37
37
  const COLORS = {
38
- textPrimary: '#F3F4F6',
39
- textSecondary: '#9CA3AF',
40
- textTertiary: '#6B7280',
38
+ // Theme-aware tokens: text foregrounds and this card's own surfaces/borders
39
+ // must flip together so text stays readable in both light and dark. The
40
+ // expanded/pressed surfaces are self-owned here (not from a themed Card), so
41
+ // hardcoding dark values would leave dark-on-dark once the text flips.
42
+ // react-native-web passes the CSS var through to the inline style.
43
+ textPrimary: 'var(--color-text-primary)',
44
+ textSecondary: 'var(--color-text-secondary)',
45
+ textTertiary: 'var(--color-text-tertiary)',
41
46
  brandPrimary: '#FF7900',
47
+ surfaceRaised: 'var(--color-surface-raised)',
48
+ surfaceElevated: 'var(--color-surface-elevated)',
49
+ borderDefault: 'var(--color-border-default)',
42
50
  }
43
51
 
44
- const COLUMN_HEADERS = ['SET', 'PREV', 'REPS', 'LBS', 'RPE'] as const
52
+ /** Column headers; the weight column reflects the card's unit (LBS / KG). */
53
+ function buildColumnHeaders(unit: 'lbs' | 'kg'): string[] {
54
+ return ['SET', 'PREV', 'REPS', unit.toUpperCase(), 'RPE']
55
+ }
45
56
 
46
57
  function getSupersetBorderRadius(
47
58
  position: ExerciseCardProps['supersetPosition'],
@@ -89,7 +100,7 @@ function getSupersetMargin(
89
100
 
90
101
  function formatSummary(summary: ExerciseCardProps['summary']): string {
91
102
  if (!summary) return ''
92
- return `${summary.sets}\u00D7${summary.reps} @ ${summary.weight} ${summary.unit}`
103
+ return `${summary.sets}\u00D7${summary.reps} @ ${roundWeight(summary.weight)} ${summary.unit}`
93
104
  }
94
105
 
95
106
  function formatAccessibilityLabel(
@@ -132,9 +143,7 @@ function CollapsedCard({
132
143
  padding: 12,
133
144
  paddingHorizontal: 14,
134
145
  cursor: 'pointer',
135
- backgroundColor: pressed
136
- ? WORKOUT_TOKENS.surface.raised
137
- : 'transparent',
146
+ backgroundColor: pressed ? COLORS.surfaceRaised : 'transparent',
138
147
  ...borderRadius,
139
148
  ...supersetMargin,
140
149
  }}
@@ -170,7 +179,7 @@ function CollapsedCard({
170
179
  )}
171
180
  {e1rm && (
172
181
  <WeightBadge
173
- value={e1rm.value}
182
+ value={roundWeight(e1rm.value)}
174
183
  unit={e1rm.unit}
175
184
  size="sm"
176
185
  showIcon={false}
@@ -223,13 +232,17 @@ function ExpandedCard({
223
232
  }: ExerciseCardProps) {
224
233
  const borderRadius = getSupersetBorderRadius(supersetPosition)
225
234
  const supersetMargin = getSupersetMargin(supersetPosition)
235
+ // Summary is the card-level authority for the weight column; fall back to the
236
+ // first set's unit, then lbs. (Mixed per-set units keep the card-level label.)
237
+ const unit = summary?.unit ?? sets?.[0]?.unit ?? 'lbs'
238
+ const columnHeaders = buildColumnHeaders(unit)
226
239
 
227
240
  return (
228
241
  <View
229
242
  style={{
230
- backgroundColor: WORKOUT_TOKENS.surface.elevated,
243
+ backgroundColor: COLORS.surfaceElevated,
231
244
  borderWidth: 1,
232
- borderColor: WORKOUT_TOKENS.border.default,
245
+ borderColor: COLORS.borderDefault,
233
246
  ...borderRadius,
234
247
  ...supersetMargin,
235
248
  }}
@@ -249,7 +262,7 @@ function ExpandedCard({
249
262
  paddingHorizontal: 14,
250
263
  paddingBottom: 10,
251
264
  borderBottomWidth: 1,
252
- borderBottomColor: WORKOUT_TOKENS.border.default,
265
+ borderBottomColor: COLORS.borderDefault,
253
266
  }}
254
267
  >
255
268
  <Text
@@ -279,7 +292,7 @@ function ExpandedCard({
279
292
  )}
280
293
  {e1rm && (
281
294
  <WeightBadge
282
- value={e1rm.value}
295
+ value={roundWeight(e1rm.value)}
283
296
  unit={e1rm.unit}
284
297
  size="sm"
285
298
  showIcon={false}
@@ -309,7 +322,7 @@ function ExpandedCard({
309
322
  }}
310
323
  testID="exercise-card-column-headers"
311
324
  >
312
- {COLUMN_HEADERS.map((header, i) => {
325
+ {columnHeaders.map((header, i) => {
313
326
  const widths = [36, undefined, 44, 56, 36]
314
327
  const width = widths[i]
315
328
  const isFlex = width === undefined
@@ -0,0 +1,225 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import {
3
+ ExerciseDetailPage,
4
+ type ExerciseDetailEntry,
5
+ type ExerciseTrend,
6
+ type ExerciseVbt,
7
+ } from './ExerciseDetailPage'
8
+ import type { MesoStatusCardProps } from './MesoStatusCard'
9
+ import type { SetRowProps } from './SetRow'
10
+
11
+ const meso: MesoStatusCardProps = {
12
+ mesoName: 'Hypertrophy Block',
13
+ mesoSubtitle: 'Week 6 of 8 · Push/Pull/Legs',
14
+ statusBadge: { label: 'On Track', variant: 'success' },
15
+ metrics: [
16
+ { label: 'Prescribed', value: '4×8 @ RPE 8' },
17
+ { label: 'Actual', value: '4×8 @ 195 lbs' },
18
+ { label: 'Best e1RM', value: '248 lbs' },
19
+ { label: 'Sessions', value: '11' },
20
+ ],
21
+ gauges: [
22
+ { label: 'Intensity', level: 0.72, sublabel: 'Actual' },
23
+ { label: 'Volume', level: 0.6, sublabel: 'Weekly' },
24
+ ],
25
+ coaching: {
26
+ text: 'Bar speed is holding — add 5 lbs next session before the deload.',
27
+ highlights: ['add 5 lbs'],
28
+ },
29
+ nextTarget: { icon: '🎯', text: '200 lbs × 8 @ RPE 8' },
30
+ }
31
+
32
+ function historySets(weight: number, topRpe: number): SetRowProps[] {
33
+ return [1, 2, 3, 4].map((setNumber) => ({
34
+ mode: 'history' as const,
35
+ setNumber,
36
+ previous: { reps: 8, weight: weight - 5 },
37
+ reps: 8,
38
+ weight,
39
+ rpe: topRpe - (4 - setNumber) * 0.5,
40
+ unit: 'lbs' as const,
41
+ }))
42
+ }
43
+
44
+ const progression: ExerciseDetailEntry[] = [
45
+ {
46
+ id: 'w1',
47
+ title: 'Week 1',
48
+ summary: { sets: 4, reps: 8, weight: 175, unit: 'lbs' },
49
+ e1rm: { value: 222, unit: 'lbs' },
50
+ tempo: [3, 1, 1, 0],
51
+ sets: historySets(175, 7.5),
52
+ },
53
+ {
54
+ id: 'w2',
55
+ title: 'Week 2',
56
+ summary: { sets: 4, reps: 8, weight: 180, unit: 'lbs' },
57
+ e1rm: { value: 228, unit: 'lbs' },
58
+ tempo: [3, 1, 1, 0],
59
+ sets: historySets(180, 8),
60
+ },
61
+ {
62
+ id: 'w3',
63
+ title: 'Week 3',
64
+ summary: { sets: 4, reps: 8, weight: 185, unit: 'lbs' },
65
+ e1rm: { value: 235, unit: 'lbs' },
66
+ tempo: [3, 1, 1, 0],
67
+ sets: historySets(185, 8.5),
68
+ },
69
+ {
70
+ id: 'w4',
71
+ title: 'Week 4 · Deload',
72
+ summary: { sets: 3, reps: 6, weight: 165, unit: 'lbs' },
73
+ e1rm: { value: 205, unit: 'lbs' },
74
+ sets: historySets(165, 6.5).slice(0, 3),
75
+ },
76
+ {
77
+ id: 'w5',
78
+ title: 'Week 5',
79
+ summary: { sets: 4, reps: 8, weight: 190, unit: 'lbs' },
80
+ e1rm: { value: 241, unit: 'lbs' },
81
+ tempo: [3, 1, 1, 0],
82
+ sets: historySets(190, 8.5),
83
+ },
84
+ {
85
+ id: 'w6',
86
+ title: 'Week 6',
87
+ summary: { sets: 4, reps: 8, weight: 195, unit: 'lbs' },
88
+ e1rm: { value: 248, unit: 'lbs' },
89
+ isPR: true,
90
+ tempo: [3, 1, 1, 0],
91
+ sets: historySets(195, 9),
92
+ },
93
+ ]
94
+
95
+ const history: ExerciseDetailEntry[] = [
96
+ {
97
+ id: 's6',
98
+ title: 'Mon, Jun 16',
99
+ summary: { sets: 4, reps: 8, weight: 195, unit: 'lbs' },
100
+ e1rm: { value: 248, unit: 'lbs' },
101
+ isPR: true,
102
+ tempo: [3, 1, 1, 0],
103
+ sets: historySets(195, 9),
104
+ },
105
+ {
106
+ id: 's5',
107
+ title: 'Mon, Jun 9',
108
+ summary: { sets: 4, reps: 8, weight: 190, unit: 'lbs' },
109
+ e1rm: { value: 241, unit: 'lbs' },
110
+ sets: historySets(190, 8.5),
111
+ },
112
+ {
113
+ id: 's4',
114
+ title: 'Mon, Jun 2 · Deload',
115
+ summary: { sets: 3, reps: 6, weight: 165, unit: 'lbs' },
116
+ e1rm: { value: 205, unit: 'lbs' },
117
+ sets: historySets(165, 6.5).slice(0, 3),
118
+ },
119
+ {
120
+ id: 's3',
121
+ title: 'Mon, May 26',
122
+ summary: { sets: 4, reps: 8, weight: 185, unit: 'lbs' },
123
+ e1rm: { value: 235, unit: 'lbs' },
124
+ sets: historySets(185, 8.5),
125
+ },
126
+ {
127
+ id: 's2',
128
+ title: 'Mon, May 19',
129
+ summary: { sets: 4, reps: 8, weight: 180, unit: 'lbs' },
130
+ e1rm: { value: 228, unit: 'lbs' },
131
+ sets: historySets(180, 8),
132
+ },
133
+ {
134
+ id: 's1',
135
+ title: 'Mon, May 12',
136
+ summary: { sets: 4, reps: 8, weight: 175, unit: 'lbs' },
137
+ e1rm: { value: 222, unit: 'lbs' },
138
+ sets: historySets(175, 7.5),
139
+ },
140
+ ]
141
+
142
+ const trend: ExerciseTrend = {
143
+ data: [
144
+ { date: '2026-05-12', e1rm: 222 },
145
+ { date: '2026-05-19', e1rm: 228 },
146
+ { date: '2026-05-26', e1rm: 235 },
147
+ { date: '2026-06-02', e1rm: 231 },
148
+ { date: '2026-06-09', e1rm: 241 },
149
+ { date: '2026-06-16', e1rm: 248, isPR: true },
150
+ ],
151
+ projection: [
152
+ { date: '2026-06-16', e1rm: 248 },
153
+ { date: '2026-06-23', e1rm: 253 },
154
+ { date: '2026-06-30', e1rm: 258 },
155
+ ],
156
+ mesoBoundaries: [{ date: '2026-06-02', label: 'Deload' }],
157
+ }
158
+
159
+ const vbt: ExerciseVbt = {
160
+ band: [
161
+ { date: '2026-05-12', bandLow: 40, bandHigh: 70 },
162
+ { date: '2026-05-26', bandLow: 48, bandHigh: 78 },
163
+ { date: '2026-06-09', bandLow: 55, bandHigh: 86 },
164
+ { date: '2026-06-16', bandLow: 60, bandHigh: 92 },
165
+ ],
166
+ workouts: [
167
+ { date: '2026-05-12', load: 52, status: 'within' },
168
+ { date: '2026-05-26', load: 80, status: 'above' },
169
+ { date: '2026-06-09', load: 68, status: 'within' },
170
+ { date: '2026-06-16', load: 58, status: 'below' },
171
+ ],
172
+ projection: {
173
+ withTraining: [
174
+ { date: '2026-06-16', bandLow: 60, bandHigh: 92 },
175
+ { date: '2026-06-23', bandLow: 64, bandHigh: 98 },
176
+ ],
177
+ withRest: [
178
+ { date: '2026-06-16', bandLow: 60, bandHigh: 92 },
179
+ { date: '2026-06-23', bandLow: 54, bandHigh: 84 },
180
+ ],
181
+ },
182
+ sets: [
183
+ { label: 'Set 1', velocities: [0.72, 0.7, 0.68, 0.66, 0.63, 0.6, 0.57, 0.54] },
184
+ { label: 'Set 2', velocities: [0.7, 0.67, 0.64, 0.61, 0.58, 0.54, 0.5, 0.46] },
185
+ { label: 'Set 3', velocities: [0.68, 0.64, 0.6, 0.56, 0.52, 0.47, 0.42, 0.38] },
186
+ { label: 'Set 4', velocities: [0.65, 0.6, 0.55, 0.5, 0.44, 0.39, 0.34, 0.3] },
187
+ ],
188
+ }
189
+
190
+ const meta: Meta<typeof ExerciseDetailPage> = {
191
+ title: 'Custom/Workout/ExerciseDetailPage',
192
+ component: ExerciseDetailPage,
193
+ parameters: { layout: 'fullscreen' },
194
+ tags: ['autodocs'],
195
+ args: {
196
+ exercise: {
197
+ name: 'Barbell Bench Press',
198
+ subtitle: 'Chest · Barbell',
199
+ unit: 'lbs',
200
+ currentE1rm: 248,
201
+ },
202
+ meso,
203
+ trend,
204
+ progression,
205
+ history,
206
+ vbt,
207
+ },
208
+ }
209
+
210
+ export default meta
211
+ type Story = StoryObj<typeof ExerciseDetailPage>
212
+
213
+ export const Default: Story = {}
214
+
215
+ export const NoVbtData: Story = {
216
+ args: {
217
+ exercise: {
218
+ name: 'Goblet Squat',
219
+ subtitle: 'Quads · Dumbbell',
220
+ unit: 'lbs',
221
+ currentE1rm: 132,
222
+ },
223
+ vbt: { band: [], workouts: [], sets: [] },
224
+ },
225
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import {
4
+ ExerciseDetailPage,
5
+ deriveExerciseStats,
6
+ toExerciseCardProps,
7
+ summarizeVbt,
8
+ type ExerciseDetailEntry,
9
+ type ExerciseDetailPageProps,
10
+ } from './ExerciseDetailPage'
11
+
12
+ const entry = (id: string, weight: number, e1rm: number, isPR = false): ExerciseDetailEntry => ({
13
+ id,
14
+ title: `Session ${id}`,
15
+ summary: { sets: 3, reps: 8, weight, unit: 'lbs' },
16
+ e1rm: { value: e1rm, unit: 'lbs' },
17
+ isPR,
18
+ sets: [
19
+ { mode: 'history', setNumber: 1, reps: 8, weight, rpe: 8, unit: 'lbs' },
20
+ { mode: 'history', setNumber: 2, reps: 8, weight, rpe: 8.5, unit: 'lbs' },
21
+ ],
22
+ })
23
+
24
+ const history: ExerciseDetailEntry[] = [
25
+ entry('a', 195, 248, true),
26
+ entry('b', 185, 235),
27
+ entry('c', 175, 222),
28
+ ]
29
+
30
+ const baseProps: ExerciseDetailPageProps = {
31
+ exercise: { name: 'Bench Press', subtitle: 'Chest · Barbell', unit: 'lbs', currentE1rm: 248 },
32
+ meso: {
33
+ mesoName: 'Hypertrophy',
34
+ mesoSubtitle: 'Week 6 of 8',
35
+ statusBadge: { label: 'On Track', variant: 'success' },
36
+ metrics: [{ label: 'Actual', value: '4×8' }],
37
+ gauges: [{ label: 'Intensity', level: 0.7 }],
38
+ },
39
+ trend: {
40
+ data: [
41
+ { date: '2026-05-12', e1rm: 222 },
42
+ { date: '2026-06-16', e1rm: 248, isPR: true },
43
+ ],
44
+ },
45
+ progression: [entry('w1', 175, 222), entry('w2', 195, 248, true)],
46
+ history,
47
+ vbt: {
48
+ band: [{ date: '2026-05-12', bandLow: 40, bandHigh: 70 }],
49
+ workouts: [{ date: '2026-05-12', load: 52, status: 'within' }],
50
+ sets: [
51
+ { label: 'Set 1', velocities: [0.7, 0.6, 0.5] },
52
+ { label: 'Set 2', velocities: [0.68, 0.5, 0.34] },
53
+ ],
54
+ },
55
+ }
56
+
57
+ describe('deriveExerciseStats', () => {
58
+ it('counts sessions, tracks the best e1RM, and tallies PRs', () => {
59
+ expect(deriveExerciseStats(history)).toEqual({ sessions: 3, bestE1rm: 248, prCount: 1 })
60
+ })
61
+
62
+ it('returns a null best e1RM for an empty history', () => {
63
+ expect(deriveExerciseStats([])).toEqual({ sessions: 0, bestE1rm: null, prCount: 0 })
64
+ })
65
+ })
66
+
67
+ describe('toExerciseCardProps', () => {
68
+ it('collapses without sets and expands with them', () => {
69
+ const collapsed = toExerciseCardProps(history[0], false, () => {})
70
+ expect(collapsed.state).toBe('collapsed')
71
+ expect(collapsed.sets).toBeUndefined()
72
+
73
+ const expanded = toExerciseCardProps(history[0], true, () => {})
74
+ expect(expanded.state).toBe('expanded')
75
+ expect(expanded.sets).toHaveLength(2)
76
+ expect(expanded.name).toBe('Session a')
77
+ })
78
+ })
79
+
80
+ describe('summarizeVbt', () => {
81
+ it('averages every rep and reports the worst within-set loss', () => {
82
+ const summary = summarizeVbt([
83
+ { label: 'Set 1', velocities: [1, 0.5] },
84
+ { label: 'Set 2', velocities: [1, 0.25] },
85
+ ])
86
+ expect(summary.meanVelocity).toBeCloseTo(0.6875)
87
+ expect(summary.velocityLoss).toBe(75)
88
+ })
89
+
90
+ it('is zeroed for no sets', () => {
91
+ expect(summarizeVbt([])).toEqual({ meanVelocity: 0, velocityLoss: 0 })
92
+ })
93
+ })
94
+
95
+ describe('ExerciseDetailPage', () => {
96
+ it('renders the header and opens on the Progress tab', () => {
97
+ render(<ExerciseDetailPage {...baseProps} />)
98
+ expect(screen.getByTestId('exercise-detail-page')).toBeInTheDocument()
99
+ expect(screen.getByTestId('exercise-detail-page-title')).toHaveTextContent('Bench Press')
100
+ expect(screen.getByTestId('exercise-detail-page-panel-progress')).toBeInTheDocument()
101
+ expect(screen.getByTestId('meso-status-card')).toBeInTheDocument()
102
+ expect(screen.getByTestId('exercise-detail-page-stat-sessions')).toHaveTextContent('3')
103
+ })
104
+
105
+ it('switches tabs to reveal history and advanced panels', () => {
106
+ render(<ExerciseDetailPage {...baseProps} />)
107
+
108
+ fireEvent.click(screen.getByTestId('exercise-detail-page-tab-history'))
109
+ expect(screen.getByTestId('exercise-detail-page-panel-history')).toBeInTheDocument()
110
+ expect(screen.queryByTestId('exercise-detail-page-panel-progress')).not.toBeInTheDocument()
111
+
112
+ fireEvent.click(screen.getByTestId('exercise-detail-page-tab-advanced'))
113
+ expect(screen.getByTestId('exercise-detail-page-panel-advanced')).toBeInTheDocument()
114
+ expect(screen.getByTestId('exercise-detail-page-vbt-summary')).toBeInTheDocument()
115
+ expect(screen.getAllByTestId('exercise-detail-page-vbt-set')).toHaveLength(2)
116
+ })
117
+
118
+ it('inline-expands a progression entry to reveal its sets', () => {
119
+ render(<ExerciseDetailPage {...baseProps} />)
120
+ expect(screen.queryByTestId('exercise-card-sets')).not.toBeInTheDocument()
121
+
122
+ fireEvent.click(screen.getAllByTestId('exercise-card')[0])
123
+ expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
124
+
125
+ fireEvent.click(screen.getAllByTestId('exercise-card-header')[0])
126
+ expect(screen.queryByTestId('exercise-card-sets')).not.toBeInTheDocument()
127
+ })
128
+ })