@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.
Files changed (157) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/package.json +16 -5
  23. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  24. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  25. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  26. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  27. package/src/components/custom/Gauge/index.ts +2 -0
  28. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  29. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  30. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  31. package/src/components/custom/Scatter/index.ts +2 -0
  32. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  33. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  34. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  35. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  36. package/src/components/custom/Treemap/index.ts +2 -0
  37. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  38. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  39. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  40. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  41. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  42. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  43. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  46. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  47. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  48. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  49. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  50. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  51. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  52. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  54. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  55. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  56. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  57. package/src/components/custom/Workout/InputBar.tsx +163 -0
  58. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  59. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  60. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  61. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  62. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  63. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  64. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  65. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  66. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  67. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  68. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  69. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  70. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  73. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  76. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  77. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  78. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  79. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  80. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  81. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  82. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  83. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  84. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  85. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  86. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  87. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  88. package/src/components/custom/Workout/SetRow.tsx +253 -0
  89. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  90. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  91. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  92. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  93. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  94. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  95. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  98. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  99. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  100. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  101. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  102. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  103. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  104. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  105. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  106. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  107. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  108. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  109. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  110. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  111. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  112. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  113. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  114. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  115. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  116. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  117. package/src/components/custom/Workout/index.ts +113 -0
  118. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  119. package/src/components/custom/index.ts +3 -0
  120. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  121. package/src/components/custom/stepper/Stepper.tsx +2 -2
  122. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  123. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  124. package/src/components/ui/card/Card.tsx +1 -1
  125. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  126. package/src/components/ui/collapse/Collapse.tsx +1 -1
  127. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  128. package/src/components/ui/drawer/Drawer.tsx +3 -3
  129. package/src/components/ui/form-field/FormField.tsx +1 -1
  130. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  131. package/src/components/ui/menu/Menu.tsx +2 -2
  132. package/src/components/ui/pill/Pill.tsx +1 -1
  133. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  134. package/src/components/ui/popover/Popover.tsx +1 -1
  135. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  136. package/src/components/ui/section/Section.stories.tsx +4 -4
  137. package/src/components/ui/select/Select.tsx +1 -1
  138. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  139. package/src/components/ui/tabs/Tabs.tsx +2 -2
  140. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  141. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  142. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  143. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  144. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  145. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  146. package/src/stories/PresetShowcase.stories.tsx +15 -15
  147. package/src/theme/index.ts +2 -1
  148. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  149. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  150. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  151. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  152. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  153. package/src/theme/manifest/index.ts +11 -0
  154. package/src/theme/tokens-css.test.ts +28 -0
  155. package/src/theme/tokens-css.ts +34 -0
  156. package/src/theme/workout-tokens.ts +61 -0
  157. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View } from 'react-native'
