@titan-design/react-ui 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/test/no-device-internals.test.ts +51 -0
- package/src/lab/north-star/LivePage.tsx +0 -101
- package/src/lab/north-star/LiveView.tsx +0 -409
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +0 -129
- package/src/lab/north-star/RestView.tsx +0 -140
- package/src/lab/north-star/fixtures.ts +0 -224
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@titan-design/react-ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Cross-platform design system built on Gluestack UI",
|
|
5
5
|
"author": "Henry Jewkes",
|
|
6
6
|
"license": "MIT",
|
|
@@ -147,6 +147,7 @@
|
|
|
147
147
|
"files": [
|
|
148
148
|
"dist",
|
|
149
149
|
"src",
|
|
150
|
+
"!src/lab",
|
|
150
151
|
"docs",
|
|
151
152
|
"tailwind.config.js"
|
|
152
153
|
],
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* eslint-disable titan/no-device-internals --
|
|
2
|
+
* This file is the rule's own test bench, so it necessarily contains the shapes
|
|
3
|
+
* the rule rejects. Every value below is invented for the test — none is a real
|
|
4
|
+
* device value.
|
|
5
|
+
*/
|
|
6
|
+
import { RuleTester } from 'eslint'
|
|
7
|
+
import rule from '../../eslint-rules/no-device-internals'
|
|
8
|
+
|
|
9
|
+
const ruleTester = new RuleTester({
|
|
10
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('no-device-internals', () => {
|
|
14
|
+
it('flags device internals without flagging design-system content', () => {
|
|
15
|
+
ruleTester.run('no-device-internals', rule as never, {
|
|
16
|
+
valid: [
|
|
17
|
+
// Hex colours are the dominant hex form in this package.
|
|
18
|
+
{ code: "const bg = '#1C1C1C'" },
|
|
19
|
+
{ code: "const bg = '#fff'" },
|
|
20
|
+
{ code: 'const bg = `${base} #1C1C1CFF`' },
|
|
21
|
+
// Dimension and ratio strings: the `\b` guard before `0x` means a digit
|
|
22
|
+
// followed by `x` never reads as a hex literal. These exact strings
|
|
23
|
+
// tripped an earlier, looser sweep.
|
|
24
|
+
{ code: "// icons render at 20x20" },
|
|
25
|
+
{ code: "// 200x400 intrinsic -> ~160x320 px" },
|
|
26
|
+
// SVG path coordinates — digit-only runs are not byte frames.
|
|
27
|
+
{ code: 'const d = "M12 2 22 12 12 22 2 12Z"' },
|
|
28
|
+
{ code: 'const d = "M7 7 17 17 12 22 12 2 17 7 7 17"' },
|
|
29
|
+
// Timestamps — `-` and `:` are not byte separators.
|
|
30
|
+
{ code: "// datetime // 2024-01-15 14:30" },
|
|
31
|
+
// "cascade" in its CSS sense is ordinary styling vocabulary.
|
|
32
|
+
{ code: "// RN doesn't cascade text color, so hand-color the text" },
|
|
33
|
+
// Plain numbers and durations.
|
|
34
|
+
{ code: 'const duration = 300' },
|
|
35
|
+
],
|
|
36
|
+
invalid: [
|
|
37
|
+
// Bare hex identifier, in code and in a comment.
|
|
38
|
+
{ code: 'const mode = 0xab', errors: [{ messageId: 'hex' }] },
|
|
39
|
+
{ code: '// resistance mode (cmd=0xab)', errors: [{ messageId: 'hex' }] },
|
|
40
|
+
{ code: "const s = 'send cmd=0xab now'", errors: [{ messageId: 'hex' }] },
|
|
41
|
+
// Raw byte frame: 4+ space-separated pairs including hex letters.
|
|
42
|
+
{ code: '// frame: ab cd ef 01', errors: [{ messageId: 'frame' }] },
|
|
43
|
+
// Transport UUID.
|
|
44
|
+
{
|
|
45
|
+
code: "const id = '0000fe59-1234-5678-9abc-def012345678'",
|
|
46
|
+
errors: [{ messageId: 'uuid' }],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import { useState, type ReactElement } from 'react'
|
|
3
|
-
import { View, Text, type LayoutChangeEvent } from 'react-native'
|
|
4
|
-
import {
|
|
5
|
-
LiveAuraFrame,
|
|
6
|
-
VelocityStrip,
|
|
7
|
-
TempoDisplay,
|
|
8
|
-
SetsRepsLoad,
|
|
9
|
-
ActivityIcon,
|
|
10
|
-
AlertTriangleIcon,
|
|
11
|
-
CircleSlashIcon,
|
|
12
|
-
type IconProps,
|
|
13
|
-
} from '../../components'
|
|
14
|
-
import { Tooltip } from '../../components/ui/tooltip/Tooltip'
|
|
15
|
-
import { getSemanticColors } from '../../theme/tokens/semantic'
|
|
16
|
-
import { alpha } from '../../utils/colors'
|
|
17
|
-
import { neumorphicShadows } from '../../theme/shadows'
|
|
18
|
-
import { type DashboardModel, verdictFromLoss } from './fixtures'
|
|
19
|
-
|
|
20
|
-
const t = getSemanticColors('dark')
|
|
21
|
-
|
|
22
|
-
/** Raised-card elevation shared by the alert + tempo cards. */
|
|
23
|
-
const CARD_SHADOW = neumorphicShadows.charcoal.raised.medium
|
|
24
|
-
/** One row height for the tempo + alert cards, so they line up regardless of tempo font size. */
|
|
25
|
-
const CONTROL_HEIGHT = 34
|
|
26
|
-
/** The tempo card ground — mirrors TempoDisplay's own charcoal so a shorter inner pill reads seamless. */
|
|
27
|
-
const TEMPO_GROUND = '#1C1C1C'
|
|
28
|
-
|
|
29
|
-
/** Clamped linear interpolation of `v` between `vLo..vHi` as `w` runs `wLo..wHi`. */
|
|
30
|
-
function clampLerp(w: number, wLo: number, wHi: number, vLo: number, vHi: number): number {
|
|
31
|
-
if (w <= wLo) return vLo
|
|
32
|
-
if (w >= wHi) return vHi
|
|
33
|
-
return vLo + ((w - wLo) / (wHi - wLo)) * (vHi - vLo)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// --- Voltra slot --------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/** Which Voltra a live view is reading from — for dual mode and multi-device sessions. */
|
|
39
|
-
export type VoltraSlot = 'L' | 'R'
|
|
40
|
-
|
|
41
|
-
const SLOT_META: Record<VoltraSlot, { label: string }> = {
|
|
42
|
-
L: { label: 'LEFT VOLTRA' },
|
|
43
|
-
R: { label: 'RIGHT VOLTRA' },
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The voltra name set vertically down the far-left edge of a layer (dual / multi-device). */
|
|
47
|
-
function VerticalSlotLabel({ slot }: { slot: VoltraSlot }) {
|
|
48
|
-
const { label } = SLOT_META[slot]
|
|
49
|
-
return (
|
|
50
|
-
<View
|
|
51
|
-
className="border-border"
|
|
52
|
-
style={{ width: 34, alignItems: 'center', justifyContent: 'center', borderRightWidth: 1 }}
|
|
53
|
-
>
|
|
54
|
-
{/* Fixed width holds the full label before rotation (a bare rotate clips to the strip). */}
|
|
55
|
-
<Text
|
|
56
|
-
className="text-text-tertiary"
|
|
57
|
-
style={{
|
|
58
|
-
width: 150,
|
|
59
|
-
textAlign: 'center',
|
|
60
|
-
fontSize: 12,
|
|
61
|
-
fontWeight: '700',
|
|
62
|
-
letterSpacing: 3,
|
|
63
|
-
transform: [{ rotate: '-90deg' }],
|
|
64
|
-
}}
|
|
65
|
-
>
|
|
66
|
-
{label}
|
|
67
|
-
</Text>
|
|
68
|
-
</View>
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// --- Alert cue ----------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
type Verdict = 'productive' | 'threshold' | 'stop'
|
|
75
|
-
const STATUS_COLOR: Record<Verdict, string> = {
|
|
76
|
-
productive: t['status-success'],
|
|
77
|
-
threshold: t['status-warning'],
|
|
78
|
-
stop: t['status-error'],
|
|
79
|
-
}
|
|
80
|
-
const VERDICT_LABEL: Record<Verdict, string> = {
|
|
81
|
-
productive: 'Productive',
|
|
82
|
-
threshold: 'Threshold',
|
|
83
|
-
stop: 'Stop',
|
|
84
|
-
}
|
|
85
|
-
// A CONTEXTUAL glyph keyed on proximity to the velocity-loss threshold (replaces the flat
|
|
86
|
-
// colour dot): a healthy pulse well under, a warning triangle at the threshold band, a
|
|
87
|
-
// slashed circle once past it.
|
|
88
|
-
const STATUS_ICON: Record<Verdict, (props: IconProps) => ReactElement> = {
|
|
89
|
-
productive: ActivityIcon,
|
|
90
|
-
threshold: AlertTriangleIcon,
|
|
91
|
-
stop: CircleSlashIcon,
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** How much of the alert survives at the current width. */
|
|
95
|
-
export type AlertMode = 'full' | 'compact' | 'icon'
|
|
96
|
-
|
|
97
|
-
/** The tinted alert surface (border + wash + raised shadow) shared by the card and the icon pill. */
|
|
98
|
-
function alertSurface(tone: string) {
|
|
99
|
-
return {
|
|
100
|
-
borderWidth: 1,
|
|
101
|
-
borderColor: alpha(tone, 0.45),
|
|
102
|
-
backgroundColor: alpha(tone, 0.14),
|
|
103
|
-
...CARD_SHADOW,
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The single status element — a tinted alert card carrying the exertion message. It sheds
|
|
109
|
-
* detail as space tightens: `full` shows the contextual icon + verdict + inline message
|
|
110
|
-
* (capped at `availWidth` so it ellipsises + keeps a hover tip rather than running off-page);
|
|
111
|
-
* `compact` drops the message to the tip; `icon` collapses to just the contextual glyph,
|
|
112
|
-
* verdict + message on hover.
|
|
113
|
-
*/
|
|
114
|
-
function AlertCue({
|
|
115
|
-
status,
|
|
116
|
-
message,
|
|
117
|
-
mode,
|
|
118
|
-
availWidth,
|
|
119
|
-
}: {
|
|
120
|
-
status: Verdict
|
|
121
|
-
message: string
|
|
122
|
-
mode: AlertMode
|
|
123
|
-
/** Pixels the alert may occupy (row width − tempo − gap); caps the card so the message clips. */
|
|
124
|
-
availWidth?: number
|
|
125
|
-
}) {
|
|
126
|
-
const tone = STATUS_COLOR[status]
|
|
127
|
-
const Icon = STATUS_ICON[status]
|
|
128
|
-
const meaningful = status === 'threshold' || status === 'stop'
|
|
129
|
-
|
|
130
|
-
// Tightest: icon-only pill. Verdict + message live in the hover tip.
|
|
131
|
-
if (mode === 'icon') {
|
|
132
|
-
const pill = (
|
|
133
|
-
<View
|
|
134
|
-
style={{
|
|
135
|
-
width: CONTROL_HEIGHT,
|
|
136
|
-
height: CONTROL_HEIGHT,
|
|
137
|
-
borderRadius: 9,
|
|
138
|
-
alignItems: 'center',
|
|
139
|
-
justifyContent: 'center',
|
|
140
|
-
...alertSurface(tone),
|
|
141
|
-
}}
|
|
142
|
-
>
|
|
143
|
-
<Icon size={17} color={tone} />
|
|
144
|
-
</View>
|
|
145
|
-
)
|
|
146
|
-
return meaningful ? (
|
|
147
|
-
<Tooltip label={`${VERDICT_LABEL[status]} · ${message}`} placement="bottom">
|
|
148
|
-
{pill}
|
|
149
|
-
</Tooltip>
|
|
150
|
-
) : (
|
|
151
|
-
pill
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const card = (
|
|
156
|
-
<View
|
|
157
|
-
className="flex-row items-center"
|
|
158
|
-
style={{
|
|
159
|
-
gap: 8,
|
|
160
|
-
height: CONTROL_HEIGHT,
|
|
161
|
-
// Concrete px cap (not %) so the single-line message ellipsises through the wrapper chain.
|
|
162
|
-
maxWidth: availWidth,
|
|
163
|
-
borderRadius: 10,
|
|
164
|
-
paddingHorizontal: 12,
|
|
165
|
-
...alertSurface(tone),
|
|
166
|
-
}}
|
|
167
|
-
>
|
|
168
|
-
<Icon size={15} color={tone} />
|
|
169
|
-
<Text style={{ color: tone, fontSize: 13, fontWeight: '700', flexShrink: 0 }}>
|
|
170
|
-
{VERDICT_LABEL[status]}
|
|
171
|
-
</Text>
|
|
172
|
-
{mode === 'full' && meaningful && (
|
|
173
|
-
// Bounded + single-line: ellipsises instead of pushing off the page (full text on hover).
|
|
174
|
-
<Text
|
|
175
|
-
numberOfLines={1}
|
|
176
|
-
style={{ color: tone, fontSize: 13, fontWeight: '600', flexShrink: 1, minWidth: 0 }}
|
|
177
|
-
>
|
|
178
|
-
· {message}
|
|
179
|
-
</Text>
|
|
180
|
-
)}
|
|
181
|
-
</View>
|
|
182
|
-
)
|
|
183
|
-
// Keep the full message a hover away whenever it isn't fully spelled out (compact) or may be
|
|
184
|
-
// clipped (full → ellipsis).
|
|
185
|
-
return meaningful ? (
|
|
186
|
-
<Tooltip label={message} placement="bottom">
|
|
187
|
-
{card}
|
|
188
|
-
</Tooltip>
|
|
189
|
-
) : (
|
|
190
|
-
card
|
|
191
|
-
)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// --- Live tempo phase mapping -------------------------------------------------
|
|
195
|
-
|
|
196
|
-
/** Map the model's movement phase onto TempoDisplay's live-fill phase key. */
|
|
197
|
-
function mapLivePhase(
|
|
198
|
-
phase: DashboardModel['live']['phase']
|
|
199
|
-
): 'eccentric' | 'pauseBottom' | 'concentric' | null {
|
|
200
|
-
switch (phase) {
|
|
201
|
-
case 'concentric':
|
|
202
|
-
return 'concentric'
|
|
203
|
-
case 'eccentric':
|
|
204
|
-
return 'eccentric'
|
|
205
|
-
case 'hold':
|
|
206
|
-
return 'pauseBottom'
|
|
207
|
-
default:
|
|
208
|
-
return null
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// --- Page-level exercise header -----------------------------------------------
|
|
213
|
-
|
|
214
|
-
/** Below this header width the targets line wraps under the name (at the set-heading ratio). */
|
|
215
|
-
const HEADER_WRAP = 480
|
|
216
|
-
/** At/above this header width the targets render at full size. */
|
|
217
|
-
const HEADER_WIDE = 760
|
|
218
|
-
/** Targets:name size ratio on the wrapped second line — matches ExerciseHeading (11 / 14). */
|
|
219
|
-
const SET_HEADING_RATIO = 11 / 14
|
|
220
|
-
const HEADER_NAME_SIZE = 30
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The workout title + targets — the exercise being performed, independent of how many
|
|
224
|
-
* voltras drive it, so it lives at the TOP OF THE PAGE (above the live stage) and stays
|
|
225
|
-
* visible across single/dual. The targets shrink with width and only wrap under the name
|
|
226
|
-
* (at the set-heading size ratio) once too tight to shrink further. NOT a published component.
|
|
227
|
-
*/
|
|
228
|
-
export function ExerciseHeader({ session }: { session: DashboardModel['session'] }) {
|
|
229
|
-
const [w, setW] = useState(0)
|
|
230
|
-
const onLayout = (e: LayoutChangeEvent) => setW(e.nativeEvent.layout.width)
|
|
231
|
-
const wrap = w > 0 && w < HEADER_WRAP
|
|
232
|
-
const targetSize = wrap
|
|
233
|
-
? Math.round(HEADER_NAME_SIZE * SET_HEADING_RATIO) // set-heading ratio on the second line
|
|
234
|
-
: Math.round(clampLerp(w || HEADER_WIDE, HEADER_WRAP, HEADER_WIDE, 22, 28))
|
|
235
|
-
|
|
236
|
-
return (
|
|
237
|
-
<View
|
|
238
|
-
onLayout={onLayout}
|
|
239
|
-
className="border-border"
|
|
240
|
-
style={{
|
|
241
|
-
flexDirection: wrap ? 'column' : 'row',
|
|
242
|
-
alignItems: wrap ? 'flex-start' : 'baseline',
|
|
243
|
-
justifyContent: 'space-between',
|
|
244
|
-
gap: wrap ? 4 : 22,
|
|
245
|
-
paddingHorizontal: 24,
|
|
246
|
-
paddingTop: 20,
|
|
247
|
-
paddingBottom: 16,
|
|
248
|
-
borderBottomWidth: 1,
|
|
249
|
-
}}
|
|
250
|
-
>
|
|
251
|
-
<Text
|
|
252
|
-
className="text-text-primary"
|
|
253
|
-
style={{
|
|
254
|
-
fontSize: HEADER_NAME_SIZE,
|
|
255
|
-
fontFamily: '"Space Grotesk", sans-serif',
|
|
256
|
-
fontWeight: '700',
|
|
257
|
-
}}
|
|
258
|
-
>
|
|
259
|
-
{session.exerciseName}
|
|
260
|
-
</Text>
|
|
261
|
-
{/* targets: pinned right when inline, tucked under the name (smaller) when wrapped. */}
|
|
262
|
-
<SetsRepsLoad
|
|
263
|
-
sets={session.plannedSets}
|
|
264
|
-
reps={8}
|
|
265
|
-
load={session.weightLbs}
|
|
266
|
-
unit={session.unit}
|
|
267
|
-
fontSize={targetSize}
|
|
268
|
-
/>
|
|
269
|
-
</View>
|
|
270
|
-
)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// --- Live stage ---------------------------------------------------------------
|
|
274
|
-
|
|
275
|
-
/** Below this content width the alert drops its inline message to a hover tip. */
|
|
276
|
-
const ALERT_COMPACT = 620
|
|
277
|
-
/** Below this content width the alert collapses to just its contextual icon. */
|
|
278
|
-
const ALERT_ICON = 430
|
|
279
|
-
/** Tempo digit size at rest — matched to sit within {@link CONTROL_HEIGHT}. */
|
|
280
|
-
const TEMPO_BASE_FONT = 18
|
|
281
|
-
/** Content width at which the tempo has shrunk as far as it goes (near the panel min). */
|
|
282
|
-
const TEMPO_SHRINK_FLOOR = 300
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Lab specimen — the LIVE (mid-set) stage of one voltra. The exercise identity + targets
|
|
286
|
-
* are the PAGE header ({@link ExerciseHeader}); this layer carries only per-voltra live
|
|
287
|
-
* data: an optional vertical slot label (far left), the alert + live tempo in a row, and
|
|
288
|
-
* the velocity hero. NOT a published component.
|
|
289
|
-
*
|
|
290
|
-
* `side` renders this as one LAYER of a dual-mode set; `slot` names the active voltra in a
|
|
291
|
-
* single-view multi-device session. Either shows the vertical slot label.
|
|
292
|
-
*/
|
|
293
|
-
export function LiveView({
|
|
294
|
-
model,
|
|
295
|
-
side,
|
|
296
|
-
slot,
|
|
297
|
-
}: {
|
|
298
|
-
model: DashboardModel
|
|
299
|
-
side?: 'left' | 'right'
|
|
300
|
-
slot?: VoltraSlot
|
|
301
|
-
}) {
|
|
302
|
-
const { live, session } = model
|
|
303
|
-
const verdict = verdictFromLoss(live.velocityLossPct)
|
|
304
|
-
const dual = side != null
|
|
305
|
-
const badgeSlot: VoltraSlot | null = side ? (side === 'left' ? 'L' : 'R') : (slot ?? null)
|
|
306
|
-
|
|
307
|
-
const [contentW, setContentW] = useState(0)
|
|
308
|
-
const onContentLayout = (e: LayoutChangeEvent) => setContentW(e.nativeEvent.layout.width)
|
|
309
|
-
const [rowW, setRowW] = useState(0)
|
|
310
|
-
const onRowLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width)
|
|
311
|
-
const [tempoW, setTempoW] = useState(0)
|
|
312
|
-
const onTempoLayout = (e: LayoutChangeEvent) => setTempoW(e.nativeEvent.layout.width)
|
|
313
|
-
// The alert sheds detail first (message → verdict → icon); the tempo holds its full size
|
|
314
|
-
// until the alert can't shrink any further, then it takes over shrinking.
|
|
315
|
-
const alertMode: AlertMode =
|
|
316
|
-
contentW === 0 || contentW >= ALERT_COMPACT
|
|
317
|
-
? 'full'
|
|
318
|
-
: contentW >= ALERT_ICON
|
|
319
|
-
? 'compact'
|
|
320
|
-
: 'icon'
|
|
321
|
-
const tempoFont =
|
|
322
|
-
contentW === 0 || contentW >= ALERT_ICON
|
|
323
|
-
? TEMPO_BASE_FONT
|
|
324
|
-
: Math.round(clampLerp(contentW, TEMPO_SHRINK_FLOOR, ALERT_ICON, 14, TEMPO_BASE_FONT))
|
|
325
|
-
// Tempo is optional: a set may have no prescribed tempo — then the card is hidden entirely
|
|
326
|
-
// and the alert takes the whole row.
|
|
327
|
-
const hasTempo = session.tempo != null
|
|
328
|
-
// Width the alert may take — measured off the ROW (inside the panel padding) so a long
|
|
329
|
-
// message ellipsises at the side margin rather than running to the panel edge. With no tempo
|
|
330
|
-
// card the alert gets the full row; otherwise it's the row minus the (measured) tempo + gap.
|
|
331
|
-
const CONTROLS_GAP = 16
|
|
332
|
-
const alertAvail =
|
|
333
|
-
rowW > 0
|
|
334
|
-
? hasTempo
|
|
335
|
-
? tempoW > 0
|
|
336
|
-
? Math.max(0, rowW - tempoW - CONTROLS_GAP)
|
|
337
|
-
: undefined
|
|
338
|
-
: rowW
|
|
339
|
-
: undefined
|
|
340
|
-
|
|
341
|
-
const [heroH, setHeroH] = useState(0)
|
|
342
|
-
const onHeroLayout = (e: LayoutChangeEvent) => setHeroH(e.nativeEvent.layout.height)
|
|
343
|
-
const heroHeight = heroH > 0 ? heroH : dual ? 200 : 320
|
|
344
|
-
|
|
345
|
-
const activePhase = mapLivePhase(live.phase)
|
|
346
|
-
const message = `VL${live.velocityLossPct} · approaching threshold — 1–2 productive reps left`
|
|
347
|
-
|
|
348
|
-
return (
|
|
349
|
-
// head verdict → full-surface aura flood; fills its section edge-to-edge — squared off
|
|
350
|
-
// (no radius/border), since it's the section background, not a card within it.
|
|
351
|
-
<LiveAuraFrame category={verdict} style={{ flex: 1, borderRadius: 0, borderWidth: 0 }}>
|
|
352
|
-
<View className="flex-row" style={{ flex: 1 }}>
|
|
353
|
-
{badgeSlot && <VerticalSlotLabel slot={badgeSlot} />}
|
|
354
|
-
<View
|
|
355
|
-
onLayout={onContentLayout}
|
|
356
|
-
style={{ flex: 1, padding: dual ? 18 : 24, gap: dual ? 8 : 10 }}
|
|
357
|
-
>
|
|
358
|
-
{/* controls row: tempo upper-left (when prescribed), alert upper-right. With no tempo
|
|
359
|
-
the alert simply pins right (flex-end); otherwise they split (space-between). */}
|
|
360
|
-
<View
|
|
361
|
-
onLayout={onRowLayout}
|
|
362
|
-
className="flex-row items-center"
|
|
363
|
-
style={{ gap: CONTROLS_GAP, justifyContent: hasTempo ? 'space-between' : 'flex-end' }}
|
|
364
|
-
>
|
|
365
|
-
{/* tempo card — locked to the alert's height (this view only); the inner TempoDisplay
|
|
366
|
-
shrinks its font but stays centred on the shared charcoal ground so it reads seamless. */}
|
|
367
|
-
{session.tempo != null && (
|
|
368
|
-
<View
|
|
369
|
-
onLayout={onTempoLayout}
|
|
370
|
-
style={{
|
|
371
|
-
height: CONTROL_HEIGHT,
|
|
372
|
-
justifyContent: 'center',
|
|
373
|
-
alignItems: 'flex-start',
|
|
374
|
-
backgroundColor: TEMPO_GROUND,
|
|
375
|
-
borderRadius: 9,
|
|
376
|
-
overflow: 'hidden',
|
|
377
|
-
...CARD_SHADOW,
|
|
378
|
-
}}
|
|
379
|
-
>
|
|
380
|
-
<TempoDisplay
|
|
381
|
-
tempo={session.tempo}
|
|
382
|
-
fontSize={tempoFont}
|
|
383
|
-
live={
|
|
384
|
-
activePhase ? { activePhase, phaseElapsedMs: live.phaseElapsedMs } : undefined
|
|
385
|
-
}
|
|
386
|
-
showLabel={false}
|
|
387
|
-
showInfo={false}
|
|
388
|
-
/>
|
|
389
|
-
</View>
|
|
390
|
-
)}
|
|
391
|
-
<AlertCue status={verdict} message={message} mode={alertMode} availWidth={alertAvail} />
|
|
392
|
-
</View>
|
|
393
|
-
|
|
394
|
-
{/* the velocity hero fills the rest. */}
|
|
395
|
-
<View style={{ flex: 1 }} onLayout={onHeroLayout}>
|
|
396
|
-
<VelocityStrip
|
|
397
|
-
variant="hero"
|
|
398
|
-
velocities={live.repVelocities}
|
|
399
|
-
liveRepIndex={live.repVelocities.length - 1}
|
|
400
|
-
targetReps={8}
|
|
401
|
-
height={heroHeight}
|
|
402
|
-
scale="peak"
|
|
403
|
-
/>
|
|
404
|
-
</View>
|
|
405
|
-
</View>
|
|
406
|
-
</View>
|
|
407
|
-
</LiveAuraFrame>
|
|
408
|
-
)
|
|
409
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { View } from 'react-native'
|
|
3
|
-
import { DashboardShell, type Device } from '../../components'
|
|
4
|
-
import { LivePage, type LivePageVariant } from './LivePage'
|
|
5
|
-
import { dashboardFixture } from './fixtures'
|
|
6
|
-
|
|
7
|
-
/** The fixture with its prescribed tempo stripped — a set with no tempo (hidden readout). */
|
|
8
|
-
const noTempoModel = {
|
|
9
|
-
...dashboardFixture,
|
|
10
|
-
session: { ...dashboardFixture.session, tempo: undefined },
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* `Lab/North Star/Live Wall Dashboard` — the north-star wall-dashboard specimen.
|
|
15
|
-
*
|
|
16
|
-
* A LAB specimen: it COMPOSES existing production components ({@link DashboardShell}
|
|
17
|
-
* around a lab-scoped `LivePage`) into the target surface. It is NOT a published
|
|
18
|
-
* library component — nothing here is added to a package barrel.
|
|
19
|
-
*
|
|
20
|
-
* The live-stage slots render their REAL treatments: the VelocityStrip hero, the live
|
|
21
|
-
* TempoDisplay (running tempo, folded into the head), the consolidated status cue, and
|
|
22
|
-
* the RestTimer ring. `live-dual` stacks two live layers, one per voltra.
|
|
23
|
-
*/
|
|
24
|
-
const DEVICES: Device[] = [
|
|
25
|
-
{ id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
|
|
26
|
-
{ id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
interface WallArgs {
|
|
30
|
-
variant: LivePageVariant
|
|
31
|
-
/** Whether the current set has a prescribed tempo; off hides the tempo readout. */
|
|
32
|
-
tempo: boolean
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const meta: Meta<WallArgs> = {
|
|
36
|
-
title: 'Lab/North Star/Live Wall Dashboard',
|
|
37
|
-
args: { variant: 'live', tempo: true },
|
|
38
|
-
argTypes: {
|
|
39
|
-
variant: { control: 'inline-radio', options: ['live', 'live-dual', 'rest'] },
|
|
40
|
-
tempo: { control: 'boolean' },
|
|
41
|
-
},
|
|
42
|
-
render: ({ variant, tempo }) => (
|
|
43
|
-
<DashboardShell
|
|
44
|
-
activeKey="live"
|
|
45
|
-
state={variant === 'rest' ? 'rest' : 'live'}
|
|
46
|
-
liveKey={variant === 'rest' ? 'live' : null}
|
|
47
|
-
devices={DEVICES}
|
|
48
|
-
subtitle="wall dashboard"
|
|
49
|
-
>
|
|
50
|
-
<LivePage variant={variant} model={tempo ? dashboardFixture : noTempoModel} />
|
|
51
|
-
</DashboardShell>
|
|
52
|
-
),
|
|
53
|
-
decorators: [
|
|
54
|
-
(Story) => (
|
|
55
|
-
<View style={{ height: '100vh' as unknown as number, backgroundColor: '#0E0E0E' }}>
|
|
56
|
-
<Story />
|
|
57
|
-
</View>
|
|
58
|
-
),
|
|
59
|
-
],
|
|
60
|
-
parameters: {
|
|
61
|
-
layout: 'fullscreen',
|
|
62
|
-
docs: {
|
|
63
|
-
description: {
|
|
64
|
-
component:
|
|
65
|
-
'**North Star wall dashboard** (lab specimen). Composes ' +
|
|
66
|
-
'[DashboardShell](?path=/docs/pages-dashboardshell--docs) around a lab `LivePage` ' +
|
|
67
|
-
'(SessionRail + a Live/Rest stage). Toggle **variant** to switch between the ' +
|
|
68
|
-
'mid-set live read-out and the between-sets rest read-out. Tier-C slots render ' +
|
|
69
|
-
'their base component as a labelled stub.',
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export default meta
|
|
76
|
-
type Story = StoryObj<WallArgs>
|
|
77
|
-
|
|
78
|
-
/** Mid-set: velocity hero, live metrics, verdict pill + amber threshold aura. */
|
|
79
|
-
export const Live: Story = {
|
|
80
|
-
args: { variant: 'live' },
|
|
81
|
-
parameters: {
|
|
82
|
-
docs: {
|
|
83
|
-
description: { story: 'The live (mid-set) stage — ~22% velocity loss, threshold verdict.' },
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Mid-set with NO prescribed tempo — the tempo readout is hidden; the alert takes the row. */
|
|
89
|
-
export const LiveNoTempo: Story = {
|
|
90
|
-
args: { variant: 'live', tempo: false },
|
|
91
|
-
parameters: {
|
|
92
|
-
docs: {
|
|
93
|
-
description: {
|
|
94
|
-
story:
|
|
95
|
-
'The live stage for a set with no prescribed tempo (coach left it unset and no ' +
|
|
96
|
-
'exercise default). The tempo card is hidden entirely — no invented placeholder — ' +
|
|
97
|
-
'and the alert cue simply pins to the right of the row.',
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Dual-mode (bilateral): two stacked live layers, one per voltra — left dominant, right lags. */
|
|
104
|
-
export const LiveDual: Story = {
|
|
105
|
-
args: { variant: 'live-dual' },
|
|
106
|
-
parameters: {
|
|
107
|
-
docs: {
|
|
108
|
-
description: {
|
|
109
|
-
story:
|
|
110
|
-
'A dual-mode (bilateral) exercise — the stage stacks two live layers, one per ' +
|
|
111
|
-
'voltra. The RIGHT voltra shows a realistic left-dominant deficit (slower reps, ' +
|
|
112
|
-
'more velocity loss). v1 renders the two sides independently; a unified split-bar ' +
|
|
113
|
-
'treatment is a later exploration.',
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Between sets: rest countdown, completed-set recap, verdict metrics + next-set mock. */
|
|
120
|
-
export const Rest: Story = {
|
|
121
|
-
args: { variant: 'rest' },
|
|
122
|
-
parameters: {
|
|
123
|
-
docs: {
|
|
124
|
-
description: {
|
|
125
|
-
story: 'The rest stage — countdown, set recap, verdict, and a mock next-set preview.',
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
}
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import { View, Text } from 'react-native'
|
|
3
|
-
import { RestTimer, ExerciseCard, Metric, MetricGroup, type SetRowProps } from '../../components'
|
|
4
|
-
import { type DashboardModel, meanVelocity, verdictFromLoss } from './fixtures'
|
|
5
|
-
|
|
6
|
-
/** Project the fixture's completed + current sets onto the recap card's set rows. */
|
|
7
|
-
function deriveRecapSets(model: DashboardModel): SetRowProps[] {
|
|
8
|
-
const { session, live } = model
|
|
9
|
-
const done: SetRowProps[] = session.completedSets.map((set, i) => ({
|
|
10
|
-
state: 'done',
|
|
11
|
-
setNumber: i + 1,
|
|
12
|
-
unit: session.unit,
|
|
13
|
-
reps: set.repCount,
|
|
14
|
-
weight: set.weightLbs,
|
|
15
|
-
rpe: 8 + i * 0.5,
|
|
16
|
-
velocities: set.reps,
|
|
17
|
-
}))
|
|
18
|
-
const current: SetRowProps = {
|
|
19
|
-
state: 'done',
|
|
20
|
-
setNumber: session.completedSets.length + 1,
|
|
21
|
-
unit: session.unit,
|
|
22
|
-
reps: live.repVelocities.length,
|
|
23
|
-
weight: session.weightLbs,
|
|
24
|
-
rpe: 9,
|
|
25
|
-
velocities: live.repVelocities,
|
|
26
|
-
}
|
|
27
|
-
return [...done, current]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Lab specimen — the REST stage of the North Star wall dashboard.
|
|
32
|
-
*
|
|
33
|
-
* A rest read-out: the countdown, a recap of the set just finished, the set's verdict
|
|
34
|
-
* metrics, and a preview of what's next. NOT a published component — lab-scoped only.
|
|
35
|
-
*/
|
|
36
|
-
export function RestView({ model }: { model: DashboardModel }) {
|
|
37
|
-
const { live, session } = model
|
|
38
|
-
const verdict = verdictFromLoss(live.velocityLossPct)
|
|
39
|
-
const meanCon = meanVelocity(live.repVelocities)
|
|
40
|
-
const peakCon = Math.max(...live.repVelocities)
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<View className="flex-row" style={{ flex: 1, padding: 20, gap: 20 }}>
|
|
44
|
-
{/* left: the countdown + the just-completed exercise recap. */}
|
|
45
|
-
<View style={{ flex: 2, gap: 20 }}>
|
|
46
|
-
{/* Rest countdown — ring variant (across-the-room wall treatment). */}
|
|
47
|
-
<RestTimer
|
|
48
|
-
variant="ring"
|
|
49
|
-
size={220}
|
|
50
|
-
totalSeconds={120}
|
|
51
|
-
elapsedMs={47_000}
|
|
52
|
-
visible
|
|
53
|
-
nextSetInfo={`Next · ${session.exerciseName} · set ${session.completedSets.length + 2} of ${session.plannedSets}`}
|
|
54
|
-
onSkip={() => {}}
|
|
55
|
-
onAddTime={() => {}}
|
|
56
|
-
/>
|
|
57
|
-
|
|
58
|
-
<View style={{ gap: 8 }}>
|
|
59
|
-
<Text
|
|
60
|
-
className="text-text-tertiary"
|
|
61
|
-
style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1 }}
|
|
62
|
-
>
|
|
63
|
-
SET JUST COMPLETED
|
|
64
|
-
</Text>
|
|
65
|
-
<ExerciseCard
|
|
66
|
-
name={session.exerciseName}
|
|
67
|
-
expanded
|
|
68
|
-
summary={{
|
|
69
|
-
sets: session.plannedSets,
|
|
70
|
-
reps: 8,
|
|
71
|
-
weight: session.weightLbs,
|
|
72
|
-
unit: session.unit,
|
|
73
|
-
}}
|
|
74
|
-
tempo={session.tempo}
|
|
75
|
-
indicator="velocity-loss"
|
|
76
|
-
sets={deriveRecapSets(model)}
|
|
77
|
-
/>
|
|
78
|
-
</View>
|
|
79
|
-
</View>
|
|
80
|
-
|
|
81
|
-
{/* right: the set verdict + the next-set preview (mock). */}
|
|
82
|
-
<View style={{ flex: 1, gap: 24 }}>
|
|
83
|
-
<View style={{ gap: 6 }}>
|
|
84
|
-
<Text
|
|
85
|
-
className="text-text-tertiary"
|
|
86
|
-
style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1 }}
|
|
87
|
-
>
|
|
88
|
-
SET VERDICT
|
|
89
|
-
</Text>
|
|
90
|
-
{/* MetricGroup ×4 — the read-once summary of the finished set. */}
|
|
91
|
-
<MetricGroup>
|
|
92
|
-
<Metric size="md" value={meanCon.toFixed(2)} unit="m/s" label="Mean con" />
|
|
93
|
-
<Metric size="md" value={peakCon.toFixed(2)} unit="m/s" label="Peak con" />
|
|
94
|
-
</MetricGroup>
|
|
95
|
-
<MetricGroup>
|
|
96
|
-
<Metric size="md" value={`${live.velocityLossPct}%`} label="Vel loss" trend="down" />
|
|
97
|
-
<Metric size="md" value={String(live.repVelocities.length)} label="Reps" />
|
|
98
|
-
</MetricGroup>
|
|
99
|
-
<MetricGroup>
|
|
100
|
-
<Metric size="md" value={String(session.weightLbs)} unit="lbs" label="Load" />
|
|
101
|
-
<Metric size="md" value={String(live.peakForce)} unit="N" label="Peak force" />
|
|
102
|
-
</MetricGroup>
|
|
103
|
-
<MetricGroup>
|
|
104
|
-
<Metric
|
|
105
|
-
size="md"
|
|
106
|
-
value={verdict === 'threshold' ? 'MOD' : verdict === 'stop' ? 'HIGH' : 'LOW'}
|
|
107
|
-
label="Fatigue"
|
|
108
|
-
trend={verdict === 'productive' ? 'up' : 'neutral'}
|
|
109
|
-
/>
|
|
110
|
-
<Metric
|
|
111
|
-
size="md"
|
|
112
|
-
value={String(live.lastRep.rom.toFixed(2))}
|
|
113
|
-
unit="m"
|
|
114
|
-
label="Avg ROM"
|
|
115
|
-
/>
|
|
116
|
-
</MetricGroup>
|
|
117
|
-
</View>
|
|
118
|
-
|
|
119
|
-
{/* mock "next set" block — clearly labelled NO-DATA / mock, not a wired read-model. */}
|
|
120
|
-
<View
|
|
121
|
-
className="border-border"
|
|
122
|
-
style={{ borderWidth: 1, borderStyle: 'dashed', borderRadius: 10, padding: 16, gap: 4 }}
|
|
123
|
-
>
|
|
124
|
-
<Text
|
|
125
|
-
className="text-text-tertiary"
|
|
126
|
-
style={{ fontSize: 10, fontWeight: '700', letterSpacing: 1 }}
|
|
127
|
-
>
|
|
128
|
-
NEXT SET · MOCK (NO DATA)
|
|
129
|
-
</Text>
|
|
130
|
-
<Text className="text-text-primary" style={{ fontSize: 18, fontWeight: '700' }}>
|
|
131
|
-
{session.exerciseName}
|
|
132
|
-
</Text>
|
|
133
|
-
<Text className="text-text-secondary" style={{ fontSize: 13 }}>
|
|
134
|
-
{`Set ${session.completedSets.length + 2} · target 8 × ${session.weightLbs} ${session.unit}`}
|
|
135
|
-
</Text>
|
|
136
|
-
</View>
|
|
137
|
-
</View>
|
|
138
|
-
</View>
|
|
139
|
-
)
|
|
140
|
-
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fixtures for the `Lab/North Star/Live Wall Dashboard` specimen.
|
|
3
|
-
*
|
|
4
|
-
* Shapes deliberately MIRROR the voltras-mcp dashboard store read-models so a later
|
|
5
|
-
* provisional wiring is a mechanical field map, not a reshape:
|
|
6
|
-
* - {@link LiveModel} — the live-set telemetry read-model (`velocity`, `force`,
|
|
7
|
-
* `phase`, `lastRep`, `velocityLossPct`).
|
|
8
|
-
* - {@link SessionModel} — the session read-model (`exerciseName` + completed sets).
|
|
9
|
-
* - {@link CompletedSet} — a logged set (`weightLbs`, `mode`, `repCount`, `reps`).
|
|
10
|
-
*
|
|
11
|
-
* The presentational titan props each view consumes are DERIVED from these read-models
|
|
12
|
-
* (see the `derive*` helpers) — the same projection the store→component adapter will do.
|
|
13
|
-
* Numbers are realistic cable chest-press strength-training values.
|
|
14
|
-
*/
|
|
15
|
-
import type { SessionRailExercise } from '../../components'
|
|
16
|
-
|
|
17
|
-
// --- Store read-model shapes (mirror voltras-mcp dashboard store) -------------
|
|
18
|
-
|
|
19
|
-
/** One completed rep's per-rep telemetry (mean-concentric metric family). */
|
|
20
|
-
export interface RepModel {
|
|
21
|
-
/** Mean CONCENTRIC velocity (m/s) — the canonical metric (brain WA-D02). */
|
|
22
|
-
vCon: number
|
|
23
|
-
/** Range of motion (m). */
|
|
24
|
-
rom: number
|
|
25
|
-
/** Peak instantaneous velocity (m/s). */
|
|
26
|
-
peakVelocity: number
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The live-set telemetry read-model. */
|
|
30
|
-
export interface LiveModel {
|
|
31
|
-
/** Instantaneous cable velocity (m/s). */
|
|
32
|
-
velocity: number
|
|
33
|
-
/** Instantaneous cable force (N). */
|
|
34
|
-
force: number
|
|
35
|
-
/** Current movement phase. */
|
|
36
|
-
phase: 'concentric' | 'hold' | 'eccentric' | 'idle'
|
|
37
|
-
/** Elapsed time (ms) within the current phase — drives the live tempo fill. */
|
|
38
|
-
phaseElapsedMs: number
|
|
39
|
-
/** The most-recently completed rep. */
|
|
40
|
-
lastRep: RepModel
|
|
41
|
-
/** Per-rep mean-concentric velocities logged so far this set (m/s). */
|
|
42
|
-
repVelocities: number[]
|
|
43
|
-
/** Velocity loss vs the set's best rep (%). Drives verdict + aura + fatigue. */
|
|
44
|
-
velocityLossPct: number
|
|
45
|
-
/** Peak concentric force this set (N). */
|
|
46
|
-
peakForce: number
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** A logged set on the session read-model. */
|
|
50
|
-
export interface CompletedSet {
|
|
51
|
-
weightLbs: number
|
|
52
|
-
/** Resistance mode (cascade echo, cmd=0x10). */
|
|
53
|
-
mode: 'weight' | 'chains' | 'eccentric' | 'isokinetic'
|
|
54
|
-
repCount: number
|
|
55
|
-
/** Per-rep mean-concentric velocities (m/s). */
|
|
56
|
-
reps: number[]
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The session read-model. */
|
|
60
|
-
export interface SessionModel {
|
|
61
|
-
exerciseName: string
|
|
62
|
-
title: string
|
|
63
|
-
weightLbs: number
|
|
64
|
-
unit: 'lbs' | 'kg'
|
|
65
|
-
/**
|
|
66
|
-
* Prescribed tempo tuple [ecc, pauseBottom, con, pauseTop]. OPTIONAL — a set may carry no
|
|
67
|
-
* prescribed tempo (coach left it unset and no exercise default); the live view then hides
|
|
68
|
-
* the tempo readout entirely rather than inventing one.
|
|
69
|
-
*/
|
|
70
|
-
tempo?: [number, number, number, number]
|
|
71
|
-
completedSets: CompletedSet[]
|
|
72
|
-
plannedSets: number
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The full store snapshot the specimen reads. */
|
|
76
|
-
export interface DashboardModel {
|
|
77
|
-
live: LiveModel
|
|
78
|
-
session: SessionModel
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// --- The fixture instance -----------------------------------------------------
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* A mid-set cable chest press: 6 reps in, velocity decaying from 0.52 → 0.405 m/s,
|
|
85
|
-
* ~22% velocity loss (the VL20–VL30 mid-zone → `threshold` verdict / amber aura).
|
|
86
|
-
*/
|
|
87
|
-
export const dashboardFixture: DashboardModel = {
|
|
88
|
-
live: {
|
|
89
|
-
velocity: 0.41,
|
|
90
|
-
force: 498,
|
|
91
|
-
phase: 'concentric',
|
|
92
|
-
phaseElapsedMs: 700,
|
|
93
|
-
lastRep: { vCon: 0.405, rom: 0.58, peakVelocity: 0.61 },
|
|
94
|
-
repVelocities: [0.52, 0.5, 0.48, 0.46, 0.44, 0.405],
|
|
95
|
-
velocityLossPct: 22,
|
|
96
|
-
peakForce: 542,
|
|
97
|
-
},
|
|
98
|
-
session: {
|
|
99
|
-
exerciseName: 'Cable Chest Press',
|
|
100
|
-
title: 'Push A · Hypertrophy',
|
|
101
|
-
weightLbs: 140,
|
|
102
|
-
unit: 'lbs',
|
|
103
|
-
tempo: [3, 1, 1, 0],
|
|
104
|
-
plannedSets: 4,
|
|
105
|
-
completedSets: [
|
|
106
|
-
{
|
|
107
|
-
weightLbs: 140,
|
|
108
|
-
mode: 'weight',
|
|
109
|
-
repCount: 8,
|
|
110
|
-
reps: [0.55, 0.54, 0.52, 0.51, 0.49, 0.47, 0.45, 0.43],
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
weightLbs: 140,
|
|
114
|
-
mode: 'weight',
|
|
115
|
-
repCount: 8,
|
|
116
|
-
reps: [0.53, 0.52, 0.5, 0.48, 0.46, 0.44, 0.42, 0.4],
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
},
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// --- Derived projections (store read-model → titan presentational props) -------
|
|
123
|
-
|
|
124
|
-
/** Arithmetic mean of a per-rep velocity array (m/s). */
|
|
125
|
-
export function meanVelocity(reps: number[]): number {
|
|
126
|
-
if (reps.length === 0) return 0
|
|
127
|
-
return reps.reduce((a, v) => a + v, 0) / reps.length
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Verdict status from velocity loss %, matching FatigueMeter's VL20/VL30 bands. */
|
|
131
|
-
export function verdictFromLoss(lossPct: number): 'productive' | 'threshold' | 'stop' {
|
|
132
|
-
if (lossPct >= 30) return 'stop'
|
|
133
|
-
if (lossPct >= 20) return 'threshold'
|
|
134
|
-
return 'productive'
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The session rail's exercise list, projected from the model: the current exercise
|
|
139
|
-
* (active), the completed sets behind it, and an upcoming accessory.
|
|
140
|
-
*/
|
|
141
|
-
export function deriveRailExercises(model: DashboardModel): SessionRailExercise[] {
|
|
142
|
-
const { session, live } = model
|
|
143
|
-
return [
|
|
144
|
-
{
|
|
145
|
-
id: 'chest-press',
|
|
146
|
-
name: session.exerciseName,
|
|
147
|
-
summary: {
|
|
148
|
-
sets: session.plannedSets,
|
|
149
|
-
reps: 8,
|
|
150
|
-
weight: session.weightLbs,
|
|
151
|
-
unit: session.unit,
|
|
152
|
-
},
|
|
153
|
-
tempo: session.tempo,
|
|
154
|
-
indicator: 'velocity-loss',
|
|
155
|
-
setStates: [
|
|
156
|
-
{ status: 'done', velocities: session.completedSets[0].reps },
|
|
157
|
-
{ status: 'done', velocities: session.completedSets[1].reps },
|
|
158
|
-
{ status: 'active', velocities: live.repVelocities, planned: 8 },
|
|
159
|
-
{ status: 'todo', planned: 8 },
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
id: 'incline-db',
|
|
164
|
-
name: 'Incline DB Press',
|
|
165
|
-
summary: { sets: 3, reps: 10, weight: 55, unit: 'lbs' },
|
|
166
|
-
tempo: [2, 0, 1, 0],
|
|
167
|
-
setStates: [
|
|
168
|
-
{ status: 'todo', planned: 10 },
|
|
169
|
-
{ status: 'todo', planned: 10 },
|
|
170
|
-
{ status: 'todo', planned: 10 },
|
|
171
|
-
],
|
|
172
|
-
upcoming: true,
|
|
173
|
-
},
|
|
174
|
-
{
|
|
175
|
-
id: 'cable-fly',
|
|
176
|
-
name: 'Cable Fly',
|
|
177
|
-
summary: { sets: 3, reps: 12, weight: 30, unit: 'lbs' },
|
|
178
|
-
setStates: [
|
|
179
|
-
{ status: 'todo', planned: 12 },
|
|
180
|
-
{ status: 'todo', planned: 12 },
|
|
181
|
-
{ status: 'todo', planned: 12 },
|
|
182
|
-
],
|
|
183
|
-
upcoming: true,
|
|
184
|
-
},
|
|
185
|
-
]
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// --- Dual-mode (bilateral) projection -----------------------------------------
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Split a single-cable model into a bilateral (dual-mode) pair — one model per voltra.
|
|
192
|
-
*
|
|
193
|
-
* A dual-mode exercise drives two Voltra devices as a left/right pair. For v1 the two
|
|
194
|
-
* layers are rendered independently (one {@link LiveModel} each); a unified split-bar
|
|
195
|
-
* treatment is deferred. The fixture gives the RIGHT side a realistic left-dominant
|
|
196
|
-
* deficit (slightly slower reps, more velocity loss, a touch less force) so the two
|
|
197
|
-
* layers read as genuinely different streams rather than a mirror.
|
|
198
|
-
*/
|
|
199
|
-
export function deriveDualModel(base: DashboardModel = dashboardFixture): {
|
|
200
|
-
left: DashboardModel
|
|
201
|
-
right: DashboardModel
|
|
202
|
-
} {
|
|
203
|
-
// Left is the dominant side — the base fixture verbatim.
|
|
204
|
-
const left: DashboardModel = base
|
|
205
|
-
// Right lags: ~8% slower per-rep concentric velocity, +7 pts velocity loss, ~6% less force.
|
|
206
|
-
const scale = (v: number) => Number((v * 0.92).toFixed(3))
|
|
207
|
-
const right: DashboardModel = {
|
|
208
|
-
session: base.session,
|
|
209
|
-
live: {
|
|
210
|
-
...base.live,
|
|
211
|
-
velocity: scale(base.live.velocity),
|
|
212
|
-
force: Math.round(base.live.force * 0.94),
|
|
213
|
-
peakForce: Math.round(base.live.peakForce * 0.94),
|
|
214
|
-
velocityLossPct: base.live.velocityLossPct + 7,
|
|
215
|
-
repVelocities: base.live.repVelocities.map(scale),
|
|
216
|
-
lastRep: {
|
|
217
|
-
vCon: scale(base.live.lastRep.vCon),
|
|
218
|
-
rom: Number((base.live.lastRep.rom * 0.96).toFixed(2)),
|
|
219
|
-
peakVelocity: scale(base.live.lastRep.peakVelocity),
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
|
-
}
|
|
223
|
-
return { left, right }
|
|
224
|
-
}
|