@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,153 @@
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 { MesoProgressBar, type Meso } from './MesoProgressBar'
5
+
6
+ const mesos: Meso[] = [
7
+ { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
8
+ { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
9
+ { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
10
+ ]
11
+
12
+ describe('MesoProgressBar', () => {
13
+ describe('rendering', () => {
14
+ it('renders the container', () => {
15
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
16
+ expect(screen.getByTestId('meso-progress-bar')).toBeInTheDocument()
17
+ })
18
+
19
+ it('renders one segment per meso', () => {
20
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
21
+ expect(screen.getByTestId('meso-segment-m1')).toBeInTheDocument()
22
+ expect(screen.getByTestId('meso-segment-m2')).toBeInTheDocument()
23
+ expect(screen.getByTestId('meso-segment-m3')).toBeInTheDocument()
24
+ })
25
+
26
+ it('sets flex proportional to weekCount', () => {
27
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
28
+ expect(screen.getByTestId('meso-segment-m1')).toHaveStyle({ flex: 4 })
29
+ expect(screen.getByTestId('meso-segment-m2')).toHaveStyle({ flex: 3 })
30
+ expect(screen.getByTestId('meso-segment-m3')).toHaveStyle({ flex: 2 })
31
+ })
32
+
33
+ it('renders no segments for an empty mesos array', () => {
34
+ render(<MesoProgressBar mesos={[]} activeMesoId={null} onMesoPress={vi.fn()} />)
35
+ expect(screen.getByTestId('meso-progress-bar')).toBeEmptyDOMElement()
36
+ })
37
+ })
38
+
39
+ describe('status colors', () => {
40
+ it('applies completed color at 0.3 alpha', () => {
41
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
42
+ expect(screen.getByTestId('meso-segment-inner-m1')).toHaveStyle({
43
+ backgroundColor: 'rgba(20,184,166,0.3)',
44
+ })
45
+ })
46
+
47
+ it('applies current color at 0.5 alpha', () => {
48
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
49
+ expect(screen.getByTestId('meso-segment-inner-m2')).toHaveStyle({
50
+ backgroundColor: 'rgba(255,121,0,0.5)',
51
+ })
52
+ })
53
+
54
+ it('applies upcoming color', () => {
55
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
56
+ expect(screen.getByTestId('meso-segment-inner-m3')).toHaveStyle({
57
+ backgroundColor: 'rgba(255,255,255,0.06)',
58
+ })
59
+ })
60
+ })
61
+
62
+ describe('current week progress fill', () => {
63
+ it('renders a solid fill for the current meso when progress > 0', () => {
64
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
65
+ const fill = screen.getByTestId('meso-segment-fill-m2')
66
+ expect(fill).toBeInTheDocument()
67
+ expect(fill).toHaveStyle({ width: '66.66666666666666%' })
68
+ })
69
+
70
+ it('does not render a fill for completed or upcoming mesos', () => {
71
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
72
+ expect(screen.queryByTestId('meso-segment-fill-m1')).not.toBeInTheDocument()
73
+ expect(screen.queryByTestId('meso-segment-fill-m3')).not.toBeInTheDocument()
74
+ })
75
+
76
+ it('does not render a fill when current meso has no currentWeek', () => {
77
+ const noProgress: Meso[] = [
78
+ { id: 'c', name: 'Current', weekCount: 3, status: 'current' },
79
+ ]
80
+ render(<MesoProgressBar mesos={noProgress} activeMesoId={null} onMesoPress={vi.fn()} />)
81
+ expect(screen.queryByTestId('meso-segment-fill-c')).not.toBeInTheDocument()
82
+ })
83
+
84
+ it('clamps progress to 100% when currentWeek exceeds weekCount', () => {
85
+ const overflow: Meso[] = [
86
+ { id: 'c', name: 'Current', weekCount: 3, status: 'current', currentWeek: 5 },
87
+ ]
88
+ render(<MesoProgressBar mesos={overflow} activeMesoId={null} onMesoPress={vi.fn()} />)
89
+ expect(screen.getByTestId('meso-segment-fill-c')).toHaveStyle({ width: '100%' })
90
+ })
91
+ })
92
+
93
+ describe('active highlighting', () => {
94
+ it('applies a brand border to the active segment', () => {
95
+ render(<MesoProgressBar mesos={mesos} activeMesoId="m2" onMesoPress={vi.fn()} />)
96
+ const style = screen.getByTestId('meso-segment-inner-m2').getAttribute('style') ?? ''
97
+ expect(style).toContain('border-top-width: 2px')
98
+ expect(style).toContain('border-top-color: rgb(255, 121, 0)')
99
+ })
100
+
101
+ it('does not apply a border to inactive segments', () => {
102
+ render(<MesoProgressBar mesos={mesos} activeMesoId="m2" onMesoPress={vi.fn()} />)
103
+ const inner = screen.getByTestId('meso-segment-inner-m1')
104
+ const style = inner.getAttribute('style') ?? ''
105
+ expect(style).not.toContain('border-top-width: 2px')
106
+ })
107
+
108
+ it('highlights nothing when activeMesoId is null', () => {
109
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
110
+ const inner = screen.getByTestId('meso-segment-inner-m2')
111
+ const style = inner.getAttribute('style') ?? ''
112
+ expect(style).not.toContain('border-top-width: 2px')
113
+ })
114
+ })
115
+
116
+ describe('interaction', () => {
117
+ it('calls onMesoPress with the meso id when a segment is pressed', () => {
118
+ const onMesoPress = vi.fn()
119
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={onMesoPress} />)
120
+ fireEvent.click(screen.getByTestId('meso-segment-m3'))
121
+ expect(onMesoPress).toHaveBeenCalledWith('m3')
122
+ })
123
+ })
124
+
125
+ describe('accessibility', () => {
126
+ it('exposes each segment as a button with name and status', () => {
127
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
128
+ expect(screen.getByLabelText('Accumulation, completed')).toBeInTheDocument()
129
+ expect(screen.getByLabelText('Intensification, current')).toBeInTheDocument()
130
+ expect(screen.getByLabelText('Peak, upcoming')).toBeInTheDocument()
131
+ })
132
+
133
+ it('has no accessibility violations', async () => {
134
+ const { container } = render(
135
+ <MesoProgressBar mesos={mesos} activeMesoId="m2" onMesoPress={vi.fn()} />,
136
+ )
137
+ const results = await axe(container)
138
+ expect(results).toHaveNoViolations()
139
+ })
140
+ })
141
+ })
142
+
143
+ describe('MesoProgressBar invalid weekCount (flex guard)', () => {
144
+ it('clamps zero and negative weekCount to a valid flex of 1', () => {
145
+ const edgeMesos: Meso[] = [
146
+ { id: 'z', name: 'Zero', weekCount: 0, status: 'upcoming' },
147
+ { id: 'n', name: 'Neg', weekCount: -3, status: 'upcoming' },
148
+ ]
149
+ render(<MesoProgressBar mesos={edgeMesos} activeMesoId={null} onMesoPress={vi.fn()} />)
150
+ expect(screen.getByTestId('meso-segment-z')).toHaveStyle({ flex: 1 })
151
+ expect(screen.getByTestId('meso-segment-n')).toHaveStyle({ flex: 1 })
152
+ })
153
+ })
@@ -0,0 +1,165 @@
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
+ // weekCount drives the segment's flex width; guard 0 / negative so flex stays
70
+ // a valid positive value and the segment never collapses out of the layout.
71
+ const segmentFlex = Math.max(1, meso.weekCount)
72
+
73
+ return (
74
+ <Pressable
75
+ style={{ flex: segmentFlex }}
76
+ onPress={() => onPress(meso.id)}
77
+ onPressIn={handlePressIn}
78
+ onPressOut={handlePressOut}
79
+ accessibilityRole="button"
80
+ accessibilityLabel={`${meso.name}, ${meso.status}`}
81
+ testID={`meso-segment-${meso.id}`}
82
+ >
83
+ <Animated.View
84
+ style={[
85
+ {
86
+ height: 8,
87
+ borderRadius: 4,
88
+ overflow: 'hidden',
89
+ backgroundColor: segmentBgColors[meso.status],
90
+ transform: [{ scale: pressScale }],
91
+ },
92
+ isActive
93
+ ? {
94
+ borderWidth: 2,
95
+ borderColor: BRAND_PRIMARY,
96
+ transform: [{ scale: 1.02 }, { scale: pressScale }],
97
+ }
98
+ : undefined,
99
+ ]}
100
+ testID={`meso-segment-inner-${meso.id}`}
101
+ >
102
+ {meso.status === 'current' && progress > 0 && (
103
+ <View
104
+ style={{
105
+ position: 'absolute',
106
+ left: 0,
107
+ top: 0,
108
+ bottom: 0,
109
+ width: `${progress * 100}%`,
110
+ backgroundColor: BRAND_PRIMARY,
111
+ }}
112
+ testID={`meso-segment-fill-${meso.id}`}
113
+ />
114
+ )}
115
+ </Animated.View>
116
+ </Pressable>
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Segmented horizontal bar of mesocycles. Each segment is tappable and its
122
+ * flex width is proportional to the meso's `weekCount`. The active segment is
123
+ * highlighted with a 2px brand border to stay in sync with MesoCard selection.
124
+ *
125
+ * @example
126
+ * <MesoProgressBar
127
+ * mesos={[
128
+ * { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
129
+ * { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
130
+ * { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
131
+ * ]}
132
+ * activeMesoId="m2"
133
+ * onMesoPress={(id) => console.log(id)}
134
+ * />
135
+ */
136
+ export function MesoProgressBar({
137
+ mesos,
138
+ activeMesoId,
139
+ onMesoPress,
140
+ className,
141
+ ...props
142
+ }: MesoProgressBarProps) {
143
+ return (
144
+ <View
145
+ className={className}
146
+ style={{
147
+ flexDirection: 'row',
148
+ gap: 2,
149
+ paddingHorizontal: 16,
150
+ alignItems: 'center',
151
+ }}
152
+ testID="meso-progress-bar"
153
+ {...props}
154
+ >
155
+ {mesos.map((meso) => (
156
+ <MesoSegment
157
+ key={meso.id}
158
+ meso={meso}
159
+ isActive={meso.id === activeMesoId}
160
+ onPress={onMesoPress}
161
+ />
162
+ ))}
163
+ </View>
164
+ )
165
+ }
@@ -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
+ })