@titan-design/react-ui 0.4.0 → 0.6.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 +232 -185
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +1963 -11
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +193 -744
- package/dist/index.d.ts +193 -744
- package/dist/index.js +1551 -1856
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10770 -8213
- 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 +6694 -0
- package/dist/pages.js.map +1 -0
- package/dist/pages.mjs +6674 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/theme/index.d.mts +473 -211
- package/dist/theme/index.d.ts +473 -211
- package/dist/theme/index.js +341 -127
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2085 -2
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +148 -139
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +582 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +11 -7
- package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
- package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
- package/src/components/custom/DateTime/DateTime.tsx +55 -15
- package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
- package/src/components/custom/Gauge/Gauge.tsx +6 -3
- package/src/components/custom/Scatter/Scatter.tsx +3 -5
- package/src/components/custom/Treemap/Treemap.tsx +3 -5
- package/src/components/custom/Typography/Typography.stories.tsx +45 -27
- package/src/components/custom/Typography/Typography.test.tsx +13 -3
- package/src/components/custom/Typography/Typography.tsx +5 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
- 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 +6 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
- package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
- package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.tsx +7 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +19 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -1
- package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
- package/src/components/custom/Workout/IntensityBar.tsx +113 -58
- package/src/components/custom/Workout/MesoCard.tsx +8 -3
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
- package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
- package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
- package/src/components/custom/Workout/PrBadge.tsx +7 -4
- package/src/components/custom/Workout/PrHistoryModal.tsx +6 -5
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
- package/src/components/custom/Workout/README.md +50 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
- package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
- package/src/components/custom/Workout/RestTimer.tsx +50 -44
- package/src/components/custom/Workout/SetRow.tsx +12 -2
- package/src/components/custom/Workout/Sparkline.tsx +2 -1
- package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
- package/src/components/custom/Workout/StatusDot.tsx +12 -9
- package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
- package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
- 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 +109 -3
- package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
- package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
- package/src/components/custom/Workout/WeekRow.tsx +3 -2
- package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.tsx +8 -4
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
- package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
- package/src/components/custom/Workout/icons.tsx +7 -0
- package/src/components/custom/Workout/index.ts +38 -47
- package/src/components/icons/SvgIcon.tsx +50 -0
- package/src/components/icons/icons.stories.tsx +57 -0
- package/src/components/icons/icons.test.tsx +43 -0
- package/src/components/icons/icons.tsx +45 -0
- package/src/components/icons/index.ts +4 -0
- package/src/components/index.ts +6 -0
- package/src/components/shell/BrandLockup.stories.tsx +27 -0
- package/src/components/shell/BrandLockup.test.tsx +23 -0
- package/src/components/shell/BrandLockup.tsx +47 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
- package/src/components/shell/DeviceIndicator.test.tsx +26 -0
- package/src/components/shell/DeviceIndicator.tsx +52 -0
- package/src/components/shell/DeviceMenu.stories.tsx +47 -0
- package/src/components/shell/DeviceMenu.test.tsx +40 -0
- package/src/components/shell/DeviceMenu.tsx +65 -0
- package/src/components/shell/DeviceRow.stories.tsx +38 -0
- package/src/components/shell/DeviceRow.test.tsx +37 -0
- package/src/components/shell/DeviceRow.tsx +58 -0
- package/src/components/shell/README.md +86 -0
- package/src/components/shell/SessionStatePill.stories.tsx +27 -0
- package/src/components/shell/SessionStatePill.test.tsx +25 -0
- package/src/components/shell/SessionStatePill.tsx +46 -0
- package/src/components/shell/TopBar.stories.tsx +49 -0
- package/src/components/shell/TopBar.test.tsx +36 -0
- package/src/components/shell/TopBar.tsx +89 -0
- package/src/components/shell/index.ts +8 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
- package/src/components/ui/indicator/Indicator.test.tsx +19 -1
- package/src/components/ui/indicator/Indicator.tsx +45 -6
- package/src/components/ui/indicator/index.ts +1 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -2
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
- package/src/index.ts +3 -2
- package/src/pages.ts +61 -0
- package/src/test/stories-smoke.test.tsx +50 -0
- package/src/theme/ColorSystem.stories.tsx +323 -0
- package/src/theme/Colors.stories.tsx +69 -1
- package/src/theme/barrel.ts +54 -0
- package/src/theme/config.ts +16 -3
- package/src/theme/extracted-colors-dataviz.ts +21 -0
- package/src/theme/extracted-colors-ui.ts +20 -0
- package/src/theme/global.css +64 -52
- package/src/theme/gradients.test.ts +25 -0
- package/src/theme/gradients.ts +36 -0
- package/src/theme/index.ts +8 -43
- package/src/theme/native-theming-guard.test.ts +117 -0
- package/src/theme/tokens/primitives.test.ts +232 -0
- package/src/theme/tokens/primitives.ts +247 -0
- package/src/theme/tokens/semantic.ts +93 -81
- package/src/theme/workout-tokens.ts +22 -21
- package/src/utils/avatar-color.ts +3 -3
- package/tailwind.config.js +10 -3
- package/dist/bodymap-BhG55xHM.d.mts +0 -318
- package/dist/bodymap-BhG55xHM.d.ts +0 -318
- package/dist/chunk-2SISKTWM.mjs +0 -1027
- package/dist/chunk-2SISKTWM.mjs.map +0 -1
- package/dist/chunk-7XPB4NAO.mjs +0 -1013
- package/dist/chunk-7XPB4NAO.mjs.map +0 -1
- package/dist/chunk-SNVKL5WZ.mjs +0 -883
- package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
import { useState, useCallback } from 'react'
|
|
3
3
|
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
4
4
|
import { roundTempo } from '../../../utils/workout-format'
|
|
5
|
+
import { alpha } from '../../../utils/colors'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
7
|
+
import { getTempoFillPct, getTempoPacingState } from './TempoBar'
|
|
8
|
+
|
|
9
|
+
const t = getSemanticColors('dark')
|
|
10
|
+
|
|
11
|
+
/** The four tempo phases, in the order the display renders them. */
|
|
12
|
+
export type TempoLivePhase = 'eccentric' | 'pauseBottom' | 'concentric' | 'pauseTop'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Live rep state driving the phase-fill overlay. Controlled by the consumer
|
|
16
|
+
* (matching titan's `elapsedMs`-in convention) so the same real timer feeds
|
|
17
|
+
* both web dashboard and native app — TempoDisplay owns rendering only.
|
|
18
|
+
*/
|
|
19
|
+
export interface TempoLiveState {
|
|
20
|
+
/** Phase currently in progress, or null when idle/at rest. */
|
|
21
|
+
activePhase: TempoLivePhase | null
|
|
22
|
+
/** Elapsed time (ms) within the active phase. */
|
|
23
|
+
phaseElapsedMs: number
|
|
24
|
+
}
|
|
5
25
|
|
|
6
26
|
export interface TempoDisplayProps extends ViewProps {
|
|
7
27
|
/** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
|
|
@@ -11,18 +31,25 @@ export interface TempoDisplayProps extends ViewProps {
|
|
|
11
31
|
colored?: boolean
|
|
12
32
|
/** Show info tooltip on press */
|
|
13
33
|
showInfo?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* When set, renders a live phase-fill: the active phase digit fills against
|
|
36
|
+
* its prescribed duration and inactive phases dim. Omit for the static
|
|
37
|
+
* prescription string (default, backward compatible).
|
|
38
|
+
*/
|
|
39
|
+
live?: TempoLiveState
|
|
14
40
|
onPress?: () => void
|
|
15
41
|
className?: string
|
|
16
42
|
}
|
|
17
43
|
|
|
18
44
|
const INTER = 'Inter, sans-serif'
|
|
19
45
|
const TEXT_TERTIARY = '#6B7280'
|
|
46
|
+
const STATUS_ERROR = t['status-error'] // shown when a phase runs behind pace
|
|
20
47
|
|
|
21
48
|
// Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
|
|
22
49
|
const phaseColors = {
|
|
23
|
-
eccentric: '
|
|
50
|
+
eccentric: t['status-warning'],
|
|
24
51
|
pauseBottom: TEXT_TERTIARY,
|
|
25
|
-
concentric: '
|
|
52
|
+
concentric: t['status-success'],
|
|
26
53
|
pauseTop: TEXT_TERTIARY,
|
|
27
54
|
dash: TEXT_TERTIARY,
|
|
28
55
|
}
|
|
@@ -67,11 +94,84 @@ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }
|
|
|
67
94
|
)
|
|
68
95
|
}
|
|
69
96
|
|
|
97
|
+
// Phase order + color matching the tempo tuple, for the live phase-fill row.
|
|
98
|
+
const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
|
|
99
|
+
{ key: 'eccentric', color: phaseColors.eccentric },
|
|
100
|
+
{ key: 'pauseBottom', color: phaseColors.pauseBottom },
|
|
101
|
+
{ key: 'concentric', color: phaseColors.concentric },
|
|
102
|
+
{ key: 'pauseTop', color: phaseColors.pauseTop },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
function LiveTempoCell({
|
|
106
|
+
value,
|
|
107
|
+
color,
|
|
108
|
+
isActive,
|
|
109
|
+
phaseElapsedMs,
|
|
110
|
+
fontSize,
|
|
111
|
+
}: {
|
|
112
|
+
value: number
|
|
113
|
+
color: string
|
|
114
|
+
isActive: boolean
|
|
115
|
+
phaseElapsedMs: number
|
|
116
|
+
fontSize: number
|
|
117
|
+
}) {
|
|
118
|
+
if (!isActive) {
|
|
119
|
+
return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
|
|
120
|
+
}
|
|
121
|
+
const targetMs = value > 0 ? value * 1000 : null
|
|
122
|
+
const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
|
|
123
|
+
const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
|
|
124
|
+
return (
|
|
125
|
+
<View style={{ position: 'relative' }} testID="tempo-live-active">
|
|
126
|
+
<View
|
|
127
|
+
style={{
|
|
128
|
+
position: 'absolute',
|
|
129
|
+
left: 0,
|
|
130
|
+
top: 0,
|
|
131
|
+
bottom: 0,
|
|
132
|
+
width: `${fillPct}%`,
|
|
133
|
+
backgroundColor: alpha(barColor, 0.3),
|
|
134
|
+
borderRadius: 2,
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
<TempoValue value={value} color={barColor} fontSize={fontSize} />
|
|
138
|
+
</View>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function LiveTempoRow({
|
|
143
|
+
values,
|
|
144
|
+
live,
|
|
145
|
+
fontSize,
|
|
146
|
+
}: {
|
|
147
|
+
values: [number, number, number, number]
|
|
148
|
+
live: TempoLiveState
|
|
149
|
+
fontSize: number
|
|
150
|
+
}) {
|
|
151
|
+
return (
|
|
152
|
+
<>
|
|
153
|
+
{LIVE_PHASES.map((phase, i) => (
|
|
154
|
+
<View key={phase.key} style={{ flexDirection: 'row' }}>
|
|
155
|
+
{i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
|
|
156
|
+
<LiveTempoCell
|
|
157
|
+
value={values[i]}
|
|
158
|
+
color={phase.color}
|
|
159
|
+
isActive={live.activePhase === phase.key}
|
|
160
|
+
phaseElapsedMs={live.phaseElapsedMs}
|
|
161
|
+
fontSize={fontSize}
|
|
162
|
+
/>
|
|
163
|
+
</View>
|
|
164
|
+
))}
|
|
165
|
+
</>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
70
169
|
export function TempoDisplay({
|
|
71
170
|
tempo,
|
|
72
171
|
size = 'md',
|
|
73
172
|
colored = true,
|
|
74
173
|
showInfo = true,
|
|
174
|
+
live,
|
|
75
175
|
onPress,
|
|
76
176
|
className,
|
|
77
177
|
...props
|
|
@@ -119,7 +219,13 @@ export function TempoDisplay({
|
|
|
119
219
|
TEMPO
|
|
120
220
|
</Text>
|
|
121
221
|
<View style={{ flexDirection: 'row' }} testID="tempo-value">
|
|
122
|
-
{
|
|
222
|
+
{live ? (
|
|
223
|
+
<LiveTempoRow
|
|
224
|
+
values={[eccentric, pauseBottom, concentric, pauseTop]}
|
|
225
|
+
live={live}
|
|
226
|
+
fontSize={fontSize}
|
|
227
|
+
/>
|
|
228
|
+
) : colored ? (
|
|
123
229
|
<>
|
|
124
230
|
<TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
|
|
125
231
|
<TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { axe } from 'jest-axe'
|
|
4
4
|
import {
|
|
@@ -210,10 +210,26 @@ describe('getVelocityZoneName', () => {
|
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
describe('calculateVelocityLoss', () => {
|
|
213
|
-
it('calculates percentage loss from
|
|
213
|
+
it('calculates percentage loss from running-best to last rep', () => {
|
|
214
|
+
// Best is the first rep here, so best->last matches the legacy result.
|
|
214
215
|
expect(calculateVelocityLoss([1.0, 0.8])).toBe(20)
|
|
215
216
|
})
|
|
216
217
|
|
|
218
|
+
it('uses the best rep (not the first) as the reference for non-monotonic sets', () => {
|
|
219
|
+
// best = 1.0 (rep 2), last = 0.6 -> (1.0 - 0.6) / 1.0 = 40%.
|
|
220
|
+
// Legacy first->last would have been (0.8 - 0.6) / 0.8 = 25%.
|
|
221
|
+
expect(calculateVelocityLoss([0.8, 1.0, 0.6])).toBe(40)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('clamps to 0 when the set ends on its best rep', () => {
|
|
225
|
+
expect(calculateVelocityLoss([0.6, 0.8, 1.0])).toBe(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('clamps to 0 when a later rep exceeds the first rep', () => {
|
|
229
|
+
// best = 1.2 (last), so loss is negative pre-clamp -> 0.
|
|
230
|
+
expect(calculateVelocityLoss([1.0, 0.9, 1.2])).toBe(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
217
233
|
it('returns 0 for single rep', () => {
|
|
218
234
|
expect(calculateVelocityLoss([1.0])).toBe(0)
|
|
219
235
|
})
|
|
@@ -221,6 +237,10 @@ describe('calculateVelocityLoss', () => {
|
|
|
221
237
|
it('returns 0 for empty array', () => {
|
|
222
238
|
expect(calculateVelocityLoss([])).toBe(0)
|
|
223
239
|
})
|
|
240
|
+
|
|
241
|
+
it('returns 0 when the best rep is non-positive', () => {
|
|
242
|
+
expect(calculateVelocityLoss([0, 0])).toBe(0)
|
|
243
|
+
})
|
|
224
244
|
})
|
|
225
245
|
|
|
226
246
|
describe('calculateMeanVelocity', () => {
|
|
@@ -233,6 +253,73 @@ describe('calculateMeanVelocity', () => {
|
|
|
233
253
|
})
|
|
234
254
|
})
|
|
235
255
|
|
|
256
|
+
// WA-shaped 5-band zone set (compound movement-class defaults, mean m/s).
|
|
257
|
+
const compoundBands = [
|
|
258
|
+
{ id: 'grinding', label: 'Grinding', min: 0, max: 0.35 },
|
|
259
|
+
{ id: 'maximalStrength', label: 'Max Strength', min: 0.35, max: 0.5 },
|
|
260
|
+
{ id: 'strengthSpeed', label: 'Strength-Speed', min: 0.5, max: 0.75 },
|
|
261
|
+
{ id: 'power', label: 'Power', min: 0.75, max: 1.0 },
|
|
262
|
+
{ id: 'speed', label: 'Speed', min: 1.0, max: null },
|
|
263
|
+
] as const
|
|
264
|
+
|
|
265
|
+
describe('VelocityStrip zones prop', () => {
|
|
266
|
+
it('colors bars from the supplied bands (mini)', () => {
|
|
267
|
+
render(<VelocityStrip velocities={[1.1, 0.45]} zones={compoundBands} variant="mini" />)
|
|
268
|
+
// 1.1 -> speed -> green; 0.45 -> maximalStrength -> red (shared with grinding).
|
|
269
|
+
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ED573' })
|
|
270
|
+
expect(screen.getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: '#D14343' })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('labels the summary row with the band containing the mean velocity', () => {
|
|
274
|
+
// mean of the sample = 0.80 -> power band.
|
|
275
|
+
render(<VelocityStrip velocities={sampleVelocities} zones={compoundBands} expanded />)
|
|
276
|
+
expect(screen.getByTestId('velocity-info-row')).toHaveTextContent('Power')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('falls back to the default scale when zones is absent', () => {
|
|
280
|
+
render(<VelocityStrip velocities={[1.1]} variant="mini" />)
|
|
281
|
+
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ED573' })
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('VelocityStrip live mode', () => {
|
|
286
|
+
const originalMatchMedia = window.matchMedia
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
if (originalMatchMedia) window.matchMedia = originalMatchMedia
|
|
290
|
+
else delete (window as { matchMedia?: unknown }).matchMedia
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('marks the latest rep bar in its accessibility label', () => {
|
|
294
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={3} expanded />)
|
|
295
|
+
expect(
|
|
296
|
+
screen.getByLabelText(/Rep 4: 0\.68 meters per second, latest rep$/),
|
|
297
|
+
).toBeInTheDocument()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('flags a new set peak on the latest bar', () => {
|
|
301
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
|
|
302
|
+
expect(
|
|
303
|
+
screen.getByLabelText(/Rep 1: 1\.10 meters per second, latest rep, new set peak$/),
|
|
304
|
+
).toBeInTheDocument()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('renders without animation when prefers-reduced-motion is set', () => {
|
|
308
|
+
window.matchMedia = vi.fn().mockReturnValue({
|
|
309
|
+
matches: true,
|
|
310
|
+
addEventListener: vi.fn(),
|
|
311
|
+
removeEventListener: vi.fn(),
|
|
312
|
+
}) as unknown as typeof window.matchMedia
|
|
313
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
|
|
314
|
+
expect(screen.getByTestId('velocity-bar-0')).toBeInTheDocument()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('does not apply live marking in the mini variant', () => {
|
|
318
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} variant="mini" />)
|
|
319
|
+
expect(screen.queryByLabelText(/latest rep/)).not.toBeInTheDocument()
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
236
323
|
describe('VelocityStrip all-zero velocities (NaN guard)', () => {
|
|
237
324
|
it('emits a valid 0% bar height (not NaN) when every velocity is zero (expanded)', () => {
|
|
238
325
|
render(<VelocityStrip velocities={[0, 0, 0]} expanded />)
|
|
@@ -1,11 +1,54 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useEffect, useState } from 'react'
|
|
3
|
-
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
3
|
+
import { View, Text, Pressable, Animated, Easing, type ViewProps, type ViewStyle } from 'react-native'
|
|
4
4
|
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
5
5
|
import { formatVelocity } from '../../../utils/workout-format'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Structural velocity-zone band accepted from an upstream analytics source
|
|
9
|
+
* (e.g. workout-analytics' `VelocityZones.bands`).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately a plain structural shape — titan never imports the analytics
|
|
12
|
+
* package (same policy as TempoBar's presentational phase key). Any object with
|
|
13
|
+
* this shape can be passed, so `zones={waVelocityZones.bands}` works directly.
|
|
14
|
+
* Bands are ordered slow → fast, contiguous, and cover `[0, ∞)` with the top
|
|
15
|
+
* band's `max === null`. Bands carry NO color — color is a UI concern resolved
|
|
16
|
+
* here via {@link zoneIdToScaleToken}.
|
|
17
|
+
*/
|
|
18
|
+
export interface VelocityZoneBandProp {
|
|
19
|
+
/** Stable zone identity (e.g. WA's `VelocityZoneId`). Drives color mapping. */
|
|
20
|
+
id: string
|
|
21
|
+
/** Human-readable label shown in the summary row. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Inclusive lower bound (m/s mean concentric velocity). */
|
|
24
|
+
min: number
|
|
25
|
+
/** Exclusive upper bound (m/s); `null` marks the open top band. */
|
|
26
|
+
max: number | null
|
|
27
|
+
}
|
|
28
|
+
|
|
7
29
|
export interface VelocityStripProps extends ViewProps {
|
|
30
|
+
/**
|
|
31
|
+
* Per-rep MEAN concentric velocity (m/s), one entry per rep (brain WA-D02).
|
|
32
|
+
* Drives both bar height and — in the default path — zone color. Callers must
|
|
33
|
+
* feed mean (not peak) concentric velocity so bar color, height, and the
|
|
34
|
+
* summary label all describe one metric.
|
|
35
|
+
*/
|
|
8
36
|
velocities: number[]
|
|
37
|
+
/**
|
|
38
|
+
* Optional velocity-zone bands (shape-compatible with WA's
|
|
39
|
+
* `VelocityZones.bands`). When provided, bar color and the summary zone label
|
|
40
|
+
* come from these bands via {@link zoneIdToScaleToken}. When omitted, the
|
|
41
|
+
* built-in default scale (≥1.0 / ≥0.75 / ≥0.5) is used — identical to prior
|
|
42
|
+
* releases. Colors NEVER come from the bands themselves.
|
|
43
|
+
*/
|
|
44
|
+
zones?: readonly VelocityZoneBandProp[]
|
|
45
|
+
/**
|
|
46
|
+
* Live mode: index of the most-recently-completed rep. That bar animates in
|
|
47
|
+
* with a "pop"; if it is also the current set peak (a new best), it "bounces"
|
|
48
|
+
* instead. Only the latest bar animates. Honors `prefers-reduced-motion`.
|
|
49
|
+
* Full variant only.
|
|
50
|
+
*/
|
|
51
|
+
liveRepIndex?: number
|
|
9
52
|
expanded?: boolean
|
|
10
53
|
onToggle?: () => void
|
|
11
54
|
onRepPress?: (index: number, velocity: number) => void
|
|
@@ -14,9 +57,36 @@ export interface VelocityStripProps extends ViewProps {
|
|
|
14
57
|
className?: string
|
|
15
58
|
}
|
|
16
59
|
|
|
17
|
-
// Canonical scale shared with SetRow's RPE color
|
|
60
|
+
// Canonical 4-color performance scale shared with SetRow's RPE color
|
|
61
|
+
// (see theme/workout-tokens.ts). green = fastest, red = slowest/grinding.
|
|
18
62
|
const VEL_COLORS = WORKOUT_TOKENS.scale
|
|
19
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Map a velocity-zone id (WA's 5-band taxonomy) onto the canonical 4-color
|
|
66
|
+
* scale. The scale has 4 hues but the taxonomy has 5 bands, so the two slowest
|
|
67
|
+
* bands — `maximalStrength` and `grinding` — intentionally share `red`: both
|
|
68
|
+
* read as "heavy / effortful" and there is no distinct 5th data-viz hue. The
|
|
69
|
+
* top three bands align 1:1 with the legacy default scale so an exercise moving
|
|
70
|
+
* from default to profile-derived zones never shifts its fast-end colors.
|
|
71
|
+
*
|
|
72
|
+
* Legacy default parity: speed=green, power=yellow, strengthSpeed=orange, and
|
|
73
|
+
* the old sub-0.5 "Strength" band (now split into maximalStrength + grinding)
|
|
74
|
+
* stays red.
|
|
75
|
+
*
|
|
76
|
+
* Unknown ids fall back to `green` (matching the historical default-path
|
|
77
|
+
* fallback), so a forward-compatible band id never renders an empty bar.
|
|
78
|
+
*/
|
|
79
|
+
const zoneIdToScaleToken: Record<string, keyof typeof VEL_COLORS> = {
|
|
80
|
+
speed: 'green',
|
|
81
|
+
power: 'yellow',
|
|
82
|
+
strengthSpeed: 'orange',
|
|
83
|
+
maximalStrength: 'red',
|
|
84
|
+
grinding: 'red',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Default (no-zones) scale ------------------------------------------------
|
|
88
|
+
// Retained as named exports for back-compat; they define the default path.
|
|
89
|
+
|
|
20
90
|
export function getVelocityZoneColor(velocity: number): string {
|
|
21
91
|
if (velocity >= 1.0) return 'vel-green'
|
|
22
92
|
if (velocity >= 0.75) return 'vel-yellow'
|
|
@@ -31,35 +101,47 @@ export function getVelocityZoneName(velocity: number): string {
|
|
|
31
101
|
return 'Strength'
|
|
32
102
|
}
|
|
33
103
|
|
|
104
|
+
const zoneHexMap: Record<string, string> = {
|
|
105
|
+
'vel-green': VEL_COLORS.green,
|
|
106
|
+
'vel-yellow': VEL_COLORS.yellow,
|
|
107
|
+
'vel-orange': VEL_COLORS.orange,
|
|
108
|
+
'vel-red': VEL_COLORS.red,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Velocity loss for a set, as a whole percentage. Uses the running-best rep as
|
|
113
|
+
* the reference (matching WA-02.05 / brain WA-D01): `(vBest − vLast) / vBest`,
|
|
114
|
+
* clamped to ≥ 0 so a set that ends on its best rep reports 0 loss.
|
|
115
|
+
*/
|
|
34
116
|
export function calculateVelocityLoss(velocities: number[]): number {
|
|
35
117
|
if (velocities.length < 2) return 0
|
|
36
|
-
const
|
|
118
|
+
const best = Math.max(...velocities)
|
|
119
|
+
if (best <= 0) return 0
|
|
37
120
|
const last = velocities[velocities.length - 1]
|
|
38
|
-
|
|
39
|
-
return Math.round(((first - last) / first) * 100)
|
|
121
|
+
return Math.max(0, Math.round(((best - last) / best) * 100))
|
|
40
122
|
}
|
|
41
123
|
|
|
124
|
+
/** Arithmetic mean of the per-rep mean-concentric velocities. */
|
|
42
125
|
export function calculateMeanVelocity(velocities: number[]): number {
|
|
43
126
|
if (velocities.length === 0) return 0
|
|
44
127
|
const sum = velocities.reduce((acc, v) => acc + v, 0)
|
|
45
128
|
return sum / velocities.length
|
|
46
129
|
}
|
|
47
130
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
131
|
+
/** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
|
|
132
|
+
function classifyBand(
|
|
133
|
+
velocity: number,
|
|
134
|
+
bands: readonly VelocityZoneBandProp[],
|
|
135
|
+
): VelocityZoneBandProp | undefined {
|
|
136
|
+
for (const band of bands) {
|
|
137
|
+
if (band.max === null || velocity < band.max) return band
|
|
55
138
|
}
|
|
139
|
+
return bands[bands.length - 1]
|
|
56
140
|
}
|
|
57
141
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'vel-orange': VEL_COLORS.orange,
|
|
62
|
-
'vel-red': VEL_COLORS.red,
|
|
142
|
+
function bandColor(band: VelocityZoneBandProp): string {
|
|
143
|
+
const token = zoneIdToScaleToken[band.id]
|
|
144
|
+
return token ? VEL_COLORS[token] : VEL_COLORS.green
|
|
63
145
|
}
|
|
64
146
|
|
|
65
147
|
function getLossStyle(loss: number): Record<string, string> | null {
|
|
@@ -68,11 +150,33 @@ function getLossStyle(loss: number): Record<string, string> | null {
|
|
|
68
150
|
return null
|
|
69
151
|
}
|
|
70
152
|
|
|
153
|
+
function getReducedMotionPreference(): boolean {
|
|
154
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
155
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Track the OS "reduce motion" preference; falls back to `false` (jsdom/SSR). */
|
|
159
|
+
function usePrefersReducedMotion(): boolean {
|
|
160
|
+
const [reduced, setReduced] = useState(getReducedMotionPreference)
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
|
163
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
164
|
+
const handler = () => setReduced(mq.matches)
|
|
165
|
+
handler()
|
|
166
|
+
mq.addEventListener?.('change', handler)
|
|
167
|
+
return () => mq.removeEventListener?.('change', handler)
|
|
168
|
+
}, [])
|
|
169
|
+
return reduced
|
|
170
|
+
}
|
|
171
|
+
|
|
71
172
|
const ANIMATION_DURATION = 400
|
|
72
173
|
const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
|
|
174
|
+
const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
|
|
73
175
|
|
|
74
176
|
export function VelocityStrip({
|
|
75
177
|
velocities,
|
|
178
|
+
zones,
|
|
179
|
+
liveRepIndex,
|
|
76
180
|
expanded = false,
|
|
77
181
|
onToggle,
|
|
78
182
|
onRepPress,
|
|
@@ -84,11 +188,55 @@ export function VelocityStrip({
|
|
|
84
188
|
const maxVelocity = Math.max(...velocities, 0)
|
|
85
189
|
const meanVelocity = calculateMeanVelocity(velocities)
|
|
86
190
|
const loss = calculateVelocityLoss(velocities)
|
|
87
|
-
const meanZone = getVelocityZoneName(meanVelocity)
|
|
88
191
|
|
|
192
|
+
const hasZones = zones != null && zones.length > 0
|
|
193
|
+
const barColorFor = (v: number): string =>
|
|
194
|
+
hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
|
|
195
|
+
const meanZone = hasZones
|
|
196
|
+
? (classifyBand(meanVelocity, zones)?.label ?? '')
|
|
197
|
+
: getVelocityZoneName(meanVelocity)
|
|
198
|
+
|
|
199
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
89
200
|
const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
|
|
90
201
|
const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
91
202
|
const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
203
|
+
const [liveScale] = useState(() => new Animated.Value(1))
|
|
204
|
+
|
|
205
|
+
// Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
|
|
206
|
+
const liveVelocity = liveRepIndex != null ? velocities[liveRepIndex] : undefined
|
|
207
|
+
const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (variant === 'mini' || liveRepIndex == null || liveVelocity == null) return
|
|
210
|
+
if (prefersReducedMotion) {
|
|
211
|
+
liveScale.setValue(1)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (isNewPeak) {
|
|
215
|
+
liveScale.setValue(1)
|
|
216
|
+
Animated.sequence([
|
|
217
|
+
Animated.timing(liveScale, {
|
|
218
|
+
toValue: 1.25,
|
|
219
|
+
duration: 150,
|
|
220
|
+
easing: Easing.out(Easing.quad),
|
|
221
|
+
useNativeDriver: true,
|
|
222
|
+
}),
|
|
223
|
+
Animated.spring(liveScale, {
|
|
224
|
+
toValue: 1,
|
|
225
|
+
friction: 3,
|
|
226
|
+
tension: 140,
|
|
227
|
+
useNativeDriver: true,
|
|
228
|
+
}),
|
|
229
|
+
]).start()
|
|
230
|
+
} else {
|
|
231
|
+
liveScale.setValue(0.8)
|
|
232
|
+
Animated.timing(liveScale, {
|
|
233
|
+
toValue: 1,
|
|
234
|
+
duration: 300,
|
|
235
|
+
easing: POP_EASING,
|
|
236
|
+
useNativeDriver: true,
|
|
237
|
+
}).start()
|
|
238
|
+
}
|
|
239
|
+
}, [variant, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
|
|
92
240
|
|
|
93
241
|
useEffect(() => {
|
|
94
242
|
if (variant === 'mini') return
|
|
@@ -131,7 +279,7 @@ export function VelocityStrip({
|
|
|
131
279
|
key={i}
|
|
132
280
|
style={{
|
|
133
281
|
flex: 1,
|
|
134
|
-
backgroundColor:
|
|
282
|
+
backgroundColor: barColorFor(v),
|
|
135
283
|
borderRadius: 1,
|
|
136
284
|
minWidth: 4,
|
|
137
285
|
height: '100%' as unknown as number,
|
|
@@ -176,11 +324,44 @@ export function VelocityStrip({
|
|
|
176
324
|
style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
|
|
177
325
|
>
|
|
178
326
|
{velocities.map((v, i) => {
|
|
179
|
-
const
|
|
327
|
+
const barBackground = barColorFor(v)
|
|
180
328
|
// Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
|
|
181
329
|
// this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
|
|
182
330
|
const barHeightPct =
|
|
183
331
|
maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
|
|
332
|
+
const isLive = liveRepIndex === i
|
|
333
|
+
const liveLabelSuffix = isLive
|
|
334
|
+
? isNewPeak
|
|
335
|
+
? ', latest rep, new set peak'
|
|
336
|
+
: ', latest rep'
|
|
337
|
+
: ''
|
|
338
|
+
|
|
339
|
+
const barStyle: ViewStyle = expanded
|
|
340
|
+
? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: barBackground }
|
|
341
|
+
: {
|
|
342
|
+
minHeight: '100%' as unknown as number,
|
|
343
|
+
borderTopLeftRadius: 2,
|
|
344
|
+
borderTopRightRadius: 2,
|
|
345
|
+
borderBottomLeftRadius: 0,
|
|
346
|
+
borderBottomRightRadius: 0,
|
|
347
|
+
backgroundColor: barBackground,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const barInner = isLive ? (
|
|
351
|
+
<Animated.View
|
|
352
|
+
style={[barStyle, { transform: [{ scale: liveScale }] }]}
|
|
353
|
+
testID={`velocity-bar-${i}`}
|
|
354
|
+
accessibilityRole="image"
|
|
355
|
+
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
|
|
356
|
+
/>
|
|
357
|
+
) : (
|
|
358
|
+
<View
|
|
359
|
+
style={barStyle}
|
|
360
|
+
testID={`velocity-bar-${i}`}
|
|
361
|
+
accessibilityRole="image"
|
|
362
|
+
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
|
|
363
|
+
/>
|
|
364
|
+
)
|
|
184
365
|
|
|
185
366
|
const bar = (
|
|
186
367
|
<View
|
|
@@ -198,23 +379,7 @@ export function VelocityStrip({
|
|
|
198
379
|
</Text>
|
|
199
380
|
</Animated.View>
|
|
200
381
|
)}
|
|
201
|
-
|
|
202
|
-
style={
|
|
203
|
-
expanded
|
|
204
|
-
? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
|
|
205
|
-
: {
|
|
206
|
-
minHeight: '100%' as unknown as number,
|
|
207
|
-
borderTopLeftRadius: 2,
|
|
208
|
-
borderTopRightRadius: 2,
|
|
209
|
-
borderBottomLeftRadius: 0,
|
|
210
|
-
borderBottomRightRadius: 0,
|
|
211
|
-
backgroundColor: zoneHexMap[zone],
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
testID={`velocity-bar-${i}`}
|
|
215
|
-
accessibilityRole="image"
|
|
216
|
-
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
|
|
217
|
-
/>
|
|
382
|
+
{barInner}
|
|
218
383
|
</View>
|
|
219
384
|
)
|
|
220
385
|
|
|
@@ -256,7 +421,7 @@ export function VelocityStrip({
|
|
|
256
421
|
fontFamily: 'Inter, sans-serif',
|
|
257
422
|
}}
|
|
258
423
|
>
|
|
259
|
-
{meanZone} {'
|
|
424
|
+
{meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
|
|
260
425
|
</Text>
|
|
261
426
|
<Text
|
|
262
427
|
className="text-text-secondary"
|
|
@@ -37,12 +37,12 @@ describe('WeekRow', () => {
|
|
|
37
37
|
|
|
38
38
|
it('passes the threshold through to the intensity bar', () => {
|
|
39
39
|
render(<WeekRow {...baseProps} intensityThreshold={0.8} />)
|
|
40
|
-
expect(screen.getByTestId('intensity-
|
|
40
|
+
expect(screen.getByTestId('intensity-target')).toBeInTheDocument()
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
it('omits the threshold line when not provided', () => {
|
|
44
44
|
render(<WeekRow {...baseProps} />)
|
|
45
|
-
expect(screen.queryByTestId('intensity-
|
|
45
|
+
expect(screen.queryByTestId('intensity-target')).not.toBeInTheDocument()
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
48
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { View, Text, type ViewProps } from 'react-native'
|
|
3
3
|
import { WorkoutPill, type WorkoutPillStatus } from './WorkoutPill'
|
|
4
4
|
import { IntensityBar } from './IntensityBar'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
6
|
|
|
6
|
-
const BRAND_PRIMARY = '
|
|
7
|
+
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
7
8
|
|
|
8
9
|
export interface WeekRowWorkout {
|
|
9
10
|
name: string
|
|
@@ -66,7 +67,7 @@ export function WeekRow({
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
if (isDeload) {
|
|
69
|
-
rowStyle.backgroundColor = 'rgba(
|
|
70
|
+
rowStyle.backgroundColor = 'rgba(186,41,150,0.06)'
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
if (isCurrent) {
|
|
@@ -75,10 +75,10 @@ describe('WeightBadge', () => {
|
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
it('uses brand-primary
|
|
78
|
+
it('uses a brand-primary border ring on PR state', () => {
|
|
79
79
|
render(<WeightBadge value={315} isPr />)
|
|
80
80
|
expect(screen.getByTestId('weight-badge')).toHaveStyle({
|
|
81
|
-
borderTopColor: 'rgba(255, 121, 0, 0.
|
|
81
|
+
borderTopColor: 'rgba(255, 121, 0, 0.3)',
|
|
82
82
|
})
|
|
83
83
|
})
|
|
84
84
|
})
|