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