@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,477 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import {
4
+ View,
5
+ Text,
6
+ Pressable,
7
+ ScrollView,
8
+ Animated,
9
+ Easing,
10
+ type ViewProps,
11
+ type ViewStyle,
12
+ } from 'react-native'
13
+ import { Badge, type BadgeColor } from '../../ui/badge'
14
+ import { Sparkline } from './Sparkline'
15
+ import {
16
+ MuscleGroup,
17
+ VOLUME_STATUS_LABELS,
18
+ type VolumeLandmarks,
19
+ type VolumeStatus,
20
+ } from './muscleTaxonomy'
21
+
22
+ const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
23
+ const SURFACE_RAISED = 'var(--color-surface-raised)'
24
+ const BORDER_STRONG = 'var(--color-border-strong)'
25
+ const BORDER_DEFAULT = 'var(--color-border-default)'
26
+ const BRAND_PRIMARY = '#FF7900'
27
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
28
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
29
+ const TEXT_TERTIARY = 'var(--color-text-tertiary)'
30
+
31
+ /** Volume track gradient: status-info (blue) -> result-improve (teal) -> status-error (red). */
32
+ const VOLUME_GRADIENT = 'linear-gradient(90deg, #2196F3 0%, #14B8A6 50%, #D14343 100%)'
33
+
34
+ /** Sheet slides up from this offset (px) and the backdrop fades to this opacity. */
35
+ const SLIDE_OFFSET = 400
36
+ const BACKDROP_OPACITY = 0.4
37
+
38
+ /** Volume status -> Badge color scheme. */
39
+ const STATUS_BADGE_COLOR: Record<VolumeStatus, BadgeColor> = {
40
+ under: 'info',
41
+ maintenance: 'warning',
42
+ productive: 'success',
43
+ over: 'error',
44
+ }
45
+
46
+ export interface ContributingExercise {
47
+ /** Exercise name. */
48
+ name: string
49
+ /** Working sets contributed this week. */
50
+ sets: number
51
+ /** Effective-set multiplier toward this muscle (0-1). */
52
+ contributionWeight: number
53
+ }
54
+
55
+ export interface UpcomingExercise {
56
+ /** Exercise name. */
57
+ name: string
58
+ /** Workout the exercise belongs to. */
59
+ workoutName: string
60
+ /** Planned sets. */
61
+ sets: number
62
+ }
63
+
64
+ export interface BodyMapDetailPanelProps extends ViewProps {
65
+ /** Muscle group whose volume detail is shown. */
66
+ muscleGroup: MuscleGroup
67
+ /** Human-readable muscle name for the title and a11y label. */
68
+ displayName: string
69
+ /** Weekly effective sets logged for this muscle. */
70
+ weeklySets: number
71
+ /** Volume landmarks (weekly working sets). */
72
+ landmarks: VolumeLandmarks
73
+ /** Weekly-volume status relative to the landmarks. */
74
+ volumeStatus: VolumeStatus
75
+ /** Last trained label, e.g. "2 days ago". */
76
+ lastTrained?: string
77
+ /** Volume sparkline data (last 4-8 weeks). */
78
+ weeklyHistory?: number[]
79
+ /** Exercises contributing to this muscle's volume. */
80
+ contributingExercises?: ContributingExercise[]
81
+ /** Upcoming exercises targeting this muscle. */
82
+ upcomingExercises?: UpcomingExercise[]
83
+ /** Whether the bottom sheet is visible. */
84
+ isOpen?: boolean
85
+ /** Alias for `isOpen` (spec wording: "isOpen/visible"). `isOpen` wins when both are set. */
86
+ visible?: boolean
87
+ /** Dismiss the panel (backdrop, handle, or close button). */
88
+ onClose: () => void
89
+ /** Navigate to a filtered exercise list. */
90
+ onViewExercises?: () => void
91
+ }
92
+
93
+ function clamp01(value: number): number {
94
+ if (value < 0) return 0
95
+ if (value > 1) return 1
96
+ return value
97
+ }
98
+
99
+ /** Marker position (0-1) of current sets between MEV (left) and MRV (right). */
100
+ function markerFraction(weeklySets: number, { mev, mrv }: VolumeLandmarks): number {
101
+ const span = mrv - mev
102
+ if (span <= 0) return weeklySets >= mrv ? 1 : 0
103
+ return clamp01((weeklySets - mev) / span)
104
+ }
105
+
106
+ /**
107
+ * Slide-up bottom-sheet panel with detailed weekly-volume info for a tapped
108
+ * muscle group: a MEV|current|MRV gradient progress bar, the big weekly set
109
+ * count against MRV, an optional volume sparkline, and the contributing /
110
+ * upcoming exercise lists. Composes the titan `Badge` and Workout `Sparkline`.
111
+ *
112
+ * Controlled via `isOpen` (or the `visible` alias); renders nothing when closed
113
+ * and animates in on open — sheet translateY 400->0 (400ms ease-out) and a
114
+ * backdrop fade 0->0.4 (RN Animated, useNativeDriver:false). Backdrop, handle,
115
+ * and the header close button all call `onClose`.
116
+ *
117
+ * @example
118
+ * <BodyMapDetailPanel
119
+ * muscleGroup={MuscleGroup.CHEST}
120
+ * displayName="Chest"
121
+ * weeklySets={14}
122
+ * landmarks={{ mev: 8, mav: 14, mrv: 20 }}
123
+ * volumeStatus="productive"
124
+ * lastTrained="2 days ago"
125
+ * weeklyHistory={[8, 10, 12, 14]}
126
+ * isOpen={open}
127
+ * onClose={() => setOpen(false)}
128
+ * />
129
+ */
130
+ export function BodyMapDetailPanel({
131
+ muscleGroup,
132
+ displayName,
133
+ weeklySets,
134
+ landmarks,
135
+ volumeStatus,
136
+ lastTrained,
137
+ weeklyHistory,
138
+ contributingExercises,
139
+ upcomingExercises,
140
+ isOpen,
141
+ visible,
142
+ onClose,
143
+ onViewExercises,
144
+ ...props
145
+ }: BodyMapDetailPanelProps) {
146
+ const open = isOpen ?? visible ?? false
147
+
148
+ const [translateY] = useState(() => new Animated.Value(SLIDE_OFFSET))
149
+ const [backdrop] = useState(() => new Animated.Value(0))
150
+
151
+ useEffect(() => {
152
+ if (!open) return
153
+ const animation = Animated.parallel([
154
+ Animated.timing(translateY, {
155
+ toValue: 0,
156
+ duration: 400,
157
+ easing: Easing.out(Easing.ease),
158
+ useNativeDriver: false,
159
+ }),
160
+ Animated.timing(backdrop, {
161
+ toValue: BACKDROP_OPACITY,
162
+ duration: 300,
163
+ easing: Easing.out(Easing.ease),
164
+ useNativeDriver: false,
165
+ }),
166
+ ])
167
+ animation.start()
168
+ return () => animation.stop()
169
+ }, [open, translateY, backdrop])
170
+
171
+ if (!open) return null
172
+
173
+ const dialogLabel = `${displayName} volume details`
174
+ const markerLeft = markerFraction(weeklySets, landmarks) * 100
175
+ const hasContributing = (contributingExercises?.length ?? 0) > 0
176
+ const hasUpcoming = (upcomingExercises?.length ?? 0) > 0
177
+
178
+ return (
179
+ <View
180
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'flex-end' }}
181
+ testID="body-map-detail-panel-root"
182
+ >
183
+ <Animated.View
184
+ style={{
185
+ position: 'absolute',
186
+ top: 0,
187
+ left: 0,
188
+ right: 0,
189
+ bottom: 0,
190
+ backgroundColor: '#000000',
191
+ opacity: backdrop,
192
+ }}
193
+ testID="body-map-detail-panel-backdrop-anim"
194
+ >
195
+ <Pressable
196
+ onPress={onClose}
197
+ accessibilityRole="button"
198
+ accessibilityLabel={`Close ${displayName} details`}
199
+ style={{ flex: 1 }}
200
+ testID="body-map-detail-panel-backdrop"
201
+ />
202
+ </Animated.View>
203
+
204
+ <Animated.View
205
+ accessibilityRole={'dialog' as ViewProps['accessibilityRole']}
206
+ accessibilityLabel={dialogLabel}
207
+ aria-label={dialogLabel}
208
+ style={{
209
+ backgroundColor: SURFACE_ELEVATED,
210
+ borderTopLeftRadius: 16,
211
+ borderTopRightRadius: 16,
212
+ maxHeight: '88%',
213
+ transform: [{ translateY }],
214
+ }}
215
+ testID="body-map-detail-panel"
216
+ {...props}
217
+ >
218
+ <Pressable
219
+ onPress={onClose}
220
+ accessibilityRole="button"
221
+ accessibilityLabel={`Close ${displayName} details`}
222
+ hitSlop={12}
223
+ style={{ alignSelf: 'center', paddingVertical: 8 }}
224
+ testID="body-map-detail-panel-handle"
225
+ >
226
+ <View
227
+ style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: BORDER_STRONG }}
228
+ accessibilityElementsHidden
229
+ />
230
+ </Pressable>
231
+
232
+ <ScrollView
233
+ style={{ paddingHorizontal: 16 }}
234
+ contentContainerStyle={{ paddingBottom: 20 }}
235
+ testID="body-map-detail-panel-scroll"
236
+ >
237
+ <View
238
+ className="flex-row items-center justify-between"
239
+ style={{ gap: 8, paddingTop: 4, paddingBottom: 4 }}
240
+ testID="body-map-detail-panel-header"
241
+ >
242
+ <View className="flex-row items-center" style={{ gap: 8, flexShrink: 1 }}>
243
+ <Text
244
+ accessibilityRole="header"
245
+ style={{
246
+ fontSize: 16,
247
+ fontFamily: '"Space Grotesk", sans-serif',
248
+ fontWeight: '700',
249
+ color: TEXT_PRIMARY,
250
+ }}
251
+ testID="body-map-detail-panel-title"
252
+ >
253
+ {displayName}
254
+ </Text>
255
+ <Badge
256
+ variant="subtle"
257
+ color={STATUS_BADGE_COLOR[volumeStatus]}
258
+ size="sm"
259
+ testID="body-map-detail-panel-status-badge"
260
+ >
261
+ {VOLUME_STATUS_LABELS[volumeStatus]}
262
+ </Badge>
263
+ </View>
264
+ <Pressable
265
+ onPress={onClose}
266
+ accessibilityRole="button"
267
+ accessibilityLabel={`Close ${displayName} details`}
268
+ hitSlop={8}
269
+ style={{ padding: 4, margin: -4 }}
270
+ testID="body-map-detail-panel-close"
271
+ >
272
+ <Text style={{ fontSize: 22, lineHeight: 22, color: TEXT_SECONDARY }}>{'×'}</Text>
273
+ </Pressable>
274
+ </View>
275
+
276
+ {lastTrained != null && (
277
+ <Text
278
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
279
+ testID="body-map-detail-panel-last-trained"
280
+ >
281
+ {`Last trained ${lastTrained}`}
282
+ </Text>
283
+ )}
284
+
285
+ <View style={{ marginTop: 16 }} testID="body-map-detail-panel-volume">
286
+ <View
287
+ className="flex-row items-center justify-between"
288
+ style={{ marginBottom: 6 }}
289
+ accessibilityElementsHidden
290
+ >
291
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_TERTIARY }}>
292
+ {`MEV ${landmarks.mev}`}
293
+ </Text>
294
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_TERTIARY }}>
295
+ {`MRV ${landmarks.mrv}`}
296
+ </Text>
297
+ </View>
298
+ <View
299
+ style={{ height: 14, justifyContent: 'center' }}
300
+ accessibilityRole="progressbar"
301
+ accessibilityValue={{ min: landmarks.mev, max: landmarks.mrv, now: weeklySets }}
302
+ accessibilityLabel={`${weeklySets} weekly sets, between MEV ${landmarks.mev} and MRV ${landmarks.mrv}`}
303
+ aria-valuenow={weeklySets}
304
+ aria-valuemin={landmarks.mev}
305
+ aria-valuemax={landmarks.mrv}
306
+ testID="body-map-detail-panel-volume-bar"
307
+ >
308
+ <View
309
+ style={{ height: 8, borderRadius: 4, backgroundImage: VOLUME_GRADIENT } as ViewStyle}
310
+ accessibilityElementsHidden
311
+ />
312
+ <View
313
+ style={{
314
+ position: 'absolute',
315
+ left: `${markerLeft}%`,
316
+ marginLeft: -7,
317
+ width: 14,
318
+ height: 14,
319
+ borderRadius: 7,
320
+ borderWidth: 2,
321
+ borderColor: TEXT_PRIMARY,
322
+ backgroundColor: SURFACE_ELEVATED,
323
+ boxShadow: '0 0 4px rgba(0,0,0,0.5)',
324
+ }}
325
+ accessibilityElementsHidden
326
+ testID="body-map-detail-panel-volume-marker"
327
+ />
328
+ </View>
329
+ </View>
330
+
331
+ <View className="flex-row items-baseline" style={{ marginTop: 14, gap: 4 }}>
332
+ <Text
333
+ style={{
334
+ fontSize: 24,
335
+ fontFamily: '"Space Grotesk", sans-serif',
336
+ fontWeight: '700',
337
+ color: TEXT_PRIMARY,
338
+ }}
339
+ testID="body-map-detail-panel-set-count"
340
+ >
341
+ {weeklySets}
342
+ </Text>
343
+ <Text
344
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
345
+ testID="body-map-detail-panel-mrv-suffix"
346
+ >
347
+ {`/ ${landmarks.mrv} MRV`}
348
+ </Text>
349
+ </View>
350
+
351
+ {weeklyHistory != null && weeklyHistory.length > 0 && (
352
+ <View style={{ marginTop: 14 }} testID="body-map-detail-panel-sparkline">
353
+ <Text
354
+ style={{
355
+ fontSize: 10,
356
+ fontFamily: 'Inter, sans-serif',
357
+ fontWeight: '600',
358
+ color: TEXT_TERTIARY,
359
+ marginBottom: 6,
360
+ }}
361
+ accessibilityElementsHidden
362
+ >
363
+ {'VOLUME TREND'}
364
+ </Text>
365
+ <Sparkline data={weeklyHistory} width={80} height={30} highlightLast />
366
+ </View>
367
+ )}
368
+
369
+ {hasContributing && (
370
+ <View style={{ marginTop: 16 }} testID="body-map-detail-panel-contributing">
371
+ <Text
372
+ style={{
373
+ fontSize: 10,
374
+ fontFamily: 'Inter, sans-serif',
375
+ fontWeight: '600',
376
+ color: TEXT_TERTIARY,
377
+ marginBottom: 8,
378
+ }}
379
+ >
380
+ {'CONTRIBUTING EXERCISES'}
381
+ </Text>
382
+ {contributingExercises!.map((exercise, index) => (
383
+ <View
384
+ key={`${exercise.name}-${index}`}
385
+ className="flex-row items-center justify-between"
386
+ style={{
387
+ gap: 10,
388
+ paddingVertical: 8,
389
+ paddingHorizontal: 12,
390
+ marginBottom: 6,
391
+ borderRadius: 8,
392
+ backgroundColor: SURFACE_RAISED,
393
+ borderWidth: 1,
394
+ borderColor: BORDER_DEFAULT,
395
+ }}
396
+ testID={`body-map-detail-panel-contributing-${index}`}
397
+ >
398
+ <Text
399
+ style={{ flex: 1, fontSize: 13, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_PRIMARY }}
400
+ testID={`body-map-detail-panel-contributing-${index}-name`}
401
+ >
402
+ {exercise.name}
403
+ </Text>
404
+ <Text
405
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
406
+ testID={`body-map-detail-panel-contributing-${index}-detail`}
407
+ >
408
+ {`${exercise.sets} sets · ${Math.round(exercise.contributionWeight * 100)}%`}
409
+ </Text>
410
+ </View>
411
+ ))}
412
+ </View>
413
+ )}
414
+
415
+ {hasUpcoming && (
416
+ <View style={{ marginTop: 16 }} testID="body-map-detail-panel-upcoming">
417
+ <Text
418
+ style={{
419
+ fontSize: 10,
420
+ fontFamily: 'Inter, sans-serif',
421
+ fontWeight: '600',
422
+ color: TEXT_TERTIARY,
423
+ marginBottom: 8,
424
+ }}
425
+ >
426
+ {'UPCOMING'}
427
+ </Text>
428
+ {upcomingExercises!.map((exercise, index) => (
429
+ <View
430
+ key={`${exercise.name}-${index}`}
431
+ className="flex-row items-center justify-between"
432
+ style={{ gap: 10, paddingVertical: 6 }}
433
+ testID={`body-map-detail-panel-upcoming-${index}`}
434
+ >
435
+ <Text
436
+ style={{ flex: 1, fontSize: 13, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_PRIMARY }}
437
+ testID={`body-map-detail-panel-upcoming-${index}-name`}
438
+ >
439
+ {exercise.name}
440
+ </Text>
441
+ <Text
442
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}
443
+ testID={`body-map-detail-panel-upcoming-${index}-detail`}
444
+ >
445
+ {`${exercise.workoutName} · ${exercise.sets} sets`}
446
+ </Text>
447
+ </View>
448
+ ))}
449
+ </View>
450
+ )}
451
+
452
+ {onViewExercises != null && (
453
+ <Pressable
454
+ onPress={onViewExercises}
455
+ accessibilityRole="button"
456
+ accessibilityLabel={`View ${displayName} exercises`}
457
+ style={{
458
+ marginTop: 16,
459
+ paddingVertical: 10,
460
+ borderRadius: 8,
461
+ alignItems: 'center',
462
+ backgroundColor: 'rgba(255,121,0,0.12)',
463
+ borderWidth: 1,
464
+ borderColor: 'rgba(255,121,0,0.3)',
465
+ }}
466
+ testID="body-map-detail-panel-view-exercises"
467
+ >
468
+ <Text style={{ fontSize: 13, fontFamily: 'Inter, sans-serif', fontWeight: '700', color: BRAND_PRIMARY }}>
469
+ {'View exercises'}
470
+ </Text>
471
+ </Pressable>
472
+ )}
473
+ </ScrollView>
474
+ </Animated.View>
475
+ </View>
476
+ )
477
+ }
@@ -0,0 +1,125 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View, Text } from 'react-native'
4
+ import { CapacityBandChart } from './CapacityBandChart'
5
+ import type {
6
+ CapacityBandDataPoint,
7
+ CapacityBandProjection,
8
+ WorkoutDot,
9
+ } from './CapacityBandChart'
10
+
11
+ const band: CapacityBandDataPoint[] = [
12
+ { date: '2026-06-01', bandLow: 40, bandHigh: 70 },
13
+ { date: '2026-06-05', bandLow: 44, bandHigh: 74 },
14
+ { date: '2026-06-09', bandLow: 50, bandHigh: 80 },
15
+ { date: '2026-06-13', bandLow: 54, bandHigh: 86 },
16
+ { date: '2026-06-17', bandLow: 58, bandHigh: 90 },
17
+ { date: '2026-06-21', bandLow: 60, bandHigh: 94 },
18
+ { date: '2026-06-25', bandLow: 62, bandHigh: 96 },
19
+ ]
20
+
21
+ const workouts: WorkoutDot[] = [
22
+ { date: '2026-06-02', load: 58, status: 'within' },
23
+ { date: '2026-06-06', load: 80, status: 'above' },
24
+ { date: '2026-06-10', load: 66, status: 'within' },
25
+ { date: '2026-06-14', load: 48, status: 'below' },
26
+ { date: '2026-06-18', load: 76, status: 'within' },
27
+ { date: '2026-06-23', load: 100, status: 'above' },
28
+ ]
29
+
30
+ const projection: CapacityBandProjection = {
31
+ withTraining: [
32
+ { date: '2026-06-27', bandLow: 64, bandHigh: 99 },
33
+ { date: '2026-06-29', bandLow: 67, bandHigh: 104 },
34
+ ],
35
+ withRest: [
36
+ { date: '2026-06-27', bandLow: 56, bandHigh: 88 },
37
+ { date: '2026-06-29', bandLow: 48, bandHigh: 78 },
38
+ ],
39
+ }
40
+
41
+ const meta: Meta<typeof CapacityBandChart> = {
42
+ title: 'Custom/Workout/CapacityBandChart',
43
+ component: CapacityBandChart,
44
+ tags: ['autodocs'],
45
+ argTypes: {
46
+ width: { control: { type: 'range', min: 240, max: 480, step: 10 }, description: 'Chart width in px' },
47
+ height: { control: { type: 'range', min: 120, max: 280, step: 10 }, description: 'Chart height in px' },
48
+ band: { description: 'Capacity band shape over time' },
49
+ workouts: { description: 'Workout sessions plotted as load dots' },
50
+ projection: { description: 'Optional dashed forward projection (training vs rest)' },
51
+ onWorkoutPress: { description: 'Called when a workout dot is tapped' },
52
+ },
53
+ }
54
+
55
+ export default meta
56
+ type Story = StoryObj<typeof CapacityBandChart>
57
+
58
+ export const Default: Story = {
59
+ args: {
60
+ band,
61
+ workouts,
62
+ projection,
63
+ width: 340,
64
+ height: 200,
65
+ },
66
+ }
67
+
68
+ export const WithoutProjection: Story = {
69
+ args: {
70
+ ...Default.args,
71
+ projection: undefined,
72
+ },
73
+ }
74
+
75
+ export const Compact: Story = {
76
+ args: {
77
+ ...Default.args,
78
+ width: 260,
79
+ height: 140,
80
+ },
81
+ }
82
+
83
+ export const SparseData: Story = {
84
+ args: {
85
+ band: band.slice(0, 3),
86
+ workouts: workouts.slice(0, 2),
87
+ projection: undefined,
88
+ width: 300,
89
+ height: 180,
90
+ },
91
+ }
92
+
93
+ export const Empty: Story = {
94
+ args: {
95
+ band: [],
96
+ workouts: [],
97
+ width: 300,
98
+ height: 180,
99
+ },
100
+ }
101
+
102
+ function InteractiveCapacityBandChart() {
103
+ const [selected, setSelected] = useState<WorkoutDot | null>(null)
104
+ return (
105
+ <View style={{ maxWidth: 380, padding: 16, gap: 10 }}>
106
+ <CapacityBandChart
107
+ band={band}
108
+ workouts={workouts}
109
+ projection={projection}
110
+ width={340}
111
+ height={200}
112
+ onWorkoutPress={setSelected}
113
+ />
114
+ <Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: '#9CA3AF' }}>
115
+ {selected
116
+ ? `Selected ${selected.date}: load ${selected.load} (${selected.status})`
117
+ : 'Tap a workout dot to inspect the session.'}
118
+ </Text>
119
+ </View>
120
+ )
121
+ }
122
+
123
+ export const Interactive: Story = {
124
+ render: () => <InteractiveCapacityBandChart />,
125
+ }