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