@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.
- 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 +3014 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1088 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/chunk-2SISKTWM.mjs +1027 -0
- package/dist/chunk-2SISKTWM.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-SNVKL5WZ.mjs} +42 -5
- package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
- package/dist/index.d.mts +416 -250
- package/dist/index.d.ts +416 -250
- package/dist/index.js +1567 -1123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1491 -2201
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +288 -2
- package/dist/theme/index.d.ts +288 -2
- package/dist/theme/index.js +159 -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 +442 -0
- package/src/components/custom/Workout/BodyMap.tsx +7 -9
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -29
- package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
- package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +24 -26
- 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 +624 -0
- package/src/components/custom/Workout/InputBar.tsx +10 -12
- package/src/components/custom/Workout/MesoCard.tsx +2 -4
- 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 +17 -15
- package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -4
- package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
- 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 +418 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +13 -12
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +16 -19
- package/src/components/custom/Workout/StatusDot.tsx +1 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
- package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
- package/src/components/custom/Workout/TempoBar.tsx +215 -0
- 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 +312 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +19 -16
- package/src/components/custom/Workout/WeekRow.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -6
- package/src/components/custom/Workout/index.ts +80 -32
- package/src/test/nativewind-stub.ts +13 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +50 -0
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -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/resolve-color.ts +19 -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-3VLOBS6O.mjs.map +0 -1
- 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
|
+
}
|