@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.
- 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-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +25 -5
- package/src/bodymap.ts +37 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/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/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +347 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- 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.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +162 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/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.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +247 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +160 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
- 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.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +152 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/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/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- 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-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { Fragment, type ReactNode } from 'react'
|
|
3
|
+
import { View, Text, type ViewProps, type ViewStyle } from 'react-native'
|
|
4
|
+
import { Card } from '../../ui/card'
|
|
5
|
+
import { StatusDot } from './StatusDot'
|
|
6
|
+
|
|
7
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
8
|
+
const BRAND_PRIMARY_DARK = '#C45E00'
|
|
9
|
+
const BRAND_PRIMARY_LIGHT = '#FFB066'
|
|
10
|
+
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
11
|
+
const TEXT_SECONDARY = 'var(--color-text-secondary)'
|
|
12
|
+
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
13
|
+
|
|
14
|
+
const SUCCESS = '#14B8A6'
|
|
15
|
+
const WARNING = '#FFB020'
|
|
16
|
+
const ERROR = '#D14343'
|
|
17
|
+
|
|
18
|
+
/** Gradient stops for the 3px top accent: dark -> primary -> light (matches MesoCard). */
|
|
19
|
+
const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
|
|
20
|
+
|
|
21
|
+
/** Card surface gradient: elevated -> raised at 135deg (theme-aware). */
|
|
22
|
+
const CARD_GRADIENT =
|
|
23
|
+
'linear-gradient(135deg, var(--color-surface-elevated) 0%, var(--color-surface-raised) 100%)'
|
|
24
|
+
|
|
25
|
+
/** Gauge track gradient (teal -> amber -> red) at 0.25 alpha. */
|
|
26
|
+
const GAUGE_GRADIENT =
|
|
27
|
+
'linear-gradient(90deg, rgba(20,184,166,0.25) 0%, rgba(255,176,32,0.25) 50%, rgba(209,67,67,0.25) 100%)'
|
|
28
|
+
|
|
29
|
+
export type MesoStatusBadgeVariant = 'success' | 'warning' | 'error'
|
|
30
|
+
|
|
31
|
+
const STATUS_VARIANTS: Record<
|
|
32
|
+
MesoStatusBadgeVariant,
|
|
33
|
+
{ bg: string; border: string; text: string }
|
|
34
|
+
> = {
|
|
35
|
+
success: { bg: 'rgba(20,184,166,0.15)', border: 'rgba(20,184,166,0.3)', text: SUCCESS },
|
|
36
|
+
warning: { bg: 'rgba(255,176,32,0.15)', border: 'rgba(255,176,32,0.3)', text: WARNING },
|
|
37
|
+
error: { bg: 'rgba(209,67,67,0.15)', border: 'rgba(209,67,67,0.25)', text: ERROR },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MesoStatusBadge {
|
|
41
|
+
/** Short status label, e.g. "On Track". */
|
|
42
|
+
label: string
|
|
43
|
+
/** Color treatment for the badge. */
|
|
44
|
+
variant: MesoStatusBadgeVariant
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MesoStatusMetric {
|
|
48
|
+
/** Cell label, e.g. "Prescribed". */
|
|
49
|
+
label: string
|
|
50
|
+
/** Cell value, e.g. "3×8 @ RPE 8". */
|
|
51
|
+
value: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MesoStatusGauge {
|
|
55
|
+
/** Gauge label, e.g. "Intensity". */
|
|
56
|
+
label: string
|
|
57
|
+
/** Fill position 0-1; the 14px marker sits at this level. */
|
|
58
|
+
level: number
|
|
59
|
+
/** Optional right-aligned sublabel, e.g. "Prescribed". */
|
|
60
|
+
sublabel?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MesoStatusCoaching {
|
|
64
|
+
/** Coaching recommendation copy. */
|
|
65
|
+
text: string
|
|
66
|
+
/** Substrings within `text` to render in bold. */
|
|
67
|
+
highlights?: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MesoStatusNextTarget {
|
|
71
|
+
/** Leading glyph/emoji, e.g. "→" or "🎯". */
|
|
72
|
+
icon: string
|
|
73
|
+
/** Next-session target copy. */
|
|
74
|
+
text: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MesoStatusCardProps extends ViewProps {
|
|
78
|
+
/** Mesocycle name, e.g. "Hypertrophy Block". */
|
|
79
|
+
mesoName: string
|
|
80
|
+
/** Context line, e.g. "Week 6 of 8 · Upper/Lower". */
|
|
81
|
+
mesoSubtitle: string
|
|
82
|
+
/** Status pill in the header. */
|
|
83
|
+
statusBadge: MesoStatusBadge
|
|
84
|
+
/** Prescription-vs-actual metrics rendered as a 2x2 grid. */
|
|
85
|
+
metrics: MesoStatusMetric[]
|
|
86
|
+
/** Intensity / volume gauges stacked vertically. */
|
|
87
|
+
gauges: MesoStatusGauge[]
|
|
88
|
+
/** Optional coaching recommendation box. */
|
|
89
|
+
coaching?: MesoStatusCoaching
|
|
90
|
+
/** Optional next-session target box. */
|
|
91
|
+
nextTarget?: MesoStatusNextTarget
|
|
92
|
+
className?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clamp01(value: number): number {
|
|
96
|
+
if (!Number.isFinite(value)) return 0
|
|
97
|
+
if (value < 0) return 0
|
|
98
|
+
if (value > 1) return 1
|
|
99
|
+
return value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Marker fill color by gauge zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
|
|
103
|
+
function getGaugeZoneColor(level: number): string {
|
|
104
|
+
if (level >= 0.7) return ERROR
|
|
105
|
+
if (level >= 0.4) return WARNING
|
|
106
|
+
return SUCCESS
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Splits `text` into nodes, bolding any segment that exactly matches a highlight. */
|
|
110
|
+
function renderCoachingText(text: string, highlights?: string[]): ReactNode {
|
|
111
|
+
if (!highlights || highlights.length === 0) return text
|
|
112
|
+
const escaped = highlights
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.map((h) => h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
115
|
+
if (escaped.length === 0) return text
|
|
116
|
+
const regex = new RegExp(`(${escaped.join('|')})`, 'g')
|
|
117
|
+
return text.split(regex).map((part, index) =>
|
|
118
|
+
highlights.includes(part) ? (
|
|
119
|
+
<Text
|
|
120
|
+
key={`${part}-${index}`}
|
|
121
|
+
style={{ fontWeight: '700', color: TEXT_PRIMARY }}
|
|
122
|
+
>
|
|
123
|
+
{part}
|
|
124
|
+
</Text>
|
|
125
|
+
) : (
|
|
126
|
+
<Fragment key={`t-${index}`}>{part}</Fragment>
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function StatusPill({ badge }: { badge: MesoStatusBadge }) {
|
|
132
|
+
const colors = STATUS_VARIANTS[badge.variant]
|
|
133
|
+
return (
|
|
134
|
+
<View
|
|
135
|
+
className="flex-row items-center"
|
|
136
|
+
style={{
|
|
137
|
+
gap: 5,
|
|
138
|
+
paddingVertical: 3,
|
|
139
|
+
paddingHorizontal: 8,
|
|
140
|
+
borderRadius: 4,
|
|
141
|
+
borderWidth: 1,
|
|
142
|
+
backgroundColor: colors.bg,
|
|
143
|
+
borderColor: colors.border,
|
|
144
|
+
}}
|
|
145
|
+
testID="meso-status-card-badge"
|
|
146
|
+
>
|
|
147
|
+
<View accessibilityElementsHidden>
|
|
148
|
+
<StatusDot variant={badge.variant} size="sm" />
|
|
149
|
+
</View>
|
|
150
|
+
<Text
|
|
151
|
+
style={{
|
|
152
|
+
fontSize: 10,
|
|
153
|
+
fontFamily: 'Inter, sans-serif',
|
|
154
|
+
fontWeight: '700',
|
|
155
|
+
letterSpacing: 0.3,
|
|
156
|
+
color: colors.text,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{badge.label}
|
|
160
|
+
</Text>
|
|
161
|
+
</View>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function MetricCell({ metric }: { metric: MesoStatusMetric }) {
|
|
166
|
+
return (
|
|
167
|
+
<View style={{ width: '48%' }} testID="meso-status-card-metric">
|
|
168
|
+
<Text
|
|
169
|
+
style={{
|
|
170
|
+
fontSize: 10,
|
|
171
|
+
fontFamily: 'Inter, sans-serif',
|
|
172
|
+
fontWeight: '600',
|
|
173
|
+
letterSpacing: 0.5,
|
|
174
|
+
textTransform: 'uppercase',
|
|
175
|
+
color: TEXT_TERTIARY,
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{metric.label}
|
|
179
|
+
</Text>
|
|
180
|
+
<Text
|
|
181
|
+
style={{
|
|
182
|
+
marginTop: 2,
|
|
183
|
+
fontSize: 13,
|
|
184
|
+
fontFamily: 'Inter, sans-serif',
|
|
185
|
+
fontWeight: '600',
|
|
186
|
+
color: TEXT_PRIMARY,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{metric.value}
|
|
190
|
+
</Text>
|
|
191
|
+
</View>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function Gauge({ gauge }: { gauge: MesoStatusGauge }) {
|
|
196
|
+
const level = clamp01(gauge.level)
|
|
197
|
+
const percentage = Math.round(level * 100)
|
|
198
|
+
const markerColor = getGaugeZoneColor(level)
|
|
199
|
+
return (
|
|
200
|
+
<View
|
|
201
|
+
style={{ gap: 6 }}
|
|
202
|
+
accessibilityRole="progressbar"
|
|
203
|
+
accessibilityValue={{ min: 0, max: 100, now: percentage }}
|
|
204
|
+
accessibilityLabel={`${gauge.label}: ${percentage}%`}
|
|
205
|
+
aria-valuenow={percentage}
|
|
206
|
+
aria-valuemin={0}
|
|
207
|
+
aria-valuemax={100}
|
|
208
|
+
testID="meso-status-card-gauge"
|
|
209
|
+
>
|
|
210
|
+
<View
|
|
211
|
+
className="flex-row items-center justify-between"
|
|
212
|
+
accessibilityElementsHidden
|
|
213
|
+
>
|
|
214
|
+
<Text
|
|
215
|
+
style={{
|
|
216
|
+
fontSize: 10,
|
|
217
|
+
fontFamily: 'Inter, sans-serif',
|
|
218
|
+
fontWeight: '500',
|
|
219
|
+
letterSpacing: 0.5,
|
|
220
|
+
textTransform: 'uppercase',
|
|
221
|
+
color: TEXT_SECONDARY,
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
{gauge.label}
|
|
225
|
+
</Text>
|
|
226
|
+
{gauge.sublabel != null && (
|
|
227
|
+
<Text
|
|
228
|
+
style={{
|
|
229
|
+
fontSize: 10,
|
|
230
|
+
fontFamily: 'Inter, sans-serif',
|
|
231
|
+
fontWeight: '500',
|
|
232
|
+
color: TEXT_TERTIARY,
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
{gauge.sublabel}
|
|
236
|
+
</Text>
|
|
237
|
+
)}
|
|
238
|
+
</View>
|
|
239
|
+
|
|
240
|
+
<View
|
|
241
|
+
style={
|
|
242
|
+
{
|
|
243
|
+
height: 8,
|
|
244
|
+
borderRadius: 4,
|
|
245
|
+
position: 'relative',
|
|
246
|
+
backgroundImage: GAUGE_GRADIENT,
|
|
247
|
+
} as ViewStyle
|
|
248
|
+
}
|
|
249
|
+
accessibilityElementsHidden
|
|
250
|
+
>
|
|
251
|
+
<View
|
|
252
|
+
style={{
|
|
253
|
+
position: 'absolute',
|
|
254
|
+
left: '50%',
|
|
255
|
+
top: 0,
|
|
256
|
+
bottom: 0,
|
|
257
|
+
width: 1,
|
|
258
|
+
marginLeft: -0.5,
|
|
259
|
+
backgroundColor: 'rgba(255,255,255,0.3)',
|
|
260
|
+
}}
|
|
261
|
+
testID="meso-status-card-gauge-center"
|
|
262
|
+
/>
|
|
263
|
+
<View
|
|
264
|
+
style={{
|
|
265
|
+
position: 'absolute',
|
|
266
|
+
left: `${percentage}%`,
|
|
267
|
+
top: -3,
|
|
268
|
+
marginLeft: -7,
|
|
269
|
+
width: 14,
|
|
270
|
+
height: 14,
|
|
271
|
+
borderRadius: 9999,
|
|
272
|
+
borderWidth: 2,
|
|
273
|
+
borderColor: TEXT_PRIMARY,
|
|
274
|
+
backgroundColor: markerColor,
|
|
275
|
+
boxShadow: '0 0 4px rgba(0,0,0,0.5)',
|
|
276
|
+
}}
|
|
277
|
+
testID="meso-status-card-gauge-marker"
|
|
278
|
+
/>
|
|
279
|
+
</View>
|
|
280
|
+
</View>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Mesocycle context card for a specific exercise: prescription vs actual
|
|
286
|
+
* metrics, intensity/volume gauges, and coaching guidance. Composes the titan
|
|
287
|
+
* `Card` plus the Workout `StatusDot`, and renders the §25 gauge (8px track,
|
|
288
|
+
* 50% center line, 14px marker) inline since that visual differs from the
|
|
289
|
+
* IntensityBar/DeviationBar primitives.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* <MesoStatusCard
|
|
293
|
+
* mesoName="Hypertrophy Block"
|
|
294
|
+
* mesoSubtitle="Week 6 of 8 · Upper/Lower"
|
|
295
|
+
* statusBadge={{ label: 'On Track', variant: 'success' }}
|
|
296
|
+
* metrics={[
|
|
297
|
+
* { label: 'Prescribed', value: '3×8 @ RPE 8' },
|
|
298
|
+
* { label: 'Actual', value: '3×8 @ 185 lbs' },
|
|
299
|
+
* ]}
|
|
300
|
+
* gauges={[{ label: 'Intensity', level: 0.7, sublabel: 'Actual' }]}
|
|
301
|
+
* coaching={{ text: 'Add load next session.', highlights: ['Add load'] }}
|
|
302
|
+
* nextTarget={{ icon: '→', text: '190 lbs × 8' }}
|
|
303
|
+
* />
|
|
304
|
+
*/
|
|
305
|
+
export function MesoStatusCard({
|
|
306
|
+
mesoName,
|
|
307
|
+
mesoSubtitle,
|
|
308
|
+
statusBadge,
|
|
309
|
+
metrics,
|
|
310
|
+
gauges,
|
|
311
|
+
coaching,
|
|
312
|
+
nextTarget,
|
|
313
|
+
className,
|
|
314
|
+
...props
|
|
315
|
+
}: MesoStatusCardProps) {
|
|
316
|
+
return (
|
|
317
|
+
<Card
|
|
318
|
+
variant="outline"
|
|
319
|
+
elevation={1}
|
|
320
|
+
borderColor={BRAND_PRIMARY_DARK}
|
|
321
|
+
className={className}
|
|
322
|
+
style={
|
|
323
|
+
{
|
|
324
|
+
borderColor: BRAND_PRIMARY_DARK,
|
|
325
|
+
borderWidth: 1,
|
|
326
|
+
backgroundImage: CARD_GRADIENT,
|
|
327
|
+
} as ViewStyle
|
|
328
|
+
}
|
|
329
|
+
role="article"
|
|
330
|
+
aria-label={`Mesocycle status for ${mesoName}`}
|
|
331
|
+
testID="meso-status-card"
|
|
332
|
+
{...props}
|
|
333
|
+
>
|
|
334
|
+
<View
|
|
335
|
+
className="flex-row"
|
|
336
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, zIndex: 1 }}
|
|
337
|
+
accessibilityElementsHidden
|
|
338
|
+
testID="meso-status-card-accent"
|
|
339
|
+
>
|
|
340
|
+
{ACCENT_STOPS.map((color) => (
|
|
341
|
+
<View key={color} style={{ flex: 1, backgroundColor: color }} />
|
|
342
|
+
))}
|
|
343
|
+
</View>
|
|
344
|
+
|
|
345
|
+
<View style={{ padding: 14, gap: 14 }} testID="meso-status-card-body">
|
|
346
|
+
<View testID="meso-status-card-header">
|
|
347
|
+
<View
|
|
348
|
+
className="flex-row items-center justify-between"
|
|
349
|
+
style={{ gap: 8 }}
|
|
350
|
+
>
|
|
351
|
+
<Text
|
|
352
|
+
style={{
|
|
353
|
+
flexShrink: 1,
|
|
354
|
+
fontSize: 15,
|
|
355
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
356
|
+
fontWeight: '700',
|
|
357
|
+
color: TEXT_PRIMARY,
|
|
358
|
+
}}
|
|
359
|
+
testID="meso-status-card-name"
|
|
360
|
+
>
|
|
361
|
+
{mesoName}
|
|
362
|
+
</Text>
|
|
363
|
+
<StatusPill badge={statusBadge} />
|
|
364
|
+
</View>
|
|
365
|
+
<Text
|
|
366
|
+
style={{
|
|
367
|
+
marginTop: 4,
|
|
368
|
+
fontSize: 12,
|
|
369
|
+
fontFamily: 'Inter, sans-serif',
|
|
370
|
+
color: TEXT_SECONDARY,
|
|
371
|
+
}}
|
|
372
|
+
testID="meso-status-card-subtitle"
|
|
373
|
+
>
|
|
374
|
+
{mesoSubtitle}
|
|
375
|
+
</Text>
|
|
376
|
+
</View>
|
|
377
|
+
|
|
378
|
+
{metrics.length > 0 && (
|
|
379
|
+
<View
|
|
380
|
+
className="flex-row flex-wrap"
|
|
381
|
+
style={{ gap: 8 }}
|
|
382
|
+
testID="meso-status-card-metrics"
|
|
383
|
+
>
|
|
384
|
+
{metrics.map((metric, index) => (
|
|
385
|
+
<MetricCell key={`${metric.label}-${index}`} metric={metric} />
|
|
386
|
+
))}
|
|
387
|
+
</View>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{gauges.length > 0 && (
|
|
391
|
+
<View style={{ gap: 12 }} testID="meso-status-card-gauges">
|
|
392
|
+
{gauges.map((gauge, index) => (
|
|
393
|
+
<Gauge key={`${gauge.label}-${index}`} gauge={gauge} />
|
|
394
|
+
))}
|
|
395
|
+
</View>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{coaching != null && (
|
|
399
|
+
<View
|
|
400
|
+
style={{
|
|
401
|
+
backgroundColor: 'rgba(255,176,32,0.06)',
|
|
402
|
+
borderWidth: 1,
|
|
403
|
+
borderColor: 'rgba(255,176,32,0.15)',
|
|
404
|
+
borderRadius: 8,
|
|
405
|
+
paddingVertical: 10,
|
|
406
|
+
paddingHorizontal: 12,
|
|
407
|
+
}}
|
|
408
|
+
testID="meso-status-card-coaching"
|
|
409
|
+
>
|
|
410
|
+
<Text
|
|
411
|
+
style={{
|
|
412
|
+
fontSize: 12,
|
|
413
|
+
lineHeight: 17,
|
|
414
|
+
fontFamily: 'Inter, sans-serif',
|
|
415
|
+
color: TEXT_SECONDARY,
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
{renderCoachingText(coaching.text, coaching.highlights)}
|
|
419
|
+
</Text>
|
|
420
|
+
</View>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{nextTarget != null && (
|
|
424
|
+
<View
|
|
425
|
+
className="flex-row items-center"
|
|
426
|
+
style={{
|
|
427
|
+
gap: 8,
|
|
428
|
+
backgroundColor: 'rgba(20,184,166,0.06)',
|
|
429
|
+
borderWidth: 1,
|
|
430
|
+
borderColor: 'rgba(20,184,166,0.2)',
|
|
431
|
+
borderRadius: 8,
|
|
432
|
+
paddingVertical: 10,
|
|
433
|
+
paddingHorizontal: 12,
|
|
434
|
+
}}
|
|
435
|
+
testID="meso-status-card-next-target"
|
|
436
|
+
>
|
|
437
|
+
<Text style={{ fontSize: 14, color: SUCCESS }} accessibilityElementsHidden>
|
|
438
|
+
{nextTarget.icon}
|
|
439
|
+
</Text>
|
|
440
|
+
<Text
|
|
441
|
+
style={{
|
|
442
|
+
flexShrink: 1,
|
|
443
|
+
fontSize: 12,
|
|
444
|
+
fontFamily: 'Inter, sans-serif',
|
|
445
|
+
fontWeight: '600',
|
|
446
|
+
color: SUCCESS,
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
{nextTarget.text}
|
|
450
|
+
</Text>
|
|
451
|
+
</View>
|
|
452
|
+
)}
|
|
453
|
+
</View>
|
|
454
|
+
</Card>
|
|
455
|
+
)
|
|
456
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { MuscleGroupChip, type VolumeStatus } from './MuscleGroupChip'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MuscleGroupChip> = {
|
|
6
|
+
title: 'Custom/Workout/MuscleGroupChip',
|
|
7
|
+
component: MuscleGroupChip,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
name: {
|
|
11
|
+
control: 'text',
|
|
12
|
+
description: 'Muscle group name',
|
|
13
|
+
},
|
|
14
|
+
volumeStatus: {
|
|
15
|
+
control: 'select',
|
|
16
|
+
options: [undefined, 'untrained', 'behind', 'ontrack', 'target', 'over'],
|
|
17
|
+
description: 'Volume status determines dot color',
|
|
18
|
+
},
|
|
19
|
+
onPress: {
|
|
20
|
+
action: 'pressed',
|
|
21
|
+
description: 'Callback when chip is tapped',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default meta
|
|
27
|
+
type Story = StoryObj<typeof MuscleGroupChip>
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
name: 'Chest',
|
|
32
|
+
volumeStatus: 'ontrack',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Untrained: Story = {
|
|
37
|
+
args: {
|
|
38
|
+
name: 'Rear Delts',
|
|
39
|
+
volumeStatus: 'untrained',
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Behind: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
name: 'Quads',
|
|
46
|
+
volumeStatus: 'behind',
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const OnTrack: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
name: 'Back',
|
|
53
|
+
volumeStatus: 'ontrack',
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const Target: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
name: 'Biceps',
|
|
60
|
+
volumeStatus: 'target',
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const Over: Story = {
|
|
65
|
+
args: {
|
|
66
|
+
name: 'Front Delts',
|
|
67
|
+
volumeStatus: 'over',
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const WithoutStatus: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
name: 'Hamstrings',
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const Tappable: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
name: 'Glutes',
|
|
80
|
+
volumeStatus: 'ontrack',
|
|
81
|
+
onPress: () => {},
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const allStatuses: VolumeStatus[] = [
|
|
86
|
+
'untrained',
|
|
87
|
+
'behind',
|
|
88
|
+
'ontrack',
|
|
89
|
+
'target',
|
|
90
|
+
'over',
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
export const AllStatuses: Story = {
|
|
94
|
+
render: () => (
|
|
95
|
+
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
|
96
|
+
{allStatuses.map((status) => (
|
|
97
|
+
<MuscleGroupChip
|
|
98
|
+
key={status}
|
|
99
|
+
name={status.charAt(0).toUpperCase() + status.slice(1)}
|
|
100
|
+
volumeStatus={status}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
<MuscleGroupChip name="No Status" />
|
|
104
|
+
</View>
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const MuscleGroups: Story = {
|
|
109
|
+
render: () => (
|
|
110
|
+
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
|
111
|
+
<MuscleGroupChip name="Chest" volumeStatus="ontrack" />
|
|
112
|
+
<MuscleGroupChip name="Triceps" volumeStatus="target" />
|
|
113
|
+
<MuscleGroupChip name="Front Delts" volumeStatus="over" />
|
|
114
|
+
<MuscleGroupChip name="Side Delts" volumeStatus="behind" />
|
|
115
|
+
<MuscleGroupChip name="Abs" volumeStatus="untrained" />
|
|
116
|
+
</View>
|
|
117
|
+
),
|
|
118
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { MuscleGroupChip } from './MuscleGroupChip'
|
|
5
|
+
|
|
6
|
+
describe('MuscleGroupChip', () => {
|
|
7
|
+
it('renders muscle group name', () => {
|
|
8
|
+
render(<MuscleGroupChip name="Chest" />)
|
|
9
|
+
expect(screen.getByText('Chest')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders the colored dot', () => {
|
|
13
|
+
render(<MuscleGroupChip name="Quads" volumeStatus="ontrack" />)
|
|
14
|
+
expect(screen.getByTestId('muscle-group-chip-dot')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('sets accessibility label with name and status', () => {
|
|
18
|
+
render(<MuscleGroupChip name="Back" volumeStatus="behind" />)
|
|
19
|
+
expect(
|
|
20
|
+
screen.getByLabelText('Back, volume status: behind'),
|
|
21
|
+
).toBeInTheDocument()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('sets accessibility label with no status when omitted', () => {
|
|
25
|
+
render(<MuscleGroupChip name="Biceps" />)
|
|
26
|
+
expect(
|
|
27
|
+
screen.getByLabelText('Biceps, volume status: no status'),
|
|
28
|
+
).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('wraps in Pressable when onPress is provided', () => {
|
|
32
|
+
const onPress = vi.fn()
|
|
33
|
+
render(<MuscleGroupChip name="Chest" onPress={onPress} />)
|
|
34
|
+
const pressable = screen.getByTestId('muscle-group-chip-pressable')
|
|
35
|
+
fireEvent.click(pressable)
|
|
36
|
+
expect(onPress).toHaveBeenCalledOnce()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not render Pressable when onPress is omitted', () => {
|
|
40
|
+
render(<MuscleGroupChip name="Chest" />)
|
|
41
|
+
expect(
|
|
42
|
+
screen.queryByTestId('muscle-group-chip-pressable'),
|
|
43
|
+
).not.toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders all volume status variants without error', () => {
|
|
47
|
+
const statuses = [
|
|
48
|
+
'untrained',
|
|
49
|
+
'behind',
|
|
50
|
+
'ontrack',
|
|
51
|
+
'target',
|
|
52
|
+
'over',
|
|
53
|
+
] as const
|
|
54
|
+
const { rerender } = render(
|
|
55
|
+
<MuscleGroupChip name="Test" volumeStatus="untrained" />,
|
|
56
|
+
)
|
|
57
|
+
for (const status of statuses) {
|
|
58
|
+
rerender(<MuscleGroupChip name="Test" volumeStatus={status} />)
|
|
59
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('accessibility', () => {
|
|
64
|
+
it('has no accessibility violations', async () => {
|
|
65
|
+
const { container } = render(
|
|
66
|
+
<MuscleGroupChip
|
|
67
|
+
name="Chest"
|
|
68
|
+
volumeStatus="ontrack"
|
|
69
|
+
onPress={() => {}}
|
|
70
|
+
/>,
|
|
71
|
+
)
|
|
72
|
+
const results = await axe(container)
|
|
73
|
+
expect(results).toHaveNoViolations()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('has no accessibility violations as static chip', async () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<MuscleGroupChip name="Quads" volumeStatus="behind" />,
|
|
79
|
+
)
|
|
80
|
+
const results = await axe(container)
|
|
81
|
+
expect(results).toHaveNoViolations()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('pressable has correct accessibility label', () => {
|
|
85
|
+
render(
|
|
86
|
+
<MuscleGroupChip name="Back" volumeStatus="target" onPress={() => {}} />,
|
|
87
|
+
)
|
|
88
|
+
expect(screen.getByLabelText('Back, volume status: target')).toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|