Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,20 +189,20 @@ jest.mock('../../src/components/ActionSheet', () => {

const mockRestSheet: {
present: jest.Mock;
onChange: ((seconds: number) => void) | null;
} = { present: jest.fn(), onChange: null };
onApply: ((updates: { setId: string; seconds: number }[]) => void) | null;
} = { present: jest.fn(), onApply: null };

jest.mock('../../src/components/RestPeriodSheet', () => {
jest.mock('../../src/components/ExerciseSetRestSheet', () => {
const React = require('react');
return {
__esModule: true,
default: React.forwardRef(({ onChange }: any, ref: any) => {
default: React.forwardRef(({ onApply }: any, ref: any) => {
React.useImperativeHandle(ref, () => ({
present: mockRestSheet.present,
dismiss: jest.fn(),
}));
React.useEffect(() => {
mockRestSheet.onChange = onChange;
mockRestSheet.onApply = onApply;
});
return null;
}),
Expand Down Expand Up @@ -731,9 +731,35 @@ describe('WorkoutFormExerciseList', () => {
it('targets the rest sheet at the pressed exercise', () => {
const utils = renderList([makeExercise('a'), makeExercise('b')]);
fireEvent.press(utils.getByTestId('card-b-rest'));
expect(mockRestSheet.present).toHaveBeenCalledWith(90);
expect(mockRestSheet.present).toHaveBeenCalledWith('B', [
{
setId: 'b-s1',
setNumber: 1,
restSec: 90,
},
], false);

mockRestSheet.onApply?.([{ setId: 'b-s1', seconds: 120 }]);
expect(utils.callbacks.setExerciseRest).toHaveBeenCalledWith('b', 120);
});

mockRestSheet.onChange?.(120);
it('targets the rest sheet for superset members and calls setExerciseRest', () => {
const supersetExerciseA = makeExercise('a', { supersetGroup: 1 });
const supersetExerciseB = makeExercise('b', { supersetGroup: 1 });
const utils = renderList([supersetExerciseA, supersetExerciseB]);

fireEvent.press(utils.getByTestId('card-b-rest'));
// For superset members, isSupersetRound should be true
expect(mockRestSheet.present).toHaveBeenCalledWith('B', [
{
setId: 'b-s1',
setNumber: 1,
restSec: 90,
},
], true);

// When superset member rest is applied, use setExerciseRest regardless of matching sets
mockRestSheet.onApply?.([{ setId: 'b-s1', seconds: 120 }]);
expect(utils.callbacks.setExerciseRest).toHaveBeenCalledWith('b', 120);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ describe('WorkoutPresetDetailScreen', () => {
expect(screen.getByText('220.5')).toBeTruthy();
});

it('shows one exercise-level rest chip from the first set (mixed rest degrades to it)', () => {
it('shows one exercise-level rest chip spanning the min-max of its sets when rest times differ', () => {
const preset = buildPreset({
exercises: [
{
Expand All @@ -336,7 +336,8 @@ describe('WorkoutPresetDetailScreen', () => {
});
const screen = renderScreen(preset);

expect(screen.getByLabelText('Rest 45s')).toBeTruthy();
expect(screen.getByLabelText('Rest 45s-2:00')).toBeTruthy();
expect(screen.queryByLabelText('Rest 45s')).toBeNull();
expect(screen.queryByLabelText('Rest 1:30')).toBeNull();
expect(screen.queryByLabelText('Rest 2:00')).toBeNull();
});
Expand Down
57 changes: 48 additions & 9 deletions SparkyFitnessMobile/__tests__/stores/activeWorkoutStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,21 +288,25 @@ describe('activeWorkoutStore', () => {
expect(state.activeSetId).toBe('101');
});

it('derives restSec from the first set per exercise', () => {
useActiveWorkoutStore.getState().startWorkout(makeSession());
it('derives restSec from each set individually, not just the first', () => {
const session = makeSession();
session.exercises[0].sets[0].rest_time = 45;
session.exercises[0].sets[1].rest_time = 75;
useActiveWorkoutStore.getState().startWorkout(session);
const { steps } = useActiveWorkoutStore.getState();
expect(steps[0].restSec).toBe(60);
expect(steps[1].restSec).toBe(60);
expect(steps[0].restSec).toBe(45);
expect(steps[1].restSec).toBe(75);
expect(steps[2].restSec).toBe(120);
});

it('falls back to 90s when rest_time is null', () => {
it('falls back to 90s only for the set whose rest_time is null', () => {
const session = makeSession();
session.exercises[0].sets[0].rest_time = null;
useActiveWorkoutStore.getState().startWorkout(session);
const { steps } = useActiveWorkoutStore.getState();
expect(steps[0].restSec).toBe(90);
expect(steps[1].restSec).toBe(90);
// Unaffected — each set uses its own rest_time, not the first set's.
expect(steps[1].restSec).toBe(60);
});

it('snapshots exerciseName and exerciseImage per step', () => {
Expand Down Expand Up @@ -1233,12 +1237,47 @@ describe('activeWorkoutStore', () => {
});
});

describe('completeSet rest respects each set\'s own rest_time', () => {
it('uses the just-completed set\'s own rest_time, not the exercise\'s first set (regression)', async () => {
const session = makeSession();
session.exercises[0].sets[0].rest_time = 45;
session.exercises[0].sets[1].rest_time = 75;
useActiveWorkoutStore.getState().startWorkout(session);

useActiveWorkoutStore.getState().completeSet('101');
expect(useActiveWorkoutStore.getState().rest.durationSec).toBe(45);
await flushPromises();

useActiveWorkoutStore.getState().completeSet('102');
expect(useActiveWorkoutStore.getState().rest.durationSec).toBe(75);
await flushPromises();
});
});

describe('completeSet rest (supersets)', () => {
// Steps: 301(90), 401(0), 302(90), 402(0).
beforeEach(() => {
useActiveWorkoutStore.getState().startWorkout(makeSupersetSession(2));
});

it('uses the round-specific rest, not always round 0\'s (regression)', async () => {
const session = makeSupersetSession(3);
// Round 1 (index 1) has a shorter rest than round 0 and round 2.
session.exercises[0].sets[1].rest_time = 60;
session.exercises[1].sets[1].rest_time = 60;
useActiveWorkoutStore.getState().startWorkout(session);

useActiveWorkoutStore.getState().completeSet('301');
useActiveWorkoutStore.getState().completeSet('401'); // finishes round 0
expect(useActiveWorkoutStore.getState().rest.durationSec).toBe(90);
await flushPromises();

useActiveWorkoutStore.getState().completeSet('302');
useActiveWorkoutStore.getState().completeSet('402'); // finishes round 1
expect(useActiveWorkoutStore.getState().rest.durationSec).toBe(60);
await flushPromises();
});

it('rests between rounds but not between partners when logged in order', async () => {
// Partner within the round → no rest.
useActiveWorkoutStore.getState().completeSet('301');
Expand Down Expand Up @@ -1861,15 +1900,15 @@ describe('activeWorkoutStore', () => {
expect(completedSetIds['101']).toBe(FIXED_NOW);
});

it('refreshes restSec on every step when first set rest_time changes', () => {
it('keeps each set restSec tied to that set when one set rest_time changes', () => {
const updated = makeSession();
updated.exercises[0].sets[0].rest_time = 180;
updated.exercises[0].sets[1].rest_time = 60; // unchanged; should still be overridden
updated.exercises[0].sets[1].rest_time = 60;

useActiveWorkoutStore.getState().reconcileWithSession(updated);
const { steps } = useActiveWorkoutStore.getState();
expect(steps[0].restSec).toBe(180);
expect(steps[1].restSec).toBe(180);
expect(steps[1].restSec).toBe(60);
});

it('reorders steps to match new session order', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ function ActiveWorkoutExerciseCard({
{showRestChip && !cardioForm && (
<RestPeriodChip
value={exercise.sets[0]?.rest_time}
values={exercise.sets.map((set) => set.rest_time)}
readOnly={readOnly}
onPress={
readOnly
Expand Down
149 changes: 149 additions & 0 deletions SparkyFitnessMobile/src/components/DurationWheel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useMemo, useState } from 'react';
import { Text, View } from 'react-native';
import WheelPicker, { type PickerOption as WheelPickerOption } from './ui/wheel-picker';
import { useCSSVariable } from 'uniwind';

interface DurationWheelProps {
valueSec: number;
onChangeSec: (seconds: number) => void;
maxSec?: number;
}

const ITEM_HEIGHT = 44;

// Seconds rollover: give the wheel 10× the real range (600 items) so the user
// can scroll up or down through many full rotations without hitting an edge.
// The displayed text is `index % 60`; onChange strips the loop offset back to
// the canonical 0–59 value. Start position is always the center repetition so
// equal runway exists in both directions.
const SEC_LOOP = 10;
const SEC_TOTAL = 60 * SEC_LOOP; // 600
const SEC_MID_OFFSET = Math.floor(SEC_LOOP / 2) * 60; // 300

function DurationWheel({ valueSec, onChangeSec, maxSec = 900 }: DurationWheelProps) {
const [textPrimary, borderSubtle] = useCSSVariable([
'--color-text-primary',
'--color-border-subtle',
]) as [string, string];

const clamped = Math.max(0, Math.min(maxSec, valueSec));
const currentMin = Math.floor(clamped / 60);
const currentSec = clamped % 60;
const maxMinutes = Math.floor(maxSec / 60);

const minuteOptions = useMemo<WheelPickerOption[]>(
() =>
Array.from({ length: maxMinutes + 1 }, (_, i) => ({
value: i,
text: String(i).padStart(2, '0'),
})),
[maxMinutes],
);

// Each item has a unique numeric `value` (its index) so WheelPicker's
// findIndex lookup always lands on the correct row. Text wraps mod 60.
const secondOptions = useMemo<WheelPickerOption[]>(
() =>
Array.from({ length: SEC_TOTAL }, (_, i) => ({
value: i,
text: String(i % 60).padStart(2, '0'),
})),
[],
);

// Raw seconds-wheel index (0..SEC_TOTAL-1), kept in local state instead of
// recomputed from currentSec every render. Recomputing it (always mid-offset
// + currentSec) would snap the wheel back to the middle repetition on its
// own echoed onChangeSec, which is jarring right when a scroll crosses a
// repetition boundary (e.g. index 359 "59" -> 360 "00"). We only reconcile
// this index below when valueSec changes for a reason other than our own
// handleSecondChange call.
const [secondsIndex, setSecondsIndex] = useState(() => SEC_MID_OFFSET + currentSec);
const [lastEmittedSec, setLastEmittedSec] = useState(currentSec);

// valueSec changes are reflected in the same commit rather than triggering an extra render.
// The "last known" value is plain state rather than a ref.
if (currentSec !== lastEmittedSec) {
setLastEmittedSec(currentSec);
setSecondsIndex(SEC_MID_OFFSET + currentSec);
}

const secondsWheelValue = secondsIndex;

const indicatorStyle = useMemo(
() => ({ backgroundColor: borderSubtle, borderRadius: 8 }),
[borderSubtle],
);

const textStyle = useMemo(
() => ({ color: textPrimary, fontSize: 22, fontWeight: '500' as const }),
[textPrimary],
);

const handleMinuteChange = (v: number | string) => {
const m = Number(v);
const total = Math.max(0, Math.min(maxSec, m * 60 + currentSec));
onChangeSec(total);
};

const handleSecondChange = (v: number | string) => {
// Preserve the wheel's actual raw index (don't rebase to the middle
// repetition) so a scroll across a repetition boundary doesn't snap back.
const index = Number(v);
// Strip the loop offset; the canonical value is always 0–59.
const s = index % 60;
setLastEmittedSec(s);
setSecondsIndex(index);
const total = Math.max(0, Math.min(maxSec, currentMin * 60 + s));
onChangeSec(total);
};

return (
<View style={{ height: ITEM_HEIGHT * 5 + 22 }}>
<View className="flex-row items-center justify-center mb-1">
<Text className="flex-1 text-center text-xs font-semibold uppercase text-text-muted">
Minutes
</Text>
<View style={{ width: 18 }} />
<Text className="flex-1 text-center text-xs font-semibold uppercase text-text-muted">
Seconds
</Text>
</View>

<View className="flex-row items-center justify-center">
<View className="flex-1">
<WheelPicker
value={currentMin}
options={minuteOptions}
onChange={handleMinuteChange}
selectedIndicatorStyle={indicatorStyle}
itemTextStyle={textStyle}
itemHeight={ITEM_HEIGHT}
decelerationRate="fast"
/>
</View>

<Text
className="text-text-primary"
style={{ fontSize: 26, fontWeight: '300', marginHorizontal: 4, marginBottom: 2 }}
>
:
</Text>

<View className="flex-1">
<WheelPicker
value={secondsWheelValue}
options={secondOptions}
onChange={handleSecondChange}
selectedIndicatorStyle={indicatorStyle}
itemTextStyle={textStyle}
itemHeight={ITEM_HEIGHT}
decelerationRate="fast"
/>
</View>
</View>
</View>
);
}

export default DurationWheel;
Loading
Loading