@titan-design/react-ui 0.2.5 → 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/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -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,493 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
3
|
+
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
|
|
5
|
+
const STATUS_SUCCESS = '#14B8A6'
|
|
6
|
+
const STATUS_WARNING = '#FFB020'
|
|
7
|
+
const STATUS_INFO = '#2196F3'
|
|
8
|
+
/** Dot outline: near-white ring so load dots read on the band fill and any
|
|
9
|
+
* surface (matches the MesoStatusCard gauge-marker convention). */
|
|
10
|
+
const DOT_BORDER = '#F3F4F6'
|
|
11
|
+
const TEXT_TERTIARY = '#6B7280'
|
|
12
|
+
const BAND_FILL = 'rgba(20,184,166,0.1)'
|
|
13
|
+
const BAND_EDGE = 'rgba(20,184,166,0.45)'
|
|
14
|
+
const PROJECTION_FILL = 'rgba(20,184,166,0.05)'
|
|
15
|
+
|
|
16
|
+
const PADDING_LEFT = 28
|
|
17
|
+
const PADDING_RIGHT = 10
|
|
18
|
+
const PADDING_TOP = 8
|
|
19
|
+
const PADDING_BOTTOM = 16
|
|
20
|
+
const COLUMN_STEP = 4
|
|
21
|
+
const DOT_SIZE = 8
|
|
22
|
+
|
|
23
|
+
export type WorkoutDotStatus = 'within' | 'above' | 'below'
|
|
24
|
+
|
|
25
|
+
export interface CapacityBandDataPoint {
|
|
26
|
+
/** ISO-ish date string (e.g. "2026-06-01"). */
|
|
27
|
+
date: string
|
|
28
|
+
/** Lower bound of the capacity band (MEV equivalent). */
|
|
29
|
+
bandLow: number
|
|
30
|
+
/** Upper bound of the capacity band (MRV equivalent). */
|
|
31
|
+
bandHigh: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkoutDot {
|
|
35
|
+
date: string
|
|
36
|
+
/** Actual session load score. */
|
|
37
|
+
load: number
|
|
38
|
+
/** Position of the session relative to the band. */
|
|
39
|
+
status: WorkoutDotStatus
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CapacityBandProjection {
|
|
43
|
+
/** Band shape over the next days if training continues (rises). */
|
|
44
|
+
withTraining: CapacityBandDataPoint[]
|
|
45
|
+
/** Band shape over the next days if resting (drops). */
|
|
46
|
+
withRest: CapacityBandDataPoint[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CapacityBandChartProps extends ViewProps {
|
|
50
|
+
/** Capacity band shape over time. */
|
|
51
|
+
band: CapacityBandDataPoint[]
|
|
52
|
+
/** Workout sessions plotted as dots. */
|
|
53
|
+
workouts: WorkoutDot[]
|
|
54
|
+
/** Optional forward projection (next few days). */
|
|
55
|
+
projection?: CapacityBandProjection
|
|
56
|
+
/** Chart width in px. */
|
|
57
|
+
width: number
|
|
58
|
+
/** Chart height in px. */
|
|
59
|
+
height: number
|
|
60
|
+
/** Called when a workout dot is tapped. */
|
|
61
|
+
onWorkoutPress?: (workout: WorkoutDot) => void
|
|
62
|
+
className?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PixelPoint {
|
|
66
|
+
x: number
|
|
67
|
+
yHigh: number
|
|
68
|
+
yLow: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DOT_COLORS: Record<WorkoutDotStatus, string> = {
|
|
72
|
+
within: STATUS_SUCCESS,
|
|
73
|
+
above: STATUS_WARNING,
|
|
74
|
+
below: STATUS_INFO,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const STATUS_PHRASES: Record<WorkoutDotStatus, string> = {
|
|
78
|
+
within: 'within range',
|
|
79
|
+
above: 'above range',
|
|
80
|
+
below: 'below range',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseTime(date: string): number {
|
|
84
|
+
const parts = date.split('-')
|
|
85
|
+
if (parts.length === 3) {
|
|
86
|
+
return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))
|
|
87
|
+
}
|
|
88
|
+
const parsed = Date.parse(date)
|
|
89
|
+
return Number.isNaN(parsed) ? 0 : parsed
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatDate(date: string): string {
|
|
93
|
+
const parts = date.split('-')
|
|
94
|
+
if (parts.length === 3) return `${Number(parts[1])}/${Number(parts[2])}`
|
|
95
|
+
const d = new Date(date)
|
|
96
|
+
return Number.isNaN(d.getTime()) ? date : `${d.getMonth() + 1}/${d.getDate()}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toPixels(
|
|
100
|
+
points: CapacityBandDataPoint[],
|
|
101
|
+
toX: (date: string) => number,
|
|
102
|
+
toY: (value: number) => number,
|
|
103
|
+
): PixelPoint[] {
|
|
104
|
+
return points
|
|
105
|
+
.map((p) => ({ x: toX(p.date), yHigh: toY(p.bandHigh), yLow: toY(p.bandLow) }))
|
|
106
|
+
.sort((a, b) => a.x - b.x)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function interpEdge(pixels: PixelPoint[], x: number, key: 'yHigh' | 'yLow'): number {
|
|
110
|
+
for (let i = 0; i < pixels.length - 1; i++) {
|
|
111
|
+
const a = pixels[i]
|
|
112
|
+
const b = pixels[i + 1]
|
|
113
|
+
if (x >= a.x && x <= b.x) {
|
|
114
|
+
const t = b.x === a.x ? 0 : (x - a.x) / (b.x - a.x)
|
|
115
|
+
return a[key] + t * (b[key] - a[key])
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return pixels[pixels.length - 1][key]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildColumns(pixels: PixelPoint[], step: number) {
|
|
122
|
+
if (pixels.length < 2) return []
|
|
123
|
+
const start = pixels[0].x
|
|
124
|
+
const end = pixels[pixels.length - 1].x
|
|
125
|
+
const columns: Array<{ x: number; top: number; height: number }> = []
|
|
126
|
+
for (let x = start; x < end; x += step) {
|
|
127
|
+
const top = interpEdge(pixels, x, 'yHigh')
|
|
128
|
+
const bottom = interpEdge(pixels, x, 'yLow')
|
|
129
|
+
columns.push({ x, top, height: Math.max(0, bottom - top) })
|
|
130
|
+
}
|
|
131
|
+
return columns
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildEdges(pixels: PixelPoint[], key: 'yHigh' | 'yLow') {
|
|
135
|
+
const segments: Array<{ left: number; top: number; length: number; angle: number }> = []
|
|
136
|
+
for (let i = 1; i < pixels.length; i++) {
|
|
137
|
+
const a = pixels[i - 1]
|
|
138
|
+
const b = pixels[i]
|
|
139
|
+
const dx = b.x - a.x
|
|
140
|
+
const dy = b[key] - a[key]
|
|
141
|
+
segments.push({
|
|
142
|
+
left: a.x,
|
|
143
|
+
top: a[key],
|
|
144
|
+
length: Math.sqrt(dx * dx + dy * dy),
|
|
145
|
+
angle: Math.atan2(dy, dx) * (180 / Math.PI),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
return segments
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function collectValues(
|
|
152
|
+
band: CapacityBandDataPoint[],
|
|
153
|
+
workouts: WorkoutDot[],
|
|
154
|
+
projection?: CapacityBandProjection,
|
|
155
|
+
): number[] {
|
|
156
|
+
const values: number[] = []
|
|
157
|
+
band.forEach((p) => values.push(p.bandLow, p.bandHigh))
|
|
158
|
+
workouts.forEach((w) => values.push(w.load))
|
|
159
|
+
projection?.withTraining.forEach((p) => values.push(p.bandLow, p.bandHigh))
|
|
160
|
+
projection?.withRest.forEach((p) => values.push(p.bandLow, p.bandHigh))
|
|
161
|
+
return values
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function currentStatus(workouts: WorkoutDot[]): string {
|
|
165
|
+
if (workouts.length === 0) return 'no recent sessions'
|
|
166
|
+
const latest = [...workouts].sort((a, b) => parseTime(b.date) - parseTime(a.date))[0]
|
|
167
|
+
return STATUS_PHRASES[latest.status]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Gentler-Streak-inspired fatigue visualization. Renders a shaded capacity
|
|
172
|
+
* band (between a rolling MEV/MRV estimate), workout sessions as colored dots
|
|
173
|
+
* positioned by load, and an optional dashed forward projection that diverges
|
|
174
|
+
* for "keep training" (rises) versus "rest" (drops). View-based, no SVG:
|
|
175
|
+
* the band fill is a run of vertical columns and the edges/projection are
|
|
176
|
+
* rotated line Views (same technique as Sparkline).
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* <CapacityBandChart
|
|
180
|
+
* band={band}
|
|
181
|
+
* workouts={workouts}
|
|
182
|
+
* projection={{ withTraining, withRest }}
|
|
183
|
+
* width={320}
|
|
184
|
+
* height={180}
|
|
185
|
+
* onWorkoutPress={(w) => openSession(w)}
|
|
186
|
+
* />
|
|
187
|
+
*/
|
|
188
|
+
export function CapacityBandChart({
|
|
189
|
+
band,
|
|
190
|
+
workouts,
|
|
191
|
+
projection,
|
|
192
|
+
width,
|
|
193
|
+
height,
|
|
194
|
+
onWorkoutPress,
|
|
195
|
+
className,
|
|
196
|
+
...props
|
|
197
|
+
}: CapacityBandChartProps) {
|
|
198
|
+
const [reveal] = useState(() => new Animated.Value(0))
|
|
199
|
+
const dotAnims = useMemo(
|
|
200
|
+
() => Array.from({ length: workouts.length }, () => new Animated.Value(0)),
|
|
201
|
+
[workouts.length],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
reveal.setValue(0)
|
|
206
|
+
dotAnims.forEach((a) => a.setValue(0))
|
|
207
|
+
const draw = Animated.timing(reveal, {
|
|
208
|
+
toValue: 1,
|
|
209
|
+
duration: 800,
|
|
210
|
+
easing: Easing.out(Easing.ease),
|
|
211
|
+
useNativeDriver: false,
|
|
212
|
+
})
|
|
213
|
+
const pops = dotAnims.map((a) =>
|
|
214
|
+
Animated.timing(a, {
|
|
215
|
+
toValue: 1,
|
|
216
|
+
duration: 200,
|
|
217
|
+
easing: Easing.out(Easing.ease),
|
|
218
|
+
useNativeDriver: false,
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
const animation = Animated.sequence([draw, Animated.stagger(200, pops)])
|
|
222
|
+
animation.start()
|
|
223
|
+
return () => animation.stop()
|
|
224
|
+
}, [reveal, dotAnims, band.length, workouts.length])
|
|
225
|
+
|
|
226
|
+
const plotWidth = width - PADDING_LEFT - PADDING_RIGHT
|
|
227
|
+
const plotHeight = height - PADDING_TOP - PADDING_BOTTOM
|
|
228
|
+
|
|
229
|
+
const scale = useMemo(() => {
|
|
230
|
+
const times = band.map((p) => parseTime(p.date))
|
|
231
|
+
projection?.withTraining.forEach((p) => times.push(parseTime(p.date)))
|
|
232
|
+
projection?.withRest.forEach((p) => times.push(parseTime(p.date)))
|
|
233
|
+
const minT = times.length ? Math.min(...times) : 0
|
|
234
|
+
const maxT = times.length ? Math.max(...times) : 1
|
|
235
|
+
const tRange = maxT - minT || 1
|
|
236
|
+
|
|
237
|
+
const values = collectValues(band, workouts, projection)
|
|
238
|
+
const minV = values.length ? Math.min(...values) : 0
|
|
239
|
+
const maxV = values.length ? Math.max(...values) : 1
|
|
240
|
+
const pad = (maxV - minV) * 0.1 || 1
|
|
241
|
+
const domainMin = minV - pad
|
|
242
|
+
const domainMax = maxV + pad
|
|
243
|
+
const vRange = domainMax - domainMin || 1
|
|
244
|
+
|
|
245
|
+
const toX = (date: string) =>
|
|
246
|
+
PADDING_LEFT + ((parseTime(date) - minT) / tRange) * plotWidth
|
|
247
|
+
const toY = (value: number) =>
|
|
248
|
+
PADDING_TOP + (1 - (value - domainMin) / vRange) * plotHeight
|
|
249
|
+
return { toX, toY }
|
|
250
|
+
}, [band, workouts, projection, plotWidth, plotHeight])
|
|
251
|
+
|
|
252
|
+
if (band.length === 0) {
|
|
253
|
+
return (
|
|
254
|
+
<View
|
|
255
|
+
style={{ width, height }}
|
|
256
|
+
className={className}
|
|
257
|
+
accessibilityRole="image"
|
|
258
|
+
accessibilityLabel="Training capacity band chart, no data"
|
|
259
|
+
testID="capacity-band-chart-empty"
|
|
260
|
+
{...props}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { toX, toY } = scale
|
|
266
|
+
const bandPixels = toPixels(band, toX, toY)
|
|
267
|
+
const columns = buildColumns(bandPixels, COLUMN_STEP)
|
|
268
|
+
const topEdge = buildEdges(bandPixels, 'yHigh')
|
|
269
|
+
const bottomEdge = buildEdges(bandPixels, 'yLow')
|
|
270
|
+
|
|
271
|
+
const lastBandPoint = band[band.length - 1]
|
|
272
|
+
const trainingPixels = projection
|
|
273
|
+
? toPixels([lastBandPoint, ...projection.withTraining], toX, toY)
|
|
274
|
+
: []
|
|
275
|
+
const restPixels = projection
|
|
276
|
+
? toPixels([lastBandPoint, ...projection.withRest], toX, toY)
|
|
277
|
+
: []
|
|
278
|
+
|
|
279
|
+
const labelStride = Math.max(1, Math.ceil(band.length / 5))
|
|
280
|
+
const revealWidth = reveal.interpolate({ inputRange: [0, 1], outputRange: [0, width] })
|
|
281
|
+
|
|
282
|
+
const renderColumns = (cols: ReturnType<typeof buildColumns>, color: string, key: string) =>
|
|
283
|
+
cols.map((col, i) => (
|
|
284
|
+
<View
|
|
285
|
+
key={`${key}-${i}`}
|
|
286
|
+
style={{
|
|
287
|
+
position: 'absolute',
|
|
288
|
+
left: col.x,
|
|
289
|
+
top: col.top,
|
|
290
|
+
width: COLUMN_STEP + 0.5,
|
|
291
|
+
height: col.height,
|
|
292
|
+
backgroundColor: color,
|
|
293
|
+
}}
|
|
294
|
+
accessibilityElementsHidden
|
|
295
|
+
importantForAccessibility="no-hide-descendants"
|
|
296
|
+
testID={key}
|
|
297
|
+
/>
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
const renderEdges = (
|
|
301
|
+
segments: ReturnType<typeof buildEdges>,
|
|
302
|
+
color: string,
|
|
303
|
+
dashed: boolean,
|
|
304
|
+
key: string,
|
|
305
|
+
) =>
|
|
306
|
+
segments.map((seg, i) => (
|
|
307
|
+
<View
|
|
308
|
+
key={`${key}-${i}`}
|
|
309
|
+
style={{
|
|
310
|
+
position: 'absolute',
|
|
311
|
+
left: seg.left,
|
|
312
|
+
top: seg.top,
|
|
313
|
+
width: seg.length,
|
|
314
|
+
height: dashed ? 0 : 1.5,
|
|
315
|
+
borderTopWidth: dashed ? 1 : 0,
|
|
316
|
+
borderTopColor: dashed ? color : undefined,
|
|
317
|
+
borderStyle: dashed ? 'dashed' : 'solid',
|
|
318
|
+
backgroundColor: dashed ? undefined : color,
|
|
319
|
+
opacity: dashed ? 0.7 : 1,
|
|
320
|
+
transformOrigin: '0 0',
|
|
321
|
+
transform: [{ rotate: `${seg.angle}deg` }],
|
|
322
|
+
}}
|
|
323
|
+
accessibilityElementsHidden
|
|
324
|
+
importantForAccessibility="no-hide-descendants"
|
|
325
|
+
testID={key}
|
|
326
|
+
/>
|
|
327
|
+
))
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<View
|
|
331
|
+
style={{ width, height, position: 'relative' }}
|
|
332
|
+
className={className}
|
|
333
|
+
testID="capacity-band-chart-root"
|
|
334
|
+
{...props}
|
|
335
|
+
>
|
|
336
|
+
{/* Static visual layer carries the image role; interactive dots live as
|
|
337
|
+
siblings so the image role has no focusable descendants. */}
|
|
338
|
+
<View
|
|
339
|
+
style={{ position: 'absolute', top: 0, left: 0, width, height }}
|
|
340
|
+
accessibilityRole="image"
|
|
341
|
+
accessibilityLabel={`Training capacity band chart. Current capacity: ${currentStatus(
|
|
342
|
+
workouts,
|
|
343
|
+
)}. ${workouts.length} workout${workouts.length === 1 ? '' : 's'} shown.`}
|
|
344
|
+
testID="capacity-band-chart"
|
|
345
|
+
>
|
|
346
|
+
{/* Band + projection draw left-to-right via an animated clip. */}
|
|
347
|
+
<Animated.View
|
|
348
|
+
style={{ position: 'absolute', top: 0, left: 0, height, width: revealWidth, overflow: 'hidden' }}
|
|
349
|
+
accessibilityElementsHidden
|
|
350
|
+
importantForAccessibility="no-hide-descendants"
|
|
351
|
+
testID="capacity-band-chart-reveal"
|
|
352
|
+
>
|
|
353
|
+
<View style={{ width, height, position: 'relative' }}>
|
|
354
|
+
{projection && (
|
|
355
|
+
<View testID="capacity-band-chart-projection">
|
|
356
|
+
{renderColumns(buildColumns(trainingPixels, COLUMN_STEP), PROJECTION_FILL, 'capacity-band-chart-projection-fill')}
|
|
357
|
+
{renderColumns(buildColumns(restPixels, COLUMN_STEP), PROJECTION_FILL, 'capacity-band-chart-projection-fill')}
|
|
358
|
+
{renderEdges(buildEdges(trainingPixels, 'yHigh'), STATUS_SUCCESS, true, 'capacity-band-chart-projection-training')}
|
|
359
|
+
{renderEdges(buildEdges(trainingPixels, 'yLow'), STATUS_SUCCESS, true, 'capacity-band-chart-projection-training')}
|
|
360
|
+
{renderEdges(buildEdges(restPixels, 'yHigh'), STATUS_INFO, true, 'capacity-band-chart-projection-rest')}
|
|
361
|
+
{renderEdges(buildEdges(restPixels, 'yLow'), STATUS_INFO, true, 'capacity-band-chart-projection-rest')}
|
|
362
|
+
<Text
|
|
363
|
+
style={{
|
|
364
|
+
position: 'absolute',
|
|
365
|
+
left: trainingPixels[trainingPixels.length - 1]?.x + 2,
|
|
366
|
+
top: trainingPixels[trainingPixels.length - 1]?.yHigh - 12,
|
|
367
|
+
fontSize: 9,
|
|
368
|
+
fontFamily: 'Inter, sans-serif',
|
|
369
|
+
color: STATUS_SUCCESS,
|
|
370
|
+
}}
|
|
371
|
+
testID="capacity-band-chart-projection-training-label"
|
|
372
|
+
>
|
|
373
|
+
Training
|
|
374
|
+
</Text>
|
|
375
|
+
<Text
|
|
376
|
+
style={{
|
|
377
|
+
position: 'absolute',
|
|
378
|
+
left: restPixels[restPixels.length - 1]?.x + 2,
|
|
379
|
+
top: restPixels[restPixels.length - 1]?.yLow + 2,
|
|
380
|
+
fontSize: 9,
|
|
381
|
+
fontFamily: 'Inter, sans-serif',
|
|
382
|
+
color: STATUS_INFO,
|
|
383
|
+
}}
|
|
384
|
+
testID="capacity-band-chart-projection-rest-label"
|
|
385
|
+
>
|
|
386
|
+
Rest
|
|
387
|
+
</Text>
|
|
388
|
+
</View>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
<View testID="capacity-band-chart-band">
|
|
392
|
+
{renderColumns(columns, BAND_FILL, 'capacity-band-chart-band-cell')}
|
|
393
|
+
</View>
|
|
394
|
+
{renderEdges(topEdge, BAND_EDGE, false, 'capacity-band-chart-edge-top')}
|
|
395
|
+
{renderEdges(bottomEdge, BAND_EDGE, false, 'capacity-band-chart-edge-bottom')}
|
|
396
|
+
</View>
|
|
397
|
+
</Animated.View>
|
|
398
|
+
|
|
399
|
+
{/* Y axis conceptual label (no numbers). */}
|
|
400
|
+
<Text
|
|
401
|
+
style={{
|
|
402
|
+
position: 'absolute',
|
|
403
|
+
left: PADDING_LEFT / 2 - 14,
|
|
404
|
+
top: PADDING_TOP + plotHeight / 2 - 7,
|
|
405
|
+
width: 32,
|
|
406
|
+
textAlign: 'center',
|
|
407
|
+
fontSize: 9,
|
|
408
|
+
fontFamily: 'Inter, sans-serif',
|
|
409
|
+
color: TEXT_TERTIARY,
|
|
410
|
+
transform: [{ rotate: '-90deg' }],
|
|
411
|
+
}}
|
|
412
|
+
accessibilityElementsHidden
|
|
413
|
+
importantForAccessibility="no-hide-descendants"
|
|
414
|
+
testID="capacity-band-chart-y-axis-label"
|
|
415
|
+
>
|
|
416
|
+
Load
|
|
417
|
+
</Text>
|
|
418
|
+
|
|
419
|
+
{/* X axis date labels (subset to avoid overlap). */}
|
|
420
|
+
{band.map((point, i) => {
|
|
421
|
+
if (i % labelStride !== 0 && i !== band.length - 1) return null
|
|
422
|
+
return (
|
|
423
|
+
<Text
|
|
424
|
+
key={`x-${point.date}`}
|
|
425
|
+
style={{
|
|
426
|
+
position: 'absolute',
|
|
427
|
+
left: toX(point.date) - 14,
|
|
428
|
+
top: height - PADDING_BOTTOM + 2,
|
|
429
|
+
width: 28,
|
|
430
|
+
textAlign: 'center',
|
|
431
|
+
fontSize: 10,
|
|
432
|
+
fontFamily: 'Inter, sans-serif',
|
|
433
|
+
color: TEXT_TERTIARY,
|
|
434
|
+
}}
|
|
435
|
+
accessibilityElementsHidden
|
|
436
|
+
importantForAccessibility="no-hide-descendants"
|
|
437
|
+
testID="capacity-band-chart-x-label"
|
|
438
|
+
>
|
|
439
|
+
{formatDate(point.date)}
|
|
440
|
+
</Text>
|
|
441
|
+
)
|
|
442
|
+
})}
|
|
443
|
+
</View>
|
|
444
|
+
|
|
445
|
+
{/* Workout dots — scale in after the band draws. */}
|
|
446
|
+
{workouts.map((workout, i) => {
|
|
447
|
+
const cx = toX(workout.date)
|
|
448
|
+
const cy = toY(workout.load)
|
|
449
|
+
const color = DOT_COLORS[workout.status]
|
|
450
|
+
const dotStyle = {
|
|
451
|
+
width: DOT_SIZE,
|
|
452
|
+
height: DOT_SIZE,
|
|
453
|
+
borderRadius: DOT_SIZE / 2,
|
|
454
|
+
backgroundColor: color,
|
|
455
|
+
borderWidth: 2,
|
|
456
|
+
borderColor: DOT_BORDER,
|
|
457
|
+
}
|
|
458
|
+
const label = `Workout on ${formatDate(workout.date)}, load ${workout.load}, ${
|
|
459
|
+
STATUS_PHRASES[workout.status]
|
|
460
|
+
}`
|
|
461
|
+
return (
|
|
462
|
+
<Animated.View
|
|
463
|
+
key={`dot-${workout.date}-${i}`}
|
|
464
|
+
style={{
|
|
465
|
+
position: 'absolute',
|
|
466
|
+
left: cx - DOT_SIZE / 2,
|
|
467
|
+
top: cy - DOT_SIZE / 2,
|
|
468
|
+
transform: [{ scale: dotAnims[i] ?? 1 }],
|
|
469
|
+
}}
|
|
470
|
+
testID="capacity-band-chart-dot-wrapper"
|
|
471
|
+
>
|
|
472
|
+
{onWorkoutPress ? (
|
|
473
|
+
<Pressable
|
|
474
|
+
onPress={() => onWorkoutPress(workout)}
|
|
475
|
+
accessibilityRole="button"
|
|
476
|
+
accessibilityLabel={label}
|
|
477
|
+
style={dotStyle}
|
|
478
|
+
testID="capacity-band-chart-dot"
|
|
479
|
+
/>
|
|
480
|
+
) : (
|
|
481
|
+
<View
|
|
482
|
+
accessibilityElementsHidden
|
|
483
|
+
importantForAccessibility="no-hide-descendants"
|
|
484
|
+
style={dotStyle}
|
|
485
|
+
testID="capacity-band-chart-dot"
|
|
486
|
+
/>
|
|
487
|
+
)}
|
|
488
|
+
</Animated.View>
|
|
489
|
+
)
|
|
490
|
+
})}
|
|
491
|
+
</View>
|
|
492
|
+
)
|
|
493
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { DeviationBar } from './DeviationBar'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof DeviationBar> = {
|
|
6
|
+
title: 'Custom/Workout/DeviationBar',
|
|
7
|
+
component: DeviationBar,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
deviation: {
|
|
11
|
+
control: { type: 'range', min: -1, max: 1, step: 0.1 },
|
|
12
|
+
description: 'Deviation from plan (-1 to +1)',
|
|
13
|
+
},
|
|
14
|
+
width: {
|
|
15
|
+
control: 'number',
|
|
16
|
+
description: 'Width of the bar in pixels',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof DeviationBar>
|
|
23
|
+
|
|
24
|
+
export const OnPlan: Story = {
|
|
25
|
+
args: { deviation: 0 },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SlightlyLighter: Story = {
|
|
29
|
+
args: { deviation: -0.2 },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MuchLighter: Story = {
|
|
33
|
+
args: { deviation: -0.8 },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SlightlyHarder: Story = {
|
|
37
|
+
args: { deviation: 0.3 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const MuchHarder: Story = {
|
|
41
|
+
args: { deviation: 0.9 },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const FullRange: Story = {
|
|
45
|
+
render: () => (
|
|
46
|
+
<View style={{ gap: 8 }}>
|
|
47
|
+
{[-1, -0.7, -0.3, 0, 0.3, 0.7, 1].map((d) => (
|
|
48
|
+
<View key={d} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
49
|
+
<DeviationBar deviation={d} />
|
|
50
|
+
</View>
|
|
51
|
+
))}
|
|
52
|
+
</View>
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const WideBar: Story = {
|
|
57
|
+
args: { deviation: 0.5, width: 80 },
|
|
58
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { DeviationBar } from './DeviationBar'
|
|
5
|
+
|
|
6
|
+
describe('DeviationBar', () => {
|
|
7
|
+
it('renders the bar and dot', () => {
|
|
8
|
+
render(<DeviationBar deviation={0} />)
|
|
9
|
+
expect(screen.getByTestId('deviation-bar')).toBeInTheDocument()
|
|
10
|
+
expect(screen.getByTestId('deviation-dot')).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('labels on-plan deviation correctly', () => {
|
|
14
|
+
render(<DeviationBar deviation={0} />)
|
|
15
|
+
expect(screen.getByLabelText('Session deviation: on plan')).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('labels lighter deviation correctly', () => {
|
|
19
|
+
render(<DeviationBar deviation={-0.5} />)
|
|
20
|
+
expect(screen.getByLabelText('Session deviation: lighter than planned')).toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('labels harder deviation correctly', () => {
|
|
24
|
+
render(<DeviationBar deviation={0.5} />)
|
|
25
|
+
expect(screen.getByLabelText('Session deviation: harder than planned')).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('clamps deviation to -1', () => {
|
|
29
|
+
render(<DeviationBar deviation={-2} />)
|
|
30
|
+
expect(screen.getByTestId('deviation-bar')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('clamps deviation to +1', () => {
|
|
34
|
+
render(<DeviationBar deviation={2} />)
|
|
35
|
+
expect(screen.getByTestId('deviation-bar')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('accepts custom width', () => {
|
|
39
|
+
render(<DeviationBar deviation={0} width={80} />)
|
|
40
|
+
expect(screen.getByTestId('deviation-bar')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders dot at left edge for -1 deviation', () => {
|
|
44
|
+
const { getByTestId } = render(<DeviationBar deviation={-1} width={40} />)
|
|
45
|
+
const dot = getByTestId('deviation-dot')
|
|
46
|
+
expect(dot).toBeInTheDocument()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders dot at right edge for +1 deviation', () => {
|
|
50
|
+
const { getByTestId } = render(<DeviationBar deviation={1} width={40} />)
|
|
51
|
+
const dot = getByTestId('deviation-dot')
|
|
52
|
+
expect(dot).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('has adjustable accessibility role', () => {
|
|
56
|
+
render(<DeviationBar deviation={0} />)
|
|
57
|
+
const bar = screen.getByTestId('deviation-bar')
|
|
58
|
+
expect(bar).toHaveAttribute('role', 'slider')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders an 8px dot with a text-primary border and shadow', () => {
|
|
62
|
+
const { getByTestId } = render(<DeviationBar deviation={0} />)
|
|
63
|
+
const dot = getByTestId('deviation-dot')
|
|
64
|
+
expect(dot).toHaveStyle({ width: '8px', height: '8px' })
|
|
65
|
+
expect(dot).toHaveStyle({ borderTopWidth: '1.5px', borderTopColor: '#F3F4F6' })
|
|
66
|
+
expect(dot).toHaveStyle({ boxShadow: '0 0 4px rgba(0,0,0,0.5)' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders the track with the deviation gradient background', () => {
|
|
70
|
+
const { getByTestId } = render(<DeviationBar deviation={0} />)
|
|
71
|
+
const bar = getByTestId('deviation-bar')
|
|
72
|
+
const track = bar.firstElementChild as HTMLElement
|
|
73
|
+
expect(track).toHaveStyle({
|
|
74
|
+
backgroundImage:
|
|
75
|
+
'linear-gradient(90deg, rgba(20,184,166,0.25) 0%, rgba(107,114,128,0.15) 50%, rgba(255,176,32,0.25) 100%)',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('accessibility', () => {
|
|
80
|
+
it('has no accessibility violations', async () => {
|
|
81
|
+
const { container } = render(<DeviationBar deviation={0.3} />)
|
|
82
|
+
const results = await axe(container)
|
|
83
|
+
expect(results).toHaveNoViolations()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|