@titan-design/react-ui 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -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 +25 -5
- package/src/bodymap.ts +37 -0
- 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/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- 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/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -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 +347 -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 +477 -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 +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -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 +162 -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 +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -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 +90 -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/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -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 +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- 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 +247 -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 +160 -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 +209 -0
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -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 +228 -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 +152 -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/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- 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/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
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 { 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
|
+
]
|
|
17
|
+
|
|
18
|
+
const workouts: WorkoutDot[] = [
|
|
19
|
+
{ date: '2026-06-02', load: 58, status: 'within' },
|
|
20
|
+
{ date: '2026-06-06', load: 80, status: 'above' },
|
|
21
|
+
{ date: '2026-06-10', load: 48, status: 'below' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const projection: CapacityBandProjection = {
|
|
25
|
+
withTraining: [
|
|
26
|
+
{ date: '2026-06-15', bandLow: 56, bandHigh: 90 },
|
|
27
|
+
{ date: '2026-06-17', bandLow: 60, bandHigh: 96 },
|
|
28
|
+
],
|
|
29
|
+
withRest: [
|
|
30
|
+
{ date: '2026-06-15', bandLow: 50, bandHigh: 80 },
|
|
31
|
+
{ date: '2026-06-17', bandLow: 44, bandHigh: 70 },
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const baseProps = { band, workouts, width: 320, height: 200 }
|
|
36
|
+
|
|
37
|
+
describe('CapacityBandChart', () => {
|
|
38
|
+
describe('rendering', () => {
|
|
39
|
+
it('renders the chart container', () => {
|
|
40
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
41
|
+
expect(screen.getByTestId('capacity-band-chart')).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders the shaded band fill as column cells', () => {
|
|
45
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
46
|
+
expect(screen.getByTestId('capacity-band-chart-band')).toBeInTheDocument()
|
|
47
|
+
expect(
|
|
48
|
+
screen.getAllByTestId('capacity-band-chart-band-cell').length,
|
|
49
|
+
).toBeGreaterThan(0)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders the top and bottom band edge lines', () => {
|
|
53
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
54
|
+
expect(
|
|
55
|
+
screen.getAllByTestId('capacity-band-chart-edge-top').length,
|
|
56
|
+
).toBe(band.length - 1)
|
|
57
|
+
expect(
|
|
58
|
+
screen.getAllByTestId('capacity-band-chart-edge-bottom').length,
|
|
59
|
+
).toBe(band.length - 1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders a conceptual Load axis label with no numbers', () => {
|
|
63
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
64
|
+
expect(
|
|
65
|
+
screen.getByTestId('capacity-band-chart-y-axis-label'),
|
|
66
|
+
).toHaveTextContent('Load')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders x-axis date labels', () => {
|
|
70
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
71
|
+
expect(
|
|
72
|
+
screen.getAllByTestId('capacity-band-chart-x-label').length,
|
|
73
|
+
).toBeGreaterThan(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('renders the empty state when band has no data', () => {
|
|
77
|
+
render(<CapacityBandChart band={[]} workouts={[]} width={320} height={200} />)
|
|
78
|
+
expect(screen.getByTestId('capacity-band-chart-empty')).toBeInTheDocument()
|
|
79
|
+
expect(
|
|
80
|
+
screen.queryByTestId('capacity-band-chart-band'),
|
|
81
|
+
).not.toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('workout dots', () => {
|
|
86
|
+
it('renders one dot per workout', () => {
|
|
87
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
88
|
+
expect(screen.getAllByTestId('capacity-band-chart-dot')).toHaveLength(
|
|
89
|
+
workouts.length,
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('colors dots by status relative to the band', () => {
|
|
94
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
95
|
+
const dots = screen.getAllByTestId('capacity-band-chart-dot')
|
|
96
|
+
expect(dots[0]).toHaveStyle({ backgroundColor: '#14B8A6' }) // within
|
|
97
|
+
expect(dots[1]).toHaveStyle({ backgroundColor: '#FFB020' }) // above
|
|
98
|
+
expect(dots[2]).toHaveStyle({ backgroundColor: '#2196F3' }) // below
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('renders dots at the spec 8px size with a visible outline', () => {
|
|
102
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
103
|
+
const dots = screen.getAllByTestId('capacity-band-chart-dot')
|
|
104
|
+
expect(dots[0]).toHaveStyle({
|
|
105
|
+
width: '8px',
|
|
106
|
+
height: '8px',
|
|
107
|
+
borderTopColor: '#F3F4F6',
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not render dots as buttons without onWorkoutPress', () => {
|
|
112
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
113
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('interaction', () => {
|
|
118
|
+
it('fires onWorkoutPress with the tapped workout', () => {
|
|
119
|
+
const onWorkoutPress = vi.fn()
|
|
120
|
+
render(<CapacityBandChart {...baseProps} onWorkoutPress={onWorkoutPress} />)
|
|
121
|
+
const dots = screen.getAllByTestId('capacity-band-chart-dot')
|
|
122
|
+
fireEvent.click(dots[1])
|
|
123
|
+
expect(onWorkoutPress).toHaveBeenCalledWith(workouts[1])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('exposes dots as labeled buttons when interactive', () => {
|
|
127
|
+
render(<CapacityBandChart {...baseProps} onWorkoutPress={vi.fn()} />)
|
|
128
|
+
expect(
|
|
129
|
+
screen.getByLabelText('Workout on 6/6, load 80, above range'),
|
|
130
|
+
).toHaveAttribute('role', 'button')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('projection', () => {
|
|
135
|
+
it('renders dashed diverging training and rest edges when provided', () => {
|
|
136
|
+
render(<CapacityBandChart {...baseProps} projection={projection} />)
|
|
137
|
+
expect(
|
|
138
|
+
screen.getByTestId('capacity-band-chart-projection'),
|
|
139
|
+
).toBeInTheDocument()
|
|
140
|
+
expect(
|
|
141
|
+
screen.getAllByTestId('capacity-band-chart-projection-training').length,
|
|
142
|
+
).toBeGreaterThan(0)
|
|
143
|
+
expect(
|
|
144
|
+
screen.getAllByTestId('capacity-band-chart-projection-rest').length,
|
|
145
|
+
).toBeGreaterThan(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('labels the training and rest projections', () => {
|
|
149
|
+
render(<CapacityBandChart {...baseProps} projection={projection} />)
|
|
150
|
+
expect(
|
|
151
|
+
screen.getByTestId('capacity-band-chart-projection-training-label'),
|
|
152
|
+
).toHaveTextContent('Training')
|
|
153
|
+
expect(
|
|
154
|
+
screen.getByTestId('capacity-band-chart-projection-rest-label'),
|
|
155
|
+
).toHaveTextContent('Rest')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('omits the projection layer when not provided', () => {
|
|
159
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
160
|
+
expect(
|
|
161
|
+
screen.queryByTestId('capacity-band-chart-projection'),
|
|
162
|
+
).not.toBeInTheDocument()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('accessibility', () => {
|
|
167
|
+
it('exposes an image role with a descriptive label', () => {
|
|
168
|
+
render(<CapacityBandChart {...baseProps} />)
|
|
169
|
+
const chart = screen.getByTestId('capacity-band-chart')
|
|
170
|
+
expect(chart).toHaveAttribute('role', 'img')
|
|
171
|
+
expect(chart).toHaveAttribute(
|
|
172
|
+
'aria-label',
|
|
173
|
+
'Training capacity band chart. Current capacity: below range. 3 workouts shown.',
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('has no accessibility violations', async () => {
|
|
178
|
+
const { container } = render(<CapacityBandChart {...baseProps} />)
|
|
179
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('has no accessibility violations with all features enabled', async () => {
|
|
183
|
+
const { container } = render(
|
|
184
|
+
<CapacityBandChart
|
|
185
|
+
{...baseProps}
|
|
186
|
+
projection={projection}
|
|
187
|
+
onWorkoutPress={vi.fn()}
|
|
188
|
+
/>,
|
|
189
|
+
)
|
|
190
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
3
|
+
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
|
|
5
|
+
const STATUS_SUCCESS = '#14B8A6'
|
|
6
|
+
const STATUS_WARNING = '#FFB020'
|
|
7
|
+
const STATUS_INFO = '#2196F3'
|
|
8
|
+
/** Dot outline: near-white ring so load dots read on the band fill and any
|
|
9
|
+
* surface (matches the MesoStatusCard gauge-marker convention). */
|
|
10
|
+
const DOT_BORDER = '#F3F4F6'
|
|
11
|
+
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
12
|
+
const BAND_FILL = 'rgba(20,184,166,0.1)'
|
|
13
|
+
const BAND_EDGE = 'rgba(20,184,166,0.45)'
|
|
14
|
+
const PROJECTION_FILL = 'rgba(20,184,166,0.05)'
|
|
15
|
+
|
|
16
|
+
const PADDING_LEFT = 28
|
|
17
|
+
const PADDING_RIGHT = 10
|
|
18
|
+
const PADDING_TOP = 8
|
|
19
|
+
const PADDING_BOTTOM = 16
|
|
20
|
+
const COLUMN_STEP = 4
|
|
21
|
+
const DOT_SIZE = 8
|
|
22
|
+
|
|
23
|
+
export type WorkoutDotStatus = 'within' | 'above' | 'below'
|
|
24
|
+
|
|
25
|
+
export interface CapacityBandDataPoint {
|
|
26
|
+
/** ISO-ish date string (e.g. "2026-06-01"). */
|
|
27
|
+
date: string
|
|
28
|
+
/** Lower bound of the capacity band (MEV equivalent). */
|
|
29
|
+
bandLow: number
|
|
30
|
+
/** Upper bound of the capacity band (MRV equivalent). */
|
|
31
|
+
bandHigh: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkoutDot {
|
|
35
|
+
date: string
|
|
36
|
+
/** Actual session load score. */
|
|
37
|
+
load: number
|
|
38
|
+
/** Position of the session relative to the band. */
|
|
39
|
+
status: WorkoutDotStatus
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CapacityBandProjection {
|
|
43
|
+
/** Band shape over the next days if training continues (rises). */
|
|
44
|
+
withTraining: CapacityBandDataPoint[]
|
|
45
|
+
/** Band shape over the next days if resting (drops). */
|
|
46
|
+
withRest: CapacityBandDataPoint[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CapacityBandChartProps extends ViewProps {
|
|
50
|
+
/** Capacity band shape over time. */
|
|
51
|
+
band: CapacityBandDataPoint[]
|
|
52
|
+
/** Workout sessions plotted as dots. */
|
|
53
|
+
workouts: WorkoutDot[]
|
|
54
|
+
/** Optional forward projection (next few days). */
|
|
55
|
+
projection?: CapacityBandProjection
|
|
56
|
+
/** Chart width in px. */
|
|
57
|
+
width: number
|
|
58
|
+
/** Chart height in px. */
|
|
59
|
+
height: number
|
|
60
|
+
/** Called when a workout dot is tapped. */
|
|
61
|
+
onWorkoutPress?: (workout: WorkoutDot) => void
|
|
62
|
+
className?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PixelPoint {
|
|
66
|
+
x: number
|
|
67
|
+
yHigh: number
|
|
68
|
+
yLow: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DOT_COLORS: Record<WorkoutDotStatus, string> = {
|
|
72
|
+
within: STATUS_SUCCESS,
|
|
73
|
+
above: STATUS_WARNING,
|
|
74
|
+
below: STATUS_INFO,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const STATUS_PHRASES: Record<WorkoutDotStatus, string> = {
|
|
78
|
+
within: 'within range',
|
|
79
|
+
above: 'above range',
|
|
80
|
+
below: 'below range',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseTime(date: string): number {
|
|
84
|
+
const parts = date.split('-')
|
|
85
|
+
if (parts.length === 3) {
|
|
86
|
+
return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))
|
|
87
|
+
}
|
|
88
|
+
const parsed = Date.parse(date)
|
|
89
|
+
return Number.isNaN(parsed) ? 0 : parsed
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatDate(date: string): string {
|
|
93
|
+
const parts = date.split('-')
|
|
94
|
+
if (parts.length === 3) return `${Number(parts[1])}/${Number(parts[2])}`
|
|
95
|
+
const d = new Date(date)
|
|
96
|
+
return Number.isNaN(d.getTime()) ? date : `${d.getMonth() + 1}/${d.getDate()}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toPixels(
|
|
100
|
+
points: CapacityBandDataPoint[],
|
|
101
|
+
toX: (date: string) => number,
|
|
102
|
+
toY: (value: number) => number,
|
|
103
|
+
): PixelPoint[] {
|
|
104
|
+
return points
|
|
105
|
+
.map((p) => ({ x: toX(p.date), yHigh: toY(p.bandHigh), yLow: toY(p.bandLow) }))
|
|
106
|
+
.sort((a, b) => a.x - b.x)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function interpEdge(pixels: PixelPoint[], x: number, key: 'yHigh' | 'yLow'): number {
|
|
110
|
+
for (let i = 0; i < pixels.length - 1; i++) {
|
|
111
|
+
const a = pixels[i]
|
|
112
|
+
const b = pixels[i + 1]
|
|
113
|
+
if (x >= a.x && x <= b.x) {
|
|
114
|
+
const t = b.x === a.x ? 0 : (x - a.x) / (b.x - a.x)
|
|
115
|
+
return a[key] + t * (b[key] - a[key])
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return pixels[pixels.length - 1][key]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildColumns(pixels: PixelPoint[], step: number) {
|
|
122
|
+
if (pixels.length < 2) return []
|
|
123
|
+
const start = pixels[0].x
|
|
124
|
+
const end = pixels[pixels.length - 1].x
|
|
125
|
+
const columns: Array<{ x: number; top: number; height: number }> = []
|
|
126
|
+
for (let x = start; x < end; x += step) {
|
|
127
|
+
const top = interpEdge(pixels, x, 'yHigh')
|
|
128
|
+
const bottom = interpEdge(pixels, x, 'yLow')
|
|
129
|
+
columns.push({ x, top, height: Math.max(0, bottom - top) })
|
|
130
|
+
}
|
|
131
|
+
return columns
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildEdges(pixels: PixelPoint[], key: 'yHigh' | 'yLow') {
|
|
135
|
+
const segments: Array<{ left: number; top: number; length: number; angle: number }> = []
|
|
136
|
+
for (let i = 1; i < pixels.length; i++) {
|
|
137
|
+
const a = pixels[i - 1]
|
|
138
|
+
const b = pixels[i]
|
|
139
|
+
const dx = b.x - a.x
|
|
140
|
+
const dy = b[key] - a[key]
|
|
141
|
+
segments.push({
|
|
142
|
+
left: a.x,
|
|
143
|
+
top: a[key],
|
|
144
|
+
length: Math.sqrt(dx * dx + dy * dy),
|
|
145
|
+
angle: Math.atan2(dy, dx) * (180 / Math.PI),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
return segments
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function collectValues(
|
|
152
|
+
band: CapacityBandDataPoint[],
|
|
153
|
+
workouts: WorkoutDot[],
|
|
154
|
+
projection?: CapacityBandProjection,
|
|
155
|
+
): number[] {
|
|
156
|
+
const values: number[] = []
|
|
157
|
+
band.forEach((p) => values.push(p.bandLow, p.bandHigh))
|
|
158
|
+
workouts.forEach((w) => values.push(w.load))
|
|
159
|
+
projection?.withTraining.forEach((p) => values.push(p.bandLow, p.bandHigh))
|
|
160
|
+
projection?.withRest.forEach((p) => values.push(p.bandLow, p.bandHigh))
|
|
161
|
+
return values
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function currentStatus(workouts: WorkoutDot[]): string {
|
|
165
|
+
if (workouts.length === 0) return 'no recent sessions'
|
|
166
|
+
const latest = [...workouts].sort((a, b) => parseTime(b.date) - parseTime(a.date))[0]
|
|
167
|
+
return STATUS_PHRASES[latest.status]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Gentler-Streak-inspired fatigue visualization. Renders a shaded capacity
|
|
172
|
+
* band (between a rolling MEV/MRV estimate), workout sessions as colored dots
|
|
173
|
+
* positioned by load, and an optional dashed forward projection that diverges
|
|
174
|
+
* for "keep training" (rises) versus "rest" (drops). View-based, no SVG:
|
|
175
|
+
* the band fill is a run of vertical columns and the edges/projection are
|
|
176
|
+
* rotated line Views (same technique as Sparkline).
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* <CapacityBandChart
|
|
180
|
+
* band={band}
|
|
181
|
+
* workouts={workouts}
|
|
182
|
+
* projection={{ withTraining, withRest }}
|
|
183
|
+
* width={320}
|
|
184
|
+
* height={180}
|
|
185
|
+
* onWorkoutPress={(w) => openSession(w)}
|
|
186
|
+
* />
|
|
187
|
+
*/
|
|
188
|
+
export function CapacityBandChart({
|
|
189
|
+
band,
|
|
190
|
+
workouts,
|
|
191
|
+
projection,
|
|
192
|
+
width,
|
|
193
|
+
height,
|
|
194
|
+
onWorkoutPress,
|
|
195
|
+
className,
|
|
196
|
+
...props
|
|
197
|
+
}: CapacityBandChartProps) {
|
|
198
|
+
const [reveal] = useState(() => new Animated.Value(0))
|
|
199
|
+
const dotAnims = useMemo(
|
|
200
|
+
() => Array.from({ length: workouts.length }, () => new Animated.Value(0)),
|
|
201
|
+
[workouts.length],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
reveal.setValue(0)
|
|
206
|
+
dotAnims.forEach((a) => a.setValue(0))
|
|
207
|
+
const draw = Animated.timing(reveal, {
|
|
208
|
+
toValue: 1,
|
|
209
|
+
duration: 800,
|
|
210
|
+
easing: Easing.out(Easing.ease),
|
|
211
|
+
useNativeDriver: false,
|
|
212
|
+
})
|
|
213
|
+
const pops = dotAnims.map((a) =>
|
|
214
|
+
Animated.timing(a, {
|
|
215
|
+
toValue: 1,
|
|
216
|
+
duration: 200,
|
|
217
|
+
easing: Easing.out(Easing.ease),
|
|
218
|
+
useNativeDriver: false,
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
const animation = Animated.sequence([draw, Animated.stagger(200, pops)])
|
|
222
|
+
animation.start()
|
|
223
|
+
return () => animation.stop()
|
|
224
|
+
}, [reveal, dotAnims, band.length, workouts.length])
|
|
225
|
+
|
|
226
|
+
const plotWidth = width - PADDING_LEFT - PADDING_RIGHT
|
|
227
|
+
const plotHeight = height - PADDING_TOP - PADDING_BOTTOM
|
|
228
|
+
|
|
229
|
+
const scale = useMemo(() => {
|
|
230
|
+
const times = band.map((p) => parseTime(p.date))
|
|
231
|
+
projection?.withTraining.forEach((p) => times.push(parseTime(p.date)))
|
|
232
|
+
projection?.withRest.forEach((p) => times.push(parseTime(p.date)))
|
|
233
|
+
const minT = times.length ? Math.min(...times) : 0
|
|
234
|
+
const maxT = times.length ? Math.max(...times) : 1
|
|
235
|
+
const tRange = maxT - minT || 1
|
|
236
|
+
|
|
237
|
+
const values = collectValues(band, workouts, projection)
|
|
238
|
+
const minV = values.length ? Math.min(...values) : 0
|
|
239
|
+
const maxV = values.length ? Math.max(...values) : 1
|
|
240
|
+
const pad = (maxV - minV) * 0.1 || 1
|
|
241
|
+
const domainMin = minV - pad
|
|
242
|
+
const domainMax = maxV + pad
|
|
243
|
+
const vRange = domainMax - domainMin || 1
|
|
244
|
+
|
|
245
|
+
const toX = (date: string) =>
|
|
246
|
+
PADDING_LEFT + ((parseTime(date) - minT) / tRange) * plotWidth
|
|
247
|
+
const toY = (value: number) =>
|
|
248
|
+
PADDING_TOP + (1 - (value - domainMin) / vRange) * plotHeight
|
|
249
|
+
return { toX, toY }
|
|
250
|
+
}, [band, workouts, projection, plotWidth, plotHeight])
|
|
251
|
+
|
|
252
|
+
if (band.length === 0) {
|
|
253
|
+
return (
|
|
254
|
+
<View
|
|
255
|
+
style={{ width, height }}
|
|
256
|
+
className={className}
|
|
257
|
+
accessibilityRole="image"
|
|
258
|
+
accessibilityLabel="Training capacity band chart, no data"
|
|
259
|
+
testID="capacity-band-chart-empty"
|
|
260
|
+
{...props}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { toX, toY } = scale
|
|
266
|
+
const bandPixels = toPixels(band, toX, toY)
|
|
267
|
+
const columns = buildColumns(bandPixels, COLUMN_STEP)
|
|
268
|
+
const topEdge = buildEdges(bandPixels, 'yHigh')
|
|
269
|
+
const bottomEdge = buildEdges(bandPixels, 'yLow')
|
|
270
|
+
|
|
271
|
+
const lastBandPoint = band[band.length - 1]
|
|
272
|
+
const trainingPixels = projection
|
|
273
|
+
? toPixels([lastBandPoint, ...projection.withTraining], toX, toY)
|
|
274
|
+
: []
|
|
275
|
+
const restPixels = projection
|
|
276
|
+
? toPixels([lastBandPoint, ...projection.withRest], toX, toY)
|
|
277
|
+
: []
|
|
278
|
+
|
|
279
|
+
const labelStride = Math.max(1, Math.ceil(band.length / 5))
|
|
280
|
+
const revealWidth = reveal.interpolate({ inputRange: [0, 1], outputRange: [0, width] })
|
|
281
|
+
|
|
282
|
+
const renderColumns = (cols: ReturnType<typeof buildColumns>, color: string, key: string) =>
|
|
283
|
+
cols.map((col, i) => (
|
|
284
|
+
<View
|
|
285
|
+
key={`${key}-${i}`}
|
|
286
|
+
style={{
|
|
287
|
+
position: 'absolute',
|
|
288
|
+
left: col.x,
|
|
289
|
+
top: col.top,
|
|
290
|
+
width: COLUMN_STEP + 0.5,
|
|
291
|
+
height: col.height,
|
|
292
|
+
backgroundColor: color,
|
|
293
|
+
}}
|
|
294
|
+
accessibilityElementsHidden
|
|
295
|
+
importantForAccessibility="no-hide-descendants"
|
|
296
|
+
testID={key}
|
|
297
|
+
/>
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
const renderEdges = (
|
|
301
|
+
segments: ReturnType<typeof buildEdges>,
|
|
302
|
+
color: string,
|
|
303
|
+
dashed: boolean,
|
|
304
|
+
key: string,
|
|
305
|
+
) =>
|
|
306
|
+
segments.map((seg, i) => (
|
|
307
|
+
<View
|
|
308
|
+
key={`${key}-${i}`}
|
|
309
|
+
style={{
|
|
310
|
+
position: 'absolute',
|
|
311
|
+
left: seg.left,
|
|
312
|
+
top: seg.top,
|
|
313
|
+
width: seg.length,
|
|
314
|
+
height: dashed ? 0 : 1.5,
|
|
315
|
+
borderTopWidth: dashed ? 1 : 0,
|
|
316
|
+
borderTopColor: dashed ? color : undefined,
|
|
317
|
+
borderStyle: dashed ? 'dashed' : 'solid',
|
|
318
|
+
backgroundColor: dashed ? undefined : color,
|
|
319
|
+
opacity: dashed ? 0.7 : 1,
|
|
320
|
+
transformOrigin: '0 0',
|
|
321
|
+
transform: [{ rotate: `${seg.angle}deg` }],
|
|
322
|
+
}}
|
|
323
|
+
accessibilityElementsHidden
|
|
324
|
+
importantForAccessibility="no-hide-descendants"
|
|
325
|
+
testID={key}
|
|
326
|
+
/>
|
|
327
|
+
))
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<View
|
|
331
|
+
style={{ width, height, position: 'relative' }}
|
|
332
|
+
className={className}
|
|
333
|
+
testID="capacity-band-chart-root"
|
|
334
|
+
{...props}
|
|
335
|
+
>
|
|
336
|
+
{/* Static visual layer carries the image role; interactive dots live as
|
|
337
|
+
siblings so the image role has no focusable descendants. */}
|
|
338
|
+
<View
|
|
339
|
+
style={{ position: 'absolute', top: 0, left: 0, width, height }}
|
|
340
|
+
accessibilityRole="image"
|
|
341
|
+
accessibilityLabel={`Training capacity band chart. Current capacity: ${currentStatus(
|
|
342
|
+
workouts,
|
|
343
|
+
)}. ${workouts.length} workout${workouts.length === 1 ? '' : 's'} shown.`}
|
|
344
|
+
testID="capacity-band-chart"
|
|
345
|
+
>
|
|
346
|
+
{/* Band + projection draw left-to-right via an animated clip. */}
|
|
347
|
+
<Animated.View
|
|
348
|
+
style={{ position: 'absolute', top: 0, left: 0, height, width: revealWidth, overflow: 'hidden' }}
|
|
349
|
+
accessibilityElementsHidden
|
|
350
|
+
importantForAccessibility="no-hide-descendants"
|
|
351
|
+
testID="capacity-band-chart-reveal"
|
|
352
|
+
>
|
|
353
|
+
<View style={{ width, height, position: 'relative' }}>
|
|
354
|
+
{projection && (
|
|
355
|
+
<View testID="capacity-band-chart-projection">
|
|
356
|
+
{renderColumns(buildColumns(trainingPixels, COLUMN_STEP), PROJECTION_FILL, 'capacity-band-chart-projection-fill')}
|
|
357
|
+
{renderColumns(buildColumns(restPixels, COLUMN_STEP), PROJECTION_FILL, 'capacity-band-chart-projection-fill')}
|
|
358
|
+
{renderEdges(buildEdges(trainingPixels, 'yHigh'), STATUS_SUCCESS, true, 'capacity-band-chart-projection-training')}
|
|
359
|
+
{renderEdges(buildEdges(trainingPixels, 'yLow'), STATUS_SUCCESS, true, 'capacity-band-chart-projection-training')}
|
|
360
|
+
{renderEdges(buildEdges(restPixels, 'yHigh'), STATUS_INFO, true, 'capacity-band-chart-projection-rest')}
|
|
361
|
+
{renderEdges(buildEdges(restPixels, 'yLow'), STATUS_INFO, true, 'capacity-band-chart-projection-rest')}
|
|
362
|
+
<Text
|
|
363
|
+
style={{
|
|
364
|
+
position: 'absolute',
|
|
365
|
+
left: trainingPixels[trainingPixels.length - 1]?.x + 2,
|
|
366
|
+
top: trainingPixels[trainingPixels.length - 1]?.yHigh - 12,
|
|
367
|
+
fontSize: 9,
|
|
368
|
+
fontFamily: 'Inter, sans-serif',
|
|
369
|
+
color: STATUS_SUCCESS,
|
|
370
|
+
}}
|
|
371
|
+
testID="capacity-band-chart-projection-training-label"
|
|
372
|
+
>
|
|
373
|
+
Training
|
|
374
|
+
</Text>
|
|
375
|
+
<Text
|
|
376
|
+
style={{
|
|
377
|
+
position: 'absolute',
|
|
378
|
+
left: restPixels[restPixels.length - 1]?.x + 2,
|
|
379
|
+
top: restPixels[restPixels.length - 1]?.yLow + 2,
|
|
380
|
+
fontSize: 9,
|
|
381
|
+
fontFamily: 'Inter, sans-serif',
|
|
382
|
+
color: STATUS_INFO,
|
|
383
|
+
}}
|
|
384
|
+
testID="capacity-band-chart-projection-rest-label"
|
|
385
|
+
>
|
|
386
|
+
Rest
|
|
387
|
+
</Text>
|
|
388
|
+
</View>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
<View testID="capacity-band-chart-band">
|
|
392
|
+
{renderColumns(columns, BAND_FILL, 'capacity-band-chart-band-cell')}
|
|
393
|
+
</View>
|
|
394
|
+
{renderEdges(topEdge, BAND_EDGE, false, 'capacity-band-chart-edge-top')}
|
|
395
|
+
{renderEdges(bottomEdge, BAND_EDGE, false, 'capacity-band-chart-edge-bottom')}
|
|
396
|
+
</View>
|
|
397
|
+
</Animated.View>
|
|
398
|
+
|
|
399
|
+
{/* Y axis conceptual label (no numbers). */}
|
|
400
|
+
<Text
|
|
401
|
+
style={{
|
|
402
|
+
position: 'absolute',
|
|
403
|
+
left: PADDING_LEFT / 2 - 14,
|
|
404
|
+
top: PADDING_TOP + plotHeight / 2 - 7,
|
|
405
|
+
width: 32,
|
|
406
|
+
textAlign: 'center',
|
|
407
|
+
fontSize: 9,
|
|
408
|
+
fontFamily: 'Inter, sans-serif',
|
|
409
|
+
color: TEXT_TERTIARY,
|
|
410
|
+
transform: [{ rotate: '-90deg' }],
|
|
411
|
+
}}
|
|
412
|
+
accessibilityElementsHidden
|
|
413
|
+
importantForAccessibility="no-hide-descendants"
|
|
414
|
+
testID="capacity-band-chart-y-axis-label"
|
|
415
|
+
>
|
|
416
|
+
Load
|
|
417
|
+
</Text>
|
|
418
|
+
|
|
419
|
+
{/* X axis date labels (subset to avoid overlap). */}
|
|
420
|
+
{band.map((point, i) => {
|
|
421
|
+
if (i % labelStride !== 0 && i !== band.length - 1) return null
|
|
422
|
+
return (
|
|
423
|
+
<Text
|
|
424
|
+
key={`x-${point.date}`}
|
|
425
|
+
style={{
|
|
426
|
+
position: 'absolute',
|
|
427
|
+
left: toX(point.date) - 14,
|
|
428
|
+
top: height - PADDING_BOTTOM + 2,
|
|
429
|
+
width: 28,
|
|
430
|
+
textAlign: 'center',
|
|
431
|
+
fontSize: 10,
|
|
432
|
+
fontFamily: 'Inter, sans-serif',
|
|
433
|
+
color: TEXT_TERTIARY,
|
|
434
|
+
}}
|
|
435
|
+
accessibilityElementsHidden
|
|
436
|
+
importantForAccessibility="no-hide-descendants"
|
|
437
|
+
testID="capacity-band-chart-x-label"
|
|
438
|
+
>
|
|
439
|
+
{formatDate(point.date)}
|
|
440
|
+
</Text>
|
|
441
|
+
)
|
|
442
|
+
})}
|
|
443
|
+
</View>
|
|
444
|
+
|
|
445
|
+
{/* Workout dots — scale in after the band draws. */}
|
|
446
|
+
{workouts.map((workout, i) => {
|
|
447
|
+
const cx = toX(workout.date)
|
|
448
|
+
const cy = toY(workout.load)
|
|
449
|
+
const color = DOT_COLORS[workout.status]
|
|
450
|
+
const dotStyle = {
|
|
451
|
+
width: DOT_SIZE,
|
|
452
|
+
height: DOT_SIZE,
|
|
453
|
+
borderRadius: DOT_SIZE / 2,
|
|
454
|
+
backgroundColor: color,
|
|
455
|
+
borderWidth: 2,
|
|
456
|
+
borderColor: DOT_BORDER,
|
|
457
|
+
}
|
|
458
|
+
const label = `Workout on ${formatDate(workout.date)}, load ${workout.load}, ${
|
|
459
|
+
STATUS_PHRASES[workout.status]
|
|
460
|
+
}`
|
|
461
|
+
return (
|
|
462
|
+
<Animated.View
|
|
463
|
+
key={`dot-${workout.date}-${i}`}
|
|
464
|
+
style={{
|
|
465
|
+
position: 'absolute',
|
|
466
|
+
left: cx - DOT_SIZE / 2,
|
|
467
|
+
top: cy - DOT_SIZE / 2,
|
|
468
|
+
transform: [{ scale: dotAnims[i] ?? 1 }],
|
|
469
|
+
}}
|
|
470
|
+
testID="capacity-band-chart-dot-wrapper"
|
|
471
|
+
>
|
|
472
|
+
{onWorkoutPress ? (
|
|
473
|
+
<Pressable
|
|
474
|
+
onPress={() => onWorkoutPress(workout)}
|
|
475
|
+
accessibilityRole="button"
|
|
476
|
+
accessibilityLabel={label}
|
|
477
|
+
style={dotStyle}
|
|
478
|
+
testID="capacity-band-chart-dot"
|
|
479
|
+
/>
|
|
480
|
+
) : (
|
|
481
|
+
<View
|
|
482
|
+
accessibilityElementsHidden
|
|
483
|
+
importantForAccessibility="no-hide-descendants"
|
|
484
|
+
style={dotStyle}
|
|
485
|
+
testID="capacity-band-chart-dot"
|
|
486
|
+
/>
|
|
487
|
+
)}
|
|
488
|
+
</Animated.View>
|
|
489
|
+
)
|
|
490
|
+
})}
|
|
491
|
+
</View>
|
|
492
|
+
)
|
|
493
|
+
}
|