@titan-design/react-ui 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
- package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
- package/dist/bodymap.d.mts +246 -3
- package/dist/bodymap.d.ts +246 -3
- package/dist/bodymap.js +441 -97
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +1958 -66
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +71 -738
- package/dist/index.d.ts +71 -738
- package/dist/index.js +888 -1583
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4515 -2348
- package/dist/index.mjs.map +1 -1
- package/dist/pages-D7Jlssvp.d.ts +791 -0
- package/dist/pages-MO0JCySL.d.mts +791 -0
- package/dist/pages.d.mts +4 -0
- package/dist/pages.d.ts +4 -0
- package/dist/pages.js +6646 -0
- package/dist/pages.js.map +1 -0
- package/dist/pages.mjs +6626 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/theme/index.d.mts +28 -2
- package/dist/theme/index.d.ts +28 -2
- package/dist/theme/index.js +37 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1880 -2
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.mjs +573 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +9 -7
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
- package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +7 -8
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
- package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
- package/src/components/custom/Workout/ExerciseCard.tsx +17 -28
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +38 -31
- package/src/components/custom/Workout/InputBar.tsx +10 -11
- package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
- package/src/components/custom/Workout/IntensityBar.tsx +109 -58
- package/src/components/custom/Workout/MesoCard.tsx +2 -6
- package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
- package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -10
- package/src/components/custom/Workout/PrBadge.tsx +3 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +14 -16
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
- package/src/components/custom/Workout/README.md +50 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
- package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
- package/src/components/custom/Workout/RestTimer.tsx +55 -49
- package/src/components/custom/Workout/SetRow.tsx +20 -8
- package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
- package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
- package/src/components/custom/Workout/TempoBar.tsx +215 -0
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
- package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
- package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +210 -43
- package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
- package/src/components/custom/Workout/WeekRow.tsx +2 -1
- package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.tsx +2 -2
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
- package/src/components/custom/Workout/icons.tsx +68 -0
- package/src/components/custom/Workout/index.ts +47 -47
- package/src/index.ts +3 -2
- package/src/pages.ts +61 -0
- package/src/test/nativewind-stub.ts +13 -0
- package/src/test/stories-smoke.test.tsx +50 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +50 -0
- package/src/theme/barrel.ts +53 -0
- package/src/theme/index.ts +8 -41
- package/src/theme/native-theming-guard.test.ts +117 -0
- package/src/theme/resolve-color.ts +19 -0
- package/dist/bodymap-BhG55xHM.d.mts +0 -318
- package/dist/bodymap-BhG55xHM.d.ts +0 -318
- package/dist/chunk-7XPB4NAO.mjs +0 -1013
- package/dist/chunk-7XPB4NAO.mjs.map +0 -1
- package/dist/chunk-P7TSQUN6.mjs +0 -846
- package/dist/chunk-P7TSQUN6.mjs.map +0 -1
- package/dist/chunk-TOD2WQBG.mjs +0 -1048
- package/dist/chunk-TOD2WQBG.mjs.map +0 -1
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
2
3
|
import { View } from 'react-native'
|
|
3
|
-
import { TempoDisplay } from './TempoDisplay'
|
|
4
|
+
import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
|
|
4
5
|
|
|
5
6
|
// tempo = [eccentric, pauseBottom, concentric, pauseTop]
|
|
6
7
|
const meta: Meta<typeof TempoDisplay> = {
|
|
@@ -8,10 +9,17 @@ const meta: Meta<typeof TempoDisplay> = {
|
|
|
8
9
|
component: TempoDisplay,
|
|
9
10
|
tags: ['autodocs'],
|
|
10
11
|
argTypes: {
|
|
11
|
-
tempo: {
|
|
12
|
+
tempo: {
|
|
13
|
+
control: 'object',
|
|
14
|
+
description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds',
|
|
15
|
+
},
|
|
12
16
|
size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
|
|
13
17
|
colored: { control: 'boolean', description: 'Colored vs mono display' },
|
|
14
18
|
showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
|
|
19
|
+
live: {
|
|
20
|
+
control: 'object',
|
|
21
|
+
description: 'Live phase-fill state (opt-in); omit for static prescription',
|
|
22
|
+
},
|
|
15
23
|
},
|
|
16
24
|
}
|
|
17
25
|
|
|
@@ -64,3 +72,62 @@ export const AllVariants: Story = {
|
|
|
64
72
|
</View>
|
|
65
73
|
),
|
|
66
74
|
}
|
|
75
|
+
|
|
76
|
+
// --- Live phase-fill (opt-in) ----------------------------------------------
|
|
77
|
+
|
|
78
|
+
// Deterministic mid-rep frames — the active phase digit fills against its target.
|
|
79
|
+
export const LiveEccentricInProgress: Story = {
|
|
80
|
+
args: { tempo: [3, 1, 1, 0], live: { activePhase: 'eccentric', phaseElapsedMs: 1500 } },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Concentric target is 1s; 1.6s elapsed is behind pace, so the digit turns red.
|
|
84
|
+
export const LiveBehindPace: Story = {
|
|
85
|
+
args: { tempo: [3, 1, 1, 0], live: { activePhase: 'concentric', phaseElapsedMs: 1600 } },
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const LIVE_CYCLE: { phase: TempoLivePhase; ms: number }[] = [
|
|
89
|
+
{ phase: 'eccentric', ms: 3000 },
|
|
90
|
+
{ phase: 'pauseBottom', ms: 1000 },
|
|
91
|
+
{ phase: 'concentric', ms: 1000 },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
function LiveTempoDemo() {
|
|
95
|
+
const [live, setLive] = useState<TempoLiveState>({ activePhase: 'eccentric', phaseElapsedMs: 0 })
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
let idx = 0
|
|
98
|
+
let start = Date.now()
|
|
99
|
+
const id = setInterval(() => {
|
|
100
|
+
const step = LIVE_CYCLE[idx]
|
|
101
|
+
const elapsed = Date.now() - start
|
|
102
|
+
if (elapsed >= step.ms + 400) {
|
|
103
|
+
idx = (idx + 1) % LIVE_CYCLE.length
|
|
104
|
+
start = Date.now()
|
|
105
|
+
}
|
|
106
|
+
setLive({
|
|
107
|
+
activePhase: LIVE_CYCLE[idx].phase,
|
|
108
|
+
phaseElapsedMs: Math.min(elapsed, step.ms + 400),
|
|
109
|
+
})
|
|
110
|
+
}, 50)
|
|
111
|
+
return () => clearInterval(id)
|
|
112
|
+
}, [])
|
|
113
|
+
return <TempoDisplay tempo={[3, 1, 1, 0]} live={live} />
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Self-driving demo cycling Ecc → Pause → Con to show the fill animating.
|
|
117
|
+
export const LiveAnimated: Story = {
|
|
118
|
+
render: () => <LiveTempoDemo />,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Static and live side by side — the same prescription, two modes.
|
|
122
|
+
export const StaticVsLive: Story = {
|
|
123
|
+
render: () => (
|
|
124
|
+
<View style={{ gap: 12 }}>
|
|
125
|
+
<TempoDisplay tempo={[3, 1, 1, 0]} />
|
|
126
|
+
<TempoDisplay
|
|
127
|
+
tempo={[3, 1, 1, 0]}
|
|
128
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
|
|
129
|
+
/>
|
|
130
|
+
<LiveTempoDemo />
|
|
131
|
+
</View>
|
|
132
|
+
),
|
|
133
|
+
}
|
|
@@ -80,6 +80,41 @@ describe('TempoDisplay', () => {
|
|
|
80
80
|
})
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
describe('live phase-fill', () => {
|
|
84
|
+
it('is inert by default (no active fill) for backward-compatible static use', () => {
|
|
85
|
+
render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
|
|
86
|
+
expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('renders an active fill overlay for the in-progress phase when live', () => {
|
|
90
|
+
render(
|
|
91
|
+
<TempoDisplay
|
|
92
|
+
tempo={[3, 1, 1, 0]}
|
|
93
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
expect(screen.getByTestId('tempo-live-active')).toBeInTheDocument()
|
|
97
|
+
expect(screen.getByText('3')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('marks no phase active when idle (activePhase null)', () => {
|
|
101
|
+
render(<TempoDisplay tempo={[3, 1, 1, 0]} live={{ activePhase: null, phaseElapsedMs: 0 }} />)
|
|
102
|
+
expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('still renders all four phase digits in live mode', () => {
|
|
106
|
+
render(
|
|
107
|
+
<TempoDisplay
|
|
108
|
+
tempo={[4, 3, 2, 1]}
|
|
109
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 500 }}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
for (const digit of ['4', '3', '2', '1']) {
|
|
113
|
+
expect(screen.getByText(digit)).toBeInTheDocument()
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
83
118
|
describe('accessibility', () => {
|
|
84
119
|
it('has no accessibility violations', async () => {
|
|
85
120
|
const { container } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
|
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
import { useState, useCallback } from 'react'
|
|
3
3
|
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
4
4
|
import { roundTempo } from '../../../utils/workout-format'
|
|
5
|
+
import { alpha } from '../../../utils/colors'
|
|
6
|
+
import { getTempoFillPct, getTempoPacingState } from './TempoBar'
|
|
7
|
+
|
|
8
|
+
/** The four tempo phases, in the order the display renders them. */
|
|
9
|
+
export type TempoLivePhase = 'eccentric' | 'pauseBottom' | 'concentric' | 'pauseTop'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Live rep state driving the phase-fill overlay. Controlled by the consumer
|
|
13
|
+
* (matching titan's `elapsedMs`-in convention) so the same real timer feeds
|
|
14
|
+
* both web dashboard and native app — TempoDisplay owns rendering only.
|
|
15
|
+
*/
|
|
16
|
+
export interface TempoLiveState {
|
|
17
|
+
/** Phase currently in progress, or null when idle/at rest. */
|
|
18
|
+
activePhase: TempoLivePhase | null
|
|
19
|
+
/** Elapsed time (ms) within the active phase. */
|
|
20
|
+
phaseElapsedMs: number
|
|
21
|
+
}
|
|
5
22
|
|
|
6
23
|
export interface TempoDisplayProps extends ViewProps {
|
|
7
24
|
/** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
|
|
@@ -11,12 +28,19 @@ export interface TempoDisplayProps extends ViewProps {
|
|
|
11
28
|
colored?: boolean
|
|
12
29
|
/** Show info tooltip on press */
|
|
13
30
|
showInfo?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* When set, renders a live phase-fill: the active phase digit fills against
|
|
33
|
+
* its prescribed duration and inactive phases dim. Omit for the static
|
|
34
|
+
* prescription string (default, backward compatible).
|
|
35
|
+
*/
|
|
36
|
+
live?: TempoLiveState
|
|
14
37
|
onPress?: () => void
|
|
15
38
|
className?: string
|
|
16
39
|
}
|
|
17
40
|
|
|
18
41
|
const INTER = 'Inter, sans-serif'
|
|
19
42
|
const TEXT_TERTIARY = '#6B7280'
|
|
43
|
+
const STATUS_ERROR = '#D14343' // status-error (red), shown when a phase runs behind pace
|
|
20
44
|
|
|
21
45
|
// Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
|
|
22
46
|
const phaseColors = {
|
|
@@ -67,11 +91,84 @@ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }
|
|
|
67
91
|
)
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
// Phase order + color matching the tempo tuple, for the live phase-fill row.
|
|
95
|
+
const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
|
|
96
|
+
{ key: 'eccentric', color: phaseColors.eccentric },
|
|
97
|
+
{ key: 'pauseBottom', color: phaseColors.pauseBottom },
|
|
98
|
+
{ key: 'concentric', color: phaseColors.concentric },
|
|
99
|
+
{ key: 'pauseTop', color: phaseColors.pauseTop },
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
function LiveTempoCell({
|
|
103
|
+
value,
|
|
104
|
+
color,
|
|
105
|
+
isActive,
|
|
106
|
+
phaseElapsedMs,
|
|
107
|
+
fontSize,
|
|
108
|
+
}: {
|
|
109
|
+
value: number
|
|
110
|
+
color: string
|
|
111
|
+
isActive: boolean
|
|
112
|
+
phaseElapsedMs: number
|
|
113
|
+
fontSize: number
|
|
114
|
+
}) {
|
|
115
|
+
if (!isActive) {
|
|
116
|
+
return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
|
|
117
|
+
}
|
|
118
|
+
const targetMs = value > 0 ? value * 1000 : null
|
|
119
|
+
const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
|
|
120
|
+
const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
|
|
121
|
+
return (
|
|
122
|
+
<View style={{ position: 'relative' }} testID="tempo-live-active">
|
|
123
|
+
<View
|
|
124
|
+
style={{
|
|
125
|
+
position: 'absolute',
|
|
126
|
+
left: 0,
|
|
127
|
+
top: 0,
|
|
128
|
+
bottom: 0,
|
|
129
|
+
width: `${fillPct}%`,
|
|
130
|
+
backgroundColor: alpha(barColor, 0.3),
|
|
131
|
+
borderRadius: 2,
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
<TempoValue value={value} color={barColor} fontSize={fontSize} />
|
|
135
|
+
</View>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function LiveTempoRow({
|
|
140
|
+
values,
|
|
141
|
+
live,
|
|
142
|
+
fontSize,
|
|
143
|
+
}: {
|
|
144
|
+
values: [number, number, number, number]
|
|
145
|
+
live: TempoLiveState
|
|
146
|
+
fontSize: number
|
|
147
|
+
}) {
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
{LIVE_PHASES.map((phase, i) => (
|
|
151
|
+
<View key={phase.key} style={{ flexDirection: 'row' }}>
|
|
152
|
+
{i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
|
|
153
|
+
<LiveTempoCell
|
|
154
|
+
value={values[i]}
|
|
155
|
+
color={phase.color}
|
|
156
|
+
isActive={live.activePhase === phase.key}
|
|
157
|
+
phaseElapsedMs={live.phaseElapsedMs}
|
|
158
|
+
fontSize={fontSize}
|
|
159
|
+
/>
|
|
160
|
+
</View>
|
|
161
|
+
))}
|
|
162
|
+
</>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
70
166
|
export function TempoDisplay({
|
|
71
167
|
tempo,
|
|
72
168
|
size = 'md',
|
|
73
169
|
colored = true,
|
|
74
170
|
showInfo = true,
|
|
171
|
+
live,
|
|
75
172
|
onPress,
|
|
76
173
|
className,
|
|
77
174
|
...props
|
|
@@ -119,7 +216,13 @@ export function TempoDisplay({
|
|
|
119
216
|
TEMPO
|
|
120
217
|
</Text>
|
|
121
218
|
<View style={{ flexDirection: 'row' }} testID="tempo-value">
|
|
122
|
-
{
|
|
219
|
+
{live ? (
|
|
220
|
+
<LiveTempoRow
|
|
221
|
+
values={[eccentric, pauseBottom, concentric, pauseTop]}
|
|
222
|
+
live={live}
|
|
223
|
+
fontSize={fontSize}
|
|
224
|
+
/>
|
|
225
|
+
) : colored ? (
|
|
123
226
|
<>
|
|
124
227
|
<TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
|
|
125
228
|
<TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
|
|
@@ -16,13 +16,6 @@ import {
|
|
|
16
16
|
type VolumeStatus,
|
|
17
17
|
} from './muscleTaxonomy'
|
|
18
18
|
|
|
19
|
-
const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
|
|
20
|
-
const BORDER_DEFAULT = 'var(--color-border-default)'
|
|
21
|
-
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
22
|
-
const TEXT_SECONDARY = 'var(--color-text-secondary)'
|
|
23
|
-
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
24
|
-
const PAGE_BG = 'var(--color-background-base)'
|
|
25
|
-
|
|
26
19
|
/** Volume statuses in display order for the legend and summary cards. */
|
|
27
20
|
const STATUS_ORDER: VolumeStatus[] = ['under', 'maintenance', 'productive', 'over']
|
|
28
21
|
|
|
@@ -121,28 +114,28 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
|
|
|
121
114
|
return (
|
|
122
115
|
<View
|
|
123
116
|
key={key}
|
|
117
|
+
className="bg-surface-elevated border-border"
|
|
124
118
|
style={{
|
|
125
119
|
flexGrow: 1,
|
|
126
120
|
flexBasis: '46%',
|
|
127
121
|
padding: 12,
|
|
128
122
|
borderRadius: 10,
|
|
129
|
-
backgroundColor: SURFACE_ELEVATED,
|
|
130
123
|
borderWidth: 1,
|
|
131
|
-
borderColor: BORDER_DEFAULT,
|
|
132
124
|
}}
|
|
133
125
|
testID={`training-status-page-summary-${key}`}
|
|
134
126
|
>
|
|
135
127
|
<Text
|
|
128
|
+
className="text-text-primary"
|
|
136
129
|
style={{
|
|
137
130
|
fontSize: 22,
|
|
138
131
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
139
132
|
fontWeight: '700',
|
|
140
|
-
color: TEXT_PRIMARY,
|
|
141
133
|
}}
|
|
142
134
|
>
|
|
143
135
|
{value}
|
|
144
136
|
</Text>
|
|
145
137
|
<Text
|
|
138
|
+
className="text-text-tertiary"
|
|
146
139
|
style={{
|
|
147
140
|
marginTop: 2,
|
|
148
141
|
fontSize: 11,
|
|
@@ -150,7 +143,6 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
|
|
|
150
143
|
fontWeight: '600',
|
|
151
144
|
letterSpacing: 0.4,
|
|
152
145
|
textTransform: 'uppercase',
|
|
153
|
-
color: TEXT_TERTIARY,
|
|
154
146
|
}}
|
|
155
147
|
>
|
|
156
148
|
{label}
|
|
@@ -186,10 +178,10 @@ function StatusLegend() {
|
|
|
186
178
|
accessibilityElementsHidden
|
|
187
179
|
/>
|
|
188
180
|
<Text
|
|
181
|
+
className="text-text-secondary"
|
|
189
182
|
style={{
|
|
190
183
|
fontSize: 11,
|
|
191
184
|
fontFamily: 'Inter, sans-serif',
|
|
192
|
-
color: TEXT_SECONDARY,
|
|
193
185
|
textTransform: 'capitalize',
|
|
194
186
|
}}
|
|
195
187
|
>
|
|
@@ -247,8 +239,8 @@ export function TrainingStatusPage({
|
|
|
247
239
|
|
|
248
240
|
return (
|
|
249
241
|
<View
|
|
250
|
-
className={className}
|
|
251
|
-
style={{ position: 'relative', width: 390
|
|
242
|
+
className={['bg-background-base', className].filter(Boolean).join(' ')}
|
|
243
|
+
style={{ position: 'relative', width: 390 }}
|
|
252
244
|
accessibilityRole={'main' as ViewProps['accessibilityRole']}
|
|
253
245
|
aria-label={title}
|
|
254
246
|
testID="training-status-page"
|
|
@@ -257,11 +249,11 @@ export function TrainingStatusPage({
|
|
|
257
249
|
<View style={{ padding: 16, gap: 16 }} testID="training-status-page-content">
|
|
258
250
|
<Text
|
|
259
251
|
accessibilityRole="header"
|
|
252
|
+
className="text-text-primary"
|
|
260
253
|
style={{
|
|
261
254
|
fontSize: 20,
|
|
262
255
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
263
256
|
fontWeight: '700',
|
|
264
|
-
color: TEXT_PRIMARY,
|
|
265
257
|
}}
|
|
266
258
|
testID="training-status-page-title"
|
|
267
259
|
>
|
|
@@ -273,10 +265,9 @@ export function TrainingStatusPage({
|
|
|
273
265
|
<SummaryCards summary={summary} />
|
|
274
266
|
|
|
275
267
|
<View
|
|
268
|
+
className="bg-surface-elevated border-border"
|
|
276
269
|
style={{
|
|
277
|
-
backgroundColor: SURFACE_ELEVATED,
|
|
278
270
|
borderWidth: 1,
|
|
279
|
-
borderColor: BORDER_DEFAULT,
|
|
280
271
|
borderRadius: 12,
|
|
281
272
|
padding: 12,
|
|
282
273
|
gap: 12,
|