@titan-design/react-ui 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/{chunk-3VLOBS6O.mjs → chunk-P7TSQUN6.mjs} +3 -3
- package/dist/{chunk-3VLOBS6O.mjs.map → chunk-P7TSQUN6.mjs.map} +1 -1
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +375 -249
- package/dist/index.d.ts +375 -249
- package/dist/index.js +1259 -918
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1314 -2107
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +260 -0
- package/dist/theme/index.d.ts +260 -0
- package/dist/theme/index.js +122 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -2
- package/dist/theme/tokens-css.d.mts +5 -3
- package/dist/theme/tokens-css.d.ts +5 -3
- package/dist/theme/tokens-css.js +318 -9
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +1 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +10 -1
- package/src/bodymap.ts +37 -0
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
- package/src/components/custom/Workout/BodyMap.tsx +5 -6
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +7 -8
- package/src/components/custom/Workout/CapacityBandChart.tsx +1 -1
- package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +28 -15
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
- package/src/components/custom/Workout/InputBar.tsx +6 -7
- package/src/components/custom/Workout/MesoCard.tsx +4 -2
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
- package/src/components/custom/Workout/MesoStatusCard.tsx +5 -5
- package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +5 -5
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -8
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +11 -9
- package/src/components/custom/Workout/SetRow.tsx +13 -19
- package/src/components/custom/Workout/StatusDot.tsx +1 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +5 -5
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +16 -15
- package/src/components/custom/Workout/WeekRow.tsx +1 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -3
- package/src/components/custom/Workout/index.ts +71 -32
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/tokens-css.ts +5 -3
- package/src/theme/workout-tokens.ts +12 -14
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-5SSKGS6E.mjs.map +0 -1
|
@@ -4,15 +4,15 @@ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-n
|
|
|
4
4
|
import { cn } from '../../../utils/cn'
|
|
5
5
|
|
|
6
6
|
const BRAND_PRIMARY = '#FF7900'
|
|
7
|
-
const TEXT_PRIMARY = '
|
|
8
|
-
const TEXT_SECONDARY = '
|
|
9
|
-
const TEXT_TERTIARY = '
|
|
10
|
-
const BORDER_STRONG = '
|
|
7
|
+
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
8
|
+
const TEXT_SECONDARY = 'var(--color-text-secondary)'
|
|
9
|
+
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
10
|
+
const BORDER_STRONG = 'var(--color-border-strong)'
|
|
11
11
|
const STATUS_SUCCESS = '#14B8A6'
|
|
12
12
|
const STATUS_ERROR = '#D14343'
|
|
13
13
|
const STATUS_WARNING = '#FFB020'
|
|
14
14
|
const GRID_LINE = 'rgba(255,255,255,0.06)'
|
|
15
|
-
const TOOLTIP_BG = '
|
|
15
|
+
const TOOLTIP_BG = 'var(--color-surface-elevated)'
|
|
16
16
|
const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
|
|
17
17
|
const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
|
|
18
18
|
const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useState, useCallback } from 'react'
|
|
3
3
|
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
4
|
+
import { roundTempo } from '../../../utils/workout-format'
|
|
4
5
|
|
|
5
6
|
export interface TempoDisplayProps extends ViewProps {
|
|
6
7
|
/** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
|
|
@@ -76,7 +77,8 @@ export function TempoDisplay({
|
|
|
76
77
|
...props
|
|
77
78
|
}: TempoDisplayProps) {
|
|
78
79
|
const [showTooltip, setShowTooltip] = useState(false)
|
|
79
|
-
|
|
80
|
+
// Round exact (unrounded) tempo seconds to the 1-dp display granularity.
|
|
81
|
+
const [eccentric, pauseBottom, concentric, pauseTop] = roundTempo(tempo)
|
|
80
82
|
const isSm = size === 'sm'
|
|
81
83
|
const fontSize = isSm ? 9 : 11
|
|
82
84
|
const monoColor = TEXT_TERTIARY
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
3
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
4
|
+
import { BodyMap, type BodyMapData } from './BodyMap'
|
|
5
|
+
import {
|
|
6
|
+
BodyMapDetailPanel,
|
|
7
|
+
type ContributingExercise,
|
|
8
|
+
type UpcomingExercise,
|
|
9
|
+
} from './BodyMapDetailPanel'
|
|
10
|
+
import { MesoStatusCard, type MesoStatusCardProps } from './MesoStatusCard'
|
|
11
|
+
import {
|
|
12
|
+
MuscleGroup,
|
|
13
|
+
VOLUME_STATUS_LABELS,
|
|
14
|
+
getHeatmapColor,
|
|
15
|
+
type VolumeLandmarks,
|
|
16
|
+
type VolumeStatus,
|
|
17
|
+
} from './muscleTaxonomy'
|
|
18
|
+
|
|
19
|
+
const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
|
|
20
|
+
const BORDER_DEFAULT = 'var(--color-border-default)'
|
|
21
|
+
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
22
|
+
const TEXT_SECONDARY = 'var(--color-text-secondary)'
|
|
23
|
+
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
24
|
+
const PAGE_BG = 'var(--color-background-base)'
|
|
25
|
+
|
|
26
|
+
/** Volume statuses in display order for the legend and summary cards. */
|
|
27
|
+
const STATUS_ORDER: VolumeStatus[] = ['under', 'maintenance', 'productive', 'over']
|
|
28
|
+
|
|
29
|
+
/** One muscle's full volume picture — feeds the body maps and the detail panel. */
|
|
30
|
+
export interface TrainingStatusMuscle {
|
|
31
|
+
/** Detailed muscle group (drives the SVG heatmap + panel identity). */
|
|
32
|
+
muscleGroup: MuscleGroup
|
|
33
|
+
/** Human-readable name for the legend, summary, and panel title. */
|
|
34
|
+
displayName: string
|
|
35
|
+
/** Which body-map view the muscle belongs to. */
|
|
36
|
+
side: 'front' | 'back'
|
|
37
|
+
/** Heatmap intensity 0-1. */
|
|
38
|
+
intensity: number
|
|
39
|
+
/** Weekly-volume status relative to the landmarks. */
|
|
40
|
+
volumeStatus: VolumeStatus
|
|
41
|
+
/** Weekly effective sets logged. */
|
|
42
|
+
weeklySets: number
|
|
43
|
+
/** Volume landmarks (weekly working sets). */
|
|
44
|
+
landmarks: VolumeLandmarks
|
|
45
|
+
/** Last trained label, e.g. "2 days ago". */
|
|
46
|
+
lastTrained?: string
|
|
47
|
+
/** Weekly volume history for the panel sparkline. */
|
|
48
|
+
weeklyHistory?: number[]
|
|
49
|
+
/** Exercises contributing to this muscle's volume. */
|
|
50
|
+
contributingExercises?: ContributingExercise[]
|
|
51
|
+
/** Upcoming exercises targeting this muscle. */
|
|
52
|
+
upcomingExercises?: UpcomingExercise[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Derived, page-level roll-up of the tracked muscles. */
|
|
56
|
+
export interface TrainingStatusSummary {
|
|
57
|
+
/** Total weekly effective sets across all tracked muscles. */
|
|
58
|
+
totalWeeklySets: number
|
|
59
|
+
/** Number of muscles being tracked. */
|
|
60
|
+
trackedMuscles: number
|
|
61
|
+
/** Count of muscles per volume status. */
|
|
62
|
+
statusCounts: Record<VolumeStatus, number>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TrainingStatusPageProps extends ViewProps {
|
|
66
|
+
/** Page heading. */
|
|
67
|
+
title?: string
|
|
68
|
+
/** Mesocycle context banner shown at the top. */
|
|
69
|
+
meso: MesoStatusCardProps
|
|
70
|
+
/** Muscles tracked this week. */
|
|
71
|
+
muscles: TrainingStatusMuscle[]
|
|
72
|
+
className?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Roll up per-muscle rows into the page summary (pure, testable). */
|
|
76
|
+
export function deriveTrainingSummary(muscles: TrainingStatusMuscle[]): TrainingStatusSummary {
|
|
77
|
+
const statusCounts: Record<VolumeStatus, number> = {
|
|
78
|
+
under: 0,
|
|
79
|
+
maintenance: 0,
|
|
80
|
+
productive: 0,
|
|
81
|
+
over: 0,
|
|
82
|
+
}
|
|
83
|
+
let totalWeeklySets = 0
|
|
84
|
+
for (const muscle of muscles) {
|
|
85
|
+
totalWeeklySets += muscle.weeklySets
|
|
86
|
+
statusCounts[muscle.volumeStatus] += 1
|
|
87
|
+
}
|
|
88
|
+
return { totalWeeklySets, trackedMuscles: muscles.length, statusCounts }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Project the muscle rows for one side into `BodyMap` heatmap data. */
|
|
92
|
+
export function toBodyMapData(
|
|
93
|
+
muscles: TrainingStatusMuscle[],
|
|
94
|
+
side: 'front' | 'back'
|
|
95
|
+
): BodyMapData[] {
|
|
96
|
+
return muscles
|
|
97
|
+
.filter((muscle) => muscle.side === side)
|
|
98
|
+
.map(({ muscleGroup, intensity, volumeStatus, weeklySets }) => ({
|
|
99
|
+
muscleGroup,
|
|
100
|
+
intensity,
|
|
101
|
+
volumeStatus,
|
|
102
|
+
weeklySets,
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SUMMARY_CARDS: Array<{
|
|
107
|
+
key: keyof TrainingStatusSummary['statusCounts'] | 'total'
|
|
108
|
+
label: string
|
|
109
|
+
}> = [
|
|
110
|
+
{ key: 'total', label: 'Weekly Sets' },
|
|
111
|
+
{ key: 'productive', label: 'Productive' },
|
|
112
|
+
{ key: 'under', label: 'Under' },
|
|
113
|
+
{ key: 'over', label: 'Over' },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
|
|
117
|
+
return (
|
|
118
|
+
<View className="flex-row flex-wrap" style={{ gap: 8 }} testID="training-status-page-summary">
|
|
119
|
+
{SUMMARY_CARDS.map(({ key, label }) => {
|
|
120
|
+
const value = key === 'total' ? summary.totalWeeklySets : summary.statusCounts[key]
|
|
121
|
+
return (
|
|
122
|
+
<View
|
|
123
|
+
key={key}
|
|
124
|
+
style={{
|
|
125
|
+
flexGrow: 1,
|
|
126
|
+
flexBasis: '46%',
|
|
127
|
+
padding: 12,
|
|
128
|
+
borderRadius: 10,
|
|
129
|
+
backgroundColor: SURFACE_ELEVATED,
|
|
130
|
+
borderWidth: 1,
|
|
131
|
+
borderColor: BORDER_DEFAULT,
|
|
132
|
+
}}
|
|
133
|
+
testID={`training-status-page-summary-${key}`}
|
|
134
|
+
>
|
|
135
|
+
<Text
|
|
136
|
+
style={{
|
|
137
|
+
fontSize: 22,
|
|
138
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
139
|
+
fontWeight: '700',
|
|
140
|
+
color: TEXT_PRIMARY,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{value}
|
|
144
|
+
</Text>
|
|
145
|
+
<Text
|
|
146
|
+
style={{
|
|
147
|
+
marginTop: 2,
|
|
148
|
+
fontSize: 11,
|
|
149
|
+
fontFamily: 'Inter, sans-serif',
|
|
150
|
+
fontWeight: '600',
|
|
151
|
+
letterSpacing: 0.4,
|
|
152
|
+
textTransform: 'uppercase',
|
|
153
|
+
color: TEXT_TERTIARY,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{label}
|
|
157
|
+
</Text>
|
|
158
|
+
</View>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</View>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function StatusLegend() {
|
|
166
|
+
return (
|
|
167
|
+
<View
|
|
168
|
+
className="flex-row flex-wrap"
|
|
169
|
+
style={{ gap: 10, justifyContent: 'center' }}
|
|
170
|
+
testID="training-status-page-legend"
|
|
171
|
+
>
|
|
172
|
+
{STATUS_ORDER.map((status) => (
|
|
173
|
+
<View
|
|
174
|
+
key={status}
|
|
175
|
+
className="flex-row items-center"
|
|
176
|
+
style={{ gap: 5 }}
|
|
177
|
+
testID={`training-status-page-legend-${status}`}
|
|
178
|
+
>
|
|
179
|
+
<View
|
|
180
|
+
style={{
|
|
181
|
+
width: 8,
|
|
182
|
+
height: 8,
|
|
183
|
+
borderRadius: 9999,
|
|
184
|
+
backgroundColor: getHeatmapColor(status, 0.6),
|
|
185
|
+
}}
|
|
186
|
+
accessibilityElementsHidden
|
|
187
|
+
/>
|
|
188
|
+
<Text
|
|
189
|
+
style={{
|
|
190
|
+
fontSize: 11,
|
|
191
|
+
fontFamily: 'Inter, sans-serif',
|
|
192
|
+
color: TEXT_SECONDARY,
|
|
193
|
+
textTransform: 'capitalize',
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
{VOLUME_STATUS_LABELS[status]}
|
|
197
|
+
</Text>
|
|
198
|
+
</View>
|
|
199
|
+
))}
|
|
200
|
+
</View>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface BodyMapColumnProps {
|
|
205
|
+
side: 'front' | 'back'
|
|
206
|
+
muscles: TrainingStatusMuscle[]
|
|
207
|
+
selected: MuscleGroup | null
|
|
208
|
+
onSelect: (muscle: MuscleGroup) => void
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function BodyMapColumn({ side, muscles, selected, onSelect }: BodyMapColumnProps) {
|
|
212
|
+
const data = useMemo(() => toBodyMapData(muscles, side), [muscles, side])
|
|
213
|
+
return (
|
|
214
|
+
<View style={{ flexGrow: 1, flexBasis: '46%' }} testID={`training-status-page-bodymap-${side}`}>
|
|
215
|
+
<BodyMap
|
|
216
|
+
data={data}
|
|
217
|
+
view={side}
|
|
218
|
+
onMusclePress={onSelect}
|
|
219
|
+
highlightedMuscle={selected}
|
|
220
|
+
mode="detailed"
|
|
221
|
+
/>
|
|
222
|
+
</View>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* TrainingStatusPage — a body-map training dashboard that composes the
|
|
228
|
+
* mesocycle context banner (`MesoStatusCard`), side-by-side front/back
|
|
229
|
+
* `BodyMap`s with a shared heatmap legend, per-status summary cards, and a
|
|
230
|
+
* tap-to-detail `BodyMapDetailPanel`. Tapping any muscle (SVG or legend chip)
|
|
231
|
+
* opens the slide-up panel for that group; the summary is derived from the
|
|
232
|
+
* supplied muscle rows.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* <TrainingStatusPage meso={meso} muscles={muscles} />
|
|
236
|
+
*/
|
|
237
|
+
export function TrainingStatusPage({
|
|
238
|
+
title = 'Training Status',
|
|
239
|
+
meso,
|
|
240
|
+
muscles,
|
|
241
|
+
className,
|
|
242
|
+
...props
|
|
243
|
+
}: TrainingStatusPageProps) {
|
|
244
|
+
const [selected, setSelected] = useState<MuscleGroup | null>(null)
|
|
245
|
+
const summary = useMemo(() => deriveTrainingSummary(muscles), [muscles])
|
|
246
|
+
const active = selected != null ? (muscles.find((m) => m.muscleGroup === selected) ?? null) : null
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<View
|
|
250
|
+
className={className}
|
|
251
|
+
style={{ position: 'relative', width: 390, backgroundColor: PAGE_BG }}
|
|
252
|
+
accessibilityRole={'main' as ViewProps['accessibilityRole']}
|
|
253
|
+
aria-label={title}
|
|
254
|
+
testID="training-status-page"
|
|
255
|
+
{...props}
|
|
256
|
+
>
|
|
257
|
+
<View style={{ padding: 16, gap: 16 }} testID="training-status-page-content">
|
|
258
|
+
<Text
|
|
259
|
+
accessibilityRole="header"
|
|
260
|
+
style={{
|
|
261
|
+
fontSize: 20,
|
|
262
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
263
|
+
fontWeight: '700',
|
|
264
|
+
color: TEXT_PRIMARY,
|
|
265
|
+
}}
|
|
266
|
+
testID="training-status-page-title"
|
|
267
|
+
>
|
|
268
|
+
{title}
|
|
269
|
+
</Text>
|
|
270
|
+
|
|
271
|
+
<MesoStatusCard {...meso} />
|
|
272
|
+
|
|
273
|
+
<SummaryCards summary={summary} />
|
|
274
|
+
|
|
275
|
+
<View
|
|
276
|
+
style={{
|
|
277
|
+
backgroundColor: SURFACE_ELEVATED,
|
|
278
|
+
borderWidth: 1,
|
|
279
|
+
borderColor: BORDER_DEFAULT,
|
|
280
|
+
borderRadius: 12,
|
|
281
|
+
padding: 12,
|
|
282
|
+
gap: 12,
|
|
283
|
+
}}
|
|
284
|
+
testID="training-status-page-bodymaps"
|
|
285
|
+
>
|
|
286
|
+
<View className="flex-row" style={{ gap: 8 }}>
|
|
287
|
+
<BodyMapColumn
|
|
288
|
+
side="front"
|
|
289
|
+
muscles={muscles}
|
|
290
|
+
selected={selected}
|
|
291
|
+
onSelect={setSelected}
|
|
292
|
+
/>
|
|
293
|
+
<BodyMapColumn
|
|
294
|
+
side="back"
|
|
295
|
+
muscles={muscles}
|
|
296
|
+
selected={selected}
|
|
297
|
+
onSelect={setSelected}
|
|
298
|
+
/>
|
|
299
|
+
</View>
|
|
300
|
+
<StatusLegend />
|
|
301
|
+
</View>
|
|
302
|
+
</View>
|
|
303
|
+
|
|
304
|
+
{active != null && (
|
|
305
|
+
<BodyMapDetailPanel
|
|
306
|
+
muscleGroup={active.muscleGroup}
|
|
307
|
+
displayName={active.displayName}
|
|
308
|
+
weeklySets={active.weeklySets}
|
|
309
|
+
landmarks={active.landmarks}
|
|
310
|
+
volumeStatus={active.volumeStatus}
|
|
311
|
+
lastTrained={active.lastTrained}
|
|
312
|
+
weeklyHistory={active.weeklyHistory}
|
|
313
|
+
contributingExercises={active.contributingExercises}
|
|
314
|
+
upcomingExercises={active.upcomingExercises}
|
|
315
|
+
isOpen
|
|
316
|
+
onClose={() => setSelected(null)}
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
</View>
|
|
320
|
+
)
|
|
321
|
+
}
|