@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,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: '#ff4757' })
|
|
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,
|
|
@@ -151,7 +299,9 @@ export function VelocityStrip({
|
|
|
151
299
|
|
|
152
300
|
const stripContent = (
|
|
153
301
|
<Animated.View
|
|
154
|
-
className={className
|
|
302
|
+
className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.join(' ')}
|
|
155
305
|
style={[
|
|
156
306
|
{
|
|
157
307
|
height: heightAnim,
|
|
@@ -162,7 +312,6 @@ export function VelocityStrip({
|
|
|
162
312
|
paddingBottom: expanded ? 24 : 0,
|
|
163
313
|
paddingHorizontal: expanded ? 6 : 0,
|
|
164
314
|
paddingVertical: expanded ? undefined : 8,
|
|
165
|
-
backgroundColor: expanded ? 'var(--color-surface-raised)' : 'transparent',
|
|
166
315
|
overflow: expanded ? 'visible' : 'hidden',
|
|
167
316
|
},
|
|
168
317
|
]}
|
|
@@ -175,11 +324,44 @@ export function VelocityStrip({
|
|
|
175
324
|
style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
|
|
176
325
|
>
|
|
177
326
|
{velocities.map((v, i) => {
|
|
178
|
-
const
|
|
327
|
+
const barBackground = barColorFor(v)
|
|
179
328
|
// Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
|
|
180
329
|
// this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
|
|
181
330
|
const barHeightPct =
|
|
182
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
|
+
)
|
|
183
365
|
|
|
184
366
|
const bar = (
|
|
185
367
|
<View
|
|
@@ -189,30 +371,15 @@ export function VelocityStrip({
|
|
|
189
371
|
{expanded && (
|
|
190
372
|
<Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
|
|
191
373
|
<Text
|
|
192
|
-
|
|
374
|
+
className="text-text-secondary"
|
|
375
|
+
style={{ fontSize: 8, fontWeight: '600' }}
|
|
193
376
|
testID={`velocity-label-${i}`}
|
|
194
377
|
>
|
|
195
378
|
{formatVelocity(v)}
|
|
196
379
|
</Text>
|
|
197
380
|
</Animated.View>
|
|
198
381
|
)}
|
|
199
|
-
|
|
200
|
-
style={
|
|
201
|
-
expanded
|
|
202
|
-
? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
|
|
203
|
-
: {
|
|
204
|
-
minHeight: '100%' as unknown as number,
|
|
205
|
-
borderTopLeftRadius: 2,
|
|
206
|
-
borderTopRightRadius: 2,
|
|
207
|
-
borderBottomLeftRadius: 0,
|
|
208
|
-
borderBottomRightRadius: 0,
|
|
209
|
-
backgroundColor: zoneHexMap[zone],
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
testID={`velocity-bar-${i}`}
|
|
213
|
-
accessibilityRole="image"
|
|
214
|
-
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
|
|
215
|
-
/>
|
|
382
|
+
{barInner}
|
|
216
383
|
</View>
|
|
217
384
|
)
|
|
218
385
|
|
|
@@ -248,18 +415,18 @@ export function VelocityStrip({
|
|
|
248
415
|
testID="velocity-info-row"
|
|
249
416
|
>
|
|
250
417
|
<Text
|
|
418
|
+
className="text-text-secondary"
|
|
251
419
|
style={{
|
|
252
420
|
fontSize: 10,
|
|
253
|
-
color: 'var(--color-text-secondary)',
|
|
254
421
|
fontFamily: 'Inter, sans-serif',
|
|
255
422
|
}}
|
|
256
423
|
>
|
|
257
|
-
{meanZone} {'
|
|
424
|
+
{meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
|
|
258
425
|
</Text>
|
|
259
426
|
<Text
|
|
427
|
+
className="text-text-secondary"
|
|
260
428
|
style={{
|
|
261
429
|
fontSize: 10,
|
|
262
|
-
color: 'var(--color-text-secondary)',
|
|
263
430
|
fontFamily: 'Inter, sans-serif',
|
|
264
431
|
...getLossStyle(loss),
|
|
265
432
|
}}
|
|
@@ -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
|
|
|
@@ -100,11 +100,12 @@ export function WeekRow({
|
|
|
100
100
|
/>
|
|
101
101
|
)}
|
|
102
102
|
<Text
|
|
103
|
+
className={isCurrent ? undefined : 'text-text-primary'}
|
|
103
104
|
style={{
|
|
104
105
|
fontSize: 13,
|
|
105
106
|
fontWeight: '700',
|
|
106
107
|
fontFamily: 'Inter, sans-serif',
|
|
107
|
-
color: isCurrent ? BRAND_PRIMARY :
|
|
108
|
+
color: isCurrent ? BRAND_PRIMARY : undefined,
|
|
108
109
|
}}
|
|
109
110
|
accessibilityElementsHidden
|
|
110
111
|
>
|
|
@@ -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
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { Text } from 'react-native'
|
|
3
|
-
import {
|
|
3
|
+
import { DumbbellIcon } from './icons'
|
|
4
4
|
import {
|
|
5
5
|
BaseBadge,
|
|
6
6
|
baseBadgeSizeConfig,
|
|
@@ -61,7 +61,7 @@ export function WeightBadge({
|
|
|
61
61
|
className={className}
|
|
62
62
|
icon={
|
|
63
63
|
showIcon ? (
|
|
64
|
-
<
|
|
64
|
+
<DumbbellIcon size={config.iconSize} color={iconColor} strokeWidth={2} />
|
|
65
65
|
) : undefined
|
|
66
66
|
}
|
|
67
67
|
accessibilityLabel={fullLabel}
|
|
@@ -43,11 +43,6 @@ export interface WorkoutCardProps extends ViewProps {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const COLORS = {
|
|
46
|
-
// Theme-aware foregrounds: resolve to a readable color in both light and dark
|
|
47
|
-
// (react-native-web passes the CSS var through to the inline style).
|
|
48
|
-
textPrimary: 'var(--color-text-primary)',
|
|
49
|
-
textSecondary: 'var(--color-text-secondary)',
|
|
50
|
-
textTertiary: 'var(--color-text-tertiary)',
|
|
51
46
|
statusSuccess: '#14B8A6',
|
|
52
47
|
brandPrimary: '#FF7900',
|
|
53
48
|
borderDefault: '#1F1F1F',
|
|
@@ -124,11 +119,11 @@ export function WorkoutCard({
|
|
|
124
119
|
<View style={{ padding: 14 }} testID="workout-card-body">
|
|
125
120
|
<View className="flex-row items-center" testID="workout-card-header">
|
|
126
121
|
<Text
|
|
122
|
+
className="text-text-primary"
|
|
127
123
|
style={{
|
|
128
124
|
fontSize: 15,
|
|
129
125
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
130
126
|
fontWeight: '700',
|
|
131
|
-
color: COLORS.textPrimary,
|
|
132
127
|
}}
|
|
133
128
|
testID="workout-card-name"
|
|
134
129
|
>
|
|
@@ -136,10 +131,10 @@ export function WorkoutCard({
|
|
|
136
131
|
</Text>
|
|
137
132
|
<View className="flex-1" />
|
|
138
133
|
<Text
|
|
134
|
+
className="text-text-tertiary"
|
|
139
135
|
style={{
|
|
140
136
|
fontSize: 11,
|
|
141
137
|
fontFamily: 'Inter, sans-serif',
|
|
142
|
-
color: COLORS.textTertiary,
|
|
143
138
|
}}
|
|
144
139
|
testID="workout-card-date"
|
|
145
140
|
>
|
|
@@ -148,11 +143,11 @@ export function WorkoutCard({
|
|
|
148
143
|
</View>
|
|
149
144
|
|
|
150
145
|
<Text
|
|
146
|
+
className="text-text-secondary"
|
|
151
147
|
style={{
|
|
152
148
|
marginTop: 4,
|
|
153
149
|
fontSize: 12,
|
|
154
150
|
fontFamily: 'Inter, sans-serif',
|
|
155
|
-
color: COLORS.textSecondary,
|
|
156
151
|
}}
|
|
157
152
|
testID="workout-card-stats"
|
|
158
153
|
>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Inline SVG icons for the Workout badges — replaces the `lucide-react`
|
|
2
|
+
// dependency (dep hygiene, titan 0.5.0). The paths mirror lucide-react's
|
|
3
|
+
// `Dumbbell` and `Star` glyphs verbatim so rendering is pixel-identical to the
|
|
4
|
+
// previous `<Dumbbell>` / `<Star>` output (same viewBox, stroke, fill, and
|
|
5
|
+
// round line caps). Rendered as raw `<svg>` DOM elements — the same web-SVG
|
|
6
|
+
// mechanism `Progress.tsx` uses, and the same surface `lucide-react` produced
|
|
7
|
+
// (both are web-only SVG; native icon rendering was never wired here).
|
|
8
|
+
|
|
9
|
+
export interface WorkoutIconProps {
|
|
10
|
+
/** Rendered width/height in px (viewBox is fixed 24×24). */
|
|
11
|
+
size?: number
|
|
12
|
+
/** Stroke color. */
|
|
13
|
+
color?: string
|
|
14
|
+
/** Fill color; defaults to `none` (outline only). */
|
|
15
|
+
fill?: string
|
|
16
|
+
/** Stroke width. */
|
|
17
|
+
strokeWidth?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Dumbbell glyph (mirrors lucide-react `Dumbbell`). Outline-only by default. */
|
|
21
|
+
export function DumbbellIcon({
|
|
22
|
+
size = 24,
|
|
23
|
+
color = 'currentColor',
|
|
24
|
+
fill = 'none',
|
|
25
|
+
strokeWidth = 2,
|
|
26
|
+
}: WorkoutIconProps) {
|
|
27
|
+
return (
|
|
28
|
+
<svg
|
|
29
|
+
width={size}
|
|
30
|
+
height={size}
|
|
31
|
+
viewBox="0 0 24 24"
|
|
32
|
+
fill={fill}
|
|
33
|
+
stroke={color}
|
|
34
|
+
strokeWidth={strokeWidth}
|
|
35
|
+
strokeLinecap="round"
|
|
36
|
+
strokeLinejoin="round"
|
|
37
|
+
>
|
|
38
|
+
<path d="M14.4 14.4 9.6 9.6" />
|
|
39
|
+
<path d="M18.657 21.485a2 2 0 1 1-2.829-2.828l-1.767 1.768a2 2 0 1 1-2.829-2.829l6.364-6.364a2 2 0 1 1 2.829 2.829l-1.768 1.767a2 2 0 1 1 2.828 2.829z" />
|
|
40
|
+
<path d="m21.5 21.5-1.4-1.4" />
|
|
41
|
+
<path d="M3.9 3.9 2.5 2.5" />
|
|
42
|
+
<path d="M6.404 12.768a2 2 0 1 1-2.829-2.829l1.768-1.767a2 2 0 1 1-2.828-2.829l2.828-2.828a2 2 0 1 1 2.829 2.828l1.767-1.768a2 2 0 1 1 2.829 2.829z" />
|
|
43
|
+
</svg>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Star glyph (mirrors lucide-react `Star`). Pass `fill` for a solid star. */
|
|
48
|
+
export function StarIcon({
|
|
49
|
+
size = 24,
|
|
50
|
+
color = 'currentColor',
|
|
51
|
+
fill = 'none',
|
|
52
|
+
strokeWidth = 2,
|
|
53
|
+
}: WorkoutIconProps) {
|
|
54
|
+
return (
|
|
55
|
+
<svg
|
|
56
|
+
width={size}
|
|
57
|
+
height={size}
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
fill={fill}
|
|
60
|
+
stroke={color}
|
|
61
|
+
strokeWidth={strokeWidth}
|
|
62
|
+
strokeLinecap="round"
|
|
63
|
+
strokeLinejoin="round"
|
|
64
|
+
>
|
|
65
|
+
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
|
66
|
+
</svg>
|
|
67
|
+
)
|
|
68
|
+
}
|