@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.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,90 @@
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 { TempoDisplay } from './TempoDisplay'
5
+
6
+ // tempo = [eccentric, pauseBottom, concentric, pauseTop]
7
+ describe('TempoDisplay', () => {
8
+ it('renders tempo values', () => {
9
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
10
+ expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
11
+ expect(screen.getByText('3')).toBeInTheDocument()
12
+ })
13
+
14
+ it('renders the TEMPO label prefix', () => {
15
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
16
+ expect(screen.getByText('TEMPO')).toBeInTheDocument()
17
+ })
18
+
19
+ it('renders colored phases by default', () => {
20
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
21
+ const tempoValue = screen.getByTestId('tempo-value')
22
+ expect(tempoValue.children.length).toBeGreaterThan(1)
23
+ })
24
+
25
+ it('renders mono variant', () => {
26
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />)
27
+ expect(screen.getByText('3-1-1-0')).toBeInTheDocument()
28
+ })
29
+
30
+ it('renders order: eccentric-pauseBottom-concentric-pauseTop', () => {
31
+ render(<TempoDisplay tempo={[4, 3, 2, 1]} colored={false} />)
32
+ expect(screen.getByText('4-3-2-1')).toBeInTheDocument()
33
+ })
34
+
35
+ it('has correct accessibility label', () => {
36
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
37
+ expect(
38
+ screen.getByLabelText(
39
+ 'Tempo: 3 second eccentric, 1 second pause, 1 second concentric, 0 second pause'
40
+ )
41
+ ).toBeInTheDocument()
42
+ })
43
+
44
+ it('is always a Pressable for tooltip', () => {
45
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
46
+ const display = screen.getByTestId('tempo-display')
47
+ expect(display).toBeInTheDocument()
48
+ })
49
+
50
+ it('calls onPress when pressed', () => {
51
+ const onPress = vi.fn()
52
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} onPress={onPress} />)
53
+ const display = screen.getByTestId('tempo-display')
54
+ fireEvent.click(display)
55
+ expect(onPress).toHaveBeenCalledOnce()
56
+ })
57
+
58
+ it('toggles tooltip on press', () => {
59
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
60
+ expect(screen.queryByTestId('tempo-tooltip')).not.toBeInTheDocument()
61
+ fireEvent.click(screen.getByTestId('tempo-display'))
62
+ expect(screen.getByTestId('tempo-tooltip')).toBeInTheDocument()
63
+ })
64
+
65
+ it('does not show tooltip when showInfo is false', () => {
66
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} showInfo={false} />)
67
+ fireEvent.click(screen.getByTestId('tempo-display'))
68
+ expect(screen.queryByTestId('tempo-tooltip')).not.toBeInTheDocument()
69
+ })
70
+
71
+ describe('sizes', () => {
72
+ it('renders at sm size', () => {
73
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" />)
74
+ expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
75
+ })
76
+
77
+ it('renders at md size', () => {
78
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} size="md" />)
79
+ expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
80
+ })
81
+ })
82
+
83
+ describe('accessibility', () => {
84
+ it('has no accessibility violations', async () => {
85
+ const { container } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
86
+ const results = await axe(container)
87
+ expect(results).toHaveNoViolations()
88
+ })
89
+ })
90
+ })
@@ -0,0 +1,209 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState, useCallback } from 'react'
3
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
4
+ import { roundTempo } from '../../../utils/workout-format'
5
+
6
+ export interface TempoDisplayProps extends ViewProps {
7
+ /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
8
+ tempo: [number, number, number, number]
9
+ size?: 'sm' | 'md'
10
+ /** Colored phases or mono (all gray) */
11
+ colored?: boolean
12
+ /** Show info tooltip on press */
13
+ showInfo?: boolean
14
+ onPress?: () => void
15
+ className?: string
16
+ }
17
+
18
+ const INTER = 'Inter, sans-serif'
19
+ const TEXT_TERTIARY = '#6B7280'
20
+
21
+ // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
22
+ const phaseColors = {
23
+ eccentric: '#FFB020', // status-warning (amber)
24
+ pauseBottom: TEXT_TERTIARY,
25
+ concentric: '#14B8A6', // status-success (teal)
26
+ pauseTop: TEXT_TERTIARY,
27
+ dash: TEXT_TERTIARY,
28
+ }
29
+
30
+ function TempoValue({
31
+ value,
32
+ color,
33
+ fontSize,
34
+ }: {
35
+ value: number
36
+ color: string
37
+ fontSize: number
38
+ }) {
39
+ return (
40
+ <Text
41
+ style={{
42
+ fontFamily: INTER,
43
+ fontSize,
44
+ color,
45
+ fontWeight: '600',
46
+ letterSpacing: 1,
47
+ }}
48
+ >
49
+ {value}
50
+ </Text>
51
+ )
52
+ }
53
+
54
+ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }) {
55
+ return (
56
+ <Text
57
+ style={{
58
+ fontFamily: INTER,
59
+ fontSize,
60
+ color,
61
+ fontWeight: '600',
62
+ letterSpacing: 1,
63
+ }}
64
+ >
65
+ -
66
+ </Text>
67
+ )
68
+ }
69
+
70
+ export function TempoDisplay({
71
+ tempo,
72
+ size = 'md',
73
+ colored = true,
74
+ showInfo = true,
75
+ onPress,
76
+ className,
77
+ ...props
78
+ }: TempoDisplayProps) {
79
+ const [showTooltip, setShowTooltip] = useState(false)
80
+ // Round exact (unrounded) tempo seconds to the 1-dp display granularity.
81
+ const [eccentric, pauseBottom, concentric, pauseTop] = roundTempo(tempo)
82
+ const isSm = size === 'sm'
83
+ const fontSize = isSm ? 9 : 11
84
+ const monoColor = TEXT_TERTIARY
85
+
86
+ const handlePress = useCallback(() => {
87
+ if (onPress) {
88
+ onPress()
89
+ }
90
+ if (showInfo) {
91
+ setShowTooltip((prev) => !prev)
92
+ }
93
+ }, [onPress, showInfo])
94
+
95
+ const content = (
96
+ <View
97
+ className={className}
98
+ style={{
99
+ flexDirection: 'row',
100
+ alignItems: 'center',
101
+ backgroundColor: '#1C1C1C',
102
+ paddingHorizontal: isSm ? 6 : 8,
103
+ paddingVertical: 3,
104
+ borderRadius: 4,
105
+ }}
106
+ {...props}
107
+ >
108
+ <Text
109
+ style={{
110
+ fontFamily: INTER,
111
+ fontSize: 9,
112
+ fontWeight: '500',
113
+ color: TEXT_TERTIARY,
114
+ letterSpacing: 0.5,
115
+ textTransform: 'uppercase',
116
+ marginRight: 6,
117
+ }}
118
+ >
119
+ TEMPO
120
+ </Text>
121
+ <View style={{ flexDirection: 'row' }} testID="tempo-value">
122
+ {colored ? (
123
+ <>
124
+ <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
125
+ <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
126
+ <TempoValue value={pauseBottom} color={phaseColors.pauseBottom} fontSize={fontSize} />
127
+ <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
128
+ <TempoValue value={concentric} color={phaseColors.concentric} fontSize={fontSize} />
129
+ <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
130
+ <TempoValue value={pauseTop} color={phaseColors.pauseTop} fontSize={fontSize} />
131
+ </>
132
+ ) : (
133
+ <Text
134
+ style={{
135
+ fontFamily: INTER,
136
+ fontSize,
137
+ color: monoColor,
138
+ fontWeight: '600',
139
+ letterSpacing: 1,
140
+ }}
141
+ >
142
+ {eccentric}-{pauseBottom}-{concentric}-{pauseTop}
143
+ </Text>
144
+ )}
145
+ </View>
146
+ </View>
147
+ )
148
+
149
+ return (
150
+ <Pressable
151
+ accessibilityLabel={`Tempo: ${eccentric} second eccentric, ${pauseBottom} second pause, ${concentric} second concentric, ${pauseTop} second pause`}
152
+ accessibilityRole="button"
153
+ onPress={handlePress}
154
+ testID="tempo-display"
155
+ >
156
+ {content}
157
+ {showTooltip && (
158
+ <View
159
+ style={{
160
+ position: 'absolute',
161
+ bottom: '100%',
162
+ left: '50%',
163
+ transform: [{ translateX: '-50%' as unknown as number }],
164
+ marginBottom: 8,
165
+ zIndex: 20,
166
+ alignItems: 'center',
167
+ }}
168
+ testID="tempo-tooltip"
169
+ >
170
+ <View
171
+ style={{
172
+ backgroundColor: '#191919',
173
+ borderRadius: 6,
174
+ paddingVertical: 8,
175
+ paddingHorizontal: 12,
176
+ borderWidth: 1,
177
+ borderColor: '#2C2C2C',
178
+ }}
179
+ >
180
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
181
+ Eccentric: {eccentric}s
182
+ </Text>
183
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
184
+ Pause (bottom): {pauseBottom}s
185
+ </Text>
186
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
187
+ Concentric: {concentric}s
188
+ </Text>
189
+ <Text style={{ fontSize: 10, lineHeight: 16, color: '#9CA3AF', fontFamily: INTER }}>
190
+ Pause (top): {pauseTop}s
191
+ </Text>
192
+ </View>
193
+ <View
194
+ style={{
195
+ width: 0,
196
+ height: 0,
197
+ borderLeftWidth: 5,
198
+ borderRightWidth: 5,
199
+ borderTopWidth: 5,
200
+ borderLeftColor: 'transparent',
201
+ borderRightColor: 'transparent',
202
+ borderTopColor: '#2C2C2C',
203
+ }}
204
+ />
205
+ </View>
206
+ )}
207
+ </Pressable>
208
+ )
209
+ }
@@ -0,0 +1,212 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { TrainingStatusPage, type TrainingStatusMuscle } from './TrainingStatusPage'
3
+ import { MesoStatusCardProps } from './MesoStatusCard'
4
+ import { MuscleGroup } from './muscleTaxonomy'
5
+
6
+ const meso: MesoStatusCardProps = {
7
+ mesoName: 'Hypertrophy Block',
8
+ mesoSubtitle: 'Week 6 of 8 · Upper/Lower',
9
+ statusBadge: { label: 'On Track', variant: 'success' },
10
+ metrics: [
11
+ { label: 'Weekly Volume', value: '112 sets' },
12
+ { label: 'Avg Intensity', value: 'RPE 8.2' },
13
+ { label: 'Sessions', value: '5 / 5' },
14
+ { label: 'Deload', value: 'in 2 weeks' },
15
+ ],
16
+ gauges: [
17
+ { label: 'Volume', level: 0.68, sublabel: 'Actual' },
18
+ { label: 'Fatigue', level: 0.55, sublabel: 'Managed' },
19
+ ],
20
+ coaching: {
21
+ text: 'Push volume on lagging back and rear delts before the deload.',
22
+ highlights: ['back and rear delts'],
23
+ },
24
+ nextTarget: { icon: '🎯', text: 'Add 2 sets to pulling this week' },
25
+ }
26
+
27
+ const muscles: TrainingStatusMuscle[] = [
28
+ {
29
+ muscleGroup: MuscleGroup.CHEST,
30
+ displayName: 'Chest',
31
+ side: 'front',
32
+ intensity: 0.72,
33
+ volumeStatus: 'productive',
34
+ weeklySets: 14,
35
+ landmarks: { mev: 8, mav: 14, mrv: 20 },
36
+ lastTrained: '2 days ago',
37
+ weeklyHistory: [8, 10, 12, 11, 13, 14],
38
+ contributingExercises: [
39
+ { name: 'Barbell Bench Press', sets: 4, contributionWeight: 1 },
40
+ { name: 'Incline Dumbbell Press', sets: 3, contributionWeight: 1 },
41
+ { name: 'Cable Fly', sets: 3, contributionWeight: 0.75 },
42
+ ],
43
+ upcomingExercises: [{ name: 'Machine Chest Press', workoutName: 'Push B', sets: 3 }],
44
+ },
45
+ {
46
+ muscleGroup: MuscleGroup.FRONT_DELTS,
47
+ displayName: 'Front Delts',
48
+ side: 'front',
49
+ intensity: 0.45,
50
+ volumeStatus: 'maintenance',
51
+ weeklySets: 5,
52
+ landmarks: { mev: 2, mav: 6, mrv: 10 },
53
+ lastTrained: '2 days ago',
54
+ weeklyHistory: [4, 5, 5, 5],
55
+ },
56
+ {
57
+ muscleGroup: MuscleGroup.SIDE_DELTS,
58
+ displayName: 'Side Delts',
59
+ side: 'front',
60
+ intensity: 0.6,
61
+ volumeStatus: 'productive',
62
+ weeklySets: 12,
63
+ landmarks: { mev: 6, mav: 14, mrv: 22 },
64
+ lastTrained: '1 day ago',
65
+ weeklyHistory: [8, 9, 10, 11, 12],
66
+ },
67
+ {
68
+ muscleGroup: MuscleGroup.BICEPS,
69
+ displayName: 'Biceps',
70
+ side: 'front',
71
+ intensity: 0.3,
72
+ volumeStatus: 'under',
73
+ weeklySets: 3,
74
+ landmarks: { mev: 4, mav: 10, mrv: 18 },
75
+ lastTrained: '4 days ago',
76
+ weeklyHistory: [2, 3, 3, 3],
77
+ },
78
+ {
79
+ muscleGroup: MuscleGroup.ABS,
80
+ displayName: 'Abs',
81
+ side: 'front',
82
+ intensity: 0.4,
83
+ volumeStatus: 'maintenance',
84
+ weeklySets: 6,
85
+ landmarks: { mev: 0, mav: 8, mrv: 16 },
86
+ lastTrained: '1 day ago',
87
+ },
88
+ {
89
+ muscleGroup: MuscleGroup.QUADS,
90
+ displayName: 'Quads',
91
+ side: 'front',
92
+ intensity: 0.95,
93
+ volumeStatus: 'over',
94
+ weeklySets: 20,
95
+ landmarks: { mev: 6, mav: 12, mrv: 18 },
96
+ lastTrained: 'today',
97
+ weeklyHistory: [12, 14, 16, 18, 20],
98
+ contributingExercises: [
99
+ { name: 'Back Squat', sets: 5, contributionWeight: 1 },
100
+ { name: 'Leg Press', sets: 4, contributionWeight: 0.85 },
101
+ ],
102
+ },
103
+ {
104
+ muscleGroup: MuscleGroup.LATS,
105
+ displayName: 'Lats',
106
+ side: 'back',
107
+ intensity: 0.58,
108
+ volumeStatus: 'productive',
109
+ weeklySets: 11,
110
+ landmarks: { mev: 8, mav: 14, mrv: 20 },
111
+ lastTrained: '3 days ago',
112
+ weeklyHistory: [7, 8, 9, 10, 11],
113
+ contributingExercises: [
114
+ { name: 'Pull-Up', sets: 4, contributionWeight: 1 },
115
+ { name: 'Barbell Row', sets: 4, contributionWeight: 0.9 },
116
+ ],
117
+ },
118
+ {
119
+ muscleGroup: MuscleGroup.UPPER_BACK,
120
+ displayName: 'Upper Back',
121
+ side: 'back',
122
+ intensity: 0.5,
123
+ volumeStatus: 'maintenance',
124
+ weeklySets: 8,
125
+ landmarks: { mev: 6, mav: 12, mrv: 18 },
126
+ lastTrained: '3 days ago',
127
+ },
128
+ {
129
+ muscleGroup: MuscleGroup.REAR_DELTS,
130
+ displayName: 'Rear Delts',
131
+ side: 'back',
132
+ intensity: 0.28,
133
+ volumeStatus: 'under',
134
+ weeklySets: 4,
135
+ landmarks: { mev: 6, mav: 12, mrv: 18 },
136
+ lastTrained: '5 days ago',
137
+ weeklyHistory: [2, 3, 3, 4],
138
+ },
139
+ {
140
+ muscleGroup: MuscleGroup.TRICEPS,
141
+ displayName: 'Triceps',
142
+ side: 'back',
143
+ intensity: 0.65,
144
+ volumeStatus: 'productive',
145
+ weeklySets: 9,
146
+ landmarks: { mev: 4, mav: 8, mrv: 14 },
147
+ lastTrained: '2 days ago',
148
+ weeklyHistory: [6, 7, 8, 9],
149
+ },
150
+ {
151
+ muscleGroup: MuscleGroup.GLUTES,
152
+ displayName: 'Glutes',
153
+ side: 'back',
154
+ intensity: 0.7,
155
+ volumeStatus: 'productive',
156
+ weeklySets: 10,
157
+ landmarks: { mev: 4, mav: 10, mrv: 16 },
158
+ lastTrained: 'today',
159
+ },
160
+ {
161
+ muscleGroup: MuscleGroup.HAMSTRINGS,
162
+ displayName: 'Hamstrings',
163
+ side: 'back',
164
+ intensity: 0.55,
165
+ volumeStatus: 'productive',
166
+ weeklySets: 9,
167
+ landmarks: { mev: 4, mav: 10, mrv: 16 },
168
+ lastTrained: 'today',
169
+ weeklyHistory: [6, 7, 8, 9],
170
+ },
171
+ {
172
+ muscleGroup: MuscleGroup.CALVES,
173
+ displayName: 'Calves',
174
+ side: 'back',
175
+ intensity: 0.2,
176
+ volumeStatus: 'under',
177
+ weeklySets: 3,
178
+ landmarks: { mev: 6, mav: 10, mrv: 16 },
179
+ lastTrained: '6 days ago',
180
+ },
181
+ ]
182
+
183
+ const meta: Meta<typeof TrainingStatusPage> = {
184
+ title: 'Custom/Workout/TrainingStatusPage',
185
+ component: TrainingStatusPage,
186
+ parameters: { layout: 'fullscreen' },
187
+ tags: ['autodocs'],
188
+ args: { title: 'Training Status', meso, muscles },
189
+ }
190
+
191
+ export default meta
192
+ type Story = StoryObj<typeof TrainingStatusPage>
193
+
194
+ export const Default: Story = {}
195
+
196
+ export const NeedsAttention: Story = {
197
+ args: {
198
+ meso: {
199
+ ...meso,
200
+ statusBadge: { label: 'Behind', variant: 'warning' },
201
+ coaching: {
202
+ text: 'Volume is trailing target — add a pulling session this week.',
203
+ highlights: ['add a pulling session'],
204
+ },
205
+ },
206
+ muscles: muscles.map((m) =>
207
+ m.side === 'back'
208
+ ? { ...m, volumeStatus: 'under', weeklySets: Math.max(2, m.weeklySets - 4) }
209
+ : m
210
+ ),
211
+ },
212
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import {
4
+ TrainingStatusPage,
5
+ deriveTrainingSummary,
6
+ toBodyMapData,
7
+ type TrainingStatusMuscle,
8
+ } from './TrainingStatusPage'
9
+ import { type MesoStatusCardProps } from './MesoStatusCard'
10
+ import { MuscleGroup } from './muscleTaxonomy'
11
+
12
+ const meso: MesoStatusCardProps = {
13
+ mesoName: 'Hypertrophy Block',
14
+ mesoSubtitle: 'Week 6 of 8',
15
+ statusBadge: { label: 'On Track', variant: 'success' },
16
+ metrics: [{ label: 'Volume', value: '112 sets' }],
17
+ gauges: [{ label: 'Volume', level: 0.68 }],
18
+ }
19
+
20
+ const muscles: TrainingStatusMuscle[] = [
21
+ {
22
+ muscleGroup: MuscleGroup.CHEST,
23
+ displayName: 'Chest',
24
+ side: 'front',
25
+ intensity: 0.7,
26
+ volumeStatus: 'productive',
27
+ weeklySets: 14,
28
+ landmarks: { mev: 8, mav: 14, mrv: 20 },
29
+ lastTrained: '2 days ago',
30
+ weeklyHistory: [10, 12, 14],
31
+ },
32
+ {
33
+ muscleGroup: MuscleGroup.BICEPS,
34
+ displayName: 'Biceps',
35
+ side: 'front',
36
+ intensity: 0.3,
37
+ volumeStatus: 'under',
38
+ weeklySets: 3,
39
+ landmarks: { mev: 4, mav: 10, mrv: 18 },
40
+ },
41
+ {
42
+ muscleGroup: MuscleGroup.LATS,
43
+ displayName: 'Lats',
44
+ side: 'back',
45
+ intensity: 0.6,
46
+ volumeStatus: 'productive',
47
+ weeklySets: 11,
48
+ landmarks: { mev: 8, mav: 14, mrv: 20 },
49
+ },
50
+ {
51
+ muscleGroup: MuscleGroup.QUADS,
52
+ displayName: 'Quads',
53
+ side: 'front',
54
+ intensity: 0.95,
55
+ volumeStatus: 'over',
56
+ weeklySets: 20,
57
+ landmarks: { mev: 6, mav: 12, mrv: 18 },
58
+ },
59
+ ]
60
+
61
+ describe('deriveTrainingSummary', () => {
62
+ it('sums weekly sets and counts muscles per volume status', () => {
63
+ const summary = deriveTrainingSummary(muscles)
64
+ expect(summary.totalWeeklySets).toBe(48)
65
+ expect(summary.trackedMuscles).toBe(4)
66
+ expect(summary.statusCounts).toEqual({
67
+ under: 1,
68
+ maintenance: 0,
69
+ productive: 2,
70
+ over: 1,
71
+ })
72
+ })
73
+
74
+ it('returns zeroed counts for an empty muscle list', () => {
75
+ const summary = deriveTrainingSummary([])
76
+ expect(summary.totalWeeklySets).toBe(0)
77
+ expect(summary.trackedMuscles).toBe(0)
78
+ expect(summary.statusCounts).toEqual({ under: 0, maintenance: 0, productive: 0, over: 0 })
79
+ })
80
+ })
81
+
82
+ describe('toBodyMapData', () => {
83
+ it('keeps only the requested side and projects heatmap fields', () => {
84
+ const front = toBodyMapData(muscles, 'front')
85
+ expect(front.map((d) => d.muscleGroup)).toEqual([
86
+ MuscleGroup.CHEST,
87
+ MuscleGroup.BICEPS,
88
+ MuscleGroup.QUADS,
89
+ ])
90
+ expect(front[0]).toEqual({
91
+ muscleGroup: MuscleGroup.CHEST,
92
+ intensity: 0.7,
93
+ volumeStatus: 'productive',
94
+ weeklySets: 14,
95
+ })
96
+ })
97
+
98
+ it('filters to the back side', () => {
99
+ const back = toBodyMapData(muscles, 'back')
100
+ expect(back.map((d) => d.muscleGroup)).toEqual([MuscleGroup.LATS])
101
+ })
102
+ })
103
+
104
+ describe('TrainingStatusPage', () => {
105
+ it('renders the banner, summary cards, and both body maps', () => {
106
+ render(<TrainingStatusPage meso={meso} muscles={muscles} />)
107
+ expect(screen.getByTestId('training-status-page')).toBeInTheDocument()
108
+ expect(screen.getByTestId('meso-status-card')).toBeInTheDocument()
109
+ expect(screen.getByTestId('training-status-page-summary-total')).toHaveTextContent('48')
110
+ expect(screen.getByTestId('training-status-page-summary-productive')).toHaveTextContent('2')
111
+ expect(screen.getByTestId('training-status-page-bodymap-front')).toBeInTheDocument()
112
+ expect(screen.getByTestId('training-status-page-bodymap-back')).toBeInTheDocument()
113
+ })
114
+
115
+ it('uses a custom title when provided', () => {
116
+ render(<TrainingStatusPage title="Weekly Volume" meso={meso} muscles={muscles} />)
117
+ expect(screen.getByTestId('training-status-page-title')).toHaveTextContent('Weekly Volume')
118
+ })
119
+
120
+ it('opens the detail panel for a tapped muscle and closes it again', () => {
121
+ render(<TrainingStatusPage meso={meso} muscles={muscles} />)
122
+ expect(screen.queryByTestId('body-map-detail-panel')).not.toBeInTheDocument()
123
+
124
+ fireEvent.click(screen.getByTestId('body-map-muscle-chest'))
125
+ expect(screen.getByTestId('body-map-detail-panel')).toBeInTheDocument()
126
+ expect(screen.getByTestId('body-map-detail-panel-title')).toHaveTextContent('Chest')
127
+
128
+ fireEvent.click(screen.getByTestId('body-map-detail-panel-close'))
129
+ expect(screen.queryByTestId('body-map-detail-panel')).not.toBeInTheDocument()
130
+ })
131
+ })