@titan-design/react-ui 0.7.0 → 0.8.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/index.d.mts +109 -12
- package/dist/index.d.ts +109 -12
- package/dist/index.js +648 -231
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +648 -232
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
- package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +495 -92
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +496 -93
- package/dist/pages.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
- package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
- package/src/components/custom/Workout/README.md +47 -0
- package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +141 -50
- package/src/components/custom/Workout/TempoBar.stories.tsx +69 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
- package/src/components/custom/Workout/TempoBar.tsx +107 -22
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
- package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +76 -18
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/alert/Alert.stories.tsx +85 -2
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- package/src/components/ui/progress/Progress.tsx +47 -22
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text, Pressable } from 'react-native'
|
|
3
|
+
import { CircularTimer } from './CircularTimer'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof CircularTimer> = {
|
|
6
|
+
title: 'Custom/CircularTimer',
|
|
7
|
+
component: CircularTimer,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A circular countdown / countup with an mm:ss readout in the center — the graphical ' +
|
|
14
|
+
'sibling of `TimerReadout`. Batteries-included: owns `useTimer` (pass controlled ' +
|
|
15
|
+
'`durationMs` + `elapsedMs`) and composes `CircularProgress` for the arc (`down` drains, ' +
|
|
16
|
+
'`up` fills). On completion a `down` timer with a `doneLabel` fills fully in `doneColor` ' +
|
|
17
|
+
'and reads the label (e.g. "GO"). Pass `controls` to render buttons below the ring, or ' +
|
|
18
|
+
"`fill` to make it responsive to its container. `RestTimer`'s `ring` variant wraps this.",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
argTypes: {
|
|
23
|
+
mode: { control: 'inline-radio', options: ['down', 'up'] },
|
|
24
|
+
size: { control: { type: 'number', min: 96, max: 280, step: 4 } },
|
|
25
|
+
fill: { control: 'boolean' },
|
|
26
|
+
doneLabel: { control: 'text' },
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default meta
|
|
31
|
+
type Story = StoryObj<typeof CircularTimer>
|
|
32
|
+
|
|
33
|
+
const dark: Decorator = (Story) => (
|
|
34
|
+
<View style={{ padding: 32, alignItems: 'center', backgroundColor: '#0E0E0E' }}>
|
|
35
|
+
<Story />
|
|
36
|
+
</View>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/** Controls-driven: flip `mode` / `durationMs` / `elapsedMs` / `size` / `doneLabel`. */
|
|
40
|
+
export const Playground: Story = {
|
|
41
|
+
args: { durationMs: 120000, elapsedMs: 47000, doneLabel: 'GO', size: 180 },
|
|
42
|
+
decorators: [dark],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Countdown, midway — the ring drains from full. */
|
|
46
|
+
export const CountdownMidway: Story = {
|
|
47
|
+
args: { mode: 'down', durationMs: 120000, elapsedMs: 60000, size: 180 },
|
|
48
|
+
decorators: [dark],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Countup — the ring fills as time elapses. */
|
|
52
|
+
export const CountupMidway: Story = {
|
|
53
|
+
args: { mode: 'up', durationMs: 120000, elapsedMs: 40000, size: 180 },
|
|
54
|
+
decorators: [dark],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Completed countdown with a done label — full ring, flips to success, reads "GO". */
|
|
58
|
+
export const DoneWithLabel: Story = {
|
|
59
|
+
args: { mode: 'down', durationMs: 120000, elapsedMs: 120000, doneLabel: 'GO', size: 180 },
|
|
60
|
+
decorators: [dark],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DemoButton = ({ label }: { label: string }) => (
|
|
64
|
+
<Pressable
|
|
65
|
+
style={{
|
|
66
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
67
|
+
paddingVertical: 8,
|
|
68
|
+
paddingHorizontal: 20,
|
|
69
|
+
borderRadius: 8,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<Text style={{ color: '#DADADA', fontSize: 11, fontWeight: '600' }}>{label}</Text>
|
|
73
|
+
</Pressable>
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** With controls rendered below the ring. */
|
|
77
|
+
export const WithControls: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
durationMs: 120000,
|
|
80
|
+
elapsedMs: 47000,
|
|
81
|
+
size: 180,
|
|
82
|
+
controls: (
|
|
83
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
84
|
+
<DemoButton label="+30s" />
|
|
85
|
+
<DemoButton label="Skip" />
|
|
86
|
+
</View>
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
decorators: [dark],
|
|
90
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Text } from 'react-native'
|
|
5
|
+
import { CircularTimer } from './CircularTimer'
|
|
6
|
+
|
|
7
|
+
describe('CircularTimer', () => {
|
|
8
|
+
it('renders a timer with a composed progressbar ring', () => {
|
|
9
|
+
render(<CircularTimer durationMs={120000} elapsedMs={30000} />)
|
|
10
|
+
expect(screen.getByTestId('circular-timer')).toBeInTheDocument()
|
|
11
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('shows the mm:ss remaining in the center while counting down', () => {
|
|
15
|
+
render(<CircularTimer durationMs={120000} elapsedMs={60000} />)
|
|
16
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('1:00')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('shows the elapsed mm:ss in up mode', () => {
|
|
20
|
+
render(<CircularTimer mode="up" durationMs={120000} elapsedMs={45000} />)
|
|
21
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('0:45')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders the done label (not a time) when a countdown completes', () => {
|
|
25
|
+
render(<CircularTimer durationMs={120000} elapsedMs={120000} doneLabel="GO" />)
|
|
26
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('GO')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps showing 0:00 at completion when no doneLabel is given', () => {
|
|
30
|
+
render(<CircularTimer durationMs={120000} elapsedMs={120000} />)
|
|
31
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('0:00')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders optional controls below the ring', () => {
|
|
35
|
+
render(<CircularTimer durationMs={120000} elapsedMs={0} controls={<Text>+30s</Text>} />)
|
|
36
|
+
expect(screen.getByText('+30s')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('defaults its accessibility label to seconds remaining', () => {
|
|
40
|
+
render(<CircularTimer durationMs={120000} elapsedMs={60000} />)
|
|
41
|
+
expect(screen.getAllByLabelText('60 seconds remaining').length).toBeGreaterThan(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('uses a supplied accessibility label', () => {
|
|
45
|
+
render(
|
|
46
|
+
<CircularTimer durationMs={120000} elapsedMs={60000} accessibilityLabel="Rest, 60s left" />
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getAllByLabelText('Rest, 60s left').length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('emits no NaN when durationMs is 0 (guard)', () => {
|
|
52
|
+
const { container } = render(<CircularTimer durationMs={0} elapsedMs={0} />)
|
|
53
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('has no accessibility violations', async () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<CircularTimer durationMs={120000} elapsedMs={30000} accessibilityLabel="Rest countdown" />
|
|
59
|
+
)
|
|
60
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { View, Text } from 'react-native'
|
|
4
|
+
import { CircularProgress, colorVarMap, type ProgressColor } from '../../ui/progress/Progress'
|
|
5
|
+
import { useTimer, type TimerMode } from '../../../hooks/useTimer'
|
|
6
|
+
|
|
7
|
+
export interface CircularTimerProps {
|
|
8
|
+
/** Total duration (ms). `down`: the countdown length; `up`: the target for progress. */
|
|
9
|
+
durationMs: number
|
|
10
|
+
/** Controlled elapsed time (ms). */
|
|
11
|
+
elapsedMs: number
|
|
12
|
+
/** `down` (default) counts remaining toward 0 (ring drains); `up` counts elapsed up (ring fills). */
|
|
13
|
+
mode?: TimerMode
|
|
14
|
+
/** Ring diameter (px) — the geometry/stroke reference. Default 160. */
|
|
15
|
+
size?: number
|
|
16
|
+
/** Ring stroke width (px). Default 8. */
|
|
17
|
+
strokeWidth?: number
|
|
18
|
+
/** Fill the parent container (responsive) instead of a fixed `size` box. */
|
|
19
|
+
fill?: boolean
|
|
20
|
+
/** Ring color while running. Default `primary`. */
|
|
21
|
+
color?: ProgressColor
|
|
22
|
+
/**
|
|
23
|
+
* `down` mode: the text shown in the center — and the color the ring fills to — when
|
|
24
|
+
* the countdown completes (e.g. `doneLabel="GO"`). Omit to keep showing `0:00`.
|
|
25
|
+
*/
|
|
26
|
+
doneLabel?: string
|
|
27
|
+
/** Ring + center color when complete (with {@link doneLabel}). Default `success`. */
|
|
28
|
+
doneColor?: ProgressColor
|
|
29
|
+
/** Optional controls rendered below the ring (e.g. +30s / Skip). */
|
|
30
|
+
controls?: ReactNode
|
|
31
|
+
/** Accessibility label for the timer. Defaults to a seconds-remaining description. */
|
|
32
|
+
accessibilityLabel?: string
|
|
33
|
+
testID?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A circular countdown / countup with a time readout in the center — the graphical
|
|
38
|
+
* sibling of {@link TimerReadout}. Batteries-included: owns {@link useTimer} (pass
|
|
39
|
+
* controlled `durationMs` + `elapsedMs`) and composes {@link CircularProgress} for the
|
|
40
|
+
* arc (`down` drains as time runs out, `up` fills), rendering the mm:ss label in its
|
|
41
|
+
* center slot. On completion (`down` + {@link doneLabel}) the ring fills fully in
|
|
42
|
+
* {@link doneColor} and the center reads the done label — the "go" moment. An optional
|
|
43
|
+
* {@link controls} node renders below the ring. Consumers (e.g. `RestTimer`'s `ring`
|
|
44
|
+
* variant) wrap this and add their own domain chrome.
|
|
45
|
+
*/
|
|
46
|
+
export function CircularTimer({
|
|
47
|
+
durationMs,
|
|
48
|
+
elapsedMs,
|
|
49
|
+
mode = 'down',
|
|
50
|
+
size = 160,
|
|
51
|
+
strokeWidth = 8,
|
|
52
|
+
fill = false,
|
|
53
|
+
color = 'primary',
|
|
54
|
+
doneLabel,
|
|
55
|
+
doneColor = 'success',
|
|
56
|
+
controls,
|
|
57
|
+
accessibilityLabel,
|
|
58
|
+
testID,
|
|
59
|
+
}: CircularTimerProps) {
|
|
60
|
+
const { remainingMs, progress, label, done } = useTimer({ mode, durationMs, elapsedMs })
|
|
61
|
+
|
|
62
|
+
const isDone = done && mode === 'down'
|
|
63
|
+
const showDone = isDone && doneLabel != null
|
|
64
|
+
// Down → the remaining fraction (ring drains); up → elapsed progress (ring fills).
|
|
65
|
+
// A completed countdown with a done label fills fully to signal "go".
|
|
66
|
+
const remainingFrac = durationMs > 0 ? Math.max(0, Math.min(1, remainingMs / durationMs)) : 0
|
|
67
|
+
const arc = mode === 'down' ? (showDone ? 1 : remainingFrac) : progress
|
|
68
|
+
const ringColor: ProgressColor = showDone ? doneColor : color
|
|
69
|
+
|
|
70
|
+
const remainingSec = Math.ceil(Math.max(0, remainingMs) / 1000)
|
|
71
|
+
const a11y =
|
|
72
|
+
accessibilityLabel ?? (isDone ? 'Timer complete' : `${remainingSec} seconds remaining`)
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View
|
|
76
|
+
style={{ alignItems: 'center', gap: 16 }}
|
|
77
|
+
accessibilityRole="timer"
|
|
78
|
+
accessibilityLabel={a11y}
|
|
79
|
+
testID={testID ?? 'circular-timer'}
|
|
80
|
+
>
|
|
81
|
+
<CircularProgress
|
|
82
|
+
value={arc}
|
|
83
|
+
max={1}
|
|
84
|
+
size={size}
|
|
85
|
+
strokeWidth={strokeWidth}
|
|
86
|
+
fill={fill}
|
|
87
|
+
color={ringColor}
|
|
88
|
+
// A distinct static name for the ring (the live countdown text lives on the
|
|
89
|
+
// enclosing timer role) — satisfies the progressbar-name a11y rule without
|
|
90
|
+
// duplicating the timer label.
|
|
91
|
+
accessibilityLabel="Timer ring"
|
|
92
|
+
testID="circular-timer-ring"
|
|
93
|
+
>
|
|
94
|
+
<Text
|
|
95
|
+
className={showDone ? undefined : 'text-text-primary'}
|
|
96
|
+
style={{
|
|
97
|
+
fontSize: Math.round(size * (showDone ? 0.28 : 0.24)),
|
|
98
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
99
|
+
fontWeight: '800',
|
|
100
|
+
fontVariant: ['tabular-nums'],
|
|
101
|
+
letterSpacing: showDone ? 1 : -1,
|
|
102
|
+
...(showDone ? { color: colorVarMap[doneColor] } : null),
|
|
103
|
+
}}
|
|
104
|
+
testID="circular-timer-label"
|
|
105
|
+
>
|
|
106
|
+
{showDone ? doneLabel : label}
|
|
107
|
+
</Text>
|
|
108
|
+
</CircularProgress>
|
|
109
|
+
{controls}
|
|
110
|
+
</View>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CircularTimer, type CircularTimerProps } from './CircularTimer'
|
|
@@ -42,8 +42,8 @@ export const Vl20: Story = { args: { value: 21 } }
|
|
|
42
42
|
/** Stop zone — deep into the red. */
|
|
43
43
|
export const StopZone: Story = { args: { value: 34 } }
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
export const WallDensity: Story = { args: { value: 21,
|
|
45
|
+
/** The across-the-room wall scale — `size="wall"` grows the track, needle and tick labels together. */
|
|
46
|
+
export const WallDensity: Story = { args: { value: 21, size: 'wall' } }
|
|
47
47
|
|
|
48
48
|
/** Custom scale — a tighter 0..30 domain with earlier thresholds. */
|
|
49
49
|
export const CustomScale: Story = {
|
|
@@ -71,3 +71,15 @@ describe('FatigueMeter', () => {
|
|
|
71
71
|
})
|
|
72
72
|
})
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
describe('FatigueMeter size="wall"', () => {
|
|
76
|
+
it('passes wall density through to the composed ZoneTrack (thicker needle)', () => {
|
|
77
|
+
render(<FatigueMeter value={20} size="wall" />)
|
|
78
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '7px' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps the compact needle by default', () => {
|
|
82
|
+
render(<FatigueMeter value={20} />)
|
|
83
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '4px' })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import type { ViewProps } from 'react-native'
|
|
3
3
|
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
4
|
-
import { ZoneTrack, type ZoneTrackZone, type ZoneTrackTick } from './ZoneTrack'
|
|
4
|
+
import { ZoneTrack, type ZoneTrackZone, type ZoneTrackTick, type ZoneTrackSize } from './ZoneTrack'
|
|
5
5
|
|
|
6
6
|
const { green, yellow, orange, red } = WORKOUT_TOKENS.scale
|
|
7
7
|
|
|
@@ -24,7 +24,9 @@ export interface FatigueMeterProps extends ViewProps {
|
|
|
24
24
|
zoneColors?: [string, string, string, string]
|
|
25
25
|
/** Tick labels at [min, ...thresholds, max]. Default ['fresh','VL10','VL20','VL30','stop']. */
|
|
26
26
|
labels?: string[]
|
|
27
|
-
/**
|
|
27
|
+
/** Density: `default` (compact) or `wall` (the across-the-room dashboard scale — bigger track, needle and tick labels). */
|
|
28
|
+
size?: ZoneTrackSize
|
|
29
|
+
/** Track height in px. Overrides the `size` default (14 default / 24 wall). */
|
|
28
30
|
trackHeight?: number
|
|
29
31
|
/** Needle colour. Default white. */
|
|
30
32
|
needleColor?: string
|
|
@@ -49,7 +51,8 @@ export function FatigueMeter({
|
|
|
49
51
|
thresholds = DEFAULT_THRESHOLDS,
|
|
50
52
|
zoneColors = DEFAULT_ZONE_COLORS,
|
|
51
53
|
labels = DEFAULT_LABELS,
|
|
52
|
-
|
|
54
|
+
size = 'default',
|
|
55
|
+
trackHeight,
|
|
53
56
|
needleColor,
|
|
54
57
|
className,
|
|
55
58
|
...props
|
|
@@ -71,6 +74,7 @@ export function FatigueMeter({
|
|
|
71
74
|
zones={zones}
|
|
72
75
|
min={0}
|
|
73
76
|
max={max}
|
|
77
|
+
size={size}
|
|
74
78
|
trackHeight={trackHeight}
|
|
75
79
|
ticks={ticks}
|
|
76
80
|
marker={{ type: 'needle', value, color: needleColor }}
|
|
@@ -112,6 +112,53 @@ type props without pulling the dependency.
|
|
|
112
112
|
with a copy-paste `set` config in **`Workout/DataViz/VelocityStrip/Set
|
|
113
113
|
Modalities`** (each card carries a `Collapse` accordion; promoted from the
|
|
114
114
|
now-deleted `S3SetModalities` Lab specimen).
|
|
115
|
+
- **VelocityStrip `hero` variant** — the across-the-room, single-set **wall**
|
|
116
|
+
treatment (the north-star live page's velocity hero). Tall bars (default 220px
|
|
117
|
+
plot) with a per-bar velocity value label, a dashed **running-best reference
|
|
118
|
+
line** (unlabeled — the tallest bar already shows the number, and the container
|
|
119
|
+
a11y label carries it), and dashed **placeholders for the reps still to come**
|
|
120
|
+
driven by a new `targetReps` prop. Absorbs the R2 `HeroVelocityBars` candidate
|
|
121
|
+
into the atom rather than shipping a parallel component. Reuses the shared zone
|
|
122
|
+
scale (`barColorFor`) and the extracted `useLiveRepPop` entrance (now shared with
|
|
123
|
+
the framed `expanded` chart). **Layout:** width-fluid (flex bars, capped at 52px,
|
|
124
|
+
left-packed) with a caller-set fixed height; the eyebrow/section title is
|
|
125
|
+
organism chrome, not part of the primitive. **Live-update model:** the plan's
|
|
126
|
+
slots are pre-allocated (`max(done, target)` columns), so a landing rep converts
|
|
127
|
+
placeholder→bar in the *same* slot with a pop — no reflow within the plan; pair
|
|
128
|
+
with `scale="fixed"` so heights never rescale either. The one reflow case is
|
|
129
|
+
set-expansion **beyond** `targetReps` (AMRAP overflow adds a column and flex-
|
|
130
|
+
narrows the rest — currently snaps; smooth overflow reflow is a deferred
|
|
131
|
+
follow-up). Documented by the `HeroPlayground` / `Hero*` stories on the wall
|
|
132
|
+
background.
|
|
133
|
+
- **RestTimer `ring` variant** — the across-the-room **wall** rest treatment (the
|
|
134
|
+
north-star rest page). Built as a **three-tier decomposition** (not a one-off):
|
|
135
|
+
`CircularProgress` (atom, gained a **`children`** center slot + a **`fill`**
|
|
136
|
+
responsive mode) → **`CircularTimer`** (new molecule, `custom/CircularTimer/` — a
|
|
137
|
+
batteries-included circular countdown/countup; owns `useTimer`, renders the mm:ss
|
|
138
|
+
readout into the ring center, flips a completed `down` timer to a full `doneColor`
|
|
139
|
+
ring reading its `doneLabel`, and takes an optional `controls` slot) → **RestTimer
|
|
140
|
+
`ring`** which composes `CircularTimer` (`doneLabel="GO"`, `doneColor="success"`,
|
|
141
|
+
`controls={<RestActions/>}`) and adds the rest-specific next-set footer caption.
|
|
142
|
+
No third copy of the arc math and no `react-native-svg` — it rides
|
|
143
|
+
`CircularProgress`'s web-`<svg>` + free `stroke-dashoffset` transition. Absorbs
|
|
144
|
+
the R2 `RestRing` candidate into the atom; the mobile `CircularTimer` countup fork
|
|
145
|
+
is the natural second consumer of the new molecule (via `mode="up"`). `displayOnly`
|
|
146
|
+
hides the shared `RestActions` (+30s/Skip, extracted, used by both variants);
|
|
147
|
+
`size` (default 180) sets the diameter; the eyebrow/section title is organism
|
|
148
|
+
chrome. Web/RNW-only (like `CircularProgress`) — the wall variant; mobile keeps
|
|
149
|
+
`bar`. `Ring*` (RestTimer) + `Custom/CircularTimer` stories on the wall background.
|
|
150
|
+
- **`size="wall"` density (TempoBar · FatigueMeter · ZoneTrack)** — the across-the-room
|
|
151
|
+
dashboard scale, added as the idiomatic titan `size` union (a JS number-map per
|
|
152
|
+
component; **`default` values are byte-identical** to before, so existing consumers are
|
|
153
|
+
untouched). **TempoBar** scales its bar/labels/durations and at wall spells out the full
|
|
154
|
+
phase words (Concentric / Hold / Eccentric); its completed segments are **colour-coded**
|
|
155
|
+
(a missed target reads red, not just a ✗), and the **active** segment shows a **delta
|
|
156
|
+
countdown** (`activeDisplay`) — remaining time to target counting to `0.0` then negative
|
|
157
|
+
(red) once over — **tap to toggle** delta ↔ elapsed/target. **ZoneTrack** (the shared
|
|
158
|
+
gauge primitive) gained `size` that scales track, needle, tick lines and tick labels
|
|
159
|
+
together; its *other* consumers (TrainingLoadGauge, RpeCalibration) default to `default`
|
|
160
|
+
and are unaffected. **FatigueMeter** passes `size` through and lets `trackHeight` flow
|
|
161
|
+
from it. `Wall*` / `WallDensity` stories on the wall background.
|
|
115
162
|
- Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
|
|
116
163
|
SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
|
|
117
164
|
to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
|
|
@@ -1,12 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
3
|
import { RestTimer } from './RestTimer'
|
|
4
4
|
|
|
5
5
|
const meta: Meta<typeof RestTimer> = {
|
|
6
6
|
title: 'Workout/RestTimer',
|
|
7
7
|
component: RestTimer,
|
|
8
8
|
tags: ['autodocs'],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Controlled rest countdown (presentational off `useTimer`; pass `totalSeconds` + ' +
|
|
14
|
+
'`elapsedMs`). Two variants: `bar` (default) — the compact linear card (REST label + ' +
|
|
15
|
+
'mm:ss + progress bar + controls), the mobile bottom-bar treatment; and `ring` — the ' +
|
|
16
|
+
'across-the-room wall treatment: a draining countdown ring (composes `CircularProgress`) ' +
|
|
17
|
+
'with the mm:ss countdown in its center, flipping to a full `success` ring reading "GO" ' +
|
|
18
|
+
'when rest is up. `displayOnly` hides the +30s/Skip controls in either variant; `size` ' +
|
|
19
|
+
'sets the ring diameter.',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
9
23
|
argTypes: {
|
|
24
|
+
variant: {
|
|
25
|
+
control: 'inline-radio',
|
|
26
|
+
options: ['bar', 'ring'],
|
|
27
|
+
description: 'bar (compact linear card) or ring (wall countdown)',
|
|
28
|
+
},
|
|
10
29
|
totalSeconds: {
|
|
11
30
|
control: 'number',
|
|
12
31
|
description: 'Total rest duration in seconds',
|
|
@@ -15,6 +34,10 @@ const meta: Meta<typeof RestTimer> = {
|
|
|
15
34
|
control: 'number',
|
|
16
35
|
description: 'Elapsed time in milliseconds',
|
|
17
36
|
},
|
|
37
|
+
size: {
|
|
38
|
+
control: { type: 'number', min: 96, max: 280, step: 4 },
|
|
39
|
+
description: 'ring diameter in px (default 180)',
|
|
40
|
+
},
|
|
18
41
|
visible: {
|
|
19
42
|
control: 'boolean',
|
|
20
43
|
description: 'Whether the timer is visible',
|
|
@@ -69,3 +92,110 @@ export const JustStarted: Story = {
|
|
|
69
92
|
visible: true,
|
|
70
93
|
},
|
|
71
94
|
}
|
|
95
|
+
|
|
96
|
+
// --- Ring variant ------------------------------------------------------------
|
|
97
|
+
// The across-the-room wall rest treatment. Rendered on the north-star wall
|
|
98
|
+
// background so the draining ring reads the way it will live.
|
|
99
|
+
|
|
100
|
+
/** Wall-background frame for the ring stories (mirrors the north-star rest page). */
|
|
101
|
+
const ringDecorator: Decorator = (Story) => (
|
|
102
|
+
<View style={{ width: 420, padding: 32, alignItems: 'center', backgroundColor: '#0E0E0E' }}>
|
|
103
|
+
<Text
|
|
104
|
+
style={{
|
|
105
|
+
color: '#5A5A5A',
|
|
106
|
+
fontSize: 10,
|
|
107
|
+
fontWeight: '700',
|
|
108
|
+
letterSpacing: 1,
|
|
109
|
+
marginBottom: 20,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
REST · UNTIL NEXT SET · (organism chrome — not the component)
|
|
113
|
+
</Text>
|
|
114
|
+
<Story />
|
|
115
|
+
</View>
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** Controls-driven ring: flip `totalSeconds` / `elapsedMs` / `size` / `displayOnly`. */
|
|
119
|
+
export const RingPlayground: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
variant: 'ring',
|
|
122
|
+
totalSeconds: 120,
|
|
123
|
+
elapsedMs: 47000,
|
|
124
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
125
|
+
onSkip: () => {},
|
|
126
|
+
onAddTime: () => {},
|
|
127
|
+
visible: true,
|
|
128
|
+
},
|
|
129
|
+
decorators: [ringDecorator],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Just started: the ring is nearly full and draining. */
|
|
133
|
+
export const RingJustStarted: Story = {
|
|
134
|
+
args: {
|
|
135
|
+
variant: 'ring',
|
|
136
|
+
totalSeconds: 120,
|
|
137
|
+
elapsedMs: 8000,
|
|
138
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
139
|
+
onSkip: () => {},
|
|
140
|
+
onAddTime: () => {},
|
|
141
|
+
visible: true,
|
|
142
|
+
},
|
|
143
|
+
decorators: [ringDecorator],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Midway: half the rest has drained. */
|
|
147
|
+
export const RingMidway: Story = {
|
|
148
|
+
args: {
|
|
149
|
+
variant: 'ring',
|
|
150
|
+
totalSeconds: 120,
|
|
151
|
+
elapsedMs: 60000,
|
|
152
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
153
|
+
onSkip: () => {},
|
|
154
|
+
onAddTime: () => {},
|
|
155
|
+
visible: true,
|
|
156
|
+
},
|
|
157
|
+
decorators: [ringDecorator],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Almost done: a sliver of ring left. */
|
|
161
|
+
export const RingAlmostDone: Story = {
|
|
162
|
+
args: {
|
|
163
|
+
variant: 'ring',
|
|
164
|
+
totalSeconds: 120,
|
|
165
|
+
elapsedMs: 112000,
|
|
166
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
167
|
+
onSkip: () => {},
|
|
168
|
+
onAddTime: () => {},
|
|
169
|
+
visible: true,
|
|
170
|
+
},
|
|
171
|
+
decorators: [ringDecorator],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Rest is up: the ring empties and flips to success (go time). */
|
|
175
|
+
export const RingDone: Story = {
|
|
176
|
+
args: {
|
|
177
|
+
variant: 'ring',
|
|
178
|
+
totalSeconds: 120,
|
|
179
|
+
elapsedMs: 120000,
|
|
180
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
181
|
+
onSkip: () => {},
|
|
182
|
+
onAddTime: () => {},
|
|
183
|
+
visible: true,
|
|
184
|
+
},
|
|
185
|
+
decorators: [ringDecorator],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Display-only ring (poll mode): the ring + next-set line, no controls. */
|
|
189
|
+
export const RingDisplayOnly: Story = {
|
|
190
|
+
args: {
|
|
191
|
+
variant: 'ring',
|
|
192
|
+
totalSeconds: 120,
|
|
193
|
+
elapsedMs: 47000,
|
|
194
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
195
|
+
displayOnly: true,
|
|
196
|
+
onSkip: () => {},
|
|
197
|
+
onAddTime: () => {},
|
|
198
|
+
visible: true,
|
|
199
|
+
},
|
|
200
|
+
decorators: [ringDecorator],
|
|
201
|
+
}
|
|
@@ -184,3 +184,76 @@ describe('RestTimer zero-duration timer (NaN guard)', () => {
|
|
|
184
184
|
expect(container.innerHTML).not.toContain('NaN')
|
|
185
185
|
})
|
|
186
186
|
})
|
|
187
|
+
|
|
188
|
+
describe('RestTimer ring variant', () => {
|
|
189
|
+
const ringProps = { ...defaultProps, variant: 'ring' as const }
|
|
190
|
+
|
|
191
|
+
it('renders the ring (composed CircularProgress) instead of the linear bar', () => {
|
|
192
|
+
render(<RestTimer {...ringProps} />)
|
|
193
|
+
expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
|
|
194
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
|
195
|
+
expect(screen.queryByTestId('rest-timer-progress-track')).not.toBeInTheDocument()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('shows the mm:ss countdown in the ring center while resting', () => {
|
|
199
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
|
|
200
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('1:00')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('shows GO (not a time) when rest is complete', () => {
|
|
204
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={120000} />)
|
|
205
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('GO')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('labels the completed ring as rest complete', () => {
|
|
209
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={125000} />)
|
|
210
|
+
expect(screen.getByLabelText('Rest complete, next set ready')).toBeInTheDocument()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('labels the resting ring with seconds remaining', () => {
|
|
214
|
+
render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
|
|
215
|
+
expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('renders the next-set line and controls by default', () => {
|
|
219
|
+
render(<RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />)
|
|
220
|
+
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent('Next · Bench · set 3 of 4')
|
|
221
|
+
expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
|
|
222
|
+
expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('hides the controls in displayOnly mode (keeps ring + next-set)', () => {
|
|
226
|
+
render(<RestTimer {...ringProps} nextSetInfo="Next" displayOnly />)
|
|
227
|
+
expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
|
|
228
|
+
expect(screen.getByTestId('rest-timer-next-set')).toBeInTheDocument()
|
|
229
|
+
expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
|
|
230
|
+
expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('fires onSkip / onAddTime from the ring controls', () => {
|
|
234
|
+
const onSkip = vi.fn()
|
|
235
|
+
const onAddTime = vi.fn()
|
|
236
|
+
render(<RestTimer {...ringProps} onSkip={onSkip} onAddTime={onAddTime} />)
|
|
237
|
+
fireEvent.click(screen.getByTestId('rest-timer-skip'))
|
|
238
|
+
fireEvent.click(screen.getByTestId('rest-timer-add-time'))
|
|
239
|
+
expect(onSkip).toHaveBeenCalledOnce()
|
|
240
|
+
expect(onAddTime).toHaveBeenCalledOnce()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('respects visible=false', () => {
|
|
244
|
+
render(<RestTimer {...ringProps} visible={false} />)
|
|
245
|
+
expect(screen.queryByTestId('rest-timer-ring')).not.toBeInTheDocument()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('emits no NaN when totalSeconds is 0', () => {
|
|
249
|
+
const { container } = render(<RestTimer {...ringProps} totalSeconds={0} elapsedMs={0} />)
|
|
250
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('has no accessibility violations', async () => {
|
|
254
|
+
const { container } = render(
|
|
255
|
+
<RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />
|
|
256
|
+
)
|
|
257
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
258
|
+
})
|
|
259
|
+
})
|