@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.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,67 @@
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 { Treemap, type TreemapDatum } from './Treemap'
5
+
6
+ const data: TreemapDatum[] = [
7
+ { id: 'a', value: 100, color: '#5B9BD5', label: 'A' },
8
+ { id: 'b', value: 40, color: '#14B8A6', label: 'B' },
9
+ { id: 'c', value: 10, color: '#F4A736', label: 'C' },
10
+ ]
11
+
12
+ describe('Treemap', () => {
13
+ it('renders one tile per positive datum', () => {
14
+ render(<Treemap data={data} width={300} height={200} />)
15
+ expect(screen.getByTestId('treemap-tile-a')).toBeInTheDocument()
16
+ expect(screen.getByTestId('treemap-tile-b')).toBeInTheDocument()
17
+ expect(screen.getByTestId('treemap-tile-c')).toBeInTheDocument()
18
+ })
19
+
20
+ it('drops non-positive values', () => {
21
+ render(<Treemap data={[...data, { id: 'zero', value: 0 }]} width={300} height={200} />)
22
+ expect(screen.queryByTestId('treemap-tile-zero')).not.toBeInTheDocument()
23
+ })
24
+
25
+ it('folds overflow into a single "+M more" tile', () => {
26
+ const many = Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, value: 10 - i * 0.5 }))
27
+ render(<Treemap data={many} width={300} height={200} maxTiles={4} />)
28
+ expect(screen.getByTestId('treemap-tile-__more__')).toBeInTheDocument()
29
+ // 3 explicit + 1 fold = 4 tiles
30
+ expect(screen.getByLabelText('+7 more')).toBeInTheDocument()
31
+ })
32
+
33
+ it('fires onPress with the tile id', () => {
34
+ const onPress = vi.fn()
35
+ render(<Treemap data={data} width={300} height={200} onPress={onPress} />)
36
+ fireEvent.click(screen.getByTestId('treemap-tile-a'))
37
+ expect(onPress).toHaveBeenCalledWith('a')
38
+ })
39
+
40
+ it('does not fire onPress for the fold tile', () => {
41
+ const onPress = vi.fn()
42
+ const many = Array.from({ length: 6 }, (_, i) => ({ id: `n${i}`, value: 6 - i }))
43
+ render(<Treemap data={many} width={300} height={200} maxTiles={3} onPress={onPress} />)
44
+ fireEvent.click(screen.getByTestId('treemap-tile-__more__'))
45
+ expect(onPress).not.toHaveBeenCalled()
46
+ })
47
+
48
+ it('assigns fallback palette colors when omitted', () => {
49
+ const { container } = render(
50
+ <Treemap data={[{ id: 'x', value: 1 }]} width={100} height={100} />,
51
+ )
52
+ expect(container.firstChild).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders nothing meaningful for all-zero data without crashing', () => {
56
+ render(<Treemap data={[{ id: 'z', value: 0 }]} width={100} height={100} />)
57
+ expect(screen.queryByTestId('treemap-tile-z')).not.toBeInTheDocument()
58
+ })
59
+
60
+ describe('accessibility', () => {
61
+ it('has no accessibility violations', async () => {
62
+ const { container } = render(<Treemap data={data} width={300} height={200} />)
63
+ const results = await axe(container)
64
+ expect(results).toHaveNoViolations()
65
+ })
66
+ })
67
+ })
@@ -0,0 +1,192 @@
1
+ import React from 'react'
2
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export interface TreemapDatum {
6
+ /** Stable identity — returned by onPress and used as the React key. */
7
+ id: string
8
+ /** Area weight (e.g. size, count, severity). Non-positive values are dropped. */
9
+ value: number
10
+ /** Tile fill color. Falls back to a built-in categorical palette by index. */
11
+ color?: string
12
+ /** Optional label rendered inside sufficiently large tiles. */
13
+ label?: string
14
+ }
15
+
16
+ export type TreemapScale = 'linear' | 'sqrt' | 'log'
17
+
18
+ export interface TreemapProps extends Omit<ViewProps, 'children'> {
19
+ data: TreemapDatum[]
20
+ /** Layout box. Required — the treemap fills exactly this area. */
21
+ width: number
22
+ height: number
23
+ /**
24
+ * Area scale. Real-world weights are often heavy-tailed, where one outlier
25
+ * under a `linear` scale swallows the whole canvas. `sqrt` (default) damps
26
+ * that while preserving order; `log` compresses hardest.
27
+ */
28
+ scale?: TreemapScale
29
+ /** Show at most N tiles; the remainder collapse into one "+M more" tile. */
30
+ maxTiles?: number
31
+ /** Fires with a tile's id on press (never for the "+M more" tile). */
32
+ onPress?: (id: string) => void
33
+ /** Draws a highlight border on the matching tile. */
34
+ selectedId?: string
35
+ className?: string
36
+ }
37
+
38
+ /** Titan categorical fallback palette (data-1..data-8). */
39
+ const PALETTE = [
40
+ '#5B9BD5', '#14B8A6', '#F4A736', '#F83030',
41
+ '#823CA0', '#D4A520', '#406D87', '#43C6B7',
42
+ ]
43
+
44
+ interface Rect {
45
+ x: number
46
+ y: number
47
+ w: number
48
+ h: number
49
+ datum: TreemapDatum
50
+ }
51
+
52
+ function scaleValue(v: number, scale: TreemapScale): number {
53
+ if (v <= 0) return 0
54
+ if (scale === 'sqrt') return Math.sqrt(v)
55
+ if (scale === 'log') return Math.log10(v + 1)
56
+ return v
57
+ }
58
+
59
+ function worstRatio(row: { area: number }[], side: number): number {
60
+ const sum = row.reduce((s, r) => s + r.area, 0)
61
+ const thickness = sum / side
62
+ let worst = 0
63
+ for (const r of row) {
64
+ const len = r.area / thickness
65
+ const ratio = Math.max(thickness / len, len / thickness)
66
+ if (ratio > worst) worst = ratio
67
+ }
68
+ return worst
69
+ }
70
+
71
+ /** Squarified treemap (Bruls, Huizing & van Wijk, 2000). */
72
+ function squarify(data: TreemapDatum[], w: number, h: number): Rect[] {
73
+ const total = data.reduce((s, d) => s + d.value, 0)
74
+ if (total <= 0 || w <= 0 || h <= 0) return []
75
+ const scaled = data.map((d) => ({ datum: d, area: (d.value / total) * w * h }))
76
+ const out: Rect[] = []
77
+ let rx = 0
78
+ let ry = 0
79
+ let rw = w
80
+ let rh = h
81
+ let i = 0
82
+
83
+ while (i < scaled.length) {
84
+ const vertical = rw >= rh
85
+ const side = vertical ? rh : rw
86
+ let row: typeof scaled = []
87
+ let bestWorst = Infinity
88
+ let j = i
89
+ while (j < scaled.length) {
90
+ const trial = [...row, scaled[j]]
91
+ const worst = worstRatio(trial, side)
92
+ if (row.length > 0 && worst > bestWorst) break
93
+ row = trial
94
+ bestWorst = worst
95
+ j++
96
+ }
97
+ const rowArea = row.reduce((s, r) => s + r.area, 0)
98
+ const thickness = rowArea / side
99
+ let along = vertical ? ry : rx
100
+ for (const r of row) {
101
+ const len = r.area / thickness
102
+ out.push(
103
+ vertical
104
+ ? { x: rx, y: along, w: thickness, h: len, datum: r.datum }
105
+ : { x: along, y: ry, w: len, h: thickness, datum: r.datum },
106
+ )
107
+ along += len
108
+ }
109
+ if (vertical) {
110
+ rx += thickness
111
+ rw -= thickness
112
+ } else {
113
+ ry += thickness
114
+ rh -= thickness
115
+ }
116
+ i = j
117
+ }
118
+ return out
119
+ }
120
+
121
+ /**
122
+ * Squarified treemap. SVG-free (absolutely-positioned tiles) so it renders
123
+ * identically on web (react-native-web) and native. Layout is computed from
124
+ * `value`; color and labels are caller-supplied.
125
+ */
126
+ export function Treemap({
127
+ data,
128
+ width,
129
+ height,
130
+ scale = 'sqrt',
131
+ maxTiles = 60,
132
+ onPress,
133
+ selectedId,
134
+ className,
135
+ ...props
136
+ }: TreemapProps) {
137
+ const clean = data
138
+ .filter((d) => d.value > 0)
139
+ .sort((a, b) => b.value - a.value)
140
+ .map((d, i) => ({ ...d, color: d.color ?? PALETTE[i % PALETTE.length] }))
141
+
142
+ let tiles = clean
143
+ if (clean.length > maxTiles) {
144
+ const head = clean.slice(0, maxTiles - 1)
145
+ const restValue = clean.slice(maxTiles - 1).reduce((s, d) => s + d.value, 0)
146
+ tiles = [
147
+ ...head,
148
+ { id: '__more__', value: restValue, color: '#3a3a3a', label: `+${clean.length - (maxTiles - 1)} more` },
149
+ ]
150
+ }
151
+
152
+ const weighted = tiles.map((d) => ({ ...d, value: scaleValue(d.value, scale) }))
153
+ const rects = squarify(weighted, width, height)
154
+
155
+ return (
156
+ <View className={cn('relative', className)} style={{ width, height }} {...props}>
157
+ {rects.map((r) => {
158
+ const selected = r.datum.id === selectedId
159
+ const labelFits = r.w > 54 && r.h > 22
160
+ const pressable = r.datum.id !== '__more__'
161
+ return (
162
+ <Pressable
163
+ key={r.datum.id}
164
+ testID={`treemap-tile-${r.datum.id}`}
165
+ accessibilityLabel={r.datum.label ?? r.datum.id}
166
+ onPress={pressable ? () => onPress?.(r.datum.id) : undefined}
167
+ style={{
168
+ position: 'absolute',
169
+ left: r.x,
170
+ top: r.y,
171
+ width: Math.max(0, r.w - 2),
172
+ height: Math.max(0, r.h - 2),
173
+ backgroundColor: r.datum.color,
174
+ borderRadius: 3,
175
+ opacity: selected ? 1 : 0.9,
176
+ borderWidth: selected ? 2 : 0,
177
+ borderColor: '#ffffff',
178
+ padding: 4,
179
+ overflow: 'hidden',
180
+ }}
181
+ >
182
+ {labelFits && (
183
+ <Text numberOfLines={2} className="text-[10px] font-semibold text-[#0b0b0b]">
184
+ {r.datum.label ?? r.datum.id}
185
+ </Text>
186
+ )}
187
+ </Pressable>
188
+ )
189
+ })}
190
+ </View>
191
+ )
192
+ }
@@ -0,0 +1,2 @@
1
+ export { Treemap } from './Treemap'
2
+ export type { TreemapProps, TreemapDatum, TreemapScale } from './Treemap'
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { ActiveWorkoutPage, type ActiveWorkoutExercise } from './ActiveWorkoutPage'
3
+ import type { SetRowProps } from './SetRow'
4
+
5
+ function activeSets(weight: number, logged: number, total: number): SetRowProps[] {
6
+ return Array.from({ length: total }, (_, i) => {
7
+ const setNumber = i + 1
8
+ if (setNumber <= logged) {
9
+ return {
10
+ mode: 'completed' as const,
11
+ setNumber,
12
+ previous: { reps: 8, weight: weight - 5 },
13
+ reps: 8,
14
+ weight,
15
+ rpe: 7.5 + setNumber * 0.5,
16
+ unit: 'lbs' as const,
17
+ }
18
+ }
19
+ return {
20
+ mode: 'active' as const,
21
+ setNumber,
22
+ previous: { reps: 8, weight: weight - 5 },
23
+ reps: null,
24
+ weight: null,
25
+ unit: 'lbs' as const,
26
+ isNextSet: setNumber === logged + 1,
27
+ targets: { reps: 8, weight },
28
+ }
29
+ })
30
+ }
31
+
32
+ const velocities = (base: number): number[] =>
33
+ Array.from({ length: 8 }, (_, i) => Number((base - i * 0.03).toFixed(2)))
34
+
35
+ const exercises: ActiveWorkoutExercise[] = [
36
+ {
37
+ id: 'warmup',
38
+ name: 'Band Pull-Apart',
39
+ status: 'completed',
40
+ unit: 'lbs',
41
+ totalPlannedSets: 2,
42
+ summary: { sets: 2, reps: 15, weight: 0, unit: 'lbs' },
43
+ setVelocities: [velocities(0.9), velocities(0.88)],
44
+ },
45
+ {
46
+ id: 'bench',
47
+ name: 'Barbell Bench Press',
48
+ status: 'active',
49
+ unit: 'lbs',
50
+ totalPlannedSets: 4,
51
+ e1rm: { value: 248, unit: 'lbs' },
52
+ tempo: [3, 1, 1, 0],
53
+ setVelocities: [velocities(0.72), velocities(0.68)],
54
+ sets: activeSets(195, 2, 4),
55
+ },
56
+ {
57
+ id: 'incline-db',
58
+ name: 'Incline DB Press',
59
+ status: 'upcoming',
60
+ unit: 'lbs',
61
+ totalPlannedSets: 3,
62
+ prescription: '3 × 10 @ RPE 8',
63
+ previousBest: 'Last: 70 lbs',
64
+ supersetId: 'ss1',
65
+ },
66
+ {
67
+ id: 'lateral-raise',
68
+ name: 'Cable Lateral Raise',
69
+ status: 'upcoming',
70
+ unit: 'lbs',
71
+ totalPlannedSets: 3,
72
+ prescription: '3 × 15 @ RPE 9',
73
+ previousBest: 'Last: 20 lbs',
74
+ supersetId: 'ss1',
75
+ },
76
+ {
77
+ id: 'triceps',
78
+ name: 'Rope Pushdown',
79
+ status: 'upcoming',
80
+ unit: 'lbs',
81
+ totalPlannedSets: 3,
82
+ prescription: '3 × 12 @ RPE 9',
83
+ previousBest: 'Last: 55 lbs',
84
+ },
85
+ ]
86
+
87
+ const meta: Meta<typeof ActiveWorkoutPage> = {
88
+ title: 'Custom/Workout/ActiveWorkoutPage',
89
+ component: ActiveWorkoutPage,
90
+ parameters: { layout: 'fullscreen' },
91
+ tags: ['autodocs'],
92
+ args: {
93
+ title: 'Push Day A',
94
+ subtitle: 'Hypertrophy · Week 6 of 8',
95
+ exercises,
96
+ supersets: [{ id: 'ss1', label: 'SS1', color: '#22D3EE' }],
97
+ input: { reps: '8', weight: '195' },
98
+ rest: { totalSeconds: 120, elapsedMs: 42_000, nextSetInfo: 'Set 3 · 8 × 195 lbs' },
99
+ },
100
+ }
101
+
102
+ export default meta
103
+ type Story = StoryObj<typeof ActiveWorkoutPage>
104
+
105
+ export const Default: Story = {}
106
+
107
+ export const Resting: Story = {
108
+ args: { initialResting: true },
109
+ }
110
+
111
+ export const AllCollapsed: Story = {
112
+ args: {
113
+ exercises: exercises.map((exercise) =>
114
+ exercise.status === 'active' ? { ...exercise, status: 'completed' } : exercise
115
+ ),
116
+ input: undefined,
117
+ },
118
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import {
4
+ ActiveWorkoutPage,
5
+ countCompletedSets,
6
+ deriveWorkoutProgress,
7
+ findActiveExercise,
8
+ statusToCardState,
9
+ groupExercises,
10
+ type ActiveWorkoutExercise,
11
+ type ActiveWorkoutPageProps,
12
+ } from './ActiveWorkoutPage'
13
+
14
+ const exercises: ActiveWorkoutExercise[] = [
15
+ {
16
+ id: 'warmup',
17
+ name: 'Band Pull-Apart',
18
+ status: 'completed',
19
+ unit: 'lbs',
20
+ totalPlannedSets: 2,
21
+ summary: { sets: 2, reps: 15, weight: 0, unit: 'lbs' },
22
+ setVelocities: [[0.9], [0.88]],
23
+ },
24
+ {
25
+ id: 'bench',
26
+ name: 'Barbell Bench Press',
27
+ status: 'active',
28
+ unit: 'lbs',
29
+ totalPlannedSets: 4,
30
+ setVelocities: [[0.72], [0.68]],
31
+ sets: [
32
+ { mode: 'completed', setNumber: 1, reps: 8, weight: 195, rpe: 8, unit: 'lbs' },
33
+ { mode: 'active', setNumber: 3, reps: null, weight: null, unit: 'lbs', isNextSet: true },
34
+ ],
35
+ },
36
+ {
37
+ id: 'incline',
38
+ name: 'Incline DB Press',
39
+ status: 'upcoming',
40
+ unit: 'lbs',
41
+ totalPlannedSets: 3,
42
+ prescription: '3 × 10',
43
+ supersetId: 'ss1',
44
+ },
45
+ {
46
+ id: 'lateral',
47
+ name: 'Cable Lateral Raise',
48
+ status: 'upcoming',
49
+ unit: 'lbs',
50
+ totalPlannedSets: 3,
51
+ prescription: '3 × 15',
52
+ supersetId: 'ss1',
53
+ },
54
+ ]
55
+
56
+ const baseProps: ActiveWorkoutPageProps = {
57
+ title: 'Push Day A',
58
+ subtitle: 'Week 6 of 8',
59
+ exercises,
60
+ supersets: [{ id: 'ss1', label: 'SS1', color: '#22D3EE' }],
61
+ input: { reps: '8', weight: '195' },
62
+ rest: { totalSeconds: 120, elapsedMs: 30_000, nextSetInfo: 'Set 3' },
63
+ }
64
+
65
+ describe('countCompletedSets', () => {
66
+ it('uses logged velocities when present', () => {
67
+ expect(countCompletedSets(exercises[1])).toBe(2)
68
+ })
69
+
70
+ it('falls back to the full plan for a completed exercise without velocities', () => {
71
+ expect(countCompletedSets({ ...exercises[0], setVelocities: undefined })).toBe(2)
72
+ })
73
+
74
+ it('counts zero for an upcoming exercise', () => {
75
+ expect(countCompletedSets(exercises[2])).toBe(0)
76
+ })
77
+ })
78
+
79
+ describe('deriveWorkoutProgress', () => {
80
+ it('rolls up completed exercises, sets, and the fraction', () => {
81
+ const progress = deriveWorkoutProgress(exercises)
82
+ expect(progress.completedExercises).toBe(1)
83
+ expect(progress.totalExercises).toBe(4)
84
+ expect(progress.completedSets).toBe(4)
85
+ expect(progress.totalSets).toBe(12)
86
+ expect(progress.fraction).toBeCloseTo(1 / 3)
87
+ })
88
+
89
+ it('is zeroed for an empty workout', () => {
90
+ expect(deriveWorkoutProgress([])).toEqual({
91
+ completedExercises: 0,
92
+ totalExercises: 0,
93
+ completedSets: 0,
94
+ totalSets: 0,
95
+ fraction: 0,
96
+ })
97
+ })
98
+ })
99
+
100
+ describe('findActiveExercise', () => {
101
+ it('returns the active exercise', () => {
102
+ expect(findActiveExercise(exercises)?.id).toBe('bench')
103
+ })
104
+
105
+ it('returns null when none are active', () => {
106
+ expect(findActiveExercise([exercises[0]])).toBeNull()
107
+ })
108
+ })
109
+
110
+ describe('statusToCardState', () => {
111
+ it('keeps upcoming exercises upcoming regardless of focus', () => {
112
+ expect(statusToCardState('upcoming', true)).toBe('upcoming')
113
+ expect(statusToCardState('upcoming', false)).toBe('upcoming')
114
+ })
115
+
116
+ it('expands only the focused active/completed exercise', () => {
117
+ expect(statusToCardState('active', true)).toBe('expanded')
118
+ expect(statusToCardState('active', false)).toBe('collapsed')
119
+ expect(statusToCardState('completed', true)).toBe('expanded')
120
+ })
121
+ })
122
+
123
+ describe('groupExercises', () => {
124
+ it('folds consecutive same-superset exercises into one group', () => {
125
+ const groups = groupExercises(exercises, baseProps.supersets ?? [])
126
+ expect(groups).toHaveLength(3)
127
+ expect(groups[0]).toEqual({ type: 'single', exercise: exercises[0] })
128
+ expect(groups[2].type).toBe('superset')
129
+ if (groups[2].type === 'superset') {
130
+ expect(groups[2].exercises).toHaveLength(2)
131
+ expect(groups[2].superset.label).toBe('SS1')
132
+ }
133
+ })
134
+ })
135
+
136
+ describe('ActiveWorkoutPage', () => {
137
+ it('renders the header, progress, and the focused active exercise expanded', () => {
138
+ render(<ActiveWorkoutPage {...baseProps} />)
139
+ expect(screen.getByTestId('active-workout-page')).toBeInTheDocument()
140
+ expect(screen.getByTestId('active-workout-page-title')).toHaveTextContent('Push Day A')
141
+ expect(screen.getByTestId('active-workout-page-progress-count')).toHaveTextContent('1/4')
142
+ expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
143
+ expect(screen.getByTestId('superset-wrapper')).toBeInTheDocument()
144
+ })
145
+
146
+ it('shows the input bar and hides it once a set is recorded (starts rest)', () => {
147
+ render(<ActiveWorkoutPage {...baseProps} />)
148
+ expect(screen.getByTestId('input-bar')).toBeInTheDocument()
149
+ expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 3/4')
150
+
151
+ fireEvent.click(screen.getByTestId('input-bar-record'))
152
+ expect(screen.queryByTestId('input-bar')).not.toBeInTheDocument()
153
+ expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
154
+ })
155
+
156
+ it('zoom-toggles focus: collapsing the active card hides its sets', () => {
157
+ render(<ActiveWorkoutPage {...baseProps} />)
158
+ expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
159
+
160
+ fireEvent.click(screen.getAllByTestId('exercise-card-header')[0])
161
+ expect(screen.queryByTestId('exercise-card-sets')).not.toBeInTheDocument()
162
+ })
163
+
164
+ it('skipping rest restores the input bar', () => {
165
+ render(<ActiveWorkoutPage {...baseProps} initialResting />)
166
+ expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
167
+
168
+ fireEvent.click(screen.getByTestId('rest-timer-skip'))
169
+ expect(screen.getByTestId('input-bar')).toBeInTheDocument()
170
+ })
171
+ })