@titan-design/react-ui 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,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
+ }