@titan-design/react-ui 0.2.6 → 0.2.7
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/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -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 +16 -5
- 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/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/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 +348 -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 +478 -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 +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -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 +163 -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 +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -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 +91 -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/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- 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 +253 -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 +159 -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 +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -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 +226 -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 +113 -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/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/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { View, Pressable, Animated, type ViewProps } from 'react-native'
|
|
4
|
+
|
|
5
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
6
|
+
|
|
7
|
+
export type MesoStatus = 'completed' | 'current' | 'upcoming'
|
|
8
|
+
|
|
9
|
+
export interface Meso {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
weekCount: number
|
|
13
|
+
status: MesoStatus
|
|
14
|
+
/**
|
|
15
|
+
* 1-based week index of progress within a `current` meso.
|
|
16
|
+
* Drives the solid fill width. Ignored for non-current mesos.
|
|
17
|
+
*/
|
|
18
|
+
currentWeek?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MesoProgressBarProps extends ViewProps {
|
|
22
|
+
mesos: Meso[]
|
|
23
|
+
activeMesoId: string | null
|
|
24
|
+
onMesoPress: (mesoId: string) => void
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const segmentBgColors: Record<MesoStatus, string> = {
|
|
29
|
+
completed: 'rgba(20,184,166,0.3)',
|
|
30
|
+
current: 'rgba(255,121,0,0.5)',
|
|
31
|
+
upcoming: 'rgba(255,255,255,0.06)',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clampProgress(currentWeek: number | undefined, weekCount: number): number {
|
|
35
|
+
if (currentWeek == null || weekCount <= 0) return 0
|
|
36
|
+
const ratio = currentWeek / weekCount
|
|
37
|
+
return Math.max(0, Math.min(1, ratio))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SegmentProps {
|
|
41
|
+
meso: Meso
|
|
42
|
+
isActive: boolean
|
|
43
|
+
onPress: (id: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function MesoSegment({ meso, isActive, onPress }: SegmentProps) {
|
|
47
|
+
const [pressScale] = useState(() => new Animated.Value(1))
|
|
48
|
+
|
|
49
|
+
const handlePressIn = () => {
|
|
50
|
+
Animated.timing(pressScale, {
|
|
51
|
+
toValue: 0.98,
|
|
52
|
+
duration: 150,
|
|
53
|
+
useNativeDriver: true,
|
|
54
|
+
}).start()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handlePressOut = () => {
|
|
58
|
+
Animated.timing(pressScale, {
|
|
59
|
+
toValue: 1,
|
|
60
|
+
duration: 150,
|
|
61
|
+
useNativeDriver: true,
|
|
62
|
+
}).start()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const progress = meso.status === 'current'
|
|
66
|
+
? clampProgress(meso.currentWeek, meso.weekCount)
|
|
67
|
+
: 0
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Pressable
|
|
71
|
+
style={{ flex: meso.weekCount }}
|
|
72
|
+
onPress={() => onPress(meso.id)}
|
|
73
|
+
onPressIn={handlePressIn}
|
|
74
|
+
onPressOut={handlePressOut}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityLabel={`${meso.name}, ${meso.status}`}
|
|
77
|
+
testID={`meso-segment-${meso.id}`}
|
|
78
|
+
>
|
|
79
|
+
<Animated.View
|
|
80
|
+
style={[
|
|
81
|
+
{
|
|
82
|
+
height: 8,
|
|
83
|
+
borderRadius: 4,
|
|
84
|
+
overflow: 'hidden',
|
|
85
|
+
backgroundColor: segmentBgColors[meso.status],
|
|
86
|
+
transform: [{ scale: pressScale }],
|
|
87
|
+
},
|
|
88
|
+
isActive
|
|
89
|
+
? {
|
|
90
|
+
borderWidth: 2,
|
|
91
|
+
borderColor: BRAND_PRIMARY,
|
|
92
|
+
transform: [{ scale: 1.02 }, { scale: pressScale }],
|
|
93
|
+
}
|
|
94
|
+
: undefined,
|
|
95
|
+
]}
|
|
96
|
+
testID={`meso-segment-inner-${meso.id}`}
|
|
97
|
+
>
|
|
98
|
+
{meso.status === 'current' && progress > 0 && (
|
|
99
|
+
<View
|
|
100
|
+
style={{
|
|
101
|
+
position: 'absolute',
|
|
102
|
+
left: 0,
|
|
103
|
+
top: 0,
|
|
104
|
+
bottom: 0,
|
|
105
|
+
width: `${progress * 100}%`,
|
|
106
|
+
backgroundColor: BRAND_PRIMARY,
|
|
107
|
+
}}
|
|
108
|
+
testID={`meso-segment-fill-${meso.id}`}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
</Animated.View>
|
|
112
|
+
</Pressable>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Segmented horizontal bar of mesocycles. Each segment is tappable and its
|
|
118
|
+
* flex width is proportional to the meso's `weekCount`. The active segment is
|
|
119
|
+
* highlighted with a 2px brand border to stay in sync with MesoCard selection.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* <MesoProgressBar
|
|
123
|
+
* mesos={[
|
|
124
|
+
* { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
|
|
125
|
+
* { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
|
|
126
|
+
* { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
|
|
127
|
+
* ]}
|
|
128
|
+
* activeMesoId="m2"
|
|
129
|
+
* onMesoPress={(id) => console.log(id)}
|
|
130
|
+
* />
|
|
131
|
+
*/
|
|
132
|
+
export function MesoProgressBar({
|
|
133
|
+
mesos,
|
|
134
|
+
activeMesoId,
|
|
135
|
+
onMesoPress,
|
|
136
|
+
className,
|
|
137
|
+
...props
|
|
138
|
+
}: MesoProgressBarProps) {
|
|
139
|
+
return (
|
|
140
|
+
<View
|
|
141
|
+
className={className}
|
|
142
|
+
style={{
|
|
143
|
+
flexDirection: 'row',
|
|
144
|
+
gap: 2,
|
|
145
|
+
paddingHorizontal: 16,
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
}}
|
|
148
|
+
testID="meso-progress-bar"
|
|
149
|
+
{...props}
|
|
150
|
+
>
|
|
151
|
+
{mesos.map((meso) => (
|
|
152
|
+
<MesoSegment
|
|
153
|
+
key={meso.id}
|
|
154
|
+
meso={meso}
|
|
155
|
+
isActive={meso.id === activeMesoId}
|
|
156
|
+
onPress={onMesoPress}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</View>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { MesoStatusCard } from './MesoStatusCard'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MesoStatusCard> = {
|
|
6
|
+
title: 'Custom/Workout/MesoStatusCard',
|
|
7
|
+
component: MesoStatusCard,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
mesoName: {
|
|
11
|
+
control: 'text',
|
|
12
|
+
description: 'Mesocycle name shown in the header',
|
|
13
|
+
},
|
|
14
|
+
mesoSubtitle: {
|
|
15
|
+
control: 'text',
|
|
16
|
+
description: 'Context line, e.g. "Week 6 of 8 · Upper/Lower"',
|
|
17
|
+
},
|
|
18
|
+
statusBadge: {
|
|
19
|
+
control: 'object',
|
|
20
|
+
description: 'Header status pill { label, variant }',
|
|
21
|
+
},
|
|
22
|
+
metrics: {
|
|
23
|
+
control: 'object',
|
|
24
|
+
description: 'Prescription-vs-actual metrics, rendered as a 2x2 grid',
|
|
25
|
+
},
|
|
26
|
+
gauges: {
|
|
27
|
+
control: 'object',
|
|
28
|
+
description: 'Intensity / volume gauges (level 0-1, optional sublabel)',
|
|
29
|
+
},
|
|
30
|
+
coaching: {
|
|
31
|
+
control: 'object',
|
|
32
|
+
description: 'Optional coaching box with optional bold highlights',
|
|
33
|
+
},
|
|
34
|
+
nextTarget: {
|
|
35
|
+
control: 'object',
|
|
36
|
+
description: 'Optional next-session target box { icon, text }',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
decorators: [
|
|
40
|
+
(Story) => (
|
|
41
|
+
<View style={{ maxWidth: 380, padding: 16 }}>
|
|
42
|
+
<Story />
|
|
43
|
+
</View>
|
|
44
|
+
),
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default meta
|
|
49
|
+
type Story = StoryObj<typeof MesoStatusCard>
|
|
50
|
+
|
|
51
|
+
export const Default: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
mesoName: 'Hypertrophy Block',
|
|
54
|
+
mesoSubtitle: 'Week 6 of 8 · Upper/Lower',
|
|
55
|
+
statusBadge: { label: 'On Track', variant: 'success' },
|
|
56
|
+
metrics: [
|
|
57
|
+
{ label: 'Prescribed', value: '3×8 @ RPE 8' },
|
|
58
|
+
{ label: 'Actual', value: '3×8 @ 185 lbs' },
|
|
59
|
+
{ label: 'Volume', value: '+12% vs wk 1' },
|
|
60
|
+
{ label: 'e1RM', value: '232 lbs' },
|
|
61
|
+
],
|
|
62
|
+
gauges: [
|
|
63
|
+
{ label: 'Intensity', level: 0.68, sublabel: 'Actual' },
|
|
64
|
+
{ label: 'Volume', level: 0.82, sublabel: 'vs MRV' },
|
|
65
|
+
],
|
|
66
|
+
coaching: {
|
|
67
|
+
text: 'Velocity is holding well — add load next session to keep intensity progressive.',
|
|
68
|
+
highlights: ['add load'],
|
|
69
|
+
},
|
|
70
|
+
nextTarget: { icon: '→', text: 'Next: 190 lbs × 8 @ RPE 8.5' },
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Warning: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
...Default.args,
|
|
77
|
+
mesoName: 'Intensification',
|
|
78
|
+
mesoSubtitle: 'Week 7 of 8 · Push/Pull/Legs',
|
|
79
|
+
statusBadge: { label: 'Watch Fatigue', variant: 'warning' },
|
|
80
|
+
gauges: [
|
|
81
|
+
{ label: 'Intensity', level: 0.78, sublabel: 'Actual' },
|
|
82
|
+
{ label: 'Volume', level: 0.94, sublabel: 'vs MRV' },
|
|
83
|
+
],
|
|
84
|
+
coaching: {
|
|
85
|
+
text: 'Velocity loss is creeping up. Hold load and reduce one set to manage fatigue.',
|
|
86
|
+
highlights: ['Hold load', 'reduce one set'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const Overreaching: Story = {
|
|
92
|
+
args: {
|
|
93
|
+
...Default.args,
|
|
94
|
+
mesoName: 'Peaking Block',
|
|
95
|
+
mesoSubtitle: 'Week 8 of 8 · Upper/Lower',
|
|
96
|
+
statusBadge: { label: 'Deload Soon', variant: 'error' },
|
|
97
|
+
metrics: [
|
|
98
|
+
{ label: 'Prescribed', value: '4×5 @ RPE 9' },
|
|
99
|
+
{ label: 'Actual', value: '4×4 @ 205 lbs' },
|
|
100
|
+
{ label: 'Volume', value: '−8% vs wk 6' },
|
|
101
|
+
{ label: 'e1RM', value: '241 lbs' },
|
|
102
|
+
],
|
|
103
|
+
gauges: [
|
|
104
|
+
{ label: 'Intensity', level: 0.92, sublabel: 'Actual' },
|
|
105
|
+
{ label: 'Volume', level: 0.45, sublabel: 'vs MRV' },
|
|
106
|
+
],
|
|
107
|
+
coaching: {
|
|
108
|
+
text: 'Bar speed has dropped sharply across sessions. Schedule a deload next week.',
|
|
109
|
+
highlights: ['deload'],
|
|
110
|
+
},
|
|
111
|
+
nextTarget: { icon: '↓', text: 'Next: deload — 60% × 3×5' },
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const Minimal: Story = {
|
|
116
|
+
args: {
|
|
117
|
+
mesoName: 'Base Phase',
|
|
118
|
+
mesoSubtitle: 'Week 2 of 6 · Full Body',
|
|
119
|
+
statusBadge: { label: 'On Track', variant: 'success' },
|
|
120
|
+
metrics: [
|
|
121
|
+
{ label: 'Prescribed', value: '3×10 @ RPE 7' },
|
|
122
|
+
{ label: 'Actual', value: '3×10 @ 135 lbs' },
|
|
123
|
+
],
|
|
124
|
+
gauges: [{ label: 'Intensity', level: 0.5 }],
|
|
125
|
+
},
|
|
126
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { MesoStatusCard, type MesoStatusCardProps } from './MesoStatusCard'
|
|
5
|
+
|
|
6
|
+
const baseProps: MesoStatusCardProps = {
|
|
7
|
+
mesoName: 'Hypertrophy Block',
|
|
8
|
+
mesoSubtitle: 'Week 6 of 8 · Upper/Lower',
|
|
9
|
+
statusBadge: { label: 'On Track', variant: 'success' },
|
|
10
|
+
metrics: [
|
|
11
|
+
{ label: 'Prescribed', value: '3×8 @ RPE 8' },
|
|
12
|
+
{ label: 'Actual', value: '3×8 @ 185 lbs' },
|
|
13
|
+
{ label: 'Volume', value: '+12% vs wk 1' },
|
|
14
|
+
{ label: 'e1RM', value: '232 lbs' },
|
|
15
|
+
],
|
|
16
|
+
gauges: [
|
|
17
|
+
{ label: 'Intensity', level: 0.68, sublabel: 'Actual' },
|
|
18
|
+
{ label: 'Volume', level: 0.82, sublabel: 'vs MRV' },
|
|
19
|
+
],
|
|
20
|
+
coaching: {
|
|
21
|
+
text: 'Velocity is holding — add load next session.',
|
|
22
|
+
highlights: ['add load'],
|
|
23
|
+
},
|
|
24
|
+
nextTarget: { icon: '→', text: 'Next: 190 lbs × 8' },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('MesoStatusCard', () => {
|
|
28
|
+
describe('rendering', () => {
|
|
29
|
+
it('renders the card, name, and subtitle', () => {
|
|
30
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
31
|
+
expect(screen.getByTestId('meso-status-card')).toBeInTheDocument()
|
|
32
|
+
expect(screen.getByTestId('meso-status-card-name')).toHaveTextContent(
|
|
33
|
+
'Hypertrophy Block',
|
|
34
|
+
)
|
|
35
|
+
expect(screen.getByTestId('meso-status-card-subtitle')).toHaveTextContent(
|
|
36
|
+
'Week 6 of 8 · Upper/Lower',
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the 3px gradient top accent', () => {
|
|
41
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
42
|
+
expect(screen.getByTestId('meso-status-card-accent')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders the status badge with its label', () => {
|
|
46
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
47
|
+
expect(screen.getByTestId('meso-status-card-badge')).toHaveTextContent(
|
|
48
|
+
'On Track',
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('metrics grid', () => {
|
|
54
|
+
it('renders one cell per metric with label and value', () => {
|
|
55
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
56
|
+
const cells = screen.getAllByTestId('meso-status-card-metric')
|
|
57
|
+
expect(cells).toHaveLength(4)
|
|
58
|
+
expect(screen.getByText('Prescribed')).toBeInTheDocument()
|
|
59
|
+
expect(screen.getByText('3×8 @ 185 lbs')).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders every metric cell even when labels repeat (stable unique keys)', () => {
|
|
63
|
+
render(
|
|
64
|
+
<MesoStatusCard
|
|
65
|
+
{...baseProps}
|
|
66
|
+
metrics={[
|
|
67
|
+
{ label: 'Set', value: '1' },
|
|
68
|
+
{ label: 'Set', value: '2' },
|
|
69
|
+
]}
|
|
70
|
+
/>,
|
|
71
|
+
)
|
|
72
|
+
expect(screen.getAllByTestId('meso-status-card-metric')).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not render the metrics grid when empty', () => {
|
|
76
|
+
render(<MesoStatusCard {...baseProps} metrics={[]} />)
|
|
77
|
+
expect(
|
|
78
|
+
screen.queryByTestId('meso-status-card-metrics'),
|
|
79
|
+
).not.toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('gauges', () => {
|
|
84
|
+
it('renders one gauge per entry with a marker', () => {
|
|
85
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
86
|
+
expect(screen.getAllByTestId('meso-status-card-gauge')).toHaveLength(2)
|
|
87
|
+
expect(
|
|
88
|
+
screen.getAllByTestId('meso-status-card-gauge-marker'),
|
|
89
|
+
).toHaveLength(2)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('renders a 50% center line per gauge', () => {
|
|
93
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
94
|
+
const center = screen.getAllByTestId('meso-status-card-gauge-center')[0]
|
|
95
|
+
expect(center).toHaveStyle({ left: '50%' })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('exposes gauge level via progressbar value', () => {
|
|
99
|
+
render(
|
|
100
|
+
<MesoStatusCard
|
|
101
|
+
{...baseProps}
|
|
102
|
+
gauges={[{ label: 'Intensity', level: 0.7 }]}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
const gauge = screen.getByLabelText('Intensity: 70%')
|
|
106
|
+
expect(gauge).toHaveAttribute('role', 'progressbar')
|
|
107
|
+
expect(gauge).toHaveAttribute('aria-valuenow', '70')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('clamps out-of-range levels', () => {
|
|
111
|
+
render(
|
|
112
|
+
<MesoStatusCard
|
|
113
|
+
{...baseProps}
|
|
114
|
+
gauges={[{ label: 'Volume', level: 1.7 }]}
|
|
115
|
+
/>,
|
|
116
|
+
)
|
|
117
|
+
expect(screen.getByLabelText('Volume: 100%')).toBeInTheDocument()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('guards a non-finite level, rendering 0% instead of NaN%', () => {
|
|
121
|
+
render(
|
|
122
|
+
<MesoStatusCard
|
|
123
|
+
{...baseProps}
|
|
124
|
+
gauges={[{ label: 'Intensity', level: Number.NaN }]}
|
|
125
|
+
/>,
|
|
126
|
+
)
|
|
127
|
+
expect(screen.getByLabelText('Intensity: 0%')).toBeInTheDocument()
|
|
128
|
+
expect(screen.queryByLabelText('Intensity: NaN%')).not.toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders every gauge even when labels repeat (stable unique keys)', () => {
|
|
132
|
+
render(
|
|
133
|
+
<MesoStatusCard
|
|
134
|
+
{...baseProps}
|
|
135
|
+
gauges={[
|
|
136
|
+
{ label: 'Load', level: 0.3 },
|
|
137
|
+
{ label: 'Load', level: 0.6 },
|
|
138
|
+
]}
|
|
139
|
+
/>,
|
|
140
|
+
)
|
|
141
|
+
expect(screen.getAllByTestId('meso-status-card-gauge')).toHaveLength(2)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('coaching', () => {
|
|
146
|
+
it('renders coaching copy with bolded highlights', () => {
|
|
147
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
148
|
+
const box = screen.getByTestId('meso-status-card-coaching')
|
|
149
|
+
expect(box).toHaveTextContent('Velocity is holding — add load next session.')
|
|
150
|
+
expect(screen.getByText('add load')).toBeInTheDocument()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('omits the coaching box when not provided', () => {
|
|
154
|
+
render(<MesoStatusCard {...baseProps} coaching={undefined} />)
|
|
155
|
+
expect(
|
|
156
|
+
screen.queryByTestId('meso-status-card-coaching'),
|
|
157
|
+
).not.toBeInTheDocument()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('next target', () => {
|
|
162
|
+
it('renders the next-target box with icon and text', () => {
|
|
163
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
164
|
+
const box = screen.getByTestId('meso-status-card-next-target')
|
|
165
|
+
expect(box).toHaveTextContent('Next: 190 lbs × 8')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('omits the next-target box when not provided', () => {
|
|
169
|
+
render(<MesoStatusCard {...baseProps} nextTarget={undefined} />)
|
|
170
|
+
expect(
|
|
171
|
+
screen.queryByTestId('meso-status-card-next-target'),
|
|
172
|
+
).not.toBeInTheDocument()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('accessibility', () => {
|
|
177
|
+
it('exposes the card as an article with a descriptive label', () => {
|
|
178
|
+
render(<MesoStatusCard {...baseProps} />)
|
|
179
|
+
const article = screen.getByRole('article')
|
|
180
|
+
expect(article).toHaveAttribute(
|
|
181
|
+
'aria-label',
|
|
182
|
+
'Mesocycle status for Hypertrophy Block',
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('has no accessibility violations', async () => {
|
|
187
|
+
const { container } = render(<MesoStatusCard {...baseProps} />)
|
|
188
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('has no accessibility violations with all features and every badge variant', async () => {
|
|
192
|
+
const { container } = render(
|
|
193
|
+
<>
|
|
194
|
+
<MesoStatusCard
|
|
195
|
+
{...baseProps}
|
|
196
|
+
statusBadge={{ label: 'Watch Fatigue', variant: 'warning' }}
|
|
197
|
+
/>
|
|
198
|
+
<MesoStatusCard
|
|
199
|
+
{...baseProps}
|
|
200
|
+
statusBadge={{ label: 'Deload Soon', variant: 'error' }}
|
|
201
|
+
coaching={undefined}
|
|
202
|
+
nextTarget={undefined}
|
|
203
|
+
/>
|
|
204
|
+
</>,
|
|
205
|
+
)
|
|
206
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
})
|