@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,181 @@
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 { ReadinessCheck, type ReadinessFactor } from './ReadinessCheck'
5
+
6
+ function makeFactors(overrides: Partial<Record<string, number>> = {}): ReadinessFactor[] {
7
+ return [
8
+ { id: 'sleep', label: 'Sleep', value: overrides.sleep ?? 4, onChange: vi.fn() },
9
+ { id: 'soreness', label: 'Soreness', value: overrides.soreness ?? 3, onChange: vi.fn() },
10
+ { id: 'motivation', label: 'Motivation', value: overrides.motivation ?? 5, onChange: vi.fn() },
11
+ { id: 'stress', label: 'Stress', value: overrides.stress ?? 3, onChange: vi.fn() },
12
+ ]
13
+ }
14
+
15
+ const baseProps = {
16
+ score: 78,
17
+ warmUpCompleted: false,
18
+ onConfirm: vi.fn(),
19
+ }
20
+
21
+ describe('ReadinessCheck', () => {
22
+ describe('rendering', () => {
23
+ it('renders the title and one row per factor', () => {
24
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
25
+ expect(screen.getByTestId('readiness-check-title')).toHaveTextContent(
26
+ 'How are you feeling?',
27
+ )
28
+ expect(screen.getByTestId('readiness-check-factor-sleep')).toBeInTheDocument()
29
+ expect(screen.getByTestId('readiness-check-factor-stress')).toBeInTheDocument()
30
+ })
31
+
32
+ it('renders five emoji options per factor', () => {
33
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
34
+ // 4 factors x 5 options
35
+ expect(screen.getAllByTestId('readiness-check-emoji')).toHaveLength(20)
36
+ })
37
+
38
+ it('shows the score in the gauge', () => {
39
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} score={64} />)
40
+ expect(screen.getByTestId('readiness-check-score-value')).toHaveTextContent('64')
41
+ })
42
+ })
43
+
44
+ describe('interaction', () => {
45
+ it('calls the factor onChange with the tapped 1-5 level', () => {
46
+ const factors = makeFactors()
47
+ render(<ReadinessCheck {...baseProps} factors={factors} />)
48
+ const sleepOptions = screen
49
+ .getByTestId('readiness-check-factor-sleep')
50
+ .querySelectorAll('[data-testid="readiness-check-emoji"]')
51
+ fireEvent.click(sleepOptions[0])
52
+ expect(factors[0].onChange).toHaveBeenCalledWith(1)
53
+ fireEvent.click(sleepOptions[4])
54
+ expect(factors[0].onChange).toHaveBeenCalledWith(5)
55
+ })
56
+
57
+ it('marks the selected level as checked', () => {
58
+ render(<ReadinessCheck {...baseProps} factors={makeFactors({ sleep: 2 })} />)
59
+ const sleepOptions = screen
60
+ .getByTestId('readiness-check-factor-sleep')
61
+ .querySelectorAll('[data-testid="readiness-check-emoji"]')
62
+ expect(sleepOptions[1]).toHaveAttribute('aria-checked', 'true')
63
+ expect(sleepOptions[0]).toHaveAttribute('aria-checked', 'false')
64
+ })
65
+
66
+ it('fires onConfirm when the start button is pressed', () => {
67
+ const onConfirm = vi.fn()
68
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} onConfirm={onConfirm} />)
69
+ fireEvent.click(screen.getByTestId('readiness-check-confirm'))
70
+ expect(onConfirm).toHaveBeenCalledOnce()
71
+ })
72
+ })
73
+
74
+ describe('warm-up validation', () => {
75
+ it('does not render the warm-up card until completed', () => {
76
+ render(
77
+ <ReadinessCheck
78
+ {...baseProps}
79
+ factors={makeFactors()}
80
+ warmUpValidation={{ velocityDeficit: 5, recommendation: 'OK', status: 'good' }}
81
+ />,
82
+ )
83
+ expect(screen.queryByTestId('readiness-check-warmup')).not.toBeInTheDocument()
84
+ })
85
+
86
+ it('renders deficit, recommendation, and status badge once completed', () => {
87
+ render(
88
+ <ReadinessCheck
89
+ {...baseProps}
90
+ factors={makeFactors()}
91
+ warmUpCompleted
92
+ warmUpValidation={{
93
+ velocityDeficit: 18,
94
+ recommendation: 'Reduce load today.',
95
+ status: 'caution',
96
+ }}
97
+ />,
98
+ )
99
+ expect(screen.getByTestId('readiness-check-warmup-deficit')).toHaveTextContent(
100
+ '18% below 14-day average',
101
+ )
102
+ expect(screen.getByTestId('readiness-check-warmup-recommendation')).toHaveTextContent(
103
+ 'Reduce load today.',
104
+ )
105
+ expect(screen.getByTestId('readiness-check-warmup-badge')).toHaveTextContent('Caution')
106
+ })
107
+
108
+ it('phrases a zero deficit as on-average rather than "below"', () => {
109
+ render(
110
+ <ReadinessCheck
111
+ {...baseProps}
112
+ factors={makeFactors()}
113
+ warmUpCompleted
114
+ warmUpValidation={{ velocityDeficit: 0, recommendation: 'Proceed.', status: 'good' }}
115
+ />,
116
+ )
117
+ expect(screen.getByTestId('readiness-check-warmup-deficit')).toHaveTextContent(
118
+ 'Velocity on 14-day average',
119
+ )
120
+ })
121
+
122
+ it('phrases a negative deficit as above average', () => {
123
+ render(
124
+ <ReadinessCheck
125
+ {...baseProps}
126
+ factors={makeFactors()}
127
+ warmUpCompleted
128
+ warmUpValidation={{ velocityDeficit: -6, recommendation: 'Proceed.', status: 'good' }}
129
+ />,
130
+ )
131
+ expect(screen.getByTestId('readiness-check-warmup-deficit')).toHaveTextContent(
132
+ 'Velocity 6% above 14-day average',
133
+ )
134
+ })
135
+ })
136
+
137
+ describe('accessibility', () => {
138
+ it('labels each emoji as a radio with level and factor', () => {
139
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
140
+ const radio = screen.getByLabelText('Sleep level 3 of 5')
141
+ expect(radio).toHaveAttribute('role', 'radio')
142
+ })
143
+
144
+ it('groups each factor as a radiogroup', () => {
145
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
146
+ const groups = screen.getAllByRole('radiogroup')
147
+ expect(groups).toHaveLength(4)
148
+ })
149
+
150
+ it('exposes the readiness score on the gauge', () => {
151
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} score={78} />)
152
+ expect(
153
+ screen.getByLabelText('Readiness score: 78 out of 100'),
154
+ ).toBeInTheDocument()
155
+ })
156
+
157
+ it('has no accessibility violations', async () => {
158
+ const { container } = render(
159
+ <ReadinessCheck {...baseProps} factors={makeFactors()} />,
160
+ )
161
+ expect(await axe(container)).toHaveNoViolations()
162
+ })
163
+
164
+ it('has no accessibility violations with warm-up and all features', async () => {
165
+ const { container } = render(
166
+ <ReadinessCheck
167
+ factors={makeFactors()}
168
+ score={88}
169
+ warmUpCompleted
170
+ warmUpValidation={{
171
+ velocityDeficit: 6,
172
+ recommendation: 'Warm up thoroughly.',
173
+ status: 'moderate',
174
+ }}
175
+ onConfirm={vi.fn()}
176
+ />,
177
+ )
178
+ expect(await axe(container)).toHaveNoViolations()
179
+ })
180
+ })
181
+ })
@@ -0,0 +1,265 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, Pressable } from 'react-native'
3
+ import { Card } from '../../ui/card'
4
+ import { Badge, type BadgeColor } from '../../ui/badge'
5
+
6
+ const BRAND_PRIMARY = '#FF7900'
7
+ const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
8
+ const STATUS_SUCCESS = '#14B8A6'
9
+ const STATUS_WARNING = '#FFB020'
10
+ const STATUS_ERROR = '#D14343'
11
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
12
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
13
+
14
+ /** Emoji option sets per known factor, keyed by lowercase id/label. */
15
+ const EMOJI_SETS: Record<string, readonly string[]> = {
16
+ sleep: ['😴', '😐', '🙂', '😊', '🤩'],
17
+ soreness: ['😵', '😣', '😐', '🙂', '💪'],
18
+ motivation: ['😑', '😐', '🙂', '😄', '🔥'],
19
+ stress: ['😰', '😟', '😐', '😌', '😎'],
20
+ }
21
+ const DEFAULT_EMOJI_SET = ['😞', '😕', '😐', '🙂', '😄'] as const
22
+
23
+ export type WarmUpStatus = 'good' | 'moderate' | 'caution'
24
+
25
+ export interface ReadinessFactor {
26
+ /** Stable identifier; also used to pick the emoji set (sleep/soreness/motivation/stress). */
27
+ id: string
28
+ /** Human label, e.g. "Sleep". */
29
+ label: string
30
+ /** Selected level, 1-5. */
31
+ value: number
32
+ /** Called with the new 1-5 level when an emoji is tapped. */
33
+ onChange: (value: number) => void
34
+ }
35
+
36
+ export interface WarmUpValidation {
37
+ /** Percent below the 14-day rolling velocity average. */
38
+ velocityDeficit: number
39
+ /** Short human recommendation, e.g. "Velocity on target — proceed as planned". */
40
+ recommendation: string
41
+ /** Severity of the deficit. */
42
+ status: WarmUpStatus
43
+ }
44
+
45
+ export interface ReadinessCheckProps {
46
+ /** Subjective readiness factors (Sleep, Soreness, Motivation, Stress). */
47
+ factors: ReadinessFactor[]
48
+ /** Computed overall readiness score, 0-100. */
49
+ score: number
50
+ /** VBT warm-up validation result, shown once the warm-up set is performed. */
51
+ warmUpValidation?: WarmUpValidation
52
+ /** Whether the VBT warm-up has been performed. */
53
+ warmUpCompleted: boolean
54
+ /** Called when the assessment is confirmed and the workout can begin. */
55
+ onConfirm: () => void
56
+ className?: string
57
+ }
58
+
59
+ /** Maps a 0-100 readiness score onto a status color. */
60
+ function scoreColor(score: number): string {
61
+ if (score < 40) return STATUS_ERROR
62
+ if (score < 70) return STATUS_WARNING
63
+ return STATUS_SUCCESS
64
+ }
65
+
66
+ const WARMUP_BADGE: Record<WarmUpStatus, { color: BadgeColor; label: string }> = {
67
+ good: { color: 'success', label: 'Good' },
68
+ moderate: { color: 'warning', label: 'Moderate' },
69
+ caution: { color: 'error', label: 'Caution' },
70
+ }
71
+
72
+ function emojiSet(factor: ReadinessFactor): readonly string[] {
73
+ return EMOJI_SETS[factor.id.toLowerCase()] ?? EMOJI_SETS[factor.label.toLowerCase()] ?? DEFAULT_EMOJI_SET
74
+ }
75
+
76
+ /** Warm-up velocity phrasing. Deficit is % below the 14-day average, so a
77
+ * non-positive deficit means velocity is on/above average — never "below". */
78
+ function velocityDeficitText(deficit: number): string {
79
+ if (deficit > 0) return `Velocity ${deficit}% below 14-day average`
80
+ if (deficit < 0) return `Velocity ${Math.abs(deficit)}% above 14-day average`
81
+ return 'Velocity on 14-day average'
82
+ }
83
+
84
+ interface EmojiSliderProps {
85
+ factor: ReadinessFactor
86
+ }
87
+
88
+ /** One factor row: label plus a radiogroup of five tappable emoji. */
89
+ function EmojiSlider({ factor }: EmojiSliderProps) {
90
+ const emojis = emojiSet(factor)
91
+ return (
92
+ <View style={{ marginTop: 14 }} testID={`readiness-check-factor-${factor.id}`}>
93
+ <Text
94
+ style={{ fontSize: 12, fontWeight: '500', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
95
+ testID="readiness-check-factor-label"
96
+ >
97
+ {factor.label}
98
+ </Text>
99
+ <View
100
+ className="flex-row items-center"
101
+ style={{ marginTop: 6, gap: 6 }}
102
+ accessibilityRole="radiogroup"
103
+ aria-label={factor.label}
104
+ testID="readiness-check-factor-options"
105
+ >
106
+ {emojis.map((emoji, index) => {
107
+ const level = index + 1
108
+ const selected = factor.value === level
109
+ return (
110
+ <Pressable
111
+ key={level}
112
+ onPress={() => factor.onChange(level)}
113
+ accessibilityRole="radio"
114
+ accessibilityLabel={`${factor.label} level ${level} of 5`}
115
+ aria-checked={selected}
116
+ testID="readiness-check-emoji"
117
+ style={({ pressed }) => ({
118
+ flex: 1,
119
+ alignItems: 'center',
120
+ paddingVertical: 8,
121
+ borderRadius: 8,
122
+ borderWidth: 1,
123
+ borderColor: selected ? BRAND_PRIMARY : 'var(--color-border-default)',
124
+ backgroundColor: selected ? BRAND_PRIMARY_SUBTLE : 'var(--color-surface-raised)',
125
+ opacity: selected ? 1 : pressed ? 0.8 : 0.55,
126
+ transform: [{ scale: selected ? 1.06 : 1 }],
127
+ })}
128
+ >
129
+ <Text style={{ fontSize: 20 }}>{emoji}</Text>
130
+ </Pressable>
131
+ )
132
+ })}
133
+ </View>
134
+ </View>
135
+ )
136
+ }
137
+
138
+ /** Circular score gauge: 60px ring colored by range with the score centered. */
139
+ function ScoreGauge({ score }: { score: number }) {
140
+ const color = scoreColor(score)
141
+ return (
142
+ <View
143
+ accessibilityRole="image"
144
+ accessibilityLabel={`Readiness score: ${score} out of 100`}
145
+ testID="readiness-check-score"
146
+ style={{
147
+ width: 60,
148
+ height: 60,
149
+ borderRadius: 30,
150
+ borderWidth: 4,
151
+ borderColor: color,
152
+ backgroundColor: 'var(--color-surface-raised)',
153
+ alignItems: 'center',
154
+ justifyContent: 'center',
155
+ }}
156
+ >
157
+ <Text
158
+ style={{ fontSize: 20, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color }}
159
+ testID="readiness-check-score-value"
160
+ >
161
+ {score}
162
+ </Text>
163
+ </View>
164
+ )
165
+ }
166
+
167
+ /** VBT warm-up validation card with status badge and recommendation. */
168
+ function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
169
+ const badge = WARMUP_BADGE[validation.status]
170
+ return (
171
+ <View
172
+ style={{
173
+ marginTop: 16,
174
+ padding: 12,
175
+ borderRadius: 8,
176
+ borderWidth: 1,
177
+ borderColor: 'var(--color-border-default)',
178
+ backgroundColor: 'var(--color-surface-raised)',
179
+ }}
180
+ testID="readiness-check-warmup"
181
+ >
182
+ <View className="flex-row items-center justify-between" style={{ gap: 8 }}>
183
+ <Text
184
+ style={{ fontSize: 12, fontWeight: '600', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
185
+ testID="readiness-check-warmup-deficit"
186
+ >
187
+ {velocityDeficitText(validation.velocityDeficit)}
188
+ </Text>
189
+ <Badge variant="subtle" color={badge.color} size="sm" testID="readiness-check-warmup-badge">
190
+ {badge.label}
191
+ </Badge>
192
+ </View>
193
+ <Text
194
+ style={{ marginTop: 6, fontSize: 13, fontFamily: 'Inter, sans-serif', color: TEXT_PRIMARY }}
195
+ testID="readiness-check-warmup-recommendation"
196
+ >
197
+ {validation.recommendation}
198
+ </Text>
199
+ </View>
200
+ )
201
+ }
202
+
203
+ /**
204
+ * Pre-workout readiness assessment combining subjective emoji sliders with an
205
+ * objective VBT warm-up validation and a computed readiness score.
206
+ *
207
+ * @example
208
+ * <ReadinessCheck
209
+ * factors={[{ id: 'sleep', label: 'Sleep', value: 4, onChange: setSleep }]}
210
+ * score={78}
211
+ * warmUpCompleted
212
+ * warmUpValidation={{ velocityDeficit: 4, recommendation: 'On target', status: 'good' }}
213
+ * onConfirm={start}
214
+ * />
215
+ */
216
+ export function ReadinessCheck({
217
+ factors,
218
+ score,
219
+ warmUpValidation,
220
+ warmUpCompleted,
221
+ onConfirm,
222
+ className,
223
+ }: ReadinessCheckProps) {
224
+ return (
225
+ <Card variant="outline" elevation={2} className={className} testID="readiness-check">
226
+ <View style={{ padding: 16 }}>
227
+ <View className="flex-row items-center justify-between" style={{ gap: 12 }}>
228
+ <Text
229
+ style={{ fontSize: 18, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color: TEXT_PRIMARY }}
230
+ testID="readiness-check-title"
231
+ >
232
+ How are you feeling?
233
+ </Text>
234
+ <ScoreGauge score={score} />
235
+ </View>
236
+
237
+ {factors.map((factor) => (
238
+ <EmojiSlider key={factor.id} factor={factor} />
239
+ ))}
240
+
241
+ {warmUpCompleted && warmUpValidation && <WarmUpCard validation={warmUpValidation} />}
242
+
243
+ <Pressable
244
+ onPress={onConfirm}
245
+ accessibilityRole="button"
246
+ accessibilityLabel="Confirm readiness and start workout"
247
+ testID="readiness-check-confirm"
248
+ style={({ pressed }) => ({
249
+ marginTop: 16,
250
+ width: '100%',
251
+ alignItems: 'center',
252
+ backgroundColor: BRAND_PRIMARY,
253
+ borderRadius: 8,
254
+ paddingVertical: 12,
255
+ opacity: pressed ? 0.8 : 1,
256
+ })}
257
+ >
258
+ <Text style={{ fontSize: 13, fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#FFFFFF' }}>
259
+ Start Workout
260
+ </Text>
261
+ </Pressable>
262
+ </View>
263
+ </Card>
264
+ )
265
+ }
@@ -143,3 +143,13 @@ describe('RestTimer', () => {
143
143
  })
144
144
  })
145
145
  })
146
+
147
+ describe('RestTimer zero-duration timer (NaN guard)', () => {
148
+ it('emits a valid 100% width (not NaN) when totalSeconds is 0', () => {
149
+ const { container } = render(
150
+ <RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />,
151
+ )
152
+ expect(screen.getByTestId('rest-timer-progress-fill')).toHaveStyle({ width: '100%' })
153
+ expect(container.innerHTML).not.toContain('NaN')
154
+ })
155
+ })
@@ -1,7 +1,8 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import React from 'react'
3
2
  import { View, Text, Pressable } from 'react-native'
4
3
 
4
+ const BRAND_PRIMARY = '#FF7900'
5
+
5
6
  export interface RestTimerProps {
6
7
  totalSeconds: number
7
8
  elapsedMs: number
@@ -26,15 +27,18 @@ export function RestTimer({
26
27
  const minutes = Math.floor(remainingSec / 60)
27
28
  const seconds = remainingSec % 60
28
29
  const timeDisplay = `${minutes}:${String(seconds).padStart(2, '0')}`
29
- const progressPct = Math.min(100, (elapsedMs / (totalSeconds * 1000)) * 100)
30
+ // A zero-duration timer is already complete; guard the 0 / 0 === NaN case
31
+ // that would otherwise emit width:'NaN%'.
32
+ const totalMs = totalSeconds * 1000
33
+ const progressPct = totalMs > 0 ? Math.min(100, (elapsedMs / totalMs) * 100) : 100
30
34
 
31
35
  return (
32
36
  <View
33
37
  style={{
34
38
  width: '100%',
35
- backgroundColor: '#1C1C1C',
39
+ backgroundColor: 'var(--color-surface-raised)',
36
40
  borderTopWidth: 1,
37
- borderTopColor: '#1F1F1F',
41
+ borderTopColor: 'var(--color-border-default)',
38
42
  paddingVertical: 12,
39
43
  paddingHorizontal: 16,
40
44
  }}
@@ -60,7 +64,7 @@ export function RestTimer({
60
64
  fontWeight: '600',
61
65
  textTransform: 'uppercase',
62
66
  letterSpacing: 1,
63
- color: '#9CA3AF',
67
+ color: 'var(--color-text-secondary)',
64
68
  }}
65
69
  testID="rest-timer-label"
66
70
  >
@@ -71,7 +75,7 @@ export function RestTimer({
71
75
  style={{
72
76
  fontSize: 11,
73
77
  fontFamily: 'Inter, sans-serif',
74
- color: '#6B7280',
78
+ color: 'var(--color-text-tertiary)',
75
79
  marginTop: 2,
76
80
  }}
77
81
  testID="rest-timer-next-set"
@@ -87,7 +91,7 @@ export function RestTimer({
87
91
  fontSize: 28,
88
92
  fontFamily: '"Space Grotesk", sans-serif',
89
93
  fontWeight: '700',
90
- color: '#F3F4F6',
94
+ color: 'var(--color-text-primary)',
91
95
  fontVariant: ['tabular-nums'],
92
96
  letterSpacing: -0.5,
93
97
  }}
@@ -101,7 +105,7 @@ export function RestTimer({
101
105
  <View
102
106
  style={{
103
107
  height: 3,
104
- backgroundColor: '#1F1F1F',
108
+ backgroundColor: 'var(--color-border-default)',
105
109
  borderRadius: 2,
106
110
  marginBottom: 12,
107
111
  }}
@@ -110,7 +114,7 @@ export function RestTimer({
110
114
  <View
111
115
  style={{
112
116
  height: '100%',
113
- backgroundColor: '#FF7900',
117
+ backgroundColor: BRAND_PRIMARY,
114
118
  borderRadius: 2,
115
119
  width: `${progressPct}%`,
116
120
  }}
@@ -137,7 +141,7 @@ export function RestTimer({
137
141
  fontSize: 11,
138
142
  fontFamily: 'Inter, sans-serif',
139
143
  fontWeight: '600',
140
- color: '#9CA3AF',
144
+ color: 'var(--color-text-secondary)',
141
145
  }}
142
146
  >
143
147
  +30s
@@ -160,7 +164,7 @@ export function RestTimer({
160
164
  fontSize: 11,
161
165
  fontFamily: 'Inter, sans-serif',
162
166
  fontWeight: '600',
163
- color: '#FF7900',
167
+ color: BRAND_PRIMARY,
164
168
  }}
165
169
  >
166
170
  Skip