@titan-design/react-ui 0.7.0 → 0.9.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/index.d.mts +112 -55
- package/dist/index.d.ts +112 -55
- package/dist/index.js +795 -501
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +795 -498
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
- package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +691 -250
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +692 -251
- package/dist/pages.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
- package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
- package/src/components/custom/Workout/README.md +70 -3
- package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +141 -50
- package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
- package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
- package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
- package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
- package/src/components/custom/Workout/index.ts +1 -9
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/alert/Alert.stories.tsx +85 -2
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- package/src/components/ui/progress/Progress.tsx +47 -22
- package/src/lab/north-star/LivePage.tsx +101 -0
- package/src/lab/north-star/LiveView.tsx +409 -0
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
- package/src/lab/north-star/RestView.tsx +140 -0
- package/src/lab/north-star/fixtures.ts +224 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
- package/src/components/custom/Workout/TempoBar.tsx +0 -215
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures for the `Lab/North Star/Live Wall Dashboard` specimen.
|
|
3
|
+
*
|
|
4
|
+
* Shapes deliberately MIRROR the voltras-mcp dashboard store read-models so a later
|
|
5
|
+
* provisional wiring is a mechanical field map, not a reshape:
|
|
6
|
+
* - {@link LiveModel} — the live-set telemetry read-model (`velocity`, `force`,
|
|
7
|
+
* `phase`, `lastRep`, `velocityLossPct`).
|
|
8
|
+
* - {@link SessionModel} — the session read-model (`exerciseName` + completed sets).
|
|
9
|
+
* - {@link CompletedSet} — a logged set (`weightLbs`, `mode`, `repCount`, `reps`).
|
|
10
|
+
*
|
|
11
|
+
* The presentational titan props each view consumes are DERIVED from these read-models
|
|
12
|
+
* (see the `derive*` helpers) — the same projection the store→component adapter will do.
|
|
13
|
+
* Numbers are realistic cable chest-press strength-training values.
|
|
14
|
+
*/
|
|
15
|
+
import type { SessionRailExercise } from '../../components'
|
|
16
|
+
|
|
17
|
+
// --- Store read-model shapes (mirror voltras-mcp dashboard store) -------------
|
|
18
|
+
|
|
19
|
+
/** One completed rep's per-rep telemetry (mean-concentric metric family). */
|
|
20
|
+
export interface RepModel {
|
|
21
|
+
/** Mean CONCENTRIC velocity (m/s) — the canonical metric (brain WA-D02). */
|
|
22
|
+
vCon: number
|
|
23
|
+
/** Range of motion (m). */
|
|
24
|
+
rom: number
|
|
25
|
+
/** Peak instantaneous velocity (m/s). */
|
|
26
|
+
peakVelocity: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The live-set telemetry read-model. */
|
|
30
|
+
export interface LiveModel {
|
|
31
|
+
/** Instantaneous cable velocity (m/s). */
|
|
32
|
+
velocity: number
|
|
33
|
+
/** Instantaneous cable force (N). */
|
|
34
|
+
force: number
|
|
35
|
+
/** Current movement phase. */
|
|
36
|
+
phase: 'concentric' | 'hold' | 'eccentric' | 'idle'
|
|
37
|
+
/** Elapsed time (ms) within the current phase — drives the live tempo fill. */
|
|
38
|
+
phaseElapsedMs: number
|
|
39
|
+
/** The most-recently completed rep. */
|
|
40
|
+
lastRep: RepModel
|
|
41
|
+
/** Per-rep mean-concentric velocities logged so far this set (m/s). */
|
|
42
|
+
repVelocities: number[]
|
|
43
|
+
/** Velocity loss vs the set's best rep (%). Drives verdict + aura + fatigue. */
|
|
44
|
+
velocityLossPct: number
|
|
45
|
+
/** Peak concentric force this set (N). */
|
|
46
|
+
peakForce: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A logged set on the session read-model. */
|
|
50
|
+
export interface CompletedSet {
|
|
51
|
+
weightLbs: number
|
|
52
|
+
/** Resistance mode (cascade echo, cmd=0x10). */
|
|
53
|
+
mode: 'weight' | 'chains' | 'eccentric' | 'isokinetic'
|
|
54
|
+
repCount: number
|
|
55
|
+
/** Per-rep mean-concentric velocities (m/s). */
|
|
56
|
+
reps: number[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The session read-model. */
|
|
60
|
+
export interface SessionModel {
|
|
61
|
+
exerciseName: string
|
|
62
|
+
title: string
|
|
63
|
+
weightLbs: number
|
|
64
|
+
unit: 'lbs' | 'kg'
|
|
65
|
+
/**
|
|
66
|
+
* Prescribed tempo tuple [ecc, pauseBottom, con, pauseTop]. OPTIONAL — a set may carry no
|
|
67
|
+
* prescribed tempo (coach left it unset and no exercise default); the live view then hides
|
|
68
|
+
* the tempo readout entirely rather than inventing one.
|
|
69
|
+
*/
|
|
70
|
+
tempo?: [number, number, number, number]
|
|
71
|
+
completedSets: CompletedSet[]
|
|
72
|
+
plannedSets: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The full store snapshot the specimen reads. */
|
|
76
|
+
export interface DashboardModel {
|
|
77
|
+
live: LiveModel
|
|
78
|
+
session: SessionModel
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- The fixture instance -----------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A mid-set cable chest press: 6 reps in, velocity decaying from 0.52 → 0.405 m/s,
|
|
85
|
+
* ~22% velocity loss (the VL20–VL30 mid-zone → `threshold` verdict / amber aura).
|
|
86
|
+
*/
|
|
87
|
+
export const dashboardFixture: DashboardModel = {
|
|
88
|
+
live: {
|
|
89
|
+
velocity: 0.41,
|
|
90
|
+
force: 498,
|
|
91
|
+
phase: 'concentric',
|
|
92
|
+
phaseElapsedMs: 700,
|
|
93
|
+
lastRep: { vCon: 0.405, rom: 0.58, peakVelocity: 0.61 },
|
|
94
|
+
repVelocities: [0.52, 0.5, 0.48, 0.46, 0.44, 0.405],
|
|
95
|
+
velocityLossPct: 22,
|
|
96
|
+
peakForce: 542,
|
|
97
|
+
},
|
|
98
|
+
session: {
|
|
99
|
+
exerciseName: 'Cable Chest Press',
|
|
100
|
+
title: 'Push A · Hypertrophy',
|
|
101
|
+
weightLbs: 140,
|
|
102
|
+
unit: 'lbs',
|
|
103
|
+
tempo: [3, 1, 1, 0],
|
|
104
|
+
plannedSets: 4,
|
|
105
|
+
completedSets: [
|
|
106
|
+
{
|
|
107
|
+
weightLbs: 140,
|
|
108
|
+
mode: 'weight',
|
|
109
|
+
repCount: 8,
|
|
110
|
+
reps: [0.55, 0.54, 0.52, 0.51, 0.49, 0.47, 0.45, 0.43],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
weightLbs: 140,
|
|
114
|
+
mode: 'weight',
|
|
115
|
+
repCount: 8,
|
|
116
|
+
reps: [0.53, 0.52, 0.5, 0.48, 0.46, 0.44, 0.42, 0.4],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Derived projections (store read-model → titan presentational props) -------
|
|
123
|
+
|
|
124
|
+
/** Arithmetic mean of a per-rep velocity array (m/s). */
|
|
125
|
+
export function meanVelocity(reps: number[]): number {
|
|
126
|
+
if (reps.length === 0) return 0
|
|
127
|
+
return reps.reduce((a, v) => a + v, 0) / reps.length
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Verdict status from velocity loss %, matching FatigueMeter's VL20/VL30 bands. */
|
|
131
|
+
export function verdictFromLoss(lossPct: number): 'productive' | 'threshold' | 'stop' {
|
|
132
|
+
if (lossPct >= 30) return 'stop'
|
|
133
|
+
if (lossPct >= 20) return 'threshold'
|
|
134
|
+
return 'productive'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The session rail's exercise list, projected from the model: the current exercise
|
|
139
|
+
* (active), the completed sets behind it, and an upcoming accessory.
|
|
140
|
+
*/
|
|
141
|
+
export function deriveRailExercises(model: DashboardModel): SessionRailExercise[] {
|
|
142
|
+
const { session, live } = model
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
id: 'chest-press',
|
|
146
|
+
name: session.exerciseName,
|
|
147
|
+
summary: {
|
|
148
|
+
sets: session.plannedSets,
|
|
149
|
+
reps: 8,
|
|
150
|
+
weight: session.weightLbs,
|
|
151
|
+
unit: session.unit,
|
|
152
|
+
},
|
|
153
|
+
tempo: session.tempo,
|
|
154
|
+
indicator: 'velocity-loss',
|
|
155
|
+
setStates: [
|
|
156
|
+
{ status: 'done', velocities: session.completedSets[0].reps },
|
|
157
|
+
{ status: 'done', velocities: session.completedSets[1].reps },
|
|
158
|
+
{ status: 'active', velocities: live.repVelocities, planned: 8 },
|
|
159
|
+
{ status: 'todo', planned: 8 },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'incline-db',
|
|
164
|
+
name: 'Incline DB Press',
|
|
165
|
+
summary: { sets: 3, reps: 10, weight: 55, unit: 'lbs' },
|
|
166
|
+
tempo: [2, 0, 1, 0],
|
|
167
|
+
setStates: [
|
|
168
|
+
{ status: 'todo', planned: 10 },
|
|
169
|
+
{ status: 'todo', planned: 10 },
|
|
170
|
+
{ status: 'todo', planned: 10 },
|
|
171
|
+
],
|
|
172
|
+
upcoming: true,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'cable-fly',
|
|
176
|
+
name: 'Cable Fly',
|
|
177
|
+
summary: { sets: 3, reps: 12, weight: 30, unit: 'lbs' },
|
|
178
|
+
setStates: [
|
|
179
|
+
{ status: 'todo', planned: 12 },
|
|
180
|
+
{ status: 'todo', planned: 12 },
|
|
181
|
+
{ status: 'todo', planned: 12 },
|
|
182
|
+
],
|
|
183
|
+
upcoming: true,
|
|
184
|
+
},
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- Dual-mode (bilateral) projection -----------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Split a single-cable model into a bilateral (dual-mode) pair — one model per voltra.
|
|
192
|
+
*
|
|
193
|
+
* A dual-mode exercise drives two Voltra devices as a left/right pair. For v1 the two
|
|
194
|
+
* layers are rendered independently (one {@link LiveModel} each); a unified split-bar
|
|
195
|
+
* treatment is deferred. The fixture gives the RIGHT side a realistic left-dominant
|
|
196
|
+
* deficit (slightly slower reps, more velocity loss, a touch less force) so the two
|
|
197
|
+
* layers read as genuinely different streams rather than a mirror.
|
|
198
|
+
*/
|
|
199
|
+
export function deriveDualModel(base: DashboardModel = dashboardFixture): {
|
|
200
|
+
left: DashboardModel
|
|
201
|
+
right: DashboardModel
|
|
202
|
+
} {
|
|
203
|
+
// Left is the dominant side — the base fixture verbatim.
|
|
204
|
+
const left: DashboardModel = base
|
|
205
|
+
// Right lags: ~8% slower per-rep concentric velocity, +7 pts velocity loss, ~6% less force.
|
|
206
|
+
const scale = (v: number) => Number((v * 0.92).toFixed(3))
|
|
207
|
+
const right: DashboardModel = {
|
|
208
|
+
session: base.session,
|
|
209
|
+
live: {
|
|
210
|
+
...base.live,
|
|
211
|
+
velocity: scale(base.live.velocity),
|
|
212
|
+
force: Math.round(base.live.force * 0.94),
|
|
213
|
+
peakForce: Math.round(base.live.peakForce * 0.94),
|
|
214
|
+
velocityLossPct: base.live.velocityLossPct + 7,
|
|
215
|
+
repVelocities: base.live.repVelocities.map(scale),
|
|
216
|
+
lastRep: {
|
|
217
|
+
vCon: scale(base.live.lastRep.vCon),
|
|
218
|
+
rom: Number((base.live.lastRep.rom * 0.96).toFixed(2)),
|
|
219
|
+
peakVelocity: scale(base.live.lastRep.peakVelocity),
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
return { left, right }
|
|
224
|
+
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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: '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
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
-
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
|
-
import { alpha } from '../../../utils/colors'
|
|
5
|
-
|
|
6
|
-
const t = getSemanticColors('dark')
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Titan-local phase key. Consumers map their own movement-phase enum onto this
|
|
10
|
-
* presentational key at the call site — TempoBar owns rendering only.
|
|
11
|
-
*/
|
|
12
|
-
export type TempoPhaseKey = 'concentric' | 'hold' | 'eccentric'
|
|
13
|
-
|
|
14
|
-
/** Pacing tuning shared with the fill/pacing helpers below. */
|
|
15
|
-
export const TEMPO_PACING = {
|
|
16
|
-
behindThresholdPct: 0.15,
|
|
17
|
-
minPhaseDurationMs: 500,
|
|
18
|
-
} as const
|
|
19
|
-
|
|
20
|
-
export type TempoPacingState = 'none' | 'on-pace' | 'behind'
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Classify how a phase's elapsed time tracks against its target duration.
|
|
24
|
-
* `none` when there is no meaningful target; `behind` once elapsed exceeds the
|
|
25
|
-
* target by more than the behind threshold; `on-pace` otherwise.
|
|
26
|
-
*/
|
|
27
|
-
export function getTempoPacingState(
|
|
28
|
-
elapsedMs: number,
|
|
29
|
-
targetMs: number | null,
|
|
30
|
-
): TempoPacingState {
|
|
31
|
-
if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
|
|
32
|
-
const ratio = elapsedMs / targetMs
|
|
33
|
-
if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
|
|
34
|
-
return 'on-pace'
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Fill percentage (0–100) for the active phase; full when no target is set. */
|
|
38
|
-
export function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
|
|
39
|
-
if (!targetMs) return 100
|
|
40
|
-
return Math.min(100, (elapsedMs / targetMs) * 100)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface PhaseConfig {
|
|
44
|
-
label: string
|
|
45
|
-
color: string
|
|
46
|
-
flex: number
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
|
|
50
|
-
|
|
51
|
-
const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
|
|
52
|
-
concentric: { label: 'Con', color: t['status-success'], flex: 2 },
|
|
53
|
-
hold: { label: 'Hold', color: t['brand-primary'], flex: 1 },
|
|
54
|
-
eccentric: { label: 'Ecc', color: t['status-warning'], flex: 3 },
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface TempoBarProps extends ViewProps {
|
|
58
|
-
/** Phase currently in progress, or null when idle/at rest. */
|
|
59
|
-
activePhase: TempoPhaseKey | null
|
|
60
|
-
/** Elapsed time (ms) within the active phase. */
|
|
61
|
-
phaseElapsedMs: number
|
|
62
|
-
/** Completed phase durations (ms) for the current rep, keyed by phase. */
|
|
63
|
-
completed?: Partial<Record<TempoPhaseKey, number>>
|
|
64
|
-
/** Optional per-phase target durations (seconds) for pacing feedback. */
|
|
65
|
-
target?: Partial<Record<TempoPhaseKey, number>>
|
|
66
|
-
className?: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* TempoBar — live rep phase progression indicator.
|
|
71
|
-
*
|
|
72
|
-
* Renders a segmented bar (Con → Hold → Ecc) where the active phase fills in
|
|
73
|
-
* real time and completed phases show their duration with a ✓/✗ pacing mark.
|
|
74
|
-
* Presentational only: the consumer derives which phase is active, its elapsed
|
|
75
|
-
* time, and completed durations, then feeds them in.
|
|
76
|
-
*/
|
|
77
|
-
export function TempoBar({
|
|
78
|
-
activePhase,
|
|
79
|
-
phaseElapsedMs,
|
|
80
|
-
completed,
|
|
81
|
-
target,
|
|
82
|
-
className,
|
|
83
|
-
...props
|
|
84
|
-
}: TempoBarProps) {
|
|
85
|
-
return (
|
|
86
|
-
<View className={className} testID="tempo-bar" {...props}>
|
|
87
|
-
{/* Phase labels */}
|
|
88
|
-
<View style={{ flexDirection: 'row', marginBottom: 4 }}>
|
|
89
|
-
{PHASE_ORDER.map((phase) => {
|
|
90
|
-
const config = PHASE_CONFIG[phase]
|
|
91
|
-
return (
|
|
92
|
-
<View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
|
|
93
|
-
<Text style={{ fontSize: 10, color: t['text-disabled'] }}>{config.label}</Text>
|
|
94
|
-
</View>
|
|
95
|
-
)
|
|
96
|
-
})}
|
|
97
|
-
</View>
|
|
98
|
-
|
|
99
|
-
{/* Segmented bar */}
|
|
100
|
-
<View style={{ flexDirection: 'row', gap: 2, height: 20 }}>
|
|
101
|
-
{PHASE_ORDER.map((phase) => {
|
|
102
|
-
const config = PHASE_CONFIG[phase]
|
|
103
|
-
const isActive = activePhase === phase
|
|
104
|
-
const completedMs = completed?.[phase]
|
|
105
|
-
const targetSec = target?.[phase]
|
|
106
|
-
const targetMs = targetSec != null ? targetSec * 1000 : null
|
|
107
|
-
|
|
108
|
-
return (
|
|
109
|
-
<View
|
|
110
|
-
key={phase}
|
|
111
|
-
style={{
|
|
112
|
-
flex: config.flex,
|
|
113
|
-
backgroundColor: alpha('#ffffff', 0.06),
|
|
114
|
-
borderRadius: 4,
|
|
115
|
-
overflow: 'hidden',
|
|
116
|
-
}}
|
|
117
|
-
>
|
|
118
|
-
{isActive ? (
|
|
119
|
-
<ActiveSegment
|
|
120
|
-
config={config}
|
|
121
|
-
phaseElapsedMs={phaseElapsedMs}
|
|
122
|
-
targetMs={targetMs}
|
|
123
|
-
/>
|
|
124
|
-
) : completedMs != null ? (
|
|
125
|
-
<CompletedSegment config={config} completedMs={completedMs} targetMs={targetMs} />
|
|
126
|
-
) : null}
|
|
127
|
-
</View>
|
|
128
|
-
)
|
|
129
|
-
})}
|
|
130
|
-
</View>
|
|
131
|
-
</View>
|
|
132
|
-
)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function ActiveSegment({
|
|
136
|
-
config,
|
|
137
|
-
phaseElapsedMs,
|
|
138
|
-
targetMs,
|
|
139
|
-
}: {
|
|
140
|
-
config: PhaseConfig
|
|
141
|
-
phaseElapsedMs: number
|
|
142
|
-
targetMs: number | null
|
|
143
|
-
}) {
|
|
144
|
-
const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
|
|
145
|
-
const barColor = pacing === 'behind' ? t['status-error'] : config.color
|
|
146
|
-
const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
|
|
147
|
-
|
|
148
|
-
const label = targetMs
|
|
149
|
-
? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
|
|
150
|
-
: formatDuration(phaseElapsedMs)
|
|
151
|
-
|
|
152
|
-
return (
|
|
153
|
-
<View style={{ height: '100%', position: 'relative' }} testID={`tempo-segment-active-${config.label}`}>
|
|
154
|
-
<View
|
|
155
|
-
style={{
|
|
156
|
-
position: 'absolute',
|
|
157
|
-
left: 0,
|
|
158
|
-
top: 0,
|
|
159
|
-
bottom: 0,
|
|
160
|
-
width: `${fillPct}%`,
|
|
161
|
-
backgroundColor: alpha(barColor, 0.3),
|
|
162
|
-
borderRadius: 4,
|
|
163
|
-
}}
|
|
164
|
-
/>
|
|
165
|
-
<View
|
|
166
|
-
style={{
|
|
167
|
-
height: '100%',
|
|
168
|
-
flexDirection: 'row',
|
|
169
|
-
alignItems: 'center',
|
|
170
|
-
justifyContent: 'center',
|
|
171
|
-
}}
|
|
172
|
-
>
|
|
173
|
-
<Text style={{ fontSize: 12, fontWeight: '700', color: barColor }}>{label}</Text>
|
|
174
|
-
</View>
|
|
175
|
-
</View>
|
|
176
|
-
)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function CompletedSegment({
|
|
180
|
-
config,
|
|
181
|
-
completedMs,
|
|
182
|
-
targetMs,
|
|
183
|
-
}: {
|
|
184
|
-
config: PhaseConfig
|
|
185
|
-
completedMs: number
|
|
186
|
-
targetMs: number | null
|
|
187
|
-
}) {
|
|
188
|
-
const pacing = getTempoPacingState(completedMs, targetMs)
|
|
189
|
-
const hitTarget = pacing !== 'behind'
|
|
190
|
-
const indicator =
|
|
191
|
-
targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
|
|
192
|
-
|
|
193
|
-
return (
|
|
194
|
-
<View
|
|
195
|
-
style={{
|
|
196
|
-
height: '100%',
|
|
197
|
-
flexDirection: 'row',
|
|
198
|
-
alignItems: 'center',
|
|
199
|
-
justifyContent: 'center',
|
|
200
|
-
backgroundColor: alpha(config.color, 0.15),
|
|
201
|
-
}}
|
|
202
|
-
testID={`tempo-segment-completed-${config.label}`}
|
|
203
|
-
>
|
|
204
|
-
<Text style={{ fontSize: 12, fontWeight: '500', color: alpha(config.color, 0.7) }}>
|
|
205
|
-
{formatDuration(completedMs)}
|
|
206
|
-
{indicator}
|
|
207
|
-
</Text>
|
|
208
|
-
</View>
|
|
209
|
-
)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function formatDuration(ms: number): string {
|
|
213
|
-
const s = ms / 1000
|
|
214
|
-
return s < 10 ? s.toFixed(1) : Math.round(s).toString()
|
|
215
|
-
}
|