@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,225 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import {
|
|
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: 'Cable Fly', sets: 3, contributionWeight: 0.75 },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const upcoming: UpcomingExercise[] = [
|
|
17
|
+
{ name: 'Dips', workoutName: 'Push B', sets: 3 },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const baseProps = {
|
|
21
|
+
muscleGroup: MuscleGroup.CHEST,
|
|
22
|
+
displayName: 'Chest',
|
|
23
|
+
weeklySets: 14,
|
|
24
|
+
landmarks: { mev: 8, mav: 14, mrv: 20 },
|
|
25
|
+
volumeStatus: 'productive' as const,
|
|
26
|
+
lastTrained: '2 days ago',
|
|
27
|
+
weeklyHistory: [8, 10, 12, 14],
|
|
28
|
+
contributingExercises: contributing,
|
|
29
|
+
upcomingExercises: upcoming,
|
|
30
|
+
isOpen: true,
|
|
31
|
+
onClose: vi.fn(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('BodyMapDetailPanel', () => {
|
|
35
|
+
describe('rendering', () => {
|
|
36
|
+
it('renders the panel with the muscle title when open', () => {
|
|
37
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
38
|
+
expect(screen.getByTestId('body-map-detail-panel')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getByTestId('body-map-detail-panel-title')).toHaveTextContent('Chest')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not render when closed', () => {
|
|
43
|
+
render(<BodyMapDetailPanel {...baseProps} isOpen={false} />)
|
|
44
|
+
expect(screen.queryByTestId('body-map-detail-panel')).not.toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('supports the `visible` alias for visibility', () => {
|
|
48
|
+
const { isOpen: _isOpen, ...rest } = baseProps
|
|
49
|
+
render(<BodyMapDetailPanel {...rest} visible />)
|
|
50
|
+
expect(screen.getByTestId('body-map-detail-panel')).toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders the drag handle, status badge, and last-trained line', () => {
|
|
54
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
55
|
+
expect(screen.getByTestId('body-map-detail-panel-handle')).toBeInTheDocument()
|
|
56
|
+
expect(screen.getByTestId('body-map-detail-panel-status-badge')).toHaveTextContent(
|
|
57
|
+
'productive',
|
|
58
|
+
)
|
|
59
|
+
expect(screen.getByTestId('body-map-detail-panel-last-trained')).toHaveTextContent(
|
|
60
|
+
'Last trained 2 days ago',
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('shows the weekly set count and MRV suffix', () => {
|
|
65
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
66
|
+
expect(screen.getByTestId('body-map-detail-panel-set-count')).toHaveTextContent('14')
|
|
67
|
+
expect(screen.getByTestId('body-map-detail-panel-mrv-suffix')).toHaveTextContent('/ 20 MRV')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('volume bar', () => {
|
|
72
|
+
it('positions the marker at the midpoint when sets sit between MEV and MRV', () => {
|
|
73
|
+
// mev 8, mrv 20, sets 14 -> (14-8)/(20-8) = 0.5
|
|
74
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
75
|
+
const marker = screen.getByTestId('body-map-detail-panel-volume-marker')
|
|
76
|
+
expect(marker.getAttribute('style') ?? '').toContain('left: 50%')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('clamps the marker to the left edge below MEV', () => {
|
|
80
|
+
render(<BodyMapDetailPanel {...baseProps} weeklySets={2} />)
|
|
81
|
+
const marker = screen.getByTestId('body-map-detail-panel-volume-marker')
|
|
82
|
+
expect(marker.getAttribute('style') ?? '').toContain('left: 0%')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('clamps the marker to the right edge at or above MRV', () => {
|
|
86
|
+
render(<BodyMapDetailPanel {...baseProps} weeklySets={24} />)
|
|
87
|
+
const marker = screen.getByTestId('body-map-detail-panel-volume-marker')
|
|
88
|
+
expect(marker.getAttribute('style') ?? '').toContain('left: 100%')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('exposes the volume bar as a progressbar reflecting sets and landmarks', () => {
|
|
92
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
93
|
+
const bar = screen.getByTestId('body-map-detail-panel-volume-bar')
|
|
94
|
+
expect(bar).toHaveAttribute('aria-valuenow', '14')
|
|
95
|
+
expect(bar).toHaveAttribute('aria-valuemin', '8')
|
|
96
|
+
expect(bar).toHaveAttribute('aria-valuemax', '20')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('lists', () => {
|
|
101
|
+
it('renders contributing exercises with sets and contribution', () => {
|
|
102
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
103
|
+
expect(screen.getByTestId('body-map-detail-panel-contributing-0-name')).toHaveTextContent(
|
|
104
|
+
'Barbell Bench Press',
|
|
105
|
+
)
|
|
106
|
+
expect(screen.getByTestId('body-map-detail-panel-contributing-0-detail')).toHaveTextContent(
|
|
107
|
+
'4 sets · 100%',
|
|
108
|
+
)
|
|
109
|
+
expect(screen.getByTestId('body-map-detail-panel-contributing-1-detail')).toHaveTextContent(
|
|
110
|
+
'3 sets · 75%',
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('renders upcoming exercises with workout name and sets', () => {
|
|
115
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
116
|
+
expect(screen.getByTestId('body-map-detail-panel-upcoming-0-name')).toHaveTextContent('Dips')
|
|
117
|
+
expect(screen.getByTestId('body-map-detail-panel-upcoming-0-detail')).toHaveTextContent(
|
|
118
|
+
'Push B · 3 sets',
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('renders the volume sparkline when history is supplied', () => {
|
|
123
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
124
|
+
expect(screen.getByTestId('body-map-detail-panel-sparkline')).toBeInTheDocument()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not crash and omits sections for empty optional arrays', () => {
|
|
128
|
+
render(
|
|
129
|
+
<BodyMapDetailPanel
|
|
130
|
+
{...baseProps}
|
|
131
|
+
contributingExercises={[]}
|
|
132
|
+
upcomingExercises={[]}
|
|
133
|
+
weeklyHistory={[]}
|
|
134
|
+
/>,
|
|
135
|
+
)
|
|
136
|
+
expect(screen.getByTestId('body-map-detail-panel')).toBeInTheDocument()
|
|
137
|
+
expect(screen.queryByTestId('body-map-detail-panel-contributing')).not.toBeInTheDocument()
|
|
138
|
+
expect(screen.queryByTestId('body-map-detail-panel-upcoming')).not.toBeInTheDocument()
|
|
139
|
+
expect(screen.queryByTestId('body-map-detail-panel-sparkline')).not.toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('omits the last-trained line and view-exercises button when not provided', () => {
|
|
143
|
+
const { lastTrained: _lt, ...rest } = baseProps
|
|
144
|
+
render(<BodyMapDetailPanel {...rest} />)
|
|
145
|
+
expect(screen.queryByTestId('body-map-detail-panel-last-trained')).not.toBeInTheDocument()
|
|
146
|
+
expect(screen.queryByTestId('body-map-detail-panel-view-exercises')).not.toBeInTheDocument()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('interaction', () => {
|
|
151
|
+
it('calls onClose when the backdrop is pressed', () => {
|
|
152
|
+
const onClose = vi.fn()
|
|
153
|
+
render(<BodyMapDetailPanel {...baseProps} onClose={onClose} />)
|
|
154
|
+
fireEvent.click(screen.getByTestId('body-map-detail-panel-backdrop'))
|
|
155
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('calls onClose when the handle is pressed', () => {
|
|
159
|
+
const onClose = vi.fn()
|
|
160
|
+
render(<BodyMapDetailPanel {...baseProps} onClose={onClose} />)
|
|
161
|
+
fireEvent.click(screen.getByTestId('body-map-detail-panel-handle'))
|
|
162
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('calls onClose when the close button is pressed', () => {
|
|
166
|
+
const onClose = vi.fn()
|
|
167
|
+
render(<BodyMapDetailPanel {...baseProps} onClose={onClose} />)
|
|
168
|
+
fireEvent.click(screen.getByTestId('body-map-detail-panel-close'))
|
|
169
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('calls onViewExercises when the view button is pressed', () => {
|
|
173
|
+
const onViewExercises = vi.fn()
|
|
174
|
+
render(<BodyMapDetailPanel {...baseProps} onViewExercises={onViewExercises} />)
|
|
175
|
+
fireEvent.click(screen.getByTestId('body-map-detail-panel-view-exercises'))
|
|
176
|
+
expect(onViewExercises).toHaveBeenCalledOnce()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('accessibility', () => {
|
|
181
|
+
it('exposes a dialog role labelled with the muscle', () => {
|
|
182
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
183
|
+
const dialog = screen.getByRole('dialog')
|
|
184
|
+
expect(dialog).toHaveAttribute('aria-label', 'Chest volume details')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('labels the close affordances', () => {
|
|
188
|
+
render(<BodyMapDetailPanel {...baseProps} />)
|
|
189
|
+
expect(screen.getAllByLabelText('Close Chest details').length).toBeGreaterThan(0)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('has no accessibility violations with full content', async () => {
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<BodyMapDetailPanel {...baseProps} onViewExercises={vi.fn()} />,
|
|
195
|
+
)
|
|
196
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('has no accessibility violations for each volume status', async () => {
|
|
200
|
+
const statuses = ['under', 'maintenance', 'productive', 'over'] as const
|
|
201
|
+
for (const volumeStatus of statuses) {
|
|
202
|
+
const { container, unmount } = render(
|
|
203
|
+
<BodyMapDetailPanel {...baseProps} volumeStatus={volumeStatus} />,
|
|
204
|
+
)
|
|
205
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
206
|
+
unmount()
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('has no accessibility violations with only required props', async () => {
|
|
211
|
+
const { container } = render(
|
|
212
|
+
<BodyMapDetailPanel
|
|
213
|
+
muscleGroup={MuscleGroup.CALVES}
|
|
214
|
+
displayName="Calves"
|
|
215
|
+
weeklySets={10}
|
|
216
|
+
landmarks={{ mev: 6, mav: 10, mrv: 16 }}
|
|
217
|
+
volumeStatus="maintenance"
|
|
218
|
+
isOpen
|
|
219
|
+
onClose={vi.fn()}
|
|
220
|
+
/>,
|
|
221
|
+
)
|
|
222
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -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
|
+
}
|