@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.
- package/dist/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -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 +16 -5
- 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/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/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 +348 -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 +478 -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 +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -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 +163 -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 +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -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 +91 -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/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- 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 +253 -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 +159 -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 +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -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 +226 -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 +113 -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/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/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,628 @@
|
|
|
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 { cn } from '../../../utils/cn'
|
|
5
|
+
|
|
6
|
+
const BRAND_PRIMARY = '#FF7900'
|
|
7
|
+
const TEXT_PRIMARY = '#F3F4F6'
|
|
8
|
+
const TEXT_SECONDARY = '#9CA3AF'
|
|
9
|
+
const TEXT_TERTIARY = '#6B7280'
|
|
10
|
+
const BORDER_STRONG = '#2C2C2C'
|
|
11
|
+
const STATUS_SUCCESS = '#14B8A6'
|
|
12
|
+
const STATUS_ERROR = '#D14343'
|
|
13
|
+
const STATUS_WARNING = '#FFB020'
|
|
14
|
+
const GRID_LINE = 'rgba(255,255,255,0.06)'
|
|
15
|
+
const TOOLTIP_BG = 'rgba(28,28,28,0.95)'
|
|
16
|
+
const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
|
|
17
|
+
const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
|
|
18
|
+
const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
|
|
19
|
+
const ERROR_PILL_BORDER = 'rgba(209,67,67,0.20)'
|
|
20
|
+
|
|
21
|
+
/** Left gutter reserved for y-axis value labels. */
|
|
22
|
+
const PLOT_LEFT = 26
|
|
23
|
+
/** Approximate tooltip width used for edge clamping. */
|
|
24
|
+
const TOOLTIP_WIDTH = 124
|
|
25
|
+
|
|
26
|
+
export interface StrengthTrendDataPoint {
|
|
27
|
+
/** ISO date string for the session. */
|
|
28
|
+
date: string
|
|
29
|
+
/** Estimated one-rep max in the chart's unit. */
|
|
30
|
+
e1rm: number
|
|
31
|
+
/** Marks a personal-record session (rendered with a star). */
|
|
32
|
+
isPR?: boolean
|
|
33
|
+
/** Optional human label shown in the tooltip instead of the raw date. */
|
|
34
|
+
sessionLabel?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StrengthTrendChartMesoBoundary {
|
|
38
|
+
/** ISO date where the mesocycle boundary falls. */
|
|
39
|
+
date: string
|
|
40
|
+
/** Short label, e.g. "Hypertrophy". */
|
|
41
|
+
label: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StrengthTrendChartProps extends ViewProps {
|
|
45
|
+
/** Actual e1RM data points, chronological. */
|
|
46
|
+
data: StrengthTrendDataPoint[]
|
|
47
|
+
/** Projected/planned e1RM trajectory, drawn as a dashed line. */
|
|
48
|
+
projection?: StrengthTrendDataPoint[]
|
|
49
|
+
/** Chart plot width in pixels. */
|
|
50
|
+
width: number
|
|
51
|
+
/** Chart plot height in pixels (120-160 for the compact variant). */
|
|
52
|
+
height: number
|
|
53
|
+
/** Mesocycle boundary markers (vertical guides + labels). */
|
|
54
|
+
mesoBoundaries?: StrengthTrendChartMesoBoundary[]
|
|
55
|
+
/** Called when an actual data point is tapped. */
|
|
56
|
+
onPointPress?: (point: StrengthTrendDataPoint) => void
|
|
57
|
+
/** Unit for display and labels. */
|
|
58
|
+
unit: 'lbs' | 'kg'
|
|
59
|
+
/** Animate the left-to-right line draw on mount (default true). */
|
|
60
|
+
animateOnMount?: boolean
|
|
61
|
+
className?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Coord {
|
|
65
|
+
x: number
|
|
66
|
+
y: number
|
|
67
|
+
point: StrengthTrendDataPoint
|
|
68
|
+
index: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Geometry {
|
|
72
|
+
hasData: boolean
|
|
73
|
+
toX: (time: number) => number
|
|
74
|
+
toY: (value: number) => number
|
|
75
|
+
actual: Coord[]
|
|
76
|
+
projection: Coord[]
|
|
77
|
+
gridLines: Array<{ y: number; label: string }>
|
|
78
|
+
boundaries: Array<{ x: number; label: string }>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function timeOf(dateStr: string): number {
|
|
82
|
+
const t = Date.parse(dateStr)
|
|
83
|
+
return Number.isNaN(t) ? 0 : t
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatValue(value: number): string {
|
|
87
|
+
return `${Math.round(value)}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildGeometry(
|
|
91
|
+
data: StrengthTrendDataPoint[],
|
|
92
|
+
projection: StrengthTrendDataPoint[],
|
|
93
|
+
boundaries: StrengthTrendChartMesoBoundary[],
|
|
94
|
+
width: number,
|
|
95
|
+
height: number,
|
|
96
|
+
): Geometry {
|
|
97
|
+
const values = [...data, ...projection].map((p) => p.e1rm)
|
|
98
|
+
// Empty state keys off actual data only: a projection with no measured
|
|
99
|
+
// sessions still shows the placeholder rather than a "Current: 0" chart.
|
|
100
|
+
if (data.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
hasData: false,
|
|
103
|
+
toX: () => 0,
|
|
104
|
+
toY: () => 0,
|
|
105
|
+
actual: [],
|
|
106
|
+
projection: [],
|
|
107
|
+
gridLines: [],
|
|
108
|
+
boundaries: [],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const times = [
|
|
113
|
+
...data.map((p) => timeOf(p.date)),
|
|
114
|
+
...projection.map((p) => timeOf(p.date)),
|
|
115
|
+
...boundaries.map((b) => timeOf(b.date)),
|
|
116
|
+
]
|
|
117
|
+
const tMin = Math.min(...times)
|
|
118
|
+
const tMaxRaw = Math.max(...times)
|
|
119
|
+
const tMax = tMaxRaw === tMin ? tMin + 1 : tMaxRaw
|
|
120
|
+
|
|
121
|
+
const yMin = Math.min(...values)
|
|
122
|
+
const yMax = Math.max(...values)
|
|
123
|
+
const range = yMax - yMin || Math.max(1, yMax * 0.1)
|
|
124
|
+
const padV = range * 0.15
|
|
125
|
+
const domainMin = yMin - padV
|
|
126
|
+
const domainMax = yMax + padV
|
|
127
|
+
|
|
128
|
+
const innerW = Math.max(1, width - PLOT_LEFT)
|
|
129
|
+
const toX = (time: number) =>
|
|
130
|
+
PLOT_LEFT + ((time - tMin) / (tMax - tMin)) * innerW
|
|
131
|
+
const toY = (value: number) =>
|
|
132
|
+
height - ((value - domainMin) / (domainMax - domainMin)) * height
|
|
133
|
+
|
|
134
|
+
const project = (points: StrengthTrendDataPoint[]): Coord[] =>
|
|
135
|
+
points.map((point, index) => ({
|
|
136
|
+
x: toX(timeOf(point.date)),
|
|
137
|
+
y: toY(point.e1rm),
|
|
138
|
+
point,
|
|
139
|
+
index,
|
|
140
|
+
}))
|
|
141
|
+
|
|
142
|
+
const tickValues = Array.from(
|
|
143
|
+
new Set([yMax, (yMin + yMax) / 2, yMin].map((v) => Math.round(v))),
|
|
144
|
+
)
|
|
145
|
+
const gridLines = tickValues.map((v) => ({ y: toY(v), label: formatValue(v) }))
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
hasData: true,
|
|
149
|
+
toX,
|
|
150
|
+
toY,
|
|
151
|
+
actual: project(data),
|
|
152
|
+
projection: project(projection),
|
|
153
|
+
gridLines,
|
|
154
|
+
boundaries: boundaries.map((b) => ({
|
|
155
|
+
x: toX(timeOf(b.date)),
|
|
156
|
+
label: b.label,
|
|
157
|
+
})),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function computeTrend(
|
|
162
|
+
data: StrengthTrendDataPoint[],
|
|
163
|
+
boundaries: StrengthTrendChartMesoBoundary[],
|
|
164
|
+
): { percent: number; positive: boolean } {
|
|
165
|
+
if (data.length < 2) return { percent: 0, positive: true }
|
|
166
|
+
let series = data
|
|
167
|
+
if (boundaries.length > 0) {
|
|
168
|
+
const lastBoundary = Math.max(...boundaries.map((b) => timeOf(b.date)))
|
|
169
|
+
const filtered = data.filter((p) => timeOf(p.date) >= lastBoundary)
|
|
170
|
+
// The pill is labelled "this meso"; if the current meso has <2 points
|
|
171
|
+
// there is no meso trend to show — stay neutral rather than silently
|
|
172
|
+
// reporting the whole-series change under a "this meso" label.
|
|
173
|
+
if (filtered.length < 2) return { percent: 0, positive: true }
|
|
174
|
+
series = filtered
|
|
175
|
+
}
|
|
176
|
+
const first = series[0].e1rm
|
|
177
|
+
const last = series[series.length - 1].e1rm
|
|
178
|
+
const percent = first ? ((last - first) / first) * 100 : 0
|
|
179
|
+
return { percent, positive: percent >= 0 }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ChartLine({
|
|
183
|
+
coords,
|
|
184
|
+
color,
|
|
185
|
+
strokeWidth,
|
|
186
|
+
dashed,
|
|
187
|
+
testID,
|
|
188
|
+
}: {
|
|
189
|
+
coords: Coord[]
|
|
190
|
+
color: string
|
|
191
|
+
strokeWidth: number
|
|
192
|
+
dashed?: boolean
|
|
193
|
+
testID: string
|
|
194
|
+
}) {
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
{coords.map((c, i) => {
|
|
198
|
+
if (i === 0) return null
|
|
199
|
+
const prev = coords[i - 1]
|
|
200
|
+
const dx = c.x - prev.x
|
|
201
|
+
const dy = c.y - prev.y
|
|
202
|
+
const length = Math.sqrt(dx * dx + dy * dy)
|
|
203
|
+
const angle = Math.atan2(dy, dx) * (180 / Math.PI)
|
|
204
|
+
return (
|
|
205
|
+
<View
|
|
206
|
+
key={`${testID}-${i}`}
|
|
207
|
+
testID={testID}
|
|
208
|
+
accessibilityElementsHidden
|
|
209
|
+
style={[
|
|
210
|
+
{
|
|
211
|
+
position: 'absolute',
|
|
212
|
+
left: prev.x,
|
|
213
|
+
top: prev.y,
|
|
214
|
+
width: length,
|
|
215
|
+
transformOrigin: '0 0',
|
|
216
|
+
transform: [{ rotate: `${angle}deg` }],
|
|
217
|
+
},
|
|
218
|
+
dashed
|
|
219
|
+
? {
|
|
220
|
+
height: 0,
|
|
221
|
+
borderTopWidth: strokeWidth,
|
|
222
|
+
borderStyle: 'dashed',
|
|
223
|
+
borderTopColor: color,
|
|
224
|
+
}
|
|
225
|
+
: {
|
|
226
|
+
height: strokeWidth,
|
|
227
|
+
borderRadius: strokeWidth / 2,
|
|
228
|
+
backgroundColor: color,
|
|
229
|
+
},
|
|
230
|
+
]}
|
|
231
|
+
/>
|
|
232
|
+
)
|
|
233
|
+
})}
|
|
234
|
+
</>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Estimated-1RM line chart over time with an optional dashed plan-projection
|
|
240
|
+
* line and PR star markers. Rendered entirely with absolutely-positioned
|
|
241
|
+
* Views (no SVG), following the Sparkline approach. Tapping an actual point
|
|
242
|
+
* opens a tooltip and fires `onPointPress`. A trend pill below the chart
|
|
243
|
+
* summarizes the percentage change for the current mesocycle.
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* <StrengthTrendChart
|
|
247
|
+
* data={[{ date: '2026-01-01', e1rm: 225 }, { date: '2026-02-01', e1rm: 245, isPR: true }]}
|
|
248
|
+
* projection={[{ date: '2026-02-01', e1rm: 245 }, { date: '2026-03-01', e1rm: 260 }]}
|
|
249
|
+
* width={320}
|
|
250
|
+
* height={150}
|
|
251
|
+
* unit="lbs"
|
|
252
|
+
* />
|
|
253
|
+
*/
|
|
254
|
+
export function StrengthTrendChart({
|
|
255
|
+
data,
|
|
256
|
+
projection = [],
|
|
257
|
+
width,
|
|
258
|
+
height,
|
|
259
|
+
mesoBoundaries = [],
|
|
260
|
+
onPointPress,
|
|
261
|
+
unit,
|
|
262
|
+
animateOnMount = true,
|
|
263
|
+
className,
|
|
264
|
+
...props
|
|
265
|
+
}: StrengthTrendChartProps) {
|
|
266
|
+
const geometry = useMemo(
|
|
267
|
+
() => buildGeometry(data, projection, mesoBoundaries, width, height),
|
|
268
|
+
[data, projection, mesoBoundaries, width, height],
|
|
269
|
+
)
|
|
270
|
+
const trend = useMemo(
|
|
271
|
+
() => computeTrend(data, mesoBoundaries),
|
|
272
|
+
[data, mesoBoundaries],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const [reveal] = useState(() => new Animated.Value(animateOnMount ? 0 : 1))
|
|
276
|
+
const [selected, setSelected] = useState<number | null>(null)
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!animateOnMount) return
|
|
280
|
+
const animation = Animated.timing(reveal, {
|
|
281
|
+
toValue: 1,
|
|
282
|
+
duration: 600,
|
|
283
|
+
easing: Easing.out(Easing.ease),
|
|
284
|
+
useNativeDriver: false,
|
|
285
|
+
})
|
|
286
|
+
animation.start()
|
|
287
|
+
return () => animation.stop()
|
|
288
|
+
}, [animateOnMount, reveal])
|
|
289
|
+
|
|
290
|
+
const current = data.length > 0 ? data[data.length - 1].e1rm : 0
|
|
291
|
+
const trendSign = trend.percent >= 0 ? '+' : '-'
|
|
292
|
+
const trendText = `${trendSign}${Math.abs(trend.percent).toFixed(1)}% this meso`
|
|
293
|
+
const ariaLabel = geometry.hasData
|
|
294
|
+
? `Strength trend chart showing estimated one rep max over time. Current: ${formatValue(current)} ${unit}. Trend: ${trendSign}${Math.abs(trend.percent).toFixed(1)}%`
|
|
295
|
+
: 'Strength trend chart, no data'
|
|
296
|
+
|
|
297
|
+
if (!geometry.hasData) {
|
|
298
|
+
return (
|
|
299
|
+
<View
|
|
300
|
+
style={{ width }}
|
|
301
|
+
className={cn(className)}
|
|
302
|
+
accessibilityRole="image"
|
|
303
|
+
accessibilityLabel={ariaLabel}
|
|
304
|
+
testID="strength-trend-chart-empty"
|
|
305
|
+
{...props}
|
|
306
|
+
>
|
|
307
|
+
<View
|
|
308
|
+
style={{
|
|
309
|
+
width,
|
|
310
|
+
height,
|
|
311
|
+
alignItems: 'center',
|
|
312
|
+
justifyContent: 'center',
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}>
|
|
316
|
+
No strength data yet
|
|
317
|
+
</Text>
|
|
318
|
+
</View>
|
|
319
|
+
</View>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const revealWidth = reveal.interpolate({
|
|
324
|
+
inputRange: [0, 1],
|
|
325
|
+
outputRange: [0, width],
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const selectedCoord =
|
|
329
|
+
selected != null ? geometry.actual[selected] : undefined
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<View
|
|
333
|
+
style={{ width }}
|
|
334
|
+
className={cn(className)}
|
|
335
|
+
testID="strength-trend-chart"
|
|
336
|
+
{...props}
|
|
337
|
+
>
|
|
338
|
+
<View style={{ width, height, position: 'relative' }}>
|
|
339
|
+
{/* Labelled, non-interactive chart canvas (grid + lines + markers). */}
|
|
340
|
+
<View
|
|
341
|
+
style={{ position: 'absolute', top: 0, left: 0, width, height }}
|
|
342
|
+
accessibilityRole="image"
|
|
343
|
+
accessibilityLabel={ariaLabel}
|
|
344
|
+
testID="strength-trend-chart-canvas"
|
|
345
|
+
>
|
|
346
|
+
{geometry.gridLines.map((line, i) => (
|
|
347
|
+
<View
|
|
348
|
+
key={`grid-${i}`}
|
|
349
|
+
accessibilityElementsHidden
|
|
350
|
+
testID="strength-trend-chart-gridline"
|
|
351
|
+
style={{
|
|
352
|
+
position: 'absolute',
|
|
353
|
+
left: PLOT_LEFT,
|
|
354
|
+
right: 0,
|
|
355
|
+
top: line.y,
|
|
356
|
+
height: 1,
|
|
357
|
+
backgroundColor: GRID_LINE,
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
<Text
|
|
361
|
+
style={{
|
|
362
|
+
position: 'absolute',
|
|
363
|
+
left: -PLOT_LEFT,
|
|
364
|
+
top: -6,
|
|
365
|
+
width: PLOT_LEFT - 4,
|
|
366
|
+
textAlign: 'right',
|
|
367
|
+
fontSize: 9,
|
|
368
|
+
fontFamily: 'Inter, sans-serif',
|
|
369
|
+
color: TEXT_TERTIARY,
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{line.label}
|
|
373
|
+
</Text>
|
|
374
|
+
</View>
|
|
375
|
+
))}
|
|
376
|
+
|
|
377
|
+
{geometry.boundaries.map((boundary, i) => (
|
|
378
|
+
<View
|
|
379
|
+
key={`boundary-${i}`}
|
|
380
|
+
accessibilityElementsHidden
|
|
381
|
+
testID="strength-trend-chart-meso-boundary"
|
|
382
|
+
style={{
|
|
383
|
+
position: 'absolute',
|
|
384
|
+
left: boundary.x,
|
|
385
|
+
top: 0,
|
|
386
|
+
width: 0,
|
|
387
|
+
height,
|
|
388
|
+
borderLeftWidth: 1,
|
|
389
|
+
borderStyle: 'dashed',
|
|
390
|
+
borderLeftColor: 'rgba(255,255,255,0.10)',
|
|
391
|
+
}}
|
|
392
|
+
/>
|
|
393
|
+
))}
|
|
394
|
+
|
|
395
|
+
<Animated.View
|
|
396
|
+
style={{
|
|
397
|
+
position: 'absolute',
|
|
398
|
+
top: 0,
|
|
399
|
+
left: 0,
|
|
400
|
+
width: revealWidth,
|
|
401
|
+
height,
|
|
402
|
+
overflow: 'hidden',
|
|
403
|
+
}}
|
|
404
|
+
testID="strength-trend-chart-reveal"
|
|
405
|
+
>
|
|
406
|
+
<View style={{ width, height, position: 'absolute', top: 0, left: 0 }}>
|
|
407
|
+
<ChartLine
|
|
408
|
+
coords={geometry.projection}
|
|
409
|
+
color={TEXT_TERTIARY}
|
|
410
|
+
strokeWidth={2}
|
|
411
|
+
dashed
|
|
412
|
+
testID="strength-trend-chart-projection-segment"
|
|
413
|
+
/>
|
|
414
|
+
<ChartLine
|
|
415
|
+
coords={geometry.actual}
|
|
416
|
+
color={BRAND_PRIMARY}
|
|
417
|
+
strokeWidth={2.5}
|
|
418
|
+
testID="strength-trend-chart-actual-segment"
|
|
419
|
+
/>
|
|
420
|
+
</View>
|
|
421
|
+
</Animated.View>
|
|
422
|
+
|
|
423
|
+
{geometry.actual.map((c) =>
|
|
424
|
+
c.point.isPR ? (
|
|
425
|
+
<Text
|
|
426
|
+
key={`star-${c.index}`}
|
|
427
|
+
accessibilityElementsHidden
|
|
428
|
+
testID="strength-trend-chart-pr-star"
|
|
429
|
+
style={{
|
|
430
|
+
position: 'absolute',
|
|
431
|
+
left: c.x - 7,
|
|
432
|
+
top: c.y - 20,
|
|
433
|
+
fontSize: 12,
|
|
434
|
+
color: STATUS_WARNING,
|
|
435
|
+
}}
|
|
436
|
+
>
|
|
437
|
+
★
|
|
438
|
+
</Text>
|
|
439
|
+
) : null,
|
|
440
|
+
)}
|
|
441
|
+
</View>
|
|
442
|
+
|
|
443
|
+
{/* Interactive point overlay (kept out of the image-role canvas). */}
|
|
444
|
+
<View
|
|
445
|
+
style={{ position: 'absolute', top: 0, left: 0, width, height }}
|
|
446
|
+
testID="strength-trend-chart-points"
|
|
447
|
+
>
|
|
448
|
+
{geometry.actual.map((c) => {
|
|
449
|
+
const label = c.point.sessionLabel ?? c.point.date
|
|
450
|
+
return (
|
|
451
|
+
<Pressable
|
|
452
|
+
key={`point-${c.index}`}
|
|
453
|
+
testID="strength-trend-chart-point"
|
|
454
|
+
accessibilityRole="button"
|
|
455
|
+
accessibilityLabel={`${label}: ${formatValue(c.point.e1rm)} ${unit}${c.point.isPR ? ', personal record' : ''}`}
|
|
456
|
+
onPress={() => {
|
|
457
|
+
setSelected(c.index)
|
|
458
|
+
onPointPress?.(c.point)
|
|
459
|
+
}}
|
|
460
|
+
style={{
|
|
461
|
+
position: 'absolute',
|
|
462
|
+
left: c.x - 4,
|
|
463
|
+
top: c.y - 4,
|
|
464
|
+
width: 8,
|
|
465
|
+
height: 8,
|
|
466
|
+
borderRadius: 1,
|
|
467
|
+
backgroundColor: BRAND_PRIMARY,
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
)
|
|
471
|
+
})}
|
|
472
|
+
</View>
|
|
473
|
+
|
|
474
|
+
{selectedCoord && (
|
|
475
|
+
<View
|
|
476
|
+
testID="strength-trend-chart-tooltip"
|
|
477
|
+
accessibilityElementsHidden
|
|
478
|
+
style={{
|
|
479
|
+
position: 'absolute',
|
|
480
|
+
left: Math.max(
|
|
481
|
+
0,
|
|
482
|
+
Math.min(width - TOOLTIP_WIDTH, selectedCoord.x - TOOLTIP_WIDTH / 2),
|
|
483
|
+
),
|
|
484
|
+
top: Math.max(0, selectedCoord.y - 52),
|
|
485
|
+
maxWidth: TOOLTIP_WIDTH,
|
|
486
|
+
backgroundColor: TOOLTIP_BG,
|
|
487
|
+
borderWidth: 1,
|
|
488
|
+
borderColor: BORDER_STRONG,
|
|
489
|
+
borderRadius: 8,
|
|
490
|
+
paddingVertical: 8,
|
|
491
|
+
paddingHorizontal: 10,
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
<Text
|
|
495
|
+
style={{
|
|
496
|
+
fontSize: 10,
|
|
497
|
+
fontFamily: 'Inter, sans-serif',
|
|
498
|
+
color: TEXT_TERTIARY,
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
{selectedCoord.point.sessionLabel ?? selectedCoord.point.date}
|
|
502
|
+
</Text>
|
|
503
|
+
<Text
|
|
504
|
+
style={{
|
|
505
|
+
marginTop: 2,
|
|
506
|
+
fontSize: 13,
|
|
507
|
+
fontFamily: 'Inter, sans-serif',
|
|
508
|
+
fontWeight: '700',
|
|
509
|
+
color: TEXT_PRIMARY,
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{`${formatValue(selectedCoord.point.e1rm)} ${unit}`}
|
|
513
|
+
</Text>
|
|
514
|
+
{selected != null && selected > 0 && (
|
|
515
|
+
<Text
|
|
516
|
+
style={{
|
|
517
|
+
marginTop: 1,
|
|
518
|
+
fontSize: 10,
|
|
519
|
+
fontFamily: 'Inter, sans-serif',
|
|
520
|
+
color:
|
|
521
|
+
selectedCoord.point.e1rm - geometry.actual[selected - 1].point.e1rm >= 0
|
|
522
|
+
? STATUS_SUCCESS
|
|
523
|
+
: STATUS_ERROR,
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
{`${
|
|
527
|
+
selectedCoord.point.e1rm - geometry.actual[selected - 1].point.e1rm >= 0
|
|
528
|
+
? '+'
|
|
529
|
+
: '-'
|
|
530
|
+
}${Math.abs(
|
|
531
|
+
selectedCoord.point.e1rm - geometry.actual[selected - 1].point.e1rm,
|
|
532
|
+
).toFixed(1)} ${unit}`}
|
|
533
|
+
</Text>
|
|
534
|
+
)}
|
|
535
|
+
</View>
|
|
536
|
+
)}
|
|
537
|
+
</View>
|
|
538
|
+
|
|
539
|
+
{/* Meso boundary x-axis labels. */}
|
|
540
|
+
{geometry.boundaries.length > 0 && (
|
|
541
|
+
<View
|
|
542
|
+
style={{ width, height: 14, position: 'relative', marginTop: 2 }}
|
|
543
|
+
accessibilityElementsHidden
|
|
544
|
+
testID="strength-trend-chart-axis"
|
|
545
|
+
>
|
|
546
|
+
{geometry.boundaries.map((boundary, i) => (
|
|
547
|
+
<Text
|
|
548
|
+
key={`axis-${i}`}
|
|
549
|
+
testID="strength-trend-chart-axis-label"
|
|
550
|
+
style={{
|
|
551
|
+
position: 'absolute',
|
|
552
|
+
left: boundary.x - 24,
|
|
553
|
+
width: 48,
|
|
554
|
+
textAlign: 'center',
|
|
555
|
+
fontSize: 10,
|
|
556
|
+
fontFamily: 'Inter, sans-serif',
|
|
557
|
+
color: TEXT_TERTIARY,
|
|
558
|
+
}}
|
|
559
|
+
>
|
|
560
|
+
{boundary.label}
|
|
561
|
+
</Text>
|
|
562
|
+
))}
|
|
563
|
+
</View>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
{/* Trend pill. */}
|
|
567
|
+
<View className="flex-row" style={{ marginTop: 8 }}>
|
|
568
|
+
<View
|
|
569
|
+
testID="strength-trend-chart-trend-pill"
|
|
570
|
+
accessibilityLabel={`Strength trend: ${trendText}`}
|
|
571
|
+
style={{
|
|
572
|
+
paddingVertical: 3,
|
|
573
|
+
paddingHorizontal: 8,
|
|
574
|
+
borderRadius: 4,
|
|
575
|
+
borderWidth: 1,
|
|
576
|
+
backgroundColor: trend.positive ? SUCCESS_PILL_BG : ERROR_PILL_BG,
|
|
577
|
+
borderColor: trend.positive ? SUCCESS_PILL_BORDER : ERROR_PILL_BORDER,
|
|
578
|
+
}}
|
|
579
|
+
>
|
|
580
|
+
<Text
|
|
581
|
+
style={{
|
|
582
|
+
fontSize: 11,
|
|
583
|
+
fontFamily: 'Inter, sans-serif',
|
|
584
|
+
fontWeight: '600',
|
|
585
|
+
color: trend.positive ? STATUS_SUCCESS : STATUS_ERROR,
|
|
586
|
+
}}
|
|
587
|
+
>
|
|
588
|
+
{trendText}
|
|
589
|
+
</Text>
|
|
590
|
+
</View>
|
|
591
|
+
</View>
|
|
592
|
+
|
|
593
|
+
{/* Legend. */}
|
|
594
|
+
<View
|
|
595
|
+
className="flex-row items-center"
|
|
596
|
+
style={{ marginTop: 8, gap: 14 }}
|
|
597
|
+
testID="strength-trend-chart-legend"
|
|
598
|
+
>
|
|
599
|
+
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
600
|
+
<View style={{ width: 14, height: 2.5, borderRadius: 1.5, backgroundColor: BRAND_PRIMARY }} />
|
|
601
|
+
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
|
|
602
|
+
Actual
|
|
603
|
+
</Text>
|
|
604
|
+
</View>
|
|
605
|
+
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
606
|
+
<View
|
|
607
|
+
style={{
|
|
608
|
+
width: 14,
|
|
609
|
+
height: 0,
|
|
610
|
+
borderTopWidth: 2,
|
|
611
|
+
borderStyle: 'dashed',
|
|
612
|
+
borderTopColor: TEXT_TERTIARY,
|
|
613
|
+
}}
|
|
614
|
+
/>
|
|
615
|
+
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
|
|
616
|
+
Projected
|
|
617
|
+
</Text>
|
|
618
|
+
</View>
|
|
619
|
+
<View className="flex-row items-center" style={{ gap: 5 }}>
|
|
620
|
+
<Text style={{ fontSize: 12, color: STATUS_WARNING }}>★</Text>
|
|
621
|
+
<Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
|
|
622
|
+
PR
|
|
623
|
+
</Text>
|
|
624
|
+
</View>
|
|
625
|
+
</View>
|
|
626
|
+
</View>
|
|
627
|
+
)
|
|
628
|
+
}
|
|
@@ -6,7 +6,6 @@ export interface SupersetWrapperProps {
|
|
|
6
6
|
label?: string
|
|
7
7
|
color?: string
|
|
8
8
|
children: React.ReactNode
|
|
9
|
-
syncExpansion?: boolean
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
const DEFAULTS = {
|
|
@@ -31,7 +30,7 @@ export function SupersetWrapper({
|
|
|
31
30
|
marginBottom: 8,
|
|
32
31
|
overflow: 'visible',
|
|
33
32
|
}}
|
|
34
|
-
accessibilityRole={
|
|
33
|
+
accessibilityRole={'group' as never}
|
|
35
34
|
accessibilityLabel={`Superset: ${label}`}
|
|
36
35
|
testID="superset-wrapper"
|
|
37
36
|
>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { TempoDisplay } from './TempoDisplay'
|
|
4
|
+
|
|
5
|
+
// tempo = [eccentric, pauseBottom, concentric, pauseTop]
|
|
6
|
+
const meta: Meta<typeof TempoDisplay> = {
|
|
7
|
+
title: 'Custom/Workout/TempoDisplay',
|
|
8
|
+
component: TempoDisplay,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
tempo: { control: 'object', description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds' },
|
|
12
|
+
size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
|
|
13
|
+
colored: { control: 'boolean', description: 'Colored vs mono display' },
|
|
14
|
+
showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj<typeof TempoDisplay>
|
|
20
|
+
|
|
21
|
+
export const ColoredStandard: Story = {
|
|
22
|
+
args: { tempo: [3, 1, 1, 0], colored: true },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MonoStandard: Story = {
|
|
26
|
+
args: { tempo: [3, 1, 1, 0], colored: false },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ColoredExplosive: Story = {
|
|
30
|
+
args: { tempo: [1, 0, 1, 0], colored: true },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MonoExplosive: Story = {
|
|
34
|
+
args: { tempo: [1, 0, 1, 0], colored: false },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ColoredSlowEccentric: Story = {
|
|
38
|
+
args: { tempo: [5, 2, 1, 1], colored: true },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const SmallColored: Story = {
|
|
42
|
+
args: { tempo: [3, 1, 1, 0], size: 'sm', colored: true },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SmallMono: Story = {
|
|
46
|
+
args: { tempo: [3, 1, 1, 0], size: 'sm', colored: false },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const AllVariants: Story = {
|
|
50
|
+
render: () => (
|
|
51
|
+
<View style={{ gap: 12 }}>
|
|
52
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
53
|
+
<TempoDisplay tempo={[3, 1, 1, 0]} colored />
|
|
54
|
+
<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />
|
|
55
|
+
</View>
|
|
56
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
57
|
+
<TempoDisplay tempo={[1, 0, 1, 0]} colored />
|
|
58
|
+
<TempoDisplay tempo={[1, 0, 1, 0]} colored={false} />
|
|
59
|
+
</View>
|
|
60
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
61
|
+
<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored />
|
|
62
|
+
<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored={false} />
|
|
63
|
+
</View>
|
|
64
|
+
</View>
|
|
65
|
+
),
|
|
66
|
+
}
|