@titan-design/react-ui 0.2.5 → 0.2.7

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 (158) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,287 @@
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
+
5
+ export interface VelocityStripProps extends ViewProps {
6
+ velocities: number[]
7
+ expanded?: boolean
8
+ onToggle?: () => void
9
+ onRepPress?: (index: number, velocity: number) => void
10
+ variant?: 'full' | 'mini'
11
+ showInfo?: boolean
12
+ className?: string
13
+ }
14
+
15
+ const VEL_COLORS = {
16
+ green: '#2ed573',
17
+ yellow: '#ffd43b',
18
+ orange: '#ffa502',
19
+ red: '#ff4757',
20
+ }
21
+
22
+ export function getVelocityZoneColor(velocity: number): string {
23
+ if (velocity >= 1.0) return 'vel-green'
24
+ if (velocity >= 0.75) return 'vel-yellow'
25
+ if (velocity >= 0.5) return 'vel-orange'
26
+ return 'vel-red'
27
+ }
28
+
29
+ export function getVelocityZoneName(velocity: number): string {
30
+ if (velocity >= 1.0) return 'Speed'
31
+ if (velocity >= 0.75) return 'Power'
32
+ if (velocity >= 0.5) return 'Strength-Speed'
33
+ return 'Strength'
34
+ }
35
+
36
+ export function calculateVelocityLoss(velocities: number[]): number {
37
+ if (velocities.length < 2) return 0
38
+ const first = velocities[0]
39
+ const last = velocities[velocities.length - 1]
40
+ if (first === 0) return 0
41
+ return Math.round(((first - last) / first) * 100)
42
+ }
43
+
44
+ export function calculateMeanVelocity(velocities: number[]): number {
45
+ if (velocities.length === 0) return 0
46
+ const sum = velocities.reduce((acc, v) => acc + v, 0)
47
+ return sum / velocities.length
48
+ }
49
+
50
+ function getBarColor(zone: string): string {
51
+ switch (zone) {
52
+ case 'vel-green': return VEL_COLORS.green
53
+ case 'vel-yellow': return VEL_COLORS.yellow
54
+ case 'vel-orange': return VEL_COLORS.orange
55
+ case 'vel-red': return VEL_COLORS.red
56
+ default: return VEL_COLORS.green
57
+ }
58
+ }
59
+
60
+ const zoneHexMap: Record<string, string> = {
61
+ 'vel-green': VEL_COLORS.green,
62
+ 'vel-yellow': VEL_COLORS.yellow,
63
+ 'vel-orange': VEL_COLORS.orange,
64
+ 'vel-red': VEL_COLORS.red,
65
+ }
66
+
67
+ function getLossStyle(loss: number): Record<string, string> | null {
68
+ if (loss > 25) return { color: VEL_COLORS.red }
69
+ if (loss > 20) return { color: VEL_COLORS.orange }
70
+ return null
71
+ }
72
+
73
+ const ANIMATION_DURATION = 400
74
+ const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
75
+
76
+ export function VelocityStrip({
77
+ velocities,
78
+ expanded = false,
79
+ onToggle,
80
+ onRepPress,
81
+ variant = 'full',
82
+ showInfo = true,
83
+ className,
84
+ ...props
85
+ }: VelocityStripProps) {
86
+ const maxVelocity = Math.max(...velocities, 0)
87
+ const meanVelocity = calculateMeanVelocity(velocities)
88
+ const loss = calculateVelocityLoss(velocities)
89
+ const meanZone = getVelocityZoneName(meanVelocity)
90
+
91
+ const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
92
+ const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
93
+ const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
94
+
95
+ useEffect(() => {
96
+ if (variant === 'mini') return
97
+
98
+ Animated.parallel([
99
+ Animated.timing(heightAnim, {
100
+ toValue: expanded ? 60 : 3,
101
+ duration: ANIMATION_DURATION,
102
+ easing: ANIMATION_EASING,
103
+ useNativeDriver: false,
104
+ }),
105
+ Animated.timing(labelOpacity, {
106
+ toValue: expanded ? 1 : 0,
107
+ duration: 300,
108
+ delay: expanded ? 150 : 0,
109
+ useNativeDriver: false,
110
+ }),
111
+ Animated.timing(infoOpacity, {
112
+ toValue: expanded ? 1 : 0,
113
+ duration: 300,
114
+ delay: expanded ? 200 : 0,
115
+ useNativeDriver: false,
116
+ }),
117
+ ]).start()
118
+ }, [expanded, variant, heightAnim, labelOpacity, infoOpacity])
119
+
120
+ if (variant === 'mini') {
121
+ const { style: externalStyle, ...restProps } = props
122
+ return (
123
+ <View
124
+ className={className}
125
+ style={[{ flexDirection: 'row', height: 3, gap: 2, borderRadius: 2, overflow: 'hidden' }, externalStyle]}
126
+ accessibilityRole="image"
127
+ accessibilityLabel={`Velocity strip, ${velocities.length} reps`}
128
+ testID="velocity-strip-mini"
129
+ {...restProps}
130
+ >
131
+ {velocities.map((v, i) => (
132
+ <View
133
+ key={i}
134
+ style={{
135
+ flex: 1,
136
+ backgroundColor: getBarColor(getVelocityZoneColor(v)),
137
+ borderRadius: 1,
138
+ minWidth: 4,
139
+ height: '100%' as unknown as number,
140
+ }}
141
+ accessibilityElementsHidden
142
+ testID={`velocity-bar-${i}`}
143
+ />
144
+ ))}
145
+ </View>
146
+ )
147
+ }
148
+
149
+ const stripLabel = `Velocity chart for set, ${velocities.length} reps, tap to ${expanded ? 'collapse' : 'expand'}`
150
+ // When onToggle wraps the strip or individual reps are interactive, the container itself is not a button
151
+ const hasInteractiveContainer = onToggle != null
152
+ const hasInteractiveReps = onRepPress != null && expanded
153
+
154
+ const stripContent = (
155
+ <Animated.View
156
+ className={className}
157
+ style={[
158
+ {
159
+ height: heightAnim,
160
+ width: '100%',
161
+ gap: 2,
162
+ borderRadius: expanded ? 6 : 2,
163
+ paddingTop: expanded ? 16 : 0,
164
+ paddingBottom: expanded ? 24 : 0,
165
+ paddingHorizontal: expanded ? 6 : 0,
166
+ paddingVertical: expanded ? undefined : 8,
167
+ backgroundColor: expanded ? '#1C1C1C' : 'transparent',
168
+ overflow: expanded ? 'visible' : 'hidden',
169
+ },
170
+ ]}
171
+ accessibilityRole={hasInteractiveContainer || hasInteractiveReps ? 'none' : 'button'}
172
+ accessibilityLabel={hasInteractiveContainer || hasInteractiveReps ? undefined : stripLabel}
173
+ testID="velocity-strip"
174
+ {...props}
175
+ >
176
+ <View
177
+ style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
178
+ >
179
+ {velocities.map((v, i) => {
180
+ const zone = getVelocityZoneColor(v)
181
+ const barHeightPct = Math.round((v / (maxVelocity * 1.15)) * 100)
182
+
183
+ const bar = (
184
+ <View
185
+ key={i}
186
+ style={{ flex: 1, height: '100%', justifyContent: 'flex-end', position: 'relative' }}
187
+ >
188
+ {expanded && (
189
+ <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
190
+ <Text
191
+ style={{ fontSize: 8, fontWeight: '600', color: '#9CA3AF' }}
192
+ testID={`velocity-label-${i}`}
193
+ >
194
+ {v.toFixed(2)}
195
+ </Text>
196
+ </Animated.View>
197
+ )}
198
+ <View
199
+ style={
200
+ expanded
201
+ ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
202
+ : {
203
+ minHeight: '100%' as unknown as number,
204
+ borderTopLeftRadius: 2,
205
+ borderTopRightRadius: 2,
206
+ borderBottomLeftRadius: 0,
207
+ borderBottomRightRadius: 0,
208
+ backgroundColor: zoneHexMap[zone],
209
+ }
210
+ }
211
+ testID={`velocity-bar-${i}`}
212
+ accessibilityRole="image"
213
+ accessibilityLabel={`Rep ${i + 1}: ${v.toFixed(2)} meters per second`}
214
+ />
215
+ </View>
216
+ )
217
+
218
+ if (onRepPress && expanded) {
219
+ return (
220
+ <Pressable
221
+ key={i}
222
+ style={{ flex: 1, height: '100%' }}
223
+ onPress={() => onRepPress(i, v)}
224
+ accessibilityRole="button"
225
+ accessibilityLabel={`Rep ${i + 1}: ${v.toFixed(2)} meters per second, tap for details`}
226
+ testID={`velocity-bar-pressable-${i}`}
227
+ >
228
+ {bar}
229
+ </Pressable>
230
+ )
231
+ }
232
+
233
+ return bar
234
+ })}
235
+ </View>
236
+ {expanded && showInfo && (
237
+ <Animated.View
238
+ style={{
239
+ flexDirection: 'row',
240
+ justifyContent: 'space-between',
241
+ opacity: infoOpacity,
242
+ position: 'absolute',
243
+ bottom: 4,
244
+ left: 6,
245
+ right: 6,
246
+ }}
247
+ testID="velocity-info-row"
248
+ >
249
+ <Text
250
+ style={{
251
+ fontSize: 10,
252
+ color: '#9CA3AF',
253
+ fontFamily: 'Inter, sans-serif',
254
+ }}
255
+ >
256
+ {meanZone} {'\u00B7'} {meanVelocity.toFixed(2)} m/s
257
+ </Text>
258
+ <Text
259
+ style={{
260
+ fontSize: 10,
261
+ color: '#9CA3AF',
262
+ fontFamily: 'Inter, sans-serif',
263
+ ...getLossStyle(loss),
264
+ }}
265
+ >
266
+ Loss: {loss}%
267
+ </Text>
268
+ </Animated.View>
269
+ )}
270
+ </Animated.View>
271
+ )
272
+
273
+ if (onToggle) {
274
+ return (
275
+ <Pressable
276
+ onPress={onToggle}
277
+ accessibilityRole="button"
278
+ accessibilityLabel={stripLabel}
279
+ testID="velocity-strip-pressable"
280
+ >
281
+ {stripContent}
282
+ </Pressable>
283
+ )
284
+ }
285
+
286
+ return stripContent
287
+ }
@@ -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 : '#F3F4F6',
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
+ }