@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,288 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
+ import { formatVelocity } from '../../../utils/workout-format'
6
+
7
+ export interface VelocityStripProps extends ViewProps {
8
+ velocities: number[]
9
+ expanded?: boolean
10
+ onToggle?: () => void
11
+ onRepPress?: (index: number, velocity: number) => void
12
+ variant?: 'full' | 'mini'
13
+ showInfo?: boolean
14
+ className?: string
15
+ }
16
+
17
+ // Canonical scale shared with SetRow's RPE color (see theme/workout-tokens.ts).
18
+ const VEL_COLORS = WORKOUT_TOKENS.scale
19
+
20
+ export function getVelocityZoneColor(velocity: number): string {
21
+ if (velocity >= 1.0) return 'vel-green'
22
+ if (velocity >= 0.75) return 'vel-yellow'
23
+ if (velocity >= 0.5) return 'vel-orange'
24
+ return 'vel-red'
25
+ }
26
+
27
+ export function getVelocityZoneName(velocity: number): string {
28
+ if (velocity >= 1.0) return 'Speed'
29
+ if (velocity >= 0.75) return 'Power'
30
+ if (velocity >= 0.5) return 'Strength-Speed'
31
+ return 'Strength'
32
+ }
33
+
34
+ export function calculateVelocityLoss(velocities: number[]): number {
35
+ if (velocities.length < 2) return 0
36
+ const first = velocities[0]
37
+ const last = velocities[velocities.length - 1]
38
+ if (first === 0) return 0
39
+ return Math.round(((first - last) / first) * 100)
40
+ }
41
+
42
+ export function calculateMeanVelocity(velocities: number[]): number {
43
+ if (velocities.length === 0) return 0
44
+ const sum = velocities.reduce((acc, v) => acc + v, 0)
45
+ return sum / velocities.length
46
+ }
47
+
48
+ function getBarColor(zone: string): string {
49
+ switch (zone) {
50
+ case 'vel-green': return VEL_COLORS.green
51
+ case 'vel-yellow': return VEL_COLORS.yellow
52
+ case 'vel-orange': return VEL_COLORS.orange
53
+ case 'vel-red': return VEL_COLORS.red
54
+ default: return VEL_COLORS.green
55
+ }
56
+ }
57
+
58
+ const zoneHexMap: Record<string, string> = {
59
+ 'vel-green': VEL_COLORS.green,
60
+ 'vel-yellow': VEL_COLORS.yellow,
61
+ 'vel-orange': VEL_COLORS.orange,
62
+ 'vel-red': VEL_COLORS.red,
63
+ }
64
+
65
+ function getLossStyle(loss: number): Record<string, string> | null {
66
+ if (loss > 25) return { color: VEL_COLORS.red }
67
+ if (loss > 20) return { color: VEL_COLORS.orange }
68
+ return null
69
+ }
70
+
71
+ const ANIMATION_DURATION = 400
72
+ const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
73
+
74
+ export function VelocityStrip({
75
+ velocities,
76
+ expanded = false,
77
+ onToggle,
78
+ onRepPress,
79
+ variant = 'full',
80
+ showInfo = true,
81
+ className,
82
+ ...props
83
+ }: VelocityStripProps) {
84
+ const maxVelocity = Math.max(...velocities, 0)
85
+ const meanVelocity = calculateMeanVelocity(velocities)
86
+ const loss = calculateVelocityLoss(velocities)
87
+ const meanZone = getVelocityZoneName(meanVelocity)
88
+
89
+ const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
90
+ const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
91
+ const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
92
+
93
+ useEffect(() => {
94
+ if (variant === 'mini') return
95
+
96
+ Animated.parallel([
97
+ Animated.timing(heightAnim, {
98
+ toValue: expanded ? 60 : 3,
99
+ duration: ANIMATION_DURATION,
100
+ easing: ANIMATION_EASING,
101
+ useNativeDriver: false,
102
+ }),
103
+ Animated.timing(labelOpacity, {
104
+ toValue: expanded ? 1 : 0,
105
+ duration: 300,
106
+ delay: expanded ? 150 : 0,
107
+ useNativeDriver: false,
108
+ }),
109
+ Animated.timing(infoOpacity, {
110
+ toValue: expanded ? 1 : 0,
111
+ duration: 300,
112
+ delay: expanded ? 200 : 0,
113
+ useNativeDriver: false,
114
+ }),
115
+ ]).start()
116
+ }, [expanded, variant, heightAnim, labelOpacity, infoOpacity])
117
+
118
+ if (variant === 'mini') {
119
+ const { style: externalStyle, ...restProps } = props
120
+ return (
121
+ <View
122
+ className={className}
123
+ style={[{ flexDirection: 'row', height: 3, gap: 2, borderRadius: 2, overflow: 'hidden' }, externalStyle]}
124
+ accessibilityRole="image"
125
+ accessibilityLabel={`Velocity strip, ${velocities.length} reps`}
126
+ testID="velocity-strip-mini"
127
+ {...restProps}
128
+ >
129
+ {velocities.map((v, i) => (
130
+ <View
131
+ key={i}
132
+ style={{
133
+ flex: 1,
134
+ backgroundColor: getBarColor(getVelocityZoneColor(v)),
135
+ borderRadius: 1,
136
+ minWidth: 4,
137
+ height: '100%' as unknown as number,
138
+ }}
139
+ accessibilityElementsHidden
140
+ testID={`velocity-bar-${i}`}
141
+ />
142
+ ))}
143
+ </View>
144
+ )
145
+ }
146
+
147
+ const stripLabel = `Velocity chart for set, ${velocities.length} reps, tap to ${expanded ? 'collapse' : 'expand'}`
148
+ // When onToggle wraps the strip or individual reps are interactive, the container itself is not a button
149
+ const hasInteractiveContainer = onToggle != null
150
+ const hasInteractiveReps = onRepPress != null && expanded
151
+
152
+ const stripContent = (
153
+ <Animated.View
154
+ className={className}
155
+ style={[
156
+ {
157
+ height: heightAnim,
158
+ width: '100%',
159
+ gap: 2,
160
+ borderRadius: expanded ? 6 : 2,
161
+ paddingTop: expanded ? 16 : 0,
162
+ paddingBottom: expanded ? 24 : 0,
163
+ paddingHorizontal: expanded ? 6 : 0,
164
+ paddingVertical: expanded ? undefined : 8,
165
+ backgroundColor: expanded ? 'var(--color-surface-raised)' : 'transparent',
166
+ overflow: expanded ? 'visible' : 'hidden',
167
+ },
168
+ ]}
169
+ accessibilityRole={hasInteractiveContainer || hasInteractiveReps ? 'none' : 'button'}
170
+ accessibilityLabel={hasInteractiveContainer || hasInteractiveReps ? undefined : stripLabel}
171
+ testID="velocity-strip"
172
+ {...props}
173
+ >
174
+ <View
175
+ style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
176
+ >
177
+ {velocities.map((v, i) => {
178
+ const zone = getVelocityZoneColor(v)
179
+ // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
180
+ // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
181
+ const barHeightPct =
182
+ maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
183
+
184
+ const bar = (
185
+ <View
186
+ key={i}
187
+ style={{ flex: 1, height: '100%', justifyContent: 'flex-end', position: 'relative' }}
188
+ >
189
+ {expanded && (
190
+ <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
191
+ <Text
192
+ style={{ fontSize: 8, fontWeight: '600', color: 'var(--color-text-secondary)' }}
193
+ testID={`velocity-label-${i}`}
194
+ >
195
+ {formatVelocity(v)}
196
+ </Text>
197
+ </Animated.View>
198
+ )}
199
+ <View
200
+ style={
201
+ expanded
202
+ ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
203
+ : {
204
+ minHeight: '100%' as unknown as number,
205
+ borderTopLeftRadius: 2,
206
+ borderTopRightRadius: 2,
207
+ borderBottomLeftRadius: 0,
208
+ borderBottomRightRadius: 0,
209
+ backgroundColor: zoneHexMap[zone],
210
+ }
211
+ }
212
+ testID={`velocity-bar-${i}`}
213
+ accessibilityRole="image"
214
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
215
+ />
216
+ </View>
217
+ )
218
+
219
+ if (onRepPress && expanded) {
220
+ return (
221
+ <Pressable
222
+ key={i}
223
+ style={{ flex: 1, height: '100%' }}
224
+ onPress={() => onRepPress(i, v)}
225
+ accessibilityRole="button"
226
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
227
+ testID={`velocity-bar-pressable-${i}`}
228
+ >
229
+ {bar}
230
+ </Pressable>
231
+ )
232
+ }
233
+
234
+ return bar
235
+ })}
236
+ </View>
237
+ {expanded && showInfo && (
238
+ <Animated.View
239
+ style={{
240
+ flexDirection: 'row',
241
+ justifyContent: 'space-between',
242
+ opacity: infoOpacity,
243
+ position: 'absolute',
244
+ bottom: 4,
245
+ left: 6,
246
+ right: 6,
247
+ }}
248
+ testID="velocity-info-row"
249
+ >
250
+ <Text
251
+ style={{
252
+ fontSize: 10,
253
+ color: 'var(--color-text-secondary)',
254
+ fontFamily: 'Inter, sans-serif',
255
+ }}
256
+ >
257
+ {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
258
+ </Text>
259
+ <Text
260
+ style={{
261
+ fontSize: 10,
262
+ color: 'var(--color-text-secondary)',
263
+ fontFamily: 'Inter, sans-serif',
264
+ ...getLossStyle(loss),
265
+ }}
266
+ >
267
+ Loss: {loss}%
268
+ </Text>
269
+ </Animated.View>
270
+ )}
271
+ </Animated.View>
272
+ )
273
+
274
+ if (onToggle) {
275
+ return (
276
+ <Pressable
277
+ onPress={onToggle}
278
+ accessibilityRole="button"
279
+ accessibilityLabel={stripLabel}
280
+ testID="velocity-strip-pressable"
281
+ >
282
+ {stripContent}
283
+ </Pressable>
284
+ )
285
+ }
286
+
287
+ return stripContent
288
+ }
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { WeekRow } from './WeekRow'
4
+
5
+ const meta: Meta<typeof WeekRow> = {
6
+ title: 'Custom/Workout/WeekRow',
7
+ component: WeekRow,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ weekNumber: { control: 'number', description: 'Week index (1-based)' },
11
+ totalWeeks: { control: 'number', description: 'Weeks in the mesocycle' },
12
+ intensityLevel: {
13
+ control: { type: 'range', min: 0, max: 1, step: 0.05 },
14
+ description: 'Planned intensity, 0-1',
15
+ },
16
+ intensityThreshold: {
17
+ control: { type: 'range', min: 0, max: 1, step: 0.05 },
18
+ description: 'MRV/overexertion threshold, 0-1',
19
+ },
20
+ isCurrent: { control: 'boolean', description: 'Active week highlight' },
21
+ isDeload: { control: 'boolean', description: 'Deload week styling' },
22
+ },
23
+ }
24
+
25
+ export default meta
26
+ type Story = StoryObj<typeof WeekRow>
27
+
28
+ export const Default: Story = {
29
+ args: {
30
+ weekNumber: 1,
31
+ totalWeeks: 4,
32
+ workouts: [
33
+ { name: 'Upper', status: 'completed' },
34
+ { name: 'Lower', status: 'completed' },
35
+ { name: 'Full', status: 'upcoming' },
36
+ ],
37
+ intensityLevel: 0.45,
38
+ intensityThreshold: 0.8,
39
+ },
40
+ }
41
+
42
+ export const CurrentWeek: Story = {
43
+ args: {
44
+ weekNumber: 3,
45
+ totalWeeks: 4,
46
+ workouts: [
47
+ { name: 'Push', status: 'completed' },
48
+ { name: 'Pull', status: 'current' },
49
+ { name: 'Legs', status: 'upcoming' },
50
+ ],
51
+ intensityLevel: 0.75,
52
+ intensityThreshold: 0.8,
53
+ isCurrent: true,
54
+ },
55
+ }
56
+
57
+ export const DeloadWeek: Story = {
58
+ args: {
59
+ weekNumber: 4,
60
+ totalWeeks: 4,
61
+ workouts: [
62
+ { name: 'Upper', status: 'upcoming' },
63
+ { name: 'Lower', status: 'upcoming' },
64
+ ],
65
+ intensityLevel: 0.3,
66
+ intensityThreshold: 0.8,
67
+ isDeload: true,
68
+ },
69
+ }
70
+
71
+ export const MesocycleProgression: Story = {
72
+ render: () => (
73
+ <View style={{ gap: 2, padding: 16, maxWidth: 420 }}>
74
+ <WeekRow
75
+ weekNumber={1}
76
+ totalWeeks={4}
77
+ workouts={[
78
+ { name: 'Upper', status: 'completed' },
79
+ { name: 'Lower', status: 'completed' },
80
+ { name: 'Full', status: 'completed' },
81
+ ]}
82
+ intensityLevel={0.45}
83
+ intensityThreshold={0.8}
84
+ />
85
+ <WeekRow
86
+ weekNumber={2}
87
+ totalWeeks={4}
88
+ workouts={[
89
+ { name: 'Upper', status: 'completed' },
90
+ { name: 'Lower', status: 'completed' },
91
+ { name: 'Full', status: 'completed' },
92
+ ]}
93
+ intensityLevel={0.6}
94
+ intensityThreshold={0.8}
95
+ />
96
+ <WeekRow
97
+ weekNumber={3}
98
+ totalWeeks={4}
99
+ workouts={[
100
+ { name: 'Upper', status: 'completed' },
101
+ { name: 'Lower', status: 'current' },
102
+ { name: 'Full', status: 'upcoming' },
103
+ ]}
104
+ intensityLevel={0.78}
105
+ intensityThreshold={0.8}
106
+ isCurrent
107
+ />
108
+ <WeekRow
109
+ weekNumber={4}
110
+ totalWeeks={4}
111
+ workouts={[
112
+ { name: 'Upper', status: 'upcoming' },
113
+ { name: 'Lower', status: 'upcoming' },
114
+ ]}
115
+ intensityLevel={0.3}
116
+ intensityThreshold={0.8}
117
+ isDeload
118
+ />
119
+ </View>
120
+ ),
121
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { WeekRow } from './WeekRow'
5
+ import type { WeekRowWorkout } from './WeekRow'
6
+
7
+ const workouts: WeekRowWorkout[] = [
8
+ { name: 'Upper', status: 'completed' },
9
+ { name: 'Lower', status: 'current' },
10
+ { name: 'Full', status: 'upcoming' },
11
+ ]
12
+
13
+ const baseProps = {
14
+ weekNumber: 1,
15
+ totalWeeks: 4,
16
+ workouts,
17
+ intensityLevel: 0.55,
18
+ }
19
+
20
+ describe('WeekRow', () => {
21
+ describe('rendering', () => {
22
+ it('renders the week number label', () => {
23
+ render(<WeekRow {...baseProps} />)
24
+ expect(screen.getByTestId('week-row-number')).toHaveTextContent('W1')
25
+ })
26
+
27
+ it('renders a workout pill for each workout', () => {
28
+ render(<WeekRow {...baseProps} />)
29
+ expect(screen.getAllByTestId('workout-pill')).toHaveLength(3)
30
+ })
31
+
32
+ it('renders the intensity bar', () => {
33
+ render(<WeekRow {...baseProps} />)
34
+ expect(screen.getByTestId('week-row-intensity')).toBeInTheDocument()
35
+ expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
36
+ })
37
+
38
+ it('passes the threshold through to the intensity bar', () => {
39
+ render(<WeekRow {...baseProps} intensityThreshold={0.8} />)
40
+ expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
41
+ })
42
+
43
+ it('omits the threshold line when not provided', () => {
44
+ render(<WeekRow {...baseProps} />)
45
+ expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
46
+ })
47
+ })
48
+
49
+ describe('current week', () => {
50
+ it('renders the current week marker dot', () => {
51
+ render(<WeekRow {...baseProps} isCurrent />)
52
+ expect(screen.getByTestId('week-row-current-dot')).toBeInTheDocument()
53
+ })
54
+
55
+ it('does not render the marker dot when not current', () => {
56
+ render(<WeekRow {...baseProps} />)
57
+ expect(screen.queryByTestId('week-row-current-dot')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('applies the current-week tint and left border', () => {
61
+ render(<WeekRow {...baseProps} isCurrent />)
62
+ const style = screen.getByTestId('week-row').getAttribute('style') ?? ''
63
+ expect(style).toContain('background-color')
64
+ expect(style).toContain('border-left')
65
+ })
66
+ })
67
+
68
+ describe('deload week', () => {
69
+ it('renders all pills with the deload status', () => {
70
+ render(<WeekRow {...baseProps} isDeload />)
71
+ expect(screen.getAllByLabelText(/workout, deload/)).toHaveLength(3)
72
+ })
73
+
74
+ it('applies a purple tint to the row', () => {
75
+ render(<WeekRow {...baseProps} isDeload />)
76
+ const style = screen.getByTestId('week-row').getAttribute('style') ?? ''
77
+ expect(style).toContain('background-color')
78
+ })
79
+ })
80
+
81
+ describe('interaction', () => {
82
+ it('invokes onPress for a pressable workout', () => {
83
+ const onPress = vi.fn()
84
+ render(
85
+ <WeekRow
86
+ {...baseProps}
87
+ workouts={[{ name: 'Upper', status: 'current', onPress }]}
88
+ />,
89
+ )
90
+ fireEvent.click(screen.getByTestId('workout-pill-pressable'))
91
+ expect(onPress).toHaveBeenCalledOnce()
92
+ })
93
+ })
94
+
95
+ describe('accessibility', () => {
96
+ it('has a descriptive accessibility label', () => {
97
+ render(<WeekRow {...baseProps} />)
98
+ expect(
99
+ screen.getByLabelText('Week 1 of 4, 3 workouts'),
100
+ ).toBeInTheDocument()
101
+ })
102
+
103
+ it('has no accessibility violations', async () => {
104
+ const { container } = render(<WeekRow {...baseProps} />)
105
+ const results = await axe(container)
106
+ expect(results).toHaveNoViolations()
107
+ })
108
+
109
+ it('has no accessibility violations as a current deload week', async () => {
110
+ const { container } = render(
111
+ <WeekRow {...baseProps} isCurrent isDeload intensityThreshold={0.8} />,
112
+ )
113
+ const results = await axe(container)
114
+ expect(results).toHaveNoViolations()
115
+ })
116
+ })
117
+ })
@@ -0,0 +1,140 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+ import { WorkoutPill, type WorkoutPillStatus } from './WorkoutPill'
4
+ import { IntensityBar } from './IntensityBar'
5
+
6
+ const BRAND_PRIMARY = '#FF7900'
7
+
8
+ export interface WeekRowWorkout {
9
+ name: string
10
+ status: WorkoutPillStatus
11
+ onPress?: () => void
12
+ }
13
+
14
+ export interface WeekRowProps extends ViewProps {
15
+ /** Week index within the mesocycle (1-based) */
16
+ weekNumber: number
17
+ /** Total weeks in the mesocycle */
18
+ totalWeeks: number
19
+ /** Workouts scheduled for this week */
20
+ workouts: WeekRowWorkout[]
21
+ /** Planned intensity for the week, 0-1 */
22
+ intensityLevel: number
23
+ /** Optional MRV/overexertion threshold, 0-1 */
24
+ intensityThreshold?: number
25
+ /** Highlights this row as the active week */
26
+ isCurrent?: boolean
27
+ /** Renders this row as a deload week (purple tint, deload pills) */
28
+ isDeload?: boolean
29
+ className?: string
30
+ }
31
+
32
+ /**
33
+ * A single week within a mesocycle, showing the week number, its workout pills,
34
+ * and a right-aligned vertical intensity indicator.
35
+ *
36
+ * @example
37
+ * <WeekRow
38
+ * weekNumber={1}
39
+ * totalWeeks={4}
40
+ * workouts={[
41
+ * { name: 'Upper', status: 'completed' },
42
+ * { name: 'Lower', status: 'current' },
43
+ * ]}
44
+ * intensityLevel={0.55}
45
+ * intensityThreshold={0.8}
46
+ * isCurrent
47
+ * />
48
+ */
49
+ export function WeekRow({
50
+ weekNumber,
51
+ totalWeeks,
52
+ workouts,
53
+ intensityLevel,
54
+ intensityThreshold,
55
+ isCurrent = false,
56
+ isDeload = false,
57
+ className,
58
+ ...props
59
+ }: WeekRowProps) {
60
+ const rowStyle: Record<string, unknown> = {
61
+ flexDirection: 'row' as const,
62
+ alignItems: 'center' as const,
63
+ paddingVertical: 10,
64
+ paddingHorizontal: 12,
65
+ gap: 12,
66
+ }
67
+
68
+ if (isDeload) {
69
+ rowStyle.backgroundColor = 'rgba(147,51,234,0.06)'
70
+ }
71
+
72
+ if (isCurrent) {
73
+ rowStyle.backgroundColor = 'rgba(255,121,0,0.04)'
74
+ rowStyle.borderLeftWidth = 2
75
+ rowStyle.borderLeftColor = BRAND_PRIMARY
76
+ }
77
+
78
+ return (
79
+ <View
80
+ className={className}
81
+ style={rowStyle}
82
+ accessibilityLabel={`Week ${weekNumber} of ${totalWeeks}, ${workouts.length} workouts`}
83
+ testID="week-row"
84
+ {...props}
85
+ >
86
+ <View
87
+ style={{ flexDirection: 'row', alignItems: 'center', width: 32, gap: 4 }}
88
+ testID="week-row-number"
89
+ >
90
+ {isCurrent && (
91
+ <View
92
+ style={{
93
+ width: 6,
94
+ height: 6,
95
+ borderRadius: 3,
96
+ backgroundColor: BRAND_PRIMARY,
97
+ }}
98
+ accessibilityElementsHidden
99
+ testID="week-row-current-dot"
100
+ />
101
+ )}
102
+ <Text
103
+ style={{
104
+ fontSize: 13,
105
+ fontWeight: '700',
106
+ fontFamily: 'Inter, sans-serif',
107
+ color: isCurrent ? BRAND_PRIMARY : 'var(--color-text-primary)',
108
+ }}
109
+ accessibilityElementsHidden
110
+ >
111
+ {`W${weekNumber}`}
112
+ </Text>
113
+ </View>
114
+
115
+ <View
116
+ style={{ flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}
117
+ testID="week-row-pills"
118
+ >
119
+ {workouts.map((workout, i) => (
120
+ <WorkoutPill
121
+ key={i}
122
+ name={workout.name}
123
+ status={isDeload ? 'deload' : workout.status}
124
+ onPress={workout.onPress}
125
+ />
126
+ ))}
127
+ </View>
128
+
129
+ <View testID="week-row-intensity">
130
+ <IntensityBar
131
+ level={intensityLevel}
132
+ threshold={intensityThreshold}
133
+ orientation="vertical"
134
+ size={24}
135
+ thickness={6}
136
+ />
137
+ </View>
138
+ </View>
139
+ )
140
+ }