@titan-design/react-ui 0.2.7 → 0.4.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/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +3014 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1088 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/chunk-2SISKTWM.mjs +1027 -0
- package/dist/chunk-2SISKTWM.mjs.map +1 -0
- package/dist/{chunk-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/{chunk-3VLOBS6O.mjs → chunk-SNVKL5WZ.mjs} +42 -5
- package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
- package/dist/index.d.mts +416 -250
- package/dist/index.d.ts +416 -250
- package/dist/index.js +1567 -1123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1491 -2201
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +288 -2
- package/dist/theme/index.d.ts +288 -2
- package/dist/theme/index.js +159 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -2
- package/dist/theme/tokens-css.d.mts +5 -3
- package/dist/theme/tokens-css.d.ts +5 -3
- package/dist/theme/tokens-css.js +318 -9
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +1 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +10 -1
- package/src/bodymap.ts +37 -0
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +442 -0
- package/src/components/custom/Workout/BodyMap.tsx +7 -9
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -29
- package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
- package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +24 -26
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +624 -0
- package/src/components/custom/Workout/InputBar.tsx +10 -12
- package/src/components/custom/Workout/MesoCard.tsx +2 -4
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
- 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 +2 -4
- package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +418 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +13 -12
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +16 -19
- package/src/components/custom/Workout/StatusDot.tsx +1 -0
- 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.tsx +3 -1
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +312 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +19 -16
- package/src/components/custom/Workout/WeekRow.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -6
- package/src/components/custom/Workout/index.ts +80 -32
- package/src/test/nativewind-stub.ts +13 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +50 -0
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/resolve-color.ts +19 -0
- package/src/theme/tokens-css.ts +5 -3
- package/src/theme/workout-tokens.ts +12 -14
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-3VLOBS6O.mjs.map +0 -1
- package/dist/chunk-5SSKGS6E.mjs.map +0 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { View, Text } from 'react-native'
|
|
3
3
|
import { VelocityStrip } from './VelocityStrip'
|
|
4
4
|
import { PrBadge, type PRType } from './PrBadge'
|
|
5
|
+
import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
|
|
5
6
|
|
|
6
7
|
export type SetRowMode = 'active' | 'completed' | 'history'
|
|
7
8
|
|
|
@@ -22,13 +23,6 @@ export interface SetRowProps {
|
|
|
22
23
|
targets?: { reps: number; weight: number }
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function getRPEColor(rpe: number): string {
|
|
26
|
-
if (rpe >= 9.5) return '#ff4757'
|
|
27
|
-
if (rpe >= 8.5) return '#ffa502'
|
|
28
|
-
if (rpe >= 7) return '#ffd43b'
|
|
29
|
-
return '#2ed573'
|
|
30
|
-
}
|
|
31
|
-
|
|
32
26
|
function formatAccessibilityLabel(
|
|
33
27
|
setNumber: number,
|
|
34
28
|
reps: number | null,
|
|
@@ -36,7 +30,7 @@ function formatAccessibilityLabel(
|
|
|
36
30
|
unit: string,
|
|
37
31
|
): string {
|
|
38
32
|
const repsText = reps != null ? `${reps} reps` : 'no reps'
|
|
39
|
-
const weightText = weight != null ? `at ${weight} ${unit}` : ''
|
|
33
|
+
const weightText = weight != null ? `at ${roundWeight(weight)} ${unit}` : ''
|
|
40
34
|
return `Set ${setNumber}: ${repsText}${weightText ? ` ${weightText}` : ''}`
|
|
41
35
|
}
|
|
42
36
|
|
|
@@ -112,11 +106,11 @@ export function SetRow({
|
|
|
112
106
|
</Text>
|
|
113
107
|
) : (
|
|
114
108
|
<Text
|
|
109
|
+
className="text-text-secondary"
|
|
115
110
|
style={{
|
|
116
111
|
fontSize: 13,
|
|
117
112
|
fontWeight: '600',
|
|
118
113
|
fontFamily: 'Inter, sans-serif',
|
|
119
|
-
color: '#9CA3AF',
|
|
120
114
|
}}
|
|
121
115
|
>
|
|
122
116
|
{setNumber}
|
|
@@ -129,14 +123,14 @@ export function SetRow({
|
|
|
129
123
|
testID="set-row-previous"
|
|
130
124
|
>
|
|
131
125
|
<Text
|
|
126
|
+
className="text-text-secondary"
|
|
132
127
|
style={{
|
|
133
128
|
fontSize: 12,
|
|
134
129
|
fontFamily: 'Inter, sans-serif',
|
|
135
|
-
color: '#9CA3AF',
|
|
136
130
|
}}
|
|
137
131
|
>
|
|
138
132
|
{previous
|
|
139
|
-
? `${previous.reps} x ${previous.weight}`
|
|
133
|
+
? `${previous.reps} x ${roundWeight(previous.weight)}`
|
|
140
134
|
: '\u2014'}
|
|
141
135
|
</Text>
|
|
142
136
|
</View>
|
|
@@ -147,11 +141,11 @@ export function SetRow({
|
|
|
147
141
|
>
|
|
148
142
|
{isActive && reps == null && targets ? (
|
|
149
143
|
<Text
|
|
144
|
+
className="text-text-tertiary"
|
|
150
145
|
style={{
|
|
151
146
|
fontSize: 13,
|
|
152
147
|
fontStyle: 'italic',
|
|
153
148
|
fontFamily: 'Inter, sans-serif',
|
|
154
|
-
color: '#6B7280',
|
|
155
149
|
}}
|
|
156
150
|
testID="set-row-target-reps"
|
|
157
151
|
>
|
|
@@ -159,11 +153,11 @@ export function SetRow({
|
|
|
159
153
|
</Text>
|
|
160
154
|
) : (
|
|
161
155
|
<Text
|
|
156
|
+
className={reps != null ? 'text-text-primary' : 'text-text-tertiary'}
|
|
162
157
|
style={{
|
|
163
158
|
fontSize: 14,
|
|
164
159
|
fontWeight: '600',
|
|
165
160
|
fontFamily: 'Inter, sans-serif',
|
|
166
|
-
color: reps != null ? '#F3F4F6' : '#6B7280',
|
|
167
161
|
}}
|
|
168
162
|
>
|
|
169
163
|
{reps != null ? reps : '\u2014'}
|
|
@@ -177,26 +171,26 @@ export function SetRow({
|
|
|
177
171
|
>
|
|
178
172
|
{isActive && weight == null && targets ? (
|
|
179
173
|
<Text
|
|
174
|
+
className="text-text-tertiary"
|
|
180
175
|
style={{
|
|
181
176
|
fontSize: 13,
|
|
182
177
|
fontStyle: 'italic',
|
|
183
178
|
fontFamily: 'Inter, sans-serif',
|
|
184
|
-
color: '#6B7280',
|
|
185
179
|
}}
|
|
186
180
|
testID="set-row-target-weight"
|
|
187
181
|
>
|
|
188
|
-
{targets.weight}
|
|
182
|
+
{roundWeight(targets.weight)}
|
|
189
183
|
</Text>
|
|
190
184
|
) : (
|
|
191
185
|
<Text
|
|
186
|
+
className={weight != null ? 'text-text-primary' : 'text-text-tertiary'}
|
|
192
187
|
style={{
|
|
193
188
|
fontSize: 14,
|
|
194
189
|
fontWeight: '600',
|
|
195
190
|
fontFamily: 'Inter, sans-serif',
|
|
196
|
-
color: weight != null ? '#F3F4F6' : '#6B7280',
|
|
197
191
|
}}
|
|
198
192
|
>
|
|
199
|
-
{weight != null ? weight : '\u2014'}
|
|
193
|
+
{weight != null ? roundWeight(weight) : '\u2014'}
|
|
200
194
|
</Text>
|
|
201
195
|
)}
|
|
202
196
|
</View>
|
|
@@ -206,14 +200,17 @@ export function SetRow({
|
|
|
206
200
|
testID="set-row-rpe"
|
|
207
201
|
>
|
|
208
202
|
<Text
|
|
203
|
+
className={rpe != null ? undefined : 'text-text-tertiary'}
|
|
209
204
|
style={{
|
|
210
205
|
fontSize: 13,
|
|
211
206
|
fontWeight: '600',
|
|
212
207
|
fontFamily: 'Inter, sans-serif',
|
|
213
|
-
|
|
208
|
+
// rpeColor returns a concrete hex (native-safe); only the tertiary
|
|
209
|
+
// fallback needs the className token.
|
|
210
|
+
color: rpe != null ? rpeColor(rpe) : undefined,
|
|
214
211
|
}}
|
|
215
212
|
>
|
|
216
|
-
{rpe != null ? rpe : '\u2014'}
|
|
213
|
+
{rpe != null ? roundRpe(rpe) : '\u2014'}
|
|
217
214
|
</Text>
|
|
218
215
|
</View>
|
|
219
216
|
|
|
@@ -12,6 +12,7 @@ export type StatusDotVariant =
|
|
|
12
12
|
|
|
13
13
|
export interface StatusDotProps extends ViewProps {
|
|
14
14
|
variant: StatusDotVariant
|
|
15
|
+
/** Center glyph. Only rendered at size="md"; the 8px "sm" dot is too small to fit one. */
|
|
15
16
|
icon?: 'check' | 'exclamation' | 'dash'
|
|
16
17
|
size?: 'sm' | 'md'
|
|
17
18
|
/** Adds a subtle glow effect in the variant color */
|
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
import { useEffect, useMemo, useState } from 'react'
|
|
3
3
|
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
4
|
import { cn } from '../../../utils/cn'
|
|
5
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
5
6
|
|
|
6
7
|
const BRAND_PRIMARY = '#FF7900'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const BORDER_STRONG = '#2C2C2C'
|
|
8
|
+
// Line/border color for the dashed projection, used where a className cannot
|
|
9
|
+
// apply (passed as a prop into an inline style, and a per-edge borderTopColor).
|
|
10
|
+
const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
|
|
11
11
|
const STATUS_SUCCESS = '#14B8A6'
|
|
12
12
|
const STATUS_ERROR = '#D14343'
|
|
13
13
|
const STATUS_WARNING = '#FFB020'
|
|
14
14
|
const GRID_LINE = 'rgba(255,255,255,0.06)'
|
|
15
|
-
const TOOLTIP_BG = 'rgba(28,28,28,0.95)'
|
|
16
15
|
const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
|
|
17
16
|
const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
|
|
18
17
|
const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
|
|
@@ -312,7 +311,7 @@ export function StrengthTrendChart({
|
|
|
312
311
|
justifyContent: 'center',
|
|
313
312
|
}}
|
|
314
313
|
>
|
|
315
|
-
<Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif'
|
|
314
|
+
<Text className="text-text-tertiary" style={{ fontSize: 12, fontFamily: 'Inter, sans-serif' }}>
|
|
316
315
|
No strength data yet
|
|
317
316
|
</Text>
|
|
318
317
|
</View>
|
|
@@ -358,6 +357,7 @@ export function StrengthTrendChart({
|
|
|
358
357
|
}}
|
|
359
358
|
>
|
|
360
359
|
<Text
|
|
360
|
+
className="text-text-tertiary"
|
|
361
361
|
style={{
|
|
362
362
|
position: 'absolute',
|
|
363
363
|
left: -PLOT_LEFT,
|
|
@@ -366,7 +366,6 @@ export function StrengthTrendChart({
|
|
|
366
366
|
textAlign: 'right',
|
|
367
367
|
fontSize: 9,
|
|
368
368
|
fontFamily: 'Inter, sans-serif',
|
|
369
|
-
color: TEXT_TERTIARY,
|
|
370
369
|
}}
|
|
371
370
|
>
|
|
372
371
|
{line.label}
|
|
@@ -406,7 +405,7 @@ export function StrengthTrendChart({
|
|
|
406
405
|
<View style={{ width, height, position: 'absolute', top: 0, left: 0 }}>
|
|
407
406
|
<ChartLine
|
|
408
407
|
coords={geometry.projection}
|
|
409
|
-
color={
|
|
408
|
+
color={PROJECTION_LINE_COLOR}
|
|
410
409
|
strokeWidth={2}
|
|
411
410
|
dashed
|
|
412
411
|
testID="strength-trend-chart-projection-segment"
|
|
@@ -475,6 +474,7 @@ export function StrengthTrendChart({
|
|
|
475
474
|
<View
|
|
476
475
|
testID="strength-trend-chart-tooltip"
|
|
477
476
|
accessibilityElementsHidden
|
|
477
|
+
className="bg-surface-elevated border-border-strong"
|
|
478
478
|
style={{
|
|
479
479
|
position: 'absolute',
|
|
480
480
|
left: Math.max(
|
|
@@ -483,30 +483,28 @@ export function StrengthTrendChart({
|
|
|
483
483
|
),
|
|
484
484
|
top: Math.max(0, selectedCoord.y - 52),
|
|
485
485
|
maxWidth: TOOLTIP_WIDTH,
|
|
486
|
-
backgroundColor: TOOLTIP_BG,
|
|
487
486
|
borderWidth: 1,
|
|
488
|
-
borderColor: BORDER_STRONG,
|
|
489
487
|
borderRadius: 8,
|
|
490
488
|
paddingVertical: 8,
|
|
491
489
|
paddingHorizontal: 10,
|
|
492
490
|
}}
|
|
493
491
|
>
|
|
494
492
|
<Text
|
|
493
|
+
className="text-text-tertiary"
|
|
495
494
|
style={{
|
|
496
495
|
fontSize: 10,
|
|
497
496
|
fontFamily: 'Inter, sans-serif',
|
|
498
|
-
color: TEXT_TERTIARY,
|
|
499
497
|
}}
|
|
500
498
|
>
|
|
501
499
|
{selectedCoord.point.sessionLabel ?? selectedCoord.point.date}
|
|
502
500
|
</Text>
|
|
503
501
|
<Text
|
|
502
|
+
className="text-text-primary"
|
|
504
503
|
style={{
|
|
505
504
|
marginTop: 2,
|
|
506
505
|
fontSize: 13,
|
|
507
506
|
fontFamily: 'Inter, sans-serif',
|
|
508
507
|
fontWeight: '700',
|
|
509
|
-
color: TEXT_PRIMARY,
|
|
510
508
|
}}
|
|
511
509
|
>
|
|
512
510
|
{`${formatValue(selectedCoord.point.e1rm)} ${unit}`}
|
|
@@ -547,6 +545,7 @@ export function StrengthTrendChart({
|
|
|
547
545
|
<Text
|
|
548
546
|
key={`axis-${i}`}
|
|
549
547
|
testID="strength-trend-chart-axis-label"
|
|
548
|
+
className="text-text-tertiary"
|
|
550
549
|
style={{
|
|
551
550
|
position: 'absolute',
|
|
552
551
|
left: boundary.x - 24,
|
|
@@ -554,7 +553,6 @@ export function StrengthTrendChart({
|
|
|
554
553
|
textAlign: 'center',
|
|
555
554
|
fontSize: 10,
|
|
556
555
|
fontFamily: 'Inter, sans-serif',
|
|
557
|
-
color: TEXT_TERTIARY,
|
|
558
556
|
}}
|
|
559
557
|
>
|
|
560
558
|
{boundary.label}
|
|
@@ -598,7 +596,7 @@ export function StrengthTrendChart({
|
|
|
598
596
|
>
|
|
599
597
|
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
600
598
|
<View style={{ width: 14, height: 2.5, borderRadius: 1.5, backgroundColor: BRAND_PRIMARY }} />
|
|
601
|
-
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif'
|
|
599
|
+
<Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
|
|
602
600
|
Actual
|
|
603
601
|
</Text>
|
|
604
602
|
</View>
|
|
@@ -609,16 +607,16 @@ export function StrengthTrendChart({
|
|
|
609
607
|
height: 0,
|
|
610
608
|
borderTopWidth: 2,
|
|
611
609
|
borderStyle: 'dashed',
|
|
612
|
-
borderTopColor:
|
|
610
|
+
borderTopColor: PROJECTION_LINE_COLOR,
|
|
613
611
|
}}
|
|
614
612
|
/>
|
|
615
|
-
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif'
|
|
613
|
+
<Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
|
|
616
614
|
Projected
|
|
617
615
|
</Text>
|
|
618
616
|
</View>
|
|
619
617
|
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
620
618
|
<Text style={{ fontSize: 12, color: STATUS_WARNING }}>★</Text>
|
|
621
|
-
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif'
|
|
619
|
+
<Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
|
|
622
620
|
PR
|
|
623
621
|
</Text>
|
|
624
622
|
</View>
|
|
@@ -0,0 +1,95 @@
|
|
|
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: 'Custom/Workout/TempoBar',
|
|
7
|
+
component: TempoBar,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
activePhase: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: [null, 'concentric', 'hold', 'eccentric'],
|
|
13
|
+
description: 'Phase currently in progress (null = idle)',
|
|
14
|
+
},
|
|
15
|
+
phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
|
|
16
|
+
completed: { control: 'object', description: 'Completed phase durations (ms)' },
|
|
17
|
+
target: { control: 'object', description: 'Per-phase target durations (seconds)' },
|
|
18
|
+
},
|
|
19
|
+
decorators: [
|
|
20
|
+
(Story) => (
|
|
21
|
+
<View style={{ width: 240 }}>
|
|
22
|
+
<Story />
|
|
23
|
+
</View>
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default meta
|
|
29
|
+
type Story = StoryObj<typeof TempoBar>
|
|
30
|
+
|
|
31
|
+
// tempo target: 1s concentric, 1s hold-top, 3s eccentric
|
|
32
|
+
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
33
|
+
|
|
34
|
+
export const Idle: Story = {
|
|
35
|
+
args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const ConcentricActive: Story = {
|
|
39
|
+
args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const EccentricMidRep: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
activePhase: 'eccentric',
|
|
45
|
+
phaseElapsedMs: 1500,
|
|
46
|
+
completed: { concentric: 900, hold: 1000 },
|
|
47
|
+
target: TARGET,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const BehindPace: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
activePhase: 'eccentric',
|
|
54
|
+
phaseElapsedMs: 4200,
|
|
55
|
+
completed: { concentric: 1600, hold: 1000 },
|
|
56
|
+
target: TARGET,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const RepComplete: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
activePhase: null,
|
|
63
|
+
phaseElapsedMs: 0,
|
|
64
|
+
completed: { concentric: 950, hold: 1050, eccentric: 3100 },
|
|
65
|
+
target: TARGET,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const NoTarget: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
activePhase: 'concentric',
|
|
72
|
+
phaseElapsedMs: 800,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const AllStates: Story = {
|
|
77
|
+
render: () => (
|
|
78
|
+
<View style={{ gap: 20 }}>
|
|
79
|
+
<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
|
|
80
|
+
<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
|
|
81
|
+
<TempoBar
|
|
82
|
+
activePhase="eccentric"
|
|
83
|
+
phaseElapsedMs={1500}
|
|
84
|
+
completed={{ concentric: 900, hold: 1000 }}
|
|
85
|
+
target={TARGET}
|
|
86
|
+
/>
|
|
87
|
+
<TempoBar
|
|
88
|
+
activePhase={null}
|
|
89
|
+
phaseElapsedMs={0}
|
|
90
|
+
completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
|
|
91
|
+
target={TARGET}
|
|
92
|
+
/>
|
|
93
|
+
</View>
|
|
94
|
+
),
|
|
95
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import {
|
|
5
|
+
TempoBar,
|
|
6
|
+
getTempoPacingState,
|
|
7
|
+
getTempoFillPct,
|
|
8
|
+
TEMPO_PACING,
|
|
9
|
+
} from './TempoBar'
|
|
10
|
+
|
|
11
|
+
const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
|
|
12
|
+
|
|
13
|
+
describe('getTempoPacingState', () => {
|
|
14
|
+
it('returns none when there is no target', () => {
|
|
15
|
+
expect(getTempoPacingState(1000, null)).toBe('none')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns none for a sub-threshold target', () => {
|
|
19
|
+
expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns on-pace when within the behind threshold', () => {
|
|
23
|
+
expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
|
|
24
|
+
expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns behind once elapsed exceeds the behind threshold', () => {
|
|
28
|
+
expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('getTempoFillPct', () => {
|
|
33
|
+
it('fills fully when there is no target', () => {
|
|
34
|
+
expect(getTempoFillPct(500, null)).toBe(100)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('is proportional to elapsed vs target', () => {
|
|
38
|
+
expect(getTempoFillPct(500, 1000)).toBe(50)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('clamps at 100 when past target', () => {
|
|
42
|
+
expect(getTempoFillPct(2000, 1000)).toBe(100)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('TempoBar', () => {
|
|
47
|
+
it('renders the bar with phase labels', () => {
|
|
48
|
+
render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
|
|
49
|
+
expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
|
|
50
|
+
expect(screen.getByText('Con')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('Hold')).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByText('Ecc')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders the active segment with an elapsed/target label', () => {
|
|
56
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
|
|
57
|
+
expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
|
|
58
|
+
expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('shows only elapsed when the active phase has no target', () => {
|
|
62
|
+
render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
|
|
63
|
+
expect(screen.getByText('0.8')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('marks a completed phase that hit its target with a check', () => {
|
|
67
|
+
render(
|
|
68
|
+
<TempoBar
|
|
69
|
+
activePhase="hold"
|
|
70
|
+
phaseElapsedMs={100}
|
|
71
|
+
completed={{ concentric: 950 }}
|
|
72
|
+
target={TARGET}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
|
|
76
|
+
expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('marks a completed phase that missed its target with a cross', () => {
|
|
80
|
+
render(
|
|
81
|
+
<TempoBar
|
|
82
|
+
activePhase="hold"
|
|
83
|
+
phaseElapsedMs={100}
|
|
84
|
+
completed={{ eccentric: 4000 }}
|
|
85
|
+
target={TARGET}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
// eccentric target 3s, 4s elapsed => behind => cross
|
|
89
|
+
expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('has no accessibility violations', async () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<TempoBar
|
|
95
|
+
activePhase="eccentric"
|
|
96
|
+
phaseElapsedMs={1500}
|
|
97
|
+
completed={{ concentric: 900, hold: 1000 }}
|
|
98
|
+
target={TARGET}
|
|
99
|
+
/>,
|
|
100
|
+
)
|
|
101
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -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
|
+
}
|