@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
@@ -2,6 +2,7 @@
2
2
  import { View, Text } from 'react-native'
3
3
  import { VelocityStrip } from './VelocityStrip'
4
4
  import { PrBadge, type PRType } from './PrBadge'
5
+ import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
5
6
 
6
7
  export type SetRowMode = 'active' | 'completed' | 'history'
7
8
 
@@ -22,13 +23,6 @@ export interface SetRowProps {
22
23
  targets?: { reps: number; weight: number }
23
24
  }
24
25
 
25
- function getRPEColor(rpe: number): string {
26
- if (rpe >= 9.5) return '#ff4757'
27
- if (rpe >= 8.5) return '#ffa502'
28
- if (rpe >= 7) return '#ffd43b'
29
- return '#2ed573'
30
- }
31
-
32
26
  function formatAccessibilityLabel(
33
27
  setNumber: number,
34
28
  reps: number | null,
@@ -36,7 +30,7 @@ function formatAccessibilityLabel(
36
30
  unit: string,
37
31
  ): string {
38
32
  const repsText = reps != null ? `${reps} reps` : 'no reps'
39
- const weightText = weight != null ? `at ${weight} ${unit}` : ''
33
+ const weightText = weight != null ? `at ${roundWeight(weight)} ${unit}` : ''
40
34
  return `Set ${setNumber}: ${repsText}${weightText ? ` ${weightText}` : ''}`
41
35
  }
42
36
 
@@ -112,11 +106,11 @@ export function SetRow({
112
106
  </Text>
113
107
  ) : (
114
108
  <Text
109
+ className="text-text-secondary"
115
110
  style={{
116
111
  fontSize: 13,
117
112
  fontWeight: '600',
118
113
  fontFamily: 'Inter, sans-serif',
119
- color: '#9CA3AF',
120
114
  }}
121
115
  >
122
116
  {setNumber}
@@ -129,14 +123,14 @@ export function SetRow({
129
123
  testID="set-row-previous"
130
124
  >
131
125
  <Text
126
+ className="text-text-secondary"
132
127
  style={{
133
128
  fontSize: 12,
134
129
  fontFamily: 'Inter, sans-serif',
135
- color: '#9CA3AF',
136
130
  }}
137
131
  >
138
132
  {previous
139
- ? `${previous.reps} x ${previous.weight}`
133
+ ? `${previous.reps} x ${roundWeight(previous.weight)}`
140
134
  : '\u2014'}
141
135
  </Text>
142
136
  </View>
@@ -147,11 +141,11 @@ export function SetRow({
147
141
  >
148
142
  {isActive && reps == null && targets ? (
149
143
  <Text
144
+ className="text-text-tertiary"
150
145
  style={{
151
146
  fontSize: 13,
152
147
  fontStyle: 'italic',
153
148
  fontFamily: 'Inter, sans-serif',
154
- color: '#6B7280',
155
149
  }}
156
150
  testID="set-row-target-reps"
157
151
  >
@@ -159,11 +153,11 @@ export function SetRow({
159
153
  </Text>
160
154
  ) : (
161
155
  <Text
156
+ className={reps != null ? 'text-text-primary' : 'text-text-tertiary'}
162
157
  style={{
163
158
  fontSize: 14,
164
159
  fontWeight: '600',
165
160
  fontFamily: 'Inter, sans-serif',
166
- color: reps != null ? '#F3F4F6' : '#6B7280',
167
161
  }}
168
162
  >
169
163
  {reps != null ? reps : '\u2014'}
@@ -177,26 +171,26 @@ export function SetRow({
177
171
  >
178
172
  {isActive && weight == null && targets ? (
179
173
  <Text
174
+ className="text-text-tertiary"
180
175
  style={{
181
176
  fontSize: 13,
182
177
  fontStyle: 'italic',
183
178
  fontFamily: 'Inter, sans-serif',
184
- color: '#6B7280',
185
179
  }}
186
180
  testID="set-row-target-weight"
187
181
  >
188
- {targets.weight}
182
+ {roundWeight(targets.weight)}
189
183
  </Text>
190
184
  ) : (
191
185
  <Text
186
+ className={weight != null ? 'text-text-primary' : 'text-text-tertiary'}
192
187
  style={{
193
188
  fontSize: 14,
194
189
  fontWeight: '600',
195
190
  fontFamily: 'Inter, sans-serif',
196
- color: weight != null ? '#F3F4F6' : '#6B7280',
197
191
  }}
198
192
  >
199
- {weight != null ? weight : '\u2014'}
193
+ {weight != null ? roundWeight(weight) : '\u2014'}
200
194
  </Text>
201
195
  )}
202
196
  </View>
@@ -206,14 +200,17 @@ export function SetRow({
206
200
  testID="set-row-rpe"
207
201
  >
208
202
  <Text
203
+ className={rpe != null ? undefined : 'text-text-tertiary'}
209
204
  style={{
210
205
  fontSize: 13,
211
206
  fontWeight: '600',
212
207
  fontFamily: 'Inter, sans-serif',
213
- color: rpe != null ? getRPEColor(rpe) : '#6B7280',
208
+ // rpeColor returns a concrete hex (native-safe); only the tertiary
209
+ // fallback needs the className token.
210
+ color: rpe != null ? rpeColor(rpe) : undefined,
214
211
  }}
215
212
  >
216
- {rpe != null ? rpe : '\u2014'}
213
+ {rpe != null ? roundRpe(rpe) : '\u2014'}
217
214
  </Text>
218
215
  </View>
219
216
 
@@ -12,6 +12,7 @@ export type StatusDotVariant =
12
12
 
13
13
  export interface StatusDotProps extends ViewProps {
14
14
  variant: StatusDotVariant
15
+ /** Center glyph. Only rendered at size="md"; the 8px "sm" dot is too small to fit one. */
15
16
  icon?: 'check' | 'exclamation' | 'dash'
16
17
  size?: 'sm' | 'md'
17
18
  /** Adds a subtle glow effect in the variant color */
@@ -2,17 +2,16 @@
2
2
  import { useEffect, useMemo, useState } from 'react'
3
3
  import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
4
  import { cn } from '../../../utils/cn'
5
+ import { resolveColor } from '../../../theme/resolve-color'
5
6
 
6
7
  const BRAND_PRIMARY = '#FF7900'
7
- const TEXT_PRIMARY = '#F3F4F6'
8
- const TEXT_SECONDARY = '#9CA3AF'
9
- const TEXT_TERTIARY = '#6B7280'
10
- const BORDER_STRONG = '#2C2C2C'
8
+ // Line/border color for the dashed projection, used where a className cannot
9
+ // apply (passed as a prop into an inline style, and a per-edge borderTopColor).
10
+ const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
11
11
  const STATUS_SUCCESS = '#14B8A6'
12
12
  const STATUS_ERROR = '#D14343'
13
13
  const STATUS_WARNING = '#FFB020'
14
14
  const GRID_LINE = 'rgba(255,255,255,0.06)'
15
- const TOOLTIP_BG = 'rgba(28,28,28,0.95)'
16
15
  const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
17
16
  const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
18
17
  const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
@@ -312,7 +311,7 @@ export function StrengthTrendChart({
312
311
  justifyContent: 'center',
313
312
  }}
314
313
  >
315
- <Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}>
314
+ <Text className="text-text-tertiary" style={{ fontSize: 12, fontFamily: 'Inter, sans-serif' }}>
316
315
  No strength data yet
317
316
  </Text>
318
317
  </View>
@@ -358,6 +357,7 @@ export function StrengthTrendChart({
358
357
  }}
359
358
  >
360
359
  <Text
360
+ className="text-text-tertiary"
361
361
  style={{
362
362
  position: 'absolute',
363
363
  left: -PLOT_LEFT,
@@ -366,7 +366,6 @@ export function StrengthTrendChart({
366
366
  textAlign: 'right',
367
367
  fontSize: 9,
368
368
  fontFamily: 'Inter, sans-serif',
369
- color: TEXT_TERTIARY,
370
369
  }}
371
370
  >
372
371
  {line.label}
@@ -406,7 +405,7 @@ export function StrengthTrendChart({
406
405
  <View style={{ width, height, position: 'absolute', top: 0, left: 0 }}>
407
406
  <ChartLine
408
407
  coords={geometry.projection}
409
- color={TEXT_TERTIARY}
408
+ color={PROJECTION_LINE_COLOR}
410
409
  strokeWidth={2}
411
410
  dashed
412
411
  testID="strength-trend-chart-projection-segment"
@@ -475,6 +474,7 @@ export function StrengthTrendChart({
475
474
  <View
476
475
  testID="strength-trend-chart-tooltip"
477
476
  accessibilityElementsHidden
477
+ className="bg-surface-elevated border-border-strong"
478
478
  style={{
479
479
  position: 'absolute',
480
480
  left: Math.max(
@@ -483,30 +483,28 @@ export function StrengthTrendChart({
483
483
  ),
484
484
  top: Math.max(0, selectedCoord.y - 52),
485
485
  maxWidth: TOOLTIP_WIDTH,
486
- backgroundColor: TOOLTIP_BG,
487
486
  borderWidth: 1,
488
- borderColor: BORDER_STRONG,
489
487
  borderRadius: 8,
490
488
  paddingVertical: 8,
491
489
  paddingHorizontal: 10,
492
490
  }}
493
491
  >
494
492
  <Text
493
+ className="text-text-tertiary"
495
494
  style={{
496
495
  fontSize: 10,
497
496
  fontFamily: 'Inter, sans-serif',
498
- color: TEXT_TERTIARY,
499
497
  }}
500
498
  >
501
499
  {selectedCoord.point.sessionLabel ?? selectedCoord.point.date}
502
500
  </Text>
503
501
  <Text
502
+ className="text-text-primary"
504
503
  style={{
505
504
  marginTop: 2,
506
505
  fontSize: 13,
507
506
  fontFamily: 'Inter, sans-serif',
508
507
  fontWeight: '700',
509
- color: TEXT_PRIMARY,
510
508
  }}
511
509
  >
512
510
  {`${formatValue(selectedCoord.point.e1rm)} ${unit}`}
@@ -547,6 +545,7 @@ export function StrengthTrendChart({
547
545
  <Text
548
546
  key={`axis-${i}`}
549
547
  testID="strength-trend-chart-axis-label"
548
+ className="text-text-tertiary"
550
549
  style={{
551
550
  position: 'absolute',
552
551
  left: boundary.x - 24,
@@ -554,7 +553,6 @@ export function StrengthTrendChart({
554
553
  textAlign: 'center',
555
554
  fontSize: 10,
556
555
  fontFamily: 'Inter, sans-serif',
557
- color: TEXT_TERTIARY,
558
556
  }}
559
557
  >
560
558
  {boundary.label}
@@ -598,7 +596,7 @@ export function StrengthTrendChart({
598
596
  >
599
597
  <View className="flex-row items-center" style={{ gap: 5 }}>
600
598
  <View style={{ width: 14, height: 2.5, borderRadius: 1.5, backgroundColor: BRAND_PRIMARY }} />
601
- <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
599
+ <Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
602
600
  Actual
603
601
  </Text>
604
602
  </View>
@@ -609,16 +607,16 @@ export function StrengthTrendChart({
609
607
  height: 0,
610
608
  borderTopWidth: 2,
611
609
  borderStyle: 'dashed',
612
- borderTopColor: TEXT_TERTIARY,
610
+ borderTopColor: PROJECTION_LINE_COLOR,
613
611
  }}
614
612
  />
615
- <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
613
+ <Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
616
614
  Projected
617
615
  </Text>
618
616
  </View>
619
617
  <View className="flex-row items-center" style={{ gap: 5 }}>
620
618
  <Text style={{ fontSize: 12, color: STATUS_WARNING }}>★</Text>
621
- <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
619
+ <Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
622
620
  PR
623
621
  </Text>
624
622
  </View>
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { TempoBar } from './TempoBar'
4
+
5
+ const meta: Meta<typeof TempoBar> = {
6
+ title: 'Custom/Workout/TempoBar',
7
+ component: TempoBar,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ activePhase: {
11
+ control: 'select',
12
+ options: [null, 'concentric', 'hold', 'eccentric'],
13
+ description: 'Phase currently in progress (null = idle)',
14
+ },
15
+ phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
16
+ completed: { control: 'object', description: 'Completed phase durations (ms)' },
17
+ target: { control: 'object', description: 'Per-phase target durations (seconds)' },
18
+ },
19
+ decorators: [
20
+ (Story) => (
21
+ <View style={{ width: 240 }}>
22
+ <Story />
23
+ </View>
24
+ ),
25
+ ],
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof TempoBar>
30
+
31
+ // tempo target: 1s concentric, 1s hold-top, 3s eccentric
32
+ const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
33
+
34
+ export const Idle: Story = {
35
+ args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
36
+ }
37
+
38
+ export const ConcentricActive: Story = {
39
+ args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
40
+ }
41
+
42
+ export const EccentricMidRep: Story = {
43
+ args: {
44
+ activePhase: 'eccentric',
45
+ phaseElapsedMs: 1500,
46
+ completed: { concentric: 900, hold: 1000 },
47
+ target: TARGET,
48
+ },
49
+ }
50
+
51
+ export const BehindPace: Story = {
52
+ args: {
53
+ activePhase: 'eccentric',
54
+ phaseElapsedMs: 4200,
55
+ completed: { concentric: 1600, hold: 1000 },
56
+ target: TARGET,
57
+ },
58
+ }
59
+
60
+ export const RepComplete: Story = {
61
+ args: {
62
+ activePhase: null,
63
+ phaseElapsedMs: 0,
64
+ completed: { concentric: 950, hold: 1050, eccentric: 3100 },
65
+ target: TARGET,
66
+ },
67
+ }
68
+
69
+ export const NoTarget: Story = {
70
+ args: {
71
+ activePhase: 'concentric',
72
+ phaseElapsedMs: 800,
73
+ },
74
+ }
75
+
76
+ export const AllStates: Story = {
77
+ render: () => (
78
+ <View style={{ gap: 20 }}>
79
+ <TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
80
+ <TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
81
+ <TempoBar
82
+ activePhase="eccentric"
83
+ phaseElapsedMs={1500}
84
+ completed={{ concentric: 900, hold: 1000 }}
85
+ target={TARGET}
86
+ />
87
+ <TempoBar
88
+ activePhase={null}
89
+ phaseElapsedMs={0}
90
+ completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
91
+ target={TARGET}
92
+ />
93
+ </View>
94
+ ),
95
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import {
5
+ TempoBar,
6
+ getTempoPacingState,
7
+ getTempoFillPct,
8
+ TEMPO_PACING,
9
+ } from './TempoBar'
10
+
11
+ const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
12
+
13
+ describe('getTempoPacingState', () => {
14
+ it('returns none when there is no target', () => {
15
+ expect(getTempoPacingState(1000, null)).toBe('none')
16
+ })
17
+
18
+ it('returns none for a sub-threshold target', () => {
19
+ expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
20
+ })
21
+
22
+ it('returns on-pace when within the behind threshold', () => {
23
+ expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
24
+ expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
25
+ })
26
+
27
+ it('returns behind once elapsed exceeds the behind threshold', () => {
28
+ expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
29
+ })
30
+ })
31
+
32
+ describe('getTempoFillPct', () => {
33
+ it('fills fully when there is no target', () => {
34
+ expect(getTempoFillPct(500, null)).toBe(100)
35
+ })
36
+
37
+ it('is proportional to elapsed vs target', () => {
38
+ expect(getTempoFillPct(500, 1000)).toBe(50)
39
+ })
40
+
41
+ it('clamps at 100 when past target', () => {
42
+ expect(getTempoFillPct(2000, 1000)).toBe(100)
43
+ })
44
+ })
45
+
46
+ describe('TempoBar', () => {
47
+ it('renders the bar with phase labels', () => {
48
+ render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
49
+ expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
50
+ expect(screen.getByText('Con')).toBeInTheDocument()
51
+ expect(screen.getByText('Hold')).toBeInTheDocument()
52
+ expect(screen.getByText('Ecc')).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders the active segment with an elapsed/target label', () => {
56
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
57
+ expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
58
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
59
+ })
60
+
61
+ it('shows only elapsed when the active phase has no target', () => {
62
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
63
+ expect(screen.getByText('0.8')).toBeInTheDocument()
64
+ })
65
+
66
+ it('marks a completed phase that hit its target with a check', () => {
67
+ render(
68
+ <TempoBar
69
+ activePhase="hold"
70
+ phaseElapsedMs={100}
71
+ completed={{ concentric: 950 }}
72
+ target={TARGET}
73
+ />,
74
+ )
75
+ expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
76
+ expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
77
+ })
78
+
79
+ it('marks a completed phase that missed its target with a cross', () => {
80
+ render(
81
+ <TempoBar
82
+ activePhase="hold"
83
+ phaseElapsedMs={100}
84
+ completed={{ eccentric: 4000 }}
85
+ target={TARGET}
86
+ />,
87
+ )
88
+ // eccentric target 3s, 4s elapsed => behind => cross
89
+ expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
90
+ })
91
+
92
+ it('has no accessibility violations', async () => {
93
+ const { container } = render(
94
+ <TempoBar
95
+ activePhase="eccentric"
96
+ phaseElapsedMs={1500}
97
+ completed={{ concentric: 900, hold: 1000 }}
98
+ target={TARGET}
99
+ />,
100
+ )
101
+ expect(await axe(container)).toHaveNoViolations()
102
+ })
103
+ })
@@ -0,0 +1,215 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+ import { alpha } from '../../../utils/colors'
5
+
6
+ const t = getSemanticColors('dark')
7
+
8
+ /**
9
+ * Titan-local phase key. Consumers map their own movement-phase enum onto this
10
+ * presentational key at the call site — TempoBar owns rendering only.
11
+ */
12
+ export type TempoPhaseKey = 'concentric' | 'hold' | 'eccentric'
13
+
14
+ /** Pacing tuning shared with the fill/pacing helpers below. */
15
+ export const TEMPO_PACING = {
16
+ behindThresholdPct: 0.15,
17
+ minPhaseDurationMs: 500,
18
+ } as const
19
+
20
+ export type TempoPacingState = 'none' | 'on-pace' | 'behind'
21
+
22
+ /**
23
+ * Classify how a phase's elapsed time tracks against its target duration.
24
+ * `none` when there is no meaningful target; `behind` once elapsed exceeds the
25
+ * target by more than the behind threshold; `on-pace` otherwise.
26
+ */
27
+ export function getTempoPacingState(
28
+ elapsedMs: number,
29
+ targetMs: number | null,
30
+ ): TempoPacingState {
31
+ if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
32
+ const ratio = elapsedMs / targetMs
33
+ if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
34
+ return 'on-pace'
35
+ }
36
+
37
+ /** Fill percentage (0–100) for the active phase; full when no target is set. */
38
+ export function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
39
+ if (!targetMs) return 100
40
+ return Math.min(100, (elapsedMs / targetMs) * 100)
41
+ }
42
+
43
+ interface PhaseConfig {
44
+ label: string
45
+ color: string
46
+ flex: number
47
+ }
48
+
49
+ const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
50
+
51
+ const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
52
+ concentric: { label: 'Con', color: t['status-success'], flex: 2 },
53
+ hold: { label: 'Hold', color: t['brand-primary'], flex: 1 },
54
+ eccentric: { label: 'Ecc', color: t['status-warning'], flex: 3 },
55
+ }
56
+
57
+ export interface TempoBarProps extends ViewProps {
58
+ /** Phase currently in progress, or null when idle/at rest. */
59
+ activePhase: TempoPhaseKey | null
60
+ /** Elapsed time (ms) within the active phase. */
61
+ phaseElapsedMs: number
62
+ /** Completed phase durations (ms) for the current rep, keyed by phase. */
63
+ completed?: Partial<Record<TempoPhaseKey, number>>
64
+ /** Optional per-phase target durations (seconds) for pacing feedback. */
65
+ target?: Partial<Record<TempoPhaseKey, number>>
66
+ className?: string
67
+ }
68
+
69
+ /**
70
+ * TempoBar — live rep phase progression indicator.
71
+ *
72
+ * Renders a segmented bar (Con → Hold → Ecc) where the active phase fills in
73
+ * real time and completed phases show their duration with a ✓/✗ pacing mark.
74
+ * Presentational only: the consumer derives which phase is active, its elapsed
75
+ * time, and completed durations, then feeds them in.
76
+ */
77
+ export function TempoBar({
78
+ activePhase,
79
+ phaseElapsedMs,
80
+ completed,
81
+ target,
82
+ className,
83
+ ...props
84
+ }: TempoBarProps) {
85
+ return (
86
+ <View className={className} testID="tempo-bar" {...props}>
87
+ {/* Phase labels */}
88
+ <View style={{ flexDirection: 'row', marginBottom: 4 }}>
89
+ {PHASE_ORDER.map((phase) => {
90
+ const config = PHASE_CONFIG[phase]
91
+ return (
92
+ <View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
93
+ <Text style={{ fontSize: 10, color: t['text-disabled'] }}>{config.label}</Text>
94
+ </View>
95
+ )
96
+ })}
97
+ </View>
98
+
99
+ {/* Segmented bar */}
100
+ <View style={{ flexDirection: 'row', gap: 2, height: 20 }}>
101
+ {PHASE_ORDER.map((phase) => {
102
+ const config = PHASE_CONFIG[phase]
103
+ const isActive = activePhase === phase
104
+ const completedMs = completed?.[phase]
105
+ const targetSec = target?.[phase]
106
+ const targetMs = targetSec != null ? targetSec * 1000 : null
107
+
108
+ return (
109
+ <View
110
+ key={phase}
111
+ style={{
112
+ flex: config.flex,
113
+ backgroundColor: alpha('#ffffff', 0.06),
114
+ borderRadius: 4,
115
+ overflow: 'hidden',
116
+ }}
117
+ >
118
+ {isActive ? (
119
+ <ActiveSegment
120
+ config={config}
121
+ phaseElapsedMs={phaseElapsedMs}
122
+ targetMs={targetMs}
123
+ />
124
+ ) : completedMs != null ? (
125
+ <CompletedSegment config={config} completedMs={completedMs} targetMs={targetMs} />
126
+ ) : null}
127
+ </View>
128
+ )
129
+ })}
130
+ </View>
131
+ </View>
132
+ )
133
+ }
134
+
135
+ function ActiveSegment({
136
+ config,
137
+ phaseElapsedMs,
138
+ targetMs,
139
+ }: {
140
+ config: PhaseConfig
141
+ phaseElapsedMs: number
142
+ targetMs: number | null
143
+ }) {
144
+ const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
145
+ const barColor = pacing === 'behind' ? t['status-error'] : config.color
146
+ const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
147
+
148
+ const label = targetMs
149
+ ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
150
+ : formatDuration(phaseElapsedMs)
151
+
152
+ return (
153
+ <View style={{ height: '100%', position: 'relative' }} testID={`tempo-segment-active-${config.label}`}>
154
+ <View
155
+ style={{
156
+ position: 'absolute',
157
+ left: 0,
158
+ top: 0,
159
+ bottom: 0,
160
+ width: `${fillPct}%`,
161
+ backgroundColor: alpha(barColor, 0.3),
162
+ borderRadius: 4,
163
+ }}
164
+ />
165
+ <View
166
+ style={{
167
+ height: '100%',
168
+ flexDirection: 'row',
169
+ alignItems: 'center',
170
+ justifyContent: 'center',
171
+ }}
172
+ >
173
+ <Text style={{ fontSize: 12, fontWeight: '700', color: barColor }}>{label}</Text>
174
+ </View>
175
+ </View>
176
+ )
177
+ }
178
+
179
+ function CompletedSegment({
180
+ config,
181
+ completedMs,
182
+ targetMs,
183
+ }: {
184
+ config: PhaseConfig
185
+ completedMs: number
186
+ targetMs: number | null
187
+ }) {
188
+ const pacing = getTempoPacingState(completedMs, targetMs)
189
+ const hitTarget = pacing !== 'behind'
190
+ const indicator =
191
+ targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
192
+
193
+ return (
194
+ <View
195
+ style={{
196
+ height: '100%',
197
+ flexDirection: 'row',
198
+ alignItems: 'center',
199
+ justifyContent: 'center',
200
+ backgroundColor: alpha(config.color, 0.15),
201
+ }}
202
+ testID={`tempo-segment-completed-${config.label}`}
203
+ >
204
+ <Text style={{ fontSize: 12, fontWeight: '500', color: alpha(config.color, 0.7) }}>
205
+ {formatDuration(completedMs)}
206
+ {indicator}
207
+ </Text>
208
+ </View>
209
+ )
210
+ }
211
+
212
+ function formatDuration(ms: number): string {
213
+ const s = ms / 1000
214
+ return s < 10 ? s.toFixed(1) : Math.round(s).toString()
215
+ }