@titan-design/react-ui 0.7.0 → 0.8.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 +109 -12
- package/dist/index.d.ts +109 -12
- package/dist/index.js +648 -231
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +648 -232
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
- package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +495 -92
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +496 -93
- 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/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 +47 -0
- 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/TempoBar.stories.tsx +69 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
- package/src/components/custom/Workout/TempoBar.tsx +107 -22
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
- 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 +76 -18
- 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
|
@@ -3,9 +3,13 @@ import { View, Text, Pressable } from 'react-native'
|
|
|
3
3
|
import { resolveColor } from '../../../theme/resolve-color'
|
|
4
4
|
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
5
|
import { useTimer } from '../../../hooks/useTimer'
|
|
6
|
+
import { CircularTimer } from '../CircularTimer/CircularTimer'
|
|
6
7
|
|
|
7
8
|
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
8
9
|
|
|
10
|
+
/** Default `ring` diameter (px) — the across-the-room wall rest treatment. */
|
|
11
|
+
const RING_DEFAULT_SIZE = 180
|
|
12
|
+
|
|
9
13
|
export interface RestTimerProps {
|
|
10
14
|
totalSeconds: number
|
|
11
15
|
elapsedMs: number
|
|
@@ -15,6 +19,123 @@ export interface RestTimerProps {
|
|
|
15
19
|
visible: boolean
|
|
16
20
|
/** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
|
|
17
21
|
displayOnly?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* `bar` (default) — the compact linear card (REST label + mm:ss + progress bar +
|
|
24
|
+
* controls), the mobile bottom-bar treatment. `ring` — the across-the-room wall
|
|
25
|
+
* treatment: a draining countdown ring (composes {@link CircularProgress}) with a
|
|
26
|
+
* big remaining-seconds numeral, the next-set line, and the same controls.
|
|
27
|
+
*/
|
|
28
|
+
variant?: 'bar' | 'ring'
|
|
29
|
+
/** `ring` diameter in px. Default 180. */
|
|
30
|
+
size?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The +30s / Skip control row — shared by both variants (hidden in `displayOnly`). */
|
|
34
|
+
function RestActions({ onAddTime, onSkip }: { onAddTime: () => void; onSkip: () => void }) {
|
|
35
|
+
return (
|
|
36
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
37
|
+
<Pressable
|
|
38
|
+
onPress={onAddTime}
|
|
39
|
+
style={{
|
|
40
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
41
|
+
paddingVertical: 8,
|
|
42
|
+
paddingHorizontal: 20,
|
|
43
|
+
borderRadius: 8,
|
|
44
|
+
}}
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
accessibilityLabel="Add 30 seconds"
|
|
47
|
+
testID="rest-timer-add-time"
|
|
48
|
+
>
|
|
49
|
+
<Text
|
|
50
|
+
className="text-text-secondary"
|
|
51
|
+
style={{ fontSize: 11, fontFamily: 'Inter, sans-serif', fontWeight: '600' }}
|
|
52
|
+
>
|
|
53
|
+
+30s
|
|
54
|
+
</Text>
|
|
55
|
+
</Pressable>
|
|
56
|
+
<Pressable
|
|
57
|
+
onPress={onSkip}
|
|
58
|
+
style={{
|
|
59
|
+
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
60
|
+
paddingVertical: 8,
|
|
61
|
+
paddingHorizontal: 20,
|
|
62
|
+
borderRadius: 8,
|
|
63
|
+
}}
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
accessibilityLabel="Skip rest"
|
|
66
|
+
testID="rest-timer-skip"
|
|
67
|
+
>
|
|
68
|
+
<Text
|
|
69
|
+
style={{
|
|
70
|
+
fontSize: 11,
|
|
71
|
+
fontFamily: 'Inter, sans-serif',
|
|
72
|
+
fontWeight: '600',
|
|
73
|
+
color: BRAND_PRIMARY,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
Skip
|
|
77
|
+
</Text>
|
|
78
|
+
</Pressable>
|
|
79
|
+
</View>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RestTimerRingProps {
|
|
84
|
+
totalSeconds: number
|
|
85
|
+
elapsedMs: number
|
|
86
|
+
remainingSec: number
|
|
87
|
+
done: boolean
|
|
88
|
+
size: number
|
|
89
|
+
nextSetInfo?: string
|
|
90
|
+
displayOnly: boolean
|
|
91
|
+
onSkip: () => void
|
|
92
|
+
onAddTime: () => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The `ring` render: the wall rest treatment. Composes the generic {@link CircularTimer}
|
|
97
|
+
* (which owns the arc + mm:ss readout + the fill-green "GO" done state), passing the
|
|
98
|
+
* shared {@link RestActions} as its controls, and adds the rest-specific next-set footer
|
|
99
|
+
* caption below. Rest-domain chrome only — the timer mechanics live in `CircularTimer`.
|
|
100
|
+
*/
|
|
101
|
+
function RestTimerRing({
|
|
102
|
+
totalSeconds,
|
|
103
|
+
elapsedMs,
|
|
104
|
+
remainingSec,
|
|
105
|
+
done,
|
|
106
|
+
size,
|
|
107
|
+
nextSetInfo,
|
|
108
|
+
displayOnly,
|
|
109
|
+
onSkip,
|
|
110
|
+
onAddTime,
|
|
111
|
+
}: RestTimerRingProps) {
|
|
112
|
+
const a11yLabel = done
|
|
113
|
+
? 'Rest complete, next set ready'
|
|
114
|
+
: `Rest timer, ${Math.max(0, remainingSec)} seconds remaining`
|
|
115
|
+
return (
|
|
116
|
+
<View style={{ alignItems: 'center', gap: 16 }} testID="rest-timer">
|
|
117
|
+
<CircularTimer
|
|
118
|
+
durationMs={totalSeconds * 1000}
|
|
119
|
+
elapsedMs={elapsedMs}
|
|
120
|
+
mode="down"
|
|
121
|
+
size={size}
|
|
122
|
+
doneLabel="GO"
|
|
123
|
+
doneColor="success"
|
|
124
|
+
accessibilityLabel={a11yLabel}
|
|
125
|
+
controls={!displayOnly ? <RestActions onAddTime={onAddTime} onSkip={onSkip} /> : undefined}
|
|
126
|
+
testID="rest-timer-ring"
|
|
127
|
+
/>
|
|
128
|
+
{nextSetInfo != null && (
|
|
129
|
+
<Text
|
|
130
|
+
className="text-text-tertiary"
|
|
131
|
+
style={{ fontSize: 13, fontFamily: 'Inter, sans-serif' }}
|
|
132
|
+
testID="rest-timer-next-set"
|
|
133
|
+
>
|
|
134
|
+
{nextSetInfo}
|
|
135
|
+
</Text>
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
)
|
|
18
139
|
}
|
|
19
140
|
|
|
20
141
|
export function RestTimer({
|
|
@@ -25,6 +146,8 @@ export function RestTimer({
|
|
|
25
146
|
nextSetInfo,
|
|
26
147
|
visible,
|
|
27
148
|
displayOnly = false,
|
|
149
|
+
variant = 'bar',
|
|
150
|
+
size = RING_DEFAULT_SIZE,
|
|
28
151
|
}: RestTimerProps) {
|
|
29
152
|
// useTimer owns the countdown math (remaining/progress/mm:ss); a zero-duration
|
|
30
153
|
// timer is complete, which keeps the width out of the 0/0 === NaN case.
|
|
@@ -32,6 +155,7 @@ export function RestTimer({
|
|
|
32
155
|
remainingMs,
|
|
33
156
|
progress,
|
|
34
157
|
label: timeDisplay,
|
|
158
|
+
done,
|
|
35
159
|
} = useTimer({
|
|
36
160
|
mode: 'down',
|
|
37
161
|
durationMs: totalSeconds * 1000,
|
|
@@ -42,6 +166,22 @@ export function RestTimer({
|
|
|
42
166
|
|
|
43
167
|
if (!visible) return null
|
|
44
168
|
|
|
169
|
+
if (variant === 'ring') {
|
|
170
|
+
return (
|
|
171
|
+
<RestTimerRing
|
|
172
|
+
totalSeconds={totalSeconds}
|
|
173
|
+
elapsedMs={elapsedMs}
|
|
174
|
+
remainingSec={remainingSec}
|
|
175
|
+
done={done}
|
|
176
|
+
size={size}
|
|
177
|
+
nextSetInfo={nextSetInfo}
|
|
178
|
+
displayOnly={displayOnly}
|
|
179
|
+
onSkip={onSkip}
|
|
180
|
+
onAddTime={onAddTime}
|
|
181
|
+
/>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
45
185
|
return (
|
|
46
186
|
<View
|
|
47
187
|
className="bg-surface-raised"
|
|
@@ -133,56 +273,7 @@ export function RestTimer({
|
|
|
133
273
|
</View>
|
|
134
274
|
|
|
135
275
|
{/* Actions row — hidden in display-only (poll-only) mode */}
|
|
136
|
-
{!displayOnly &&
|
|
137
|
-
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
138
|
-
<Pressable
|
|
139
|
-
onPress={onAddTime}
|
|
140
|
-
style={{
|
|
141
|
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
142
|
-
paddingVertical: 8,
|
|
143
|
-
paddingHorizontal: 20,
|
|
144
|
-
borderRadius: 8,
|
|
145
|
-
}}
|
|
146
|
-
accessibilityRole="button"
|
|
147
|
-
accessibilityLabel="Add 30 seconds"
|
|
148
|
-
testID="rest-timer-add-time"
|
|
149
|
-
>
|
|
150
|
-
<Text
|
|
151
|
-
className="text-text-secondary"
|
|
152
|
-
style={{
|
|
153
|
-
fontSize: 11,
|
|
154
|
-
fontFamily: 'Inter, sans-serif',
|
|
155
|
-
fontWeight: '600',
|
|
156
|
-
}}
|
|
157
|
-
>
|
|
158
|
-
+30s
|
|
159
|
-
</Text>
|
|
160
|
-
</Pressable>
|
|
161
|
-
<Pressable
|
|
162
|
-
onPress={onSkip}
|
|
163
|
-
style={{
|
|
164
|
-
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
165
|
-
paddingVertical: 8,
|
|
166
|
-
paddingHorizontal: 20,
|
|
167
|
-
borderRadius: 8,
|
|
168
|
-
}}
|
|
169
|
-
accessibilityRole="button"
|
|
170
|
-
accessibilityLabel="Skip rest"
|
|
171
|
-
testID="rest-timer-skip"
|
|
172
|
-
>
|
|
173
|
-
<Text
|
|
174
|
-
style={{
|
|
175
|
-
fontSize: 11,
|
|
176
|
-
fontFamily: 'Inter, sans-serif',
|
|
177
|
-
fontWeight: '600',
|
|
178
|
-
color: BRAND_PRIMARY,
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
Skip
|
|
182
|
-
</Text>
|
|
183
|
-
</Pressable>
|
|
184
|
-
</View>
|
|
185
|
-
)}
|
|
276
|
+
{!displayOnly && <RestActions onAddTime={onAddTime} onSkip={onSkip} />}
|
|
186
277
|
</View>
|
|
187
278
|
)
|
|
188
279
|
}
|
|
@@ -15,6 +15,16 @@ const meta: Meta<typeof TempoBar> = {
|
|
|
15
15
|
phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
|
|
16
16
|
completed: { control: 'object', description: 'Completed phase durations (ms)' },
|
|
17
17
|
target: { control: 'object', description: 'Per-phase target durations (seconds)' },
|
|
18
|
+
size: {
|
|
19
|
+
control: 'inline-radio',
|
|
20
|
+
options: ['default', 'wall'],
|
|
21
|
+
description: 'Density: default (compact) or wall (across-the-room dashboard scale)',
|
|
22
|
+
},
|
|
23
|
+
activeDisplay: {
|
|
24
|
+
control: 'inline-radio',
|
|
25
|
+
options: ['time', 'delta'],
|
|
26
|
+
description: 'Active readout: time (elapsed/target) or delta (countdown). Tap to toggle.',
|
|
27
|
+
},
|
|
18
28
|
},
|
|
19
29
|
decorators: [
|
|
20
30
|
(Story) => (
|
|
@@ -93,3 +103,62 @@ export const AllStates: Story = {
|
|
|
93
103
|
</View>
|
|
94
104
|
),
|
|
95
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The across-the-room wall scale — bigger bar, full phase words, and colour-coded
|
|
109
|
+
* pacing (a missed target reads red). Here the eccentric ran long → red.
|
|
110
|
+
*/
|
|
111
|
+
export const Wall: Story = {
|
|
112
|
+
args: {
|
|
113
|
+
activePhase: null,
|
|
114
|
+
phaseElapsedMs: 0,
|
|
115
|
+
completed: { concentric: 950, hold: 1000, eccentric: 4200 },
|
|
116
|
+
target: TARGET,
|
|
117
|
+
size: 'wall',
|
|
118
|
+
},
|
|
119
|
+
decorators: [
|
|
120
|
+
(Story) => (
|
|
121
|
+
<View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
|
|
122
|
+
<Story />
|
|
123
|
+
</View>
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wall, mid-rep — the eccentric is active, showing the delta countdown (1.5s to target). Tap to switch to elapsed/target. */
|
|
129
|
+
export const WallActiveDelta: Story = {
|
|
130
|
+
args: {
|
|
131
|
+
activePhase: 'eccentric',
|
|
132
|
+
phaseElapsedMs: 1500,
|
|
133
|
+
completed: { concentric: 950, hold: 1000 },
|
|
134
|
+
target: TARGET,
|
|
135
|
+
size: 'wall',
|
|
136
|
+
activeDisplay: 'delta',
|
|
137
|
+
},
|
|
138
|
+
decorators: [
|
|
139
|
+
(Story) => (
|
|
140
|
+
<View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
|
|
141
|
+
<Story />
|
|
142
|
+
</View>
|
|
143
|
+
),
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Wall, over target — the eccentric ran 1.2s past its target, so the delta is negative and red. */
|
|
148
|
+
export const WallActiveOver: Story = {
|
|
149
|
+
args: {
|
|
150
|
+
activePhase: 'eccentric',
|
|
151
|
+
phaseElapsedMs: 4200,
|
|
152
|
+
completed: { concentric: 950, hold: 1000 },
|
|
153
|
+
target: TARGET,
|
|
154
|
+
size: 'wall',
|
|
155
|
+
activeDisplay: 'delta',
|
|
156
|
+
},
|
|
157
|
+
decorators: [
|
|
158
|
+
(Story) => (
|
|
159
|
+
<View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
|
|
160
|
+
<Story />
|
|
161
|
+
</View>
|
|
162
|
+
),
|
|
163
|
+
],
|
|
164
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { axe } from 'jest-axe'
|
|
4
4
|
import {
|
|
5
5
|
TempoBar,
|
|
6
6
|
getTempoPacingState,
|
|
7
7
|
getTempoFillPct,
|
|
8
|
+
formatTempoDelta,
|
|
8
9
|
TEMPO_PACING,
|
|
9
10
|
} from './TempoBar'
|
|
11
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
10
12
|
|
|
13
|
+
const t = getSemanticColors('dark')
|
|
11
14
|
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
12
15
|
|
|
13
16
|
describe('getTempoPacingState', () => {
|
|
@@ -70,7 +73,7 @@ describe('TempoBar', () => {
|
|
|
70
73
|
phaseElapsedMs={100}
|
|
71
74
|
completed={{ concentric: 950 }}
|
|
72
75
|
target={TARGET}
|
|
73
|
-
|
|
76
|
+
/>
|
|
74
77
|
)
|
|
75
78
|
expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
|
|
76
79
|
expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
|
|
@@ -83,7 +86,7 @@ describe('TempoBar', () => {
|
|
|
83
86
|
phaseElapsedMs={100}
|
|
84
87
|
completed={{ eccentric: 4000 }}
|
|
85
88
|
target={TARGET}
|
|
86
|
-
|
|
89
|
+
/>
|
|
87
90
|
)
|
|
88
91
|
// eccentric target 3s, 4s elapsed => behind => cross
|
|
89
92
|
expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
|
|
@@ -96,8 +99,120 @@ describe('TempoBar', () => {
|
|
|
96
99
|
phaseElapsedMs={1500}
|
|
97
100
|
completed={{ concentric: 900, hold: 1000 }}
|
|
98
101
|
target={TARGET}
|
|
99
|
-
|
|
102
|
+
/>
|
|
100
103
|
)
|
|
101
104
|
expect(await axe(container)).toHaveNoViolations()
|
|
102
105
|
})
|
|
103
106
|
})
|
|
107
|
+
|
|
108
|
+
describe('TempoBar size="wall"', () => {
|
|
109
|
+
const TARGET_W = { concentric: 1, hold: 1, eccentric: 3 }
|
|
110
|
+
|
|
111
|
+
it('scales the active label up at wall density', () => {
|
|
112
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} size="wall" />)
|
|
113
|
+
expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 22 })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('keeps the compact label size by default', () => {
|
|
117
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} />)
|
|
118
|
+
expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 12 })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('preserves the segment testIDs at wall density', () => {
|
|
122
|
+
render(
|
|
123
|
+
<TempoBar
|
|
124
|
+
activePhase="eccentric"
|
|
125
|
+
phaseElapsedMs={1500}
|
|
126
|
+
completed={{ concentric: 900 }}
|
|
127
|
+
target={TARGET_W}
|
|
128
|
+
size="wall"
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
expect(screen.getByTestId('tempo-segment-active-Ecc')).toBeInTheDocument()
|
|
132
|
+
expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('spells out the full phase words at wall density', () => {
|
|
136
|
+
render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET_W} size="wall" />)
|
|
137
|
+
expect(screen.getByText('Concentric')).toBeInTheDocument()
|
|
138
|
+
expect(screen.getByText('Eccentric')).toBeInTheDocument()
|
|
139
|
+
expect(screen.queryByText('Con')).not.toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('formatTempoDelta', () => {
|
|
144
|
+
it('shows remaining time to target as a positive value', () => {
|
|
145
|
+
expect(formatTempoDelta(1500)).toBe('1.5')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('shows over-target time as negative', () => {
|
|
149
|
+
expect(formatTempoDelta(-1200)).toBe('-1.2')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('shows 0.0 at the target (no -0.0)', () => {
|
|
153
|
+
expect(formatTempoDelta(0)).toBe('0.0')
|
|
154
|
+
expect(formatTempoDelta(-10)).toBe('0.0')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('TempoBar activeDisplay (delta countdown + toggle)', () => {
|
|
159
|
+
it('counts the remaining time down to the target in delta mode', () => {
|
|
160
|
+
render(
|
|
161
|
+
<TempoBar
|
|
162
|
+
activePhase="eccentric"
|
|
163
|
+
phaseElapsedMs={1800}
|
|
164
|
+
target={TARGET}
|
|
165
|
+
activeDisplay="delta"
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
// eccentric target 3.0s, elapsed 1.8s → 1.2s remaining
|
|
169
|
+
expect(screen.getByText('1.2')).toBeInTheDocument()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('goes negative once the phase runs past its target', () => {
|
|
173
|
+
render(
|
|
174
|
+
<TempoBar
|
|
175
|
+
activePhase="eccentric"
|
|
176
|
+
phaseElapsedMs={4200}
|
|
177
|
+
target={TARGET}
|
|
178
|
+
activeDisplay="delta"
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
expect(screen.getByText('-1.2')).toBeInTheDocument()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('tapping the active segment toggles time ↔ delta', () => {
|
|
185
|
+
render(
|
|
186
|
+
<TempoBar
|
|
187
|
+
activePhase="concentric"
|
|
188
|
+
phaseElapsedMs={600}
|
|
189
|
+
target={TARGET}
|
|
190
|
+
activeDisplay="time"
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
|
|
194
|
+
fireEvent.click(screen.getByTestId('tempo-segment-active-Con'))
|
|
195
|
+
// concentric target 1.0s, elapsed 0.6s → 0.4s remaining
|
|
196
|
+
expect(screen.getByText('0.4')).toBeInTheDocument()
|
|
197
|
+
expect(screen.queryByText('0.6 / 1.0')).not.toBeInTheDocument()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('defaults to the time readout', () => {
|
|
201
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
|
|
202
|
+
expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('TempoBar completed pacing colour', () => {
|
|
207
|
+
it('colours a missed completed phase red', () => {
|
|
208
|
+
render(
|
|
209
|
+
<TempoBar
|
|
210
|
+
activePhase="hold"
|
|
211
|
+
phaseElapsedMs={100}
|
|
212
|
+
completed={{ eccentric: 4000 }}
|
|
213
|
+
target={TARGET}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
expect(screen.getByText('4.0 ✗')).toHaveStyle({ color: t['status-error'] })
|
|
217
|
+
})
|
|
218
|
+
})
|