@titan-design/react-ui 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +348 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +163 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +253 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +159 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +113 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,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,266 @@
|
|
|
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
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
6
|
+
|
|
7
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
8
|
+
const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
|
|
9
|
+
const STATUS_SUCCESS = '#14B8A6'
|
|
10
|
+
const STATUS_WARNING = '#FFB020'
|
|
11
|
+
const STATUS_ERROR = '#D14343'
|
|
12
|
+
const TEXT_PRIMARY = '#F3F4F6'
|
|
13
|
+
const TEXT_SECONDARY = '#9CA3AF'
|
|
14
|
+
|
|
15
|
+
/** Emoji option sets per known factor, keyed by lowercase id/label. */
|
|
16
|
+
const EMOJI_SETS: Record<string, readonly string[]> = {
|
|
17
|
+
sleep: ['😴', '😐', '🙂', '😊', '🤩'],
|
|
18
|
+
soreness: ['😵', '😣', '😐', '🙂', '💪'],
|
|
19
|
+
motivation: ['😑', '😐', '🙂', '😄', '🔥'],
|
|
20
|
+
stress: ['😰', '😟', '😐', '😌', '😎'],
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_EMOJI_SET = ['😞', '😕', '😐', '🙂', '😄'] as const
|
|
23
|
+
|
|
24
|
+
export type WarmUpStatus = 'good' | 'moderate' | 'caution'
|
|
25
|
+
|
|
26
|
+
export interface ReadinessFactor {
|
|
27
|
+
/** Stable identifier; also used to pick the emoji set (sleep/soreness/motivation/stress). */
|
|
28
|
+
id: string
|
|
29
|
+
/** Human label, e.g. "Sleep". */
|
|
30
|
+
label: string
|
|
31
|
+
/** Selected level, 1-5. */
|
|
32
|
+
value: number
|
|
33
|
+
/** Called with the new 1-5 level when an emoji is tapped. */
|
|
34
|
+
onChange: (value: number) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WarmUpValidation {
|
|
38
|
+
/** Percent below the 14-day rolling velocity average. */
|
|
39
|
+
velocityDeficit: number
|
|
40
|
+
/** Short human recommendation, e.g. "Velocity on target — proceed as planned". */
|
|
41
|
+
recommendation: string
|
|
42
|
+
/** Severity of the deficit. */
|
|
43
|
+
status: WarmUpStatus
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReadinessCheckProps {
|
|
47
|
+
/** Subjective readiness factors (Sleep, Soreness, Motivation, Stress). */
|
|
48
|
+
factors: ReadinessFactor[]
|
|
49
|
+
/** Computed overall readiness score, 0-100. */
|
|
50
|
+
score: number
|
|
51
|
+
/** VBT warm-up validation result, shown once the warm-up set is performed. */
|
|
52
|
+
warmUpValidation?: WarmUpValidation
|
|
53
|
+
/** Whether the VBT warm-up has been performed. */
|
|
54
|
+
warmUpCompleted: boolean
|
|
55
|
+
/** Called when the assessment is confirmed and the workout can begin. */
|
|
56
|
+
onConfirm: () => void
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Maps a 0-100 readiness score onto a status color. */
|
|
61
|
+
function scoreColor(score: number): string {
|
|
62
|
+
if (score < 40) return STATUS_ERROR
|
|
63
|
+
if (score < 70) return STATUS_WARNING
|
|
64
|
+
return STATUS_SUCCESS
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const WARMUP_BADGE: Record<WarmUpStatus, { color: BadgeColor; label: string }> = {
|
|
68
|
+
good: { color: 'success', label: 'Good' },
|
|
69
|
+
moderate: { color: 'warning', label: 'Moderate' },
|
|
70
|
+
caution: { color: 'error', label: 'Caution' },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emojiSet(factor: ReadinessFactor): readonly string[] {
|
|
74
|
+
return EMOJI_SETS[factor.id.toLowerCase()] ?? EMOJI_SETS[factor.label.toLowerCase()] ?? DEFAULT_EMOJI_SET
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Warm-up velocity phrasing. Deficit is % below the 14-day average, so a
|
|
78
|
+
* non-positive deficit means velocity is on/above average — never "below". */
|
|
79
|
+
function velocityDeficitText(deficit: number): string {
|
|
80
|
+
if (deficit > 0) return `Velocity ${deficit}% below 14-day average`
|
|
81
|
+
if (deficit < 0) return `Velocity ${Math.abs(deficit)}% above 14-day average`
|
|
82
|
+
return 'Velocity on 14-day average'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface EmojiSliderProps {
|
|
86
|
+
factor: ReadinessFactor
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One factor row: label plus a radiogroup of five tappable emoji. */
|
|
90
|
+
function EmojiSlider({ factor }: EmojiSliderProps) {
|
|
91
|
+
const emojis = emojiSet(factor)
|
|
92
|
+
return (
|
|
93
|
+
<View style={{ marginTop: 14 }} testID={`readiness-check-factor-${factor.id}`}>
|
|
94
|
+
<Text
|
|
95
|
+
style={{ fontSize: 12, fontWeight: '500', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
|
|
96
|
+
testID="readiness-check-factor-label"
|
|
97
|
+
>
|
|
98
|
+
{factor.label}
|
|
99
|
+
</Text>
|
|
100
|
+
<View
|
|
101
|
+
className="flex-row items-center"
|
|
102
|
+
style={{ marginTop: 6, gap: 6 }}
|
|
103
|
+
accessibilityRole="radiogroup"
|
|
104
|
+
aria-label={factor.label}
|
|
105
|
+
testID="readiness-check-factor-options"
|
|
106
|
+
>
|
|
107
|
+
{emojis.map((emoji, index) => {
|
|
108
|
+
const level = index + 1
|
|
109
|
+
const selected = factor.value === level
|
|
110
|
+
return (
|
|
111
|
+
<Pressable
|
|
112
|
+
key={level}
|
|
113
|
+
onPress={() => factor.onChange(level)}
|
|
114
|
+
accessibilityRole="radio"
|
|
115
|
+
accessibilityLabel={`${factor.label} level ${level} of 5`}
|
|
116
|
+
aria-checked={selected}
|
|
117
|
+
testID="readiness-check-emoji"
|
|
118
|
+
style={({ pressed }) => ({
|
|
119
|
+
flex: 1,
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
paddingVertical: 8,
|
|
122
|
+
borderRadius: 8,
|
|
123
|
+
borderWidth: 1,
|
|
124
|
+
borderColor: selected ? BRAND_PRIMARY : WORKOUT_TOKENS.border.default,
|
|
125
|
+
backgroundColor: selected ? BRAND_PRIMARY_SUBTLE : WORKOUT_TOKENS.surface.raised,
|
|
126
|
+
opacity: selected ? 1 : pressed ? 0.8 : 0.55,
|
|
127
|
+
transform: [{ scale: selected ? 1.06 : 1 }],
|
|
128
|
+
})}
|
|
129
|
+
>
|
|
130
|
+
<Text style={{ fontSize: 20 }}>{emoji}</Text>
|
|
131
|
+
</Pressable>
|
|
132
|
+
)
|
|
133
|
+
})}
|
|
134
|
+
</View>
|
|
135
|
+
</View>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Circular score gauge: 60px ring colored by range with the score centered. */
|
|
140
|
+
function ScoreGauge({ score }: { score: number }) {
|
|
141
|
+
const color = scoreColor(score)
|
|
142
|
+
return (
|
|
143
|
+
<View
|
|
144
|
+
accessibilityRole="image"
|
|
145
|
+
accessibilityLabel={`Readiness score: ${score} out of 100`}
|
|
146
|
+
testID="readiness-check-score"
|
|
147
|
+
style={{
|
|
148
|
+
width: 60,
|
|
149
|
+
height: 60,
|
|
150
|
+
borderRadius: 30,
|
|
151
|
+
borderWidth: 4,
|
|
152
|
+
borderColor: color,
|
|
153
|
+
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
154
|
+
alignItems: 'center',
|
|
155
|
+
justifyContent: 'center',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<Text
|
|
159
|
+
style={{ fontSize: 20, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color }}
|
|
160
|
+
testID="readiness-check-score-value"
|
|
161
|
+
>
|
|
162
|
+
{score}
|
|
163
|
+
</Text>
|
|
164
|
+
</View>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** VBT warm-up validation card with status badge and recommendation. */
|
|
169
|
+
function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
|
|
170
|
+
const badge = WARMUP_BADGE[validation.status]
|
|
171
|
+
return (
|
|
172
|
+
<View
|
|
173
|
+
style={{
|
|
174
|
+
marginTop: 16,
|
|
175
|
+
padding: 12,
|
|
176
|
+
borderRadius: 8,
|
|
177
|
+
borderWidth: 1,
|
|
178
|
+
borderColor: WORKOUT_TOKENS.border.default,
|
|
179
|
+
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
180
|
+
}}
|
|
181
|
+
testID="readiness-check-warmup"
|
|
182
|
+
>
|
|
183
|
+
<View className="flex-row items-center justify-between" style={{ gap: 8 }}>
|
|
184
|
+
<Text
|
|
185
|
+
style={{ fontSize: 12, fontWeight: '600', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
|
|
186
|
+
testID="readiness-check-warmup-deficit"
|
|
187
|
+
>
|
|
188
|
+
{velocityDeficitText(validation.velocityDeficit)}
|
|
189
|
+
</Text>
|
|
190
|
+
<Badge variant="subtle" color={badge.color} size="sm" testID="readiness-check-warmup-badge">
|
|
191
|
+
{badge.label}
|
|
192
|
+
</Badge>
|
|
193
|
+
</View>
|
|
194
|
+
<Text
|
|
195
|
+
style={{ marginTop: 6, fontSize: 13, fontFamily: 'Inter, sans-serif', color: TEXT_PRIMARY }}
|
|
196
|
+
testID="readiness-check-warmup-recommendation"
|
|
197
|
+
>
|
|
198
|
+
{validation.recommendation}
|
|
199
|
+
</Text>
|
|
200
|
+
</View>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pre-workout readiness assessment combining subjective emoji sliders with an
|
|
206
|
+
* objective VBT warm-up validation and a computed readiness score.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* <ReadinessCheck
|
|
210
|
+
* factors={[{ id: 'sleep', label: 'Sleep', value: 4, onChange: setSleep }]}
|
|
211
|
+
* score={78}
|
|
212
|
+
* warmUpCompleted
|
|
213
|
+
* warmUpValidation={{ velocityDeficit: 4, recommendation: 'On target', status: 'good' }}
|
|
214
|
+
* onConfirm={start}
|
|
215
|
+
* />
|
|
216
|
+
*/
|
|
217
|
+
export function ReadinessCheck({
|
|
218
|
+
factors,
|
|
219
|
+
score,
|
|
220
|
+
warmUpValidation,
|
|
221
|
+
warmUpCompleted,
|
|
222
|
+
onConfirm,
|
|
223
|
+
className,
|
|
224
|
+
}: ReadinessCheckProps) {
|
|
225
|
+
return (
|
|
226
|
+
<Card variant="outline" elevation={2} className={className} testID="readiness-check">
|
|
227
|
+
<View style={{ padding: 16 }}>
|
|
228
|
+
<View className="flex-row items-center justify-between" style={{ gap: 12 }}>
|
|
229
|
+
<Text
|
|
230
|
+
style={{ fontSize: 18, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color: TEXT_PRIMARY }}
|
|
231
|
+
testID="readiness-check-title"
|
|
232
|
+
>
|
|
233
|
+
How are you feeling?
|
|
234
|
+
</Text>
|
|
235
|
+
<ScoreGauge score={score} />
|
|
236
|
+
</View>
|
|
237
|
+
|
|
238
|
+
{factors.map((factor) => (
|
|
239
|
+
<EmojiSlider key={factor.id} factor={factor} />
|
|
240
|
+
))}
|
|
241
|
+
|
|
242
|
+
{warmUpCompleted && warmUpValidation && <WarmUpCard validation={warmUpValidation} />}
|
|
243
|
+
|
|
244
|
+
<Pressable
|
|
245
|
+
onPress={onConfirm}
|
|
246
|
+
accessibilityRole="button"
|
|
247
|
+
accessibilityLabel="Confirm readiness and start workout"
|
|
248
|
+
testID="readiness-check-confirm"
|
|
249
|
+
style={({ pressed }) => ({
|
|
250
|
+
marginTop: 16,
|
|
251
|
+
width: '100%',
|
|
252
|
+
alignItems: 'center',
|
|
253
|
+
backgroundColor: BRAND_PRIMARY,
|
|
254
|
+
borderRadius: 8,
|
|
255
|
+
paddingVertical: 12,
|
|
256
|
+
opacity: pressed ? 0.8 : 1,
|
|
257
|
+
})}
|
|
258
|
+
>
|
|
259
|
+
<Text style={{ fontSize: 13, fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#FFFFFF' }}>
|
|
260
|
+
Start Workout
|
|
261
|
+
</Text>
|
|
262
|
+
</Pressable>
|
|
263
|
+
</View>
|
|
264
|
+
</Card>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
@@ -1,6 +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'
|
|
3
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
4
|
+
|
|
5
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
4
6
|
|
|
5
7
|
export interface RestTimerProps {
|
|
6
8
|
totalSeconds: number
|
|
@@ -32,9 +34,9 @@ export function RestTimer({
|
|
|
32
34
|
<View
|
|
33
35
|
style={{
|
|
34
36
|
width: '100%',
|
|
35
|
-
backgroundColor:
|
|
37
|
+
backgroundColor: WORKOUT_TOKENS.surface.raised,
|
|
36
38
|
borderTopWidth: 1,
|
|
37
|
-
borderTopColor:
|
|
39
|
+
borderTopColor: WORKOUT_TOKENS.border.default,
|
|
38
40
|
paddingVertical: 12,
|
|
39
41
|
paddingHorizontal: 16,
|
|
40
42
|
}}
|
|
@@ -101,7 +103,7 @@ export function RestTimer({
|
|
|
101
103
|
<View
|
|
102
104
|
style={{
|
|
103
105
|
height: 3,
|
|
104
|
-
backgroundColor:
|
|
106
|
+
backgroundColor: WORKOUT_TOKENS.border.default,
|
|
105
107
|
borderRadius: 2,
|
|
106
108
|
marginBottom: 12,
|
|
107
109
|
}}
|
|
@@ -110,7 +112,7 @@ export function RestTimer({
|
|
|
110
112
|
<View
|
|
111
113
|
style={{
|
|
112
114
|
height: '100%',
|
|
113
|
-
backgroundColor:
|
|
115
|
+
backgroundColor: BRAND_PRIMARY,
|
|
114
116
|
borderRadius: 2,
|
|
115
117
|
width: `${progressPct}%`,
|
|
116
118
|
}}
|
|
@@ -160,7 +162,7 @@ export function RestTimer({
|
|
|
160
162
|
fontSize: 11,
|
|
161
163
|
fontFamily: 'Inter, sans-serif',
|
|
162
164
|
fontWeight: '600',
|
|
163
|
-
color:
|
|
165
|
+
color: BRAND_PRIMARY,
|
|
164
166
|
}}
|
|
165
167
|
>
|
|
166
168
|
Skip
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { SetRow } from './SetRow'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof SetRow> = {
|
|
6
|
+
title: 'Custom/Workout/SetRow',
|
|
7
|
+
component: SetRow,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
mode: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: ['active', 'completed', 'history'],
|
|
13
|
+
description: 'Row display mode',
|
|
14
|
+
},
|
|
15
|
+
unit: {
|
|
16
|
+
control: 'select',
|
|
17
|
+
options: ['lbs', 'kg'],
|
|
18
|
+
description: 'Weight unit',
|
|
19
|
+
},
|
|
20
|
+
isNextSet: {
|
|
21
|
+
control: 'boolean',
|
|
22
|
+
description: 'Whether this is the next set to perform',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof SetRow>
|
|
29
|
+
|
|
30
|
+
export const CompletedWithVelocity: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
mode: 'completed',
|
|
33
|
+
setNumber: 1,
|
|
34
|
+
reps: 8,
|
|
35
|
+
weight: 135,
|
|
36
|
+
unit: 'lbs',
|
|
37
|
+
rpe: 7.5,
|
|
38
|
+
previous: { reps: 8, weight: 130 },
|
|
39
|
+
velocities: [1.1, 0.95, 0.82, 0.68, 0.55, 0.48, 0.42, 0.38],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const ActiveWithTargets: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
mode: 'active',
|
|
46
|
+
setNumber: 3,
|
|
47
|
+
reps: null,
|
|
48
|
+
weight: null,
|
|
49
|
+
unit: 'lbs',
|
|
50
|
+
previous: { reps: 8, weight: 135 },
|
|
51
|
+
targets: { reps: 10, weight: 150 },
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const ActiveNextSet: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
mode: 'active',
|
|
58
|
+
setNumber: 2,
|
|
59
|
+
reps: null,
|
|
60
|
+
weight: null,
|
|
61
|
+
unit: 'lbs',
|
|
62
|
+
isNextSet: true,
|
|
63
|
+
targets: { reps: 8, weight: 135 },
|
|
64
|
+
previous: { reps: 8, weight: 130 },
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const HistorySet: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
mode: 'history',
|
|
71
|
+
setNumber: 1,
|
|
72
|
+
reps: 10,
|
|
73
|
+
weight: 185,
|
|
74
|
+
unit: 'lbs',
|
|
75
|
+
rpe: 8,
|
|
76
|
+
previous: { reps: 10, weight: 180 },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const WithTypeBadge: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
mode: 'completed',
|
|
83
|
+
setNumber: 1,
|
|
84
|
+
reps: 5,
|
|
85
|
+
weight: 95,
|
|
86
|
+
unit: 'lbs',
|
|
87
|
+
setType: 'W',
|
|
88
|
+
previous: null,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const WithPrBadges: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
mode: 'completed',
|
|
95
|
+
setNumber: 3,
|
|
96
|
+
reps: 8,
|
|
97
|
+
weight: 225,
|
|
98
|
+
unit: 'lbs',
|
|
99
|
+
rpe: 9.5,
|
|
100
|
+
previous: { reps: 8, weight: 215 },
|
|
101
|
+
prBadges: [
|
|
102
|
+
{ type: 'e1rm', label: 'PR e1RM' },
|
|
103
|
+
{ type: 'weight', label: 'PR Weight' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const WithRPE: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
mode: 'completed',
|
|
111
|
+
setNumber: 4,
|
|
112
|
+
reps: 5,
|
|
113
|
+
weight: 275,
|
|
114
|
+
unit: 'lbs',
|
|
115
|
+
rpe: 10,
|
|
116
|
+
previous: { reps: 5, weight: 265 },
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const AllVariants: Story = {
|
|
121
|
+
render: () => (
|
|
122
|
+
<View style={{ gap: 4, padding: 16, maxWidth: 400 }}>
|
|
123
|
+
<SetRow
|
|
124
|
+
mode="completed"
|
|
125
|
+
setNumber={1}
|
|
126
|
+
reps={8}
|
|
127
|
+
weight={135}
|
|
128
|
+
unit="lbs"
|
|
129
|
+
rpe={7}
|
|
130
|
+
previous={{ reps: 8, weight: 130 }}
|
|
131
|
+
velocities={[1.1, 0.95, 0.82, 0.68, 0.55, 0.48, 0.42, 0.38]}
|
|
132
|
+
prBadges={[{ type: 'e1rm', label: 'PR e1RM' }]}
|
|
133
|
+
/>
|
|
134
|
+
<SetRow
|
|
135
|
+
mode="completed"
|
|
136
|
+
setNumber={2}
|
|
137
|
+
reps={8}
|
|
138
|
+
weight={135}
|
|
139
|
+
unit="lbs"
|
|
140
|
+
rpe={8.5}
|
|
141
|
+
previous={{ reps: 8, weight: 135 }}
|
|
142
|
+
velocities={[0.95, 0.88, 0.78, 0.65, 0.52, 0.45, 0.40, 0.35]}
|
|
143
|
+
/>
|
|
144
|
+
<SetRow
|
|
145
|
+
mode="active"
|
|
146
|
+
setNumber={3}
|
|
147
|
+
reps={null}
|
|
148
|
+
weight={null}
|
|
149
|
+
unit="lbs"
|
|
150
|
+
isNextSet
|
|
151
|
+
targets={{ reps: 8, weight: 135 }}
|
|
152
|
+
previous={{ reps: 8, weight: 135 }}
|
|
153
|
+
/>
|
|
154
|
+
<SetRow
|
|
155
|
+
mode="active"
|
|
156
|
+
setNumber={4}
|
|
157
|
+
reps={null}
|
|
158
|
+
weight={null}
|
|
159
|
+
unit="lbs"
|
|
160
|
+
targets={{ reps: 8, weight: 135 }}
|
|
161
|
+
previous={{ reps: 8, weight: 135 }}
|
|
162
|
+
/>
|
|
163
|
+
</View>
|
|
164
|
+
),
|
|
165
|
+
}
|