@titan-design/react-ui 0.8.0 → 0.9.1
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 +19 -59
- package/dist/index.d.ts +19 -59
- package/dist/index.js +248 -371
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -368
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DCFBqp79.d.mts → pages-D7hOD4Vj.d.mts} +1 -1
- package/dist/{pages-_fI3-x7Z.d.ts → pages-DyEx-lBf.d.ts} +1 -1
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +199 -161
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +199 -161
- package/dist/pages.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
- package/src/components/custom/Workout/README.md +33 -13
- 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.test.tsx +3 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +45 -11
- package/src/components/custom/Workout/ZoneTrack.tsx +15 -1
- package/src/components/custom/Workout/index.ts +1 -9
- package/src/test/no-device-internals.test.ts +51 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -164
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -218
- package/src/components/custom/Workout/TempoBar.tsx +0 -300
|
@@ -1,164 +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
|
-
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
|
-
},
|
|
28
|
-
},
|
|
29
|
-
decorators: [
|
|
30
|
-
(Story) => (
|
|
31
|
-
<View style={{ width: 240 }}>
|
|
32
|
-
<Story />
|
|
33
|
-
</View>
|
|
34
|
-
),
|
|
35
|
-
],
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export default meta
|
|
39
|
-
type Story = StoryObj<typeof TempoBar>
|
|
40
|
-
|
|
41
|
-
// tempo target: 1s concentric, 1s hold-top, 3s eccentric
|
|
42
|
-
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
43
|
-
|
|
44
|
-
export const Idle: Story = {
|
|
45
|
-
args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const ConcentricActive: Story = {
|
|
49
|
-
args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export const EccentricMidRep: Story = {
|
|
53
|
-
args: {
|
|
54
|
-
activePhase: 'eccentric',
|
|
55
|
-
phaseElapsedMs: 1500,
|
|
56
|
-
completed: { concentric: 900, hold: 1000 },
|
|
57
|
-
target: TARGET,
|
|
58
|
-
},
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export const BehindPace: Story = {
|
|
62
|
-
args: {
|
|
63
|
-
activePhase: 'eccentric',
|
|
64
|
-
phaseElapsedMs: 4200,
|
|
65
|
-
completed: { concentric: 1600, hold: 1000 },
|
|
66
|
-
target: TARGET,
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export const RepComplete: Story = {
|
|
71
|
-
args: {
|
|
72
|
-
activePhase: null,
|
|
73
|
-
phaseElapsedMs: 0,
|
|
74
|
-
completed: { concentric: 950, hold: 1050, eccentric: 3100 },
|
|
75
|
-
target: TARGET,
|
|
76
|
-
},
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export const NoTarget: Story = {
|
|
80
|
-
args: {
|
|
81
|
-
activePhase: 'concentric',
|
|
82
|
-
phaseElapsedMs: 800,
|
|
83
|
-
},
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const AllStates: Story = {
|
|
87
|
-
render: () => (
|
|
88
|
-
<View style={{ gap: 20 }}>
|
|
89
|
-
<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
|
|
90
|
-
<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
|
|
91
|
-
<TempoBar
|
|
92
|
-
activePhase="eccentric"
|
|
93
|
-
phaseElapsedMs={1500}
|
|
94
|
-
completed={{ concentric: 900, hold: 1000 }}
|
|
95
|
-
target={TARGET}
|
|
96
|
-
/>
|
|
97
|
-
<TempoBar
|
|
98
|
-
activePhase={null}
|
|
99
|
-
phaseElapsedMs={0}
|
|
100
|
-
completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
|
|
101
|
-
target={TARGET}
|
|
102
|
-
/>
|
|
103
|
-
</View>
|
|
104
|
-
),
|
|
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,218 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
-
import { axe } from 'jest-axe'
|
|
4
|
-
import {
|
|
5
|
-
TempoBar,
|
|
6
|
-
getTempoPacingState,
|
|
7
|
-
getTempoFillPct,
|
|
8
|
-
formatTempoDelta,
|
|
9
|
-
TEMPO_PACING,
|
|
10
|
-
} from './TempoBar'
|
|
11
|
-
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
12
|
-
|
|
13
|
-
const t = getSemanticColors('dark')
|
|
14
|
-
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
15
|
-
|
|
16
|
-
describe('getTempoPacingState', () => {
|
|
17
|
-
it('returns none when there is no target', () => {
|
|
18
|
-
expect(getTempoPacingState(1000, null)).toBe('none')
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('returns none for a sub-threshold target', () => {
|
|
22
|
-
expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('returns on-pace when within the behind threshold', () => {
|
|
26
|
-
expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
|
|
27
|
-
expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('returns behind once elapsed exceeds the behind threshold', () => {
|
|
31
|
-
expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
describe('getTempoFillPct', () => {
|
|
36
|
-
it('fills fully when there is no target', () => {
|
|
37
|
-
expect(getTempoFillPct(500, null)).toBe(100)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('is proportional to elapsed vs target', () => {
|
|
41
|
-
expect(getTempoFillPct(500, 1000)).toBe(50)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('clamps at 100 when past target', () => {
|
|
45
|
-
expect(getTempoFillPct(2000, 1000)).toBe(100)
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
describe('TempoBar', () => {
|
|
50
|
-
it('renders the bar with phase labels', () => {
|
|
51
|
-
render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
|
|
52
|
-
expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
|
|
53
|
-
expect(screen.getByText('Con')).toBeInTheDocument()
|
|
54
|
-
expect(screen.getByText('Hold')).toBeInTheDocument()
|
|
55
|
-
expect(screen.getByText('Ecc')).toBeInTheDocument()
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('renders the active segment with an elapsed/target label', () => {
|
|
59
|
-
render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
|
|
60
|
-
expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
|
|
61
|
-
expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('shows only elapsed when the active phase has no target', () => {
|
|
65
|
-
render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
|
|
66
|
-
expect(screen.getByText('0.8')).toBeInTheDocument()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('marks a completed phase that hit its target with a check', () => {
|
|
70
|
-
render(
|
|
71
|
-
<TempoBar
|
|
72
|
-
activePhase="hold"
|
|
73
|
-
phaseElapsedMs={100}
|
|
74
|
-
completed={{ concentric: 950 }}
|
|
75
|
-
target={TARGET}
|
|
76
|
-
/>
|
|
77
|
-
)
|
|
78
|
-
expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
|
|
79
|
-
expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('marks a completed phase that missed its target with a cross', () => {
|
|
83
|
-
render(
|
|
84
|
-
<TempoBar
|
|
85
|
-
activePhase="hold"
|
|
86
|
-
phaseElapsedMs={100}
|
|
87
|
-
completed={{ eccentric: 4000 }}
|
|
88
|
-
target={TARGET}
|
|
89
|
-
/>
|
|
90
|
-
)
|
|
91
|
-
// eccentric target 3s, 4s elapsed => behind => cross
|
|
92
|
-
expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('has no accessibility violations', async () => {
|
|
96
|
-
const { container } = render(
|
|
97
|
-
<TempoBar
|
|
98
|
-
activePhase="eccentric"
|
|
99
|
-
phaseElapsedMs={1500}
|
|
100
|
-
completed={{ concentric: 900, hold: 1000 }}
|
|
101
|
-
target={TARGET}
|
|
102
|
-
/>
|
|
103
|
-
)
|
|
104
|
-
expect(await axe(container)).toHaveNoViolations()
|
|
105
|
-
})
|
|
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
|
-
})
|
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import { useState } from 'react'
|
|
3
|
-
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
4
|
-
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
|
-
import { alpha } from '../../../utils/colors'
|
|
6
|
-
|
|
7
|
-
const t = getSemanticColors('dark')
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Titan-local phase key. Consumers map their own movement-phase enum onto this
|
|
11
|
-
* presentational key at the call site — TempoBar owns rendering only.
|
|
12
|
-
*/
|
|
13
|
-
export type TempoPhaseKey = 'concentric' | 'hold' | 'eccentric'
|
|
14
|
-
|
|
15
|
-
/** Pacing tuning shared with the fill/pacing helpers below. */
|
|
16
|
-
export const TEMPO_PACING = {
|
|
17
|
-
behindThresholdPct: 0.15,
|
|
18
|
-
minPhaseDurationMs: 500,
|
|
19
|
-
} as const
|
|
20
|
-
|
|
21
|
-
export type TempoPacingState = 'none' | 'on-pace' | 'behind'
|
|
22
|
-
|
|
23
|
-
/** Active-phase readout mode: elapsed/target `time`, or a `delta` countdown to the target. */
|
|
24
|
-
export type TempoActiveDisplay = 'time' | 'delta'
|
|
25
|
-
|
|
26
|
-
/** Signed seconds to one decimal — the delta countdown (`+` remaining, `-` over target). */
|
|
27
|
-
export function formatTempoDelta(remainingMs: number): string {
|
|
28
|
-
const s = remainingMs / 1000
|
|
29
|
-
// Guard `-0.0`; show a leading minus only once past target.
|
|
30
|
-
const rounded = Math.abs(s) < 0.05 ? 0 : s
|
|
31
|
-
return rounded.toFixed(1)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Classify how a phase's elapsed time tracks against its target duration.
|
|
36
|
-
* `none` when there is no meaningful target; `behind` once elapsed exceeds the
|
|
37
|
-
* target by more than the behind threshold; `on-pace` otherwise.
|
|
38
|
-
*/
|
|
39
|
-
export function getTempoPacingState(elapsedMs: number, targetMs: number | null): TempoPacingState {
|
|
40
|
-
if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
|
|
41
|
-
const ratio = elapsedMs / targetMs
|
|
42
|
-
if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
|
|
43
|
-
return 'on-pace'
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Fill percentage (0–100) for the active phase; full when no target is set. */
|
|
47
|
-
export function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
|
|
48
|
-
if (!targetMs) return 100
|
|
49
|
-
return Math.min(100, (elapsedMs / targetMs) * 100)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface PhaseConfig {
|
|
53
|
-
/** Compact label + the stable testID suffix. */
|
|
54
|
-
label: string
|
|
55
|
-
/** Full phase word, spelled out at `wall` density where there's room to read it. */
|
|
56
|
-
wallLabel: string
|
|
57
|
-
color: string
|
|
58
|
-
flex: number
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
|
|
62
|
-
|
|
63
|
-
const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
|
|
64
|
-
concentric: { label: 'Con', wallLabel: 'Concentric', color: t['status-success'], flex: 2 },
|
|
65
|
-
hold: { label: 'Hold', wallLabel: 'Hold', color: t['brand-primary'], flex: 1 },
|
|
66
|
-
eccentric: { label: 'Ecc', wallLabel: 'Eccentric', color: t['status-warning'], flex: 3 },
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Density. `default` — the compact card/rail treatment. `wall` — the across-the-room dashboard scale. */
|
|
70
|
-
export type TempoBarSize = 'default' | 'wall'
|
|
71
|
-
|
|
72
|
-
interface TempoBarSizing {
|
|
73
|
-
labelMargin: number
|
|
74
|
-
labelFont: number
|
|
75
|
-
barGap: number
|
|
76
|
-
barHeight: number
|
|
77
|
-
radius: number
|
|
78
|
-
segFont: number
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Per-density magnitudes. `default` reproduces the original compact values exactly. */
|
|
82
|
-
const TEMPO_SIZES: Record<TempoBarSize, TempoBarSizing> = {
|
|
83
|
-
default: { labelMargin: 4, labelFont: 10, barGap: 2, barHeight: 20, radius: 4, segFont: 12 },
|
|
84
|
-
wall: { labelMargin: 8, labelFont: 15, barGap: 4, barHeight: 40, radius: 6, segFont: 22 },
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface TempoBarProps extends ViewProps {
|
|
88
|
-
/** Phase currently in progress, or null when idle/at rest. */
|
|
89
|
-
activePhase: TempoPhaseKey | null
|
|
90
|
-
/** Elapsed time (ms) within the active phase. */
|
|
91
|
-
phaseElapsedMs: number
|
|
92
|
-
/** Completed phase durations (ms) for the current rep, keyed by phase. */
|
|
93
|
-
completed?: Partial<Record<TempoPhaseKey, number>>
|
|
94
|
-
/** Optional per-phase target durations (seconds) for pacing feedback. */
|
|
95
|
-
target?: Partial<Record<TempoPhaseKey, number>>
|
|
96
|
-
/** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). */
|
|
97
|
-
size?: TempoBarSize
|
|
98
|
-
/**
|
|
99
|
-
* Active-phase readout. `time` (default) — `elapsed / target`. `delta` — a countdown
|
|
100
|
-
* of the remaining time to target (`1.5` → `0.0` → `-1.2` once over), colour-coded like
|
|
101
|
-
* the bar. Tapping the active segment toggles between the two at runtime.
|
|
102
|
-
*/
|
|
103
|
-
activeDisplay?: TempoActiveDisplay
|
|
104
|
-
className?: string
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* TempoBar — live rep phase progression indicator.
|
|
109
|
-
*
|
|
110
|
-
* Renders a segmented bar (Con → Hold → Ecc) where the active phase fills in
|
|
111
|
-
* real time and completed phases show their duration with a ✓/✗ pacing mark.
|
|
112
|
-
* Presentational only: the consumer derives which phase is active, its elapsed
|
|
113
|
-
* time, and completed durations, then feeds them in.
|
|
114
|
-
*/
|
|
115
|
-
export function TempoBar({
|
|
116
|
-
activePhase,
|
|
117
|
-
phaseElapsedMs,
|
|
118
|
-
completed,
|
|
119
|
-
target,
|
|
120
|
-
size = 'default',
|
|
121
|
-
activeDisplay = 'time',
|
|
122
|
-
className,
|
|
123
|
-
...props
|
|
124
|
-
}: TempoBarProps) {
|
|
125
|
-
const s = TEMPO_SIZES[size]
|
|
126
|
-
// Runtime toggle of the active readout, seeded from `activeDisplay`.
|
|
127
|
-
const [display, setDisplay] = useState<TempoActiveDisplay>(activeDisplay)
|
|
128
|
-
const toggleDisplay = () => setDisplay((d) => (d === 'time' ? 'delta' : 'time'))
|
|
129
|
-
return (
|
|
130
|
-
<View className={className} testID="tempo-bar" {...props}>
|
|
131
|
-
{/* Phase labels */}
|
|
132
|
-
<View style={{ flexDirection: 'row', marginBottom: s.labelMargin }}>
|
|
133
|
-
{PHASE_ORDER.map((phase) => {
|
|
134
|
-
const config = PHASE_CONFIG[phase]
|
|
135
|
-
return (
|
|
136
|
-
<View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
|
|
137
|
-
<Text numberOfLines={1} style={{ fontSize: s.labelFont, color: t['text-disabled'] }}>
|
|
138
|
-
{size === 'wall' ? config.wallLabel : config.label}
|
|
139
|
-
</Text>
|
|
140
|
-
</View>
|
|
141
|
-
)
|
|
142
|
-
})}
|
|
143
|
-
</View>
|
|
144
|
-
|
|
145
|
-
{/* Segmented bar */}
|
|
146
|
-
<View style={{ flexDirection: 'row', gap: s.barGap, height: s.barHeight }}>
|
|
147
|
-
{PHASE_ORDER.map((phase) => {
|
|
148
|
-
const config = PHASE_CONFIG[phase]
|
|
149
|
-
const isActive = activePhase === phase
|
|
150
|
-
const completedMs = completed?.[phase]
|
|
151
|
-
const targetSec = target?.[phase]
|
|
152
|
-
const targetMs = targetSec != null ? targetSec * 1000 : null
|
|
153
|
-
|
|
154
|
-
return (
|
|
155
|
-
<View
|
|
156
|
-
key={phase}
|
|
157
|
-
style={{
|
|
158
|
-
flex: config.flex,
|
|
159
|
-
backgroundColor: alpha('#ffffff', 0.06),
|
|
160
|
-
borderRadius: s.radius,
|
|
161
|
-
overflow: 'hidden',
|
|
162
|
-
}}
|
|
163
|
-
>
|
|
164
|
-
{isActive ? (
|
|
165
|
-
<ActiveSegment
|
|
166
|
-
config={config}
|
|
167
|
-
phaseElapsedMs={phaseElapsedMs}
|
|
168
|
-
targetMs={targetMs}
|
|
169
|
-
radius={s.radius}
|
|
170
|
-
font={s.segFont}
|
|
171
|
-
display={display}
|
|
172
|
-
onToggleDisplay={toggleDisplay}
|
|
173
|
-
/>
|
|
174
|
-
) : completedMs != null ? (
|
|
175
|
-
<CompletedSegment
|
|
176
|
-
config={config}
|
|
177
|
-
completedMs={completedMs}
|
|
178
|
-
targetMs={targetMs}
|
|
179
|
-
font={s.segFont}
|
|
180
|
-
/>
|
|
181
|
-
) : null}
|
|
182
|
-
</View>
|
|
183
|
-
)
|
|
184
|
-
})}
|
|
185
|
-
</View>
|
|
186
|
-
</View>
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function ActiveSegment({
|
|
191
|
-
config,
|
|
192
|
-
phaseElapsedMs,
|
|
193
|
-
targetMs,
|
|
194
|
-
radius,
|
|
195
|
-
font,
|
|
196
|
-
display,
|
|
197
|
-
onToggleDisplay,
|
|
198
|
-
}: {
|
|
199
|
-
config: PhaseConfig
|
|
200
|
-
phaseElapsedMs: number
|
|
201
|
-
targetMs: number | null
|
|
202
|
-
radius: number
|
|
203
|
-
font: number
|
|
204
|
-
display: TempoActiveDisplay
|
|
205
|
-
onToggleDisplay: () => void
|
|
206
|
-
}) {
|
|
207
|
-
const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
|
|
208
|
-
const barColor = pacing === 'behind' ? t['status-error'] : config.color
|
|
209
|
-
const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
|
|
210
|
-
|
|
211
|
-
// `delta`: count the remaining time to target down to 0.0, then negative once over.
|
|
212
|
-
// Falls back to elapsed when there's no target. `time`: the elapsed / target readout.
|
|
213
|
-
const label =
|
|
214
|
-
display === 'delta' && targetMs != null
|
|
215
|
-
? formatTempoDelta(targetMs - phaseElapsedMs)
|
|
216
|
-
: targetMs != null
|
|
217
|
-
? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
|
|
218
|
-
: formatDuration(phaseElapsedMs)
|
|
219
|
-
|
|
220
|
-
return (
|
|
221
|
-
<Pressable
|
|
222
|
-
onPress={onToggleDisplay}
|
|
223
|
-
style={{ height: '100%', position: 'relative' }}
|
|
224
|
-
accessibilityRole="button"
|
|
225
|
-
accessibilityLabel={`Tempo readout: ${display === 'delta' ? 'delta' : 'time'} — tap to switch`}
|
|
226
|
-
testID={`tempo-segment-active-${config.label}`}
|
|
227
|
-
>
|
|
228
|
-
<View
|
|
229
|
-
style={{
|
|
230
|
-
position: 'absolute',
|
|
231
|
-
left: 0,
|
|
232
|
-
top: 0,
|
|
233
|
-
bottom: 0,
|
|
234
|
-
width: `${fillPct}%`,
|
|
235
|
-
backgroundColor: alpha(barColor, 0.3),
|
|
236
|
-
borderRadius: radius,
|
|
237
|
-
}}
|
|
238
|
-
/>
|
|
239
|
-
<View
|
|
240
|
-
style={{
|
|
241
|
-
height: '100%',
|
|
242
|
-
flexDirection: 'row',
|
|
243
|
-
alignItems: 'center',
|
|
244
|
-
justifyContent: 'center',
|
|
245
|
-
}}
|
|
246
|
-
>
|
|
247
|
-
<Text style={{ fontSize: font, fontWeight: '700', color: barColor }}>{label}</Text>
|
|
248
|
-
</View>
|
|
249
|
-
</Pressable>
|
|
250
|
-
)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function CompletedSegment({
|
|
254
|
-
config,
|
|
255
|
-
completedMs,
|
|
256
|
-
targetMs,
|
|
257
|
-
font,
|
|
258
|
-
}: {
|
|
259
|
-
config: PhaseConfig
|
|
260
|
-
completedMs: number
|
|
261
|
-
targetMs: number | null
|
|
262
|
-
font: number
|
|
263
|
-
}) {
|
|
264
|
-
const pacing = getTempoPacingState(completedMs, targetMs)
|
|
265
|
-
const hitTarget = pacing !== 'behind'
|
|
266
|
-
const indicator =
|
|
267
|
-
targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
|
|
268
|
-
// A missed target reads by COLOUR first (red fill + red duration), the ✓/✗ second —
|
|
269
|
-
// glanceable across a room; a hit keeps the phase's own colour.
|
|
270
|
-
const cueColor = hitTarget ? config.color : t['status-error']
|
|
271
|
-
|
|
272
|
-
return (
|
|
273
|
-
<View
|
|
274
|
-
style={{
|
|
275
|
-
height: '100%',
|
|
276
|
-
flexDirection: 'row',
|
|
277
|
-
alignItems: 'center',
|
|
278
|
-
justifyContent: 'center',
|
|
279
|
-
backgroundColor: alpha(cueColor, 0.15),
|
|
280
|
-
}}
|
|
281
|
-
testID={`tempo-segment-completed-${config.label}`}
|
|
282
|
-
>
|
|
283
|
-
<Text
|
|
284
|
-
style={{
|
|
285
|
-
fontSize: font,
|
|
286
|
-
fontWeight: '500',
|
|
287
|
-
color: hitTarget ? alpha(config.color, 0.7) : cueColor,
|
|
288
|
-
}}
|
|
289
|
-
>
|
|
290
|
-
{formatDuration(completedMs)}
|
|
291
|
-
{indicator}
|
|
292
|
-
</Text>
|
|
293
|
-
</View>
|
|
294
|
-
)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function formatDuration(ms: number): string {
|
|
298
|
-
const s = ms / 1000
|
|
299
|
-
return s < 10 ? s.toFixed(1) : Math.round(s).toString()
|
|
300
|
-
}
|