@titan-design/react-ui 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
- package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
- package/dist/bodymap.d.mts +246 -3
- package/dist/bodymap.d.ts +246 -3
- package/dist/bodymap.js +441 -97
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +1958 -66
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +71 -738
- package/dist/index.d.ts +71 -738
- package/dist/index.js +888 -1583
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4515 -2348
- package/dist/index.mjs.map +1 -1
- package/dist/pages-D7Jlssvp.d.ts +791 -0
- package/dist/pages-MO0JCySL.d.mts +791 -0
- package/dist/pages.d.mts +4 -0
- package/dist/pages.d.ts +4 -0
- package/dist/pages.js +6646 -0
- package/dist/pages.js.map +1 -0
- package/dist/pages.mjs +6626 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/theme/index.d.mts +28 -2
- package/dist/theme/index.d.ts +28 -2
- package/dist/theme/index.js +37 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1880 -2
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.mjs +573 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +9 -7
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
- package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +7 -8
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
- package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
- package/src/components/custom/Workout/ExerciseCard.tsx +17 -28
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +38 -31
- package/src/components/custom/Workout/InputBar.tsx +10 -11
- package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
- package/src/components/custom/Workout/IntensityBar.tsx +109 -58
- package/src/components/custom/Workout/MesoCard.tsx +2 -6
- package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
- package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -10
- package/src/components/custom/Workout/PrBadge.tsx +3 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +14 -16
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
- package/src/components/custom/Workout/README.md +50 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
- package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
- package/src/components/custom/Workout/RestTimer.tsx +55 -49
- package/src/components/custom/Workout/SetRow.tsx +20 -8
- package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
- package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
- package/src/components/custom/Workout/TempoBar.tsx +215 -0
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
- package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
- package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +210 -43
- package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
- package/src/components/custom/Workout/WeekRow.tsx +2 -1
- package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.tsx +2 -2
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
- package/src/components/custom/Workout/icons.tsx +68 -0
- package/src/components/custom/Workout/index.ts +47 -47
- package/src/index.ts +3 -2
- package/src/pages.ts +61 -0
- package/src/test/nativewind-stub.ts +13 -0
- package/src/test/stories-smoke.test.tsx +50 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +50 -0
- package/src/theme/barrel.ts +53 -0
- package/src/theme/index.ts +8 -41
- package/src/theme/native-theming-guard.test.ts +117 -0
- package/src/theme/resolve-color.ts +19 -0
- package/dist/bodymap-BhG55xHM.d.mts +0 -318
- package/dist/bodymap-BhG55xHM.d.ts +0 -318
- package/dist/chunk-7XPB4NAO.mjs +0 -1013
- package/dist/chunk-7XPB4NAO.mjs.map +0 -1
- package/dist/chunk-P7TSQUN6.mjs +0 -846
- package/dist/chunk-P7TSQUN6.mjs.map +0 -1
- package/dist/chunk-TOD2WQBG.mjs +0 -1048
- package/dist/chunk-TOD2WQBG.mjs.map +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, Pressable } from 'react-native'
|
|
3
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
3
4
|
|
|
4
5
|
const BRAND_PRIMARY = '#FF7900'
|
|
5
6
|
|
|
@@ -10,6 +11,8 @@ export interface RestTimerProps {
|
|
|
10
11
|
onAddTime: () => void
|
|
11
12
|
nextSetInfo?: string
|
|
12
13
|
visible: boolean
|
|
14
|
+
/** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
|
|
15
|
+
displayOnly?: boolean
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function RestTimer({
|
|
@@ -19,6 +22,7 @@ export function RestTimer({
|
|
|
19
22
|
onAddTime,
|
|
20
23
|
nextSetInfo,
|
|
21
24
|
visible,
|
|
25
|
+
displayOnly = false,
|
|
22
26
|
}: RestTimerProps) {
|
|
23
27
|
if (!visible) return null
|
|
24
28
|
|
|
@@ -34,11 +38,11 @@ export function RestTimer({
|
|
|
34
38
|
|
|
35
39
|
return (
|
|
36
40
|
<View
|
|
41
|
+
className="bg-surface-raised"
|
|
37
42
|
style={{
|
|
38
43
|
width: '100%',
|
|
39
|
-
backgroundColor: 'var(--color-surface-raised)',
|
|
40
44
|
borderTopWidth: 1,
|
|
41
|
-
borderTopColor: '
|
|
45
|
+
borderTopColor: resolveColor('border-default'),
|
|
42
46
|
paddingVertical: 12,
|
|
43
47
|
paddingHorizontal: 16,
|
|
44
48
|
}}
|
|
@@ -58,13 +62,13 @@ export function RestTimer({
|
|
|
58
62
|
{/* Left side */}
|
|
59
63
|
<View style={{ flexDirection: 'column' }}>
|
|
60
64
|
<Text
|
|
65
|
+
className="text-text-secondary"
|
|
61
66
|
style={{
|
|
62
67
|
fontSize: 11,
|
|
63
68
|
fontFamily: 'Inter, sans-serif',
|
|
64
69
|
fontWeight: '600',
|
|
65
70
|
textTransform: 'uppercase',
|
|
66
71
|
letterSpacing: 1,
|
|
67
|
-
color: 'var(--color-text-secondary)',
|
|
68
72
|
}}
|
|
69
73
|
testID="rest-timer-label"
|
|
70
74
|
>
|
|
@@ -72,10 +76,10 @@ export function RestTimer({
|
|
|
72
76
|
</Text>
|
|
73
77
|
{nextSetInfo != null && (
|
|
74
78
|
<Text
|
|
79
|
+
className="text-text-tertiary"
|
|
75
80
|
style={{
|
|
76
81
|
fontSize: 11,
|
|
77
82
|
fontFamily: 'Inter, sans-serif',
|
|
78
|
-
color: 'var(--color-text-tertiary)',
|
|
79
83
|
marginTop: 2,
|
|
80
84
|
}}
|
|
81
85
|
testID="rest-timer-next-set"
|
|
@@ -87,11 +91,11 @@ export function RestTimer({
|
|
|
87
91
|
|
|
88
92
|
{/* Right side - time display */}
|
|
89
93
|
<Text
|
|
94
|
+
className="text-text-primary"
|
|
90
95
|
style={{
|
|
91
96
|
fontSize: 28,
|
|
92
97
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
93
98
|
fontWeight: '700',
|
|
94
|
-
color: 'var(--color-text-primary)',
|
|
95
99
|
fontVariant: ['tabular-nums'],
|
|
96
100
|
letterSpacing: -0.5,
|
|
97
101
|
}}
|
|
@@ -103,9 +107,9 @@ export function RestTimer({
|
|
|
103
107
|
|
|
104
108
|
{/* Progress bar */}
|
|
105
109
|
<View
|
|
110
|
+
className="bg-border"
|
|
106
111
|
style={{
|
|
107
112
|
height: 3,
|
|
108
|
-
backgroundColor: 'var(--color-border-default)',
|
|
109
113
|
borderRadius: 2,
|
|
110
114
|
marginBottom: 12,
|
|
111
115
|
}}
|
|
@@ -122,55 +126,57 @@ export function RestTimer({
|
|
|
122
126
|
/>
|
|
123
127
|
</View>
|
|
124
128
|
|
|
125
|
-
{/* Actions row */}
|
|
126
|
-
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
131
|
-
paddingVertical: 8,
|
|
132
|
-
paddingHorizontal: 20,
|
|
133
|
-
borderRadius: 8,
|
|
134
|
-
}}
|
|
135
|
-
accessibilityRole="button"
|
|
136
|
-
accessibilityLabel="Add 30 seconds"
|
|
137
|
-
testID="rest-timer-add-time"
|
|
138
|
-
>
|
|
139
|
-
<Text
|
|
129
|
+
{/* Actions row — hidden in display-only (poll-only) mode */}
|
|
130
|
+
{!displayOnly && (
|
|
131
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
132
|
+
<Pressable
|
|
133
|
+
onPress={onAddTime}
|
|
140
134
|
style={{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
135
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
136
|
+
paddingVertical: 8,
|
|
137
|
+
paddingHorizontal: 20,
|
|
138
|
+
borderRadius: 8,
|
|
145
139
|
}}
|
|
140
|
+
accessibilityRole="button"
|
|
141
|
+
accessibilityLabel="Add 30 seconds"
|
|
142
|
+
testID="rest-timer-add-time"
|
|
146
143
|
>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
testID="rest-timer-skip"
|
|
161
|
-
>
|
|
162
|
-
<Text
|
|
144
|
+
<Text
|
|
145
|
+
className="text-text-secondary"
|
|
146
|
+
style={{
|
|
147
|
+
fontSize: 11,
|
|
148
|
+
fontFamily: 'Inter, sans-serif',
|
|
149
|
+
fontWeight: '600',
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
+30s
|
|
153
|
+
</Text>
|
|
154
|
+
</Pressable>
|
|
155
|
+
<Pressable
|
|
156
|
+
onPress={onSkip}
|
|
163
157
|
style={{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
159
|
+
paddingVertical: 8,
|
|
160
|
+
paddingHorizontal: 20,
|
|
161
|
+
borderRadius: 8,
|
|
168
162
|
}}
|
|
163
|
+
accessibilityRole="button"
|
|
164
|
+
accessibilityLabel="Skip rest"
|
|
165
|
+
testID="rest-timer-skip"
|
|
169
166
|
>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
167
|
+
<Text
|
|
168
|
+
style={{
|
|
169
|
+
fontSize: 11,
|
|
170
|
+
fontFamily: 'Inter, sans-serif',
|
|
171
|
+
fontWeight: '600',
|
|
172
|
+
color: BRAND_PRIMARY,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
Skip
|
|
176
|
+
</Text>
|
|
177
|
+
</Pressable>
|
|
178
|
+
</View>
|
|
179
|
+
)}
|
|
174
180
|
</View>
|
|
175
181
|
)
|
|
176
182
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text } from 'react-native'
|
|
3
|
-
import { VelocityStrip } from './VelocityStrip'
|
|
3
|
+
import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
|
|
4
4
|
import { PrBadge, type PRType } from './PrBadge'
|
|
5
5
|
import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
|
|
6
6
|
|
|
@@ -14,7 +14,12 @@ export interface SetRowProps {
|
|
|
14
14
|
weight: number | null
|
|
15
15
|
rpe?: number | null
|
|
16
16
|
unit: 'lbs' | 'kg'
|
|
17
|
+
/** Per-rep MEAN concentric velocities (m/s). */
|
|
17
18
|
velocities?: number[]
|
|
19
|
+
/** Optional velocity-zone bands (WA `VelocityZones.bands`); default scale when absent. */
|
|
20
|
+
velocityZones?: readonly VelocityZoneBandProp[]
|
|
21
|
+
/** Live mode: index of the newest rep bar to animate (pop / new-peak bounce). */
|
|
22
|
+
velocityLiveRepIndex?: number
|
|
18
23
|
velocityExpanded?: boolean
|
|
19
24
|
onVelocityToggle?: () => void
|
|
20
25
|
setType?: string
|
|
@@ -43,6 +48,8 @@ export function SetRow({
|
|
|
43
48
|
rpe,
|
|
44
49
|
unit,
|
|
45
50
|
velocities,
|
|
51
|
+
velocityZones,
|
|
52
|
+
velocityLiveRepIndex,
|
|
46
53
|
velocityExpanded,
|
|
47
54
|
onVelocityToggle,
|
|
48
55
|
setType,
|
|
@@ -106,11 +113,11 @@ export function SetRow({
|
|
|
106
113
|
</Text>
|
|
107
114
|
) : (
|
|
108
115
|
<Text
|
|
116
|
+
className="text-text-secondary"
|
|
109
117
|
style={{
|
|
110
118
|
fontSize: 13,
|
|
111
119
|
fontWeight: '600',
|
|
112
120
|
fontFamily: 'Inter, sans-serif',
|
|
113
|
-
color: 'var(--color-text-secondary)',
|
|
114
121
|
}}
|
|
115
122
|
>
|
|
116
123
|
{setNumber}
|
|
@@ -123,10 +130,10 @@ export function SetRow({
|
|
|
123
130
|
testID="set-row-previous"
|
|
124
131
|
>
|
|
125
132
|
<Text
|
|
133
|
+
className="text-text-secondary"
|
|
126
134
|
style={{
|
|
127
135
|
fontSize: 12,
|
|
128
136
|
fontFamily: 'Inter, sans-serif',
|
|
129
|
-
color: 'var(--color-text-secondary)',
|
|
130
137
|
}}
|
|
131
138
|
>
|
|
132
139
|
{previous
|
|
@@ -141,11 +148,11 @@ export function SetRow({
|
|
|
141
148
|
>
|
|
142
149
|
{isActive && reps == null && targets ? (
|
|
143
150
|
<Text
|
|
151
|
+
className="text-text-tertiary"
|
|
144
152
|
style={{
|
|
145
153
|
fontSize: 13,
|
|
146
154
|
fontStyle: 'italic',
|
|
147
155
|
fontFamily: 'Inter, sans-serif',
|
|
148
|
-
color: 'var(--color-text-tertiary)',
|
|
149
156
|
}}
|
|
150
157
|
testID="set-row-target-reps"
|
|
151
158
|
>
|
|
@@ -153,11 +160,11 @@ export function SetRow({
|
|
|
153
160
|
</Text>
|
|
154
161
|
) : (
|
|
155
162
|
<Text
|
|
163
|
+
className={reps != null ? 'text-text-primary' : 'text-text-tertiary'}
|
|
156
164
|
style={{
|
|
157
165
|
fontSize: 14,
|
|
158
166
|
fontWeight: '600',
|
|
159
167
|
fontFamily: 'Inter, sans-serif',
|
|
160
|
-
color: reps != null ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)',
|
|
161
168
|
}}
|
|
162
169
|
>
|
|
163
170
|
{reps != null ? reps : '\u2014'}
|
|
@@ -171,11 +178,11 @@ export function SetRow({
|
|
|
171
178
|
>
|
|
172
179
|
{isActive && weight == null && targets ? (
|
|
173
180
|
<Text
|
|
181
|
+
className="text-text-tertiary"
|
|
174
182
|
style={{
|
|
175
183
|
fontSize: 13,
|
|
176
184
|
fontStyle: 'italic',
|
|
177
185
|
fontFamily: 'Inter, sans-serif',
|
|
178
|
-
color: 'var(--color-text-tertiary)',
|
|
179
186
|
}}
|
|
180
187
|
testID="set-row-target-weight"
|
|
181
188
|
>
|
|
@@ -183,11 +190,11 @@ export function SetRow({
|
|
|
183
190
|
</Text>
|
|
184
191
|
) : (
|
|
185
192
|
<Text
|
|
193
|
+
className={weight != null ? 'text-text-primary' : 'text-text-tertiary'}
|
|
186
194
|
style={{
|
|
187
195
|
fontSize: 14,
|
|
188
196
|
fontWeight: '600',
|
|
189
197
|
fontFamily: 'Inter, sans-serif',
|
|
190
|
-
color: weight != null ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)',
|
|
191
198
|
}}
|
|
192
199
|
>
|
|
193
200
|
{weight != null ? roundWeight(weight) : '\u2014'}
|
|
@@ -200,11 +207,14 @@ export function SetRow({
|
|
|
200
207
|
testID="set-row-rpe"
|
|
201
208
|
>
|
|
202
209
|
<Text
|
|
210
|
+
className={rpe != null ? undefined : 'text-text-tertiary'}
|
|
203
211
|
style={{
|
|
204
212
|
fontSize: 13,
|
|
205
213
|
fontWeight: '600',
|
|
206
214
|
fontFamily: 'Inter, sans-serif',
|
|
207
|
-
|
|
215
|
+
// rpeColor returns a concrete hex (native-safe); only the tertiary
|
|
216
|
+
// fallback needs the className token.
|
|
217
|
+
color: rpe != null ? rpeColor(rpe) : undefined,
|
|
208
218
|
}}
|
|
209
219
|
>
|
|
210
220
|
{rpe != null ? roundRpe(rpe) : '\u2014'}
|
|
@@ -236,6 +246,8 @@ export function SetRow({
|
|
|
236
246
|
>
|
|
237
247
|
<VelocityStrip
|
|
238
248
|
velocities={velocities}
|
|
249
|
+
zones={velocityZones}
|
|
250
|
+
liveRepIndex={velocityLiveRepIndex}
|
|
239
251
|
expanded={velocityExpanded}
|
|
240
252
|
onToggle={onVelocityToggle}
|
|
241
253
|
variant={onVelocityToggle ? 'full' : 'mini'}
|
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
import { useEffect, useMemo, useState } from 'react'
|
|
3
3
|
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
4
|
import { cn } from '../../../utils/cn'
|
|
5
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
5
6
|
|
|
6
7
|
const BRAND_PRIMARY = '#FF7900'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const BORDER_STRONG = 'var(--color-border-strong)'
|
|
8
|
+
// Line/border color for the dashed projection, used where a className cannot
|
|
9
|
+
// apply (passed as a prop into an inline style, and a per-edge borderTopColor).
|
|
10
|
+
const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
|
|
11
11
|
const STATUS_SUCCESS = '#14B8A6'
|
|
12
12
|
const STATUS_ERROR = '#D14343'
|
|
13
13
|
const STATUS_WARNING = '#FFB020'
|
|
14
14
|
const GRID_LINE = 'rgba(255,255,255,0.06)'
|
|
15
|
-
const TOOLTIP_BG = 'var(--color-surface-elevated)'
|
|
16
15
|
const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
|
|
17
16
|
const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
|
|
18
17
|
const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
|
|
@@ -312,7 +311,7 @@ export function StrengthTrendChart({
|
|
|
312
311
|
justifyContent: 'center',
|
|
313
312
|
}}
|
|
314
313
|
>
|
|
315
|
-
<Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif'
|
|
314
|
+
<Text className="text-text-tertiary" style={{ fontSize: 12, fontFamily: 'Inter, sans-serif' }}>
|
|
316
315
|
No strength data yet
|
|
317
316
|
</Text>
|
|
318
317
|
</View>
|
|
@@ -358,6 +357,7 @@ export function StrengthTrendChart({
|
|
|
358
357
|
}}
|
|
359
358
|
>
|
|
360
359
|
<Text
|
|
360
|
+
className="text-text-tertiary"
|
|
361
361
|
style={{
|
|
362
362
|
position: 'absolute',
|
|
363
363
|
left: -PLOT_LEFT,
|
|
@@ -366,7 +366,6 @@ export function StrengthTrendChart({
|
|
|
366
366
|
textAlign: 'right',
|
|
367
367
|
fontSize: 9,
|
|
368
368
|
fontFamily: 'Inter, sans-serif',
|
|
369
|
-
color: TEXT_TERTIARY,
|
|
370
369
|
}}
|
|
371
370
|
>
|
|
372
371
|
{line.label}
|
|
@@ -406,7 +405,7 @@ export function StrengthTrendChart({
|
|
|
406
405
|
<View style={{ width, height, position: 'absolute', top: 0, left: 0 }}>
|
|
407
406
|
<ChartLine
|
|
408
407
|
coords={geometry.projection}
|
|
409
|
-
color={
|
|
408
|
+
color={PROJECTION_LINE_COLOR}
|
|
410
409
|
strokeWidth={2}
|
|
411
410
|
dashed
|
|
412
411
|
testID="strength-trend-chart-projection-segment"
|
|
@@ -475,6 +474,7 @@ export function StrengthTrendChart({
|
|
|
475
474
|
<View
|
|
476
475
|
testID="strength-trend-chart-tooltip"
|
|
477
476
|
accessibilityElementsHidden
|
|
477
|
+
className="bg-surface-elevated border-border-strong"
|
|
478
478
|
style={{
|
|
479
479
|
position: 'absolute',
|
|
480
480
|
left: Math.max(
|
|
@@ -483,30 +483,28 @@ export function StrengthTrendChart({
|
|
|
483
483
|
),
|
|
484
484
|
top: Math.max(0, selectedCoord.y - 52),
|
|
485
485
|
maxWidth: TOOLTIP_WIDTH,
|
|
486
|
-
backgroundColor: TOOLTIP_BG,
|
|
487
486
|
borderWidth: 1,
|
|
488
|
-
borderColor: BORDER_STRONG,
|
|
489
487
|
borderRadius: 8,
|
|
490
488
|
paddingVertical: 8,
|
|
491
489
|
paddingHorizontal: 10,
|
|
492
490
|
}}
|
|
493
491
|
>
|
|
494
492
|
<Text
|
|
493
|
+
className="text-text-tertiary"
|
|
495
494
|
style={{
|
|
496
495
|
fontSize: 10,
|
|
497
496
|
fontFamily: 'Inter, sans-serif',
|
|
498
|
-
color: TEXT_TERTIARY,
|
|
499
497
|
}}
|
|
500
498
|
>
|
|
501
499
|
{selectedCoord.point.sessionLabel ?? selectedCoord.point.date}
|
|
502
500
|
</Text>
|
|
503
501
|
<Text
|
|
502
|
+
className="text-text-primary"
|
|
504
503
|
style={{
|
|
505
504
|
marginTop: 2,
|
|
506
505
|
fontSize: 13,
|
|
507
506
|
fontFamily: 'Inter, sans-serif',
|
|
508
507
|
fontWeight: '700',
|
|
509
|
-
color: TEXT_PRIMARY,
|
|
510
508
|
}}
|
|
511
509
|
>
|
|
512
510
|
{`${formatValue(selectedCoord.point.e1rm)} ${unit}`}
|
|
@@ -547,6 +545,7 @@ export function StrengthTrendChart({
|
|
|
547
545
|
<Text
|
|
548
546
|
key={`axis-${i}`}
|
|
549
547
|
testID="strength-trend-chart-axis-label"
|
|
548
|
+
className="text-text-tertiary"
|
|
550
549
|
style={{
|
|
551
550
|
position: 'absolute',
|
|
552
551
|
left: boundary.x - 24,
|
|
@@ -554,7 +553,6 @@ export function StrengthTrendChart({
|
|
|
554
553
|
textAlign: 'center',
|
|
555
554
|
fontSize: 10,
|
|
556
555
|
fontFamily: 'Inter, sans-serif',
|
|
557
|
-
color: TEXT_TERTIARY,
|
|
558
556
|
}}
|
|
559
557
|
>
|
|
560
558
|
{boundary.label}
|
|
@@ -598,7 +596,7 @@ export function StrengthTrendChart({
|
|
|
598
596
|
>
|
|
599
597
|
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
600
598
|
<View style={{ width: 14, height: 2.5, borderRadius: 1.5, backgroundColor: BRAND_PRIMARY }} />
|
|
601
|
-
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif'
|
|
599
|
+
<Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
|
|
602
600
|
Actual
|
|
603
601
|
</Text>
|
|
604
602
|
</View>
|
|
@@ -609,16 +607,16 @@ export function StrengthTrendChart({
|
|
|
609
607
|
height: 0,
|
|
610
608
|
borderTopWidth: 2,
|
|
611
609
|
borderStyle: 'dashed',
|
|
612
|
-
borderTopColor:
|
|
610
|
+
borderTopColor: PROJECTION_LINE_COLOR,
|
|
613
611
|
}}
|
|
614
612
|
/>
|
|
615
|
-
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif'
|
|
613
|
+
<Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
|
|
616
614
|
Projected
|
|
617
615
|
</Text>
|
|
618
616
|
</View>
|
|
619
617
|
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
620
618
|
<Text style={{ fontSize: 12, color: STATUS_WARNING }}>★</Text>
|
|
621
|
-
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif'
|
|
619
|
+
<Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
|
|
622
620
|
PR
|
|
623
621
|
</Text>
|
|
624
622
|
</View>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { TempoBar } from './TempoBar'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof TempoBar> = {
|
|
6
|
+
title: 'Custom/Workout/TempoBar',
|
|
7
|
+
component: TempoBar,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
activePhase: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: [null, 'concentric', 'hold', 'eccentric'],
|
|
13
|
+
description: 'Phase currently in progress (null = idle)',
|
|
14
|
+
},
|
|
15
|
+
phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
|
|
16
|
+
completed: { control: 'object', description: 'Completed phase durations (ms)' },
|
|
17
|
+
target: { control: 'object', description: 'Per-phase target durations (seconds)' },
|
|
18
|
+
},
|
|
19
|
+
decorators: [
|
|
20
|
+
(Story) => (
|
|
21
|
+
<View style={{ width: 240 }}>
|
|
22
|
+
<Story />
|
|
23
|
+
</View>
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default meta
|
|
29
|
+
type Story = StoryObj<typeof TempoBar>
|
|
30
|
+
|
|
31
|
+
// tempo target: 1s concentric, 1s hold-top, 3s eccentric
|
|
32
|
+
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
33
|
+
|
|
34
|
+
export const Idle: Story = {
|
|
35
|
+
args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const ConcentricActive: Story = {
|
|
39
|
+
args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const EccentricMidRep: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
activePhase: 'eccentric',
|
|
45
|
+
phaseElapsedMs: 1500,
|
|
46
|
+
completed: { concentric: 900, hold: 1000 },
|
|
47
|
+
target: TARGET,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const BehindPace: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
activePhase: 'eccentric',
|
|
54
|
+
phaseElapsedMs: 4200,
|
|
55
|
+
completed: { concentric: 1600, hold: 1000 },
|
|
56
|
+
target: TARGET,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const RepComplete: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
activePhase: null,
|
|
63
|
+
phaseElapsedMs: 0,
|
|
64
|
+
completed: { concentric: 950, hold: 1050, eccentric: 3100 },
|
|
65
|
+
target: TARGET,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const NoTarget: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
activePhase: 'concentric',
|
|
72
|
+
phaseElapsedMs: 800,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const AllStates: Story = {
|
|
77
|
+
render: () => (
|
|
78
|
+
<View style={{ gap: 20 }}>
|
|
79
|
+
<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
|
|
80
|
+
<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
|
|
81
|
+
<TempoBar
|
|
82
|
+
activePhase="eccentric"
|
|
83
|
+
phaseElapsedMs={1500}
|
|
84
|
+
completed={{ concentric: 900, hold: 1000 }}
|
|
85
|
+
target={TARGET}
|
|
86
|
+
/>
|
|
87
|
+
<TempoBar
|
|
88
|
+
activePhase={null}
|
|
89
|
+
phaseElapsedMs={0}
|
|
90
|
+
completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
|
|
91
|
+
target={TARGET}
|
|
92
|
+
/>
|
|
93
|
+
</View>
|
|
94
|
+
),
|
|
95
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import {
|
|
5
|
+
TempoBar,
|
|
6
|
+
getTempoPacingState,
|
|
7
|
+
getTempoFillPct,
|
|
8
|
+
TEMPO_PACING,
|
|
9
|
+
} from './TempoBar'
|
|
10
|
+
|
|
11
|
+
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
12
|
+
|
|
13
|
+
describe('getTempoPacingState', () => {
|
|
14
|
+
it('returns none when there is no target', () => {
|
|
15
|
+
expect(getTempoPacingState(1000, null)).toBe('none')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns none for a sub-threshold target', () => {
|
|
19
|
+
expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns on-pace when within the behind threshold', () => {
|
|
23
|
+
expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
|
|
24
|
+
expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns behind once elapsed exceeds the behind threshold', () => {
|
|
28
|
+
expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('getTempoFillPct', () => {
|
|
33
|
+
it('fills fully when there is no target', () => {
|
|
34
|
+
expect(getTempoFillPct(500, null)).toBe(100)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('is proportional to elapsed vs target', () => {
|
|
38
|
+
expect(getTempoFillPct(500, 1000)).toBe(50)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('clamps at 100 when past target', () => {
|
|
42
|
+
expect(getTempoFillPct(2000, 1000)).toBe(100)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('TempoBar', () => {
|
|
47
|
+
it('renders the bar with phase labels', () => {
|
|
48
|
+
render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
|
|
49
|
+
expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
|
|
50
|
+
expect(screen.getByText('Con')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('Hold')).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByText('Ecc')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders the active segment with an elapsed/target label', () => {
|
|
56
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
|
|
57
|
+
expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
|
|
58
|
+
expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('shows only elapsed when the active phase has no target', () => {
|
|
62
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
|
|
63
|
+
expect(screen.getByText('0.8')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('marks a completed phase that hit its target with a check', () => {
|
|
67
|
+
render(
|
|
68
|
+
<TempoBar
|
|
69
|
+
activePhase="hold"
|
|
70
|
+
phaseElapsedMs={100}
|
|
71
|
+
completed={{ concentric: 950 }}
|
|
72
|
+
target={TARGET}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
|
|
76
|
+
expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('marks a completed phase that missed its target with a cross', () => {
|
|
80
|
+
render(
|
|
81
|
+
<TempoBar
|
|
82
|
+
activePhase="hold"
|
|
83
|
+
phaseElapsedMs={100}
|
|
84
|
+
completed={{ eccentric: 4000 }}
|
|
85
|
+
target={TARGET}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
// eccentric target 3s, 4s elapsed => behind => cross
|
|
89
|
+
expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('has no accessibility violations', async () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<TempoBar
|
|
95
|
+
activePhase="eccentric"
|
|
96
|
+
phaseElapsedMs={1500}
|
|
97
|
+
completed={{ concentric: 900, hold: 1000 }}
|
|
98
|
+
target={TARGET}
|
|
99
|
+
/>,
|
|
100
|
+
)
|
|
101
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
102
|
+
})
|
|
103
|
+
})
|