@titan-design/react-ui 0.2.7 → 0.4.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 (92) 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 +3014 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1088 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/chunk-2SISKTWM.mjs +1027 -0
  10. package/dist/chunk-2SISKTWM.mjs.map +1 -0
  11. package/dist/{chunk-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
  12. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  13. package/dist/{chunk-3VLOBS6O.mjs → chunk-SNVKL5WZ.mjs} +42 -5
  14. package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
  15. package/dist/index.d.mts +416 -250
  16. package/dist/index.d.ts +416 -250
  17. package/dist/index.js +1567 -1123
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +1491 -2201
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +288 -2
  22. package/dist/theme/index.d.ts +288 -2
  23. package/dist/theme/index.js +159 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -2
  26. package/dist/theme/tokens-css.d.mts +5 -3
  27. package/dist/theme/tokens-css.d.ts +5 -3
  28. package/dist/theme/tokens-css.js +318 -9
  29. package/dist/theme/tokens-css.js.map +1 -1
  30. package/dist/theme/tokens-css.mjs +1 -1
  31. package/dist/theme/tokens-css.mjs.map +1 -1
  32. package/package.json +10 -1
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/Table/Table.test.tsx +27 -0
  35. package/src/components/custom/Table/Table.tsx +33 -17
  36. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  37. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  38. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +442 -0
  39. package/src/components/custom/Workout/BodyMap.tsx +7 -9
  40. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -29
  41. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  42. package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
  43. package/src/components/custom/Workout/ExerciseCard.tsx +24 -26
  44. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  45. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  46. package/src/components/custom/Workout/ExerciseDetailPage.tsx +624 -0
  47. package/src/components/custom/Workout/InputBar.tsx +10 -12
  48. package/src/components/custom/Workout/MesoCard.tsx +2 -4
  49. package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
  50. package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
  51. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  52. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  53. package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -4
  54. package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
  55. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  56. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  57. package/src/components/custom/Workout/ProgramPlanningPage.tsx +418 -0
  58. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -12
  59. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  60. package/src/components/custom/Workout/RestTimer.tsx +12 -9
  61. package/src/components/custom/Workout/SetRow.tsx +16 -19
  62. package/src/components/custom/Workout/StatusDot.tsx +1 -0
  63. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  64. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  65. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  66. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  67. package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
  68. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  69. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  70. package/src/components/custom/Workout/TrainingStatusPage.tsx +312 -0
  71. package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
  72. package/src/components/custom/Workout/VelocityStrip.tsx +19 -16
  73. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  74. package/src/components/custom/Workout/WorkoutCard.tsx +3 -6
  75. package/src/components/custom/Workout/index.ts +80 -32
  76. package/src/test/nativewind-stub.ts +13 -0
  77. package/src/theme/ThemeProvider.test.tsx +24 -0
  78. package/src/theme/ThemeProvider.tsx +50 -0
  79. package/src/theme/config.completeness.test.ts +77 -0
  80. package/src/theme/config.ts +151 -0
  81. package/src/theme/index.ts +2 -0
  82. package/src/theme/manifest/design-freeze.test.ts +155 -0
  83. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  84. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  85. package/src/theme/resolve-color.ts +19 -0
  86. package/src/theme/tokens-css.ts +5 -3
  87. package/src/theme/workout-tokens.ts +12 -14
  88. package/src/utils/index.ts +10 -0
  89. package/src/utils/workout-format.test.ts +81 -0
  90. package/src/utils/workout-format.ts +83 -0
  91. package/dist/chunk-3VLOBS6O.mjs.map +0 -1
  92. package/dist/chunk-5SSKGS6E.mjs.map +0 -1
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ buildFreezeTagName,
4
+ buildFreezeTagMessage,
5
+ assertFileCommitted,
6
+ slugifyVariant,
7
+ generateSpecimenSkeleton,
8
+ parseArgs,
9
+ } from '../../../scripts/design-freeze.mjs'
10
+
11
+ describe('buildFreezeTagName', () => {
12
+ it('namespaces the tag under design-freeze/ with the component and version', () => {
13
+ expect(buildFreezeTagName('e1rm-badge', '2026-06-30')).toBe(
14
+ 'design-freeze/e1rm-badge-2026-06-30'
15
+ )
16
+ })
17
+
18
+ it('throws when component is missing', () => {
19
+ expect(() => buildFreezeTagName('', '1')).toThrow(/requires a component name/)
20
+ })
21
+ })
22
+
23
+ describe('buildFreezeTagMessage', () => {
24
+ it('records the prototype path and selector the freeze covers', () => {
25
+ const message = buildFreezeTagMessage({
26
+ component: 'status-dot',
27
+ htmlPath: 'docs/prototypes/component-demo.html',
28
+ selector: '.status-dot',
29
+ })
30
+
31
+ expect(message).toContain('Design freeze: status-dot')
32
+ expect(message).toContain('docs/prototypes/component-demo.html')
33
+ expect(message).toContain('.status-dot')
34
+ expect(message).toContain('re-freeze')
35
+ })
36
+ })
37
+
38
+ describe('assertFileCommitted', () => {
39
+ it('does not throw for a clean porcelain result', () => {
40
+ expect(() => assertFileCommitted('', 'docs/prototypes/component-demo.html')).not.toThrow()
41
+ expect(() => assertFileCommitted(' \n', 'docs/prototypes/component-demo.html')).not.toThrow()
42
+ })
43
+
44
+ it('throws with the porcelain output when the file has uncommitted changes', () => {
45
+ expect(() =>
46
+ assertFileCommitted(
47
+ ' M docs/prototypes/component-demo.html\n',
48
+ 'docs/prototypes/component-demo.html'
49
+ )
50
+ ).toThrow(/uncommitted changes/)
51
+ })
52
+
53
+ it('throws for an untracked file', () => {
54
+ expect(() => assertFileCommitted('?? new-prototype.html\n', 'new-prototype.html')).toThrow(
55
+ /uncommitted changes/
56
+ )
57
+ })
58
+ })
59
+
60
+ describe('slugifyVariant', () => {
61
+ it('returns an empty string when there are no variant axes', () => {
62
+ expect(slugifyVariant(undefined)).toBe('')
63
+ expect(slugifyVariant({})).toBe('')
64
+ })
65
+
66
+ it('joins axis/value pairs with hyphens', () => {
67
+ expect(slugifyVariant({ size: 'md' })).toBe('size-md')
68
+ expect(slugifyVariant({ variant: 'solid', size: 'md' })).toBe('variant-solid-size-md')
69
+ })
70
+ })
71
+
72
+ describe('generateSpecimenSkeleton', () => {
73
+ it('embeds the frozen markup and a TODO placeholder for the React side', () => {
74
+ const skeleton = generateSpecimenSkeleton({
75
+ component: 'e1rm-badge',
76
+ htmlSnippet: '<div class="e1rm-badge md">217 lbs</div>',
77
+ })
78
+
79
+ expect(skeleton).toContain('testId="compare-e1rm-badge"')
80
+ expect(skeleton).toContain('label="e1rm-badge"')
81
+ expect(skeleton).toContain('<div class="e1rm-badge md">217 lbs</div>')
82
+ expect(skeleton).toContain('<ComparisonPair')
83
+ expect(skeleton).toContain('render <e1rm-badge /> here')
84
+ })
85
+
86
+ it('suffixes testId and label with the slugified baseVariant', () => {
87
+ const skeleton = generateSpecimenSkeleton({
88
+ component: 'status-dot',
89
+ htmlSnippet: '<div class="status-dot sm success"></div>',
90
+ baseVariant: { size: 'sm', variant: 'success' },
91
+ })
92
+
93
+ expect(skeleton).toContain('testId="compare-status-dot-size-sm-variant-success"')
94
+ expect(skeleton).toContain('label="status-dot size sm variant success"')
95
+ })
96
+
97
+ it('escapes backticks in the markup so the template literal stays valid', () => {
98
+ const skeleton = generateSpecimenSkeleton({
99
+ component: 'quote',
100
+ htmlSnippet: '<div>`backtick`</div>',
101
+ })
102
+
103
+ expect(skeleton).toContain('\\`backtick\\`')
104
+ })
105
+
106
+ it('throws when required fields are missing', () => {
107
+ expect(() => generateSpecimenSkeleton({ component: '', htmlSnippet: '<div></div>' })).toThrow(
108
+ /requires a component name/
109
+ )
110
+ expect(() => generateSpecimenSkeleton({ component: 'x', htmlSnippet: '' })).toThrow(
111
+ /requires an htmlSnippet/
112
+ )
113
+ })
114
+ })
115
+
116
+ describe('parseArgs', () => {
117
+ it('parses positional args and design-freeze-specific flags', () => {
118
+ const result = parseArgs([
119
+ 'docs/prototypes/component-demo.html',
120
+ '.e1rm-badge',
121
+ 'e1rm-badge',
122
+ '--out-manifest',
123
+ 'src/theme/manifest/e1rm-badge.manifest.json',
124
+ '--out-skeleton',
125
+ 'e1rm-badge.skeleton.tsx',
126
+ '--tag-version',
127
+ '2026-06-30',
128
+ '--variant',
129
+ 'size=md',
130
+ ])
131
+
132
+ expect(result).toMatchObject({
133
+ htmlPath: 'docs/prototypes/component-demo.html',
134
+ selector: '.e1rm-badge',
135
+ component: 'e1rm-badge',
136
+ outManifest: 'src/theme/manifest/e1rm-badge.manifest.json',
137
+ outSkeleton: 'e1rm-badge.skeleton.tsx',
138
+ version: '2026-06-30',
139
+ baseVariant: { size: 'md' },
140
+ skipGitTag: false,
141
+ })
142
+ })
143
+
144
+ it('defaults tag version to an ISO date and recognizes --skip-git-tag', () => {
145
+ const result = parseArgs([
146
+ 'docs/prototypes/component-demo.html',
147
+ '.badge',
148
+ 'badge',
149
+ '--skip-git-tag',
150
+ ])
151
+
152
+ expect(result.skipGitTag).toBe(true)
153
+ expect(result.version).toMatch(/^\d{4}-\d{2}-\d{2}$/)
154
+ })
155
+ })
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>CSS extraction fixture — button specimen</title>
6
+ </head>
7
+ <body>
8
+ <button
9
+ data-testid="button"
10
+ style="
11
+ background-color: rgb(255, 121, 0);
12
+ color: rgb(255, 255, 255);
13
+ border-color: rgba(0, 0, 0, 0);
14
+ border-style: solid;
15
+ border-width: 0px;
16
+ border-radius: 6px;
17
+ font-family: Inter, sans-serif;
18
+ font-size: 16px;
19
+ font-weight: 600;
20
+ padding-top: 10px;
21
+ padding-right: 20px;
22
+ padding-bottom: 10px;
23
+ padding-left: 20px;
24
+ "
25
+ >
26
+ Start workout
27
+ </button>
28
+ </body>
29
+ </html>
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import {
5
+ DEFAULT_VISUAL_PROPERTIES,
6
+ buildCssPropertyManifest,
7
+ readComputedStyleMap,
8
+ styleMapToProperties,
9
+ toKebabCase,
10
+ parseArgs,
11
+ } from '../../../scripts/extract-css-properties.mjs'
12
+ import { validateCssPropertyManifest } from './css-property-manifest.validate'
13
+
14
+ const loadFixtureBody = (fileName: string): string => {
15
+ const html = readFileSync(join(__dirname, fileName), 'utf-8')
16
+ return new DOMParser().parseFromString(html, 'text/html').body.innerHTML
17
+ }
18
+
19
+ describe('toKebabCase', () => {
20
+ it('converts camelCase CSS property names to kebab-case', () => {
21
+ expect(toKebabCase('backgroundColor')).toBe('background-color')
22
+ expect(toKebabCase('paddingLeft')).toBe('padding-left')
23
+ expect(toKebabCase('color')).toBe('color')
24
+ })
25
+ })
26
+
27
+ describe('styleMapToProperties', () => {
28
+ it('drops empty computed values and tags each entry with a null token', () => {
29
+ const properties = styleMapToProperties({
30
+ backgroundColor: 'rgb(255, 121, 0)',
31
+ borderRadius: ' 6px ',
32
+ letterSpacing: '',
33
+ lineHeight: ' ',
34
+ })
35
+
36
+ expect(properties).toEqual([
37
+ { property: 'backgroundColor', token: null, resolvedValue: 'rgb(255, 121, 0)' },
38
+ { property: 'borderRadius', token: null, resolvedValue: '6px' },
39
+ ])
40
+ })
41
+ })
42
+
43
+ describe('buildCssPropertyManifest', () => {
44
+ it('throws when no property has a non-empty computed value', () => {
45
+ expect(() =>
46
+ buildCssPropertyManifest({ component: 'button', styleMap: { color: '' } })
47
+ ).toThrow(/No non-empty computed CSS properties/)
48
+ })
49
+
50
+ it('includes baseVariant and description only when provided', () => {
51
+ const manifest = buildCssPropertyManifest({
52
+ component: 'button',
53
+ styleMap: { color: 'rgb(255, 255, 255)' },
54
+ baseVariant: { variant: 'solid' },
55
+ description: 'from fixture',
56
+ })
57
+
58
+ expect(manifest).toMatchObject({
59
+ component: 'button',
60
+ description: 'from fixture',
61
+ baseVariant: { variant: 'solid' },
62
+ })
63
+ })
64
+ })
65
+
66
+ describe('parseArgs', () => {
67
+ it('parses positional args, --out, and repeated --variant flags', () => {
68
+ const result = parseArgs([
69
+ 'specimen/index.html',
70
+ '.btn',
71
+ 'button',
72
+ '--out',
73
+ 'out.json',
74
+ '--variant',
75
+ 'variant=solid',
76
+ '--variant',
77
+ 'size=md',
78
+ ])
79
+
80
+ expect(result).toEqual({
81
+ htmlPath: 'specimen/index.html',
82
+ selector: '.btn',
83
+ component: 'button',
84
+ out: 'out.json',
85
+ baseVariant: { variant: 'solid', size: 'md' },
86
+ })
87
+ })
88
+ })
89
+
90
+ describe('extraction pipeline against a fixture specimen', () => {
91
+ it('produces a manifest that validates against the css-property-manifest schema', () => {
92
+ document.body.innerHTML = loadFixtureBody('extract-css-properties.fixture.html')
93
+ const element = document.querySelector('[data-testid="button"]')
94
+ expect(element).not.toBeNull()
95
+
96
+ const styleMap = readComputedStyleMap(element as Element, DEFAULT_VISUAL_PROPERTIES, (el) =>
97
+ getComputedStyle(el as Element)
98
+ )
99
+ const manifest = buildCssPropertyManifest({
100
+ component: 'button',
101
+ styleMap,
102
+ baseVariant: { variant: 'solid' },
103
+ description: 'Extracted from extract-css-properties.fixture.html',
104
+ })
105
+
106
+ expect(validateCssPropertyManifest(manifest)).toEqual({ valid: true, errors: [] })
107
+ const properties = manifest.properties.map((entry) => entry.property)
108
+ expect(properties).toContain('backgroundColor')
109
+ expect(properties).toContain('paddingLeft')
110
+ expect(manifest.properties.every((entry) => entry.token === null)).toBe(true)
111
+ })
112
+ })
@@ -0,0 +1,19 @@
1
+ import { Platform } from 'react-native'
2
+ import { getSemanticColors, type ThemeMode } from './tokens/semantic'
3
+
4
+ type ColorToken = keyof ReturnType<typeof getSemanticColors>
5
+
6
+ /**
7
+ * Resolve a semantic color token for an inline style where a nativewind
8
+ * className can't apply — gradients (`backgroundImage`), per-edge borders
9
+ * (`borderTopColor`, …), SVG attributes, and conditional hex/token mixes.
10
+ *
11
+ * The companion to className color tokens: on **web** it returns the CSS
12
+ * variable (`var(--color-…)`), so the dashboard's light/dark switching keeps
13
+ * working exactly as `global.css` intends; on **native** it returns the
14
+ * resolved hex from `getSemanticColors` (mobile is dark-only). This is why
15
+ * these spots stay theme-correct on web while also rendering on-device.
16
+ */
17
+ export function resolveColor(token: ColorToken, mode: ThemeMode = 'dark'): string {
18
+ return Platform.OS === 'web' ? `var(--color-${token})` : getSemanticColors(mode)[token]
19
+ }
@@ -2,9 +2,11 @@
2
2
  * Token CSS Generator
