@titan-design/react-ui 0.3.0 → 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.
- package/dist/bodymap.js +441 -97
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +49 -62
- package/dist/bodymap.mjs.map +1 -1
- package/dist/{chunk-TOD2WQBG.mjs → chunk-2SISKTWM.mjs} +22 -43
- package/dist/chunk-2SISKTWM.mjs.map +1 -0
- package/dist/{chunk-P7TSQUN6.mjs → chunk-SNVKL5WZ.mjs} +42 -5
- package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
- package/dist/index.d.mts +42 -2
- package/dist/index.d.ts +42 -2
- package/dist/index.js +431 -328
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +397 -314
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +28 -2
- package/dist/theme/index.d.ts +28 -2
- package/dist/theme/index.js +37 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
- package/src/components/custom/Workout/BodyMap.tsx +7 -8
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
- package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
- package/src/components/custom/Workout/ExerciseCard.tsx +12 -27
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +21 -27
- package/src/components/custom/Workout/InputBar.tsx +10 -11
- package/src/components/custom/Workout/MesoCard.tsx +2 -6
- package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
- package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
- package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
- package/src/components/custom/Workout/RestTimer.tsx +8 -7
- package/src/components/custom/Workout/SetRow.tsx +10 -7
- package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
- package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
- package/src/components/custom/Workout/TempoBar.tsx +215 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
- package/src/components/custom/Workout/VelocityStrip.tsx +7 -5
- package/src/components/custom/Workout/WeekRow.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
- package/src/components/custom/Workout/index.ts +9 -0
- package/src/test/nativewind-stub.ts +13 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +50 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/resolve-color.ts +19 -0
- package/dist/chunk-P7TSQUN6.mjs.map +0 -1
- package/dist/chunk-TOD2WQBG.mjs.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -16,13 +16,6 @@ import {
|
|
|
16
16
|
type VolumeStatus,
|
|
17
17
|
} from './muscleTaxonomy'
|
|
18
18
|
|
|
19
|
-
const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
|
|
20
|
-
const BORDER_DEFAULT = 'var(--color-border-default)'
|
|
21
|
-
const TEXT_PRIMARY = 'var(--color-text-primary)'
|
|
22
|
-
const TEXT_SECONDARY = 'var(--color-text-secondary)'
|
|
23
|
-
const TEXT_TERTIARY = 'var(--color-text-tertiary)'
|
|
24
|
-
const PAGE_BG = 'var(--color-background-base)'
|
|
25
|
-
|
|
26
19
|
/** Volume statuses in display order for the legend and summary cards. */
|
|
27
20
|
const STATUS_ORDER: VolumeStatus[] = ['under', 'maintenance', 'productive', 'over']
|
|
28
21
|
|
|
@@ -121,28 +114,28 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
|
|
|
121
114
|
return (
|
|
122
115
|
<View
|
|
123
116
|
key={key}
|
|
117
|
+
className="bg-surface-elevated border-border"
|
|
124
118
|
style={{
|
|
125
119
|
flexGrow: 1,
|
|
126
120
|
flexBasis: '46%',
|
|
127
121
|
padding: 12,
|
|
128
122
|
borderRadius: 10,
|
|
129
|
-
backgroundColor: SURFACE_ELEVATED,
|
|
130
123
|
borderWidth: 1,
|
|
131
|
-
borderColor: BORDER_DEFAULT,
|
|
132
124
|
}}
|
|
133
125
|
testID={`training-status-page-summary-${key}`}
|
|
134
126
|
>
|
|
135
127
|
<Text
|
|
128
|
+
className="text-text-primary"
|
|
136
129
|
style={{
|
|
137
130
|
fontSize: 22,
|
|
138
131
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
139
132
|
fontWeight: '700',
|
|
140
|
-
color: TEXT_PRIMARY,
|
|
141
133
|
}}
|
|
142
134
|
>
|
|
143
135
|
{value}
|
|
144
136
|
</Text>
|
|
145
137
|
<Text
|
|
138
|
+
className="text-text-tertiary"
|
|
146
139
|
style={{
|
|
147
140
|
marginTop: 2,
|
|
148
141
|
fontSize: 11,
|
|
@@ -150,7 +143,6 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
|
|
|
150
143
|
fontWeight: '600',
|
|
151
144
|
letterSpacing: 0.4,
|
|
152
145
|
textTransform: 'uppercase',
|
|
153
|
-
color: TEXT_TERTIARY,
|
|
154
146
|
}}
|
|
155
147
|
>
|
|
156
148
|
{label}
|
|
@@ -186,10 +178,10 @@ function StatusLegend() {
|
|
|
186
178
|
accessibilityElementsHidden
|
|
187
179
|
/>
|
|
188
180
|
<Text
|
|
181
|
+
className="text-text-secondary"
|
|
189
182
|
style={{
|
|
190
183
|
fontSize: 11,
|
|
191
184
|
fontFamily: 'Inter, sans-serif',
|
|
192
|
-
color: TEXT_SECONDARY,
|
|
193
185
|
textTransform: 'capitalize',
|
|
194
186
|
}}
|
|
195
187
|
>
|
|
@@ -247,8 +239,8 @@ export function TrainingStatusPage({
|
|
|
247
239
|
|
|
248
240
|
return (
|
|
249
241
|
<View
|
|
250
|
-
className={className}
|
|
251
|
-
style={{ position: 'relative', width: 390
|
|
242
|
+
className={['bg-background-base', className].filter(Boolean).join(' ')}
|
|
243
|
+
style={{ position: 'relative', width: 390 }}
|
|
252
244
|
accessibilityRole={'main' as ViewProps['accessibilityRole']}
|
|
253
245
|
aria-label={title}
|
|
254
246
|
testID="training-status-page"
|
|
@@ -257,11 +249,11 @@ export function TrainingStatusPage({
|
|
|
257
249
|
<View style={{ padding: 16, gap: 16 }} testID="training-status-page-content">
|
|
258
250
|
<Text
|
|
259
251
|
accessibilityRole="header"
|
|
252
|
+
className="text-text-primary"
|
|
260
253
|
style={{
|
|
261
254
|
fontSize: 20,
|
|
262
255
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
263
256
|
fontWeight: '700',
|
|
264
|
-
color: TEXT_PRIMARY,
|
|
265
257
|
}}
|
|
266
258
|
testID="training-status-page-title"
|
|
267
259
|
>
|
|
@@ -273,10 +265,9 @@ export function TrainingStatusPage({
|
|
|
273
265
|
<SummaryCards summary={summary} />
|
|
274
266
|
|
|
275
267
|
<View
|
|
268
|
+
className="bg-surface-elevated border-border"
|
|
276
269
|
style={{
|
|
277
|
-
backgroundColor: SURFACE_ELEVATED,
|
|
278
270
|
borderWidth: 1,
|
|
279
|
-
borderColor: BORDER_DEFAULT,
|
|
280
271
|
borderRadius: 12,
|
|
281
272
|
padding: 12,
|
|
282
273
|
gap: 12,
|
|
@@ -151,7 +151,9 @@ export function VelocityStrip({
|
|
|
151
151
|
|
|
152
152
|
const stripContent = (
|
|
153
153
|
<Animated.View
|
|
154
|
-
className={className
|
|
154
|
+
className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' ')}
|
|
155
157
|
style={[
|
|
156
158
|
{
|
|
157
159
|
height: heightAnim,
|
|
@@ -162,7 +164,6 @@ export function VelocityStrip({
|
|
|
162
164
|
paddingBottom: expanded ? 24 : 0,
|
|
163
165
|
paddingHorizontal: expanded ? 6 : 0,
|
|
164
166
|
paddingVertical: expanded ? undefined : 8,
|
|
165
|
-
backgroundColor: expanded ? 'var(--color-surface-raised)' : 'transparent',
|
|
166
167
|
overflow: expanded ? 'visible' : 'hidden',
|
|
167
168
|
},
|
|
168
169
|
]}
|
|
@@ -189,7 +190,8 @@ export function VelocityStrip({
|
|
|
189
190
|
{expanded && (
|
|
190
191
|
<Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
|
|
191
192
|
<Text
|
|
192
|
-
|
|
193
|
+
className="text-text-secondary"
|
|
194
|
+
style={{ fontSize: 8, fontWeight: '600' }}
|
|
193
195
|
testID={`velocity-label-${i}`}
|
|
194
196
|
>
|
|
195
197
|
{formatVelocity(v)}
|
|
@@ -248,18 +250,18 @@ export function VelocityStrip({
|
|
|
248
250
|
testID="velocity-info-row"
|
|
249
251
|
>
|
|
250
252
|
<Text
|
|
253
|
+
className="text-text-secondary"
|
|
251
254
|
style={{
|
|
252
255
|
fontSize: 10,
|
|
253
|
-
color: 'var(--color-text-secondary)',
|
|
254
256
|
fontFamily: 'Inter, sans-serif',
|
|
255
257
|
}}
|
|
256
258
|
>
|
|
257
259
|
{meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
|
|
258
260
|
</Text>
|
|
259
261
|
<Text
|
|
262
|
+
className="text-text-secondary"
|
|
260
263
|
style={{
|
|
261
264
|
fontSize: 10,
|
|
262
|
-
color: 'var(--color-text-secondary)',
|
|
263
265
|
fontFamily: 'Inter, sans-serif',
|
|
264
266
|
...getLossStyle(loss),
|
|
265
267
|
}}
|
|
@@ -100,11 +100,12 @@ export function WeekRow({
|
|
|
100
100
|
/>
|
|
101
101
|
)}
|
|
102
102
|
<Text
|
|
103
|
+
className={isCurrent ? undefined : 'text-text-primary'}
|
|
103
104
|
style={{
|
|
104
105
|
fontSize: 13,
|
|
105
106
|
fontWeight: '700',
|
|
106
107
|
fontFamily: 'Inter, sans-serif',
|
|
107
|
-
color: isCurrent ? BRAND_PRIMARY :
|
|
108
|
+
color: isCurrent ? BRAND_PRIMARY : undefined,
|
|
108
109
|
}}
|
|
109
110
|
accessibilityElementsHidden
|
|
110
111
|
>
|
|
@@ -43,11 +43,6 @@ export interface WorkoutCardProps extends ViewProps {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const COLORS = {
|
|
46
|
-
// Theme-aware foregrounds: resolve to a readable color in both light and dark
|
|
47
|
-
// (react-native-web passes the CSS var through to the inline style).
|
|
48
|
-
textPrimary: 'var(--color-text-primary)',
|
|
49
|
-
textSecondary: 'var(--color-text-secondary)',
|
|
50
|
-
textTertiary: 'var(--color-text-tertiary)',
|
|
51
46
|
statusSuccess: '#14B8A6',
|
|
52
47
|
brandPrimary: '#FF7900',
|
|
53
48
|
borderDefault: '#1F1F1F',
|
|
@@ -124,11 +119,11 @@ export function WorkoutCard({
|
|
|
124
119
|
<View style={{ padding: 14 }} testID="workout-card-body">
|
|
125
120
|
<View className="flex-row items-center" testID="workout-card-header">
|
|
126
121
|
<Text
|
|
122
|
+
className="text-text-primary"
|
|
127
123
|
style={{
|
|
128
124
|
fontSize: 15,
|
|
129
125
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
130
126
|
fontWeight: '700',
|
|
131
|
-
color: COLORS.textPrimary,
|
|
132
127
|
}}
|
|
133
128
|
testID="workout-card-name"
|
|
134
129
|
>
|
|
@@ -136,10 +131,10 @@ export function WorkoutCard({
|
|
|
136
131
|
</Text>
|
|
137
132
|
<View className="flex-1" />
|
|
138
133
|
<Text
|
|
134
|
+
className="text-text-tertiary"
|
|
139
135
|
style={{
|
|
140
136
|
fontSize: 11,
|
|
141
137
|
fontFamily: 'Inter, sans-serif',
|
|
142
|
-
color: COLORS.textTertiary,
|
|
143
138
|
}}
|
|
144
139
|
testID="workout-card-date"
|
|
145
140
|
>
|
|
@@ -148,11 +143,11 @@ export function WorkoutCard({
|
|
|
148
143
|
</View>
|
|
149
144
|
|
|
150
145
|
<Text
|
|
146
|
+
className="text-text-secondary"
|
|
151
147
|
style={{
|
|
152
148
|
marginTop: 4,
|
|
153
149
|
fontSize: 12,
|
|
154
150
|
fontFamily: 'Inter, sans-serif',
|
|
155
|
-
color: COLORS.textSecondary,
|
|
156
151
|
}}
|
|
157
152
|
testID="workout-card-stats"
|
|
158
153
|
>
|
|
@@ -9,6 +9,15 @@ export { PrBadge, type PrBadgeProps, type PRType } from './PrBadge'
|
|
|
9
9
|
export { StatusDot, type StatusDotVariant, type StatusDotProps } from './StatusDot'
|
|
10
10
|
export { PlaceholderStrip, type PlaceholderStripProps } from './PlaceholderStrip'
|
|
11
11
|
export { TempoDisplay, type TempoDisplayProps } from './TempoDisplay'
|
|
12
|
+
export {
|
|
13
|
+
TempoBar,
|
|
14
|
+
type TempoBarProps,
|
|
15
|
+
type TempoPhaseKey,
|
|
16
|
+
type TempoPacingState,
|
|
17
|
+
TEMPO_PACING,
|
|
18
|
+
getTempoPacingState,
|
|
19
|
+
getTempoFillPct,
|
|
20
|
+
} from './TempoBar'
|
|
12
21
|
export { DeviationBar, type DeviationBarProps } from './DeviationBar'
|
|
13
22
|
export { IntensityBar, type IntensityBarProps } from './IntensityBar'
|
|
14
23
|
export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Web test stub for `nativewind`.
|
|
2
|
+
//
|
|
3
|
+
// The real `nativewind` pulls react-native-css-interop → RN's Flow-typed
|
|
4
|
+
// sources, which the jsdom/react-native-web test environment can't parse
|
|
5
|
+
// ("Unexpected token 'typeof'"). Only `ThemeProvider` imports nativewind (for
|
|
6
|
+
// `vars()`), and only for its NATIVE behavior — on web the color tokens resolve
|
|
7
|
+
// from `global.css`, so an identity `vars()` is a faithful web stand-in.
|
|
8
|
+
// Aliased in `vitest.config.ts`; the real package is used at build/native.
|
|
9
|
+
|
|
10
|
+
/** Identity stand-in for nativewind's `vars()` — returns the token map as-is. */
|
|
11
|
+
export function vars(values: Record<string, string>): Record<string, string> {
|
|
12
|
+
return values
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
4
|
+
import { ThemeProvider } from './ThemeProvider'
|
|
5
|
+
|
|
6
|
+
describe('ThemeProvider', () => {
|
|
7
|
+
it('renders its children', () => {
|
|
8
|
+
render(
|
|
9
|
+
<ThemeProvider>
|
|
10
|
+
<Text>inside</Text>
|
|
11
|
+
</ThemeProvider>,
|
|
12
|
+
)
|
|
13
|
+
expect(screen.getByText('inside')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('accepts a light mode without error', () => {
|
|
17
|
+
render(
|
|
18
|
+
<ThemeProvider mode="light">
|
|
19
|
+
<Text>light</Text>
|
|
20
|
+
</ThemeProvider>,
|
|
21
|
+
)
|
|
22
|
+
expect(screen.getByText('light')).toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
})
|