diff --git a/app.config.js b/app.config.js index 09add8d2..a4a4e8f2 100644 --- a/app.config.js +++ b/app.config.js @@ -4,7 +4,7 @@ export default { expo: { name: IS_DEV ? "Muscle Quest (Dev)" : "Muscle Quest", slug: "musclequest", - version: "0.21.05", // MM.mm.pp + version: "1.0.0", // MM.mm.pp orientation: "portrait", icon: "./assets/images/icon.png", scheme: "musclequest", @@ -14,7 +14,7 @@ export default { bundleIdentifier: "com.isotronic.musclequest", }, android: { - versionCode: 2105, // MMmmpp + versionCode: 10000, // MMmmpp googleServicesFile: "./google-services.json", adaptiveIcon: { foregroundImage: "./assets/images/ic_launcher_foreground.png", diff --git a/app/(app)/(workout)/index.tsx b/app/(app)/(workout)/index.tsx index 0b90cd4a..6997c98a 100644 --- a/app/(app)/(workout)/index.tsx +++ b/app/(app)/(workout)/index.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, ScrollView, StyleSheet, Alert, TextInput } from "react-native"; +import { + View, + ScrollView, + StyleSheet, + Alert, + TextInput, + TouchableOpacity, +} from "react-native"; import { Trans } from "@lingui/react/macro"; import { t } from "@lingui/core/macro"; import Sortable from "react-native-sortables"; @@ -18,7 +25,7 @@ import { import { useActiveWorkoutStore } from "@/store/activeWorkoutStore"; import { ThemedText } from "@/components/ThemedText"; import { ThemedView } from "@/components/ThemedView"; -import { router, Stack } from "expo-router"; +import { router, Stack, useFocusEffect } from "expo-router"; import { Colors } from "@/constants/Colors"; import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useSaveCompletedWorkoutMutation } from "@/hooks/useSaveCompletedWorkoutMutation"; @@ -34,10 +41,27 @@ import { createStandaloneWorkout, linkCompletedWorkoutToWorkout, } from "@/utils/database"; -import { cancelRestNotifications } from "@/utils/restNotification"; +import { + cancelRestNotifications, + scheduleRestNotificationWithCancellation, +} from "@/utils/restNotification"; import { convertTimeStrToSeconds } from "@/utils/utility"; import { useQueryClient } from "@tanstack/react-query"; import { UserExercise, Workout } from "@/store/workoutStore"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, +} from "react-native-reanimated"; +import { useTimer } from "react-timer-hook"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useSoundStore } from "@/store/soundStore"; + +const AnimatedView = Animated.View as unknown as React.ComponentType<{ + style?: any; + pointerEvents?: "auto" | "none" | "box-none" | "box-only"; + children?: React.ReactNode; +}>; type SingleItem = { type: "single"; @@ -83,6 +107,10 @@ export default function WorkoutOverviewScreen() { initializeWeightAndReps, removeFromSuperset, setDurations, + timerRunning, + timerExpiry, + stopTimer, + startTimer, } = useActiveWorkoutStore(); const stableKeyMapRef = useRef(new WeakMap()); @@ -125,6 +153,81 @@ export default function WorkoutOverviewScreen() { useKeepScreenOn(); + const parsedIncrement = parseInt(settings?.restTimerIncrement || "15", 10); + const restTimerIncrement = + Number.isFinite(parsedIncrement) && parsedIncrement > 0 + ? parsedIncrement + : 15; + const { playSound, triggerVibration } = useSoundStore(); + const insets = useSafeAreaInsets(); + + const isFocusedRef = useRef(true); + useFocusEffect( + useCallback(() => { + isFocusedRef.current = true; + return () => { + isFocusedRef.current = false; + }; + }, []), + ); + + const expiryTimestampRef = useRef(null); + const timerTranslateY = useSharedValue(timerRunning ? 0 : 200); + const timerAnimStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: timerTranslateY.value }], + })); + + const { seconds, minutes, restart } = useTimer({ + expiryTimestamp: timerExpiry || new Date(), + autoStart: timerRunning, + onExpire: () => { + stopTimer(); + if (isFocusedRef.current) { + if (settings?.restTimerSound === "true") void playSound(); + if (settings?.restTimerVibration === "true") triggerVibration(); + } + }, + }); + + useEffect(() => { + timerTranslateY.value = withTiming(timerRunning ? 0 : 200, { + duration: 300, + }); + }, [timerRunning, timerTranslateY]); + + useEffect(() => { + if (timerRunning && timerExpiry) { + const time = new Date(timerExpiry); + expiryTimestampRef.current = time; + restart(time); + } + }, [timerRunning, timerExpiry, restart]); + + const adjustTimerOverview = async (deltaSeconds: number) => { + const currentRemaining = expiryTimestampRef.current + ? Math.max( + 0, + Math.round( + (expiryTimestampRef.current.getTime() - Date.now()) / 1000, + ), + ) + : minutes * 60 + seconds; + const newRemaining = Math.max(0, currentRemaining + deltaSeconds); + const newExpiry = new Date(); + newExpiry.setSeconds(newExpiry.getSeconds() + newRemaining); + expiryTimestampRef.current = newExpiry; + startTimer(newExpiry); + restart(newExpiry); + if (settings?.restTimerNotification === "true") { + await scheduleRestNotificationWithCancellation( + newRemaining, + "Rest Timer Finished!", + "Time to do your next set!", + "rest-timer1", + ); + } + }; + const [isSaving, setIsSaving] = useState(false); const [loadingExerciseIndex, setLoadingExerciseIndex] = useState< number | null @@ -889,6 +992,39 @@ export default function WorkoutOverviewScreen() { Add Exercise + + + Rest Time Left: + + + void adjustTimerOverview(-restTimerIncrement)} + > + + −{restTimerIncrement}s + + + + {minutes}:{seconds.toString().padStart(2, "0")} + + void adjustTimerOverview(restTimerIncrement)} + > + + +{restTimerIncrement}s + + + + ); } @@ -1051,4 +1187,54 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 0, borderTopRightRadius: 0, }, + timerContainer: { + position: "absolute", + bottom: 0, + right: 16, + left: 16, + paddingTop: 8, + paddingBottom: 8, + backgroundColor: Colors.dark.cardBackground, + alignItems: "center", + justifyContent: "center", + borderTopLeftRadius: 10, + borderTopRightRadius: 10, + shadowColor: "#000", + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 5, + marginBottom: 0, + }, + timerLabel: { + fontSize: 14, + color: Colors.dark.text, + marginBottom: 4, + textAlign: "center", + }, + timerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + timerAdjustButton: { + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: Colors.dark.cardBackground2, + }, + timerAdjustText: { + fontSize: 14, + fontWeight: "600", + color: Colors.dark.text, + }, + timerText: { + fontSize: 32, + fontWeight: "bold", + color: Colors.dark.text, + textAlign: "center", + lineHeight: 32, + marginBottom: 8, + }, }); diff --git a/babel.config.js b/babel.config.js index d71db1b2..2399961d 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,10 @@ module.exports = function (api) { - api.cache(true); + // @lingui/babel-plugin-lingui-macro is a native ESM module that can't run + // synchronously in Jest's transform pipeline — skip it during tests. + const isTest = api.env("test"); + api.cache.using(() => process.env.NODE_ENV); return { presets: ["babel-preset-expo"], - plugins: ["@lingui/babel-plugin-lingui-macro"], + plugins: isTest ? [] : ["@lingui/babel-plugin-lingui-macro"], }; }; diff --git a/hooks/useWorkoutDurationEstimate.ts b/hooks/useWorkoutDurationEstimate.ts index fbb49e34..459b6aa1 100644 --- a/hooks/useWorkoutDurationEstimate.ts +++ b/hooks/useWorkoutDurationEstimate.ts @@ -4,6 +4,7 @@ import { fetchSetDurationsForExercises } from "@/utils/database"; import { computeWorkoutDurationEstimate, type DurationEstimate, + type SetDurationSample, } from "@/utils/estimateWorkoutDuration"; import type { UserExercise } from "@/store/workoutStore"; import Bugsnag from "@bugsnag/expo"; @@ -20,7 +21,7 @@ export function useWorkoutDurationEstimate( [exercises], ); - const { data, isLoading } = useQuery>({ + const { data, isLoading } = useQuery>({ queryKey: ["exerciseSetDurations", exerciseIds], queryFn: async () => { try { diff --git a/utils/__tests__/estimateWorkoutDuration.test.ts b/utils/__tests__/estimateWorkoutDuration.test.ts index 78b8f8ba..a618e579 100644 --- a/utils/__tests__/estimateWorkoutDuration.test.ts +++ b/utils/__tests__/estimateWorkoutDuration.test.ts @@ -6,6 +6,8 @@ import { MAX_VALID_SET_DURATION_SEC, SUFFICIENT_HISTORY_MIN, SPARSE_HISTORY_MIN, + REP_NORM, + type SetDurationSample, } from "@/utils/estimateWorkoutDuration"; import type { UserExercise, Set } from "@/store/workoutStore"; @@ -13,8 +15,8 @@ import type { UserExercise, Set } from "@/store/workoutStore"; function makeSet(overrides: Partial = {}): Set { return { - repsMin: 8, - repsMax: 12, + repsMin: REP_NORM, + repsMax: REP_NORM, restMinutes: 0, restSeconds: 0, time: undefined, @@ -45,6 +47,16 @@ function makeExercise( } as UserExercise; } +// Wrap raw duration numbers as legacy (no-reps) history samples. +function raw(duration: number): SetDurationSample { + return { duration, reps: null }; +} + +// Wrap duration numbers as rep-aware history samples. +function withReps(duration: number, reps: number): SetDurationSample { + return { duration, reps }; +} + // ─── computeWorkoutDurationEstimate ────────────────────────────────────────── describe("computeWorkoutDurationEstimate", () => { @@ -74,17 +86,23 @@ describe("computeWorkoutDurationEstimate", () => { expect(result.maxSeconds).toBe(30); }); - it("adds rest to timed sets with no history", () => { - const set = makeSet({ time: 45, restMinutes: 1, restSeconds: 30 }); - const ex = makeExercise(1, "cable", [set], "time"); + it("adds rest to timed sets when a set follows", () => { + // Rest is only added when there is a subsequent set — not after the last set. + const setWithRest = makeSet({ + time: 45, + restMinutes: 1, + restSeconds: 30, + }); + const setNoRest = makeSet({ time: 20, restMinutes: 0, restSeconds: 0 }); + const ex = makeExercise(1, "cable", [setWithRest, setNoRest], "time"); const result = computeWorkoutDurationEstimate([ex], {}); - expect(result.minSeconds).toBe(45 + 90); - expect(result.maxSeconds).toBe(45 + 90); + expect(result.minSeconds).toBe(45 + 90 + 20); // first set + its rest + second set + expect(result.maxSeconds).toBe(45 + 90 + 20); }); it("prefers historical data over planned time when sufficient history exists", () => { // History consistently faster than planned time - const uniformHistory = [20, 20, 20, 20, 20]; + const uniformHistory = [20, 20, 20, 20, 20].map(raw); const set = makeSet({ time: 60, restMinutes: 0, restSeconds: 0 }); const ex = makeExercise(1, "cable", [set], "time"); const result = computeWorkoutDurationEstimate([ex], { @@ -100,7 +118,7 @@ describe("computeWorkoutDurationEstimate", () => { // Expected mid ≈ (40 * 0.5 + 60 * 0.5) = 50, halfSpread = 0 (planned time has no spread) const set = makeSet({ time: 60, restMinutes: 0, restSeconds: 0 }); const ex = makeExercise(1, "cable", [set], "time"); - const result = computeWorkoutDurationEstimate([ex], { 1: [40] }); + const result = computeWorkoutDurationEstimate([ex], { 1: [raw(40)] }); expect(result.minSeconds).toBe(50); expect(result.maxSeconds).toBe(50); }); @@ -112,6 +130,7 @@ describe("computeWorkoutDurationEstimate", () => { const set = makeSet({ restMinutes: 0, restSeconds: 0 }); const ex = makeExercise(1, "barbell", [set]); const result = computeWorkoutDurationEstimate([ex], {}); + // makeSet uses REP_NORM reps, so scale = 1.0 — values unchanged expect(result.minSeconds).toBe(defMin); expect(result.maxSeconds).toBe(defMax); }); @@ -134,14 +153,16 @@ describe("computeWorkoutDurationEstimate", () => { expect(result.maxSeconds).toBe(defMax); }); - it("adds rest to equipment-default estimate", () => { + it("adds rest to equipment-default estimate when a set follows", () => { + // Rest is only added when there is a subsequent set — not after the last set. const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["dumbbell"]; const rest = 90; // 1 min 30 sec - const set = makeSet({ restMinutes: 1, restSeconds: 30 }); - const ex = makeExercise(1, "dumbbell", [set]); + const setWithRest = makeSet({ restMinutes: 1, restSeconds: 30 }); + const setNoRest = makeSet({ restMinutes: 0, restSeconds: 0 }); + const ex = makeExercise(1, "dumbbell", [setWithRest, setNoRest]); const result = computeWorkoutDurationEstimate([ex], {}); - expect(result.minSeconds).toBe(defMin + rest); - expect(result.maxSeconds).toBe(defMax + rest); + expect(result.minSeconds).toBe(defMin + rest + defMin); + expect(result.maxSeconds).toBe(defMax + rest + defMax); }); }); @@ -153,11 +174,16 @@ describe("computeWorkoutDurationEstimate", () => { expect(result.minSeconds).toBe(30); }); - it("correctly combines minutes and seconds for rest", () => { - const set = makeSet({ time: 20, restMinutes: 2, restSeconds: 15 }); - const ex = makeExercise(1, "cable", [set], "time"); + it("correctly combines minutes and seconds for rest when a set follows", () => { + const setWithRest = makeSet({ + time: 20, + restMinutes: 2, + restSeconds: 15, + }); + const setNoRest = makeSet({ time: 10, restMinutes: 0, restSeconds: 0 }); + const ex = makeExercise(1, "cable", [setWithRest, setNoRest], "time"); const result = computeWorkoutDurationEstimate([ex], {}); - expect(result.minSeconds).toBe(20 + 2 * 60 + 15); + expect(result.minSeconds).toBe(20 + 2 * 60 + 15 + 10); }); }); @@ -184,8 +210,10 @@ describe("computeWorkoutDurationEstimate", () => { describe("outlier and invalid duration filtering", () => { it("ignores set durations above MAX_VALID_SET_DURATION_SEC", () => { - const validHistory = [40, 42, 44, 46, 48]; // 5 clean samples - const dirtyHistory = [40, 42, 44, 46, MAX_VALID_SET_DURATION_SEC + 1]; + const validHistory = [40, 42, 44, 46, 48].map(raw); + const dirtyHistory = [40, 42, 44, 46, MAX_VALID_SET_DURATION_SEC + 1].map( + raw, + ); const set = makeSet(); const ex1 = makeExercise(1, "barbell", [set]); const ex2 = makeExercise(2, "barbell", [set]); @@ -202,7 +230,7 @@ describe("computeWorkoutDurationEstimate", () => { const set = makeSet(); const ex = makeExercise(1, "cable", [set]); const result = computeWorkoutDurationEstimate([ex], { - 1: [0, 0, 0, 0, 0], + 1: [0, 0, 0, 0, 0].map(raw), }); expect(result.minSeconds).toBe(defMin); expect(result.maxSeconds).toBe(defMax); @@ -212,7 +240,9 @@ describe("computeWorkoutDurationEstimate", () => { const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; const set = makeSet(); const ex = makeExercise(1, "barbell", [set]); - const oversized = Array(5).fill(MAX_VALID_SET_DURATION_SEC + 100); + const oversized = Array(5) + .fill(MAX_VALID_SET_DURATION_SEC + 100) + .map(raw); const result = computeWorkoutDurationEstimate([ex], { 1: oversized }); expect(result.minSeconds).toBe(defMin); expect(result.maxSeconds).toBe(defMax); @@ -225,7 +255,9 @@ describe("computeWorkoutDurationEstimate", () => { const histValue = 60; // above equipment default const set = makeSet(); const ex = makeExercise(1, "dumbbell", [set]); - const result = computeWorkoutDurationEstimate([ex], { 1: [histValue] }); + const result = computeWorkoutDurationEstimate([ex], { + 1: [raw(histValue)], + }); // With 1 sample: mid = histMean*0.5 + defMid*0.5, spread narrowed expect(result.minSeconds).toBeGreaterThanOrEqual(defMin - 5); expect(result.maxSeconds).toBeLessThanOrEqual(histValue + 20); @@ -235,7 +267,9 @@ describe("computeWorkoutDurationEstimate", () => { it("produces a result between history mean and equipment range with 2 samples", () => { const set = makeSet(); const ex = makeExercise(1, "dumbbell", [set]); - const result = computeWorkoutDurationEstimate([ex], { 1: [50, 70] }); + const result = computeWorkoutDurationEstimate([ex], { + 1: [raw(50), raw(70)], + }); expect(result.minSeconds).toBeGreaterThan(0); expect(result.maxSeconds).toBeGreaterThanOrEqual(result.minSeconds); }); @@ -244,7 +278,7 @@ describe("computeWorkoutDurationEstimate", () => { describe("moderate history blending (3–4 samples)", () => { it("blends 70/30 with 3 samples", () => { const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["cable"]; - const history = [30, 32, 34]; // 3 samples, all lower than default + const history = [30, 32, 34].map(raw); // 3 samples, all lower than default const set = makeSet(); const ex = makeExercise(1, "cable", [set]); const result = computeWorkoutDurationEstimate([ex], { 1: history }); @@ -260,7 +294,7 @@ describe("computeWorkoutDurationEstimate", () => { const set = makeSet(); const ex = makeExercise(1, "barbell", [set]); const result = computeWorkoutDurationEstimate([ex], { - 1: [40, 45, 50, 55], + 1: [40, 45, 50, 55].map(raw), }); expect(result.minSeconds).toBeGreaterThan(0); expect(result.maxSeconds).toBeGreaterThanOrEqual(result.minSeconds); @@ -270,7 +304,7 @@ describe("computeWorkoutDurationEstimate", () => { describe("sufficient history (≥5 samples)", () => { it("returns P25/P75 range from sorted history", () => { // Uniform history: P25 = P75 = 40 - const uniform = [40, 40, 40, 40, 40]; + const uniform = [40, 40, 40, 40, 40].map(raw); const set = makeSet(); const ex = makeExercise(1, "barbell", [set]); const result = computeWorkoutDurationEstimate([ex], { 1: uniform }); @@ -280,7 +314,7 @@ describe("computeWorkoutDurationEstimate", () => { it("returns spread P25/P75 for varied history", () => { // Values: 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 - const history = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const history = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map(raw); const set = makeSet(); const ex = makeExercise(1, "barbell", [set]); const result = computeWorkoutDurationEstimate([ex], { 1: history }); @@ -292,8 +326,8 @@ describe("computeWorkoutDurationEstimate", () => { it("caps samples at MAX_HISTORY_SAMPLES (uses newest)", () => { // Pass 25 samples — only the first 20 (newest) should be used - const newestSamples = Array(20).fill(40); - const oldSamples = Array(5).fill(200); // these would be filtered as outliers anyway, but placed at end (oldest) + const newestSamples = Array(20).fill(40).map(raw); + const oldSamples = Array(5).fill(200).map(raw); // these would be filtered as outliers anyway, but placed at end (oldest) const history = [...newestSamples, ...oldSamples]; const set = makeSet(); const ex = makeExercise(1, "barbell", [set]); @@ -334,7 +368,8 @@ describe("computeWorkoutDurationEstimate", () => { it("keeps rest for the second exercise in a superset pair", () => { const setA = makeSet({ restMinutes: 1, restSeconds: 0 }); - const setB = makeSet({ restMinutes: 2, restSeconds: 0 }); // 120 s + const setB = makeSet({ restMinutes: 2, restSeconds: 0 }); // 120 s rest + const setC = makeSet({ restMinutes: 0, restSeconds: 0 }); const exA: UserExercise = { ...makeExercise(1, "barbell", [setA]), supersetGroupId: "ss1", @@ -343,12 +378,15 @@ describe("computeWorkoutDurationEstimate", () => { ...makeExercise(2, "barbell", [setB]), supersetGroupId: "ss1", }; - const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; + // A trailing exercise forces exB's rest to be counted (it has a next slot). + const exC = makeExercise(3, "cable", [setC]); + const [bbMin, bbMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; + const [cableMin, cableMax] = EQUIPMENT_DURATION_DEFAULTS["cable"]; - const result = computeWorkoutDurationEstimate([exA, exB], {}); - // work A + 0 rest + work B + 120 rest - expect(result.minSeconds).toBe(defMin + 0 + defMin + 120); - expect(result.maxSeconds).toBe(defMax + 0 + defMax + 120); + const result = computeWorkoutDurationEstimate([exA, exB, exC], {}); + // work A + 0 rest (superset first, rest skipped) + work B + 120 rest + work C + 0 rest (last) + expect(result.minSeconds).toBe(bbMin + 0 + bbMin + 120 + cableMin); + expect(result.maxSeconds).toBe(bbMax + 0 + bbMax + 120 + cableMax); }); it("non-superset exercises are unaffected", () => { @@ -367,9 +405,9 @@ describe("computeWorkoutDurationEstimate", () => { const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; const [cableMin, cableMax] = EQUIPMENT_DURATION_DEFAULTS["cable"]; - // solo exercise should contribute its full rest (60 s) - expect(result.minSeconds).toBe(defMin + 0 + defMin + 60 + cableMin + 60); - expect(result.maxSeconds).toBe(defMax + 0 + defMax + 60 + cableMax + 60); + // solo exercise is last, so its rest is not added + expect(result.minSeconds).toBe(defMin + 0 + defMin + 60 + cableMin); + expect(result.maxSeconds).toBe(defMax + 0 + defMax + 60 + cableMax); }); }); @@ -391,13 +429,157 @@ describe("computeWorkoutDurationEstimate", () => { const ex2 = makeExercise(2, "barbell", [set]); // ex1 has fast history, ex2 uses default const result = computeWorkoutDurationEstimate([ex1, ex2], { - 1: [30, 30, 30, 30, 30], // 5 fast samples → P25=P75=30 + 1: [30, 30, 30, 30, 30].map(raw), // 5 fast samples → P25=P75=30 }); const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; expect(result.minSeconds).toBe(30 + defMin); expect(result.maxSeconds).toBe(30 + defMax); }); }); + + // ─── Rep-aware estimation ──────────────────────────────────────────────────── + + describe("rep-aware estimation", () => { + describe("no history — equipment defaults scale with rep count", () => { + it("produces a shorter estimate for fewer reps than REP_NORM", () => { + const halfReps = makeSet({ repsMin: 5, repsMax: 5 }); + const normReps = makeSet({ repsMin: REP_NORM, repsMax: REP_NORM }); + const ex1 = makeExercise(1, "barbell", [halfReps]); + const ex2 = makeExercise(2, "barbell", [normReps]); + const r1 = computeWorkoutDurationEstimate([ex1], {}); + const r2 = computeWorkoutDurationEstimate([ex2], {}); + expect(r1.minSeconds).toBeLessThan(r2.minSeconds); + expect(r1.maxSeconds).toBeLessThan(r2.maxSeconds); + }); + + it("produces a longer estimate for more reps than REP_NORM", () => { + const moreReps = makeSet({ repsMin: 15, repsMax: 15 }); + const normReps = makeSet({ repsMin: REP_NORM, repsMax: REP_NORM }); + const ex1 = makeExercise(1, "dumbbell", [moreReps]); + const ex2 = makeExercise(2, "dumbbell", [normReps]); + const r1 = computeWorkoutDurationEstimate([ex1], {}); + const r2 = computeWorkoutDurationEstimate([ex2], {}); + expect(r1.minSeconds).toBeGreaterThan(r2.minSeconds); + expect(r1.maxSeconds).toBeGreaterThan(r2.maxSeconds); + }); + + it("scales proportionally: 5 reps ≈ half of 10 reps estimate", () => { + const [rawMin, rawMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; + const set = makeSet({ repsMin: 5, repsMax: 5 }); + const ex = makeExercise(1, "barbell", [set]); + const result = computeWorkoutDurationEstimate([ex], {}); + expect(result.minSeconds).toBe(Math.round(rawMin * (5 / REP_NORM))); + expect(result.maxSeconds).toBe(Math.round(rawMax * (5 / REP_NORM))); + }); + + it("widens the range when repsMin < repsMax", () => { + const wideRange = makeSet({ repsMin: 5, repsMax: 15 }); + const singleRep = makeSet({ repsMin: 10, repsMax: 10 }); + const ex1 = makeExercise(1, "barbell", [wideRange]); + const ex2 = makeExercise(2, "barbell", [singleRep]); + const r1 = computeWorkoutDurationEstimate([ex1], {}); + const r2 = computeWorkoutDurationEstimate([ex2], {}); + // Wide range: min uses 5 reps, max uses 15 reps → larger spread + const spread1 = r1.maxSeconds - r1.minSeconds; + const spread2 = r2.maxSeconds - r2.minSeconds; + expect(spread1).toBeGreaterThan(spread2); + }); + + it("does not scale timed sets", () => { + const timedSet = makeSet({ + time: 40, + repsMin: 5, + repsMax: 5, + restMinutes: 0, + restSeconds: 0, + }); + const ex = makeExercise(1, "barbell", [timedSet], "time"); + const result = computeWorkoutDurationEstimate([ex], {}); + // set.time wins regardless of reps + expect(result.minSeconds).toBe(40); + expect(result.maxSeconds).toBe(40); + }); + + it("does not scale when reps are undefined", () => { + const [defMin, defMax] = EQUIPMENT_DURATION_DEFAULTS["barbell"]; + const noRepsSet = makeSet({ + repsMin: undefined, + repsMax: undefined, + restMinutes: 0, + restSeconds: 0, + }); + const ex = makeExercise(1, "barbell", [noRepsSet]); + const result = computeWorkoutDurationEstimate([ex], {}); + expect(result.minSeconds).toBe(defMin); + expect(result.maxSeconds).toBe(defMax); + }); + }); + + describe("rep-aware history — per-rep pace used for estimation", () => { + it("estimates lower for fewer reps than historical average", () => { + // History: 5 sets of 15 reps, each taking 45 s → 3 s/rep + const history = Array(5) + .fill(null) + .map(() => withReps(45, 15)); + const set = makeSet({ repsMin: 5, repsMax: 5 }); + const ex = makeExercise(1, "barbell", [set]); + const result = computeWorkoutDurationEstimate([ex], { 1: history }); + // 3 s/rep × 5 reps = 15 s + expect(result.minSeconds).toBe(15); + expect(result.maxSeconds).toBe(15); + }); + + it("estimates higher for more reps than historical average", () => { + // History: 5 sets of 5 reps, each taking 20 s → 4 s/rep + const history = Array(5) + .fill(null) + .map(() => withReps(20, 5)); + const set = makeSet({ repsMin: 15, repsMax: 15 }); + const ex = makeExercise(1, "dumbbell", [set]); + const result = computeWorkoutDurationEstimate([ex], { 1: history }); + // 4 s/rep × 15 reps = 60 s + expect(result.minSeconds).toBe(60); + expect(result.maxSeconds).toBe(60); + }); + + it("min/max range reflects repsMin vs repsMax", () => { + // Uniform history: 5 s/rep + const history = Array(5) + .fill(null) + .map(() => withReps(50, 10)); + const set = makeSet({ repsMin: 8, repsMax: 12 }); + const ex = makeExercise(1, "barbell", [set]); + const result = computeWorkoutDurationEstimate([ex], { 1: history }); + // min = 5 * 8 = 40, max = 5 * 12 = 60 + expect(result.minSeconds).toBe(40); + expect(result.maxSeconds).toBe(60); + }); + + it("falls back gracefully when all history has null reps", () => { + // Legacy history without rep counts — should use raw-duration path + const history = [40, 40, 40, 40, 40].map(raw); + const set = makeSet({ repsMin: 8, repsMax: 12 }); + const ex = makeExercise(1, "barbell", [set]); + const result = computeWorkoutDurationEstimate([ex], { 1: history }); + // Raw P25/P75 from uniform history = 40 + expect(result.minSeconds).toBe(40); + expect(result.maxSeconds).toBe(40); + }); + + it("blends sparse rep-aware history (1–2 samples) with defaults", () => { + // 1 rep-aware sample: 30 s at 10 reps → 3 s/rep + // mid = 0.5*(30/10) + 0.5*(35/10) = 3.25 s/rep + // perRepLo = 3.25 - 0.5*1 = 2.75, perRepHi = 3.25 + 0.5*1 = 3.75 + // result = [round(2.75*10), round(3.75*10)] = [28, 38] + const history = [withReps(30, REP_NORM)]; + const set = makeSet({ repsMin: REP_NORM, repsMax: REP_NORM }); + const ex = makeExercise(1, "cable", [set]); + const result = computeWorkoutDurationEstimate([ex], { 1: history }); + expect(result.minSeconds).toBe(28); + expect(result.maxSeconds).toBe(38); + }); + }); + }); }); // ─── formatDurationEstimate ─────────────────────────────────────────────────── @@ -416,7 +598,7 @@ describe("formatDurationEstimate", () => { minSeconds: 45 * 60, maxSeconds: 45 * 60, }); - expect(result).toBe("~45 min"); + expect(result).toBe("45 min"); }); it("rounds up to the nearest minute", () => { @@ -425,7 +607,7 @@ describe("formatDurationEstimate", () => { minSeconds: 44 * 60 + 30, maxSeconds: 44 * 60 + 30, }); - expect(result).toBe("~45 min"); + expect(result).toBe("45 min"); }); it("formats exactly 60 minutes as 1h", () => { @@ -433,7 +615,7 @@ describe("formatDurationEstimate", () => { minSeconds: 3600, maxSeconds: 3600, }); - expect(result).toBe("~1h"); + expect(result).toBe("1h"); }); it("formats 1h 5min correctly", () => { @@ -441,7 +623,7 @@ describe("formatDurationEstimate", () => { minSeconds: 3600 + 5 * 60, maxSeconds: 3600 + 5 * 60, }); - expect(result).toBe("~1h 5min"); + expect(result).toBe("1h 5min"); }); it("handles ranges that straddle the 1-hour mark", () => { diff --git a/utils/database.ts b/utils/database.ts index e74c911e..4f623e25 100644 --- a/utils/database.ts +++ b/utils/database.ts @@ -1654,13 +1654,13 @@ export const upsertWeeklyCompletion = async ( export const fetchSetDurationsForExercises = async ( exerciseIds: number[], -): Promise> => { +): Promise> => { if (exerciseIds.length === 0) return {}; try { const db = await openDatabase("userData.db"); const placeholders = exerciseIds.map(() => "?").join(", "); const rows = (await db.getAllAsync( - `SELECT ce.exercise_id, cs.set_duration + `SELECT ce.exercise_id, cs.set_duration, cs.reps FROM completed_sets cs JOIN completed_exercises ce ON cs.completed_exercise_id = ce.id JOIN completed_workouts cw ON ce.completed_workout_id = cw.id @@ -1670,14 +1670,18 @@ export const fetchSetDurationsForExercises = async ( AND cw.is_deleted = FALSE ORDER BY cw.date_completed DESC`, exerciseIds, - )) as { exercise_id: number; set_duration: number }[]; + )) as { exercise_id: number; set_duration: number; reps: number | null }[]; - const result: Record = {}; + const result: Record = + {}; for (const row of rows) { if (!result[row.exercise_id]) { result[row.exercise_id] = []; } - result[row.exercise_id].push(row.set_duration); + result[row.exercise_id].push({ + duration: row.set_duration, + reps: row.reps, + }); } return result; } catch (error: any) { diff --git a/utils/estimateWorkoutDuration.ts b/utils/estimateWorkoutDuration.ts index d5e7bd32..728fe592 100644 --- a/utils/estimateWorkoutDuration.ts +++ b/utils/estimateWorkoutDuration.ts @@ -6,6 +6,7 @@ export const MAX_VALID_SET_DURATION_SEC = 480; // ignore set durations > 8 min export const MAX_HISTORY_SAMPLES = 20; // cap on samples used per exercise export const SUFFICIENT_HISTORY_MIN = 5; // pure P25/P75 from history export const SPARSE_HISTORY_MIN = 3; // blend 70 % history + 30 % equipment default +export const REP_NORM = 10; // assumed rep count the equipment defaults were calibrated for // [minSeconds, maxSeconds] per set for each equipment type export const EQUIPMENT_DURATION_DEFAULTS: Record = { @@ -37,6 +38,11 @@ export interface DurationEstimate { maxSeconds: number; } +export interface SetDurationSample { + duration: number; + reps: number | null; +} + // ─── Internal helpers ───────────────────────────────────────────────────────── function getEquipmentDefault( @@ -61,19 +67,68 @@ function percentile(sorted: number[], p: number): number { function estimateSetWorkDuration( set: Set, equipment: string | null | undefined, - history: number[], + history: SetDurationSample[], ): [number, number] { - // Baseline: planned time beats equipment defaults; history beats both. - // For timed sets set.time is an exact goal so the baseline has zero spread. + // For timed sets, planned time is the baseline (zero spread); rep scaling skipped. + // For rep-based sets, use equipment defaults scaled by planned rep count. const hasPlannedTime = set.time != null && set.time > 0; - const [defMin, defMax] = hasPlannedTime + const [rawDefMin, rawDefMax] = hasPlannedTime ? [set.time!, set.time!] : getEquipmentDefault(equipment); - const valid = history - .filter((d) => d > 0 && d <= MAX_VALID_SET_DURATION_SEC) + // Rep bounds from the planned set (repsMin → shorter side, repsMax → longer side). + // Only apply rep scaling to equipment defaults, not to planned times. + const targetMin = set.repsMin ?? set.repsMax; + const targetMax = set.repsMax ?? set.repsMin; + const hasTargetReps = + targetMin != null && targetMax != null && !hasPlannedTime; + + const defMin = hasTargetReps + ? Math.round(rawDefMin * (targetMin! / REP_NORM)) + : rawDefMin; + const defMax = hasTargetReps + ? Math.round(rawDefMax * (targetMax! / REP_NORM)) + : rawDefMax; + + // Split history into rep-aware samples (reps recorded) and raw samples (legacy). + const validAll = history + .filter((h) => h.duration > 0 && h.duration <= MAX_VALID_SET_DURATION_SEC) .slice(0, MAX_HISTORY_SAMPLES); + const repsAware = validAll.filter((h) => h.reps != null && h.reps > 0); + + // Use rep-aware path when we have samples with rep counts AND a target rep range. + if (repsAware.length > 0 && hasTargetReps) { + const perRep = repsAware.map((h) => h.duration / h.reps!); + const n = perRep.length; + const defMidPerRep = (rawDefMin + rawDefMax) / 2 / REP_NORM; + const halfSpreadPerRep = (rawDefMax - rawDefMin) / 2 / REP_NORM; + const histMeanPerRep = perRep.reduce((a, b) => a + b, 0) / n; + + let perRepLo: number; + let perRepHi: number; + + if (n < SPARSE_HISTORY_MIN) { + const mid = histMeanPerRep * 0.5 + defMidPerRep * 0.5; + perRepLo = mid - halfSpreadPerRep * 0.5; + perRepHi = mid + halfSpreadPerRep * 0.5; + } else if (n < SUFFICIENT_HISTORY_MIN) { + const mid = histMeanPerRep * 0.7 + defMidPerRep * 0.3; + perRepLo = mid - halfSpreadPerRep * 0.4; + perRepHi = mid + halfSpreadPerRep * 0.4; + } else { + const sorted = [...perRep].sort((a, b) => a - b); + perRepLo = percentile(sorted, 25); + perRepHi = percentile(sorted, 75); + } + + const clampedLo = Math.max(0, perRepLo * targetMin!); + const clampedHi = Math.max(clampedLo, perRepHi * targetMax!); + return [Math.round(clampedLo), Math.round(clampedHi)]; + } + + // Fallback: use raw durations (legacy data with null reps, or timed sets). + const valid = validAll.map((h) => h.duration); const n = valid.length; if (n === 0) { @@ -85,7 +140,6 @@ function estimateSetWorkDuration( const histMean = valid.reduce((a, b) => a + b, 0) / n; if (n < SPARSE_HISTORY_MIN) { - // 1–2 samples: 50 % history, 50 % baseline const mid = histMean * 0.5 + defMid * 0.5; return [ Math.round(mid - halfSpread * 0.5), @@ -94,7 +148,6 @@ function estimateSetWorkDuration( } if (n < SUFFICIENT_HISTORY_MIN) { - // 3–4 samples: 70 % history, 30 % baseline const mid = histMean * 0.7 + defMid * 0.3; return [ Math.round(mid - halfSpread * 0.4), @@ -102,7 +155,6 @@ function estimateSetWorkDuration( ]; } - // ≥5 samples: use P25/P75 of the cleaned distribution const sorted = [...valid].sort((a, b) => a - b); return [ Math.round(percentile(sorted, 25)), @@ -114,7 +166,7 @@ function estimateSetWorkDuration( export function computeWorkoutDurationEstimate( exercises: UserExercise[], - historyByExerciseId: Record, + historyByExerciseId: Record, countUnilateralDouble = false, ): DurationEstimate { // In a superset the transition from exercise A to B has no rest — only B's diff --git a/utils/formatSetMetric.ts b/utils/formatSetMetric.ts index 4f0acff2..eb4f2ba4 100644 --- a/utils/formatSetMetric.ts +++ b/utils/formatSetMetric.ts @@ -24,12 +24,12 @@ export function formatSetMetric( case "distance": return `${set.distance ?? 0} m`; case "assisted": { - const assist = set.weight ?? 0; - const resist = Math.max(0, bodyWeight - assist); + const assist = parseFloat((set.weight ?? 0).toFixed(1)); + const resist = parseFloat(Math.max(0, bodyWeight - assist).toFixed(1)); return `${assist} ${weightUnit} assist / ${resist} ${weightUnit} resist × ${set.reps ?? 0}`; } case "weight": default: - return `${set.weight ?? 0} ${weightUnit} × ${set.reps ?? 0}`; + return `${parseFloat((set.weight ?? 0).toFixed(1))} ${weightUnit} × ${set.reps ?? 0}`; } }