@titan-design/react-ui 0.6.0 → 0.7.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 +12 -7
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +12 -7
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +855 -124
- package/dist/index.d.ts +855 -124
- package/dist/index.js +2276 -801
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2230 -802
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-MO0JCySL.d.mts → pages-8u_4WbL-.d.mts} +280 -57
- package/dist/{pages-D7Jlssvp.d.ts → pages-DaWiBOGa.d.ts} +280 -57
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1255 -798
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1255 -798
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +16 -82
- package/dist/theme/index.d.ts +16 -82
- package/dist/theme/index.js +13 -115
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +13 -115
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +14 -6
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +14 -6
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +1 -1
- package/src/arch/Architecture.stories.tsx +870 -0
- package/src/arch/annotations.json +48 -0
- package/src/arch/arch-graph.json +4301 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
- package/src/components/custom/Metric/Metric.stories.tsx +1 -1
- package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
- package/src/components/custom/Table/Table.stories.tsx +1 -1
- package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
- package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
- package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
- package/src/components/custom/TimerReadout/index.ts +1 -0
- package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
- package/src/components/custom/Typography/Typography.stories.tsx +1 -1
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +9 -15
- package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
- package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
- package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
- package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
- package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
- package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
- package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -5
- package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
- package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
- package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
- package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
- package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
- package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
- package/src/components/custom/Workout/FatigueMeter.test.tsx +73 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +82 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
- package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
- package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
- package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
- package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
- package/src/components/custom/Workout/MetricTiles.tsx +48 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
- package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -6
- package/src/components/custom/Workout/README.md +146 -4
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
- package/src/components/custom/Workout/RestTimer.stories.tsx +1 -1
- package/src/components/custom/Workout/RestTimer.tsx +15 -10
- package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
- package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
- package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
- package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
- package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
- package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
- package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
- package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
- package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
- package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
- package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
- package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
- package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
- package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
- package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
- package/src/components/custom/Workout/SessionHeader.tsx +152 -0
- package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
- package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
- package/src/components/custom/Workout/SessionRail.tsx +126 -0
- package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
- package/src/components/custom/Workout/SetBar.test.tsx +123 -0
- package/src/components/custom/Workout/SetBar.tsx +171 -0
- package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
- package/src/components/custom/Workout/SetRow.test.tsx +94 -166
- package/src/components/custom/Workout/SetRow.tsx +169 -232
- package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
- package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
- package/src/components/custom/Workout/SetStrip.tsx +71 -0
- package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
- package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
- package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
- package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
- package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
- package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
- package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
- package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
- package/src/components/custom/Workout/StatusPill.tsx +88 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
- package/src/components/custom/Workout/TempoBar.stories.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +23 -33
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +129 -66
- package/src/components/custom/Workout/VelocityStrip.test.tsx +376 -141
- package/src/components/custom/Workout/VelocityStrip.tsx +508 -120
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
- package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
- package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
- package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +140 -0
- package/src/components/custom/Workout/ZoneTrack.test.tsx +200 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +315 -0
- package/src/components/custom/Workout/index.ts +55 -2
- package/src/components/custom/Workout/metricText.stories.tsx +69 -0
- package/src/components/custom/Workout/metricText.tsx +34 -0
- package/src/components/custom/Workout/paceTone.test.ts +29 -0
- package/src/components/custom/Workout/paceTone.ts +27 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/icons/icons.stories.tsx +67 -2
- package/src/components/icons/icons.test.tsx +32 -2
- package/src/components/icons/icons.tsx +108 -0
- package/src/components/shell/BrandLockup.stories.tsx +3 -3
- package/src/components/shell/DashboardShell.stories.tsx +65 -0
- package/src/components/shell/DashboardShell.test.tsx +42 -0
- package/src/components/shell/DashboardShell.tsx +74 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +2 -2
- package/src/components/shell/DeviceMenu.stories.tsx +5 -5
- package/src/components/shell/DeviceRow.stories.tsx +3 -3
- package/src/components/shell/NavItem.stories.tsx +52 -0
- package/src/components/shell/NavItem.test.tsx +35 -0
- package/src/components/shell/NavItem.tsx +75 -0
- package/src/components/shell/README.md +30 -0
- package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
- package/src/components/shell/SessionStatePill.stories.tsx +3 -3
- package/src/components/shell/SessionStatePill.tsx +1 -1
- package/src/components/shell/SideNav.stories.tsx +81 -0
- package/src/components/shell/SideNav.test.tsx +51 -0
- package/src/components/shell/SideNav.tsx +74 -0
- package/src/components/shell/TopBar.stories.tsx +5 -5
- package/src/components/shell/index.ts +5 -0
- package/src/components/ui/alert/Alert.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
- package/src/components/ui/badge/Badge.stories.tsx +1 -1
- package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
- package/src/components/ui/button/Button.stories.tsx +1 -1
- package/src/components/ui/card/Card.stories.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
- package/src/components/ui/chip/Chip.stories.tsx +1 -1
- package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/divider/Divider.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
- package/src/components/ui/form-field/FormField.stories.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
- package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +2 -2
- package/src/components/ui/indicator/Indicator.test.tsx +1 -0
- package/src/components/ui/indicator/Indicator.tsx +6 -1
- package/src/components/ui/input/Input.stories.tsx +1 -1
- package/src/components/ui/link/Link.stories.tsx +1 -1
- package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
- package/src/components/ui/menu/Menu.stories.tsx +1 -1
- package/src/components/ui/modal/Modal.stories.tsx +1 -1
- package/src/components/ui/pill/Pill.stories.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +1 -1
- package/src/components/ui/progress/Progress.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +1 -1
- package/src/components/ui/select/Select.stories.tsx +1 -1
- package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
- package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +1 -1
- package/src/components/ui/surface/Surface.stories.tsx +1 -1
- package/src/components/ui/switch/Switch.stories.tsx +1 -1
- package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
- package/src/components/ui/tile/Tile.stories.tsx +79 -0
- package/src/components/ui/tile/Tile.test.tsx +48 -0
- package/src/components/ui/tile/Tile.tsx +64 -0
- package/src/components/ui/tile/index.ts +2 -0
- package/src/components/ui/toast/Toast.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useTimer.test.ts +150 -0
- package/src/hooks/useTimer.ts +77 -0
- package/src/index.ts +3 -0
- package/src/pages.ts +1 -1
- package/src/stories/PresetShowcase.stories.tsx +1 -1
- package/src/stories/ThemePresets.stories.tsx +1 -1
- package/src/theme/ColorSystem.stories.tsx +1 -1
- package/src/theme/Colors.stories.tsx +5 -8
- package/src/theme/config.ts +4 -0
- package/src/theme/elevation.stories.tsx +1 -1
- package/src/theme/global.css +5 -1
- package/src/theme/shadows.stories.tsx +1 -1
- package/src/theme/tokens/primitives.ts +11 -107
- package/src/theme/tokens/semantic.ts +8 -0
- package/src/theme/workout-tokens.ts +11 -7
- package/tailwind.config.js +5 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { SegmentedBar, type SegmentedBarSegment } from './SegmentedBar'
|
|
5
|
+
|
|
6
|
+
const threeSegments: SegmentedBarSegment[] = [
|
|
7
|
+
{ color: '#D14343' },
|
|
8
|
+
{ color: '#FF7900' },
|
|
9
|
+
{ color: '#2ED573' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('SegmentedBar', () => {
|
|
13
|
+
it('renders one fill per segment', () => {
|
|
14
|
+
render(<SegmentedBar segments={threeSegments} />)
|
|
15
|
+
expect(screen.getAllByTestId('segmented-bar-segment')).toHaveLength(3)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('honours the fill fraction as the fill width', () => {
|
|
19
|
+
render(<SegmentedBar segments={[{ color: '#2ED573', fill: 0.5 }]} />)
|
|
20
|
+
expect(screen.getByTestId('segmented-bar-segment')).toHaveStyle({ width: '50%' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('lets a composer override each segment testID', () => {
|
|
24
|
+
render(<SegmentedBar segments={threeSegments} segmentTestID={(_, i) => `seg-${i}`} />)
|
|
25
|
+
expect(screen.getByTestId('seg-0')).toBeInTheDocument()
|
|
26
|
+
expect(screen.getByTestId('seg-2')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('defaults to an 8px height and honours the height prop', () => {
|
|
30
|
+
const { rerender } = render(<SegmentedBar segments={threeSegments} testID="segmented-bar" />)
|
|
31
|
+
expect(screen.getByTestId('segmented-bar')).toHaveStyle({ height: '8px' })
|
|
32
|
+
rerender(<SegmentedBar segments={threeSegments} height={4} testID="segmented-bar" />)
|
|
33
|
+
expect(screen.getByTestId('segmented-bar')).toHaveStyle({ height: '4px' })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('carves a leading gap before a segment as a fixed left margin on its slot', () => {
|
|
37
|
+
render(
|
|
38
|
+
<SegmentedBar
|
|
39
|
+
segments={[{ color: '#D14343' }, { color: '#FF7900', leadingGap: 3 }]}
|
|
40
|
+
gap={0}
|
|
41
|
+
segmentTestID={(_, i) => `seg-${i}`}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
const slot = (fill: string) => screen.getByTestId(fill).parentElement as HTMLElement
|
|
45
|
+
expect(slot('seg-0')).not.toHaveStyle({ marginLeft: '3px' })
|
|
46
|
+
expect(slot('seg-1')).toHaveStyle({ marginLeft: '3px' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('applies a static fill opacity to a non-pulsing segment', () => {
|
|
50
|
+
render(<SegmentedBar segments={[{ color: '#0B3149', opacity: 0.36 }]} />)
|
|
51
|
+
expect(screen.getByTestId('segmented-bar-segment')).toHaveStyle({ opacity: 0.36 })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('draws a marker line only when a marker is supplied', () => {
|
|
55
|
+
const { rerender } = render(<SegmentedBar segments={threeSegments} />)
|
|
56
|
+
expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
|
|
57
|
+
rerender(<SegmentedBar segments={threeSegments} marker={{ position: 0.5, color: '#01B5D1' }} />)
|
|
58
|
+
expect(screen.getByTestId('segmented-bar-marker')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('accessibility', () => {
|
|
62
|
+
it('has no accessibility violations', async () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<SegmentedBar
|
|
65
|
+
segments={[
|
|
66
|
+
{ color: '#2ED573' },
|
|
67
|
+
{ color: '#FF7900', pulse: true },
|
|
68
|
+
{ color: '#3A3A3A', fill: 0.4 },
|
|
69
|
+
]}
|
|
70
|
+
marker={{ position: 0.75, color: '#01B5D1' }}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
const results = await axe(container)
|
|
74
|
+
expect(results).toHaveNoViolations()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { View, Animated, Easing, type ViewProps, type DimensionValue } from 'react-native'
|
|
4
|
+
|
|
5
|
+
/** ~1.9s full cycle: a pulsing segment eases up then back down (2 × half). */
|
|
6
|
+
const PULSE_HALF_MS = 950
|
|
7
|
+
|
|
8
|
+
/** Pulsing segments breathe their opacity between this floor and full. */
|
|
9
|
+
const PULSE_MIN_OPACITY = 0.45
|
|
10
|
+
|
|
11
|
+
/** Default between-segment gap — matches SetStrip's between-set gap so strips read the same. */
|
|
12
|
+
const SEGMENTED_BAR_GAP = 5
|
|
13
|
+
|
|
14
|
+
export interface SegmentedBarSegment {
|
|
15
|
+
/** Flex weight of this segment's slot. Default 1 (all segments equal width). */
|
|
16
|
+
weight?: number
|
|
17
|
+
/** Fraction 0..1 of the slot the coloured fill covers, left-aligned. Default 1 (full). */
|
|
18
|
+
fill?: number
|
|
19
|
+
/** Fill colour — a literal hex (RNW-safe), never a `var()` token ref. */
|
|
20
|
+
color: string
|
|
21
|
+
/** When true the fill animates on the shared active-pulse loop. */
|
|
22
|
+
pulse?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* When pulsing, interpolate the fill's `backgroundColor` from→to over the loop
|
|
25
|
+
* (full opacity). Omit to fall back to the default opacity breathe.
|
|
26
|
+
*/
|
|
27
|
+
pulseColor?: [from: string, to: string]
|
|
28
|
+
/**
|
|
29
|
+
* A fixed gap (px) carved in before this segment's slot — lets a composer split
|
|
30
|
+
* an otherwise-butted bar into sub-structure (drop notch, myo cluster) without a
|
|
31
|
+
* uniform {@link SegmentedBarProps.gap}. Default 0 (butted against the previous).
|
|
32
|
+
*/
|
|
33
|
+
leadingGap?: number
|
|
34
|
+
/**
|
|
35
|
+
* Static fill opacity 0..1 for a non-pulsing segment (e.g. a fading "count
|
|
36
|
+
* unknown" trail). Ignored for `pulse` segments, which drive their own opacity.
|
|
37
|
+
*/
|
|
38
|
+
opacity?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SegmentedBarProps extends ViewProps {
|
|
42
|
+
/** The weighted segments, laid left-to-right. */
|
|
43
|
+
segments: SegmentedBarSegment[]
|
|
44
|
+
/** Track height in px. Default 8. */
|
|
45
|
+
height?: number
|
|
46
|
+
/** Gap between segment slots in px. Default 5 (SetStrip's between-set gap). */
|
|
47
|
+
gap?: number
|
|
48
|
+
/** Corner radius of each segment slot in px. Default 2. */
|
|
49
|
+
radius?: number
|
|
50
|
+
/** An absolutely-positioned vertical marker line at `position` (0..1 of total width). */
|
|
51
|
+
marker?: { position: number; color: string } | null
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the testID of a segment's fill element. Lets a composing component
|
|
54
|
+
* (e.g. SetBar) preserve its own test hooks; defaults to `segmented-bar-segment`.
|
|
55
|
+
*/
|
|
56
|
+
segmentTestID?: (segment: SegmentedBarSegment, index: number) => string | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The shared active-pulse loop: a single 0→1→0 value driving every pulsing segment. */
|
|
60
|
+
function usePulse(active: boolean): Animated.Value {
|
|
61
|
+
const [value] = useState(() => new Animated.Value(0))
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!active) return
|
|
65
|
+
const loop = Animated.loop(
|
|
66
|
+
Animated.sequence([
|
|
67
|
+
Animated.timing(value, {
|
|
68
|
+
toValue: 1,
|
|
69
|
+
duration: PULSE_HALF_MS,
|
|
70
|
+
easing: Easing.inOut(Easing.ease),
|
|
71
|
+
useNativeDriver: false,
|
|
72
|
+
}),
|
|
73
|
+
Animated.timing(value, {
|
|
74
|
+
toValue: 0,
|
|
75
|
+
duration: PULSE_HALF_MS,
|
|
76
|
+
easing: Easing.inOut(Easing.ease),
|
|
77
|
+
useNativeDriver: false,
|
|
78
|
+
}),
|
|
79
|
+
])
|
|
80
|
+
)
|
|
81
|
+
loop.start()
|
|
82
|
+
return () => loop.stop()
|
|
83
|
+
}, [active, value])
|
|
84
|
+
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A horizontal track split into weighted, individually-fillable segments — the
|
|
90
|
+
* presentational atom the per-set strips are built from. Each segment is a flex
|
|
91
|
+
* slot (flex = `weight`) holding a left-aligned fill (`fill` fraction, `color`);
|
|
92
|
+
* `pulse` segments breathe on a shared active-pulse loop, and `marker` drops a
|
|
93
|
+
* vertical line at a fraction of the total width. Colours are passed-in literal hex.
|
|
94
|
+
*/
|
|
95
|
+
export function SegmentedBar({
|
|
96
|
+
segments,
|
|
97
|
+
height = 8,
|
|
98
|
+
gap = SEGMENTED_BAR_GAP,
|
|
99
|
+
radius = 2,
|
|
100
|
+
marker = null,
|
|
101
|
+
segmentTestID,
|
|
102
|
+
style,
|
|
103
|
+
...props
|
|
104
|
+
}: SegmentedBarProps) {
|
|
105
|
+
const pulse = usePulse(segments.some((s) => s.pulse))
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<View style={[{ flexDirection: 'row', height, gap, position: 'relative' }, style]} {...props}>
|
|
109
|
+
{segments.map((seg, i) => {
|
|
110
|
+
const fillStyle = {
|
|
111
|
+
width: `${(seg.fill ?? 1) * 100}%` as DimensionValue,
|
|
112
|
+
height: '100%' as DimensionValue,
|
|
113
|
+
backgroundColor: seg.color,
|
|
114
|
+
opacity: seg.opacity,
|
|
115
|
+
}
|
|
116
|
+
const testID = segmentTestID?.(seg, i) ?? 'segmented-bar-segment'
|
|
117
|
+
return (
|
|
118
|
+
<View
|
|
119
|
+
key={i}
|
|
120
|
+
style={{
|
|
121
|
+
flex: seg.weight ?? 1,
|
|
122
|
+
minWidth: 0,
|
|
123
|
+
height: '100%',
|
|
124
|
+
marginLeft: seg.leadingGap,
|
|
125
|
+
borderRadius: radius,
|
|
126
|
+
overflow: 'hidden',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{seg.pulse ? (
|
|
130
|
+
<Animated.View
|
|
131
|
+
style={{
|
|
132
|
+
...fillStyle,
|
|
133
|
+
...(seg.pulseColor
|
|
134
|
+
? {
|
|
135
|
+
backgroundColor: pulse.interpolate({
|
|
136
|
+
inputRange: [0, 1],
|
|
137
|
+
outputRange: seg.pulseColor,
|
|
138
|
+
}),
|
|
139
|
+
}
|
|
140
|
+
: {
|
|
141
|
+
opacity: pulse.interpolate({
|
|
142
|
+
inputRange: [0, 1],
|
|
143
|
+
outputRange: [PULSE_MIN_OPACITY, 1],
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
}}
|
|
147
|
+
accessibilityElementsHidden
|
|
148
|
+
testID={testID}
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<View style={fillStyle} accessibilityElementsHidden testID={testID} />
|
|
152
|
+
)}
|
|
153
|
+
</View>
|
|
154
|
+
)
|
|
155
|
+
})}
|
|
156
|
+
{marker ? (
|
|
157
|
+
<View
|
|
158
|
+
style={{
|
|
159
|
+
position: 'absolute',
|
|
160
|
+
left: `${marker.position * 100}%` as DimensionValue,
|
|
161
|
+
top: 0,
|
|
162
|
+
bottom: 0,
|
|
163
|
+
width: 2,
|
|
164
|
+
backgroundColor: marker.color,
|
|
165
|
+
}}
|
|
166
|
+
accessibilityElementsHidden
|
|
167
|
+
testID="segmented-bar-marker"
|
|
168
|
+
/>
|
|
169
|
+
) : null}
|
|
170
|
+
</View>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { SegmentedProgressBar } from './SegmentedProgressBar'
|
|
5
|
+
|
|
6
|
+
const PLAN = [{ weight: 3 }, { weight: 3 }, { weight: 2 }, { weight: 2 }, { weight: 2 }]
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof SegmentedProgressBar> = {
|
|
9
|
+
title: 'Workout/SegmentedProgressBar',
|
|
10
|
+
component: SegmentedProgressBar,
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
parameters: {
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
'**Atom.** Composes [SegmentedBar](?path=/docs/workout-segmentedbar--docs) + paceTone. Used-by ↑ SessionHeader.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
decorators: [
|
|
21
|
+
(Story) => (
|
|
22
|
+
<View style={{ width: 246, padding: 16, backgroundColor: '#242424' }}>
|
|
23
|
+
<Story />
|
|
24
|
+
</View>
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
export default meta
|
|
29
|
+
|
|
30
|
+
type Story = StoryObj<typeof SegmentedProgressBar>
|
|
31
|
+
|
|
32
|
+
/** Fill (sets done) trails the time marker → amber pace. */
|
|
33
|
+
export const Behind: Story = {
|
|
34
|
+
args: { segments: PLAN, value: 7.2, target: 0.7 },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fill is at or past the time marker → green pace. */
|
|
38
|
+
export const Ahead: Story = {
|
|
39
|
+
args: { segments: PLAN, value: 9.5, target: 0.7 },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** No target → no marker, plain steel progress. */
|
|
43
|
+
export const NoTarget: Story = {
|
|
44
|
+
args: { segments: PLAN, value: 7 },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Nothing performed yet — the empty plan shape. */
|
|
48
|
+
export const Empty: Story = {
|
|
49
|
+
args: { segments: PLAN, value: 0 },
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { SegmentedProgressBar, type SegmentedProgressBarSegment } from './SegmentedProgressBar'
|
|
5
|
+
import { paceTone, paceToneColor } from './paceTone'
|
|
6
|
+
|
|
7
|
+
const PLAN: SegmentedProgressBarSegment[] = [
|
|
8
|
+
{ weight: 3 },
|
|
9
|
+
{ weight: 3 },
|
|
10
|
+
{ weight: 2 },
|
|
11
|
+
{ weight: 2 },
|
|
12
|
+
{ weight: 2 },
|
|
13
|
+
] // total = 12
|
|
14
|
+
|
|
15
|
+
describe('SegmentedProgressBar', () => {
|
|
16
|
+
it('renders one segment fill per weighted slot', () => {
|
|
17
|
+
render(<SegmentedProgressBar segments={PLAN} value={0} />)
|
|
18
|
+
expect(screen.getAllByTestId('segmented-bar-segment')).toHaveLength(5)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('fills each chunk by its clamped share of the cumulative value', () => {
|
|
22
|
+
// value 7 over weights [3,3,2,2,2]: 100%,100%,50%,0%,0%
|
|
23
|
+
render(<SegmentedProgressBar segments={PLAN} value={7} target={0.7} />)
|
|
24
|
+
const fills = screen.getAllByTestId('segmented-bar-segment')
|
|
25
|
+
expect(fills[0]).toHaveStyle({ width: '100%' })
|
|
26
|
+
expect(fills[1]).toHaveStyle({ width: '100%' })
|
|
27
|
+
expect(fills[2]).toHaveStyle({ width: '50%' })
|
|
28
|
+
expect(fills[3]).toHaveStyle({ width: '0%' })
|
|
29
|
+
expect(fills[4]).toHaveStyle({ width: '0%' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses the behind (warning) pace colour when fill trails the target', () => {
|
|
33
|
+
render(<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} />)
|
|
34
|
+
const behind = paceToneColor(paceTone(7.2 / 12, 0.7))
|
|
35
|
+
expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
|
|
36
|
+
backgroundColor: behind,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses the ahead (success) pace colour when fill is at or past the target', () => {
|
|
41
|
+
render(<SegmentedProgressBar segments={PLAN} value={9.5} target={0.7} />)
|
|
42
|
+
const ahead = paceToneColor(paceTone(9.5 / 12, 0.7))
|
|
43
|
+
expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
|
|
44
|
+
backgroundColor: ahead,
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('is neutral (steel) with no marker when no target is given', () => {
|
|
49
|
+
render(<SegmentedProgressBar segments={PLAN} value={7} />)
|
|
50
|
+
expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
|
|
51
|
+
const neutral = paceToneColor('neutral')
|
|
52
|
+
expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
|
|
53
|
+
backgroundColor: neutral,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('drops the overlay marker at the target position when target is given', () => {
|
|
58
|
+
render(<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} />)
|
|
59
|
+
const marker = screen.getByTestId('segmented-bar-marker')
|
|
60
|
+
expect(marker).toBeInTheDocument()
|
|
61
|
+
expect(marker).toHaveStyle({ left: '70%' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('lets an explicit colour override the pace tone', () => {
|
|
65
|
+
render(<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} color="#123456" />)
|
|
66
|
+
expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
|
|
67
|
+
backgroundColor: '#123456',
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('accessibility', () => {
|
|
72
|
+
it('has no accessibility violations', async () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} />
|
|
75
|
+
)
|
|
76
|
+
const results = await axe(container)
|
|
77
|
+
expect(results).toHaveNoViolations()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { ViewProps } from 'react-native'
|
|
3
|
+
import { primitiveRamps } from '../../../theme/tokens/primitives'
|
|
4
|
+
import { SegmentedBar } from './SegmentedBar'
|
|
5
|
+
import { paceTone, paceToneColor } from './paceTone'
|
|
6
|
+
|
|
7
|
+
/** Default overlay-marker colour: status-live-muted — the SideNav "Live" cue / active-timer green. */
|
|
8
|
+
const DEFAULT_MARKER_COLOR = primitiveRamps.green[500]
|
|
9
|
+
|
|
10
|
+
export interface SegmentedProgressBarSegment {
|
|
11
|
+
/** Weight of this segment's slot — e.g. an exercise's planned set count. */
|
|
12
|
+
weight: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SegmentedProgressBarProps extends ViewProps {
|
|
16
|
+
/** The weighted segments, laid left-to-right (e.g. one per exercise). */
|
|
17
|
+
segments: SegmentedProgressBarSegment[]
|
|
18
|
+
/** Cumulative progress in the SAME units as the summed weights (e.g. sets done). May be fractional. */
|
|
19
|
+
value: number
|
|
20
|
+
/** 0..1 position for the overlay pace marker (e.g. elapsed / budget). Omit → no marker, neutral tone. */
|
|
21
|
+
target?: number
|
|
22
|
+
/** Overlay-marker colour. Default the live-green (status-live-muted). */
|
|
23
|
+
markerColor?: string
|
|
24
|
+
/** Override the single pace fill colour. Default = paceToneColor(paceTone(value / total, target)). */
|
|
25
|
+
color?: string
|
|
26
|
+
/** Track height in px. Passed through to SegmentedBar. */
|
|
27
|
+
height?: number
|
|
28
|
+
/** Gap between segment slots in px. Passed through to SegmentedBar. */
|
|
29
|
+
gap?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A progress bar segmented by weighted slots (one chunk per exercise, width ∝ its
|
|
34
|
+
* set count): `value` fills the chunks left-to-right, and a single pace colour spans
|
|
35
|
+
* them all — green when at/ahead of the `target` pace, amber when behind, steel when
|
|
36
|
+
* there is no target. An optional overlay marker pins the target position. Composes
|
|
37
|
+
* {@link SegmentedBar} + {@link paceTone}.
|
|
38
|
+
*/
|
|
39
|
+
export function SegmentedProgressBar({
|
|
40
|
+
segments,
|
|
41
|
+
value,
|
|
42
|
+
target,
|
|
43
|
+
markerColor = DEFAULT_MARKER_COLOR,
|
|
44
|
+
color,
|
|
45
|
+
height,
|
|
46
|
+
gap,
|
|
47
|
+
...props
|
|
48
|
+
}: SegmentedProgressBarProps) {
|
|
49
|
+
const total = segments.reduce((sum, s) => sum + s.weight, 0)
|
|
50
|
+
const fillColor = color ?? paceToneColor(paceTone(total > 0 ? value / total : 0, target))
|
|
51
|
+
|
|
52
|
+
const barSegments = segments.map((seg, i) => {
|
|
53
|
+
const cumulativeBefore = segments.slice(0, i).reduce((sum, s) => sum + s.weight, 0)
|
|
54
|
+
const fill = Math.max(0, Math.min(1, (value - cumulativeBefore) / seg.weight))
|
|
55
|
+
return { weight: seg.weight, fill, color: fillColor }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<SegmentedBar
|
|
60
|
+
segments={barSegments}
|
|
61
|
+
marker={target != null ? { position: target, color: markerColor } : null}
|
|
62
|
+
height={height}
|
|
63
|
+
gap={gap}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { primitiveRamps } from '../../../theme/tokens/primitives'
|
|
4
|
+
import { SessionHeader, type SessionHeaderPlanEntry } from './SessionHeader'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `SessionHeader` — the session-rail heading: the session title over a stat row and a
|
|
8
|
+
* chunked pace bar. Live, it carries Volume/Load/Fatigue tiles, a per-exercise progress
|
|
9
|
+
* bar (fill ∝ sets done, coloured against the elapsed-vs-budget pace with a live marker),
|
|
10
|
+
* a mono `n/total sets` label and a ⏱ elapsed readout. Upcoming, the tiles become
|
|
11
|
+
* Date/Time/Until, the bar is the empty plan shape, and the readout shows the planned
|
|
12
|
+
* duration. Rendered on the raised charcoal plane.
|
|
13
|
+
*/
|
|
14
|
+
const meta: Meta<typeof SessionHeader> = {
|
|
15
|
+
title: 'Shell/SessionRail/SessionHeader',
|
|
16
|
+
component: SessionHeader,
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
'**Molecule** (shell S3). The session-rail heading — title, stat tiles, chunked ' +
|
|
23
|
+
'pace bar, sets label + ⏱ readout, on the raised charcoal plane. Composes ' +
|
|
24
|
+
'[MetricTiles](?path=/docs/workout-metrictiles--docs) / ' +
|
|
25
|
+
'[ScheduleTiles](?path=/docs/workout-scheduletiles--docs) + ' +
|
|
26
|
+
'[SegmentedProgressBar](?path=/docs/workout-segmentedprogressbar--docs) + ' +
|
|
27
|
+
'[TimerReadout](?path=/docs/components-atoms-timerreadout--docs). ' +
|
|
28
|
+
'Used-by ↑ [SessionRail](?path=/docs/shell-sessionrail--docs).',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
decorators: [
|
|
33
|
+
(Story) => (
|
|
34
|
+
<View style={{ width: 246 }}>
|
|
35
|
+
<Story />
|
|
36
|
+
</View>
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default meta
|
|
42
|
+
type Story = StoryObj<typeof SessionHeader>
|
|
43
|
+
|
|
44
|
+
const AMBER = primitiveRamps.amber[300]
|
|
45
|
+
|
|
46
|
+
const PLAN: SessionHeaderPlanEntry[] = [
|
|
47
|
+
{ name: 'Seated Row', sets: 3 },
|
|
48
|
+
{ name: 'Incline DB', sets: 3 },
|
|
49
|
+
{ name: 'Chest Press', sets: 2 },
|
|
50
|
+
{ name: 'Calf Raise', sets: 2 },
|
|
51
|
+
{ name: 'Face Pull', sets: 2 },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const LIVE_METRICS = [
|
|
55
|
+
{ label: 'Volume', value: '76%' },
|
|
56
|
+
{ label: 'Load', value: '7.3k' },
|
|
57
|
+
{ label: 'Fatigue', value: 'MOD', valueColor: AMBER },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
// 42:18 elapsed against a 60:00 budget → ~70% of the way through the time budget while
|
|
61
|
+
// only 7.2/12 sets are done, so the fill trails the marker (amber, behind pace).
|
|
62
|
+
const ELAPSED_MS = (42 * 60 + 18) * 1000
|
|
63
|
+
const BUDGET_MS = 60 * 60 * 1000
|
|
64
|
+
|
|
65
|
+
export const LiveBehind: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
title: 'Pull A · Intensification',
|
|
68
|
+
plan: PLAN,
|
|
69
|
+
setsDone: 7.2,
|
|
70
|
+
elapsedMs: ELAPSED_MS,
|
|
71
|
+
budgetMs: BUDGET_MS,
|
|
72
|
+
metrics: LIVE_METRICS,
|
|
73
|
+
},
|
|
74
|
+
parameters: {
|
|
75
|
+
docs: {
|
|
76
|
+
description: {
|
|
77
|
+
story:
|
|
78
|
+
'Live, behind pace — sets fill (7.2/12) trails the elapsed-time marker, so the ' +
|
|
79
|
+
'bar and sets label read amber. ⏱ elapsed goes live-green with the ` / 60:00` budget.',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const LiveNoTime: Story = {
|
|
86
|
+
args: {
|
|
87
|
+
title: 'Pull A · Intensification',
|
|
88
|
+
plan: PLAN,
|
|
89
|
+
setsDone: 7.2,
|
|
90
|
+
elapsedMs: ELAPSED_MS,
|
|
91
|
+
metrics: LIVE_METRICS,
|
|
92
|
+
},
|
|
93
|
+
parameters: {
|
|
94
|
+
docs: {
|
|
95
|
+
description: {
|
|
96
|
+
story:
|
|
97
|
+
'Live, no time target — no marker and no ` / budget`, just the ⏱ elapsed. The bar ' +
|
|
98
|
+
'is plain chunked progress (steel), and the sets label follows.',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const Upcoming: Story = {
|
|
105
|
+
args: {
|
|
106
|
+
title: 'Lower B · Volume',
|
|
107
|
+
plan: PLAN,
|
|
108
|
+
budgetMs: 52 * 60 * 1000,
|
|
109
|
+
next: Date.now() + 2 * 24 * 60 * 60 * 1000,
|
|
110
|
+
},
|
|
111
|
+
parameters: {
|
|
112
|
+
docs: {
|
|
113
|
+
description: {
|
|
114
|
+
story:
|
|
115
|
+
'Upcoming session — the tiles become Date / Time / Until (Until accents only when ' +
|
|
116
|
+
'< 1 day away), the bar is the empty plan shape, and the ⏱ readout shows the planned ' +
|
|
117
|
+
'duration. `title` is the next session’s name.',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { SessionHeader, type SessionHeaderPlanEntry } from './SessionHeader'
|
|
5
|
+
|
|
6
|
+
const PLAN: SessionHeaderPlanEntry[] = [
|
|
7
|
+
{ name: 'Seated Row', sets: 3 },
|
|
8
|
+
{ name: 'Incline DB', sets: 3 },
|
|
9
|
+
{ name: 'Chest Press', sets: 2 },
|
|
10
|
+
{ name: 'Calf Raise', sets: 2 },
|
|
11
|
+
{ name: 'Face Pull', sets: 2 },
|
|
12
|
+
] // total = 12
|
|
13
|
+
|
|
14
|
+
const liveProps = {
|
|
15
|
+
title: 'Pull A · Intensification',
|
|
16
|
+
plan: PLAN,
|
|
17
|
+
setsDone: 7.2,
|
|
18
|
+
elapsedMs: (42 * 60 + 18) * 1000,
|
|
19
|
+
budgetMs: 60 * 60 * 1000,
|
|
20
|
+
metrics: [
|
|
21
|
+
{ label: 'Volume', value: '76%' },
|
|
22
|
+
{ label: 'Load', value: '7.3k' },
|
|
23
|
+
{ label: 'Fatigue', value: 'MOD' },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('SessionHeader', () => {
|
|
28
|
+
it('renders the session title', () => {
|
|
29
|
+
render(<SessionHeader {...liveProps} />)
|
|
30
|
+
expect(screen.getByTestId('session-rail-title')).toHaveTextContent('Pull A · Intensification')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('live · behind pace', () => {
|
|
34
|
+
it('shows the floored sets-done over the total', () => {
|
|
35
|
+
render(<SessionHeader {...liveProps} />)
|
|
36
|
+
expect(screen.getByTestId('session-rail-sets')).toHaveTextContent('7/12 sets')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('renders the live metric tiles', () => {
|
|
40
|
+
render(<SessionHeader {...liveProps} />)
|
|
41
|
+
expect(screen.getByText('Volume')).toBeInTheDocument()
|
|
42
|
+
expect(screen.getByText('Fatigue')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('drops the pace marker when a budget and elapsed are given', () => {
|
|
46
|
+
render(<SessionHeader {...liveProps} />)
|
|
47
|
+
expect(screen.getByTestId('segmented-bar-marker')).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('appends the budget to the timer readout', () => {
|
|
51
|
+
render(<SessionHeader {...liveProps} />)
|
|
52
|
+
expect(screen.getByTestId('timer-readout-current')).toHaveTextContent('42:18')
|
|
53
|
+
expect(screen.getByTestId('timer-readout-total')).toHaveTextContent('60:00')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('live · no time target', () => {
|
|
58
|
+
const noTimeProps = { ...liveProps, budgetMs: undefined }
|
|
59
|
+
|
|
60
|
+
it('omits the pace marker', () => {
|
|
61
|
+
render(<SessionHeader {...noTimeProps} />)
|
|
62
|
+
expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows the elapsed readout with no budget suffix', () => {
|
|
66
|
+
render(<SessionHeader {...noTimeProps} />)
|
|
67
|
+
expect(screen.getByTestId('timer-readout-current')).toHaveTextContent('42:18')
|
|
68
|
+
expect(screen.queryByTestId('timer-readout-total')).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('upcoming', () => {
|
|
73
|
+
const upcomingProps = {
|
|
74
|
+
title: 'Lower B · Volume',
|
|
75
|
+
plan: PLAN,
|
|
76
|
+
budgetMs: 52 * 60 * 1000,
|
|
77
|
+
next: new Date('2026-07-11T18:30:00'),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it('renders the next session name as the title', () => {
|
|
81
|
+
render(<SessionHeader {...upcomingProps} />)
|
|
82
|
+
expect(screen.getByTestId('session-rail-title')).toHaveTextContent('Lower B · Volume')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('shows the schedule tiles instead of metric tiles', () => {
|
|
86
|
+
render(<SessionHeader {...upcomingProps} />)
|
|
87
|
+
expect(screen.getByText('Date')).toBeInTheDocument()
|
|
88
|
+
expect(screen.getByText('Time')).toBeInTheDocument()
|
|
89
|
+
expect(screen.getByText('Until')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('shows only the total sets and no marker', () => {
|
|
93
|
+
render(<SessionHeader {...upcomingProps} />)
|
|
94
|
+
expect(screen.getByTestId('session-rail-sets')).toHaveTextContent('12 sets')
|
|
95
|
+
expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('reads the planned duration statically (not live-green)', () => {
|
|
99
|
+
render(<SessionHeader {...upcomingProps} />)
|
|
100
|
+
expect(screen.getByTestId('timer-readout-current')).toHaveTextContent('52:00')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('accessibility', () => {
|
|
105
|
+
it('has no violations when live', async () => {
|
|
106
|
+
const { container } = render(<SessionHeader {...liveProps} />)
|
|
107
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('has no violations when upcoming', async () => {
|
|
111
|
+
const { container } = render(
|
|
112
|
+
<SessionHeader
|
|
113
|
+
title="Lower B · Volume"
|
|
114
|
+
plan={PLAN}
|
|
115
|
+
budgetMs={52 * 60 * 1000}
|
|
116
|
+
next={new Date('2026-07-11T18:30:00')}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
})
|