@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.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/{chunk-3VLOBS6O.mjs → chunk-P7TSQUN6.mjs} +3 -3
- package/dist/{chunk-3VLOBS6O.mjs.map → chunk-P7TSQUN6.mjs.map} +1 -1
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +375 -249
- package/dist/index.d.ts +375 -249
- package/dist/index.js +1259 -918
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1314 -2107
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +260 -0
- package/dist/theme/index.d.ts +260 -0
- package/dist/theme/index.js +122 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -2
- package/dist/theme/tokens-css.d.mts +5 -3
- package/dist/theme/tokens-css.d.ts +5 -3
- package/dist/theme/tokens-css.js +318 -9
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +1 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +10 -1
- package/src/bodymap.ts +37 -0
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
- package/src/components/custom/Workout/BodyMap.tsx +5 -6
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +7 -8
- package/src/components/custom/Workout/CapacityBandChart.tsx +1 -1
- package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +28 -15
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
- package/src/components/custom/Workout/InputBar.tsx +6 -7
- package/src/components/custom/Workout/MesoCard.tsx +4 -2
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
- package/src/components/custom/Workout/MesoStatusCard.tsx +5 -5
- package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +5 -5
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -8
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +11 -9
- package/src/components/custom/Workout/SetRow.tsx +13 -19
- package/src/components/custom/Workout/StatusDot.tsx +1 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +5 -5
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +16 -15
- package/src/components/custom/Workout/WeekRow.tsx +1 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -3
- package/src/components/custom/Workout/index.ts +71 -32
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/tokens-css.ts +5 -3
- package/src/theme/workout-tokens.ts +12 -14
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- 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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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:
|
|
243
|
+
backgroundColor: COLORS.surfaceElevated,
|
|
231
244
|
borderWidth: 1,
|
|
232
|
-
borderColor:
|
|
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:
|
|
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
|
-
{
|
|
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
|
+
})
|