4
+ import { BodyMap, type BodyMapData } from './BodyMap'
5
+ import { MuscleGroup } from './muscleTaxonomy'
6
+
7
+ const sampleData: BodyMapData[] = [
8
+ { muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 },
9
+ {
10
+ muscleGroup: MuscleGroup.FRONT_DELTS,
11
+ intensity: 0.5,
12
+ volumeStatus: 'maintenance',
13
+ weeklySets: 5,
14
+ },
15
+ {
16
+ muscleGroup: MuscleGroup.SIDE_DELTS,
17
+ intensity: 0.6,
18
+ volumeStatus: 'productive',
19
+ weeklySets: 10,
20
+ },
21
+ { muscleGroup: MuscleGroup.BICEPS, intensity: 0.3, volumeStatus: 'under', weeklySets: 3 },
22
+ { muscleGroup: MuscleGroup.TRICEPS, intensity: 0.65, volumeStatus: 'productive', weeklySets: 8 },
23
+ { muscleGroup: MuscleGroup.ABS, intensity: 0.4, volumeStatus: 'maintenance', weeklySets: 6 },
24
+ {
25
+ muscleGroup: MuscleGroup.OBLIQUES,
26
+ intensity: 0.35,
27
+ volumeStatus: 'maintenance',
28
+ weeklySets: 4,
29
+ },
30
+ { muscleGroup: MuscleGroup.QUADS, intensity: 0.95, volumeStatus: 'over', weeklySets: 20 },
31
+ ]
32
+
33
+ const backData: BodyMapData[] = [
34
+ { muscleGroup: MuscleGroup.LATS, intensity: 0.6, volumeStatus: 'productive', weeklySets: 11 },
35
+ {
36
+ muscleGroup: MuscleGroup.UPPER_BACK,
37
+ intensity: 0.5,
38
+ volumeStatus: 'maintenance',
39
+ weeklySets: 8,
40
+ },
41
+ { muscleGroup: MuscleGroup.REAR_DELTS, intensity: 0.3, volumeStatus: 'under', weeklySets: 4 },
42
+ { muscleGroup: MuscleGroup.TRICEPS, intensity: 0.65, volumeStatus: 'productive', weeklySets: 8 },
43
+ { muscleGroup: MuscleGroup.GLUTES, intensity: 0.7, volumeStatus: 'productive', weeklySets: 10 },
44
+ {
45
+ muscleGroup: MuscleGroup.HAMSTRINGS,
46
+ intensity: 0.55,
47
+ volumeStatus: 'productive',
48
+ weeklySets: 9,
49
+ },
50
+ { muscleGroup: MuscleGroup.CALVES, intensity: 0.2, volumeStatus: 'under', weeklySets: 3 },
51
+ ]
52
+
53
+ const meta: Meta<typeof BodyMap> = {
54
+ title: 'Custom/Workout/BodyMap',
55
+ component: BodyMap,
56
+ tags: ['autodocs'],
57
+ argTypes: {
58
+ view: { control: 'inline-radio', options: ['front', 'back'] },
59
+ mode: { control: 'inline-radio', options: ['simple', 'detailed'] },
60
+ },
61
+ decorators: [
62
+ (Story) => (
63
+ <View style={{ padding: 16, maxWidth: 320 }}>
64
+ <Story />
65
+ </View>
66
+ ),
67
+ ],
68
+ }
69
+
70
+ export default meta
71
+ type Story = StoryObj<typeof BodyMap>
72
+
73
+ export const Front: Story = {
74
+ args: { data: sampleData, view: 'front', mode: 'detailed' },
75
+ }
76
+
77
+ export const Back: Story = {
78
+ args: { data: backData, view: 'back', mode: 'detailed' },
79
+ }
80
+
81
+ export const Simple: Story = {
82
+ args: { data: sampleData, view: 'front', mode: 'simple' },
83
+ }
84
+
85
+ export const Detailed: Story = {
86
+ args: { data: sampleData, view: 'front', mode: 'detailed' },
87
+ }
88
+
89
+ export const Highlighted: Story = {
90
+ args: {
91
+ data: sampleData,
92
+ view: 'front',
93
+ mode: 'detailed',
94
+ highlightedMuscle: MuscleGroup.QUADS,
95
+ },
96
+ }
97
+
98
+ export const Empty: Story = {
99
+ args: { data: [], view: 'front' },
100
+ }
101
+
102
+ function InteractiveBodyMap() {
103
+ const [view, setView] = useState<'front' | 'back'>('front')
104
+ const [selected, setSelected] = useState<MuscleGroup | null>(null)
105
+ return (
106
+ <View style={{ maxWidth: 320 }}>
107
+ <BodyMap
108
+ data={view === 'front' ? sampleData : backData}
109
+ view={view}
110
+ onViewChange={setView}
111
+ onMusclePress={(m) => setSelected((cur) => (cur === m ? null : m))}
112
+ highlightedMuscle={selected}
113
+ mode="detailed"
114
+ />
115
+ </View>
116
+ )
117
+ }
118
+
119
+ export const Interactive: Story = {
120
+ render: () => <InteractiveBodyMap />,
121
+ }
@@ -0,0 +1,124 @@
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 { BodyMap, type BodyMapData } from './BodyMap'
5
+ import { MuscleGroup } from './muscleTaxonomy'
6
+
7
+ const data: BodyMapData[] = [
8
+ { muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 },
9
+ { muscleGroup: MuscleGroup.LATS, intensity: 0.4, volumeStatus: 'under', weeklySets: 6 },
10
+ { muscleGroup: MuscleGroup.QUADS, intensity: 0.9, volumeStatus: 'over', weeklySets: 20 },
11
+ ]
12
+
13
+ describe('BodyMap', () => {
14
+ describe('rendering', () => {
15
+ it('renders the container, SVG, and view toggle', () => {
16
+ render(<BodyMap data={data} view="front" />)
17
+ expect(screen.getByTestId('body-map')).toBeInTheDocument()
18
+ expect(screen.getByTestId('body-map-svg')).toBeInTheDocument()
19
+ expect(screen.getByTestId('body-map-view-toggle')).toBeInTheDocument()
20
+ })
21
+
22
+ it('renders the SVG body for both front and back views', () => {
23
+ const { rerender, container } = render(<BodyMap data={data} view="front" />)
24
+ expect(container.querySelector('svg')).toBeInTheDocument()
25
+ rerender(<BodyMap data={data} view="back" />)
26
+ expect(container.querySelector('svg')).toBeInTheDocument()
27
+ })
28
+
29
+ it('renders one legend button per muscle in detailed mode', () => {
30
+ render(<BodyMap data={data} view="front" mode="detailed" />)
31
+ expect(screen.getByTestId('body-map-muscle-chest')).toBeInTheDocument()
32
+ expect(screen.getByTestId('body-map-muscle-lats')).toBeInTheDocument()
33
+ expect(screen.getByTestId('body-map-muscle-quads')).toBeInTheDocument()
34
+ })
35
+
36
+ it('aggregates muscles into simple groups in simple mode', () => {
37
+ render(<BodyMap data={data} view="front" mode="simple" />)
38
+ // chest -> chest, lats -> back, quads -> legs
39
+ expect(screen.getByTestId('body-map-muscle-chest')).toBeInTheDocument()
40
+ expect(screen.getByTestId('body-map-muscle-back')).toBeInTheDocument()
41
+ expect(screen.getByTestId('body-map-muscle-legs')).toBeInTheDocument()
42
+ expect(screen.queryByTestId('body-map-muscle-lats')).not.toBeInTheDocument()
43
+ })
44
+
45
+ it('renders an empty hint when there is no data', () => {
46
+ render(<BodyMap data={[]} view="front" />)
47
+ expect(screen.getByTestId('body-map-empty')).toBeInTheDocument()
48
+ expect(screen.queryByTestId('body-map-legend')).not.toBeInTheDocument()
49
+ })
50
+ })
51
+
52
+ describe('interaction', () => {
53
+ it('calls onMusclePress with the tapped muscle group', () => {
54
+ const onMusclePress = vi.fn()
55
+ render(<BodyMap data={data} view="front" onMusclePress={onMusclePress} />)
56
+ fireEvent.click(screen.getByTestId('body-map-muscle-chest'))
57
+ expect(onMusclePress).toHaveBeenCalledWith(MuscleGroup.CHEST)
58
+ })
59
+
60
+ it('reports a representative detailed muscle from a simple group tap', () => {
61
+ const onMusclePress = vi.fn()
62
+ render(<BodyMap data={data} view="front" mode="simple" onMusclePress={onMusclePress} />)
63
+ fireEvent.click(screen.getByTestId('body-map-muscle-back'))
64
+ expect(onMusclePress).toHaveBeenCalledWith(MuscleGroup.LATS)
65
+ })
66
+
67
+ it('calls onViewChange from the front/back toggle', () => {
68
+ const onViewChange = vi.fn()
69
+ render(<BodyMap data={data} view="front" onViewChange={onViewChange} />)
70
+ fireEvent.click(screen.getByTestId('body-map-toggle-back'))
71
+ expect(onViewChange).toHaveBeenCalledWith('back')
72
+ fireEvent.click(screen.getByTestId('body-map-toggle-front'))
73
+ expect(onViewChange).toHaveBeenCalledWith('front')
74
+ })
75
+ })
76
+
77
+ describe('highlighted muscle', () => {
78
+ it('marks the highlighted muscle button as pressed', () => {
79
+ render(<BodyMap data={data} view="front" highlightedMuscle={MuscleGroup.CHEST} />)
80
+ expect(screen.getByTestId('body-map-muscle-chest')).toHaveAttribute('aria-pressed', 'true')
81
+ expect(screen.getByTestId('body-map-muscle-lats')).toHaveAttribute('aria-pressed', 'false')
82
+ })
83
+
84
+ it('applies an edge glow when a muscle is highlighted', () => {
85
+ render(<BodyMap data={data} view="front" highlightedMuscle={MuscleGroup.QUADS} />)
86
+ const svg = screen.getByTestId('body-map-svg')
87
+ expect(svg.getAttribute('style') ?? '').toContain('box-shadow')
88
+ })
89
+ })
90
+
91
+ describe('accessibility', () => {
92
+ it('labels each muscle button with name, status, and weekly sets', () => {
93
+ render(<BodyMap data={data} view="front" />)
94
+ expect(screen.getByLabelText('Chest, productive, 12 sets this week')).toBeInTheDocument()
95
+ expect(screen.getByLabelText('Lats, under-trained, 6 sets this week')).toBeInTheDocument()
96
+ })
97
+
98
+ it('labels the view toggle buttons', () => {
99
+ render(<BodyMap data={data} view="front" onViewChange={vi.fn()} />)
100
+ expect(screen.getByLabelText('Show front view')).toBeInTheDocument()
101
+ expect(screen.getByLabelText('Show back view')).toBeInTheDocument()
102
+ })
103
+
104
+ it('has no accessibility violations with data', async () => {
105
+ const { container } = render(
106
+ <BodyMap
107
+ data={data}
108
+ view="front"
109
+ onViewChange={vi.fn()}
110
+ onMusclePress={vi.fn()}
111
+ highlightedMuscle={MuscleGroup.CHEST}
112
+ />
113
+ )
114
+ const results = await axe(container)
115
+ expect(results).toHaveNoViolations()
116
+ })
117
+
118
+ it('has no accessibility violations when empty', async () => {
119
+ const { container } = render(<BodyMap data={[]} view="back" />)
120
+ const results = await axe(container)
121
+ expect(results).toHaveNoViolations()
122
+ })
123
+ })
124
+ })
@@ -0,0 +1,348 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import BodyHighlighter, { type ExtendedBodyPart, type Slug } from 'react-native-body-highlighter'
5
+ import { cn } from '../../../utils/cn'
6
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
7
+ import {
8
+ MuscleGroup,
9
+ SimpleMuscleGroup,
10
+ MUSCLE_TO_SVG_SLUGS,
11
+ MUSCLE_DISPLAY_NAMES,
12
+ SIMPLE_DISPLAY_NAMES,
13
+ DETAILED_TO_SIMPLE,
14
+ SLUG_TO_PRIMARY_MUSCLE,
15
+ VOLUME_STATUS_LABELS,
16
+ getHeatmapColor,
17
+ isMoreSevere,
18
+ type VolumeStatus,
19
+ } from './muscleTaxonomy'
20
+
21
+ /**
22
+ * react-native-body-highlighter is published as a CommonJS default export; the
23
+ * interop keeps it working whether the bundler unwraps the default or not.
24
+ */
25
+ const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter }).default ??
26
+ BodyHighlighter) as typeof BodyHighlighter
27
+
28
+ const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
29
+ const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
30
+ const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
31
+ const TEXT_PRIMARY = '#F3F4F6'
32
+ const TEXT_SECONDARY = '#9CA3AF'
33
+
34
+ export interface BodyMapData {
35
+ muscleGroup: MuscleGroup
36
+ /** Intensity 0-1 for heatmap color. */
37
+ intensity: number
38
+ /** Volume status for tooltip / heatmap token. */
39
+ volumeStatus: VolumeStatus
40
+ /** Weekly effective sets. */
41
+ weeklySets: number
42
+ }
43
+
44
+ export interface BodyMapProps extends ViewProps {
45
+ /** Muscle group intensity data. */
46
+ data: BodyMapData[]
47
+ /** Which view to show. */
48
+ view: 'front' | 'back'
49
+ /** Toggle between front/back. */
50
+ onViewChange?: (view: 'front' | 'back') => void
51
+ /** Called when a muscle group is tapped. */
52
+ onMusclePress?: (muscleGroup: MuscleGroup) => void
53
+ /** Currently highlighted muscle (from external selection). */
54
+ highlightedMuscle?: MuscleGroup | null
55
+ /** Show simplified (8 groups) or detailed (15 groups). */
56
+ mode?: 'simple' | 'detailed'
57
+ className?: string
58
+ }
59
+
60
+ interface LegendEntry {
61
+ key: string
62
+ name: string
63
+ status: VolumeStatus
64
+ intensity: number
65
+ sets: number
66
+ /** Detailed group reported to onMusclePress. */
67
+ muscle: MuscleGroup
68
+ }
69
+
70
+ /** Build the per-slug heatmap fill list, combining groups that share a slug. */
71
+ function buildSlugParts(data: BodyMapData[]): ExtendedBodyPart[] {
72
+ const bySlug = new Map<string, { status: VolumeStatus; intensity: number }>()
73
+ for (const d of data) {
74
+ for (const slug of MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []) {
75
+ const existing = bySlug.get(slug)
76
+ if (!existing || isMoreSevere(d.volumeStatus, existing.status)) {
77
+ bySlug.set(slug, { status: d.volumeStatus, intensity: d.intensity })
78
+ }
79
+ }
80
+ }
81
+ return Array.from(bySlug.entries()).map(([slug, v]) => ({
82
+ slug: slug as Slug,
83
+ color: getHeatmapColor(v.status, v.intensity),
84
+ }))
85
+ }
86
+
87
+ /** Collapse data into per-simple-group legend entries (summed sets). */
88
+ function buildSimpleLegend(data: BodyMapData[]): LegendEntry[] {
89
+ const bySimple = new Map<SimpleMuscleGroup, LegendEntry>()
90
+ for (const d of data) {
91
+ const simple = DETAILED_TO_SIMPLE[d.muscleGroup]
92
+ const existing = bySimple.get(simple)
93
+ if (!existing) {
94
+ bySimple.set(simple, {
95
+ key: simple,
96
+ name: SIMPLE_DISPLAY_NAMES[simple],
97
+ status: d.volumeStatus,
98
+ intensity: d.intensity,
99
+ sets: d.weeklySets,
100
+ muscle: d.muscleGroup,
101
+ })
102
+ continue
103
+ }
104
+ existing.sets += d.weeklySets
105
+ existing.intensity = Math.max(existing.intensity, d.intensity)
106
+ if (isMoreSevere(d.volumeStatus, existing.status)) {
107
+ existing.status = d.volumeStatus
108
+ existing.muscle = d.muscleGroup
109
+ }
110
+ }
111
+ return Array.from(bySimple.values())
112
+ }
113
+
114
+ /** One legend entry per data row, named by detailed group. */
115
+ function buildDetailedLegend(data: BodyMapData[]): LegendEntry[] {
116
+ return data.map((d) => ({
117
+ key: d.muscleGroup,
118
+ name: MUSCLE_DISPLAY_NAMES[d.muscleGroup],
119
+ status: d.volumeStatus,
120
+ intensity: d.intensity,
121
+ sets: d.weeklySets,
122
+ muscle: d.muscleGroup,
123
+ }))
124
+ }
125
+
126
+ /**
127
+ * Interactive SVG body map with tappable muscle groups and a volume heatmap.
128
+ * Renders the front or back view (toggle via onViewChange), colors each muscle
129
+ * by its weekly-volume status, and exposes an accessible muscle legend wired to
130
+ * onMusclePress. Phase 1 uses react-native-body-highlighter's combined slugs
131
+ * (deltoids as one path, lats/upper-back shared); swipe between views and the
132
+ * delt/lat SVG split are follow-ups.
133
+ *
134
+ * @example
135
+ * <BodyMap
136
+ * data={[{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 }]}
137
+ * view="front"
138
+ * onViewChange={setView}
139
+ * onMusclePress={setSelected}
140
+ * highlightedMuscle={selected}
141
+ * />
142
+ */
143
+ export function BodyMap({
144
+ data,
145
+ view,
146
+ onViewChange,
147
+ onMusclePress,
148
+ highlightedMuscle,
149
+ mode = 'detailed',
150
+ className,
151
+ ...props
152
+ }: BodyMapProps) {
153
+ const slugParts = useMemo(() => buildSlugParts(data), [data])
154
+ const legend = useMemo(
155
+ () => (mode === 'simple' ? buildSimpleLegend(data) : buildDetailedLegend(data)),
156
+ [data, mode]
157
+ )
158
+
159
+ const [highlight] = useState(() => new Animated.Value(highlightedMuscle ? 1 : 0))
160
+ useEffect(() => {
161
+ const animation = Animated.timing(highlight, {
162
+ toValue: highlightedMuscle ? 1 : 0,
163
+ duration: 250,
164
+ easing: Easing.out(Easing.ease),
165
+ useNativeDriver: false,
166
+ })
167
+ animation.start()
168
+ return () => animation.stop()
169
+ }, [highlightedMuscle, highlight])
170
+
171
+ const scale = highlight.interpolate({ inputRange: [0, 1], outputRange: [1, 1.02] })
172
+ const glowColor = highlightedMuscle
173
+ ? getHeatmapColor(
174
+ data.find((d) => d.muscleGroup === highlightedMuscle)?.volumeStatus,
175
+ data.find((d) => d.muscleGroup === highlightedMuscle)?.intensity ?? 0
176
+ )
177
+ : null
178
+
179
+ const handleBodyPress = (part: ExtendedBodyPart) => {
180
+ if (!onMusclePress || !part.slug) return
181
+ const fromData = data.find((d) =>
182
+ (MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []).includes(part.slug as string)
183
+ )
184
+ const muscle = fromData?.muscleGroup ?? SLUG_TO_PRIMARY_MUSCLE[part.slug]
185
+ if (muscle) onMusclePress(muscle)
186
+ }
187
+
188
+ return (
189
+ <View className={cn(className)} testID="body-map" {...props}>
190
+ <ViewToggle view={view} onViewChange={onViewChange} />
191
+
192
+ <Animated.View
193
+ style={[
194
+ {
195
+ width: 200 * BODY_SCALE,
196
+ alignSelf: 'center',
197
+ transform: [{ scale }],
198
+ },
199
+ glowColor ? { boxShadow: `0 0 14px 2px ${glowColor}` } : undefined,
200
+ ]}
201
+ // The SVG is decorative; the accessible muscle legend below is the
202
+ // screen-reader / keyboard interface.
203
+ aria-hidden
204
+ accessibilityElementsHidden
205
+ importantForAccessibility="no-hide-descendants"
206
+ testID="body-map-svg"
207
+ >
208
+ <Body
209
+ side={view}
210
+ data={slugParts}
211
+ scale={BODY_SCALE}
212
+ gender="male"
213
+ defaultFill={OUTLINE_FILL}
214
+ border={OUTLINE_BORDER}
215
+ onBodyPartPress={handleBodyPress}
216
+ />
217
+ </Animated.View>
218
+
219
+ {legend.length === 0 ? (
220
+ <Text
221
+ style={{
222
+ marginTop: 8,
223
+ fontSize: 12,
224
+ color: TEXT_SECONDARY,
225
+ textAlign: 'center',
226
+ fontFamily: 'Inter, sans-serif',
227
+ }}
228
+ testID="body-map-empty"
229
+ >
230
+ No training data
231
+ </Text>
232
+ ) : (
233
+ <View
234
+ className="flex-row flex-wrap"
235
+ style={{ marginTop: 10, gap: 6, justifyContent: 'center' }}
236
+ testID="body-map-legend"
237
+ >
238
+ {legend.map((entry) => (
239
+ <MuscleButton
240
+ key={entry.key}
241
+ entry={entry}
242
+ highlighted={highlightedMuscle === entry.muscle}
243
+ onMusclePress={onMusclePress}
244
+ />
245
+ ))}
246
+ </View>
247
+ )}
248
+ </View>
249
+ )
250
+ }
251
+
252
+ interface ViewToggleProps {
253
+ view: 'front' | 'back'
254
+ onViewChange?: (view: 'front' | 'back') => void
255
+ }
256
+
257
+ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
258
+ return (
259
+ <View
260
+ className="flex-row self-center"
261
+ style={{ gap: 4, marginBottom: 8 }}
262
+ testID="body-map-view-toggle"
263
+ >
264
+ {(['front', 'back'] as const).map((side) => {
265
+ const active = view === side
266
+ return (
267
+ <Pressable
268
+ key={side}
269
+ onPress={() => onViewChange?.(side)}
270
+ accessibilityRole="button"
271
+ accessibilityLabel={`Show ${side} view`}
272
+ aria-pressed={active}
273
+ disabled={!onViewChange}
274
+ style={{
275
+ paddingHorizontal: 12,
276
+ paddingVertical: 4,
277
+ borderRadius: 9999,
278
+ backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
279
+ borderWidth: 1,
280
+ borderColor: active ? '#FF7900' : WORKOUT_TOKENS.border.strong,
281
+ }}
282
+ testID={`body-map-toggle-${side}`}
283
+ >
284
+ <Text
285
+ style={{
286
+ fontSize: 12,
287
+ fontFamily: 'Inter, sans-serif',
288
+ fontWeight: active ? '700' : '500',
289
+ color: active ? '#FF7900' : TEXT_SECONDARY,
290
+ textTransform: 'capitalize',
291
+ }}
292
+ >
293
+ {side}
294
+ </Text>
295
+ </Pressable>
296
+ )
297
+ })}
298
+ </View>
299
+ )
300
+ }
301
+
302
+ interface MuscleButtonProps {
303
+ entry: LegendEntry
304
+ highlighted: boolean
305
+ onMusclePress?: (muscleGroup: MuscleGroup) => void
306
+ }
307
+
308
+ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps) {
309
+ const dotColor = getHeatmapColor(entry.status, entry.intensity)
310
+ const label = `${entry.name}, ${VOLUME_STATUS_LABELS[entry.status]}, ${entry.sets} sets this week`
311
+ return (
312
+ <Pressable
313
+ onPress={() => onMusclePress?.(entry.muscle)}
314
+ accessibilityRole="button"
315
+ accessibilityLabel={label}
316
+ aria-pressed={highlighted}
317
+ style={{
318
+ flexDirection: 'row',
319
+ alignItems: 'center',
320
+ gap: 5,
321
+ paddingHorizontal: 8,
322
+ paddingVertical: 3,
323
+ borderRadius: 9999,
324
+ borderWidth: 1,
325
+ borderColor: highlighted ? '#FF7900' : WORKOUT_TOKENS.border.default,
326
+ backgroundColor: WORKOUT_TOKENS.surface.raised,
327
+ }}
328
+ testID={`body-map-muscle-${entry.key}`}
329
+ >
330
+ <View
331
+ style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: dotColor }}
332
+ accessibilityElementsHidden
333
+ testID={`body-map-muscle-dot-${entry.key}`}
334
+ />
335
+ <Text
336
+ style={{
337
+ fontSize: 11,
338
+ fontFamily: 'Inter, sans-serif',
339
+ fontWeight: '500',
340
+ color: TEXT_PRIMARY,
341
+ }}
342
+ accessibilityElementsHidden
343
+ >
344
+ {entry.name}
345
+ </Text>
346
+ </Pressable>
347
+ )
348
+ }