@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.
- 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/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -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,348 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
3
|
+
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
import BodyHighlighter, { type ExtendedBodyPart, type Slug } from 'react-native-body-highlighter'
|
|
5
|
+
import { cn } from '../../../utils/cn'
|
|
6
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
7
|
+
import {
|
|
8
|
+
MuscleGroup,
|
|
9
|
+
SimpleMuscleGroup,
|
|
10
|
+
MUSCLE_TO_SVG_SLUGS,
|
|
11
|
+
MUSCLE_DISPLAY_NAMES,
|
|
12
|
+
SIMPLE_DISPLAY_NAMES,
|
|
13
|
+
DETAILED_TO_SIMPLE,
|
|
14
|
+
SLUG_TO_PRIMARY_MUSCLE,
|
|
15
|
+
VOLUME_STATUS_LABELS,
|
|
16
|
+
getHeatmapColor,
|
|
17
|
+
isMoreSevere,
|
|
18
|
+
type VolumeStatus,
|
|
19
|
+
} from './muscleTaxonomy'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* react-native-body-highlighter is published as a CommonJS default export; the
|
|
23
|
+
* interop keeps it working whether the bundler unwraps the default or not.
|
|
24
|
+
*/
|
|
25
|
+
const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter }).default ??
|
|
26
|
+
BodyHighlighter) as typeof BodyHighlighter
|
|
27
|
+
|
|
28
|
+
const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
|
|
29
|
+
const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
|
|
30
|
+
const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
|
|
31
|
+
const TEXT_PRIMARY = '#F3F4F6'
|
|
32
|
+
const TEXT_SECONDARY = '#9CA3AF'
|
|
33
|
+
|
|
34
|
+
export interface BodyMapData {
|
|
35
|
+
muscleGroup: MuscleGroup
|
|
36
|
+
/** Intensity 0-1 for heatmap color. */
|
|
37
|
+
intensity: number
|
|
38
|
+
/** Volume status for tooltip / heatmap token. */
|
|
39
|
+
volumeStatus: VolumeStatus
|
|
40
|
+
/** Weekly effective sets. */
|
|
41
|
+
weeklySets: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BodyMapProps extends ViewProps {
|
|
45
|
+
/** Muscle group intensity data. */
|
|
46
|
+
data: BodyMapData[]
|
|
47
|
+
/** Which view to show. */
|
|
48
|
+
view: 'front' | 'back'
|
|
49
|
+
/** Toggle between front/back. */
|
|
50
|
+
onViewChange?: (view: 'front' | 'back') => void
|
|
51
|
+
/** Called when a muscle group is tapped. */
|
|
52
|
+
onMusclePress?: (muscleGroup: MuscleGroup) => void
|
|
53
|
+
/** Currently highlighted muscle (from external selection). */
|
|
54
|
+
highlightedMuscle?: MuscleGroup | null
|
|
55
|
+
/** Show simplified (8 groups) or detailed (15 groups). */
|
|
56
|
+
mode?: 'simple' | 'detailed'
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LegendEntry {
|
|
61
|
+
key: string
|
|
62
|
+
name: string
|
|
63
|
+
status: VolumeStatus
|
|
64
|
+
intensity: number
|
|
65
|
+
sets: number
|
|
66
|
+
/** Detailed group reported to onMusclePress. */
|
|
67
|
+
muscle: MuscleGroup
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build the per-slug heatmap fill list, combining groups that share a slug. */
|
|
71
|
+
function buildSlugParts(data: BodyMapData[]): ExtendedBodyPart[] {
|
|
72
|
+
const bySlug = new Map<string, { status: VolumeStatus; intensity: number }>()
|
|
73
|
+
for (const d of data) {
|
|
74
|
+
for (const slug of MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []) {
|
|
75
|
+
const existing = bySlug.get(slug)
|
|
76
|
+
if (!existing || isMoreSevere(d.volumeStatus, existing.status)) {
|
|
77
|
+
bySlug.set(slug, { status: d.volumeStatus, intensity: d.intensity })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Array.from(bySlug.entries()).map(([slug, v]) => ({
|
|
82
|
+
slug: slug as Slug,
|
|
83
|
+
color: getHeatmapColor(v.status, v.intensity),
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Collapse data into per-simple-group legend entries (summed sets). */
|
|
88
|
+
function buildSimpleLegend(data: BodyMapData[]): LegendEntry[] {
|
|
89
|
+
const bySimple = new Map<SimpleMuscleGroup, LegendEntry>()
|
|
90
|
+
for (const d of data) {
|
|
91
|
+
const simple = DETAILED_TO_SIMPLE[d.muscleGroup]
|
|
92
|
+
const existing = bySimple.get(simple)
|
|
93
|
+
if (!existing) {
|
|
94
|
+
bySimple.set(simple, {
|
|
95
|
+
key: simple,
|
|
96
|
+
name: SIMPLE_DISPLAY_NAMES[simple],
|
|
97
|
+
status: d.volumeStatus,
|
|
98
|
+
intensity: d.intensity,
|
|
99
|
+
sets: d.weeklySets,
|
|
100
|
+
muscle: d.muscleGroup,
|
|
101
|
+
})
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
existing.sets += d.weeklySets
|
|
105
|
+
existing.intensity = Math.max(existing.intensity, d.intensity)
|
|
106
|
+
if (isMoreSevere(d.volumeStatus, existing.status)) {
|
|
107
|
+
existing.status = d.volumeStatus
|
|
108
|
+
existing.muscle = d.muscleGroup
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return Array.from(bySimple.values())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One legend entry per data row, named by detailed group. */
|
|
115
|
+
function buildDetailedLegend(data: BodyMapData[]): LegendEntry[] {
|
|
116
|
+
return data.map((d) => ({
|
|
117
|
+
key: d.muscleGroup,
|
|
118
|
+
name: MUSCLE_DISPLAY_NAMES[d.muscleGroup],
|
|
119
|
+
status: d.volumeStatus,
|
|
120
|
+
intensity: d.intensity,
|
|
121
|
+
sets: d.weeklySets,
|
|
122
|
+
muscle: d.muscleGroup,
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Interactive SVG body map with tappable muscle groups and a volume heatmap.
|
|
128
|
+
* Renders the front or back view (toggle via onViewChange), colors each muscle
|
|
129
|
+
* by its weekly-volume status, and exposes an accessible muscle legend wired to
|
|
130
|
+
* onMusclePress. Phase 1 uses react-native-body-highlighter's combined slugs
|
|
131
|
+
* (deltoids as one path, lats/upper-back shared); swipe between views and the
|
|
132
|
+
* delt/lat SVG split are follow-ups.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* <BodyMap
|
|
136
|
+
* data={[{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 }]}
|
|
137
|
+
* view="front"
|
|
138
|
+
* onViewChange={setView}
|
|
139
|
+
* onMusclePress={setSelected}
|
|
140
|
+
* highlightedMuscle={selected}
|
|
141
|
+
* />
|
|
142
|
+
*/
|
|
143
|
+
export function BodyMap({
|
|
144
|
+
data,
|
|
145
|
+
view,
|
|
146
|
+
onViewChange,
|
|
147
|
+
onMusclePress,
|
|
148
|
+
highlightedMuscle,
|
|
149
|
+
mode = 'detailed',
|
|
150
|
+
className,
|
|
151
|
+
...props
|
|
152
|
+
}: BodyMapProps) {
|
|
153
|
+
const slugParts = useMemo(() => buildSlugParts(data), [data])
|
|
154
|
+
const legend = useMemo(
|
|
155
|
+
() => (mode === 'simple' ? buildSimpleLegend(data) : buildDetailedLegend(data)),
|
|
156
|
+
[data, mode]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const [highlight] = useState(() => new Animated.Value(highlightedMuscle ? 1 : 0))
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const animation = Animated.timing(highlight, {
|
|
162
|
+
toValue: highlightedMuscle ? 1 : 0,
|
|
163
|
+
duration: 250,
|
|
164
|
+
easing: Easing.out(Easing.ease),
|
|
165
|
+
useNativeDriver: false,
|
|
166
|
+
})
|
|
167
|
+
animation.start()
|
|
168
|
+
return () => animation.stop()
|
|
169
|
+
}, [highlightedMuscle, highlight])
|
|
170
|
+
|
|
171
|
+
const scale = highlight.interpolate({ inputRange: [0, 1], outputRange: [1, 1.02] })
|
|
172
|
+
const glowColor = highlightedMuscle
|
|
173
|
+
? getHeatmapColor(
|
|
174
|
+
data.find((d) => d.muscleGroup === highlightedMuscle)?.volumeStatus,
|
|
175
|
+
data.find((d) => d.muscleGroup === highlightedMuscle)?.intensity ?? 0
|
|
176
|
+
)
|
|
177
|
+
: null
|
|
178
|
+
|
|
179
|
+
const handleBodyPress = (part: ExtendedBodyPart) => {
|
|
180
|
+
if (!onMusclePress || !part.slug) return
|
|
181
|
+
const fromData = data.find((d) =>
|
|
182
|
+
(MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []).includes(part.slug as string)
|
|
183
|
+
)
|
|
184
|
+
const muscle = fromData?.muscleGroup ?? SLUG_TO_PRIMARY_MUSCLE[part.slug]
|
|
185
|
+
if (muscle) onMusclePress(muscle)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<View className={cn(className)} testID="body-map" {...props}>
|
|
190
|
+
<ViewToggle view={view} onViewChange={onViewChange} />
|
|
191
|
+
|
|
192
|
+
<Animated.View
|
|
193
|
+
style={[
|
|
194
|
+
{
|
|
195
|
+
width: 200 * BODY_SCALE,
|
|
196
|
+
alignSelf: 'center',
|
|
197
|
+
transform: [{ scale }],
|
|
198
|
+
},
|
|
199
|
+
glowColor ? { boxShadow: `0 0 14px 2px ${glowColor}` } : undefined,
|
|
200
|
+
]}
|
|
201
|
+
// The SVG is decorative; the accessible muscle legend below is the
|
|
202
|
+
// screen-reader / keyboard interface.
|
|
203
|
+
aria-hidden
|
|
204
|
+
accessibilityElementsHidden
|
|
205
|
+
importantForAccessibility="no-hide-descendants"
|
|
206
|
+
testID="body-map-svg"
|
|
207
|
+
>
|
|
208
|
+
<Body
|
|
209
|
+
side={view}
|
|
210
|
+
data={slugParts}
|
|
211
|
+
scale={BODY_SCALE}
|
|
212
|
+
gender="male"
|
|
213
|
+
defaultFill={OUTLINE_FILL}
|
|
214
|
+
border={OUTLINE_BORDER}
|
|
215
|
+
onBodyPartPress={handleBodyPress}
|
|
216
|
+
/>
|
|
217
|
+
</Animated.View>
|
|
218
|
+
|
|
219
|
+
{legend.length === 0 ? (
|
|
220
|
+
<Text
|
|
221
|
+
style={{
|
|
222
|
+
marginTop: 8,
|
|
223
|
+
fontSize: 12,
|
|
224
|
+
color: TEXT_SECONDARY,
|
|
225
|
+
textAlign: 'center',
|
|
226
|
+
fontFamily: 'Inter, sans-serif',
|
|
227
|
+
}}
|
|
228
|
+
testID="body-map-empty"
|
|
229
|
+
>
|
|
230
|
+
No training data
|
|
231
|
+
</Text>
|
|
232
|
+
) : (
|
|
233
|
+
<View
|
|
234
|
+
className="flex-row flex-wrap"
|
|
235
|
+
style={{ marginTop: 10, gap: 6, justifyContent: 'center' }}
|
|
236
|
+
testID="body-map-legend"
|
|
237
|
+
>
|
|
238
|
+
{legend.map((entry) => (
|
|
239
|
+
<MuscleButton
|
|
240
|
+
key={entry.key}
|
|
241
|
+
entry={entry}
|
|
242
|
+
highlighted={highlightedMuscle === entry.muscle}
|
|
243
|
+
onMusclePress={onMusclePress}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</View>
|
|
247
|
+
)}
|
|
248
|
+
</View>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface ViewToggleProps {
|
|
253
|
+
view: 'front' | 'back'
|
|
254
|
+
onViewChange?: (view: 'front' | 'back') => void
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ViewToggle({ view, onViewChange }: ViewToggleProps) {
|
|
258
|
+
return (
|
|
259
|
+
<View
|
|
260
|
+
className="flex-row self-center"
|
|
261
|
+
style={{ gap: 4, marginBottom: 8 }}
|
|
262
|
+
testID="body-map-view-toggle"
|
|
263
|
+
>
|
|
264
|
+
{(['front', 'back'] as const).map((side) => {
|
|
265
|
+
const active = view === side
|
|
266
|
+
return (
|
|
267
|
+
<Pressable
|
|
268
|
+
key={side}
|
|
269
|
+
onPress={() => onViewChange?.(side)}
|
|
270
|
+
accessibilityRole="button"
|
|
271
|
+
accessibilityLabel={`Show ${side} view`}
|
|
272
|
+
aria-pressed={active}
|
|
273
|
+
disabled={!onViewChange}
|
|
274
|
+
style={{
|
|
275
|
+
paddingHorizontal: 12,
|
|
276
|
+
paddingVertical: 4,
|
|
277
|
+
borderRadius: 9999,
|
|
278
|
+
backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
|
|
279
|
+
borderWidth: 1,
|
|
280
|
+
borderColor: active ? '#FF7900' : WORKOUT_TOKENS.border.strong,
|
|
281
|
+
}}
|
|
282
|
+
testID={`body-map-toggle-${side}`}
|
|
283
|
+
>
|
|
284
|
+
<Text
|
|
285
|
+
style={{
|
|
286
|
+
fontSize: 12,
|
|
287
|
+
fontFamily: 'Inter, sans-serif',
|
|
288
|
+
fontWeight: active ? '700' : '500',
|
|
289
|
+
color: active ? '#FF7900' : TEXT_SECONDARY,
|
|
290
|
+
textTransform: 'capitalize',
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{side}
|
|
294
|
+
</Text>
|
|
295
|
+
</Pressable>
|
|
296
|
+
)
|
|
297
|
+
})}
|
|
298
|
+
</View>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface MuscleButtonProps {
|
|
303
|
+
entry: LegendEntry
|
|
304
|
+
highlighted: boolean
|
|
305
|
+
onMusclePress?: (muscleGroup: MuscleGroup) => void
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps) {
|
|
309
|
+
const dotColor = getHeatmapColor(entry.status, entry.intensity)
|
|
310
|
+
const label = `${entry.name}, ${VOLUME_STATUS_LABELS[entry.status]}, ${entry.sets} sets this week`
|
|
311
|
+
return (
|
|
312
|
+
<Pressable
|
|
313
|
+
onPress={() => onMusclePress?.(entry.muscle)}
|
|
314
|
+
accessibilityRole="button"
|
|
315
|
+
accessibilityLabel={label}
|
|
316
|
+
aria-pressed={highlighted}
|
|
317
|
+
style={{
|
|
318
|
+
flexDirection: 'row',
|
|
319
|
+
alignItems: 'center',
|
|
320
|
+
gap: 5,
|
|
321
|
+
paddingHorizontal: 8,
|
|
322
|
+
paddingVertical: 3,
|
|
323
|
+
borderRadius: 9999,
|
|
324
|
+
borderWidth: 1,
|
|
325
|
+
borderColor: highlighted ? '#FF7900' : WORKOUT_TOKENS.border.default,
|
|
326
|
+
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
327
|
+
}}
|
|
328
|
+
testID={`body-map-muscle-${entry.key}`}
|
|
329
|
+
>
|
|
330
|
+
<View
|
|
331
|
+
style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: dotColor }}
|
|
332
|
+
accessibilityElementsHidden
|
|
333
|
+
testID={`body-map-muscle-dot-${entry.key}`}
|
|
334
|
+
/>
|
|
335
|
+
<Text
|
|
336
|
+
style={{
|
|
337
|
+
fontSize: 11,
|
|
338
|
+
fontFamily: 'Inter, sans-serif',
|
|
339
|
+
fontWeight: '500',
|
|
340
|
+
color: TEXT_PRIMARY,
|
|
341
|
+
}}
|
|
342
|
+
accessibilityElementsHidden
|
|
343
|
+
>
|
|
344
|
+
{entry.name}
|
|
345
|
+
</Text>
|
|
346
|
+
</Pressable>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { View, Text, Pressable } from 'react-native'
|
|
4
|
+
import {
|
|
5
|
+
BodyMapDetailPanel,
|
|
6
|
+
type ContributingExercise,
|
|
7
|
+
type UpcomingExercise,
|
|
8
|
+
} from './BodyMapDetailPanel'
|
|
9
|
+
import { MuscleGroup } from './muscleTaxonomy'
|
|
10
|
+
|
|
11
|
+
const contributing: ContributingExercise[] = [
|
|
12
|
+
{ name: 'Barbell Bench Press', sets: 4, contributionWeight: 1 },
|
|
13
|
+
{ name: 'Incline Dumbbell Press', sets: 3, contributionWeight: 1 },
|
|
14
|
+
{ name: 'Cable Fly', sets: 3, contributionWeight: 0.75 },
|
|
15
|
+
{ name: 'Overhead Press', sets: 2, contributionWeight: 0.33 },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const upcoming: UpcomingExercise[] = [
|
|
19
|
+
{ name: 'Dips', workoutName: 'Push B', sets: 3 },
|
|
20
|
+
{ name: 'Machine Chest Press', workoutName: 'Push B', sets: 3 },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const meta: Meta<typeof BodyMapDetailPanel> = {
|
|
24
|
+
title: 'Custom/Workout/BodyMapDetailPanel',
|
|
25
|
+
component: BodyMapDetailPanel,
|
|
26
|
+
tags: ['autodocs'],
|
|
27
|
+
decorators: [
|
|
28
|
+
(Story) => (
|
|
29
|
+
<View style={{ position: 'relative', height: 640, width: 380, backgroundColor: '#0E0E0E' }}>
|
|
30
|
+
<Story />
|
|
31
|
+
</View>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
argTypes: {
|
|
35
|
+
displayName: { control: 'text', description: 'Human-readable muscle name' },
|
|
36
|
+
weeklySets: { control: 'number', description: 'Weekly effective sets logged' },
|
|
37
|
+
volumeStatus: {
|
|
38
|
+
control: 'select',
|
|
39
|
+
options: ['under', 'maintenance', 'productive', 'over'],
|
|
40
|
+
description: 'Volume status relative to landmarks',
|
|
41
|
+
},
|
|
42
|
+
isOpen: { control: 'boolean', description: 'Whether the bottom sheet is visible' },
|
|
43
|
+
lastTrained: { control: 'text', description: 'Last trained label' },
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default meta
|
|
48
|
+
type Story = StoryObj<typeof BodyMapDetailPanel>
|
|
49
|
+
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
muscleGroup: MuscleGroup.CHEST,
|
|
53
|
+
displayName: 'Chest',
|
|
54
|
+
weeklySets: 14,
|
|
55
|
+
landmarks: { mev: 8, mav: 14, mrv: 20 },
|
|
56
|
+
volumeStatus: 'productive',
|
|
57
|
+
lastTrained: '2 days ago',
|
|
58
|
+
weeklyHistory: [8, 10, 12, 11, 13, 14],
|
|
59
|
+
contributingExercises: contributing,
|
|
60
|
+
upcomingExercises: upcoming,
|
|
61
|
+
isOpen: true,
|
|
62
|
+
onClose: () => {},
|
|
63
|
+
onViewExercises: () => {},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const UnderTrained: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
...Default.args,
|
|
70
|
+
displayName: 'Rear Delts',
|
|
71
|
+
muscleGroup: MuscleGroup.REAR_DELTS,
|
|
72
|
+
weeklySets: 4,
|
|
73
|
+
landmarks: { mev: 6, mav: 12, mrv: 18 },
|
|
74
|
+
volumeStatus: 'under',
|
|
75
|
+
weeklyHistory: [2, 3, 3, 4],
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Maintenance: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
...Default.args,
|
|
82
|
+
displayName: 'Biceps',
|
|
83
|
+
muscleGroup: MuscleGroup.BICEPS,
|
|
84
|
+
weeklySets: 8,
|
|
85
|
+
landmarks: { mev: 4, mav: 10, mrv: 18 },
|
|
86
|
+
volumeStatus: 'maintenance',
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const Productive: Story = {
|
|
91
|
+
args: { ...Default.args },
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const OverReaching: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
...Default.args,
|
|
97
|
+
displayName: 'Quads',
|
|
98
|
+
muscleGroup: MuscleGroup.QUADS,
|
|
99
|
+
weeklySets: 20,
|
|
100
|
+
landmarks: { mev: 6, mav: 12, mrv: 18 },
|
|
101
|
+
volumeStatus: 'over',
|
|
102
|
+
weeklyHistory: [12, 14, 16, 18, 20],
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const Minimal: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
muscleGroup: MuscleGroup.CALVES,
|
|
109
|
+
displayName: 'Calves',
|
|
110
|
+
weeklySets: 10,
|
|
111
|
+
landmarks: { mev: 6, mav: 10, mrv: 16 },
|
|
112
|
+
volumeStatus: 'maintenance',
|
|
113
|
+
isOpen: true,
|
|
114
|
+
onClose: () => {},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const WithoutHistory: Story = {
|
|
119
|
+
args: {
|
|
120
|
+
...Default.args,
|
|
121
|
+
weeklyHistory: undefined,
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const WithoutUpcoming: Story = {
|
|
126
|
+
args: {
|
|
127
|
+
...Default.args,
|
|
128
|
+
upcomingExercises: [],
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function InteractiveBodyMapDetailPanel() {
|
|
133
|
+
const [open, setOpen] = useState(false)
|
|
134
|
+
return (
|
|
135
|
+
<View style={{ position: 'relative', height: 640, width: 380, backgroundColor: '#0E0E0E', padding: 16 }}>
|
|
136
|
+
<Pressable
|
|
137
|
+
onPress={() => setOpen(true)}
|
|
138
|
+
accessibilityRole="button"
|
|
139
|
+
style={{
|
|
140
|
+
alignSelf: 'flex-start',
|
|
141
|
+
backgroundColor: '#FF7900',
|
|
142
|
+
paddingVertical: 10,
|
|
143
|
+
paddingHorizontal: 20,
|
|
144
|
+
borderRadius: 8,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontFamily: 'Inter, sans-serif' }}>
|
|
148
|
+
Tap Chest
|
|
149
|
+
</Text>
|
|
150
|
+
</Pressable>
|
|
151
|
+
<BodyMapDetailPanel
|
|
152
|
+
muscleGroup={MuscleGroup.CHEST}
|
|
153
|
+
displayName="Chest"
|
|
154
|
+
weeklySets={14}
|
|
155
|
+
landmarks={{ mev: 8, mav: 14, mrv: 20 }}
|
|
156
|
+
volumeStatus="productive"
|
|
157
|
+
lastTrained="2 days ago"
|
|
158
|
+
weeklyHistory={[8, 10, 12, 11, 13, 14]}
|
|
159
|
+
contributingExercises={contributing}
|
|
160
|
+
upcomingExercises={upcoming}
|
|
161
|
+
isOpen={open}
|
|
162
|
+
onClose={() => setOpen(false)}
|
|
163
|
+
onViewExercises={() => {}}
|
|
164
|
+
/>
|
|
165
|
+
</View>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const Interactive: Story = {
|
|
170
|
+
render: () => <InteractiveBodyMapDetailPanel />,
|
|
171
|
+
}
|