@titan-design/react-ui 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +25 -5
- package/src/bodymap.ts +37 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +347 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +162 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +247 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +160 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +152 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
3
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
4
|
+
import { BodyMap, type BodyMapData } from './BodyMap'
|
|
5
|
+
import {
|
|
6
|
+
BodyMapDetailPanel,
|
|
7
|
+
type ContributingExercise,
|
|
8
|
+
type UpcomingExercise,
|
|
9
|
+
} from './BodyMapDetailPanel'
|
|
10
|
+
import { MesoStatusCard, type MesoStatusCardProps } from './MesoStatusCard'
|
|
11
|
+
import {
|
|
12
|
+
MuscleGroup,
|
|
13
|
+
VOLUME_STATUS_LABELS,
|
|
14
|
+
getHeatmapColor,
|
|
15
|
+
type VolumeLandmarks,
|
|
16
|
+
type VolumeStatus,
|
|
17
|
+
} from './muscleTaxonomy'
|
|
18
|
+
|
|
19
|
+
const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
|
|
20
|
+
const BORDER_DEFAULT = 'var(--color-border-default)'
|
|
21
|
+
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
22
|
+
const TEXT_SECONDARY = 'var(--color-text-secondary)'
|
|
23
|
+
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
24
|
+
const PAGE_BG = 'var(--color-background-base)'
|
|
25
|
+
|
|
26
|
+
/** Volume statuses in display order for the legend and summary cards. */
|
|
27
|
+
const STATUS_ORDER: VolumeStatus[] = ['under', 'maintenance', 'productive', 'over']
|
|
28
|
+
|
|
29
|
+
/** One muscle's full volume picture — feeds the body maps and the detail panel. */
|
|
30
|
+
export interface TrainingStatusMuscle {
|
|
31
|
+
/** Detailed muscle group (drives the SVG heatmap + panel identity). */
|
|
32
|
+
muscleGroup: MuscleGroup
|
|
33
|
+
/** Human-readable name for the legend, summary, and panel title. */
|
|
34
|
+
displayName: string
|
|
35
|
+
/** Which body-map view the muscle belongs to. */
|
|
36
|
+
side: 'front' | 'back'
|
|
37
|
+
/** Heatmap intensity 0-1. */
|
|
38
|
+
intensity: number
|
|
39
|
+
/** Weekly-volume status relative to the landmarks. */
|
|
40
|
+
volumeStatus: VolumeStatus
|
|
41
|
+
/** Weekly effective sets logged. */
|
|
42
|
+
weeklySets: number
|
|
43
|
+
/** Volume landmarks (weekly working sets). */
|
|
44
|
+
landmarks: VolumeLandmarks
|
|
45
|
+
/** Last trained label, e.g. "2 days ago". */
|
|
46
|
+
lastTrained?: string
|
|
47
|
+
/** Weekly volume history for the panel sparkline. */
|
|
48
|
+
weeklyHistory?: number[]
|
|
49
|
+
/** Exercises contributing to this muscle's volume. */
|
|
50
|
+
contributingExercises?: ContributingExercise[]
|
|
51
|
+
/** Upcoming exercises targeting this muscle. */
|
|
52
|
+
upcomingExercises?: UpcomingExercise[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Derived, page-level roll-up of the tracked muscles. */
|
|
56
|
+
export interface TrainingStatusSummary {
|
|
57
|
+
/** Total weekly effective sets across all tracked muscles. */
|
|
58
|
+
totalWeeklySets: number
|
|
59
|
+
/** Number of muscles being tracked. */
|
|
60
|
+
trackedMuscles: number
|
|
61
|
+
/** Count of muscles per volume status. */
|
|
62
|
+
statusCounts: Record<VolumeStatus, number>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TrainingStatusPageProps extends ViewProps {
|
|
66
|
+
/** Page heading. */
|
|
67
|
+
title?: string
|
|
68
|
+
/** Mesocycle context banner shown at the top. */
|
|
69
|
+
meso: MesoStatusCardProps
|
|
70
|
+
/** Muscles tracked this week. */
|
|
71
|
+
muscles: TrainingStatusMuscle[]
|
|
72
|
+
className?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Roll up per-muscle rows into the page summary (pure, testable). */
|
|
76
|
+
export function deriveTrainingSummary(muscles: TrainingStatusMuscle[]): TrainingStatusSummary {
|
|
77
|
+
const statusCounts: Record<VolumeStatus, number> = {
|
|
78
|
+
under: 0,
|
|
79
|
+
maintenance: 0,
|
|
80
|
+
productive: 0,
|
|
81
|
+
over: 0,
|
|
82
|
+
}
|
|
83
|
+
let totalWeeklySets = 0
|
|
84
|
+
for (const muscle of muscles) {
|
|
85
|
+
totalWeeklySets += muscle.weeklySets
|
|
86
|
+
statusCounts[muscle.volumeStatus] += 1
|
|
87
|
+
}
|
|
88
|
+
return { totalWeeklySets, trackedMuscles: muscles.length, statusCounts }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Project the muscle rows for one side into `BodyMap` heatmap data. */
|
|
92
|
+
export function toBodyMapData(
|
|
93
|
+
muscles: TrainingStatusMuscle[],
|
|
94
|
+
side: 'front' | 'back'
|
|
95
|
+
): BodyMapData[] {
|
|
96
|
+
return muscles
|
|
97
|
+
.filter((muscle) => muscle.side === side)
|
|
98
|
+
.map(({ muscleGroup, intensity, volumeStatus, weeklySets }) => ({
|
|
99
|
+
muscleGroup,
|
|
100
|
+
intensity,
|
|
101
|
+
volumeStatus,
|
|
102
|
+
weeklySets,
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SUMMARY_CARDS: Array<{
|
|
107
|
+
key: keyof TrainingStatusSummary['statusCounts'] | 'total'
|
|
108
|
+
label: string
|
|
109
|
+
}> = [
|
|
110
|
+
{ key: 'total', label: 'Weekly Sets' },
|
|
111
|
+
{ key: 'productive', label: 'Productive' },
|
|
112
|
+
{ key: 'under', label: 'Under' },
|
|
113
|
+
{ key: 'over', label: 'Over' },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
|
|
117
|
+
return (
|
|
118
|
+
<View className="flex-row flex-wrap" style={{ gap: 8 }} testID="training-status-page-summary">
|
|
119
|
+
{SUMMARY_CARDS.map(({ key, label }) => {
|
|
120
|
+
const value = key === 'total' ? summary.totalWeeklySets : summary.statusCounts[key]
|
|
121
|
+
return (
|
|
122
|
+
<View
|
|
123
|
+
key={key}
|
|
124
|
+
style={{
|
|
125
|
+
flexGrow: 1,
|
|
126
|
+
flexBasis: '46%',
|
|
127
|
+
padding: 12,
|
|
128
|
+
borderRadius: 10,
|
|
129
|
+
backgroundColor: SURFACE_ELEVATED,
|
|
130
|
+
borderWidth: 1,
|
|
131
|
+
borderColor: BORDER_DEFAULT,
|
|
132
|
+
}}
|
|
133
|
+
testID={`training-status-page-summary-${key}`}
|
|
134
|
+
>
|
|
135
|
+
<Text
|
|
136
|
+
style={{
|
|
137
|
+
fontSize: 22,
|
|
138
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
139
|
+
fontWeight: '700',
|
|
140
|
+
color: TEXT_PRIMARY,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{value}
|
|
144
|
+
</Text>
|
|
145
|
+
<Text
|
|
146
|
+
style={{
|
|
147
|
+
marginTop: 2,
|
|
148
|
+
fontSize: 11,
|
|
149
|
+
fontFamily: 'Inter, sans-serif',
|
|
150
|
+
fontWeight: '600',
|
|
151
|
+
letterSpacing: 0.4,
|
|
152
|
+
textTransform: 'uppercase',
|
|
153
|
+
color: TEXT_TERTIARY,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{label}
|
|
157
|
+
</Text>
|
|
158
|
+
</View>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</View>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function StatusLegend() {
|
|
166
|
+
return (
|
|
167
|
+
<View
|
|
168
|
+
className="flex-row flex-wrap"
|
|
169
|
+
style={{ gap: 10, justifyContent: 'center' }}
|
|
170
|
+
testID="training-status-page-legend"
|
|
171
|
+
>
|
|
172
|
+
{STATUS_ORDER.map((status) => (
|
|
173
|
+
<View
|
|
174
|
+
key={status}
|
|
175
|
+
className="flex-row items-center"
|
|
176
|
+
style={{ gap: 5 }}
|
|
177
|
+
testID={`training-status-page-legend-${status}`}
|
|
178
|
+
>
|
|
179
|
+
<View
|
|
180
|
+
style={{
|
|
181
|
+
width: 8,
|
|
182
|
+
height: 8,
|
|
183
|
+
borderRadius: 9999,
|
|
184
|
+
backgroundColor: getHeatmapColor(status, 0.6),
|
|
185
|
+
}}
|
|
186
|
+
accessibilityElementsHidden
|
|
187
|
+
/>
|
|
188
|
+
<Text
|
|
189
|
+
style={{
|
|
190
|
+
fontSize: 11,
|
|
191
|
+
fontFamily: 'Inter, sans-serif',
|
|
192
|
+
color: TEXT_SECONDARY,
|
|
193
|
+
textTransform: 'capitalize',
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
{VOLUME_STATUS_LABELS[status]}
|
|
197
|
+
</Text>
|
|
198
|
+
</View>
|
|
199
|
+
))}
|
|
200
|
+
</View>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface BodyMapColumnProps {
|
|
205
|
+
side: 'front' | 'back'
|
|
206
|
+
muscles: TrainingStatusMuscle[]
|
|
207
|
+
selected: MuscleGroup | null
|
|
208
|
+
onSelect: (muscle: MuscleGroup) => void
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function BodyMapColumn({ side, muscles, selected, onSelect }: BodyMapColumnProps) {
|
|
212
|
+
const data = useMemo(() => toBodyMapData(muscles, side), [muscles, side])
|
|
213
|
+
return (
|
|
214
|
+
<View style={{ flexGrow: 1, flexBasis: '46%' }} testID={`training-status-page-bodymap-${side}`}>
|
|
215
|
+
<BodyMap
|
|
216
|
+
data={data}
|
|
217
|
+
view={side}
|
|
218
|
+
onMusclePress={onSelect}
|
|
219
|
+
highlightedMuscle={selected}
|
|
220
|
+
mode="detailed"
|
|
221
|
+
/>
|
|
222
|
+
</View>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* TrainingStatusPage — a body-map training dashboard that composes the
|
|
228
|
+
* mesocycle context banner (`MesoStatusCard`), side-by-side front/back
|
|
229
|
+
* `BodyMap`s with a shared heatmap legend, per-status summary cards, and a
|
|
230
|
+
* tap-to-detail `BodyMapDetailPanel`. Tapping any muscle (SVG or legend chip)
|
|
231
|
+
* opens the slide-up panel for that group; the summary is derived from the
|
|
232
|
+
* supplied muscle rows.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* <TrainingStatusPage meso={meso} muscles={muscles} />
|
|
236
|
+
*/
|
|
237
|
+
export function TrainingStatusPage({
|
|
238
|
+
title = 'Training Status',
|
|
239
|
+
meso,
|
|
240
|
+
muscles,
|
|
241
|
+
className,
|
|
242
|
+
...props
|
|
243
|
+
}: TrainingStatusPageProps) {
|
|
244
|
+
const [selected, setSelected] = useState<MuscleGroup | null>(null)
|
|
245
|
+
const summary = useMemo(() => deriveTrainingSummary(muscles), [muscles])
|
|
246
|
+
const active = selected != null ? (muscles.find((m) => m.muscleGroup === selected) ?? null) : null
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<View
|
|
250
|
+
className={className}
|
|
251
|
+
style={{ position: 'relative', width: 390, backgroundColor: PAGE_BG }}
|
|
252
|
+
accessibilityRole={'main' as ViewProps['accessibilityRole']}
|
|
253
|
+
aria-label={title}
|
|
254
|
+
testID="training-status-page"
|
|
255
|
+
{...props}
|
|
256
|
+
>
|
|
257
|
+
<View style={{ padding: 16, gap: 16 }} testID="training-status-page-content">
|
|
258
|
+
<Text
|
|
259
|
+
accessibilityRole="header"
|
|
260
|
+
style={{
|
|
261
|
+
fontSize: 20,
|
|
262
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
263
|
+
fontWeight: '700',
|
|
264
|
+
color: TEXT_PRIMARY,
|
|
265
|
+
}}
|
|
266
|
+
testID="training-status-page-title"
|
|
267
|
+
>
|
|
268
|
+
{title}
|
|
269
|
+
</Text>
|
|
270
|
+
|
|
271
|
+
<MesoStatusCard {...meso} />
|
|
272
|
+
|
|
273
|
+
<SummaryCards summary={summary} />
|
|
274
|
+
|
|
275
|
+
<View
|
|
276
|
+
style={{
|
|
277
|
+
backgroundColor: SURFACE_ELEVATED,
|
|
278
|
+
borderWidth: 1,
|
|
279
|
+
borderColor: BORDER_DEFAULT,
|
|
280
|
+
borderRadius: 12,
|
|
281
|
+
padding: 12,
|
|
282
|
+
gap: 12,
|
|
283
|
+
}}
|
|
284
|
+
testID="training-status-page-bodymaps"
|
|
285
|
+
>
|
|
286
|
+
<View className="flex-row" style={{ gap: 8 }}>
|
|
287
|
+
<BodyMapColumn
|
|
288
|
+
side="front"
|
|
289
|
+
muscles={muscles}
|
|
290
|
+
selected={selected}
|
|
291
|
+
onSelect={setSelected}
|
|
292
|
+
/>
|
|
293
|
+
<BodyMapColumn
|
|
294
|
+
side="back"
|
|
295
|
+
muscles={muscles}
|
|
296
|
+
selected={selected}
|
|
297
|
+
onSelect={setSelected}
|
|
298
|
+
/>
|
|
299
|
+
</View>
|
|
300
|
+
<StatusLegend />
|
|
301
|
+
</View>
|
|
302
|
+
</View>
|
|
303
|
+
|
|
304
|
+
{active != null && (
|
|
305
|
+
<BodyMapDetailPanel
|
|
306
|
+
muscleGroup={active.muscleGroup}
|
|
307
|
+
displayName={active.displayName}
|
|
308
|
+
weeklySets={active.weeklySets}
|
|
309
|
+
landmarks={active.landmarks}
|
|
310
|
+
volumeStatus={active.volumeStatus}
|
|
311
|
+
lastTrained={active.lastTrained}
|
|
312
|
+
weeklyHistory={active.weeklyHistory}
|
|
313
|
+
contributingExercises={active.contributingExercises}
|
|
314
|
+
upcomingExercises={active.upcomingExercises}
|
|
315
|
+
isOpen
|
|
316
|
+
onClose={() => setSelected(null)}
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
</View>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { VelocityStrip } from './VelocityStrip'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof VelocityStrip> = {
|
|
7
|
+
title: 'Custom/Workout/VelocityStrip',
|
|
8
|
+
component: VelocityStrip,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
expanded: {
|
|
12
|
+
control: 'boolean',
|
|
13
|
+
description: 'Whether the strip is expanded into a bar chart',
|
|
14
|
+
},
|
|
15
|
+
variant: {
|
|
16
|
+
control: 'select',
|
|
17
|
+
options: ['full', 'mini'],
|
|
18
|
+
description: 'Display variant',
|
|
19
|
+
},
|
|
20
|
+
showInfo: {
|
|
21
|
+
control: 'boolean',
|
|
22
|
+
description: 'Whether to show the info row (zone name + loss %) in expanded mode',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof VelocityStrip>
|
|
29
|
+
|
|
30
|
+
const fastSet = [1.15, 1.12, 1.08, 1.05, 1.02]
|
|
31
|
+
const slowSet = [0.65, 0.58, 0.52, 0.48, 0.42]
|
|
32
|
+
const mixedSet = [1.1, 0.95, 0.82, 0.68, 0.55, 0.45]
|
|
33
|
+
const moderateSet = [0.88, 0.85, 0.82, 0.78, 0.76]
|
|
34
|
+
|
|
35
|
+
export const DefaultCollapsed: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
velocities: moderateSet,
|
|
38
|
+
onToggle: () => {},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const Expanded: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
velocities: moderateSet,
|
|
45
|
+
expanded: true,
|
|
46
|
+
onToggle: () => {},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ExpandedNoInfo: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
velocities: moderateSet,
|
|
53
|
+
expanded: true,
|
|
54
|
+
showInfo: false,
|
|
55
|
+
onToggle: () => {},
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const FastEasySet: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
velocities: fastSet,
|
|
62
|
+
expanded: true,
|
|
63
|
+
onToggle: () => {},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const SlowGrindingSet: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
velocities: slowSet,
|
|
70
|
+
expanded: true,
|
|
71
|
+
onToggle: () => {},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const MixedVelocities: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
velocities: mixedSet,
|
|
78
|
+
expanded: true,
|
|
79
|
+
onToggle: () => {},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const Mini: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
velocities: mixedSet,
|
|
86
|
+
variant: 'mini',
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function InteractiveVelocityStrip() {
|
|
91
|
+
const [expanded, setExpanded] = useState(false)
|
|
92
|
+
return (
|
|
93
|
+
<View style={{ width: 300, padding: 16 }}>
|
|
94
|
+
<VelocityStrip
|
|
95
|
+
velocities={moderateSet}
|
|
96
|
+
expanded={expanded}
|
|
97
|
+
onToggle={() => setExpanded(!expanded)}
|
|
98
|
+
onRepPress={(index, velocity) =>
|
|
99
|
+
console.log(`Rep ${index + 1}: ${velocity}`)
|
|
100
|
+
}
|
|
101
|
+
/>
|
|
102
|
+
</View>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const Interactive: Story = {
|
|
107
|
+
render: () => <InteractiveVelocityStrip />,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const AllProfiles: Story = {
|
|
111
|
+
render: () => (
|
|
112
|
+
<View style={{ gap: 24, padding: 16 }}>
|
|
113
|
+
<View style={{ gap: 8 }}>
|
|
114
|
+
<VelocityStrip velocities={fastSet} expanded onToggle={() => {}} />
|
|
115
|
+
<VelocityStrip
|
|
116
|
+
velocities={moderateSet}
|
|
117
|
+
expanded
|
|
118
|
+
onToggle={() => {}}
|
|
119
|
+
/>
|
|
120
|
+
<VelocityStrip velocities={slowSet} expanded onToggle={() => {}} />
|
|
121
|
+
<VelocityStrip velocities={mixedSet} expanded onToggle={() => {}} />
|
|
122
|
+
</View>
|
|
123
|
+
</View>
|
|
124
|
+
),
|
|
125
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import {
|
|
5
|
+
VelocityStrip,
|
|
6
|
+
getVelocityZoneColor,
|
|
7
|
+
getVelocityZoneName,
|
|
8
|
+
calculateVelocityLoss,
|
|
9
|
+
calculateMeanVelocity,
|
|
10
|
+
} from './VelocityStrip'
|
|
11
|
+
|
|
12
|
+
const sampleVelocities = [1.1, 0.95, 0.82, 0.68, 0.45]
|
|
13
|
+
|
|
14
|
+
describe('VelocityStrip', () => {
|
|
15
|
+
it('renders correct number of segments', () => {
|
|
16
|
+
render(<VelocityStrip velocities={sampleVelocities} />)
|
|
17
|
+
for (let i = 0; i < sampleVelocities.length; i++) {
|
|
18
|
+
expect(screen.getByTestId(`velocity-bar-${i}`)).toBeInTheDocument()
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders with expanded state', () => {
|
|
23
|
+
render(<VelocityStrip velocities={sampleVelocities} expanded />)
|
|
24
|
+
expect(screen.getByTestId('velocity-strip')).toBeInTheDocument()
|
|
25
|
+
expect(screen.getByTestId('velocity-info-row')).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('shows velocity labels when expanded', () => {
|
|
29
|
+
render(<VelocityStrip velocities={sampleVelocities} expanded />)
|
|
30
|
+
for (let i = 0; i < sampleVelocities.length; i++) {
|
|
31
|
+
expect(screen.getByTestId(`velocity-label-${i}`)).toBeInTheDocument()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('fires onToggle on press', () => {
|
|
36
|
+
const onToggle = vi.fn()
|
|
37
|
+
render(
|
|
38
|
+
<VelocityStrip velocities={sampleVelocities} onToggle={onToggle} />,
|
|
39
|
+
)
|
|
40
|
+
const pressable = screen.getByTestId('velocity-strip-pressable')
|
|
41
|
+
fireEvent.click(pressable)
|
|
42
|
+
expect(onToggle).toHaveBeenCalledOnce()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('fires onRepPress with correct index when bar is tapped', () => {
|
|
46
|
+
const onRepPress = vi.fn()
|
|
47
|
+
render(
|
|
48
|
+
<VelocityStrip
|
|
49
|
+
velocities={sampleVelocities}
|
|
50
|
+
expanded
|
|
51
|
+
onRepPress={onRepPress}
|
|
52
|
+
/>,
|
|
53
|
+
)
|
|
54
|
+
const bar = screen.getByTestId('velocity-bar-pressable-2')
|
|
55
|
+
fireEvent.click(bar)
|
|
56
|
+
expect(onRepPress).toHaveBeenCalledWith(2, 0.82)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('sets accessibility role and label', () => {
|
|
60
|
+
render(<VelocityStrip velocities={sampleVelocities} />)
|
|
61
|
+
expect(
|
|
62
|
+
screen.getByLabelText(
|
|
63
|
+
'Velocity chart for set, 5 reps, tap to expand',
|
|
64
|
+
),
|
|
65
|
+
).toBeInTheDocument()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('sets expanded accessibility label when expanded', () => {
|
|
69
|
+
render(<VelocityStrip velocities={sampleVelocities} expanded />)
|
|
70
|
+
expect(
|
|
71
|
+
screen.getByLabelText(
|
|
72
|
+
'Velocity chart for set, 5 reps, tap to collapse',
|
|
73
|
+
),
|
|
74
|
+
).toBeInTheDocument()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('showInfo prop', () => {
|
|
78
|
+
it('shows info row when expanded and showInfo is true (default)', () => {
|
|
79
|
+
render(<VelocityStrip velocities={sampleVelocities} expanded />)
|
|
80
|
+
expect(screen.getByTestId('velocity-info-row')).toBeInTheDocument()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('hides info row when expanded and showInfo is false', () => {
|
|
84
|
+
render(<VelocityStrip velocities={sampleVelocities} expanded showInfo={false} />)
|
|
85
|
+
expect(screen.queryByTestId('velocity-info-row')).not.toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not show info row when collapsed regardless of showInfo', () => {
|
|
89
|
+
render(<VelocityStrip velocities={sampleVelocities} showInfo />)
|
|
90
|
+
expect(screen.queryByTestId('velocity-info-row')).not.toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('mini variant', () => {
|
|
95
|
+
it('renders mini strip', () => {
|
|
96
|
+
render(<VelocityStrip velocities={sampleVelocities} variant="mini" />)
|
|
97
|
+
expect(screen.getByTestId('velocity-strip-mini')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does not respond to press', () => {
|
|
101
|
+
const onToggle = vi.fn()
|
|
102
|
+
render(
|
|
103
|
+
<VelocityStrip
|
|
104
|
+
velocities={sampleVelocities}
|
|
105
|
+
variant="mini"
|
|
106
|
+
onToggle={onToggle}
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
expect(
|
|
110
|
+
screen.queryByTestId('velocity-strip-pressable'),
|
|
111
|
+
).not.toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('renders correct number of segments', () => {
|
|
115
|
+
render(<VelocityStrip velocities={sampleVelocities} variant="mini" />)
|
|
116
|
+
for (let i = 0; i < sampleVelocities.length; i++) {
|
|
117
|
+
expect(screen.getByTestId(`velocity-bar-${i}`)).toBeInTheDocument()
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('accessibility', () => {
|
|
123
|
+
it('has no accessibility violations', async () => {
|
|
124
|
+
const { container } = render(
|
|
125
|
+
<VelocityStrip velocities={sampleVelocities} onToggle={() => {}} />,
|
|
126
|
+
)
|
|
127
|
+
const results = await axe(container)
|
|
128
|
+
expect(results).toHaveNoViolations()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('has no accessibility violations when expanded', async () => {
|
|
132
|
+
const { container } = render(
|
|
133
|
+
<VelocityStrip velocities={sampleVelocities} expanded onToggle={() => {}} />,
|
|
134
|
+
)
|
|
135
|
+
const results = await axe(container)
|
|
136
|
+
expect(results).toHaveNoViolations()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('has no accessibility violations when expanded with rep press', async () => {
|
|
140
|
+
const { container } = render(
|
|
141
|
+
<VelocityStrip
|
|
142
|
+
velocities={sampleVelocities}
|
|
143
|
+
expanded
|
|
144
|
+
onRepPress={() => {}}
|
|
145
|
+
/>,
|
|
146
|
+
)
|
|
147
|
+
const results = await axe(container)
|
|
148
|
+
expect(results).toHaveNoViolations()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('has no accessibility violations in mini variant', async () => {
|
|
152
|
+
const { container } = render(
|
|
153
|
+
<VelocityStrip velocities={sampleVelocities} variant="mini" />,
|
|
154
|
+
)
|
|
155
|
+
const results = await axe(container)
|
|
156
|
+
expect(results).toHaveNoViolations()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('outer pressable has accessibility label when onToggle provided', () => {
|
|
160
|
+
render(<VelocityStrip velocities={sampleVelocities} onToggle={() => {}} />)
|
|
161
|
+
expect(
|
|
162
|
+
screen.getByLabelText('Velocity chart for set, 5 reps, tap to expand'),
|
|
163
|
+
).toBeInTheDocument()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('rep pressables have accessibility labels when expanded with onRepPress', () => {
|
|
167
|
+
render(
|
|
168
|
+
<VelocityStrip
|
|
169
|
+
velocities={sampleVelocities}
|
|
170
|
+
expanded
|
|
171
|
+
onRepPress={() => {}}
|
|
172
|
+
/>,
|
|
173
|
+
)
|
|
174
|
+
expect(
|
|
175
|
+
screen.getByLabelText('Rep 1: 1.10 meters per second, tap for details'),
|
|
176
|
+
).toBeInTheDocument()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('getVelocityZoneColor', () => {
|
|
182
|
+
it('returns vel-green for velocities >= 1.0', () => {
|
|
183
|
+
expect(getVelocityZoneColor(1.0)).toBe('vel-green')
|
|
184
|
+
expect(getVelocityZoneColor(1.5)).toBe('vel-green')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('returns vel-yellow for velocities >= 0.75 and < 1.0', () => {
|
|
188
|
+
expect(getVelocityZoneColor(0.75)).toBe('vel-yellow')
|
|
189
|
+
expect(getVelocityZoneColor(0.99)).toBe('vel-yellow')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('returns vel-orange for velocities >= 0.50 and < 0.75', () => {
|
|
193
|
+
expect(getVelocityZoneColor(0.5)).toBe('vel-orange')
|
|
194
|
+
expect(getVelocityZoneColor(0.74)).toBe('vel-orange')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('returns vel-red for velocities < 0.50', () => {
|
|
198
|
+
expect(getVelocityZoneColor(0.49)).toBe('vel-red')
|
|
199
|
+
expect(getVelocityZoneColor(0.1)).toBe('vel-red')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('getVelocityZoneName', () => {
|
|
204
|
+
it('returns correct zone names', () => {
|
|
205
|
+
expect(getVelocityZoneName(1.2)).toBe('Speed')
|
|
206
|
+
expect(getVelocityZoneName(0.85)).toBe('Power')
|
|
207
|
+
expect(getVelocityZoneName(0.6)).toBe('Strength-Speed')
|
|
208
|
+
expect(getVelocityZoneName(0.3)).toBe('Strength')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('calculateVelocityLoss', () => {
|
|
213
|
+
it('calculates percentage loss from first to last rep', () => {
|
|
214
|
+
expect(calculateVelocityLoss([1.0, 0.8])).toBe(20)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('returns 0 for single rep', () => {
|
|
218
|
+
expect(calculateVelocityLoss([1.0])).toBe(0)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('returns 0 for empty array', () => {
|
|
222
|
+
expect(calculateVelocityLoss([])).toBe(0)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('calculateMeanVelocity', () => {
|
|
227
|
+
it('calculates mean of velocities', () => {
|
|
228
|
+
expect(calculateMeanVelocity([1.0, 0.8, 0.6])).toBeCloseTo(0.8)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('returns 0 for empty array', () => {
|
|
232
|
+
expect(calculateMeanVelocity([])).toBe(0)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('VelocityStrip all-zero velocities (NaN guard)', () => {
|
|
237
|
+
it('emits a valid 0% bar height (not NaN) when every velocity is zero (expanded)', () => {
|
|
238
|
+
render(<VelocityStrip velocities={[0, 0, 0]} expanded />)
|
|
239
|
+
// Without the guard, 0 / (0 * 1.15) === NaN and the bar height becomes
|
|
240
|
+
// 'NaN%' (dropped by jsdom, leaving no height); the guard flattens to '0%'.
|
|
241
|
+
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '0%' })
|
|
242
|
+
expect(screen.getByTestId('velocity-bar-2')).toHaveStyle({ height: '0%' })
|
|
243
|
+
})
|
|
244
|
+
})
|