@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,418 @@
|
|
|
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 { MesoProgressBar, type Meso, type MesoStatus } from './MesoProgressBar'
|
|
5
|
+
import { MesoCard, type MesoVolumeHeatmapEntry } from './MesoCard'
|
|
6
|
+
import { type WeekRowProps, type WeekRowWorkout } from './WeekRow'
|
|
7
|
+
import { WorkoutCard, type WorkoutStatus, type WorkoutMuscleGroup } from './WorkoutCard'
|
|
8
|
+
import { type WorkoutPillStatus } from './WorkoutPill'
|
|
9
|
+
import { type ExerciseCardProps } from './ExerciseCard'
|
|
10
|
+
import { cn } from '../../../utils/cn'
|
|
11
|
+
|
|
12
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
13
|
+
|
|
14
|
+
/** A single workout within a planned week, plus its exercise breakdown. */
|
|
15
|
+
export interface PlanWorkout {
|
|
16
|
+
/** Stable identifier used for drill-down selection. */
|
|
17
|
+
id: string
|
|
18
|
+
name: string
|
|
19
|
+
date: string
|
|
20
|
+
duration?: string
|
|
21
|
+
status: WorkoutStatus
|
|
22
|
+
muscleGroups: WorkoutMuscleGroup[]
|
|
23
|
+
totalSets: number
|
|
24
|
+
totalVolume?: number
|
|
25
|
+
unit: 'lbs' | 'kg'
|
|
26
|
+
/** Exercises revealed when the workout is opened. */
|
|
27
|
+
exercises: ExerciseCardProps[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A training week inside a mesocycle. */
|
|
31
|
+
export interface PlanWeek {
|
|
32
|
+
/** 1-based week index within the mesocycle. */
|
|
33
|
+
weekNumber: number
|
|
34
|
+
/** Planned intensity for the week, 0-1. */
|
|
35
|
+
intensityLevel: number
|
|
36
|
+
/** Optional MRV/overexertion threshold, 0-1. */
|
|
37
|
+
intensityThreshold?: number
|
|
38
|
+
isDeload?: boolean
|
|
39
|
+
isCurrent?: boolean
|
|
40
|
+
workouts: PlanWorkout[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A mesocycle: the top level of the drill-down. */
|
|
44
|
+
export interface PlanMeso {
|
|
45
|
+
id: string
|
|
46
|
+
name: string
|
|
47
|
+
goal: string
|
|
48
|
+
split: string
|
|
49
|
+
weekRange: string
|
|
50
|
+
weekCount: number
|
|
51
|
+
currentWeek?: number
|
|
52
|
+
status: MesoStatus
|
|
53
|
+
volumeHeatmap?: MesoVolumeHeatmapEntry[]
|
|
54
|
+
weeks: PlanWeek[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Current depth of the meso -> week -> workout drill-down. */
|
|
58
|
+
export type ProgramNavLevel = 'meso' | 'week' | 'workout'
|
|
59
|
+
|
|
60
|
+
/** The navigation cursor within the active mesocycle. */
|
|
61
|
+
export interface ProgramSelection {
|
|
62
|
+
weekNumber: number | null
|
|
63
|
+
workoutId: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One tappable step in the breadcrumb trail. */
|
|
67
|
+
export interface ProgramBreadcrumb {
|
|
68
|
+
key: string
|
|
69
|
+
label: string
|
|
70
|
+
level: ProgramNavLevel
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProgramPlanningPageProps extends ViewProps {
|
|
74
|
+
/** Page heading. */
|
|
75
|
+
title?: string
|
|
76
|
+
/** Mesocycles that make up the program. */
|
|
77
|
+
mesos: PlanMeso[]
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Map a workout's lifecycle status onto a WorkoutPill status. */
|
|
82
|
+
const WORKOUT_PILL_STATUS: Record<WorkoutStatus, WorkoutPillStatus> = {
|
|
83
|
+
completed: 'completed',
|
|
84
|
+
today: 'current',
|
|
85
|
+
upcoming: 'upcoming',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Derive the current drill-down level from the selection cursor (pure). */
|
|
89
|
+
export function deriveNavLevel(selection: ProgramSelection): ProgramNavLevel {
|
|
90
|
+
if (selection.workoutId != null) return 'workout'
|
|
91
|
+
if (selection.weekNumber != null) return 'week'
|
|
92
|
+
return 'meso'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Project the mesocycles onto MesoProgressBar segment data (pure). */
|
|
96
|
+
export function toProgressBarMesos(mesos: PlanMeso[]): Meso[] {
|
|
97
|
+
return mesos.map(({ id, name, weekCount, status, currentWeek }) => ({
|
|
98
|
+
id,
|
|
99
|
+
name,
|
|
100
|
+
weekCount,
|
|
101
|
+
status,
|
|
102
|
+
currentWeek,
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Look up a meso by id, or null. */
|
|
107
|
+
export function findMeso(mesos: PlanMeso[], id: string | null): PlanMeso | null {
|
|
108
|
+
if (id == null) return null
|
|
109
|
+
return mesos.find((meso) => meso.id === id) ?? null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Look up a week within a meso, or null. */
|
|
113
|
+
export function findWeek(meso: PlanMeso | null, weekNumber: number | null): PlanWeek | null {
|
|
114
|
+
if (meso == null || weekNumber == null) return null
|
|
115
|
+
return meso.weeks.find((week) => week.weekNumber === weekNumber) ?? null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Look up a workout within a week, or null. */
|
|
119
|
+
export function findWorkout(week: PlanWeek | null, workoutId: string | null): PlanWorkout | null {
|
|
120
|
+
if (week == null || workoutId == null) return null
|
|
121
|
+
return week.workouts.find((workout) => workout.id === workoutId) ?? null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Build the breadcrumb trail for the active drill path (pure). */
|
|
125
|
+
export function buildBreadcrumbs(
|
|
126
|
+
meso: PlanMeso,
|
|
127
|
+
weekNumber: number | null,
|
|
128
|
+
workout: PlanWorkout | null
|
|
129
|
+
): ProgramBreadcrumb[] {
|
|
130
|
+
const crumbs: ProgramBreadcrumb[] = [{ key: 'meso', label: meso.name, level: 'meso' }]
|
|
131
|
+
if (weekNumber != null) {
|
|
132
|
+
crumbs.push({ key: 'week', label: `Week ${weekNumber}`, level: 'week' })
|
|
133
|
+
}
|
|
134
|
+
if (workout != null) {
|
|
135
|
+
crumbs.push({ key: 'workout', label: workout.name, level: 'workout' })
|
|
136
|
+
}
|
|
137
|
+
return crumbs
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface BreadcrumbsProps {
|
|
141
|
+
crumbs: ProgramBreadcrumb[]
|
|
142
|
+
onNavigate: (level: ProgramNavLevel) => void
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Breadcrumbs({ crumbs, onNavigate }: BreadcrumbsProps) {
|
|
146
|
+
return (
|
|
147
|
+
<View
|
|
148
|
+
className="flex-row items-center flex-wrap"
|
|
149
|
+
style={{ gap: 4 }}
|
|
150
|
+
testID="program-planning-page-breadcrumbs"
|
|
151
|
+
>
|
|
152
|
+
{crumbs.map((crumb, index) => {
|
|
153
|
+
const isLast = index === crumbs.length - 1
|
|
154
|
+
return (
|
|
155
|
+
<View key={crumb.key} className="flex-row items-center" style={{ gap: 4 }}>
|
|
156
|
+
{index > 0 && <Text className="text-text-tertiary" style={{ fontSize: 12 }}>{'›'}</Text>}
|
|
157
|
+
<Pressable
|
|
158
|
+
onPress={() => onNavigate(crumb.level)}
|
|
159
|
+
disabled={isLast}
|
|
160
|
+
accessibilityRole="button"
|
|
161
|
+
testID={`program-planning-page-crumb-${crumb.key}`}
|
|
162
|
+
>
|
|
163
|
+
<Text
|
|
164
|
+
className={isLast ? 'text-text-primary' : undefined}
|
|
165
|
+
style={{
|
|
166
|
+
fontSize: 12,
|
|
167
|
+
fontFamily: 'Inter, sans-serif',
|
|
168
|
+
fontWeight: isLast ? '700' : '500',
|
|
169
|
+
color: isLast ? undefined : BRAND_PRIMARY,
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{crumb.label}
|
|
173
|
+
</Text>
|
|
174
|
+
</Pressable>
|
|
175
|
+
</View>
|
|
176
|
+
)
|
|
177
|
+
})}
|
|
178
|
+
</View>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Project a plan week onto WeekRow props, wiring pill taps to week selection. */
|
|
183
|
+
function toWeekRow(
|
|
184
|
+
week: PlanWeek,
|
|
185
|
+
meso: PlanMeso,
|
|
186
|
+
onSelectWeek: (weekNumber: number) => void
|
|
187
|
+
): WeekRowProps {
|
|
188
|
+
const workouts: WeekRowWorkout[] = week.workouts.map((workout) => ({
|
|
189
|
+
name: workout.name,
|
|
190
|
+
status: WORKOUT_PILL_STATUS[workout.status],
|
|
191
|
+
onPress: () => onSelectWeek(week.weekNumber),
|
|
192
|
+
}))
|
|
193
|
+
return {
|
|
194
|
+
weekNumber: week.weekNumber,
|
|
195
|
+
totalWeeks: meso.weekCount,
|
|
196
|
+
workouts,
|
|
197
|
+
intensityLevel: week.intensityLevel,
|
|
198
|
+
intensityThreshold: week.intensityThreshold,
|
|
199
|
+
isCurrent: week.isCurrent ?? week.weekNumber === meso.currentWeek,
|
|
200
|
+
isDeload: week.isDeload,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface MesoLevelProps {
|
|
205
|
+
mesos: PlanMeso[]
|
|
206
|
+
activeMesoId: string | null
|
|
207
|
+
onSelectMeso: (id: string) => void
|
|
208
|
+
onSelectWeek: (weekNumber: number) => void
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function MesoLevel({ mesos, activeMesoId, onSelectMeso, onSelectWeek }: MesoLevelProps) {
|
|
212
|
+
return (
|
|
213
|
+
<View style={{ gap: 12 }} testID="program-planning-page-meso-level">
|
|
214
|
+
{mesos.map((meso) => {
|
|
215
|
+
const expanded = meso.id === activeMesoId
|
|
216
|
+
return (
|
|
217
|
+
<MesoCard
|
|
218
|
+
key={meso.id}
|
|
219
|
+
name={meso.name}
|
|
220
|
+
goal={meso.goal}
|
|
221
|
+
split={meso.split}
|
|
222
|
+
weekRange={meso.weekRange}
|
|
223
|
+
currentWeek={meso.currentWeek}
|
|
224
|
+
totalWeeks={meso.weekCount}
|
|
225
|
+
weeks={expanded ? meso.weeks.map((week) => toWeekRow(week, meso, onSelectWeek)) : []}
|
|
226
|
+
volumeHeatmap={meso.volumeHeatmap}
|
|
227
|
+
expanded={expanded}
|
|
228
|
+
onToggle={() => onSelectMeso(meso.id)}
|
|
229
|
+
highlighted={expanded}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</View>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface WeekLevelProps {
|
|
238
|
+
week: PlanWeek
|
|
239
|
+
onSelectWorkout: (id: string) => void
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function WeekLevel({ week, onSelectWorkout }: WeekLevelProps) {
|
|
243
|
+
return (
|
|
244
|
+
<View style={{ gap: 10 }} testID="program-planning-page-week-level">
|
|
245
|
+
{week.workouts.map((workout) => (
|
|
246
|
+
<WorkoutCard
|
|
247
|
+
key={workout.id}
|
|
248
|
+
name={workout.name}
|
|
249
|
+
date={workout.date}
|
|
250
|
+
duration={workout.duration}
|
|
251
|
+
status={workout.status}
|
|
252
|
+
muscleGroups={workout.muscleGroups}
|
|
253
|
+
totalSets={workout.totalSets}
|
|
254
|
+
totalVolume={workout.totalVolume}
|
|
255
|
+
unit={workout.unit}
|
|
256
|
+
exercises={workout.exercises}
|
|
257
|
+
expanded={false}
|
|
258
|
+
onToggle={() => onSelectWorkout(workout.id)}
|
|
259
|
+
/>
|
|
260
|
+
))}
|
|
261
|
+
</View>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface WorkoutLevelProps {
|
|
266
|
+
workout: PlanWorkout
|
|
267
|
+
expandedExercise: number | null
|
|
268
|
+
onToggleExercise: (index: number) => void
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function WorkoutLevel({ workout, expandedExercise, onToggleExercise }: WorkoutLevelProps) {
|
|
272
|
+
const exercises = workout.exercises.map((exercise, index) => ({
|
|
273
|
+
...exercise,
|
|
274
|
+
state:
|
|
275
|
+
exercise.state === 'upcoming'
|
|
276
|
+
? exercise.state
|
|
277
|
+
: ((index === expandedExercise ? 'expanded' : 'collapsed') as ExerciseCardProps['state']),
|
|
278
|
+
onToggle: () => onToggleExercise(index),
|
|
279
|
+
}))
|
|
280
|
+
return (
|
|
281
|
+
<View testID="program-planning-page-workout-level">
|
|
282
|
+
<WorkoutCard
|
|
283
|
+
name={workout.name}
|
|
284
|
+
date={workout.date}
|
|
285
|
+
duration={workout.duration}
|
|
286
|
+
status={workout.status}
|
|
287
|
+
muscleGroups={workout.muscleGroups}
|
|
288
|
+
totalSets={workout.totalSets}
|
|
289
|
+
totalVolume={workout.totalVolume}
|
|
290
|
+
unit={workout.unit}
|
|
291
|
+
exercises={exercises}
|
|
292
|
+
expanded
|
|
293
|
+
onToggle={() => undefined}
|
|
294
|
+
/>
|
|
295
|
+
</View>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* ProgramPlanningPage — a meso -> week -> workout drill-down over an entire
|
|
301
|
+
* training program. A `MesoProgressBar` pins the mesocycle timeline to the top;
|
|
302
|
+
* the body swaps between three levels: expandable `MesoCard`s (with inline
|
|
303
|
+
* `WeekRow`s), a week's `WorkoutCard` list, and a single opened `WorkoutCard`
|
|
304
|
+
* with inline `ExerciseCard` expansion. A breadcrumb trail tracks and rewinds
|
|
305
|
+
* the drill path. Selection is page-level state; children keep their own styles.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* <ProgramPlanningPage mesos={mesos} />
|
|
309
|
+
*/
|
|
310
|
+
export function ProgramPlanningPage({
|
|
311
|
+
title = 'Program Plan',
|
|
312
|
+
mesos,
|
|
313
|
+
className,
|
|
314
|
+
...props
|
|
315
|
+
}: ProgramPlanningPageProps) {
|
|
316
|
+
const [activeMesoId, setActiveMesoId] = useState<string | null>(mesos[0]?.id ?? null)
|
|
317
|
+
const [selectedWeek, setSelectedWeek] = useState<number | null>(null)
|
|
318
|
+
const [selectedWorkoutId, setSelectedWorkoutId] = useState<string | null>(null)
|
|
319
|
+
const [expandedExercise, setExpandedExercise] = useState<number | null>(null)
|
|
320
|
+
|
|
321
|
+
const progressMesos = useMemo(() => toProgressBarMesos(mesos), [mesos])
|
|
322
|
+
const activeMeso = findMeso(mesos, activeMesoId)
|
|
323
|
+
const level = deriveNavLevel({ weekNumber: selectedWeek, workoutId: selectedWorkoutId })
|
|
324
|
+
const activeWeek = findWeek(activeMeso, selectedWeek)
|
|
325
|
+
const activeWorkout = findWorkout(activeWeek, selectedWorkoutId)
|
|
326
|
+
|
|
327
|
+
const selectMeso = (id: string) => {
|
|
328
|
+
setActiveMesoId(id)
|
|
329
|
+
setSelectedWeek(null)
|
|
330
|
+
setSelectedWorkoutId(null)
|
|
331
|
+
}
|
|
332
|
+
const selectWeek = (weekNumber: number) => {
|
|
333
|
+
setSelectedWeek(weekNumber)
|
|
334
|
+
setSelectedWorkoutId(null)
|
|
335
|
+
setExpandedExercise(null)
|
|
336
|
+
}
|
|
337
|
+
const selectWorkout = (id: string) => {
|
|
338
|
+
setSelectedWorkoutId(id)
|
|
339
|
+
setExpandedExercise(null)
|
|
340
|
+
}
|
|
341
|
+
const toggleExercise = (index: number) =>
|
|
342
|
+
setExpandedExercise((current) => (current === index ? null : index))
|
|
343
|
+
|
|
344
|
+
const navigateTo = (target: ProgramNavLevel) => {
|
|
345
|
+
if (target === 'meso') selectMeso(activeMesoId ?? mesos[0]?.id)
|
|
346
|
+
else if (target === 'week' && selectedWeek != null) selectWeek(selectedWeek)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<View
|
|
351
|
+
className={cn(className, 'bg-background-base')}
|
|
352
|
+
style={{ width: 390 }}
|
|
353
|
+
accessibilityRole={'main' as ViewProps['accessibilityRole']}
|
|
354
|
+
aria-label={title}
|
|
355
|
+
testID="program-planning-page"
|
|
356
|
+
{...props}
|
|
357
|
+
>
|
|
358
|
+
<View style={{ padding: 16, gap: 14 }} testID="program-planning-page-content">
|
|
359
|
+
<Text
|
|
360
|
+
accessibilityRole="header"
|
|
361
|
+
className="text-text-primary"
|
|
362
|
+
style={{
|
|
363
|
+
fontSize: 20,
|
|
364
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
365
|
+
fontWeight: '700',
|
|
366
|
+
}}
|
|
367
|
+
testID="program-planning-page-title"
|
|
368
|
+
>
|
|
369
|
+
{title}
|
|
370
|
+
</Text>
|
|
371
|
+
|
|
372
|
+
<View style={{ marginHorizontal: -16 }}>
|
|
373
|
+
<MesoProgressBar
|
|
374
|
+
mesos={progressMesos}
|
|
375
|
+
activeMesoId={activeMesoId}
|
|
376
|
+
onMesoPress={selectMeso}
|
|
377
|
+
/>
|
|
378
|
+
</View>
|
|
379
|
+
|
|
380
|
+
{activeMeso != null && (
|
|
381
|
+
<Breadcrumbs
|
|
382
|
+
crumbs={buildBreadcrumbs(activeMeso, selectedWeek, activeWorkout)}
|
|
383
|
+
onNavigate={navigateTo}
|
|
384
|
+
/>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
<View
|
|
388
|
+
className="bg-surface-elevated border-border"
|
|
389
|
+
style={{
|
|
390
|
+
borderWidth: 1,
|
|
391
|
+
borderRadius: 12,
|
|
392
|
+
padding: 12,
|
|
393
|
+
}}
|
|
394
|
+
testID="program-planning-page-body"
|
|
395
|
+
>
|
|
396
|
+
{level === 'meso' && (
|
|
397
|
+
<MesoLevel
|
|
398
|
+
mesos={mesos}
|
|
399
|
+
activeMesoId={activeMesoId}
|
|
400
|
+
onSelectMeso={selectMeso}
|
|
401
|
+
onSelectWeek={selectWeek}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
{level === 'week' && activeWeek != null && (
|
|
405
|
+
<WeekLevel week={activeWeek} onSelectWorkout={selectWorkout} />
|
|
406
|
+
)}
|
|
407
|
+
{level === 'workout' && activeWorkout != null && (
|
|
408
|
+
<WorkoutLevel
|
|
409
|
+
workout={activeWorkout}
|
|
410
|
+
expandedExercise={expandedExercise}
|
|
411
|
+
onToggleExercise={toggleExercise}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
</View>
|
|
415
|
+
</View>
|
|
416
|
+
</View>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
@@ -2,15 +2,12 @@
|
|
|
2
2
|
import { View, Text, Pressable } from 'react-native'
|
|
3
3
|
import { Card } from '../../ui/card'
|
|
4
4
|
import { Badge, type BadgeColor } from '../../ui/badge'
|
|
5
|
-
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
6
5
|
|
|
7
6
|
const BRAND_PRIMARY = '#FF7900'
|
|
8
7
|
const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
|
|
9
8
|
const STATUS_SUCCESS = '#14B8A6'
|
|
10
9
|
const STATUS_WARNING = '#FFB020'
|
|
11
10
|
const STATUS_ERROR = '#D14343'
|
|
12
|
-
const TEXT_PRIMARY = '#F3F4F6'
|
|
13
|
-
const TEXT_SECONDARY = '#9CA3AF'
|
|
14
11
|
|
|
15
12
|
/** Emoji option sets per known factor, keyed by lowercase id/label. */
|
|
16
13
|
const EMOJI_SETS: Record<string, readonly string[]> = {
|
|
@@ -92,7 +89,8 @@ function EmojiSlider({ factor }: EmojiSliderProps) {
|
|
|
92
89
|
return (
|
|
93
90
|
<View style={{ marginTop: 14 }} testID={`readiness-check-factor-${factor.id}`}>
|
|
94
91
|
<Text
|
|
95
|
-
|
|
92
|
+
className="text-text-secondary"
|
|
93
|
+
style={{ fontSize: 12, fontWeight: '500', fontFamily: 'Inter, sans-serif' }}
|
|
96
94
|
testID="readiness-check-factor-label"
|
|
97
95
|
>
|
|
98
96
|
{factor.label}
|
|
@@ -115,14 +113,15 @@ function EmojiSlider({ factor }: EmojiSliderProps) {
|
|
|
115
113
|
accessibilityLabel={`${factor.label} level ${level} of 5`}
|
|
116
114
|
aria-checked={selected}
|
|
117
115
|
testID="readiness-check-emoji"
|
|
116
|
+
className={selected ? undefined : 'border-border bg-surface-raised'}
|
|
118
117
|
style={({ pressed }) => ({
|
|
119
118
|
flex: 1,
|
|
120
119
|
alignItems: 'center',
|
|
121
120
|
paddingVertical: 8,
|
|
122
121
|
borderRadius: 8,
|
|
123
122
|
borderWidth: 1,
|
|
124
|
-
borderColor: selected ? BRAND_PRIMARY :
|
|
125
|
-
backgroundColor: selected ? BRAND_PRIMARY_SUBTLE :
|
|
123
|
+
borderColor: selected ? BRAND_PRIMARY : undefined,
|
|
124
|
+
backgroundColor: selected ? BRAND_PRIMARY_SUBTLE : undefined,
|
|
126
125
|
opacity: selected ? 1 : pressed ? 0.8 : 0.55,
|
|
127
126
|
transform: [{ scale: selected ? 1.06 : 1 }],
|
|
128
127
|
})}
|
|
@@ -144,13 +143,13 @@ function ScoreGauge({ score }: { score: number }) {
|
|
|
144
143
|
accessibilityRole="image"
|
|
145
144
|
accessibilityLabel={`Readiness score: ${score} out of 100`}
|
|
146
145
|
testID="readiness-check-score"
|
|
146
|
+
className="bg-surface-raised"
|
|
147
147
|
style={{
|
|
148
148
|
width: 60,
|
|
149
149
|
height: 60,
|
|
150
150
|
borderRadius: 30,
|
|
151
151
|
borderWidth: 4,
|
|
152
152
|
borderColor: color,
|
|
153
|
-
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
154
153
|
alignItems: 'center',
|
|
155
154
|
justifyContent: 'center',
|
|
156
155
|
}}
|
|
@@ -170,19 +169,19 @@ function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
|
|
|
170
169
|
const badge = WARMUP_BADGE[validation.status]
|
|
171
170
|
return (
|
|
172
171
|
<View
|
|
172
|
+
className="border-border bg-surface-raised"
|
|
173
173
|
style={{
|
|
174
174
|
marginTop: 16,
|
|
175
175
|
padding: 12,
|
|
176
176
|
borderRadius: 8,
|
|
177
177
|
borderWidth: 1,
|
|
178
|
-
borderColor: WORKOUT_TOKENS.border.default,
|
|
179
|
-
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
180
178
|
}}
|
|
181
179
|
testID="readiness-check-warmup"
|
|
182
180
|
>
|
|
183
181
|
<View className="flex-row items-center justify-between" style={{ gap: 8 }}>
|
|
184
182
|
<Text
|
|
185
|
-
|
|
183
|
+
className="text-text-secondary"
|
|
184
|
+
style={{ fontSize: 12, fontWeight: '600', fontFamily: 'Inter, sans-serif' }}
|
|
186
185
|
testID="readiness-check-warmup-deficit"
|
|
187
186
|
>
|
|
188
187
|
{velocityDeficitText(validation.velocityDeficit)}
|
|
@@ -192,7 +191,8 @@ function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
|
|
|
192
191
|
</Badge>
|
|
193
192
|
</View>
|
|
194
193
|
<Text
|
|
195
|
-
|
|
194
|
+
className="text-text-primary"
|
|
195
|
+
style={{ marginTop: 6, fontSize: 13, fontFamily: 'Inter, sans-serif' }}
|
|
196
196
|
testID="readiness-check-warmup-recommendation"
|
|
197
197
|
>
|
|
198
198
|
{validation.recommendation}
|
|
@@ -227,7 +227,8 @@ export function ReadinessCheck({
|
|
|
227
227
|
<View style={{ padding: 16 }}>
|
|
228
228
|
<View className="flex-row items-center justify-between" style={{ gap: 12 }}>
|
|
229
229
|
<Text
|
|
230
|
-
|
|
230
|
+
className="text-text-primary"
|
|
231
|
+
style={{ fontSize: 18, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif' }}
|
|
231
232
|
testID="readiness-check-title"
|
|
232
233
|
>
|
|
233
234
|
How are you feeling?
|
|
@@ -143,3 +143,13 @@ describe('RestTimer', () => {
|
|
|
143
143
|
})
|
|
144
144
|
})
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
describe('RestTimer zero-duration timer (NaN guard)', () => {
|
|
148
|
+
it('emits a valid 100% width (not NaN) when totalSeconds is 0', () => {
|
|
149
|
+
const { container } = render(
|
|
150
|
+
<RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />,
|
|
151
|
+
)
|
|
152
|
+
expect(screen.getByTestId('rest-timer-progress-fill')).toHaveStyle({ width: '100%' })
|
|
153
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, Pressable } from 'react-native'
|
|
3
|
-
import {
|
|
3
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
4
4
|
|
|
5
5
|
const BRAND_PRIMARY = '#FF7900'
|
|
6
6
|
|
|
@@ -28,15 +28,18 @@ export function RestTimer({
|
|
|
28
28
|
const minutes = Math.floor(remainingSec / 60)
|
|
29
29
|
const seconds = remainingSec % 60
|
|
30
30
|
const timeDisplay = `${minutes}:${String(seconds).padStart(2, '0')}`
|
|
31
|
-
|
|
31
|
+
// A zero-duration timer is already complete; guard the 0 / 0 === NaN case
|
|
32
|
+
// that would otherwise emit width:'NaN%'.
|
|
33
|
+
const totalMs = totalSeconds * 1000
|
|
34
|
+
const progressPct = totalMs > 0 ? Math.min(100, (elapsedMs / totalMs) * 100) : 100
|
|
32
35
|
|
|
33
36
|
return (
|
|
34
37
|
<View
|
|
38
|
+
className="bg-surface-raised"
|
|
35
39
|
style={{
|
|
36
40
|
width: '100%',
|
|
37
|
-
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
38
41
|
borderTopWidth: 1,
|
|
39
|
-
borderTopColor:
|
|
42
|
+
borderTopColor: resolveColor('border-default'),
|
|
40
43
|
paddingVertical: 12,
|
|
41
44
|
paddingHorizontal: 16,
|
|
42
45
|
}}
|
|
@@ -56,13 +59,13 @@ export function RestTimer({
|
|
|
56
59
|
{/* Left side */}
|
|
57
60
|
<View style={{ flexDirection: 'column' }}>
|
|
58
61
|
<Text
|
|
62
|
+
className="text-text-secondary"
|
|
59
63
|
style={{
|
|
60
64
|
fontSize: 11,
|
|
61
65
|
fontFamily: 'Inter, sans-serif',
|
|
62
66
|
fontWeight: '600',
|
|
63
67
|
textTransform: 'uppercase',
|
|
64
68
|
letterSpacing: 1,
|
|
65
|
-
color: '#9CA3AF',
|
|
66
69
|
}}
|
|
67
70
|
testID="rest-timer-label"
|
|
68
71
|
>
|
|
@@ -70,10 +73,10 @@ export function RestTimer({
|
|
|
70
73
|
</Text>
|
|
71
74
|
{nextSetInfo != null && (
|
|
72
75
|
<Text
|
|
76
|
+
className="text-text-tertiary"
|
|
73
77
|
style={{
|
|
74
78
|
fontSize: 11,
|
|
75
79
|
fontFamily: 'Inter, sans-serif',
|
|
76
|
-
color: '#6B7280',
|
|
77
80
|
marginTop: 2,
|
|
78
81
|
}}
|
|
79
82
|
testID="rest-timer-next-set"
|
|
@@ -85,11 +88,11 @@ export function RestTimer({
|
|
|
85
88
|
|
|
86
89
|
{/* Right side - time display */}
|
|
87
90
|
<Text
|
|
91
|
+
className="text-text-primary"
|
|
88
92
|
style={{
|
|
89
93
|
fontSize: 28,
|
|
90
94
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
91
95
|
fontWeight: '700',
|
|
92
|
-
color: '#F3F4F6',
|
|
93
96
|
fontVariant: ['tabular-nums'],
|
|
94
97
|
letterSpacing: -0.5,
|
|
95
98
|
}}
|
|
@@ -101,9 +104,9 @@ export function RestTimer({
|
|
|
101
104
|
|
|
102
105
|
{/* Progress bar */}
|
|
103
106
|
<View
|
|
107
|
+
className="bg-border"
|
|
104
108
|
style={{
|
|
105
109
|
height: 3,
|
|
106
|
-
backgroundColor: WORKOUT_TOKENS.border.default,
|
|
107
110
|
borderRadius: 2,
|
|
108
111
|
marginBottom: 12,
|
|
109
112
|
}}
|
|
@@ -135,11 +138,11 @@ export function RestTimer({
|
|
|
135
138
|
testID="rest-timer-add-time"
|
|
136
139
|
>
|
|
137
140
|
<Text
|
|
141
|
+
className="text-text-secondary"
|
|
138
142
|
style={{
|
|
139
143
|
fontSize: 11,
|
|
140
144
|
fontFamily: 'Inter, sans-serif',
|
|
141
145
|
fontWeight: '600',
|
|
142
|
-
color: '#9CA3AF',
|
|
143
146
|
}}
|
|
144
147
|
>
|
|
145
148
|
+30s
|