@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.
- 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-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +25 -5
- package/src/bodymap.ts +37 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- 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/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +347 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- 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.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +162 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- 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.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +247 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +160 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
- 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.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +152 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -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/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- 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-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
|
+
}
|