@titan-design/react-ui 0.2.6 → 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.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.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
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useMemo, useState } from 'react'
3
+ import { View } from 'react-native'
4
+ import { ReadinessCheck, type ReadinessFactor } from './ReadinessCheck'
5
+
6
+ const FACTOR_SEED: Array<{ id: string; label: string; value: number }> = [
7
+ { id: 'sleep', label: 'Sleep', value: 4 },
8
+ { id: 'soreness', label: 'Soreness', value: 3 },
9
+ { id: 'motivation', label: 'Motivation', value: 5 },
10
+ { id: 'stress', label: 'Stress', value: 3 },
11
+ ]
12
+
13
+ const staticFactors: ReadinessFactor[] = FACTOR_SEED.map((f) => ({
14
+ ...f,
15
+ onChange: () => {},
16
+ }))
17
+
18
+ const meta: Meta<typeof ReadinessCheck> = {
19
+ title: 'Custom/Workout/ReadinessCheck',
20
+ component: ReadinessCheck,
21
+ tags: ['autodocs'],
22
+ argTypes: {
23
+ score: {
24
+ control: { type: 'range', min: 0, max: 100, step: 1 },
25
+ description: 'Computed overall readiness score (0-100), colors the gauge',
26
+ },
27
+ warmUpCompleted: {
28
+ control: 'boolean',
29
+ description: 'Whether the VBT warm-up set has been performed',
30
+ },
31
+ onConfirm: {
32
+ description: 'Called when the assessment is confirmed and the workout begins',
33
+ },
34
+ },
35
+ }
36
+
37
+ export default meta
38
+ type Story = StoryObj<typeof ReadinessCheck>
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ factors: staticFactors,
43
+ score: 78,
44
+ warmUpCompleted: false,
45
+ onConfirm: () => {},
46
+ },
47
+ }
48
+
49
+ export const HighReadiness: Story = {
50
+ args: {
51
+ ...Default.args,
52
+ score: 92,
53
+ },
54
+ }
55
+
56
+ export const LowReadiness: Story = {
57
+ args: {
58
+ ...Default.args,
59
+ factors: FACTOR_SEED.map((f) => ({ ...f, value: 2, onChange: () => {} })),
60
+ score: 34,
61
+ },
62
+ }
63
+
64
+ export const WithWarmUpGood: Story = {
65
+ args: {
66
+ ...Default.args,
67
+ score: 81,
68
+ warmUpCompleted: true,
69
+ warmUpValidation: {
70
+ velocityDeficit: 3,
71
+ recommendation: 'Velocity on target — proceed with the planned loads.',
72
+ status: 'good',
73
+ },
74
+ },
75
+ }
76
+
77
+ export const WithWarmUpCaution: Story = {
78
+ args: {
79
+ ...Default.args,
80
+ score: 52,
81
+ warmUpCompleted: true,
82
+ warmUpValidation: {
83
+ velocityDeficit: 18,
84
+ recommendation: 'Significant velocity loss — consider reducing load 5-10% today.',
85
+ status: 'caution',
86
+ },
87
+ },
88
+ }
89
+
90
+ function InteractiveReadinessCheck() {
91
+ const [values, setValues] = useState<Record<string, number>>(
92
+ Object.fromEntries(FACTOR_SEED.map((f) => [f.id, f.value])),
93
+ )
94
+
95
+ const factors: ReadinessFactor[] = FACTOR_SEED.map((f) => ({
96
+ id: f.id,
97
+ label: f.label,
98
+ value: values[f.id],
99
+ onChange: (value) => setValues((prev) => ({ ...prev, [f.id]: value })),
100
+ }))
101
+
102
+ const score = useMemo(() => {
103
+ const total = FACTOR_SEED.reduce((sum, f) => sum + values[f.id], 0)
104
+ return Math.round((total / (FACTOR_SEED.length * 5)) * 100)
105
+ }, [values])
106
+
107
+ return (
108
+ <View style={{ maxWidth: 420, padding: 16 }}>
109
+ <ReadinessCheck
110
+ factors={factors}
111
+ score={score}
112
+ warmUpCompleted
113
+ warmUpValidation={{
114
+ velocityDeficit: 6,
115
+ recommendation: 'Velocity slightly down — warm up thoroughly before top sets.',
116
+ status: 'moderate',
117
+ }}
118
+ onConfirm={() => {}}
119
+ />
120
+ </View>
121
+ )
122
+ }
123
+
124
+ export const Interactive: Story = {
125
+ render: () => <InteractiveReadinessCheck />,
126
+ }