@titan-design/react-ui 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +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 +1 -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/lab/north-star/LivePage.tsx +101 -0
- package/src/lab/north-star/LiveView.tsx +409 -0
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
- package/src/lab/north-star/RestView.tsx +140 -0
- package/src/lab/north-star/fixtures.ts +224 -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
|
@@ -4,7 +4,7 @@ import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
|
4
4
|
import { roundTempo } from '../../../utils/workout-format'
|
|
5
5
|
import { alpha } from '../../../utils/colors'
|
|
6
6
|
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
7
|
-
import {
|
|
7
|
+
import { primitiveRamps } from '../../../theme/tokens/primitives'
|
|
8
8
|
import { MetricCell } from './metricText'
|
|
9
9
|
|
|
10
10
|
const t = getSemanticColors('dark')
|
|
@@ -22,14 +22,20 @@ export interface TempoLiveState {
|
|
|
22
22
|
activePhase: TempoLivePhase | null
|
|
23
23
|
/** Elapsed time (ms) within the active phase. */
|
|
24
24
|
phaseElapsedMs: number
|
|
25
|
+
/**
|
|
26
|
+
* Actual completed durations (ms) for phases finished earlier THIS rep. A done cell
|
|
27
|
+
* freezes at its final readout (not the target) until the next rep resets the row.
|
|
28
|
+
* Omit a phase to fall back to its target (assume it completed on pace).
|
|
29
|
+
*/
|
|
30
|
+
completed?: Partial<Record<TempoLivePhase, number>>
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface TempoDisplayProps extends ViewProps {
|
|
28
34
|
/** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
|
|
29
35
|
tempo: [number, number, number, number]
|
|
30
36
|
size?: 'sm' | 'md'
|
|
31
|
-
/**
|
|
32
|
-
|
|
37
|
+
/** Override the digit font size (px). Defaults from `size` (9 sm / 11 md); raise for a wall read-out. */
|
|
38
|
+
fontSize?: number
|
|
33
39
|
/** Show the "TEMPO" caption before the values. Default true. */
|
|
34
40
|
showLabel?: boolean
|
|
35
41
|
/** Show info tooltip on press */
|
|
@@ -40,19 +46,60 @@ export interface TempoDisplayProps extends ViewProps {
|
|
|
40
46
|
* prescription string (default, backward compatible).
|
|
41
47
|
*/
|
|
42
48
|
live?: TempoLiveState
|
|
49
|
+
/** Active-phase readout when `live`: `countdown` remaining to 0.0 (default), or `countup` elapsed. */
|
|
50
|
+
liveReadout?: TempoLiveReadout
|
|
43
51
|
onPress?: () => void
|
|
44
52
|
className?: string
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
const INTER = 'Inter, sans-serif'
|
|
48
56
|
const TEXT_TERTIARY = '#6B7280'
|
|
49
|
-
const STATUS_ERROR = t['status-error'] //
|
|
57
|
+
const STATUS_ERROR = t['status-error'] // slow — over the target time
|
|
58
|
+
const STATUS_SUCCESS = t['status-success'] // on target (within the band of 0.0)
|
|
59
|
+
const STATUS_WARNING = t['status-warning'] // ahead — still time left to the target
|
|
60
|
+
|
|
61
|
+
/** Live active-phase readout: `countdown` remaining to 0.0, or `countup` elapsed to target. */
|
|
62
|
+
export type TempoLiveReadout = 'countdown' | 'countup'
|
|
63
|
+
|
|
64
|
+
/** ± this window (ms) around the target still counts as on target (0.0 ± 0.1s). */
|
|
65
|
+
const ON_TARGET_MS = 100
|
|
66
|
+
|
|
67
|
+
/** Phase-progress fill percent (0–100): how far the active phase has run toward its target. */
|
|
68
|
+
function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
|
|
69
|
+
if (!targetMs) return 100
|
|
70
|
+
return Math.min(100, (elapsedMs / targetMs) * 100)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Semantic pacing tone for the active/completed phase's NUMBER (the fill bar carries phase
|
|
75
|
+
* identity, so the number is free to carry pacing). Keyed on time remaining to target:
|
|
76
|
+
* ahead of target (still counting) → warning; within ±0.1s of 0.0 → success; over → error.
|
|
77
|
+
*/
|
|
78
|
+
function activeNumberTone(elapsedMs: number, targetMs: number | null): string {
|
|
79
|
+
if (targetMs == null) return t['text-primary']
|
|
80
|
+
const remainingMs = targetMs - elapsedMs
|
|
81
|
+
if (remainingMs > ON_TARGET_MS) return STATUS_WARNING
|
|
82
|
+
if (remainingMs >= -ON_TARGET_MS) return STATUS_SUCCESS
|
|
83
|
+
return STATUS_ERROR
|
|
84
|
+
}
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
/** The active number: `countup` elapsed (→ target) or `countdown` remaining (→ 0.0, then −). */
|
|
87
|
+
function liveReadoutText(
|
|
88
|
+
elapsedMs: number,
|
|
89
|
+
targetMs: number,
|
|
90
|
+
readout: TempoLiveReadout,
|
|
91
|
+
): string {
|
|
92
|
+
const seconds = readout === 'countup' ? elapsedMs / 1000 : (targetMs - elapsedMs) / 1000
|
|
93
|
+
return seconds.toFixed(1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Phase IDENTITY colours — deliberately NON-semantic (magenta ecc / cyan con) so the
|
|
97
|
+
// phase hue never collides with the semantic pacing tones (success/error) the active
|
|
98
|
+
// number carries. Pauses stay neutral grey. [eccentric, pauseBottom, concentric, pauseTop]
|
|
52
99
|
const phaseColors = {
|
|
53
|
-
eccentric:
|
|
100
|
+
eccentric: primitiveRamps.magenta[400],
|
|
54
101
|
pauseBottom: TEXT_TERTIARY,
|
|
55
|
-
concentric:
|
|
102
|
+
concentric: primitiveRamps.cyan[300],
|
|
56
103
|
pauseTop: TEXT_TERTIARY,
|
|
57
104
|
dash: TEXT_TERTIARY,
|
|
58
105
|
}
|
|
@@ -89,39 +136,95 @@ const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
|
|
|
89
136
|
{ key: 'pauseTop', color: phaseColors.pauseTop },
|
|
90
137
|
]
|
|
91
138
|
|
|
139
|
+
/** A phase's place in the current rep: already done (locked full), filling now, or still to come. */
|
|
140
|
+
type LiveCellStatus = 'done' | 'active' | 'upcoming'
|
|
141
|
+
|
|
142
|
+
/** A bottom-anchored vertical fill behind the number (the phase-progress bar). */
|
|
143
|
+
function CellFill({ color, pct }: { color: string; pct: number }) {
|
|
144
|
+
return (
|
|
145
|
+
<View
|
|
146
|
+
style={{
|
|
147
|
+
position: 'absolute',
|
|
148
|
+
left: 2,
|
|
149
|
+
right: 2,
|
|
150
|
+
bottom: 0,
|
|
151
|
+
height: `${pct}%`,
|
|
152
|
+
backgroundColor: alpha(color, 0.28),
|
|
153
|
+
borderRadius: 3,
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Monospace stack for the live readout so digit widths never shift. */
|
|
160
|
+
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
|
161
|
+
/** Widest readout the cell must hold: sign · two whole digits · point · tenths, e.g. "-12.3". */
|
|
162
|
+
const READOUT_MAX_CHARS = 5
|
|
163
|
+
|
|
92
164
|
function LiveTempoCell({
|
|
93
165
|
value,
|
|
94
166
|
color,
|
|
95
|
-
|
|
167
|
+
status,
|
|
96
168
|
phaseElapsedMs,
|
|
169
|
+
completedMs,
|
|
97
170
|
fontSize,
|
|
171
|
+
readout,
|
|
98
172
|
}: {
|
|
99
173
|
value: number
|
|
100
174
|
color: string
|
|
101
|
-
|
|
175
|
+
status: LiveCellStatus
|
|
102
176
|
phaseElapsedMs: number
|
|
177
|
+
completedMs: number | undefined
|
|
103
178
|
fontSize: number
|
|
179
|
+
readout: TempoLiveReadout
|
|
104
180
|
}) {
|
|
105
|
-
if (!isActive) {
|
|
106
|
-
return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
|
|
107
|
-
}
|
|
108
181
|
const targetMs = value > 0 ? value * 1000 : null
|
|
109
|
-
|
|
182
|
+
// Fixed, monospace-width cell sized to the widest readout — a phase activating or its
|
|
183
|
+
// number changing never shifts the layout (the fill bar stays put too).
|
|
184
|
+
const cellW = Math.ceil(fontSize * 0.62 * READOUT_MAX_CHARS)
|
|
185
|
+
const wrap = {
|
|
186
|
+
position: 'relative' as const,
|
|
187
|
+
width: cellW,
|
|
188
|
+
minHeight: Math.round(fontSize * 1.25),
|
|
189
|
+
alignItems: 'center' as const,
|
|
190
|
+
justifyContent: 'center' as const,
|
|
191
|
+
}
|
|
192
|
+
const num = (c: string, text: string) => (
|
|
193
|
+
<Text style={{ fontFamily: MONO, fontSize, color: c, fontWeight: '700' }}>{text}</Text>
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// Upcoming / idle: the phase's prescribed time in the FULL phase colour, no fill — so a
|
|
197
|
+
// not-yet-started (or fully idle) row just reads as the base tempo lockup, not a dim mode.
|
|
198
|
+
if (status === 'upcoming') {
|
|
199
|
+
const targetText = targetMs != null ? (targetMs / 1000).toFixed(1) : String(value)
|
|
200
|
+
return <View style={wrap}>{num(color, targetText)}</View>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Done: FROZEN at the phase's final readout (its actual completed time, not the target),
|
|
204
|
+
// fill locked full in the phase colour. The number keeps its SEMANTIC pacing tone (it is
|
|
205
|
+
// showing the final time, not the target) — banked progress until the next rep resets.
|
|
206
|
+
if (status === 'done') {
|
|
207
|
+
const finalMs = completedMs ?? targetMs ?? 0
|
|
208
|
+
const finalText = targetMs != null ? liveReadoutText(finalMs, targetMs, readout) : String(value)
|
|
209
|
+
const finalTone = targetMs != null ? activeNumberTone(finalMs, targetMs) : color
|
|
210
|
+
return (
|
|
211
|
+
<View style={wrap} testID="tempo-live-done">
|
|
212
|
+
<CellFill color={color} pct={100} />
|
|
213
|
+
{num(finalTone, finalText)}
|
|
214
|
+
</View>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Active: the phase-hued fill grows with the phase; the number reads the live time
|
|
219
|
+
// (countdown/countup) to 0.1s, coloured SEMANTICALLY by pacing (neutral/on-target/behind).
|
|
220
|
+
const numberTone = activeNumberTone(phaseElapsedMs, targetMs)
|
|
110
221
|
const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
|
|
222
|
+
const activeText =
|
|
223
|
+
targetMs != null ? liveReadoutText(phaseElapsedMs, targetMs, readout) : String(value)
|
|
111
224
|
return (
|
|
112
|
-
<View style={
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
position: 'absolute',
|
|
116
|
-
left: 0,
|
|
117
|
-
top: 0,
|
|
118
|
-
bottom: 0,
|
|
119
|
-
width: `${fillPct}%`,
|
|
120
|
-
backgroundColor: alpha(barColor, 0.3),
|
|
121
|
-
borderRadius: 2,
|
|
122
|
-
}}
|
|
123
|
-
/>
|
|
124
|
-
<TempoValue value={value} color={barColor} fontSize={fontSize} />
|
|
225
|
+
<View style={wrap} testID="tempo-live-active">
|
|
226
|
+
<CellFill color={color} pct={fillPct} />
|
|
227
|
+
{num(numberTone, activeText)}
|
|
125
228
|
</View>
|
|
126
229
|
)
|
|
127
230
|
}
|
|
@@ -130,25 +233,38 @@ function LiveTempoRow({
|
|
|
130
233
|
values,
|
|
131
234
|
live,
|
|
132
235
|
fontSize,
|
|
236
|
+
readout,
|
|
133
237
|
}: {
|
|
134
238
|
values: [number, number, number, number]
|
|
135
239
|
live: TempoLiveState
|
|
136
240
|
fontSize: number
|
|
241
|
+
readout: TempoLiveReadout
|
|
137
242
|
}) {
|
|
243
|
+
// Phases run in a fixed order within a rep, so the active phase's position tells us which
|
|
244
|
+
// phases are already done (locked). When it wraps back to the first phase, the row resets.
|
|
245
|
+
const activeIndex = live.activePhase
|
|
246
|
+
? LIVE_PHASES.findIndex((p) => p.key === live.activePhase)
|
|
247
|
+
: -1
|
|
138
248
|
return (
|
|
139
249
|
<>
|
|
140
|
-
{LIVE_PHASES.map((phase, i) =>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
color={
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
250
|
+
{LIVE_PHASES.map((phase, i) => {
|
|
251
|
+
const status: LiveCellStatus =
|
|
252
|
+
activeIndex < 0 || i > activeIndex ? 'upcoming' : i < activeIndex ? 'done' : 'active'
|
|
253
|
+
return (
|
|
254
|
+
<View key={phase.key} style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
255
|
+
{i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
|
|
256
|
+
<LiveTempoCell
|
|
257
|
+
value={values[i]}
|
|
258
|
+
color={phase.color}
|
|
259
|
+
status={status}
|
|
260
|
+
phaseElapsedMs={live.phaseElapsedMs}
|
|
261
|
+
completedMs={live.completed?.[phase.key]}
|
|
262
|
+
fontSize={fontSize}
|
|
263
|
+
readout={readout}
|
|
264
|
+
/>
|
|
265
|
+
</View>
|
|
266
|
+
)
|
|
267
|
+
})}
|
|
152
268
|
</>
|
|
153
269
|
)
|
|
154
270
|
}
|
|
@@ -156,10 +272,11 @@ function LiveTempoRow({
|
|
|
156
272
|
export function TempoDisplay({
|
|
157
273
|
tempo,
|
|
158
274
|
size = 'md',
|
|
159
|
-
|
|
275
|
+
fontSize: fontSizeProp,
|
|
160
276
|
showLabel = true,
|
|
161
277
|
showInfo = true,
|
|
162
278
|
live,
|
|
279
|
+
liveReadout = 'countdown',
|
|
163
280
|
onPress,
|
|
164
281
|
className,
|
|
165
282
|
...props
|
|
@@ -168,8 +285,13 @@ export function TempoDisplay({
|
|
|
168
285
|
// Round exact (unrounded) tempo seconds to the 1-dp display granularity.
|
|
169
286
|
const [eccentric, pauseBottom, concentric, pauseTop] = roundTempo(tempo)
|
|
170
287
|
const isSm = size === 'sm'
|
|
171
|
-
const fontSize = isSm ? 9 : 11
|
|
172
|
-
|
|
288
|
+
const fontSize = fontSizeProp ?? (isSm ? 9 : 11)
|
|
289
|
+
// The chrome (padding, radius, label) scales with the digit size so the whole view stays
|
|
290
|
+
// proportional at any form factor — a compact rail chip up to a wall read-out.
|
|
291
|
+
const chromePadX = Math.round(fontSize * 0.6)
|
|
292
|
+
const chromePadY = Math.round(fontSize * 0.3)
|
|
293
|
+
const chromeRadius = Math.round(fontSize * 0.4)
|
|
294
|
+
const labelFont = Math.max(9, Math.round(fontSize * 0.5))
|
|
173
295
|
|
|
174
296
|
const handlePress = useCallback(() => {
|
|
175
297
|
if (onPress) {
|
|
@@ -187,9 +309,9 @@ export function TempoDisplay({
|
|
|
187
309
|
flexDirection: 'row',
|
|
188
310
|
alignItems: 'center',
|
|
189
311
|
backgroundColor: '#1C1C1C',
|
|
190
|
-
paddingHorizontal:
|
|
191
|
-
paddingVertical:
|
|
192
|
-
borderRadius:
|
|
312
|
+
paddingHorizontal: chromePadX,
|
|
313
|
+
paddingVertical: chromePadY,
|
|
314
|
+
borderRadius: chromeRadius,
|
|
193
315
|
}}
|
|
194
316
|
{...props}
|
|
195
317
|
>
|
|
@@ -197,12 +319,13 @@ export function TempoDisplay({
|
|
|
197
319
|
<Text
|
|
198
320
|
style={{
|
|
199
321
|
fontFamily: INTER,
|
|
200
|
-
fontSize:
|
|
322
|
+
fontSize: labelFont,
|
|
201
323
|
fontWeight: '500',
|
|
202
|
-
|
|
324
|
+
// Live-muted green while a rep is running, tertiary at rest.
|
|
325
|
+
color: live ? t['status-live-muted'] : TEXT_TERTIARY,
|
|
203
326
|
letterSpacing: 0.5,
|
|
204
327
|
textTransform: 'uppercase',
|
|
205
|
-
marginRight:
|
|
328
|
+
marginRight: Math.round(fontSize * 0.5),
|
|
206
329
|
}}
|
|
207
330
|
>
|
|
208
331
|
TEMPO
|
|
@@ -214,8 +337,10 @@ export function TempoDisplay({
|
|
|
214
337
|
values={[eccentric, pauseBottom, concentric, pauseTop]}
|
|
215
338
|
live={live}
|
|
216
339
|
fontSize={fontSize}
|
|
340
|
+
readout={liveReadout}
|
|
217
341
|
/>
|
|
218
|
-
) :
|
|
342
|
+
) : (
|
|
343
|
+
// Static prescription: the phase-coloured lockup (the same colours the live row rests at).
|
|
219
344
|
<>
|
|
220
345
|
<TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
|
|
221
346
|
<TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
|
|
@@ -225,18 +350,6 @@ export function TempoDisplay({
|
|
|
225
350
|
<TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
|
|
226
351
|
<TempoValue value={pauseTop} color={phaseColors.pauseTop} fontSize={fontSize} />
|
|
227
352
|
</>
|
|
228
|
-
) : (
|
|
229
|
-
<Text
|
|
230
|
-
style={{
|
|
231
|
-
fontFamily: INTER,
|
|
232
|
-
fontSize,
|
|
233
|
-
color: monoColor,
|
|
234
|
-
fontWeight: '600',
|
|
235
|
-
letterSpacing: 1,
|
|
236
|
-
}}
|
|
237
|
-
>
|
|
238
|
-
{eccentric}-{pauseBottom}-{concentric}-{pauseTop}
|
|
239
|
-
</Text>
|
|
240
353
|
)}
|
|
241
354
|
</View>
|
|
242
355
|
</View>
|
|
@@ -78,10 +78,10 @@ describe('VelocityStrip expanded (framed chart)', () => {
|
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
describe('scale', () => {
|
|
81
|
-
it('peak (default) scales the tallest rep to full height', () => {
|
|
82
|
-
// maxV=1.0 -> denom 1.
|
|
81
|
+
it('peak (default) scales the tallest rep to nearly full height', () => {
|
|
82
|
+
// maxV=1.0 -> denom 1.06 (peak headroom) -> 1.0/1.06 = 94%.
|
|
83
83
|
render(<VelocityStrip velocities={[1.0, 0.5]} showInfo={false} />)
|
|
84
|
-
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '
|
|
84
|
+
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '94%' })
|
|
85
85
|
})
|
|
86
86
|
|
|
87
87
|
it('fixed scales against the 1.15 ceiling regardless of the set max', () => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Easing,
|
|
9
9
|
type ViewProps,
|
|
10
10
|
type ViewStyle,
|
|
11
|
+
type LayoutChangeEvent,
|
|
11
12
|
} from 'react-native'
|
|
12
13
|
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
13
14
|
import { primitiveColors, primitiveRamps, sequentialEffort } from '../../../theme/tokens/primitives'
|
|
@@ -19,7 +20,7 @@ import { SET_STRIP_VARIABLE_COLOR } from './SetStrip'
|
|
|
19
20
|
* (e.g. workout-analytics' `VelocityZones.bands`).
|
|
20
21
|
*
|
|
21
22
|
* Deliberately a plain structural shape — titan never imports the analytics
|
|
22
|
-
* package (same policy
|
|
23
|
+
* package (the same presentational-only policy the Workout family follows). Any object with
|
|
23
24
|
* this shape can be passed, so `zones={waVelocityZones.bands}` works directly.
|
|
24
25
|
* Bands are ordered slow → fast, contiguous, and cover `[0, ∞)` with the top
|
|
25
26
|
* band's `max === null`. Bands carry NO color — color is a UI concern resolved
|
|
@@ -393,7 +394,9 @@ const HERO_LABEL_HEADROOM = 20
|
|
|
393
394
|
/** Gap between hero bars (px) — a group-notch-free even rhythm at wall scale. */
|
|
394
395
|
const HERO_BAR_GAP = 8
|
|
395
396
|
/** Cap on a single hero bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
|
|
396
|
-
const HERO_BAR_MAX_WIDTH =
|
|
397
|
+
const HERO_BAR_MAX_WIDTH = 120
|
|
398
|
+
/** Below this per-bar width the value labels collide, so all but the peak + live rep are dropped. */
|
|
399
|
+
const HERO_LABEL_MIN_BAR_WIDTH = 30
|
|
397
400
|
/** Top-corner radius on hero bars (px). */
|
|
398
401
|
const HERO_BAR_RADIUS = 5
|
|
399
402
|
/** Minimum drawn height of a performed hero bar (px) — a near-zero rep still reads as a rep. */
|
|
@@ -403,9 +406,15 @@ const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
|
|
|
403
406
|
/** The dashed running-best reference line + its label (the lightest charcoal step). */
|
|
404
407
|
const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
|
|
405
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Headroom above the peak bar: just enough to seat its value label without a big empty
|
|
411
|
+
* band at the top (was 1.15 — too airy for the wall hero).
|
|
412
|
+
*/
|
|
413
|
+
const PEAK_HEADROOM = 1.06
|
|
414
|
+
|
|
406
415
|
/** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
|
|
407
416
|
function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
|
|
408
|
-
return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity *
|
|
417
|
+
return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * PEAK_HEADROOM
|
|
409
418
|
}
|
|
410
419
|
|
|
411
420
|
/** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
|
|
@@ -525,6 +534,11 @@ function HeroVelocityChart({
|
|
|
525
534
|
const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
|
|
526
535
|
const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
|
|
527
536
|
|
|
537
|
+
// Measure the plot so per-bar value labels can thin on a narrow chart (they collide once
|
|
538
|
+
// bars get thin). Width 0 (unmeasured) → show all, so test/server renders are unchanged.
|
|
539
|
+
const [plotW, setPlotW] = useState(0)
|
|
540
|
+
const onPlotLayout = (e: LayoutChangeEvent) => setPlotW(e.nativeEvent.layout.width)
|
|
541
|
+
|
|
528
542
|
// Reserve a band above the tallest bar for its value label so a peak bar never clips.
|
|
529
543
|
const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
|
|
530
544
|
const barHeight = (velocity: number): number =>
|
|
@@ -538,6 +552,23 @@ function HeroVelocityChart({
|
|
|
538
552
|
? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
|
|
539
553
|
: 0
|
|
540
554
|
|
|
555
|
+
// When bars get too thin, keep only the peak + live-rep labels so the rest don't overlap.
|
|
556
|
+
const totalSlots = doneVelocities.length + pendingCount
|
|
557
|
+
const barWidth =
|
|
558
|
+
plotW > 0 && totalSlots > 0
|
|
559
|
+
? Math.min(HERO_BAR_MAX_WIDTH, (plotW - HERO_BAR_GAP * (totalSlots - 1)) / totalSlots)
|
|
560
|
+
: HERO_BAR_MAX_WIDTH
|
|
561
|
+
const labelsCrowded = plotW > 0 && barWidth < HERO_LABEL_MIN_BAR_WIDTH
|
|
562
|
+
// Step the inter-bar gap down with the bars so the gaps never dwarf the bars themselves.
|
|
563
|
+
const heroGap =
|
|
564
|
+
plotW === 0 ? HERO_BAR_GAP : barWidth < 16 ? 2 : barWidth < 28 ? 4 : HERO_BAR_GAP
|
|
565
|
+
const peakIndex = doneVelocities.reduce(
|
|
566
|
+
(best, v, i) => (v > doneVelocities[best] ? i : best),
|
|
567
|
+
0,
|
|
568
|
+
)
|
|
569
|
+
const showBarLabel = (i: number): boolean =>
|
|
570
|
+
!labelsCrowded || i === peakIndex || i === liveRepIndex
|
|
571
|
+
|
|
541
572
|
const repCount = doneVelocities.length
|
|
542
573
|
const total = Math.max(repCount, targetReps ?? repCount)
|
|
543
574
|
const label =
|
|
@@ -557,11 +588,12 @@ function HeroVelocityChart({
|
|
|
557
588
|
{...restProps}
|
|
558
589
|
>
|
|
559
590
|
<View
|
|
591
|
+
onLayout={onPlotLayout}
|
|
560
592
|
style={{
|
|
561
593
|
flex: 1,
|
|
562
594
|
flexDirection: 'row',
|
|
563
595
|
alignItems: 'flex-end',
|
|
564
|
-
gap:
|
|
596
|
+
gap: heroGap,
|
|
565
597
|
position: 'relative',
|
|
566
598
|
borderBottomWidth: 2,
|
|
567
599
|
borderBottomColor: HERO_PENDING_COLOR,
|
|
@@ -606,13 +638,15 @@ function HeroVelocityChart({
|
|
|
606
638
|
justifyContent: 'flex-end',
|
|
607
639
|
}}
|
|
608
640
|
>
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
641
|
+
{showBarLabel(i) && (
|
|
642
|
+
<Text
|
|
643
|
+
className="text-text-primary"
|
|
644
|
+
style={{ fontSize: 12, fontWeight: '800', marginBottom: 4 }}
|
|
645
|
+
testID={`velocity-label-${i}`}
|
|
646
|
+
>
|
|
647
|
+
{formatVelocity(velocity)}
|
|
648
|
+
</Text>
|
|
649
|
+
)}
|
|
616
650
|
<Animated.View
|
|
617
651
|
style={[
|
|
618
652
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { View, Text, type ViewProps, type DimensionValue, type LayoutChangeEvent } from 'react-native'
|
|
3
4
|
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
5
|
import { primitiveColors } from '../../../theme/tokens/primitives'
|
|
5
6
|
import { alpha } from '../../../utils/colors'
|
|
@@ -181,6 +182,17 @@ export function ZoneTrack({
|
|
|
181
182
|
const s = ZONE_SIZES[size]
|
|
182
183
|
const trackHeight = trackHeightProp ?? s.trackHeight
|
|
183
184
|
const needleOverhang = needleOverhangProp ?? s.needleOverhang
|
|
185
|
+
|
|
186
|
+
// Thin the tick labels on a narrow track so they never overlap (each occupies a fixed
|
|
187
|
+
// `tickCellWidth` cell). Width 0 (unmeasured) → show all, so test/server renders match.
|
|
188
|
+
const [trackW, setTrackW] = useState(0)
|
|
189
|
+
const onTrackLayout = (e: LayoutChangeEvent) => setTrackW(e.nativeEvent.layout.width)
|
|
190
|
+
const tickCount = ticks?.length ?? 0
|
|
191
|
+
const tickSpacing = trackW > 0 && tickCount > 0 ? trackW / tickCount : Infinity
|
|
192
|
+
const tickStep = tickSpacing < s.tickCellWidth ? Math.ceil(s.tickCellWidth / tickSpacing) : 1
|
|
193
|
+
// Keep the first, last, evenly-stepped, and any emphasized ticks; drop the rest's labels.
|
|
194
|
+
const showTickLabel = (i: number, emphasized: boolean): boolean =>
|
|
195
|
+
tickStep <= 1 || i === 0 || i === tickCount - 1 || i % tickStep === 0 || emphasized
|
|
184
196
|
const isNeedle = marker?.type === 'needle'
|
|
185
197
|
const markerHeight = isNeedle ? trackHeight + needleOverhang * 2 : trackHeight
|
|
186
198
|
const markerFrac = marker != null ? fraction(marker.value, min, max) : 0
|
|
@@ -200,6 +212,7 @@ export function ZoneTrack({
|
|
|
200
212
|
accessibilityValue={marker != null ? { min, max, now: marker.value } : { min, max, now: min }}
|
|
201
213
|
accessibilityLabel={accessibilityLabel ?? `Zone track: ${marker?.value ?? min}`}
|
|
202
214
|
testID="zone-track"
|
|
215
|
+
onLayout={onTrackLayout}
|
|
203
216
|
{...props}
|
|
204
217
|
>
|
|
205
218
|
<View style={{ position: 'relative', height: markerHeight }}>
|
|
@@ -329,6 +342,7 @@ export function ZoneTrack({
|
|
|
329
342
|
{ticks.map((tick, i) => {
|
|
330
343
|
if (tick.label == null) return null
|
|
331
344
|
const emphasized = tick.emphasized === true
|
|
345
|
+
if (!showTickLabel(i, emphasized)) return null
|
|
332
346
|
const labelColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
|
|
333
347
|
const labelNode = (
|
|
334
348
|
<Text
|
|
@@ -13,16 +13,8 @@ export {
|
|
|
13
13
|
type TempoDisplayProps,
|
|
14
14
|
type TempoLivePhase,
|
|
15
15
|
type TempoLiveState,
|
|
16
|
+
type TempoLiveReadout,
|
|
16
17
|
} from './TempoDisplay'
|
|
17
|
-
export {
|
|
18
|
-
TempoBar,
|
|
19
|
-
type TempoBarProps,
|
|
20
|
-
type TempoPhaseKey,
|
|
21
|
-
type TempoPacingState,
|
|
22
|
-
TEMPO_PACING,
|
|
23
|
-
getTempoPacingState,
|
|
24
|
-
getTempoFillPct,
|
|
25
|
-
} from './TempoBar'
|
|
26
18
|
export { DeviationBar, type DeviationBarProps } from './DeviationBar'
|
|
27
19
|
export { IntensityBar, type IntensityBarProps } from './IntensityBar'
|
|
28
20
|
export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { ScrollView, View } from 'react-native'
|
|
3
|
+
import { SessionRail } from '../../components'
|
|
4
|
+
import { ExerciseHeader, LiveView } from './LiveView'
|
|
5
|
+
import { RestView } from './RestView'
|
|
6
|
+
import {
|
|
7
|
+
dashboardFixture,
|
|
8
|
+
deriveDualModel,
|
|
9
|
+
deriveRailExercises,
|
|
10
|
+
type DashboardModel,
|
|
11
|
+
} from './fixtures'
|
|
12
|
+
|
|
13
|
+
export type LivePageVariant = 'live' | 'live-dual' | 'rest'
|
|
14
|
+
|
|
15
|
+
/** Floor the live panel around phone width so it stops collapsing on a narrow window. */
|
|
16
|
+
const PANEL_MIN_WIDTH = 390
|
|
17
|
+
|
|
18
|
+
export interface LivePageProps {
|
|
19
|
+
/** Which stage to show in the main region. */
|
|
20
|
+
variant?: LivePageVariant
|
|
21
|
+
/** The dashboard store snapshot. Defaults to the built-in fixture. */
|
|
22
|
+
model?: DashboardModel
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Lab specimen — the North Star wall-dashboard CONTENT (mounts inside `DashboardShell`'s
|
|
27
|
+
* children slot): the persistent {@link SessionRail} context beside a swappable stage
|
|
28
|
+
* ({@link LiveView} mid-set / {@link RestView} between sets).
|
|
29
|
+
*
|
|
30
|
+
* `live-dual` renders a dual-mode (bilateral) exercise as TWO stacked {@link LiveView}
|
|
31
|
+
* layers — one per voltra (left/right) — the v1 treatment for two-device sets. A
|
|
32
|
+
* unified split-bar view is a later exploration.
|
|
33
|
+
*
|
|
34
|
+
* NOT a published component — lab-scoped composition of production primitives only.
|
|
35
|
+
* The rail footer pace read-out is intentionally OMITTED (NO-DATA — no store field yet).
|
|
36
|
+
*/
|
|
37
|
+
export function LivePage({ variant = 'live', model = dashboardFixture }: LivePageProps) {
|
|
38
|
+
const exercises = deriveRailExercises(model)
|
|
39
|
+
const completedSets = model.session.completedSets.length
|
|
40
|
+
const isLive = variant === 'live' || variant === 'live-dual'
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View className="flex-1 flex-row bg-surface-base">
|
|
44
|
+
<SessionRail
|
|
45
|
+
title={model.session.title}
|
|
46
|
+
exercises={exercises}
|
|
47
|
+
setsDone={isLive ? completedSets + 0.75 : completedSets}
|
|
48
|
+
elapsedMs={18 * 60_000}
|
|
49
|
+
budgetMs={45 * 60_000}
|
|
50
|
+
running={isLive}
|
|
51
|
+
width={272}
|
|
52
|
+
metrics={[
|
|
53
|
+
{ label: 'Volume', value: '76%' },
|
|
54
|
+
{ label: 'Load', value: '7.3k' },
|
|
55
|
+
{ label: 'Fatigue', value: 'MOD' },
|
|
56
|
+
]}
|
|
57
|
+
/>
|
|
58
|
+
{/* footer pace strip — STUB / NO-DATA: intentionally omitted (no store field yet). */}
|
|
59
|
+
|
|
60
|
+
{/* Panel floors at ~phone width so the live view stops collapsing; rail-aware
|
|
61
|
+
breakpoints below this are a later pass. */}
|
|
62
|
+
<View className="flex-1" style={{ minWidth: PANEL_MIN_WIDTH }}>
|
|
63
|
+
{/* workout title + targets — page-level, always visible, independent of single/dual. */}
|
|
64
|
+
<ExerciseHeader session={model.session} />
|
|
65
|
+
<View className="flex-1">
|
|
66
|
+
{variant === 'live-dual' ? (
|
|
67
|
+
<DualLiveStage model={model} />
|
|
68
|
+
) : variant === 'live' ? (
|
|
69
|
+
// `slot` names the active voltra — the shell has two connected, so the live view
|
|
70
|
+
// flags which one it is reading from (the multi-device single-view case).
|
|
71
|
+
<LiveView model={model} slot="L" />
|
|
72
|
+
) : (
|
|
73
|
+
<RestView model={model} />
|
|
74
|
+
)}
|
|
75
|
+
</View>
|
|
76
|
+
</View>
|
|
77
|
+
</View>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Dual-mode stage: two stacked live layers, one per voltra (left dominant, right lags).
|
|
83
|
+
*
|
|
84
|
+
* The stage SCROLLS so two full live read-outs never clip on a height-restricted wall.
|
|
85
|
+
* Each layer also compresses its height-drivers (shorter hero, tighter gaps — see
|
|
86
|
+
* {@link LiveView}'s `side`/`dual` handling) so a tall wall fits both with little or no
|
|
87
|
+
* scroll; a trimmed-per-layer treatment is a later option if scrolling proves awkward.
|
|
88
|
+
*/
|
|
89
|
+
function DualLiveStage({ model }: { model: DashboardModel }) {
|
|
90
|
+
const { left, right } = deriveDualModel(model)
|
|
91
|
+
return (
|
|
92
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ flexGrow: 1 }}>
|
|
93
|
+
<View style={{ flex: 1, minHeight: 340 }}>
|
|
94
|
+
<LiveView model={left} side="left" />
|
|
95
|
+
</View>
|
|
96
|
+
<View style={{ flex: 1, minHeight: 340 }}>
|
|
97
|
+
<LiveView model={right} side="right" />
|
|
98
|
+
</View>
|
|
99
|
+
</ScrollView>
|
|
100
|
+
)
|
|
101
|
+
}
|