@titan-design/react-ui 0.4.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.mjs +1911 -6
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +31 -738
- package/dist/index.d.ts +31 -738
- package/dist/index.js +894 -1692
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10287 -8203
- 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 +12 -12
- package/dist/theme/index.d.ts +12 -12
- package/dist/theme/index.js +30 -30
- 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/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/ExerciseCard.tsx +5 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +17 -4
- package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
- package/src/components/custom/Workout/IntensityBar.tsx +109 -58
- package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
- package/src/components/custom/Workout/PrBadge.tsx +3 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/README.md +50 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
- package/src/components/custom/Workout/RestTimer.tsx +48 -43
- package/src/components/custom/Workout/SetRow.tsx +10 -1
- 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/VelocityStrip.test.tsx +89 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
- package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.tsx +2 -2
- package/src/components/custom/Workout/icons.tsx +68 -0
- package/src/components/custom/Workout/index.ts +38 -47
- package/src/index.ts +3 -2
- package/src/pages.ts +61 -0
- package/src/test/stories-smoke.test.tsx +50 -0
- package/src/theme/barrel.ts +53 -0
- package/src/theme/index.ts +8 -43
- package/src/theme/native-theming-guard.test.ts +117 -0
- package/dist/bodymap-BhG55xHM.d.mts +0 -318
- package/dist/bodymap-BhG55xHM.d.ts +0 -318
- package/dist/chunk-2SISKTWM.mjs +0 -1027
- package/dist/chunk-2SISKTWM.mjs.map +0 -1
- package/dist/chunk-7XPB4NAO.mjs +0 -1013
- package/dist/chunk-7XPB4NAO.mjs.map +0 -1
- package/dist/chunk-SNVKL5WZ.mjs +0 -883
- package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useEffect, useState } from 'react'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
View,
|
|
5
|
+
Text,
|
|
6
|
+
Animated,
|
|
7
|
+
type ViewProps,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
type DimensionValue,
|
|
10
|
+
} from 'react-native'
|
|
4
11
|
|
|
5
12
|
export type IntensityBarOrientation = 'vertical' | 'horizontal'
|
|
6
13
|
|
|
7
14
|
export interface IntensityBarProps extends ViewProps {
|
|
8
|
-
/** Current intensity level
|
|
15
|
+
/** Current intensity level; 1 == 100% of target. Values >1 render as over-target. */
|
|
9
16
|
level: number
|
|
10
|
-
/**
|
|
17
|
+
/** Target position 0-1. When the level sits at this target the fill gains an at-target glow. */
|
|
11
18
|
threshold?: number
|
|
12
19
|
/** Orientation */
|
|
13
20
|
orientation?: IntensityBarOrientation
|
|
@@ -15,28 +22,57 @@ export interface IntensityBarProps extends ViewProps {
|
|
|
15
22
|
size?: number
|
|
16
23
|
/** Width for vertical (default 6px) or height for horizontal */
|
|
17
24
|
thickness?: number
|
|
18
|
-
/** Show
|
|
25
|
+
/** Show the "MRV" threshold label under the bar */
|
|
19
26
|
showThresholdLabel?: boolean
|
|
20
27
|
className?: string
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
const TRACK_BG = '
|
|
24
|
-
const THRESHOLD_COLOR = '#FFFFFF'
|
|
30
|
+
const TRACK_BG = '#333333'
|
|
25
31
|
const LABEL_COLOR = '#6B7280'
|
|
32
|
+
const TARGET_LINE_COLOR = 'rgba(33, 150, 243, 0.5)'
|
|
33
|
+
const AT_TARGET_GLOW = '0 0 5px 1px rgba(33, 150, 243, 0.35), 0 0 10px 3px rgba(33, 150, 243, 0.15)'
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
// Zone colors graded by TRUE percentage (level * 100).
|
|
36
|
+
const ZONE = {
|
|
37
|
+
building: '#14B8A6', // teal — below target ramp-up
|
|
38
|
+
approaching: '#FFB020', // amber — nearing target
|
|
39
|
+
target: '#FF7900', // brand — exactly at target
|
|
40
|
+
over1: '#D14343', // red — mild over-target
|
|
41
|
+
over2: '#A62626', // deep red — moderate over-target
|
|
42
|
+
over3: '#7A1C1C', // darkest red — severe over-target
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
type OverTier = 0 | 1 | 2 | 3
|
|
46
|
+
|
|
47
|
+
interface Zone {
|
|
48
|
+
color: string
|
|
49
|
+
over: OverTier
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BULGE_SIZE: Record<Exclude<OverTier, 0>, number> = { 1: 8, 2: 10, 3: 11 }
|
|
53
|
+
|
|
54
|
+
/** Map a true percentage to its fill color and over-target tier. */
|
|
55
|
+
function zoneForPct(pct: number): Zone {
|
|
56
|
+
if (pct >= 125) return { color: ZONE.over3, over: 3 }
|
|
57
|
+
if (pct >= 115) return { color: ZONE.over2, over: 2 }
|
|
58
|
+
if (pct > 100) return { color: ZONE.over1, over: 1 }
|
|
59
|
+
if (pct === 100) return { color: ZONE.target, over: 0 }
|
|
60
|
+
if (pct >= 75) return { color: ZONE.approaching, over: 0 }
|
|
61
|
+
return { color: ZONE.building, over: 0 }
|
|
32
62
|
}
|
|
33
63
|
|
|
34
|
-
function
|
|
64
|
+
function clampFill(value: number): number {
|
|
35
65
|
if (value < 0) return 0
|
|
36
66
|
if (value > 1) return 1
|
|
37
67
|
return value
|
|
38
68
|
}
|
|
39
69
|
|
|
70
|
+
/** True when the level rests on the supplied target (within 5%). */
|
|
71
|
+
function isAtTarget(pct: number, threshold?: number): boolean {
|
|
72
|
+
if (threshold == null) return false
|
|
73
|
+
return Math.abs(pct - Math.round(threshold * 100)) <= 5
|
|
74
|
+
}
|
|
75
|
+
|
|
40
76
|
export function IntensityBar({
|
|
41
77
|
level,
|
|
42
78
|
threshold,
|
|
@@ -48,21 +84,22 @@ export function IntensityBar({
|
|
|
48
84
|
...props
|
|
49
85
|
}: IntensityBarProps) {
|
|
50
86
|
const isVertical = orientation === 'vertical'
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
87
|
+
const fillLevel = clampFill(level)
|
|
88
|
+
const pct = Math.round(Math.max(0, level) * 100)
|
|
89
|
+
const zone = zoneForPct(pct)
|
|
90
|
+
const atTarget = isAtTarget(pct, threshold)
|
|
54
91
|
|
|
55
|
-
const [fillAnim] = useState(() => new Animated.Value(
|
|
92
|
+
const [fillAnim] = useState(() => new Animated.Value(fillLevel))
|
|
56
93
|
|
|
57
94
|
useEffect(() => {
|
|
58
95
|
const animation = Animated.timing(fillAnim, {
|
|
59
|
-
toValue:
|
|
96
|
+
toValue: fillLevel,
|
|
60
97
|
duration: 250,
|
|
61
98
|
useNativeDriver: false,
|
|
62
99
|
})
|
|
63
100
|
animation.start()
|
|
64
101
|
return () => animation.stop()
|
|
65
|
-
}, [
|
|
102
|
+
}, [fillLevel, fillAnim])
|
|
66
103
|
|
|
67
104
|
const fillSizePercent = fillAnim.interpolate({
|
|
68
105
|
inputRange: [0, 1],
|
|
@@ -74,51 +111,50 @@ export function IntensityBar({
|
|
|
74
111
|
: { width: size, height: thickness }
|
|
75
112
|
|
|
76
113
|
const fillStyle = isVertical
|
|
114
|
+
? { position: 'absolute' as const, bottom: 0, left: 0, right: 0, height: fillSizePercent }
|
|
115
|
+
: { position: 'absolute' as const, top: 0, bottom: 0, left: 0, width: fillSizePercent }
|
|
116
|
+
|
|
117
|
+
const bulgeSize = zone.over === 0 ? 0 : BULGE_SIZE[zone.over]
|
|
118
|
+
const bulgeStyle: ViewStyle = {
|
|
119
|
+
position: 'absolute',
|
|
120
|
+
width: bulgeSize,
|
|
121
|
+
height: bulgeSize,
|
|
122
|
+
borderRadius: bulgeSize / 2,
|
|
123
|
+
backgroundColor: zone.color,
|
|
124
|
+
zIndex: 1,
|
|
125
|
+
...(isVertical
|
|
126
|
+
? { top: 0, left: '50%', transform: [{ translateX: -bulgeSize / 2 }] }
|
|
127
|
+
: { right: 0, top: '50%', transform: [{ translateY: -bulgeSize / 2 }] }),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const targetPct: DimensionValue = `${clampFill(threshold ?? 0) * 100}%`
|
|
131
|
+
const targetLineStyle: ViewStyle = isVertical
|
|
77
132
|
? {
|
|
78
|
-
position: 'absolute'
|
|
79
|
-
bottom:
|
|
80
|
-
left:
|
|
81
|
-
right:
|
|
82
|
-
height:
|
|
133
|
+
position: 'absolute',
|
|
134
|
+
bottom: targetPct,
|
|
135
|
+
left: -3,
|
|
136
|
+
right: -3,
|
|
137
|
+
height: 1.5,
|
|
138
|
+
backgroundColor: TARGET_LINE_COLOR,
|
|
139
|
+
zIndex: 4,
|
|
83
140
|
}
|
|
84
141
|
: {
|
|
85
|
-
position: 'absolute'
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
width:
|
|
142
|
+
position: 'absolute',
|
|
143
|
+
left: targetPct,
|
|
144
|
+
top: -3,
|
|
145
|
+
bottom: -3,
|
|
146
|
+
width: 1.5,
|
|
147
|
+
backgroundColor: TARGET_LINE_COLOR,
|
|
148
|
+
zIndex: 4,
|
|
90
149
|
}
|
|
91
150
|
|
|
92
|
-
const thresholdLineStyle: ViewStyle | undefined =
|
|
93
|
-
threshold != null
|
|
94
|
-
? isVertical
|
|
95
|
-
? {
|
|
96
|
-
position: 'absolute' as const,
|
|
97
|
-
bottom: `${clamp01(threshold) * 100}%`,
|
|
98
|
-
left: -2,
|
|
99
|
-
right: -2,
|
|
100
|
-
height: 1,
|
|
101
|
-
backgroundColor: THRESHOLD_COLOR,
|
|
102
|
-
zIndex: 4,
|
|
103
|
-
}
|
|
104
|
-
: {
|
|
105
|
-
position: 'absolute' as const,
|
|
106
|
-
left: `${clamp01(threshold) * 100}%`,
|
|
107
|
-
top: -2,
|
|
108
|
-
bottom: -2,
|
|
109
|
-
width: 1,
|
|
110
|
-
backgroundColor: THRESHOLD_COLOR,
|
|
111
|
-
zIndex: 4,
|
|
112
|
-
}
|
|
113
|
-
: undefined
|
|
114
|
-
|
|
115
151
|
return (
|
|
116
152
|
<View
|
|
117
153
|
className={className}
|
|
118
154
|
style={{ alignItems: 'center' }}
|
|
119
155
|
accessibilityRole="progressbar"
|
|
120
|
-
accessibilityValue={{ min: 0, max: 100, now:
|
|
121
|
-
accessibilityLabel={`Intensity level: ${
|
|
156
|
+
accessibilityValue={{ min: 0, max: 100, now: pct }}
|
|
157
|
+
accessibilityLabel={`Intensity level: ${pct}%`}
|
|
122
158
|
testID="intensity-bar"
|
|
123
159
|
{...props}
|
|
124
160
|
>
|
|
@@ -136,16 +172,31 @@ export function IntensityBar({
|
|
|
136
172
|
style={{
|
|
137
173
|
...fillStyle,
|
|
138
174
|
borderRadius: 3,
|
|
139
|
-
backgroundColor:
|
|
175
|
+
backgroundColor: zone.color,
|
|
140
176
|
zIndex: 2,
|
|
177
|
+
...(atTarget ? { boxShadow: AT_TARGET_GLOW } : null),
|
|
141
178
|
}}
|
|
142
179
|
testID="intensity-fill"
|
|
143
180
|
/>
|
|
144
181
|
|
|
145
|
-
{
|
|
146
|
-
|
|
147
|
-
|
|
182
|
+
{zone.over !== 0 && <View style={bulgeStyle} testID="intensity-bulge" />}
|
|
183
|
+
|
|
184
|
+
{threshold != null && <View style={targetLineStyle} testID="intensity-target" />}
|
|
148
185
|
</View>
|
|
186
|
+
|
|
187
|
+
<Text
|
|
188
|
+
style={{
|
|
189
|
+
fontSize: 8,
|
|
190
|
+
color: LABEL_COLOR,
|
|
191
|
+
fontFamily: '"Nunito Sans", sans-serif',
|
|
192
|
+
marginTop: 6,
|
|
193
|
+
}}
|
|
194
|
+
accessibilityElementsHidden
|
|
195
|
+
testID="intensity-label"
|
|
196
|
+
>
|
|
197
|
+
{pct}%
|
|
198
|
+
</Text>
|
|
199
|
+
|
|
149
200
|
{showThresholdLabel && threshold != null && (
|
|
150
201
|
<Text
|
|
151
202
|
style={{
|
|
@@ -153,7 +204,7 @@ export function IntensityBar({
|
|
|
153
204
|
fontWeight: '500',
|
|
154
205
|
color: LABEL_COLOR,
|
|
155
206
|
fontFamily: 'Inter, sans-serif',
|
|
156
|
-
marginTop:
|
|
207
|
+
marginTop: 2,
|
|
157
208
|
}}
|
|
158
209
|
accessibilityElementsHidden
|
|
159
210
|
testID="intensity-threshold-label"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, type ViewProps } from 'react-native'
|
|
3
3
|
import { cn } from '../../../utils/cn'
|
|
4
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
4
5
|
|
|
5
6
|
export interface PlaceholderStripProps extends ViewProps {
|
|
6
7
|
width?: number | string
|
|
@@ -18,14 +19,12 @@ function SingleStrip({
|
|
|
18
19
|
}: Omit<PlaceholderStripProps, 'mode' | 'segments'>) {
|
|
19
20
|
return (
|
|
20
21
|
<View
|
|
21
|
-
className={
|
|
22
|
+
className={className}
|
|
22
23
|
style={[
|
|
23
24
|
{
|
|
24
25
|
height: 3,
|
|
25
|
-
backgroundColor:
|
|
26
|
-
|
|
27
|
-
borderStyle: 'dashed',
|
|
28
|
-
borderRadius: 1,
|
|
26
|
+
backgroundColor: WORKOUT_TOKENS.placeholder.fill,
|
|
27
|
+
borderRadius: 2,
|
|
29
28
|
opacity: 0.5,
|
|
30
29
|
},
|
|
31
30
|
width != null ? { width: width as number } : { flex: 1 },
|
|
@@ -59,13 +58,10 @@ function SegmentedStrip({
|
|
|
59
58
|
{Array.from({ length: segments }, (_, i) => (
|
|
60
59
|
<View
|
|
61
60
|
key={i}
|
|
62
|
-
className="border-border-strong"
|
|
63
61
|
style={{
|
|
64
62
|
flex: 1,
|
|
65
63
|
height: 3,
|
|
66
|
-
backgroundColor:
|
|
67
|
-
borderWidth: 1,
|
|
68
|
-
borderStyle: 'dashed',
|
|
64
|
+
backgroundColor: WORKOUT_TOKENS.placeholder.fill,
|
|
69
65
|
borderRadius: 1,
|
|
70
66
|
minWidth: 4,
|
|
71
67
|
}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useEffect, useState } from 'react'
|
|
3
3
|
import { View, Text, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
-
import {
|
|
4
|
+
import { StarIcon } from './icons'
|
|
5
5
|
import { BaseBadge } from './BaseBadge'
|
|
6
6
|
|
|
7
7
|
export type PRType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
|
|
@@ -66,7 +66,7 @@ export function PrBadge({
|
|
|
66
66
|
testID="pr-badge-star"
|
|
67
67
|
{...props}
|
|
68
68
|
>
|
|
69
|
-
<
|
|
69
|
+
<StarIcon size={14} color="#FF7900" fill="#FF7900" strokeWidth={2} />
|
|
70
70
|
</View>
|
|
71
71
|
) : (
|
|
72
72
|
<BaseBadge
|
|
@@ -77,7 +77,7 @@ export function PrBadge({
|
|
|
77
77
|
>
|
|
78
78
|
<Text
|
|
79
79
|
style={{
|
|
80
|
-
fontSize:
|
|
80
|
+
fontSize: 12,
|
|
81
81
|
color: '#FF7900',
|
|
82
82
|
fontWeight: '700',
|
|
83
83
|
fontFamily: 'Inter, sans-serif',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
3
|
-
import {
|
|
3
|
+
import { StarIcon } from './icons'
|
|
4
4
|
import { Drawer, DrawerBody } from '../../ui/drawer'
|
|
5
5
|
import { resolveColor } from '../../../theme/resolve-color'
|
|
6
6
|
|
|
@@ -84,7 +84,7 @@ function PrRecordRow({ record, index }: { record: PrRecord; index: number }) {
|
|
|
84
84
|
}}
|
|
85
85
|
testID={`pr-history-modal-record-${index}`}
|
|
86
86
|
>
|
|
87
|
-
<
|
|
87
|
+
<StarIcon size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
|
|
88
88
|
<Text
|
|
89
89
|
className="text-text-secondary"
|
|
90
90
|
style={{
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Workout component family
|
|
2
|
+
|
|
3
|
+
Training-specific components (badges, bars, timers, cards, charts, and page-level
|
|
4
|
+
screens) built on titan's atoms. They live flat on disk — the tiering below is a
|
|
5
|
+
documentation contract, not a directory layout, so promoting or composing a
|
|
6
|
+
component is pure import churn with no file moves.
|
|
7
|
+
|
|
8
|
+
## Tier map
|
|
9
|
+
|
|
10
|
+
**Atoms** — single-purpose, no cross-component state:
|
|
11
|
+
BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip · TempoBar ·
|
|
12
|
+
DeviationBar · IntensityBar · WorkoutPill · MuscleGroupChip · Sparkline ·
|
|
13
|
+
SupersetWrapper · InputBar
|
|
14
|
+
|
|
15
|
+
**Molecules** — compose atoms, own a little local state:
|
|
16
|
+
VelocityStrip · SetRow · TempoDisplay · RestTimer · MesoProgressBar · WeekRow ·
|
|
17
|
+
WorkoutCard
|
|
18
|
+
|
|
19
|
+
**Organisms** — full features, often with their own data contract:
|
|
20
|
+
ExerciseCard · MesoCard · MesoStatusCard · PrHistoryModal · ReadinessCheck ·
|
|
21
|
+
StrengthTrendChart · CapacityBandChart · BodyMap · BodyMapDetailPanel
|
|
22
|
+
|
|
23
|
+
**Pages** — phone-shaped reference screens (whole-screen compositions):
|
|
24
|
+
ActiveWorkoutPage · ExerciseDetailPage · ProgramPlanningPage · TrainingStatusPage
|
|
25
|
+
|
|
26
|
+
## Subpaths
|
|
27
|
+
|
|
28
|
+
Two families are kept off the root barrel so `@titan-design/react-ui` stays free
|
|
29
|
+
of their heavy runtime dependencies. In both cases the value exports move to the
|
|
30
|
+
subpath while type-only re-exports remain on the root, so consumers can still
|
|
31
|
+
type props without pulling the dependency.
|
|
32
|
+
|
|
33
|
+
- **`@titan-design/react-ui/bodymap`** — isolates
|
|
34
|
+
`react-native-body-highlighter` (a native SVG dep). BodyMap, BodyMapDetailPanel,
|
|
35
|
+
TrainingStatusPage, and the muscle taxonomy live here.
|
|
36
|
+
- **`@titan-design/react-ui/pages`** — isolates the page-level organisms
|
|
37
|
+
(ActiveWorkoutPage, ExerciseDetailPage, ProgramPlanningPage). They are full app
|
|
38
|
+
screens orphaned by both consumers, kept as reference implementations while their
|
|
39
|
+
successors (the responsive level views) are built. TrainingStatusPage is the
|
|
40
|
+
exception — it stays under `/bodymap` because of the body-highlighter dep.
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
- **BaseBadge is an internal composition primitive** — the shared shell that
|
|
45
|
+
WeightBadge and PrBadge build on. It is exempt from orphan accounting; it is not
|
|
46
|
+
meant to be consumed directly even though it is exported for composition.
|
|
47
|
+
- Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
|
|
48
|
+
SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
|
|
49
|
+
to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
|
|
50
|
+
is unchanged.
|
|
@@ -75,16 +75,51 @@ describe('RestTimer', () => {
|
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
describe('
|
|
79
|
-
it('
|
|
78
|
+
describe('display-only mode', () => {
|
|
79
|
+
it('renders the Skip and +30s actions by default', () => {
|
|
80
|
+
render(<RestTimer {...defaultProps} />)
|
|
81
|
+
expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
|
|
82
|
+
expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('hides the Skip and +30s actions when displayOnly', () => {
|
|
86
|
+
render(<RestTimer {...defaultProps} displayOnly />)
|
|
87
|
+
expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
|
|
88
|
+
expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('keeps the countdown, progress bar, and next-set line when displayOnly', () => {
|
|
80
92
|
render(
|
|
81
93
|
<RestTimer
|
|
82
94
|
{...defaultProps}
|
|
95
|
+
elapsedMs={30000}
|
|
96
|
+
displayOnly
|
|
83
97
|
nextSetInfo="Bench Press — Set 3 of 4"
|
|
84
|
-
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:00')
|
|
101
|
+
expect(screen.getByTestId('rest-timer-progress-fill')).toBeInTheDocument()
|
|
102
|
+
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
|
|
103
|
+
'Bench Press — Set 3 of 4'
|
|
85
104
|
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not fire callbacks in display-only mode (controls absent)', () => {
|
|
108
|
+
const onSkip = vi.fn()
|
|
109
|
+
const onAddTime = vi.fn()
|
|
110
|
+
render(<RestTimer {...defaultProps} onSkip={onSkip} onAddTime={onAddTime} displayOnly />)
|
|
111
|
+
expect(screen.queryByLabelText('Skip rest')).not.toBeInTheDocument()
|
|
112
|
+
expect(screen.queryByLabelText('Add 30 seconds')).not.toBeInTheDocument()
|
|
113
|
+
expect(onSkip).not.toHaveBeenCalled()
|
|
114
|
+
expect(onAddTime).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('next set info', () => {
|
|
119
|
+
it('displays nextSetInfo when provided', () => {
|
|
120
|
+
render(<RestTimer {...defaultProps} nextSetInfo="Bench Press — Set 3 of 4" />)
|
|
86
121
|
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
|
|
87
|
-
'Bench Press — Set 3 of 4'
|
|
122
|
+
'Bench Press — Set 3 of 4'
|
|
88
123
|
)
|
|
89
124
|
})
|
|
90
125
|
|
|
@@ -127,9 +162,7 @@ describe('RestTimer', () => {
|
|
|
127
162
|
|
|
128
163
|
it('has timer role with remaining seconds in label', () => {
|
|
129
164
|
render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={30000} />)
|
|
130
|
-
expect(
|
|
131
|
-
screen.getByLabelText('Rest timer, 60 seconds remaining'),
|
|
132
|
-
).toBeInTheDocument()
|
|
165
|
+
expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
|
|
133
166
|
})
|
|
134
167
|
|
|
135
168
|
it('skip button has accessible label', () => {
|
|
@@ -146,9 +179,7 @@ describe('RestTimer', () => {
|
|
|
146
179
|
|
|
147
180
|
describe('RestTimer zero-duration timer (NaN guard)', () => {
|
|
148
181
|
it('emits a valid 100% width (not NaN) when totalSeconds is 0', () => {
|
|
149
|
-
const { container } = render(
|
|
150
|
-
<RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />,
|
|
151
|
-
)
|
|
182
|
+
const { container } = render(<RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />)
|
|
152
183
|
expect(screen.getByTestId('rest-timer-progress-fill')).toHaveStyle({ width: '100%' })
|
|
153
184
|
expect(container.innerHTML).not.toContain('NaN')
|
|
154
185
|
})
|
|
@@ -11,6 +11,8 @@ export interface RestTimerProps {
|
|
|
11
11
|
onAddTime: () => void
|
|
12
12
|
nextSetInfo?: string
|
|
13
13
|
visible: boolean
|
|
14
|
+
/** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
|
|
15
|
+
displayOnly?: boolean
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function RestTimer({
|
|
@@ -20,6 +22,7 @@ export function RestTimer({
|
|
|
20
22
|
onAddTime,
|
|
21
23
|
nextSetInfo,
|
|
22
24
|
visible,
|
|
25
|
+
displayOnly = false,
|
|
23
26
|
}: RestTimerProps) {
|
|
24
27
|
if (!visible) return null
|
|
25
28
|
|
|
@@ -123,55 +126,57 @@ export function RestTimer({
|
|
|
123
126
|
/>
|
|
124
127
|
</View>
|
|
125
128
|
|
|
126
|
-
{/* Actions row */}
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
132
|
-
paddingVertical: 8,
|
|
133
|
-
paddingHorizontal: 20,
|
|
134
|
-
borderRadius: 8,
|
|
135
|
-
}}
|
|
136
|
-
accessibilityRole="button"
|
|
137
|
-
accessibilityLabel="Add 30 seconds"
|
|
138
|
-
testID="rest-timer-add-time"
|
|
139
|
-
>
|
|
140
|
-
<Text
|
|
141
|
-
className="text-text-secondary"
|
|
129
|
+
{/* Actions row — hidden in display-only (poll-only) mode */}
|
|
130
|
+
{!displayOnly && (
|
|
131
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
132
|
+
<Pressable
|
|
133
|
+
onPress={onAddTime}
|
|
142
134
|
style={{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
135
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
136
|
+
paddingVertical: 8,
|
|
137
|
+
paddingHorizontal: 20,
|
|
138
|
+
borderRadius: 8,
|
|
146
139
|
}}
|
|
140
|
+
accessibilityRole="button"
|
|
141
|
+
accessibilityLabel="Add 30 seconds"
|
|
142
|
+
testID="rest-timer-add-time"
|
|
147
143
|
>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
testID="rest-timer-skip"
|
|
162
|
-
>
|
|
163
|
-
<Text
|
|
144
|
+
<Text
|
|
145
|
+
className="text-text-secondary"
|
|
146
|
+
style={{
|
|
147
|
+
fontSize: 11,
|
|
148
|
+
fontFamily: 'Inter, sans-serif',
|
|
149
|
+
fontWeight: '600',
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
+30s
|
|
153
|
+
</Text>
|
|
154
|
+
</Pressable>
|
|
155
|
+
<Pressable
|
|
156
|
+
onPress={onSkip}
|
|
164
157
|
style={{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
158
|
+
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
159
|
+
paddingVertical: 8,
|
|
160
|
+
paddingHorizontal: 20,
|
|
161
|
+
borderRadius: 8,
|
|
169
162
|
}}
|
|
163
|
+
accessibilityRole="button"
|
|
164
|
+
accessibilityLabel="Skip rest"
|
|
165
|
+
testID="rest-timer-skip"
|
|
170
166
|
>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
167
|
+
<Text
|
|
168
|
+
style={{
|
|
169
|
+
fontSize: 11,
|
|
170
|
+
fontFamily: 'Inter, sans-serif',
|
|
171
|
+
fontWeight: '600',
|
|
172
|
+
color: BRAND_PRIMARY,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
Skip
|
|
176
|
+
</Text>
|
|
177
|
+
</Pressable>
|
|
178
|
+
</View>
|
|
179
|
+
)}
|
|
175
180
|
</View>
|
|
176
181
|
)
|
|
177
182
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text } from 'react-native'
|
|
3
|
-
import { VelocityStrip } from './VelocityStrip'
|
|
3
|
+
import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
|
|
4
4
|
import { PrBadge, type PRType } from './PrBadge'
|
|
5
5
|
import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
|
|
6
6
|
|
|
@@ -14,7 +14,12 @@ export interface SetRowProps {
|
|
|
14
14
|
weight: number | null
|
|
15
15
|
rpe?: number | null
|
|
16
16
|
unit: 'lbs' | 'kg'
|
|
17
|
+
/** Per-rep MEAN concentric velocities (m/s). */
|
|
17
18
|
velocities?: number[]
|
|
19
|
+
/** Optional velocity-zone bands (WA `VelocityZones.bands`); default scale when absent. */
|
|
20
|
+
velocityZones?: readonly VelocityZoneBandProp[]
|
|
21
|
+
/** Live mode: index of the newest rep bar to animate (pop / new-peak bounce). */
|
|
22
|
+
velocityLiveRepIndex?: number
|
|
18
23
|
velocityExpanded?: boolean
|
|
19
24
|
onVelocityToggle?: () => void
|
|
20
25
|
setType?: string
|
|
@@ -43,6 +48,8 @@ export function SetRow({
|
|
|
43
48
|
rpe,
|
|
44
49
|
unit,
|
|
45
50
|
velocities,
|
|
51
|
+
velocityZones,
|
|
52
|
+
velocityLiveRepIndex,
|
|
46
53
|
velocityExpanded,
|
|
47
54
|
onVelocityToggle,
|
|
48
55
|
setType,
|
|
@@ -239,6 +246,8 @@ export function SetRow({
|
|
|
239
246
|
>
|
|
240
247
|
<VelocityStrip
|
|
241
248
|
velocities={velocities}
|
|
249
|
+
zones={velocityZones}
|
|
250
|
+
liveRepIndex={velocityLiveRepIndex}
|
|
242
251
|
expanded={velocityExpanded}
|
|
243
252
|
onToggle={onVelocityToggle}
|
|
244
253
|
variant={onVelocityToggle ? 'full' : 'mini'}
|