@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,171 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import {
|
|
4
|
+
ActiveWorkoutPage,
|
|
5
|
+
countCompletedSets,
|
|
6
|
+
deriveWorkoutProgress,
|
|
7
|
+
findActiveExercise,
|
|
8
|
+
statusToCardState,
|
|
9
|
+
groupExercises,
|
|
10
|
+
type ActiveWorkoutExercise,
|
|
11
|
+
type ActiveWorkoutPageProps,
|
|
12
|
+
} from './ActiveWorkoutPage'
|
|
13
|
+
|
|
14
|
+
const exercises: ActiveWorkoutExercise[] = [
|
|
15
|
+
{
|
|
16
|
+
id: 'warmup',
|
|
17
|
+
name: 'Band Pull-Apart',
|
|
18
|
+
status: 'completed',
|
|
19
|
+
unit: 'lbs',
|
|
20
|
+
totalPlannedSets: 2,
|
|
21
|
+
summary: { sets: 2, reps: 15, weight: 0, unit: 'lbs' },
|
|
22
|
+
setVelocities: [[0.9], [0.88]],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'bench',
|
|
26
|
+
name: 'Barbell Bench Press',
|
|
27
|
+
status: 'active',
|
|
28
|
+
unit: 'lbs',
|
|
29
|
+
totalPlannedSets: 4,
|
|
30
|
+
setVelocities: [[0.72], [0.68]],
|
|
31
|
+
sets: [
|
|
32
|
+
{ mode: 'completed', setNumber: 1, reps: 8, weight: 195, rpe: 8, unit: 'lbs' },
|
|
33
|
+
{ mode: 'active', setNumber: 3, reps: null, weight: null, unit: 'lbs', isNextSet: true },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'incline',
|
|
38
|
+
name: 'Incline DB Press',
|
|
39
|
+
status: 'upcoming',
|
|
40
|
+
unit: 'lbs',
|
|
41
|
+
totalPlannedSets: 3,
|
|
42
|
+
prescription: '3 × 10',
|
|
43
|
+
supersetId: 'ss1',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'lateral',
|
|
47
|
+
name: 'Cable Lateral Raise',
|
|
48
|
+
status: 'upcoming',
|
|
49
|
+
unit: 'lbs',
|
|
50
|
+
totalPlannedSets: 3,
|
|
51
|
+
prescription: '3 × 15',
|
|
52
|
+
supersetId: 'ss1',
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const baseProps: ActiveWorkoutPageProps = {
|
|
57
|
+
title: 'Push Day A',
|
|
58
|
+
subtitle: 'Week 6 of 8',
|
|
59
|
+
exercises,
|
|
60
|
+
supersets: [{ id: 'ss1', label: 'SS1', color: '#22D3EE' }],
|
|
61
|
+
input: { reps: '8', weight: '195' },
|
|
62
|
+
rest: { totalSeconds: 120, elapsedMs: 30_000, nextSetInfo: 'Set 3' },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('countCompletedSets', () => {
|
|
66
|
+
it('uses logged velocities when present', () => {
|
|
67
|
+
expect(countCompletedSets(exercises[1])).toBe(2)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to the full plan for a completed exercise without velocities', () => {
|
|
71
|
+
expect(countCompletedSets({ ...exercises[0], setVelocities: undefined })).toBe(2)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('counts zero for an upcoming exercise', () => {
|
|
75
|
+
expect(countCompletedSets(exercises[2])).toBe(0)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('deriveWorkoutProgress', () => {
|
|
80
|
+
it('rolls up completed exercises, sets, and the fraction', () => {
|
|
81
|
+
const progress = deriveWorkoutProgress(exercises)
|
|
82
|
+
expect(progress.completedExercises).toBe(1)
|
|
83
|
+
expect(progress.totalExercises).toBe(4)
|
|
84
|
+
expect(progress.completedSets).toBe(4)
|
|
85
|
+
expect(progress.totalSets).toBe(12)
|
|
86
|
+
expect(progress.fraction).toBeCloseTo(1 / 3)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('is zeroed for an empty workout', () => {
|
|
90
|
+
expect(deriveWorkoutProgress([])).toEqual({
|
|
91
|
+
completedExercises: 0,
|
|
92
|
+
totalExercises: 0,
|
|
93
|
+
completedSets: 0,
|
|
94
|
+
totalSets: 0,
|
|
95
|
+
fraction: 0,
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('findActiveExercise', () => {
|
|
101
|
+
it('returns the active exercise', () => {
|
|
102
|
+
expect(findActiveExercise(exercises)?.id).toBe('bench')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns null when none are active', () => {
|
|
106
|
+
expect(findActiveExercise([exercises[0]])).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('statusToCardState', () => {
|
|
111
|
+
it('keeps upcoming exercises upcoming regardless of focus', () => {
|
|
112
|
+
expect(statusToCardState('upcoming', true)).toBe('upcoming')
|
|
113
|
+
expect(statusToCardState('upcoming', false)).toBe('upcoming')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('expands only the focused active/completed exercise', () => {
|
|
117
|
+
expect(statusToCardState('active', true)).toBe('expanded')
|
|
118
|
+
expect(statusToCardState('active', false)).toBe('collapsed')
|
|
119
|
+
expect(statusToCardState('completed', true)).toBe('expanded')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('groupExercises', () => {
|
|
124
|
+
it('folds consecutive same-superset exercises into one group', () => {
|
|
125
|
+
const groups = groupExercises(exercises, baseProps.supersets ?? [])
|
|
126
|
+
expect(groups).toHaveLength(3)
|
|
127
|
+
expect(groups[0]).toEqual({ type: 'single', exercise: exercises[0] })
|
|
128
|
+
expect(groups[2].type).toBe('superset')
|
|
129
|
+
if (groups[2].type === 'superset') {
|
|
130
|
+
expect(groups[2].exercises).toHaveLength(2)
|
|
131
|
+
expect(groups[2].superset.label).toBe('SS1')
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('ActiveWorkoutPage', () => {
|
|
137
|
+
it('renders the header, progress, and the focused active exercise expanded', () => {
|
|
138
|
+
render(<ActiveWorkoutPage {...baseProps} />)
|
|
139
|
+
expect(screen.getByTestId('active-workout-page')).toBeInTheDocument()
|
|
140
|
+
expect(screen.getByTestId('active-workout-page-title')).toHaveTextContent('Push Day A')
|
|
141
|
+
expect(screen.getByTestId('active-workout-page-progress-count')).toHaveTextContent('1/4')
|
|
142
|
+
expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
|
|
143
|
+
expect(screen.getByTestId('superset-wrapper')).toBeInTheDocument()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('shows the input bar and hides it once a set is recorded (starts rest)', () => {
|
|
147
|
+
render(<ActiveWorkoutPage {...baseProps} />)
|
|
148
|
+
expect(screen.getByTestId('input-bar')).toBeInTheDocument()
|
|
149
|
+
expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 3/4')
|
|
150
|
+
|
|
151
|
+
fireEvent.click(screen.getByTestId('input-bar-record'))
|
|
152
|
+
expect(screen.queryByTestId('input-bar')).not.toBeInTheDocument()
|
|
153
|
+
expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('zoom-toggles focus: collapsing the active card hides its sets', () => {
|
|
157
|
+
render(<ActiveWorkoutPage {...baseProps} />)
|
|
158
|
+
expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
|
|
159
|
+
|
|
160
|
+
fireEvent.click(screen.getAllByTestId('exercise-card-header')[0])
|
|
161
|
+
expect(screen.queryByTestId('exercise-card-sets')).not.toBeInTheDocument()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('skipping rest restores the input bar', () => {
|
|
165
|
+
render(<ActiveWorkoutPage {...baseProps} initialResting />)
|
|
166
|
+
expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
|
|
167
|
+
|
|
168
|
+
fireEvent.click(screen.getByTestId('rest-timer-skip'))
|
|
169
|
+
expect(screen.getByTestId('input-bar')).toBeInTheDocument()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,442 @@
|
|
|
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, type ViewProps } from 'react-native'
|
|
4
|
+
import { ExerciseCard, type ExerciseCardProps, type ExerciseCardState } from './ExerciseCard'
|
|
5
|
+
import { InputBar } from './InputBar'
|
|
6
|
+
import { RestTimer } from './RestTimer'
|
|
7
|
+
import { SupersetWrapper } from './SupersetWrapper'
|
|
8
|
+
import { type SetRowProps } from './SetRow'
|
|
9
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
10
|
+
|
|
11
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
12
|
+
|
|
13
|
+
/** Where an exercise sits in the during-workout flow. */
|
|
14
|
+
export type ActiveExerciseStatus = 'completed' | 'active' | 'upcoming'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One exercise in the running workout. Projected onto an `ExerciseCard`, whose
|
|
18
|
+
* visual state is driven by `status` and the page's zoom focus: completed and
|
|
19
|
+
* active exercises collapse/expand, upcoming ones stay dimmed.
|
|
20
|
+
*/
|
|
21
|
+
export interface ActiveWorkoutExercise {
|
|
22
|
+
/** Stable id for zoom-focus selection. */
|
|
23
|
+
id: string
|
|
24
|
+
/** Exercise name, e.g. "Barbell Bench Press". */
|
|
25
|
+
name: string
|
|
26
|
+
/** Position in the workout flow. */
|
|
27
|
+
status: ActiveExerciseStatus
|
|
28
|
+
/** Unit used for this exercise's loads. */
|
|
29
|
+
unit: 'lbs' | 'kg'
|
|
30
|
+
/** Total sets prescribed for this exercise. */
|
|
31
|
+
totalPlannedSets: number
|
|
32
|
+
/** Collapsed summary (sets × reps @ weight) for a completed exercise. */
|
|
33
|
+
summary?: ExerciseCardProps['summary']
|
|
34
|
+
/** Estimated one-rep max badge. */
|
|
35
|
+
e1rm?: ExerciseCardProps['e1rm']
|
|
36
|
+
/** Flags a personal-record exercise. */
|
|
37
|
+
isPR?: boolean
|
|
38
|
+
/** Prescribed tempo, revealed when expanded. */
|
|
39
|
+
tempo?: ExerciseCardProps['tempo']
|
|
40
|
+
/** Per-rep velocities for each logged set (drives collapsed strips + set count). */
|
|
41
|
+
setVelocities?: number[][]
|
|
42
|
+
/** Per-set breakdown revealed when expanded. */
|
|
43
|
+
sets?: SetRowProps[]
|
|
44
|
+
/** One-line prescription for an upcoming exercise. */
|
|
45
|
+
prescription?: string
|
|
46
|
+
/** Previous-best hint for an upcoming exercise. */
|
|
47
|
+
previousBest?: string
|
|
48
|
+
/** Groups consecutive exercises under a shared `SupersetWrapper`. */
|
|
49
|
+
supersetId?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A superset grouping consecutive exercises that share its id. */
|
|
53
|
+
export interface ActiveWorkoutSuperset {
|
|
54
|
+
/** Matches `ActiveWorkoutExercise.supersetId`. */
|
|
55
|
+
id: string
|
|
56
|
+
/** Corner badge label, e.g. "SS1". */
|
|
57
|
+
label: string
|
|
58
|
+
/** Accent color for the wrapper rail + label. */
|
|
59
|
+
color?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Live-set inputs shown in the bottom `InputBar`. */
|
|
63
|
+
export interface ActiveWorkoutInput {
|
|
64
|
+
/** Current reps field value. */
|
|
65
|
+
reps: string
|
|
66
|
+
/** Current weight field value. */
|
|
67
|
+
weight: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Rest-timer inputs shown in the bottom `RestTimer`. */
|
|
71
|
+
export interface ActiveWorkoutRest {
|
|
72
|
+
/** Prescribed rest length, seconds. */
|
|
73
|
+
totalSeconds: number
|
|
74
|
+
/** Elapsed rest, milliseconds. */
|
|
75
|
+
elapsedMs: number
|
|
76
|
+
/** Next-set hint, e.g. "Set 3 · 8 × 195 lbs". */
|
|
77
|
+
nextSetInfo?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Derived, page-level roll-up of workout progress. */
|
|
81
|
+
export interface WorkoutProgress {
|
|
82
|
+
/** Number of exercises marked completed. */
|
|
83
|
+
completedExercises: number
|
|
84
|
+
/** Total exercises in the workout. */
|
|
85
|
+
totalExercises: number
|
|
86
|
+
/** Sets logged so far across all exercises. */
|
|
87
|
+
completedSets: number
|
|
88
|
+
/** Total sets prescribed across all exercises. */
|
|
89
|
+
totalSets: number
|
|
90
|
+
/** Fraction of prescribed sets logged, 0-1. */
|
|
91
|
+
fraction: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An ordered render group: a lone exercise or a superset of exercises. */
|
|
95
|
+
export type WorkoutGroup =
|
|
96
|
+
| { type: 'single'; exercise: ActiveWorkoutExercise }
|
|
97
|
+
| { type: 'superset'; superset: ActiveWorkoutSuperset; exercises: ActiveWorkoutExercise[] }
|
|
98
|
+
|
|
99
|
+
export interface ActiveWorkoutPageProps extends ViewProps {
|
|
100
|
+
/** Workout name shown in the header. */
|
|
101
|
+
title?: string
|
|
102
|
+
/** Context line under the title, e.g. "Push Day A · Week 6". */
|
|
103
|
+
subtitle?: string
|
|
104
|
+
/** Exercises in flow order. */
|
|
105
|
+
exercises: ActiveWorkoutExercise[]
|
|
106
|
+
/** Superset groupings referenced by `exercise.supersetId`. */
|
|
107
|
+
supersets?: ActiveWorkoutSuperset[]
|
|
108
|
+
/** Seed reps/weight for the live `InputBar`. */
|
|
109
|
+
input?: ActiveWorkoutInput
|
|
110
|
+
/** Rest-timer state; when resting, the timer replaces the input bar. */
|
|
111
|
+
rest?: ActiveWorkoutRest
|
|
112
|
+
/** Start the page in the resting state (timer showing). */
|
|
113
|
+
initialResting?: boolean
|
|
114
|
+
className?: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Count the sets logged for an exercise (pure). */
|
|
118
|
+
export function countCompletedSets(exercise: ActiveWorkoutExercise): number {
|
|
119
|
+
if (exercise.setVelocities != null) return exercise.setVelocities.length
|
|
120
|
+
if (exercise.status === 'completed') return exercise.totalPlannedSets
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Roll up the exercises into the header progress readout (pure, testable). */
|
|
125
|
+
export function deriveWorkoutProgress(exercises: ActiveWorkoutExercise[]): WorkoutProgress {
|
|
126
|
+
let completedExercises = 0
|
|
127
|
+
let completedSets = 0
|
|
128
|
+
let totalSets = 0
|
|
129
|
+
for (const exercise of exercises) {
|
|
130
|
+
if (exercise.status === 'completed') completedExercises += 1
|
|
131
|
+
completedSets += countCompletedSets(exercise)
|
|
132
|
+
totalSets += exercise.totalPlannedSets
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
completedExercises,
|
|
136
|
+
totalExercises: exercises.length,
|
|
137
|
+
completedSets,
|
|
138
|
+
totalSets,
|
|
139
|
+
fraction: totalSets === 0 ? 0 : completedSets / totalSets,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The active exercise anchors the bottom bar; find it, or null (pure). */
|
|
144
|
+
export function findActiveExercise(
|
|
145
|
+
exercises: ActiveWorkoutExercise[]
|
|
146
|
+
): ActiveWorkoutExercise | null {
|
|
147
|
+
return exercises.find((exercise) => exercise.status === 'active') ?? null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Map an exercise's status + focus onto an `ExerciseCard` visual state (pure). */
|
|
151
|
+
export function statusToCardState(
|
|
152
|
+
status: ActiveExerciseStatus,
|
|
153
|
+
focused: boolean
|
|
154
|
+
): ExerciseCardState {
|
|
155
|
+
if (status === 'upcoming') return 'upcoming'
|
|
156
|
+
return focused ? 'expanded' : 'collapsed'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Project an exercise onto ExerciseCard props, wiring zoom focus (pure). */
|
|
160
|
+
export function toActiveCardProps(
|
|
161
|
+
exercise: ActiveWorkoutExercise,
|
|
162
|
+
focused: boolean,
|
|
163
|
+
onToggle: () => void,
|
|
164
|
+
supersetPosition: ExerciseCardProps['supersetPosition'] = null,
|
|
165
|
+
supersetColor?: string
|
|
166
|
+
): ExerciseCardProps {
|
|
167
|
+
const state = statusToCardState(exercise.status, focused)
|
|
168
|
+
return {
|
|
169
|
+
name: exercise.name,
|
|
170
|
+
state,
|
|
171
|
+
onToggle,
|
|
172
|
+
summary: exercise.summary,
|
|
173
|
+
e1rm: exercise.e1rm,
|
|
174
|
+
isPR: exercise.isPR,
|
|
175
|
+
tempo: exercise.tempo,
|
|
176
|
+
setVelocities: exercise.setVelocities,
|
|
177
|
+
totalPlannedSets: exercise.totalPlannedSets,
|
|
178
|
+
sets: state === 'expanded' ? exercise.sets : undefined,
|
|
179
|
+
prescription: exercise.prescription,
|
|
180
|
+
previousBest: exercise.previousBest,
|
|
181
|
+
supersetPosition,
|
|
182
|
+
supersetColor,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Fold consecutive same-superset exercises into render groups (pure). */
|
|
187
|
+
export function groupExercises(
|
|
188
|
+
exercises: ActiveWorkoutExercise[],
|
|
189
|
+
supersets: ActiveWorkoutSuperset[]
|
|
190
|
+
): WorkoutGroup[] {
|
|
191
|
+
const byId = new Map(supersets.map((superset) => [superset.id, superset]))
|
|
192
|
+
const groups: WorkoutGroup[] = []
|
|
193
|
+
for (const exercise of exercises) {
|
|
194
|
+
const superset = exercise.supersetId != null ? byId.get(exercise.supersetId) : undefined
|
|
195
|
+
const last = groups[groups.length - 1]
|
|
196
|
+
if (superset != null && last?.type === 'superset' && last.superset.id === superset.id) {
|
|
197
|
+
last.exercises.push(exercise)
|
|
198
|
+
} else if (superset != null) {
|
|
199
|
+
groups.push({ type: 'superset', superset, exercises: [exercise] })
|
|
200
|
+
} else {
|
|
201
|
+
groups.push({ type: 'single', exercise })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return groups
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Position of an exercise within its superset, for corner-radius stitching. */
|
|
208
|
+
function supersetPositionAt(index: number, length: number): ExerciseCardProps['supersetPosition'] {
|
|
209
|
+
if (length === 1) return null
|
|
210
|
+
if (index === 0) return 'first'
|
|
211
|
+
if (index === length - 1) return 'last'
|
|
212
|
+
return 'middle'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function WorkoutHeader({
|
|
216
|
+
title,
|
|
217
|
+
subtitle,
|
|
218
|
+
progress,
|
|
219
|
+
}: {
|
|
220
|
+
title: string
|
|
221
|
+
subtitle?: string
|
|
222
|
+
progress: WorkoutProgress
|
|
223
|
+
}) {
|
|
224
|
+
return (
|
|
225
|
+
<View style={{ gap: 10 }} testID="active-workout-page-header">
|
|
226
|
+
<View className="flex-row items-start justify-between" style={{ gap: 8 }}>
|
|
227
|
+
<View style={{ flexShrink: 1 }}>
|
|
228
|
+
<Text
|
|
229
|
+
accessibilityRole="header"
|
|
230
|
+
className="text-text-primary"
|
|
231
|
+
style={{
|
|
232
|
+
fontSize: 20,
|
|
233
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
234
|
+
fontWeight: '700',
|
|
235
|
+
}}
|
|
236
|
+
testID="active-workout-page-title"
|
|
237
|
+
>
|
|
238
|
+
{title}
|
|
239
|
+
</Text>
|
|
240
|
+
{subtitle != null && (
|
|
241
|
+
<Text
|
|
242
|
+
className="text-text-secondary"
|
|
243
|
+
style={{
|
|
244
|
+
marginTop: 2,
|
|
245
|
+
fontSize: 12,
|
|
246
|
+
fontFamily: 'Inter, sans-serif',
|
|
247
|
+
}}
|
|
248
|
+
testID="active-workout-page-subtitle"
|
|
249
|
+
>
|
|
250
|
+
{subtitle}
|
|
251
|
+
</Text>
|
|
252
|
+
)}
|
|
253
|
+
</View>
|
|
254
|
+
<View style={{ alignItems: 'flex-end' }} testID="active-workout-page-progress-count">
|
|
255
|
+
<Text
|
|
256
|
+
style={{
|
|
257
|
+
fontSize: 15,
|
|
258
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
259
|
+
fontWeight: '700',
|
|
260
|
+
color: BRAND_PRIMARY,
|
|
261
|
+
fontVariant: ['tabular-nums'],
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{`${progress.completedExercises}/${progress.totalExercises}`}
|
|
265
|
+
</Text>
|
|
266
|
+
<Text
|
|
267
|
+
className="text-text-tertiary"
|
|
268
|
+
style={{
|
|
269
|
+
fontSize: 10,
|
|
270
|
+
fontFamily: 'Inter, sans-serif',
|
|
271
|
+
fontWeight: '600',
|
|
272
|
+
letterSpacing: 0.4,
|
|
273
|
+
textTransform: 'uppercase',
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
{`${progress.completedSets}/${progress.totalSets} sets`}
|
|
277
|
+
</Text>
|
|
278
|
+
</View>
|
|
279
|
+
</View>
|
|
280
|
+
<View
|
|
281
|
+
className="bg-border"
|
|
282
|
+
style={{ height: 4, borderRadius: 2 }}
|
|
283
|
+
testID="active-workout-page-progress-track"
|
|
284
|
+
>
|
|
285
|
+
<View
|
|
286
|
+
style={{
|
|
287
|
+
height: '100%',
|
|
288
|
+
width: `${Math.round(progress.fraction * 100)}%`,
|
|
289
|
+
backgroundColor: BRAND_PRIMARY,
|
|
290
|
+
borderRadius: 2,
|
|
291
|
+
}}
|
|
292
|
+
testID="active-workout-page-progress-fill"
|
|
293
|
+
/>
|
|
294
|
+
</View>
|
|
295
|
+
</View>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface ExerciseListProps {
|
|
300
|
+
groups: WorkoutGroup[]
|
|
301
|
+
focusedId: string | null
|
|
302
|
+
onToggle: (id: string) => void
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function ExerciseList({ groups, focusedId, onToggle }: ExerciseListProps) {
|
|
306
|
+
return (
|
|
307
|
+
<View style={{ gap: 10 }} testID="active-workout-page-exercises">
|
|
308
|
+
{groups.map((group) => {
|
|
309
|
+
if (group.type === 'single') {
|
|
310
|
+
return (
|
|
311
|
+
<View
|
|
312
|
+
key={group.exercise.id}
|
|
313
|
+
className="bg-surface-elevated border-border"
|
|
314
|
+
style={{
|
|
315
|
+
borderWidth: 1,
|
|
316
|
+
borderRadius: 12,
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
<ExerciseCard
|
|
320
|
+
{...toActiveCardProps(group.exercise, group.exercise.id === focusedId, () =>
|
|
321
|
+
onToggle(group.exercise.id)
|
|
322
|
+
)}
|
|
323
|
+
/>
|
|
324
|
+
</View>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
return (
|
|
328
|
+
<SupersetWrapper
|
|
329
|
+
key={group.superset.id}
|
|
330
|
+
label={group.superset.label}
|
|
331
|
+
color={group.superset.color}
|
|
332
|
+
>
|
|
333
|
+
{group.exercises.map((exercise, index) => (
|
|
334
|
+
<ExerciseCard
|
|
335
|
+
key={exercise.id}
|
|
336
|
+
{...toActiveCardProps(
|
|
337
|
+
exercise,
|
|
338
|
+
exercise.id === focusedId,
|
|
339
|
+
() => onToggle(exercise.id),
|
|
340
|
+
supersetPositionAt(index, group.exercises.length),
|
|
341
|
+
group.superset.color
|
|
342
|
+
)}
|
|
343
|
+
/>
|
|
344
|
+
))}
|
|
345
|
+
</SupersetWrapper>
|
|
346
|
+
)
|
|
347
|
+
})}
|
|
348
|
+
</View>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* ActiveWorkoutPage — the during-workout screen, built as a zoom hierarchy over
|
|
354
|
+
* `ExerciseCard`s. A header progress bar rolls up completed sets; the flow list
|
|
355
|
+
* dims upcoming exercises, collapses finished ones to their velocity strips, and
|
|
356
|
+
* expands exactly one focused exercise to its `SetRow` breakdown. Consecutive
|
|
357
|
+
* superset exercises stitch together inside a `SupersetWrapper`. A pinned bottom
|
|
358
|
+
* bar toggles between the live `InputBar` (log the next set) and the `RestTimer`.
|
|
359
|
+
* Focus and rest are page-level state; children keep their own styles.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* <ActiveWorkoutPage title="Push Day A" exercises={exercises} />
|
|
363
|
+
*/
|
|
364
|
+
export function ActiveWorkoutPage({
|
|
365
|
+
title = 'Active Workout',
|
|
366
|
+
subtitle,
|
|
367
|
+
exercises,
|
|
368
|
+
supersets = [],
|
|
369
|
+
input,
|
|
370
|
+
rest,
|
|
371
|
+
initialResting = false,
|
|
372
|
+
className,
|
|
373
|
+
...props
|
|
374
|
+
}: ActiveWorkoutPageProps) {
|
|
375
|
+
const active = findActiveExercise(exercises)
|
|
376
|
+
const [focusedId, setFocusedId] = useState<string | null>(active?.id ?? null)
|
|
377
|
+
const [resting, setResting] = useState(initialResting)
|
|
378
|
+
const [reps, setReps] = useState(input?.reps ?? '')
|
|
379
|
+
const [weight, setWeight] = useState(input?.weight ?? '')
|
|
380
|
+
|
|
381
|
+
const progress = useMemo(() => deriveWorkoutProgress(exercises), [exercises])
|
|
382
|
+
const groups = useMemo(() => groupExercises(exercises, supersets), [exercises, supersets])
|
|
383
|
+
|
|
384
|
+
const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id))
|
|
385
|
+
|
|
386
|
+
const nextSetNumber = active != null ? countCompletedSets(active) + 1 : 1
|
|
387
|
+
const canRecord = reps.trim() !== '' && weight.trim() !== ''
|
|
388
|
+
const showRest = resting && rest != null
|
|
389
|
+
const showInput = !showRest && active != null
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<View
|
|
393
|
+
className={['bg-background-base', className].filter(Boolean).join(' ')}
|
|
394
|
+
style={{ position: 'relative', width: 390 }}
|
|
395
|
+
accessibilityRole={'main' as ViewProps['accessibilityRole']}
|
|
396
|
+
aria-label={title}
|
|
397
|
+
testID="active-workout-page"
|
|
398
|
+
{...props}
|
|
399
|
+
>
|
|
400
|
+
<View style={{ padding: 16, gap: 14 }} testID="active-workout-page-content">
|
|
401
|
+
<WorkoutHeader title={title} subtitle={subtitle} progress={progress} />
|
|
402
|
+
<ExerciseList groups={groups} focusedId={focusedId} onToggle={toggleFocus} />
|
|
403
|
+
</View>
|
|
404
|
+
|
|
405
|
+
{(showRest || showInput) && (
|
|
406
|
+
<View
|
|
407
|
+
style={{ borderTopWidth: 1, borderTopColor: resolveColor('border-default') }}
|
|
408
|
+
testID="active-workout-page-bottom-bar"
|
|
409
|
+
>
|
|
410
|
+
{showRest && rest != null ? (
|
|
411
|
+
<RestTimer
|
|
412
|
+
totalSeconds={rest.totalSeconds}
|
|
413
|
+
elapsedMs={rest.elapsedMs}
|
|
414
|
+
nextSetInfo={rest.nextSetInfo}
|
|
415
|
+
onSkip={() => setResting(false)}
|
|
416
|
+
onAddTime={() => undefined}
|
|
417
|
+
visible
|
|
418
|
+
/>
|
|
419
|
+
) : (
|
|
420
|
+
active != null && (
|
|
421
|
+
<InputBar
|
|
422
|
+
exerciseName={active.name}
|
|
423
|
+
setNumber={nextSetNumber}
|
|
424
|
+
totalSets={active.totalPlannedSets}
|
|
425
|
+
reps={reps}
|
|
426
|
+
weight={weight}
|
|
427
|
+
unit={active.unit}
|
|
428
|
+
onRepsChange={setReps}
|
|
429
|
+
onWeightChange={setWeight}
|
|
430
|
+
onRecord={() => {
|
|
431
|
+
if (rest != null) setResting(true)
|
|
432
|
+
}}
|
|
433
|
+
canRecord={canRecord}
|
|
434
|
+
visible
|
|
435
|
+
/>
|
|
436
|
+
)
|
|
437
|
+
)}
|
|
438
|
+
</View>
|
|
439
|
+
)}
|
|
440
|
+
</View>
|
|
441
|
+
)
|
|
442
|
+
}
|
|
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|
|
3
3
|
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
4
|
import BodyHighlighter, { type ExtendedBodyPart, type Slug } from 'react-native-body-highlighter'
|
|
5
5
|
import { cn } from '../../../utils/cn'
|
|
6
|
-
import {
|
|
6
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
7
7
|
import {
|
|
8
8
|
MuscleGroup,
|
|
9
9
|
SimpleMuscleGroup,
|
|
@@ -28,8 +28,6 @@ const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter
|
|
|
28
28
|
const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
|
|
29
29
|
const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
|
|
30
30
|
const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
|
|
31
|
-
const TEXT_PRIMARY = '#F3F4F6'
|
|
32
|
-
const TEXT_SECONDARY = '#9CA3AF'
|
|
33
31
|
|
|
34
32
|
export interface BodyMapData {
|
|
35
33
|
muscleGroup: MuscleGroup
|
|
@@ -218,10 +216,10 @@ export function BodyMap({
|
|
|
218
216
|
|
|
219
217
|
{legend.length === 0 ? (
|
|
220
218
|
<Text
|
|
219
|
+
className="text-text-secondary"
|
|
221
220
|
style={{
|
|
222
221
|
marginTop: 8,
|
|
223
222
|
fontSize: 12,
|
|
224
|
-
color: TEXT_SECONDARY,
|
|
225
223
|
textAlign: 'center',
|
|
226
224
|
fontFamily: 'Inter, sans-serif',
|
|
227
225
|
}}
|
|
@@ -277,7 +275,7 @@ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
|
|
|
277
275
|
borderRadius: 9999,
|
|
278
276
|
backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
|
|
279
277
|
borderWidth: 1,
|
|
280
|
-
borderColor: active ? '#FF7900' :
|
|
278
|
+
borderColor: active ? '#FF7900' : resolveColor('border-strong'),
|
|
281
279
|
}}
|
|
282
280
|
testID={`body-map-toggle-${side}`}
|
|
283
281
|
>
|
|
@@ -286,7 +284,7 @@ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
|
|
|
286
284
|
fontSize: 12,
|
|
287
285
|
fontFamily: 'Inter, sans-serif',
|
|
288
286
|
fontWeight: active ? '700' : '500',
|
|
289
|
-
color: active ? '#FF7900' :
|
|
287
|
+
color: active ? '#FF7900' : resolveColor('text-secondary'),
|
|
290
288
|
textTransform: 'capitalize',
|
|
291
289
|
}}
|
|
292
290
|
>
|
|
@@ -314,6 +312,7 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
|
|
|
314
312
|
accessibilityRole="button"
|
|
315
313
|
accessibilityLabel={label}
|
|
316
314
|
aria-pressed={highlighted}
|
|
315
|
+
className="bg-surface-raised"
|
|
317
316
|
style={{
|
|
318
317
|
flexDirection: 'row',
|
|
319
318
|
alignItems: 'center',
|
|
@@ -322,8 +321,7 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
|
|
|
322
321
|
paddingVertical: 3,
|
|
323
322
|
borderRadius: 9999,
|
|
324
323
|
borderWidth: 1,
|
|
325
|
-
borderColor: highlighted ? '#FF7900' :
|
|
326
|
-
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
324
|
+
borderColor: highlighted ? '#FF7900' : resolveColor('border-default'),
|
|
327
325
|
}}
|
|
328
326
|
testID={`body-map-muscle-${entry.key}`}
|
|
329
327
|
>
|
|
@@ -333,11 +331,11 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
|
|
|
333
331
|
testID={`body-map-muscle-dot-${entry.key}`}
|
|
334
332
|
/>
|
|
335
333
|
<Text
|
|
334
|
+
className="text-text-primary"
|
|
336
335
|
style={{
|
|
337
336
|
fontSize: 11,
|
|
338
337
|
fontFamily: 'Inter, sans-serif',
|
|
339
338
|
fontWeight: '500',
|
|
340
|
-
color: TEXT_PRIMARY,
|
|
341
339
|
}}
|
|
342
340
|
accessibilityElementsHidden
|
|
343
341
|
>
|