3
3
  *
4
4
  * Emits a static `:root {}` CSS block from the canonical theme token maps
5
- * (`darkThemeCSSVars` / `lightThemeCSSVars`). HTML prototypes import the
6
- * generated stylesheet so they always resolve to the exact same values as
7
- * the design system. Pure codegen: deterministic and diffable against source.
5
+ * (`darkThemeCSSVars` / `lightThemeCSSVars`). Those maps mirror every `:root`
6
+ * custom property in `theme/global.css` completeness and value parity are
7
+ * enforced by `config.completeness.test.ts`, so HTML prototypes that import the
8
+ * generated stylesheet resolve to the exact same values as the design system.
9
+ * Pure codegen: deterministic and diffable against source.
8
10
  *
9
11
  * Dark mode is the default (`:root`); light mode is activated with `.light`,
10
12
  * matching the convention in `theme/global.css`.
@@ -3,8 +3,15 @@
3
3
  * Use these inline instead of Tailwind classes.
4
4
  */
5
5
  export const WORKOUT_TOKENS = {
6
- // Velocity zones (not in Tailwind)
7
- velocity: {
6
+ // Canonical 4-band performance scale — the single source for BOTH the
7
+ // VelocityStrip zone bars and the SetRow RPE color (TD-03.43). The direction
8
+ // is intentionally inverted between the two consumers: for velocity, green =
9
+ // fastest/best; for RPE, green = easiest. Each component maps its own
10
+ // thresholds onto these four colors. Kept as vivid data-viz values (distinct
11
+ // from the muted status tokens) and theme-independent, so — like the heatmap
12
+ // scale below — they are consumed inline as a JS import rather than CSS vars.
13
+ // A JS import also resolves on native RN, unlike var(--…) strings.
14
+ scale: {
8
15
  green: '#2ed573',
9
16
  yellow: '#ffd43b',
10
17
  orange: '#ffa502',
@@ -25,18 +32,9 @@ export const WORKOUT_TOKENS = {
25
32
  // Badge border-radius (rounded-sm is 4px, we need 2px)
26
33
  badgeRadius: 2,
27
34
 
28
- // Border colors (use inline to avoid 'border' class pitfall)
29
- border: {
30
- default: '#1F1F1F',
31
- strong: '#2C2C2C',
32
- subtle: '#1C1C1C',
33
- },
34
-
35
- // Surface colors for inline use
36
- surface: {
37
- raised: '#1C1C1C',
38
- elevated: '#191919',
39
- },
35
+ // Surface and border colors were removed here: those flip with the theme, so
36
+ // components use the CSS custom properties (var(--color-surface-*/border-*))
37
+ // directly. Only theme-independent data-viz values remain below.
40
38
 
41
39
  // Intensity bar specific
42
40
  intensity: {
@@ -27,3 +27,13 @@ export type {
27
27
  FormErrors,
28
28
  FormTouched,
29
29
  } from './form'
30
+ export {
31
+ roundRpe,
32
+ rpeColor,
33
+ roundWeight,
34
+ formatVelocity,
35
+ roundTempo,
36
+ formatSignedPct,
37
+ formatPrescription,
38
+ } from './workout-format'
39
+ export type { PrescriptionInput } from './workout-format'
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ roundRpe,
4
+ rpeColor,
5
+ roundWeight,
6
+ formatVelocity,
7
+ roundTempo,
8
+ formatSignedPct,
9
+ formatPrescription,
10
+ } from './workout-format'
11
+ import { WORKOUT_TOKENS } from '../theme/workout-tokens'
12
+
13
+ describe('roundRpe', () => {
14
+ it('rounds an exact RPE to the nearest 0.5', () => {
15
+ expect(roundRpe(8.3)).toBe(8.5)
16
+ expect(roundRpe(8.24)).toBe(8)
17
+ expect(roundRpe(9)).toBe(9)
18
+ expect(roundRpe(7.75)).toBe(8)
19
+ })
20
+ })
21
+
22
+ describe('rpeColor', () => {
23
+ it('bands onto the canonical scale, red for hardest', () => {
24
+ expect(rpeColor(9.5)).toBe(WORKOUT_TOKENS.scale.red)
25
+ expect(rpeColor(8.5)).toBe(WORKOUT_TOKENS.scale.orange)
26
+ expect(rpeColor(7)).toBe(WORKOUT_TOKENS.scale.yellow)
27
+ expect(rpeColor(6.9)).toBe(WORKOUT_TOKENS.scale.green)
28
+ })
29
+ })
30
+
31
+ describe('roundWeight', () => {
32
+ it('rounds to a whole unit', () => {
33
+ expect(roundWeight(62.4)).toBe(62)
34
+ expect(roundWeight(62.5)).toBe(63)
35
+ expect(roundWeight(60)).toBe(60)
36
+ })
37
+ })
38
+
39
+ describe('formatVelocity', () => {
40
+ it('formats m/s to 2 decimal places', () => {
41
+ expect(formatVelocity(0.6)).toBe('0.60')
42
+ expect(formatVelocity(1.234)).toBe('1.23')
43
+ })
44
+ })
45
+
46
+ describe('roundTempo', () => {
47
+ it('rounds each phase to 1 dp', () => {
48
+ expect(roundTempo([2.34, 0, 1.06, 0.5])).toEqual([2.3, 0, 1.1, 0.5])
49
+ })
50
+ })
51
+
52
+ describe('formatSignedPct', () => {
53
+ it('renders a signed percentage from a ratio', () => {
54
+ expect(formatSignedPct(0.09)).toBe('+9%')
55
+ expect(formatSignedPct(-0.05)).toBe('-5%')
56
+ expect(formatSignedPct(0)).toBe('0%')
57
+ })
58
+ })
59
+
60
+ describe('formatPrescription', () => {
61
+ it('builds a full prescription string', () => {
62
+ expect(formatPrescription({ repsLow: 8, repsHigh: 10, weightLbs: 62, rpe: 8 })).toBe(
63
+ '8–10 @ 62 lb · RPE 8'
64
+ )
65
+ })
66
+
67
+ it('collapses an equal rep range to a single number', () => {
68
+ expect(formatPrescription({ repsLow: 5, repsHigh: 5, weightLbs: 100 })).toBe('5 @ 100 lb')
69
+ })
70
+
71
+ it('omits missing parts', () => {
72
+ expect(formatPrescription({ rpe: 7 })).toBe('RPE 7')
73
+ expect(formatPrescription({ weightLbs: 50 })).toBe('50 lb')
74
+ })
75
+
76
+ it('returns null when there is nothing to show', () => {
77
+ expect(formatPrescription(null)).toBeNull()
78
+ expect(formatPrescription(undefined)).toBeNull()
79
+ expect(formatPrescription({})).toBeNull()
80
+ })
81
+ })
@@ -0,0 +1,83 @@
1
+ // Font mapping context: presentation-only helpers, no rendering.
2
+ import { WORKOUT_TOKENS } from '../theme/workout-tokens'
3
+
4
+ /**
5
+ * Presentation helpers for workout metrics.
6
+ *
7
+ * The data layer (`@voltras/workout-analytics` view-model derivations) supplies
8
+ * EXACT, unrounded values; these turn them into the rounded / banded / formatted
9
+ * forms a view renders. Keeping display munging here — in the design system, not
10
+ * the analytics layer — is the render half of the model↔render split: one view
11
+ * can round while another (or a hover) shows the exact value from the same real
12
+ * data. Workout components consume these so the rules live in one place.
13
+ */
14
+
15
+ /** Round an exact RPE onto its conventional 0.5 granularity for display. */
16
+ export function roundRpe(rpe: number): number {
17
+ return Math.round(rpe * 2) / 2
18
+ }
19
+
20
+ /**
21
+ * Color band for an RPE on the canonical performance scale. Higher RPE = harder
22
+ * = red (direction inverted vs velocity, which shares the same scale). Bands:
23
+ * `<7` green, `7–8.5` yellow, `8.5–9.5` orange, `≥9.5` red.
24
+ */
25
+ export function rpeColor(rpe: number): string {
26
+ if (rpe >= 9.5) return WORKOUT_TOKENS.scale.red
27
+ if (rpe >= 8.5) return WORKOUT_TOKENS.scale.orange
28
+ if (rpe >= 7) return WORKOUT_TOKENS.scale.yellow
29
+ return WORKOUT_TOKENS.scale.green
30
+ }
31
+
32
+ /** Round a weight or e1RM to a whole unit for compact display. */
33
+ export function roundWeight(weight: number): number {
34
+ return Math.round(weight)
35
+ }
36
+
37
+ /** Format an exact velocity (m/s) to the 2-dp string shown on the velocity strip. */
38
+ export function formatVelocity(velocity: number): string {
39
+ return velocity.toFixed(2)
40
+ }
41
+
42
+ /** Round an exact tempo tuple (seconds) to 1 dp per phase for display. */
43
+ export function roundTempo(
44
+ tempo: [number, number, number, number]
45
+ ): [number, number, number, number] {
46
+ return tempo.map((v) => Math.round(v * 10) / 10) as [number, number, number, number]
47
+ }
48
+
49
+ /** Signed percentage label for a deviation ratio, e.g. `+9%` / `-5%` / `0%`. */
50
+ export function formatSignedPct(ratio: number): string {
51
+ const pct = Math.round(ratio * 100)
52
+ return `${pct > 0 ? '+' : ''}${pct}%`
53
+ }
54
+
55
+ /** Structured prescription for the active exercise (from the plan). */
56
+ export interface PrescriptionInput {
57
+ repsLow?: number
58
+ repsHigh?: number
59
+ weightLbs?: number
60
+ rpe?: number
61
+ }
62
+
63
+ /**
64
+ * Human prescription string, e.g. `"8–10 @ 62 lb · RPE 8"`. `null` when there is
65
+ * nothing to show. Presentation-only: the plan supplies the numbers, this shapes
66
+ * the label.
67
+ */
68
+ export function formatPrescription(p: PrescriptionInput | null | undefined): string | null {
69
+ if (p == null) return null
70
+ let reps: string | null = null
71
+ if (p.repsLow != null && p.repsHigh != null) {
72
+ reps = p.repsLow === p.repsHigh ? `${p.repsLow}` : `${p.repsLow}–${p.repsHigh}`
73
+ } else if (p.repsLow != null) {
74
+ reps = `${p.repsLow}`
75
+ }
76
+ const weight = p.weightLbs != null ? `${p.weightLbs} lb` : null
77
+ const head = [reps, weight].filter((s): s is string => s != null).join(' @ ')
78
+ const rpe = p.rpe != null ? `RPE ${p.rpe}` : null
79
+ const full = [head.length > 0 ? head : null, rpe]
80
+ .filter((s): s is string => s != null)
81
+ .join(' · ')
82
+ return full.length > 0 ? full : null
83
+ }