@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
2
3
|
import { useEffect, useState } from 'react'
|
|
3
|
-
import { View } from 'react-native'
|
|
4
|
+
import { View, Text } from 'react-native'
|
|
4
5
|
import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
|
|
5
6
|
|
|
6
7
|
// tempo = [eccentric, pauseBottom, concentric, pauseTop]
|
|
@@ -25,7 +26,8 @@ const meta: Meta<typeof TempoDisplay> = {
|
|
|
25
26
|
description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds',
|
|
26
27
|
},
|
|
27
28
|
size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
|
|
28
|
-
|
|
29
|
+
fontSize: { control: { type: 'range', min: 9, max: 48, step: 1 }, description: 'Digit size (overrides `size`)' },
|
|
30
|
+
showLabel: { control: 'boolean', description: 'Show the TEMPO caption' },
|
|
29
31
|
showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
|
|
30
32
|
live: {
|
|
31
33
|
control: 'object',
|
|
@@ -37,98 +39,107 @@ const meta: Meta<typeof TempoDisplay> = {
|
|
|
37
39
|
export default meta
|
|
38
40
|
type Story = StoryObj<typeof TempoDisplay>
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
// The static prescription — the base tempo lockup (phase-coloured), no live readout.
|
|
43
|
+
// Add the `live` prop (see the Live stories below) to turn it into the running tempo.
|
|
44
|
+
export const Prescription: Story = {
|
|
45
|
+
args: { tempo: [3, 1, 2, 2] },
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
args: { tempo: [3, 1, 1, 0], colored: false },
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const ColoredExplosive: Story = {
|
|
49
|
-
args: { tempo: [1, 0, 1, 0], colored: true },
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export const MonoExplosive: Story = {
|
|
53
|
-
args: { tempo: [1, 0, 1, 0], colored: false },
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const ColoredSlowEccentric: Story = {
|
|
57
|
-
args: { tempo: [5, 2, 1, 1], colored: true },
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export const SmallColored: Story = {
|
|
61
|
-
args: { tempo: [3, 1, 1, 0], size: 'sm', colored: true },
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export const SmallMono: Story = {
|
|
65
|
-
args: { tempo: [3, 1, 1, 0], size: 'sm', colored: false },
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export const AllVariants: Story = {
|
|
69
|
-
render: () => (
|
|
70
|
-
<View style={{ gap: 12 }}>
|
|
71
|
-
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
72
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} colored />
|
|
73
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />
|
|
74
|
-
</View>
|
|
75
|
-
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
76
|
-
<TempoDisplay tempo={[1, 0, 1, 0]} colored />
|
|
77
|
-
<TempoDisplay tempo={[1, 0, 1, 0]} colored={false} />
|
|
78
|
-
</View>
|
|
79
|
-
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
80
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored />
|
|
81
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored={false} />
|
|
82
|
-
</View>
|
|
83
|
-
</View>
|
|
84
|
-
),
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// --- Live phase-fill (opt-in) ----------------------------------------------
|
|
88
|
-
|
|
89
|
-
// Deterministic mid-rep frames — the active phase digit fills against its target.
|
|
90
|
-
export const LiveEccentricInProgress: Story = {
|
|
91
|
-
args: { tempo: [3, 1, 1, 0], live: { activePhase: 'eccentric', phaseElapsedMs: 1500 } },
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Concentric target is 1s; 1.6s elapsed is behind pace, so the digit turns red.
|
|
95
|
-
export const LiveBehindPace: Story = {
|
|
96
|
-
args: { tempo: [3, 1, 1, 0], live: { activePhase: 'concentric', phaseElapsedMs: 1600 } },
|
|
97
|
-
}
|
|
48
|
+
// --- Live tempo (opt-in via the `live` prop) --------------------------------
|
|
98
49
|
|
|
50
|
+
// A full rep cycle incl. a 2s rest at the top between reps.
|
|
99
51
|
const LIVE_CYCLE: { phase: TempoLivePhase; ms: number }[] = [
|
|
100
52
|
{ phase: 'eccentric', ms: 3000 },
|
|
101
53
|
{ phase: 'pauseBottom', ms: 1000 },
|
|
102
|
-
{ phase: 'concentric', ms:
|
|
54
|
+
{ phase: 'concentric', ms: 2000 },
|
|
55
|
+
{ phase: 'pauseTop', ms: 2000 },
|
|
103
56
|
]
|
|
104
57
|
|
|
105
|
-
function LiveTempoDemo(
|
|
106
|
-
|
|
58
|
+
function LiveTempoDemo({
|
|
59
|
+
fontSize,
|
|
60
|
+
showLabel = false,
|
|
61
|
+
liveReadout,
|
|
62
|
+
}: {
|
|
63
|
+
fontSize?: number
|
|
64
|
+
showLabel?: boolean
|
|
65
|
+
liveReadout?: 'countdown' | 'countup'
|
|
66
|
+
}) {
|
|
67
|
+
const [live, setLive] = useState<TempoLiveState>({
|
|
68
|
+
activePhase: 'eccentric',
|
|
69
|
+
phaseElapsedMs: 0,
|
|
70
|
+
completed: {},
|
|
71
|
+
})
|
|
107
72
|
useEffect(() => {
|
|
108
73
|
let idx = 0
|
|
109
74
|
let start = Date.now()
|
|
75
|
+
let completed: TempoLiveState['completed'] = {}
|
|
110
76
|
const id = setInterval(() => {
|
|
111
77
|
const step = LIVE_CYCLE[idx]
|
|
112
78
|
const elapsed = Date.now() - start
|
|
113
|
-
if (elapsed >= step.ms +
|
|
79
|
+
if (elapsed >= step.ms + 300) {
|
|
80
|
+
// Bank the phase's actual duration, advance; a new rep (wrap to idx 0) resets the row.
|
|
81
|
+
completed = { ...completed, [step.phase]: elapsed }
|
|
114
82
|
idx = (idx + 1) % LIVE_CYCLE.length
|
|
83
|
+
if (idx === 0) completed = {}
|
|
115
84
|
start = Date.now()
|
|
116
85
|
}
|
|
117
86
|
setLive({
|
|
118
87
|
activePhase: LIVE_CYCLE[idx].phase,
|
|
119
|
-
phaseElapsedMs: Math.min(elapsed, step.ms +
|
|
88
|
+
phaseElapsedMs: Math.min(elapsed, step.ms + 300),
|
|
89
|
+
completed,
|
|
120
90
|
})
|
|
121
91
|
}, 50)
|
|
122
92
|
return () => clearInterval(id)
|
|
123
93
|
}, [])
|
|
124
|
-
return
|
|
94
|
+
return (
|
|
95
|
+
<TempoDisplay
|
|
96
|
+
tempo={[3, 1, 2, 2]}
|
|
97
|
+
live={live}
|
|
98
|
+
fontSize={fontSize}
|
|
99
|
+
showLabel={showLabel}
|
|
100
|
+
showInfo={false}
|
|
101
|
+
liveReadout={liveReadout}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
125
104
|
}
|
|
126
105
|
|
|
127
|
-
// Self-driving demo cycling Ecc → Pause → Con
|
|
106
|
+
// Self-driving demo cycling Ecc → Pause → Con → 2s rest, banking each phase as it completes.
|
|
128
107
|
export const LiveAnimated: Story = {
|
|
129
108
|
render: () => <LiveTempoDemo />,
|
|
130
109
|
}
|
|
131
110
|
|
|
111
|
+
// Interactive: drag the size (form factor) and toggle the TEMPO label / readout mode live.
|
|
112
|
+
interface PlaygroundArgs {
|
|
113
|
+
fontSize: number
|
|
114
|
+
showLabel: boolean
|
|
115
|
+
liveReadout: 'countdown' | 'countup'
|
|
116
|
+
}
|
|
117
|
+
export const LivePlayground: StoryObj<PlaygroundArgs> = {
|
|
118
|
+
args: { fontSize: 26, showLabel: true, liveReadout: 'countdown' },
|
|
119
|
+
argTypes: {
|
|
120
|
+
fontSize: { control: { type: 'range', min: 12, max: 52, step: 2 }, description: 'Form factor' },
|
|
121
|
+
showLabel: { control: 'boolean', description: 'Toggle the TEMPO label' },
|
|
122
|
+
liveReadout: { control: 'inline-radio', options: ['countdown', 'countup'] },
|
|
123
|
+
},
|
|
124
|
+
render: (args) => (
|
|
125
|
+
<LiveTempoDemo
|
|
126
|
+
fontSize={args.fontSize}
|
|
127
|
+
showLabel={args.showLabel}
|
|
128
|
+
liveReadout={args.liveReadout}
|
|
129
|
+
/>
|
|
130
|
+
),
|
|
131
|
+
parameters: {
|
|
132
|
+
docs: {
|
|
133
|
+
description: {
|
|
134
|
+
story:
|
|
135
|
+
'The live tempo is responsive — the whole chip (height, digits, padding, label) ' +
|
|
136
|
+
'scales with the size. Drag **fontSize** to see any form factor, toggle **showLabel** ' +
|
|
137
|
+
'(works at every size), and switch the readout mode.',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
// Static and live side by side — the same prescription, two modes.
|
|
133
144
|
export const StaticVsLive: Story = {
|
|
134
145
|
render: () => (
|
|
@@ -142,3 +153,167 @@ export const StaticVsLive: Story = {
|
|
|
142
153
|
</View>
|
|
143
154
|
),
|
|
144
155
|
}
|
|
156
|
+
|
|
157
|
+
// --- Active tempo conditions (the countdown/fill readout under different states) ----
|
|
158
|
+
|
|
159
|
+
/** One captioned live-tempo specimen at a larger scale so the fill + readout read clearly. */
|
|
160
|
+
function Condition({
|
|
161
|
+
caption,
|
|
162
|
+
...props
|
|
163
|
+
}: { caption: string } & ComponentProps<typeof TempoDisplay>) {
|
|
164
|
+
return (
|
|
165
|
+
<View style={{ gap: 5 }}>
|
|
166
|
+
<Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
|
|
167
|
+
{caption}
|
|
168
|
+
</Text>
|
|
169
|
+
<TempoDisplay showLabel={false} showInfo={false} fontSize={26} {...props} />
|
|
170
|
+
</View>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
175
|
+
return (
|
|
176
|
+
<View style={{ gap: 12 }}>
|
|
177
|
+
<Text
|
|
178
|
+
style={{
|
|
179
|
+
color: '#E5E7EB',
|
|
180
|
+
fontFamily: 'Inter, sans-serif',
|
|
181
|
+
fontSize: 12,
|
|
182
|
+
fontWeight: '700',
|
|
183
|
+
letterSpacing: 1,
|
|
184
|
+
textTransform: 'uppercase',
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{title}
|
|
188
|
+
</Text>
|
|
189
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 36, alignItems: 'flex-start' }}>
|
|
190
|
+
{children}
|
|
191
|
+
</View>
|
|
192
|
+
</View>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// tempo [3,1,2,2] — 3s eccentric, 1s bottom pause, 2s concentric, and a 2s rest at the top.
|
|
197
|
+
const TEMPO = [3, 1, 2, 2] as [number, number, number, number]
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The active tempo across the states it moves through: pacing (ahead → on-target →
|
|
201
|
+
* behind), each phase active, the countdown vs count-up readout, and idle. Static
|
|
202
|
+
* frames plus the self-driving animation, all in the one hyphenated display.
|
|
203
|
+
*/
|
|
204
|
+
export const ActiveTempoConditions: Story = {
|
|
205
|
+
render: () => (
|
|
206
|
+
<View style={{ gap: 28, padding: 8 }}>
|
|
207
|
+
<Section title="Pacing — the number by time-to-target (yellow ahead → green ±0.1 → red slow)">
|
|
208
|
+
<Condition
|
|
209
|
+
caption="Ahead · 1.2s → 1.8 left (yellow)"
|
|
210
|
+
tempo={TEMPO}
|
|
211
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1200 }}
|
|
212
|
+
/>
|
|
213
|
+
<Condition
|
|
214
|
+
caption="On target · 2.9s → 0.1 left (green)"
|
|
215
|
+
tempo={TEMPO}
|
|
216
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 2900 }}
|
|
217
|
+
/>
|
|
218
|
+
<Condition
|
|
219
|
+
caption="Slow · 3.8s → −0.8 over (red)"
|
|
220
|
+
tempo={TEMPO}
|
|
221
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 3800 }}
|
|
222
|
+
/>
|
|
223
|
+
</Section>
|
|
224
|
+
|
|
225
|
+
<Section title="Through a rep — earlier phases bank their final time, semantically toned">
|
|
226
|
+
<Condition
|
|
227
|
+
caption="Eccentric active · nothing banked yet"
|
|
228
|
+
tempo={TEMPO}
|
|
229
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
|
|
230
|
+
/>
|
|
231
|
+
<Condition
|
|
232
|
+
caption="Pause active · ecc banked slow (−0.5, red)"
|
|
233
|
+
tempo={TEMPO}
|
|
234
|
+
live={{ activePhase: 'pauseBottom', phaseElapsedMs: 500, completed: { eccentric: 3500 } }}
|
|
235
|
+
/>
|
|
236
|
+
<Condition
|
|
237
|
+
caption="Concentric active · ecc on-target, pause fast (+0.2, yellow)"
|
|
238
|
+
tempo={TEMPO}
|
|
239
|
+
live={{
|
|
240
|
+
activePhase: 'concentric',
|
|
241
|
+
phaseElapsedMs: 1000,
|
|
242
|
+
completed: { eccentric: 3000, pauseBottom: 800 },
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
<Condition
|
|
246
|
+
caption="Rest at top (2s) · whole rep banked (green/yellow/red)"
|
|
247
|
+
tempo={TEMPO}
|
|
248
|
+
live={{
|
|
249
|
+
activePhase: 'pauseTop',
|
|
250
|
+
phaseElapsedMs: 700,
|
|
251
|
+
completed: { eccentric: 3000, pauseBottom: 800, concentric: 2200 },
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
</Section>
|
|
255
|
+
|
|
256
|
+
<Section title="Readout mode — eccentric, 1.0s into a 3s phase">
|
|
257
|
+
<Condition
|
|
258
|
+
caption="Countdown → 0.0 · shows 2.0 left"
|
|
259
|
+
tempo={TEMPO}
|
|
260
|
+
liveReadout="countdown"
|
|
261
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
|
|
262
|
+
/>
|
|
263
|
+
<Condition
|
|
264
|
+
caption="Count-up → target · shows 1.0 elapsed"
|
|
265
|
+
tempo={TEMPO}
|
|
266
|
+
liveReadout="countup"
|
|
267
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
|
|
268
|
+
/>
|
|
269
|
+
</Section>
|
|
270
|
+
|
|
271
|
+
<Section title="Form factors — the view scales; the label toggles at any size">
|
|
272
|
+
<Condition
|
|
273
|
+
caption="Compact (14) · label off"
|
|
274
|
+
tempo={TEMPO}
|
|
275
|
+
fontSize={14}
|
|
276
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
|
|
277
|
+
/>
|
|
278
|
+
<Condition
|
|
279
|
+
caption="Default (22) · label on"
|
|
280
|
+
tempo={TEMPO}
|
|
281
|
+
fontSize={22}
|
|
282
|
+
showLabel
|
|
283
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
|
|
284
|
+
/>
|
|
285
|
+
<Condition
|
|
286
|
+
caption="Wall (34) · label on"
|
|
287
|
+
tempo={TEMPO}
|
|
288
|
+
fontSize={34}
|
|
289
|
+
showLabel
|
|
290
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
|
|
291
|
+
/>
|
|
292
|
+
</Section>
|
|
293
|
+
|
|
294
|
+
<Section title="Idle & animated">
|
|
295
|
+
<Condition
|
|
296
|
+
caption="Idle · no active phase (static)"
|
|
297
|
+
tempo={TEMPO}
|
|
298
|
+
live={{ activePhase: null, phaseElapsedMs: 0 }}
|
|
299
|
+
/>
|
|
300
|
+
<View style={{ gap: 5 }}>
|
|
301
|
+
<Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
|
|
302
|
+
Animated · Ecc → Pause → Con → 2s rest, banking each phase
|
|
303
|
+
</Text>
|
|
304
|
+
<LiveTempoDemo />
|
|
305
|
+
</View>
|
|
306
|
+
</Section>
|
|
307
|
+
</View>
|
|
308
|
+
),
|
|
309
|
+
parameters: {
|
|
310
|
+
docs: {
|
|
311
|
+
description: {
|
|
312
|
+
story:
|
|
313
|
+
'The active tempo under different conditions — pacing (ahead → on-target → behind), ' +
|
|
314
|
+
'each phase active, the countdown vs count-up readout, and idle — all in the one ' +
|
|
315
|
+
'hyphenated display with the vertical phase-progress fill.',
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
}
|
|
@@ -22,14 +22,9 @@ describe('TempoDisplay', () => {
|
|
|
22
22
|
expect(tempoValue.children.length).toBeGreaterThan(1)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
it('renders mono variant', () => {
|
|
26
|
-
render(<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />)
|
|
27
|
-
expect(screen.getByText('3-1-1-0')).toBeInTheDocument()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
25
|
it('renders order: eccentric-pauseBottom-concentric-pauseTop', () => {
|
|
31
|
-
render(<TempoDisplay tempo={[4, 3, 2, 1]}
|
|
32
|
-
expect(screen.
|
|
26
|
+
render(<TempoDisplay tempo={[4, 3, 2, 1]} />)
|
|
27
|
+
expect(screen.getByTestId('tempo-value')).toHaveTextContent('4-3-2-1')
|
|
33
28
|
})
|
|
34
29
|
|
|
35
30
|
it('has correct accessibility label', () => {
|
|
@@ -78,6 +73,11 @@ describe('TempoDisplay', () => {
|
|
|
78
73
|
render(<TempoDisplay tempo={[3, 1, 1, 0]} size="md" />)
|
|
79
74
|
expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
|
|
80
75
|
})
|
|
76
|
+
|
|
77
|
+
it('overrides the digit font size via fontSize', () => {
|
|
78
|
+
render(<TempoDisplay tempo={[3, 1, 1, 0]} fontSize={32} showLabel={false} />)
|
|
79
|
+
expect(screen.getByText('3')).toHaveStyle({ fontSize: 32 })
|
|
80
|
+
})
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
describe('live phase-fill', () => {
|
|
@@ -86,15 +86,30 @@ describe('TempoDisplay', () => {
|
|
|
86
86
|
expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
it('renders
|
|
89
|
+
it('renders the active phase as a live countdown readout with a fill', () => {
|
|
90
90
|
render(
|
|
91
91
|
<TempoDisplay
|
|
92
92
|
tempo={[3, 1, 1, 0]}
|
|
93
|
-
live={{ activePhase: 'eccentric', phaseElapsedMs:
|
|
93
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
|
|
94
94
|
/>
|
|
95
95
|
)
|
|
96
96
|
expect(screen.getByTestId('tempo-live-active')).toBeInTheDocument()
|
|
97
|
-
|
|
97
|
+
// eccentric target 3s, 1s in → 2.0s remaining (countdown is the default)
|
|
98
|
+
expect(screen.getByText('2.0')).toBeInTheDocument()
|
|
99
|
+
// the static eccentric digit is replaced by the readout
|
|
100
|
+
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('counts elapsed up when liveReadout is countup', () => {
|
|
104
|
+
render(
|
|
105
|
+
<TempoDisplay
|
|
106
|
+
tempo={[4, 1, 2, 3]}
|
|
107
|
+
liveReadout="countup"
|
|
108
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
// 1.5s elapsed of the 4s eccentric → 1.5 counting up (unique among the target readouts)
|
|
112
|
+
expect(screen.getByText('1.5')).toBeInTheDocument()
|
|
98
113
|
})
|
|
99
114
|
|
|
100
115
|
it('marks no phase active when idle (activePhase null)', () => {
|
|
@@ -102,16 +117,46 @@ describe('TempoDisplay', () => {
|
|
|
102
117
|
expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
|
|
103
118
|
})
|
|
104
119
|
|
|
105
|
-
it('
|
|
120
|
+
it('locks the phases completed earlier in the rep, and resets on the first phase', () => {
|
|
121
|
+
// Concentric active (3rd phase) → eccentric + pauseBottom are locked done.
|
|
122
|
+
const mid = render(
|
|
123
|
+
<TempoDisplay tempo={[3, 1, 2, 1]} live={{ activePhase: 'concentric', phaseElapsedMs: 800 }} />
|
|
124
|
+
)
|
|
125
|
+
expect(mid.getAllByTestId('tempo-live-done')).toHaveLength(2)
|
|
126
|
+
// Back to the first phase (next rep) → nothing is locked yet.
|
|
127
|
+
mid.rerender(
|
|
128
|
+
<TempoDisplay tempo={[3, 1, 2, 1]} live={{ activePhase: 'eccentric', phaseElapsedMs: 200 }} />
|
|
129
|
+
)
|
|
130
|
+
expect(mid.queryByTestId('tempo-live-done')).not.toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('colours the TEMPO label live-muted while live', () => {
|
|
134
|
+
const { getByText, rerender } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
|
|
135
|
+
const restColor = getByText('TEMPO').style.color
|
|
136
|
+
rerender(<TempoDisplay tempo={[3, 1, 1, 0]} live={{ activePhase: 'eccentric', phaseElapsedMs: 500 }} />)
|
|
137
|
+
expect(getByText('TEMPO').style.color).not.toBe(restColor)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('shows upcoming phases as their prescribed time and the active as the live readout', () => {
|
|
141
|
+
render(
|
|
142
|
+
<TempoDisplay tempo={[5, 3, 2, 1]} live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }} />
|
|
143
|
+
)
|
|
144
|
+
// active eccentric: 5s target, 1s in → 4.0 remaining
|
|
145
|
+
expect(screen.getByText('4.0')).toBeInTheDocument()
|
|
146
|
+
// upcoming pauseBottom shows its prescribed 3s as 3.0 (no bare integer digits in live mode)
|
|
147
|
+
expect(screen.getByText('3.0')).toBeInTheDocument()
|
|
148
|
+
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('freezes a completed phase at its actual final time, not the target', () => {
|
|
106
152
|
render(
|
|
107
153
|
<TempoDisplay
|
|
108
|
-
tempo={[
|
|
109
|
-
live={{ activePhase: 'concentric', phaseElapsedMs: 500 }}
|
|
154
|
+
tempo={[3, 1, 2, 2]}
|
|
155
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 500, completed: { eccentric: 3400 } }}
|
|
110
156
|
/>
|
|
111
157
|
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
158
|
+
// eccentric ran 3.4s vs its 3s target → countdown froze at 3.0 − 3.4 = −0.4, not the target
|
|
159
|
+
expect(screen.getByText('-0.4')).toBeInTheDocument()
|
|
115
160
|
})
|
|
116
161
|
})
|
|
117
162
|
|