@titan-design/react-ui 0.9.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bodymap.js +78 -24
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +78 -24
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +429 -11
- package/dist/index.d.ts +429 -11
- package/dist/index.js +2386 -885
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2306 -836
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1900 -1609
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1901 -1610
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +129 -34
- package/dist/theme/index.d.ts +129 -34
- package/dist/theme/index.js +95 -19
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +92 -20
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +88 -24
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +88 -24
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Workout/BaseBadge.tsx +5 -5
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
- package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +55 -18
- package/src/components/custom/Workout/SetRow.test.tsx +6 -6
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/surface/Surface.stories.tsx +195 -1
- package/src/components/ui/surface/Surface.test.tsx +148 -9
- package/src/components/ui/surface/Surface.tsx +37 -9
- package/src/components/ui/surface/SurfaceContext.ts +48 -8
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +871 -0
- package/src/theme/ColorPrimitives.stories.tsx +300 -0
- package/src/theme/bar-paper.ts +59 -0
- package/src/theme/color-stories.coverage.test.ts +130 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/elevation.ts +90 -67
- package/src/theme/global.css +35 -9
- package/src/theme/tokens/primitives.ts +40 -0
- package/src/theme/tokens/semantic.ts +63 -14
- package/tailwind.config.js +8 -0
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -323
- package/src/theme/Colors.stories.tsx +0 -323
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* LiveFatigueCard — the vertical live fatigue card. One focal read (the RPE/verdict
|
|
4
|
+
* hero + three why-lights, grouped at top) → the ROM progression → the ghost-spark
|
|
5
|
+
* (tempo embedded), the last two spread through the leftover height. Consumes ONE
|
|
6
|
+
* {@link LiveFatigueModel}.
|
|
7
|
+
*
|
|
8
|
+
* SURFACE (TD-07.08). The card grounds on the `base` plane — ONE step above the
|
|
9
|
+
* `background` shell the live stage paints, per the surface north-star's pairing matrix
|
|
10
|
+
* (a card is parent+1; `raised`/`overlay` skip levels and read hot against the near-black
|
|
11
|
+
* frame). Separation is carried by the alpha `hairline-default` edge, not by lightness —
|
|
12
|
+
* the north-star's primary cue, self-normalising on any plane.
|
|
13
|
+
*
|
|
14
|
+
* PAPER. The live card is one of the designated hero surfaces, so it takes the paper
|
|
15
|
+
* accent DELIBERATELY: the shared {@link barPaper} treatment (brightness-scaled matte
|
|
16
|
+
* grain + crisp top rim-light + soft contact shadow) — the same material the ROM /
|
|
17
|
+
* velocity bars are made of, now carrying the sheet they sit on. Token-sourced fill, no
|
|
18
|
+
* hardcoded surface hex.
|
|
19
|
+
*/
|
|
20
|
+
import { View } from 'react-native'
|
|
21
|
+
import { Surface } from '../../ui/surface/Surface'
|
|
22
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
23
|
+
import { barPaper } from '../../../theme/bar-paper'
|
|
24
|
+
import { VerdictHero } from './VerdictHero'
|
|
25
|
+
import { FatigueLights } from './FatigueLights'
|
|
26
|
+
import { RomProgressionChart } from './RomProgressionChart'
|
|
27
|
+
import { GhostSpark } from './GhostSpark'
|
|
28
|
+
import type { LiveFatigueModel } from './fatigue-model'
|
|
29
|
+
|
|
30
|
+
const t = getSemanticColors('dark')
|
|
31
|
+
|
|
32
|
+
export interface LiveFatigueCardProps {
|
|
33
|
+
/** The live fatigue read-model for the current set. */
|
|
34
|
+
model: LiveFatigueModel
|
|
35
|
+
/** Card width in px. Default 318. */
|
|
36
|
+
width?: number
|
|
37
|
+
/** Card height in px — when set, the sections spread through the leftover height. */
|
|
38
|
+
height?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PAD = 18
|
|
42
|
+
const GHOST_GUTTER = 4 // GhostSpark carries this L/R padding internally
|
|
43
|
+
|
|
44
|
+
export function LiveFatigueCard({ model, width = 318, height }: LiveFatigueCardProps) {
|
|
45
|
+
const chartW = width - PAD * 2 - GHOST_GUTTER * 2
|
|
46
|
+
const chartH = height != null ? Math.round(Math.min(240, Math.max(168, height * 0.4))) : 172
|
|
47
|
+
return (
|
|
48
|
+
<Surface
|
|
49
|
+
level="base"
|
|
50
|
+
testID="live-fatigue-card"
|
|
51
|
+
style={{
|
|
52
|
+
width,
|
|
53
|
+
height,
|
|
54
|
+
borderRadius: 14,
|
|
55
|
+
padding: PAD,
|
|
56
|
+
borderWidth: 1,
|
|
57
|
+
borderColor: t['hairline-default'],
|
|
58
|
+
...barPaper(t['surface-base']),
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{/* top group — verdict hero + the three why-lights, tight together. */}
|
|
62
|
+
<View style={{ gap: 12 }}>
|
|
63
|
+
<VerdictHero rpe={model.rpe} verdict={model.verdict} />
|
|
64
|
+
<FatigueLights dimensions={model.verdict?.dimensions ?? null} />
|
|
65
|
+
</View>
|
|
66
|
+
|
|
67
|
+
<View style={{ flex: 1, minHeight: 16 }} />
|
|
68
|
+
|
|
69
|
+
<RomProgressionChart
|
|
70
|
+
points={model.romProgression}
|
|
71
|
+
workingStandardM={model.romWorkingStandardM}
|
|
72
|
+
shortThresholdM={model.romShortThresholdM}
|
|
73
|
+
plannedReps={model.plannedReps}
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
<View style={{ flex: 1, minHeight: 16 }} />
|
|
77
|
+
|
|
78
|
+
<GhostSpark
|
|
79
|
+
curves={model.velocityCurves}
|
|
80
|
+
width={chartW}
|
|
81
|
+
height={chartH}
|
|
82
|
+
targetTempoSeconds={model.targetTempoSeconds}
|
|
83
|
+
/>
|
|
84
|
+
</Surface>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { LiveFatiguePanel } from './LiveFatiguePanel'
|
|
4
|
+
import { primitiveColors } from '../../../theme/tokens/primitives'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
|
+
import { FATIGUE_STATES, buildMockPanelState } from './fatigue-mock'
|
|
7
|
+
|
|
8
|
+
const PAGE_BG = primitiveColors.charcoal[900]
|
|
9
|
+
const t = getSemanticColors('dark')
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof LiveFatiguePanel> = {
|
|
12
|
+
title: 'Workout/Fatigue/Live Fatigue Panel',
|
|
13
|
+
component: LiveFatiguePanel,
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
parameters: {
|
|
16
|
+
layout: 'fullscreen',
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
'The aligned **Live panel v2** — the primary loss-relative `VelocityHero` beside the vertical ' +
|
|
21
|
+
'`LiveFatigueCard`, flooded by a `LiveAuraFrame` whose category tracks the verdict state. ' +
|
|
22
|
+
'Composes `VelocityHero` (VelocityStrip + VL bands) · `LiveFatigueCard` (VerdictHero · ' +
|
|
23
|
+
'FatigueLights · RomProgressionChart · GhostSpark) · `LiveAuraFrame`. The velocity hero data ' +
|
|
24
|
+
'is passed separately (`velocity`) — it is not on the fatigue model.',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj<typeof LiveFatiguePanel>
|
|
31
|
+
|
|
32
|
+
/** THE aligned direction — a breaking-down rep 8, ghost-spark revealed. */
|
|
33
|
+
export const LivePanelV2: Story = {
|
|
34
|
+
name: 'Live panel v2 (composition)',
|
|
35
|
+
render: () => {
|
|
36
|
+
const { model, velocity, header } = buildMockPanelState(FATIGUE_STATES[3].current, {
|
|
37
|
+
rpe: 10,
|
|
38
|
+
verdict: FATIGUE_STATES[3].model.verdict,
|
|
39
|
+
})
|
|
40
|
+
return (
|
|
41
|
+
<View style={{ backgroundColor: PAGE_BG, padding: 24 }}>
|
|
42
|
+
<LiveFatiguePanel model={model} velocity={velocity} header={header} />
|
|
43
|
+
</View>
|
|
44
|
+
)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The whole system across the verdict spectrum — GOOD → SLOWING → GRINDING → FORM BREAKING DOWN. */
|
|
49
|
+
export const LiveStates: Story = {
|
|
50
|
+
name: 'Live states (Good → Breaking down)',
|
|
51
|
+
render: () => (
|
|
52
|
+
<View style={{ backgroundColor: PAGE_BG }}>
|
|
53
|
+
<View style={{ padding: 28, paddingBottom: 8, gap: 4 }}>
|
|
54
|
+
<Text
|
|
55
|
+
style={{
|
|
56
|
+
fontSize: 16,
|
|
57
|
+
fontWeight: '800',
|
|
58
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
59
|
+
color: t['text-primary'],
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
Live-view states — the system across the fatigue spectrum
|
|
63
|
+
</Text>
|
|
64
|
+
<Text style={{ fontSize: 12, color: t['text-secondary'], maxWidth: 860, lineHeight: 18 }}>
|
|
65
|
+
The live panel rendered per verdict state, each driven by a full plausible model — RPE,
|
|
66
|
+
the three status dots, the control-aware ghost line, and the ROM progression all respond
|
|
67
|
+
together. The aura flood tracks the state. Every panel is built from ONE truncation point,
|
|
68
|
+
so the ROM chart's upcoming reps and the velocity strip's beside it always agree
|
|
69
|
+
on the rep count.
|
|
70
|
+
</Text>
|
|
71
|
+
</View>
|
|
72
|
+
{FATIGUE_STATES.map((s) => {
|
|
73
|
+
const { model, velocity, header } = buildMockPanelState(s.current, {
|
|
74
|
+
rpe: s.model.rpe,
|
|
75
|
+
verdict: s.model.verdict,
|
|
76
|
+
})
|
|
77
|
+
return (
|
|
78
|
+
<View key={s.name} style={{ gap: 6, paddingHorizontal: 28, paddingBottom: 22 }}>
|
|
79
|
+
<Text
|
|
80
|
+
style={{
|
|
81
|
+
fontSize: 9,
|
|
82
|
+
letterSpacing: 1,
|
|
83
|
+
fontFamily: 'monospace',
|
|
84
|
+
color: t['text-tertiary'],
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{s.name}
|
|
88
|
+
</Text>
|
|
89
|
+
<LiveFatiguePanel model={model} velocity={velocity} header={header} />
|
|
90
|
+
</View>
|
|
91
|
+
)
|
|
92
|
+
})}
|
|
93
|
+
</View>
|
|
94
|
+
),
|
|
95
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* LiveFatiguePanel — the aligned "Live panel v2": the loss-relative {@link VelocityHero}
|
|
4
|
+
* (primary) beside the vertical {@link LiveFatigueCard} (secondary), flooded by a
|
|
5
|
+
* coaching {@link LiveAuraFrame} whose category tracks the verdict state. The optional
|
|
6
|
+
* lite exercise header sits above.
|
|
7
|
+
*
|
|
8
|
+
* The velocity hero's per-rep velocities are NOT on the fatigue model (they come from
|
|
9
|
+
* the live-view velocity path), so they're passed as their own `velocity` prop.
|
|
10
|
+
*/
|
|
11
|
+
import { View, Text } from 'react-native'
|
|
12
|
+
import { LiveAuraFrame, type LiveAuraCategory } from '../Workout/LiveAuraFrame'
|
|
13
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
14
|
+
import { alpha } from '../../../utils/colors'
|
|
15
|
+
import { VelocityHero } from './VelocityHero'
|
|
16
|
+
import { LiveFatigueCard } from './LiveFatigueCard'
|
|
17
|
+
import { FONT_HEAD, FONT_UI, FONT_MONO, auraForVerdict } from './fatigue-tokens'
|
|
18
|
+
import type { LiveFatigueModel } from './fatigue-model'
|
|
19
|
+
|
|
20
|
+
const t = getSemanticColors('dark')
|
|
21
|
+
|
|
22
|
+
export interface LiveFatiguePanelHeader {
|
|
23
|
+
/** Exercise name — the large title. */
|
|
24
|
+
title: string
|
|
25
|
+
/** Prescription / block line under the title. */
|
|
26
|
+
subtitle?: string
|
|
27
|
+
/** Right-aligned progress meta, e.g. "SET 3 · REP 7 / 8". */
|
|
28
|
+
meta?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LiveFatiguePanelVelocity {
|
|
32
|
+
/** Per-rep MEAN concentric velocity (m/s), ordered by rep. */
|
|
33
|
+
velocities: number[]
|
|
34
|
+
targetReps?: number
|
|
35
|
+
liveRepIndex?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LiveFatiguePanelProps {
|
|
39
|
+
/** The live fatigue read-model for the current set. */
|
|
40
|
+
model: LiveFatigueModel
|
|
41
|
+
/** The velocity-hero data (its own source — not on the fatigue model). */
|
|
42
|
+
velocity: LiveFatiguePanelVelocity
|
|
43
|
+
/** Optional lite exercise header. Omitted → no header row. */
|
|
44
|
+
header?: LiveFatiguePanelHeader
|
|
45
|
+
/** Aura-flood category. Defaults to the verdict-derived category. */
|
|
46
|
+
aura?: LiveAuraCategory
|
|
47
|
+
/** Panel body height in px. Default 508. */
|
|
48
|
+
bodyHeight?: number
|
|
49
|
+
/** Fixed fatigue-card column width. Default 318. */
|
|
50
|
+
cardWidth?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ExerciseHeaderLite({ header }: { header: LiveFatiguePanelHeader }) {
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
style={{
|
|
57
|
+
flexDirection: 'row',
|
|
58
|
+
alignItems: 'flex-end',
|
|
59
|
+
justifyContent: 'space-between',
|
|
60
|
+
paddingHorizontal: 24,
|
|
61
|
+
paddingTop: 18,
|
|
62
|
+
paddingBottom: 14,
|
|
63
|
+
borderBottomWidth: 1,
|
|
64
|
+
borderColor: alpha(t['text-primary'], 0.08),
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<View style={{ gap: 2 }}>
|
|
68
|
+
<Text
|
|
69
|
+
style={{
|
|
70
|
+
fontSize: 28,
|
|
71
|
+
fontWeight: '700',
|
|
72
|
+
fontFamily: FONT_HEAD,
|
|
73
|
+
color: t['text-primary'],
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{header.title}
|
|
77
|
+
</Text>
|
|
78
|
+
{header.subtitle && (
|
|
79
|
+
<Text
|
|
80
|
+
style={{
|
|
81
|
+
fontSize: 13,
|
|
82
|
+
fontWeight: '600',
|
|
83
|
+
fontFamily: FONT_UI,
|
|
84
|
+
color: t['text-secondary'],
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{header.subtitle}
|
|
88
|
+
</Text>
|
|
89
|
+
)}
|
|
90
|
+
</View>
|
|
91
|
+
{header.meta && (
|
|
92
|
+
<Text
|
|
93
|
+
style={{
|
|
94
|
+
fontSize: 13,
|
|
95
|
+
fontWeight: '800',
|
|
96
|
+
fontFamily: FONT_MONO,
|
|
97
|
+
color: t['text-tertiary'],
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{header.meta}
|
|
101
|
+
</Text>
|
|
102
|
+
)}
|
|
103
|
+
</View>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function LiveFatiguePanel({
|
|
108
|
+
model,
|
|
109
|
+
velocity,
|
|
110
|
+
header,
|
|
111
|
+
aura,
|
|
112
|
+
bodyHeight = 508,
|
|
113
|
+
cardWidth = 318,
|
|
114
|
+
}: LiveFatiguePanelProps) {
|
|
115
|
+
const category = aura ?? auraForVerdict(model.verdict?.state ?? null)
|
|
116
|
+
const heroH = bodyHeight - 26 // leaves room for the hero's own eyebrow above it
|
|
117
|
+
return (
|
|
118
|
+
<LiveAuraFrame
|
|
119
|
+
category={category}
|
|
120
|
+
style={{ borderRadius: 0, borderWidth: 0 }}
|
|
121
|
+
testID="live-fatigue-panel"
|
|
122
|
+
>
|
|
123
|
+
<View style={{ flex: 1 }}>
|
|
124
|
+
{header && <ExerciseHeaderLite header={header} />}
|
|
125
|
+
<View style={{ padding: 24, flexDirection: 'row', gap: 18, alignItems: 'stretch' }}>
|
|
126
|
+
{/* PRIMARY — the velocity hero with VL bands; flexes to fill the width the card leaves. */}
|
|
127
|
+
<View style={{ flex: 1, gap: 8 }}>
|
|
128
|
+
<Text
|
|
129
|
+
style={{
|
|
130
|
+
fontSize: 9,
|
|
131
|
+
letterSpacing: 1.2,
|
|
132
|
+
fontFamily: FONT_MONO,
|
|
133
|
+
color: t['text-tertiary'],
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
VELOCITY · this set
|
|
137
|
+
</Text>
|
|
138
|
+
<VelocityHero
|
|
139
|
+
velocities={velocity.velocities}
|
|
140
|
+
targetReps={velocity.targetReps}
|
|
141
|
+
liveRepIndex={velocity.liveRepIndex}
|
|
142
|
+
height={heroH}
|
|
143
|
+
/>
|
|
144
|
+
</View>
|
|
145
|
+
{/* SECONDARY — the vertical fatigue card. */}
|
|
146
|
+
<LiveFatigueCard model={model} width={cardWidth} height={bodyHeight} />
|
|
147
|
+
</View>
|
|
148
|
+
</View>
|
|
149
|
+
</LiveAuraFrame>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Fatigue component family
|
|
2
|
+
|
|
3
|
+
The aligned **"Live panel v2"** hardened into titan components: the live velocity
|
|
4
|
+
hero beside the always-on fatigue card. Everything consumes one read-model
|
|
5
|
+
(`LiveFatigueModel`) — a titan-local mirror of voltras-mcp's
|
|
6
|
+
`src/dashboard/spa/live-page/fatigue-model.ts`, sourced from real WA analytics
|
|
7
|
+
(`getSetFatigueVerdict` / `getSetWorkingROM` / per-sample telemetry). Files live flat;
|
|
8
|
+
the tier map below is a documentation contract, not a directory layout.
|
|
9
|
+
|
|
10
|
+
## Composition tree (composes-↓ / used-by-↑)
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
LiveFatiguePanel ← the composition (Live panel v2)
|
|
14
|
+
├─ LiveAuraFrame (Workout/ — coaching flood, category tracks the verdict)
|
|
15
|
+
├─ VelocityHero ← primary read
|
|
16
|
+
│ └─ VelocityStrip (Workout/ — reused as-is; VL bands overlaid on its peak scale)
|
|
17
|
+
└─ LiveFatigueCard ← secondary read (consumes LiveFatigueModel)
|
|
18
|
+
├─ VerdictHero (RPE number + verdict word, tone-flooded)
|
|
19
|
+
├─ FatigueLights (VEL/ROM/TEMPO "why" dots)
|
|
20
|
+
│ ├─ StatusDot (Workout/ — the dot primitive, glow)
|
|
21
|
+
│ └─ Tooltip (ui/tooltip — hover detail)
|
|
22
|
+
├─ RomProgressionChart (per-rep silver/red depth bars + reference lines)
|
|
23
|
+
├─ GhostSpark (per-rep velocity-time sparkline; tempo EMBEDDED)
|
|
24
|
+
│ ├─ GhostBand (the phase-coloured axis band — ECC/CON labelled inside)
|
|
25
|
+
│ └─ GhostBloom (the ghost fan + silver→red current line; orientation up/down)
|
|
26
|
+
├─ DualGhostSpark (dual-Voltra: ONE GhostBand + TWO mirrored GhostBlooms)
|
|
27
|
+
│ ├─ GhostBand (the SAME band, centred, shared by both devices)
|
|
28
|
+
│ └─ GhostBloom ×2 (left up / right down — a one-prop flip, one shared scale)
|
|
29
|
+
└─ Surface (ui/surface — the base-plane, paper-accented card ground)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Tier map
|
|
33
|
+
|
|
34
|
+
**Atoms** — `VerdictHero` · `FatigueLights` · `RomProgressionChart` · `GhostBand` · `GhostBloom`
|
|
35
|
+
**Molecules** — `VelocityHero` (VelocityStrip + VL bands) · `GhostSpark` (band + one bloom) ·
|
|
36
|
+
`DualGhostSpark` (one band + two mirrored blooms)
|
|
37
|
+
**Organisms** — `LiveFatigueCard` (the card, one data contract) · `LiveFatiguePanel` (hero + card + aura)
|
|
38
|
+
|
|
39
|
+
## Reuse audit — composed, not hand-rolled
|
|
40
|
+
|
|
41
|
+
- **`StatusDot`** (Workout/) — the fatigue lights are `StatusDot` (glow) + a mono label
|
|
42
|
+
inside a `Tooltip`; no re-implemented dot.
|
|
43
|
+
- **`Tooltip`** (ui/tooltip/) — the per-dimension hover detail.
|
|
44
|
+
- **`VelocityStrip`** (Workout/) — the hero reuses it verbatim; `VelocityHero` only adds
|
|
45
|
+
the loss-relative VL20/VL30 band overlay on the strip's own peak scale.
|
|
46
|
+
- **`LiveAuraFrame`** (Workout/) — the coaching flood.
|
|
47
|
+
- **`Surface`** (ui/surface/) — the card ground (`level="base"`, one step above the
|
|
48
|
+
`background` shell), separated by the alpha `hairline-default` edge and finished with
|
|
49
|
+
the shared `barPaper` accent; no hardcoded surface hex.
|
|
50
|
+
- **`GhostBand` / `GhostBloom`** — the single `GhostSpark` and the dual `DualGhostSpark`
|
|
51
|
+
compose the SAME band + bloom; the dual is the bloom with `orientation="down"`, not a
|
|
52
|
+
second renderer. No forked path / band / tint code, so a bloom improvement reaches both.
|
|
53
|
+
- **Tokens** — colours come from `getSemanticColors` / `primitiveRamps`; formatting from
|
|
54
|
+
`roundTempo`. No literal surface/status hex constants.
|
|
55
|
+
|
|
56
|
+
**No new top-level primitives promoted.** Every genuinely-new leaf here (verdict hero,
|
|
57
|
+
lights, ROM chart, ghost-spark) is fatigue-specific with a single consumer (the card),
|
|
58
|
+
so per the ≥2-consumer rule they stay in the family rather than becoming shared atoms.
|
|
59
|
+
The reusable pure helpers (`ghostLineColor`, `auraForVerdict`, `mixHex`) live in
|
|
60
|
+
`fatigue-tokens.ts`.
|
|
61
|
+
|
|
62
|
+
## Locked design calls (applied)
|
|
63
|
+
|
|
64
|
+
- **Velocity hero = loss-relative** — VL20/VL30 decision bands (not absolute velocity
|
|
65
|
+
zones). See _deferred_ below for the bar-fill recolour.
|
|
66
|
+
- **ROM chart + ghost line = ONE silver/red scheme** — silver when right, only SHADES OF
|
|
67
|
+
RED when there's an issue (no greens, no ambers). Shared constants `SILVER` /
|
|
68
|
+
`RED_LIGHT|MID|DEEP` live in `fatigue-tokens.ts`; both consumers import them. The ghost
|
|
69
|
+
line (`ghostLineColor`): controlled rep stays silver, dimming toward grey with
|
|
70
|
+
`tempoDeviation` (a drift cue, never a colour); a collapsing rep (`grindSignature ≥ 0.35`)
|
|
71
|
+
runs light→mid→deep red by severity. ROM bars: silver at/above working, light red below
|
|
72
|
+
working, deep red below the short threshold. (The verdict tones and the velocity-loss
|
|
73
|
+
VL20/VL30 bands are a SEPARATE language — unchanged.)
|
|
74
|
+
- **RPE only** — no reps-in-reserve line; the number + verdict word carry exertion state.
|
|
75
|
+
- **No separate top alert** — the card covers exertion state.
|
|
76
|
+
- **Tempo carried by the ghost-spark phase BAND** (`PHASE_AXIS_COLOR`) — no mini-tempo
|
|
77
|
+
digits, and no standalone hero-tempo component.
|
|
78
|
+
|
|
79
|
+
## Data plan / follow-ups
|
|
80
|
+
|
|
81
|
+
- **Store wiring:** `LiveFatigueCard` consumes `LiveFatigueModel` from voltras-mcp's
|
|
82
|
+
`mapStoreToFatigueModel` (`panels/fatigue-view.ts`), replacing the provisional
|
|
83
|
+
hand-rolled `live-page/FatigueCard.tsx` spike. The velocity hero's per-rep velocities
|
|
84
|
+
are NOT on the model (they come from the live-view velocity path) — passed as the
|
|
85
|
+
panel's separate `velocity` prop.
|
|
86
|
+
- **Deferred — VelocityStrip loss-relative bar fill:** the locked call also wants the
|
|
87
|
+
hero's BAR FILL recoloured gold→orange→red by velocity loss (not absolute zone).
|
|
88
|
+
VelocityStrip has no loss-relative colour mode; `VelocityHero` owns the band overlay
|
|
89
|
+
only. Recolouring needs a new `VelocityStrip` prop (a follow-up — not done here to keep
|
|
90
|
+
VelocityStrip reused as-is).
|
|
91
|
+
- **Deferred — velocity bar "grow up from bottom" animation:** the locked call wants the
|
|
92
|
+
live bar to grow from the baseline (tracking the rep) rather than the current
|
|
93
|
+
drop-in/pop. That is VelocityStrip-internal animation; a follow-up on VelocityStrip.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { RomProgressionChart } from './RomProgressionChart'
|
|
4
|
+
import { primitiveColors } from '../../../theme/tokens/primitives'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
|
+
import { FATIGUE_STATES, WARMING_UP_MODEL } from './fatigue-mock'
|
|
7
|
+
|
|
8
|
+
const PAGE_BG = primitiveColors.charcoal[900]
|
|
9
|
+
const t = getSemanticColors('dark')
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof RomProgressionChart> = {
|
|
12
|
+
title: 'Workout/Fatigue/ROM Progression',
|
|
13
|
+
component: RomProgressionChart,
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
parameters: {
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
'Per-rep depth bars (silver at/above working → light red below working range → deep red below the short threshold), ' +
|
|
20
|
+
'with dashed working-standard + short-threshold reference lines and a faint red short-zone. ' +
|
|
21
|
+
'Data-driven from absolute metres; the bars carry the read alone (no caption strip). ' +
|
|
22
|
+
'Composes the shared **SetBarChart** — same adaptive bar spacing as the velocity hero, and an optional ' +
|
|
23
|
+
'`plannedReps` draws the remaining reps as dashed to-do placeholders (the "N of M done" read).',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
argTypes: { barHeight: { control: { type: 'number', min: 24, max: 120, step: 2 } } },
|
|
28
|
+
}
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj<typeof RomProgressionChart>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mid-set with `plannedReps` — 4 of 10 done, the remaining 6 reps drawn as dashed to-do placeholders
|
|
34
|
+
* (the velocity hero's "N of M" read, now on the ROM chart). The working-standard + short-threshold
|
|
35
|
+
* lines still sit on-chart. Bars carry the shared SetBarChart adaptive spacing.
|
|
36
|
+
*/
|
|
37
|
+
export const PlannedToDo: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 22 }}>
|
|
40
|
+
{[
|
|
41
|
+
{ name: 'MID-SET · 4 OF 10', done: 4 },
|
|
42
|
+
{ name: 'FIRST REP · 1 OF 10', done: 1 },
|
|
43
|
+
{ name: 'PLANNED · 0 OF 10', done: 0 },
|
|
44
|
+
].map((s) => (
|
|
45
|
+
<View key={s.name} style={{ gap: 6, width: 320 }}>
|
|
46
|
+
<Text
|
|
47
|
+
style={{
|
|
48
|
+
fontSize: 9,
|
|
49
|
+
letterSpacing: 1,
|
|
50
|
+
fontFamily: 'monospace',
|
|
51
|
+
color: t['text-tertiary'],
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{s.name}
|
|
55
|
+
</Text>
|
|
56
|
+
<RomProgressionChart
|
|
57
|
+
points={[0.92, 0.9, 0.86, 0.81, 0.77, 0.72]
|
|
58
|
+
.slice(0, s.done)
|
|
59
|
+
.map((romM, i) => ({ repNumber: i + 1, romM }))}
|
|
60
|
+
workingStandardM={0.88}
|
|
61
|
+
shortThresholdM={0.66}
|
|
62
|
+
plannedReps={10}
|
|
63
|
+
/>
|
|
64
|
+
</View>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const AcrossStates: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
<View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 22 }}>
|
|
73
|
+
{[...FATIGUE_STATES, { name: 'WARMING UP', current: 0, model: WARMING_UP_MODEL }].map((s) => (
|
|
74
|
+
<View key={s.name} style={{ gap: 6, width: 300 }}>
|
|
75
|
+
<Text
|
|
76
|
+
style={{
|
|
77
|
+
fontSize: 9,
|
|
78
|
+
letterSpacing: 1,
|
|
79
|
+
fontFamily: 'monospace',
|
|
80
|
+
color: t['text-tertiary'],
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{s.name}
|
|
84
|
+
</Text>
|
|
85
|
+
<RomProgressionChart
|
|
86
|
+
points={s.model.romProgression}
|
|
87
|
+
workingStandardM={s.model.romWorkingStandardM}
|
|
88
|
+
shortThresholdM={s.model.romShortThresholdM}
|
|
89
|
+
/>
|
|
90
|
+
</View>
|
|
91
|
+
))}
|
|
92
|
+
</View>
|
|
93
|
+
),
|
|
94
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { RomProgressionChart } from './RomProgressionChart'
|
|
5
|
+
import type { RepRomPoint } from './fatigue-model'
|
|
6
|
+
|
|
7
|
+
const points: RepRomPoint[] = [
|
|
8
|
+
{ repNumber: 1, romM: 0.9 },
|
|
9
|
+
{ repNumber: 2, romM: 0.88 },
|
|
10
|
+
{ repNumber: 3, romM: 0.72 },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('RomProgressionChart', () => {
|
|
14
|
+
it('renders no caption strip — the bars carry the read alone', () => {
|
|
15
|
+
render(<RomProgressionChart points={points} workingStandardM={0.88} shortThresholdM={0.66} />)
|
|
16
|
+
expect(screen.queryByText(/depth vs working range/)).not.toBeInTheDocument()
|
|
17
|
+
expect(screen.queryByText(/^now /)).not.toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders the chart container', () => {
|
|
21
|
+
render(<RomProgressionChart points={points} workingStandardM={0.88} shortThresholdM={0.66} />)
|
|
22
|
+
expect(screen.getByTestId('rom-progression')).toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('has no accessibility violations', async () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<RomProgressionChart points={points} workingStandardM={0.88} shortThresholdM={0.66} />
|
|
28
|
+
)
|
|
29
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* RomProgressionChart — the per-rep depth progression on the shared silver/red scheme:
|
|
4
|
+
* silver bars at-or-above the working range, light red below it, deep red below the
|
|
5
|
+
* short threshold — with dashed working-standard (silver) + short-threshold (red)
|
|
6
|
+
* reference lines and a faint red short-zone. Every rep reads at full colour.
|
|
7
|
+
* The bars carry the whole read — no caption strip (the card's own hierarchy names it).
|
|
8
|
+
*
|
|
9
|
+
* Composes the shared {@link SetBarChart}: SetBarChart owns the geometry (value→height
|
|
10
|
+
* scaling, the plot-width-driven adaptive bar width / gap — the same rhythm as the velocity
|
|
11
|
+
* hero) and paints the reference overlay this chart hands it. When `plannedReps` exceeds the
|
|
12
|
+
* performed count the remaining reps draw as SetBarChart's default solid to-do sections — the
|
|
13
|
+
* same upcoming-rep treatment the velocity strip uses, so "3 of 8 done" reads identically in
|
|
14
|
+
* both charts. Bars scale to the tallest of {bars, working standard, short threshold} so the
|
|
15
|
+
* reference lines always sit on-chart; when no working standard is established yet (< 3 reps)
|
|
16
|
+
* the lines drop and every bar reads silver — the bars alone carry the shape.
|
|
17
|
+
*/
|
|
18
|
+
import { View } from 'react-native'
|
|
19
|
+
import { alpha } from '../../../utils/colors'
|
|
20
|
+
import { SetBarChart, type SetSlot, type SetBarGeometry } from '../charts/SetBarChart'
|
|
21
|
+
import { SILVER, RED_LIGHT, RED_DEEP } from './fatigue-tokens'
|
|
22
|
+
import type { RepRomPoint } from './fatigue-model'
|
|
23
|
+
|
|
24
|
+
export interface RomProgressionChartProps {
|
|
25
|
+
/** Per-rep ROM points (metres), ordered by rep. */
|
|
26
|
+
points: RepRomPoint[]
|
|
27
|
+
/** Working-range standard (metres) — the silver dashed line. `null` = not yet established. */
|
|
28
|
+
workingStandardM: number | null
|
|
29
|
+
/** Short threshold (metres) — the red dashed line + short-zone. `null` = not yet established. */
|
|
30
|
+
shortThresholdM: number | null
|
|
31
|
+
/** Height of the bar plot in px. Default 44. */
|
|
32
|
+
barHeight?: number
|
|
33
|
+
/** Keep the chart to a legible recent tail. Default 12. */
|
|
34
|
+
maxReps?: number
|
|
35
|
+
/**
|
|
36
|
+
* Planned rep count. Reps beyond the performed count draw as solid to-do sections — the
|
|
37
|
+
* velocity strip's "3 of 8 done" read. Ignored when absent or ≤ the performed-rep count.
|
|
38
|
+
*/
|
|
39
|
+
plannedReps?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A per-rep bar's colour (silver/red): deep red below short, light red below working, else silver. */
|
|
43
|
+
function barColor(romM: number, working: number | null, short: number | null): string {
|
|
44
|
+
if (short != null && romM < short) return RED_DEEP
|
|
45
|
+
if (working != null && romM < working) return RED_LIGHT
|
|
46
|
+
return SILVER
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The dashed working-standard + short-threshold lines and the faint short-zone fill, painted from chart geometry. */
|
|
50
|
+
function romReferenceOverlay(
|
|
51
|
+
g: SetBarGeometry,
|
|
52
|
+
workingStandardM: number | null,
|
|
53
|
+
shortThresholdM: number | null
|
|
54
|
+
) {
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{shortThresholdM != null && (
|
|
58
|
+
<View
|
|
59
|
+
pointerEvents="none"
|
|
60
|
+
style={{
|
|
61
|
+
position: 'absolute',
|
|
62
|
+
left: 0,
|
|
63
|
+
right: 0,
|
|
64
|
+
bottom: 0,
|
|
65
|
+
height: g.yOf(shortThresholdM),
|
|
66
|
+
backgroundColor: alpha(RED_DEEP, 0.06),
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
{workingStandardM != null && (
|
|
71
|
+
<View
|
|
72
|
+
pointerEvents="none"
|
|
73
|
+
style={{
|
|
74
|
+
position: 'absolute',
|
|
75
|
+
left: 0,
|
|
76
|
+
right: 0,
|
|
77
|
+
bottom: g.yOf(workingStandardM),
|
|
78
|
+
borderTopWidth: 1,
|
|
79
|
+
borderStyle: 'dashed',
|
|
80
|
+
borderColor: alpha(SILVER, 0.4),
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
{shortThresholdM != null && (
|
|
85
|
+
<View
|
|
86
|
+
pointerEvents="none"
|
|
87
|
+
style={{
|
|
88
|
+
position: 'absolute',
|
|
89
|
+
left: 0,
|
|
90
|
+
right: 0,
|
|
91
|
+
bottom: g.yOf(shortThresholdM),
|
|
92
|
+
borderTopWidth: 1,
|
|
93
|
+
borderStyle: 'dashed',
|
|
94
|
+
borderColor: alpha(RED_DEEP, 0.4),
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function RomProgressionChart({
|
|
103
|
+
points,
|
|
104
|
+
workingStandardM,
|
|
105
|
+
shortThresholdM,
|
|
106
|
+
barHeight = 44,
|
|
107
|
+
maxReps = 12,
|
|
108
|
+
plannedReps,
|
|
109
|
+
}: RomProgressionChartProps) {
|
|
110
|
+
const shown = points.slice(-maxReps)
|
|
111
|
+
const slots: SetSlot[] = shown.map((p) => ({ kind: 'rep', value: p.romM }))
|
|
112
|
+
// Scale to the tallest of {bars, working, short} so the reference lines always sit on-chart
|
|
113
|
+
// (SetBarChart's own peak scale reads only the bar values). `0` → let SetBarChart use peak.
|
|
114
|
+
const scaleMax =
|
|
115
|
+
Math.max(...shown.map((p) => p.romM), workingStandardM ?? 0, shortThresholdM ?? 0) || undefined
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<View testID="rom-progression">
|
|
119
|
+
<SetBarChart
|
|
120
|
+
slots={slots}
|
|
121
|
+
colorFor={(romM) => barColor(romM, workingStandardM, shortThresholdM)}
|
|
122
|
+
height={barHeight}
|
|
123
|
+
scaleMax={scaleMax}
|
|
124
|
+
barRadius={2}
|
|
125
|
+
hideBaseline
|
|
126
|
+
targetReps={plannedReps}
|
|
127
|
+
renderReference={(g) => romReferenceOverlay(g, workingStandardM, shortThresholdM)}
|
|
128
|
+
testIDPrefix="rom"
|
|
129
|
+
/>
|
|
130
|
+
</View>
|
|
131
|
+
)
|
|
132
|
+
}
|