diff --git a/app/(app)/(create-plan)/create-workout.tsx b/app/(app)/(create-plan)/create-workout.tsx index 930827d9..026d1555 100644 --- a/app/(app)/(create-plan)/create-workout.tsx +++ b/app/(app)/(create-plan)/create-workout.tsx @@ -45,14 +45,85 @@ export default function CreateWorkoutScreen() { addWorkout, changeWorkoutName, setWorkouts, + saveDraftEntry, + clearDraftEntry, + clearDraft, } = useWorkoutStore(); + const draftKey = + existingWorkoutId !== null + ? `standalone:${existingWorkoutId}` + : "standalone:null"; + const { data: standaloneWorkouts } = useStandaloneWorkoutsQuery(); const createMutation = useCreateStandaloneWorkout(); const updateMutation = useUpdateStandaloneWorkout(); + // 'pending' until draft check resolves; then 'continue' or 'fresh' + const [draftDecision, setDraftDecision] = useState< + "pending" | "continue" | "fresh" + >("pending"); + + // Check for a persisted draft once on mount, after store hydrates + useEffect(() => { + const checkDraft = () => { + const { drafts } = useWorkoutStore.getState(); + const draft = drafts[draftKey]; + const hasDraft = + !!draft && + draft.workouts.length > 0 && + (draft.workouts[0].exercises.length > 0 || + draft.workouts[0].name.trim() !== ""); + + if (hasDraft) { + Alert.alert( + "Continue Editing?", + "You have unsaved changes from your last session. Would you like to continue?", + [ + { + text: "Discard Changes", + style: "destructive", + onPress: () => { + clearDraftEntry(draftKey); + clearDraft(); + setDraftDecision("fresh"); + }, + }, + { + text: "Continue", + onPress: () => { + setWorkouts(draft.workouts); + initializedWorkoutId.current = existingWorkoutId ?? null; + setDraftDecision("continue"); + }, + }, + ], + ); + } else { + setDraftDecision("fresh"); + } + }; + + if (useWorkoutStore.persist.hasHydrated()) { + checkDraft(); + } else { + const unsub = useWorkoutStore.persist.onFinishHydration(checkDraft); + return unsub; + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-save draft on every change so it survives app close + useEffect(() => { + if (draftDecision === "pending") return; + const workout = workouts[0]; + if (!workout || (!workout.exercises.length && !workout.name.trim())) return; + saveDraftEntry(draftKey, { workouts }); + }, [workouts, draftDecision]); // eslint-disable-line react-hooks/exhaustive-deps + // Initialise workoutStore for this screen — runs once per workout id useEffect(() => { + if (draftDecision === "pending" || draftDecision === "continue") return; + const sentinel = existingWorkoutId ?? null; if (initializedWorkoutId.current === sentinel) return; if (existingWorkoutId && standaloneWorkouts) { @@ -72,7 +143,7 @@ export default function CreateWorkoutScreen() { clearWorkouts(); addWorkout({ name: "", exercises: [], id: -Date.now() }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [existingWorkoutId, standaloneWorkouts]); + }, [draftDecision, existingWorkoutId, standaloneWorkouts]); // Guard back-navigation when there are unsaved changes useEffect(() => { @@ -81,13 +152,15 @@ export default function CreateWorkoutScreen() { if (savedRef.current) { savedRef.current = false; - clearWorkouts(); + clearDraftEntry(draftKey); + clearDraft(); return navigation.dispatch(e.data.action); } const workout = workouts[0]; if (!workout || (!workout.exercises.length && !workout.name.trim())) { - clearWorkouts(); + clearDraftEntry(draftKey); + clearDraft(); return navigation.dispatch(e.data.action); } @@ -100,7 +173,8 @@ export default function CreateWorkoutScreen() { text: "Discard", style: "destructive", onPress: () => { - clearWorkouts(); + clearDraftEntry(draftKey); + clearDraft(); navigation.dispatch(e.data.action); }, }, @@ -108,7 +182,7 @@ export default function CreateWorkoutScreen() { ); }); return unsubscribe; - }, [navigation, workouts, clearWorkouts]); + }, [navigation, workouts, clearDraft, clearDraftEntry, draftKey]); const handleAddExercise = () => { router.push("/(app)/(create-plan)/exercises?index=0"); @@ -136,6 +210,7 @@ export default function CreateWorkoutScreen() { } await queryClient.invalidateQueries({ queryKey: ["standaloneWorkouts"] }); + clearDraftEntry(draftKey); savedRef.current = true; router.back(); } catch (error: any) { diff --git a/app/(app)/(create-plan)/create.tsx b/app/(app)/(create-plan)/create.tsx index f8b6def3..9d2ff2d2 100644 --- a/app/(app)/(create-plan)/create.tsx +++ b/app/(app)/(create-plan)/create.tsx @@ -43,7 +43,7 @@ export default function CreatePlanScreen() { const router = useRouter(); const queryClient = useQueryClient(); const { planId } = useLocalSearchParams(); - const [dataLoaded, setDataLoaded] = useState(!planId); + const [dataLoaded, setDataLoaded] = useState(false); const [isSaving, setIsSaving] = useState(false); const scrollRef = useRef(null); const { @@ -51,23 +51,103 @@ export default function CreatePlanScreen() { planImageUrl, setPlanImageUrl, setWorkouts, - clearWorkouts, addWorkout, removeWorkout, reorderWorkouts, changeWorkoutName, planSchedule, setPlanSchedule, - clearPlanSchedule, + saveDraftEntry, + clearDraftEntry, + clearDraft, } = useWorkoutStore(); + + const currentPlanId = planId ? Number(planId) : null; + const draftKey = + currentPlanId !== null ? `plan:${currentPlanId}` : "plan:null"; const { data: settings } = useSettingsQuery(); const weeklyGoal = Number(settings?.weeklyGoal ?? 3); const { planName, setPlanName, planSaved, setPlanSaved, handleSavePlan } = useCreatePlan(); const { data: existingPlan } = usePlanQuery(planId ? Number(planId) : null); + // 'pending' until draft check resolves; then 'continue' or 'fresh' + const [draftDecision, setDraftDecision] = useState< + "pending" | "continue" | "fresh" + >("pending"); + + // Check for a persisted draft once on mount, after store hydrates + useEffect(() => { + const checkDraft = () => { + const { drafts } = useWorkoutStore.getState(); + const draft = drafts[draftKey]; + const hasDraft = + !!draft && + (draft.workouts.length > 0 || (draft.draftName ?? "").trim() !== ""); + + if (hasDraft) { + Alert.alert( + "Continue Editing?", + "You have unsaved changes from your last session. Would you like to continue?", + [ + { + text: "Discard Changes", + style: "destructive", + onPress: () => { + clearDraftEntry(draftKey); + clearDraft(); + setDraftDecision("fresh"); + if (!planId) setDataLoaded(true); + }, + }, + { + text: "Continue", + onPress: () => { + setWorkouts(draft.workouts); + if (draft.planImageUrl) setPlanImageUrl(draft.planImageUrl); + if (draft.planSchedule) setPlanSchedule(draft.planSchedule); + setPlanName(draft.draftName ?? ""); + setDraftDecision("continue"); + if (!planId) setDataLoaded(true); + }, + }, + ], + ); + } else { + setDraftDecision("fresh"); + if (!planId) setDataLoaded(true); + } + }; + + if (useWorkoutStore.persist.hasHydrated()) { + checkDraft(); + } else { + const unsub = useWorkoutStore.persist.onFinishHydration(checkDraft); + return unsub; + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-save draft on every change so it survives app close useEffect(() => { - if (existingPlan) { + if (draftDecision === "pending") return; + if (workouts.length === 0 && !planName.trim()) return; + saveDraftEntry(draftKey, { + workouts, + planImageUrl, + planSchedule, + draftName: planName, + }); + }, [workouts, planImageUrl, planSchedule, planName, draftDecision]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (draftDecision === "pending") return; + if (draftDecision === "continue") { + setDataLoaded(true); + return; + } + if (!existingPlan) return; + + if (draftDecision === "fresh") { setPlanName(existingPlan.name); setPlanImageUrl(existingPlan.image_url); @@ -85,7 +165,7 @@ export default function CreatePlanScreen() { if (entries.length > 0 && existingPlan.workouts) { const schedule: Record = {}; for (const entry of entries) { - const idx = existingPlan.workouts.findIndex( + const idx = existingPlan.workouts!.findIndex( (w) => w.id === entry.workout_id, ); if (idx !== -1) { @@ -102,18 +182,20 @@ export default function CreatePlanScreen() { planId: existingPlan.id, }); }); - // Non-critical: schedule defaults to empty }); - } - setDataLoaded(true); + setDataLoaded(true); - return () => { - cancelled = true; - }; + return () => { + cancelled = true; + }; + } } + + setDataLoaded(true); }, [ existingPlan, + draftDecision, setPlanName, setWorkouts, setPlanImageUrl, @@ -128,13 +210,15 @@ export default function CreatePlanScreen() { if (planSaved) { queryClient.invalidateQueries({ queryKey: ["plans"] }); queryClient.invalidateQueries({ queryKey: ["activePlan"] }); - clearWorkouts(); - clearPlanSchedule(); + clearDraftEntry(draftKey); + clearDraft(); setPlanSaved(false); return navigation.dispatch(e.data.action); } if (!workouts.length && !planName.trim()) { + clearDraftEntry(draftKey); + clearDraft(); return navigation.dispatch(e.data.action); } @@ -147,8 +231,8 @@ export default function CreatePlanScreen() { text: "Discard", style: "destructive", onPress: () => { - clearWorkouts(); - clearPlanSchedule(); + clearDraftEntry(draftKey); + clearDraft(); setPlanSaved(false); navigation.dispatch(e.data.action); }, @@ -162,8 +246,9 @@ export default function CreatePlanScreen() { navigation, workouts, planName, - clearWorkouts, - clearPlanSchedule, + clearDraft, + clearDraftEntry, + draftKey, planSaved, setPlanSaved, queryClient, @@ -218,6 +303,8 @@ export default function CreatePlanScreen() { existingPlan?.app_plan_id, ); + clearDraftEntry(draftKey); + await Promise.all([ queryClient.invalidateQueries({ queryKey: ["plans"] }), queryClient.invalidateQueries({ queryKey: ["activePlan"] }), diff --git a/app/(app)/(create-plan)/sets-overview.tsx b/app/(app)/(create-plan)/sets-overview.tsx index 23399ca4..92d42f2e 100644 --- a/app/(app)/(create-plan)/sets-overview.tsx +++ b/app/(app)/(create-plan)/sets-overview.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { StyleSheet, FlatList, TouchableOpacity, View } from "react-native"; -import { Button } from "react-native-paper"; +import { Button, Divider } from "react-native-paper"; import { ThemedText } from "@/components/ThemedText"; import { ThemedView } from "@/components/ThemedView"; import { router, Stack, useLocalSearchParams } from "expo-router"; @@ -18,7 +18,6 @@ export default function SetsOverviewScreen() { const { data: settings } = useSettingsQuery(); const totalSeconds = settings ? parseInt(settings?.defaultRestTime) : 0; - const weightUnit = settings?.weightUnit || "kg"; const distanceUnit = settings?.distanceUnit || "m"; const defaultRepsMin = 8; const defaultRepsMax = 12; @@ -60,6 +59,43 @@ export default function SetsOverviewScreen() { .removeSetFromExercise(Number(workoutIndex), Number(exerciseId), index); }; + const handleAddWarmupSet = () => { + let insertIndex = 0; + for (let i = sets.length - 1; i >= 0; i--) { + if (sets[i].isWarmup) { + insertIndex = i + 1; + break; + } + } + const lastWarmupSet = sets.filter((s) => s.isWarmup).at(-1); + const newSet: Set = lastWarmupSet + ? { + ...lastWarmupSet, + isWarmup: true, + isDropSet: false, + isToFailure: false, + } + : { + repsMin: defaultRepsMin, + repsMax: defaultRepsMax, + restMinutes: Math.floor(defaultTotalSeconds / 60), + restSeconds: defaultTotalSeconds % 60, + time: defaultTime, + distance: undefined, + isWarmup: true, + isDropSet: false, + isToFailure: false, + }; + useWorkoutStore + .getState() + .insertSetAtExercise( + Number(workoutIndex), + Number(exerciseId), + insertIndex, + newSet, + ); + }; + const renderSetItem = ({ item, index }: { item: Set; index: number }) => { const repRange = item.repsMin === item.repsMax @@ -74,40 +110,50 @@ export default function SetsOverviewScreen() { ? formatFromTotalSeconds(item.time) : formatFromTotalSeconds(defaultTime); + const previousSet = index > 0 ? sets[index - 1] : null; + const showGroupDivider = + previousSet !== null && + (previousSet.isWarmup ?? false) !== (item.isWarmup ?? false); + return ( - - handleEditSet(index)} - style={styles.setContent} + <> + {showGroupDivider && } + - - Set {index + 1} - - {item.isWarmup ? "Warm-up, " : ""} - {item.isDropSet ? "Drop set, " : ""} - {item.isToFailure ? "To failure, " : ""} - {trackingType === "time" - ? `${formattedTime}, ` - : trackingType === "distance" - ? item.distance !== undefined - ? `${item.distance} ${distanceUnit}, ` - : "" - : repRange !== undefined - ? `${repRange} Reps, ` - : ""} - {item.restMinutes}:{String(item.restSeconds).padStart(2, "0")}{" "} - Rest - - - - handleDeleteSet(index)} - style={styles.deleteIcon} - /> - + handleEditSet(index)} + style={styles.setContent} + > + + Set {index + 1} + + {item.isWarmup ? "Warm-up, " : ""} + {item.isDropSet ? "Drop set, " : ""} + {item.isToFailure ? "To failure, " : ""} + {trackingType === "time" + ? `${formattedTime}, ` + : trackingType === "distance" + ? item.distance !== undefined + ? `${item.distance} ${distanceUnit}, ` + : "" + : repRange !== undefined + ? `${repRange} Reps, ` + : ""} + {item.restMinutes}:{String(item.restSeconds).padStart(2, "0")}{" "} + Rest + + + + handleDeleteSet(index)} + style={styles.deleteIcon} + /> + + ); }; @@ -116,6 +162,16 @@ export default function SetsOverviewScreen() { ( + + ), }} /> {supersetPartner && ( @@ -136,12 +192,10 @@ export default function SetsOverviewScreen() { - {/* */} diff --git a/app/(app)/(workout)/workout-session.tsx b/app/(app)/(workout)/workout-session.tsx index 45419a18..6296a646 100644 --- a/app/(app)/(workout)/workout-session.tsx +++ b/app/(app)/(workout)/workout-session.tsx @@ -35,8 +35,8 @@ import Animated, { useAnimatedStyle, withTiming, withSpring, - runOnJS, } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; // Reanimated 4: Animated.View types don't include children in strict TS @@ -861,7 +861,7 @@ export default function WorkoutSessionScreen() { if (finished) { offsetX.value = 0; activeSlot.value = (activeSlot.value + 2) % 3; // -1 mod 3 - runOnJS(onSwipePrevCommit)(); + scheduleOnRN(onSwipePrevCommit); } }); }; @@ -875,7 +875,7 @@ export default function WorkoutSessionScreen() { if (finished) { offsetX.value = 0; activeSlot.value = (activeSlot.value + 1) % 3; - runOnJS(onSwipeNextCommit)(); + scheduleOnRN(onSwipeNextCommit); } }); }; @@ -974,8 +974,6 @@ export default function WorkoutSessionScreen() { if (isFirstInSuperset) { stopTimer(); void cancelRestNotifications(); - } else if (hasNextSet) { - void startRestTimer(currentSet.restMinutes, currentSet.restSeconds); } else { void cancelRestNotifications(); } @@ -984,7 +982,9 @@ export default function WorkoutSessionScreen() { setIndex: currentSetIndex, }; const durationNoAnim = currentSetStartedAt - ? Math.round((Date.now() - new Date(currentSetStartedAt).getTime()) / 1000) + ? Math.round( + (Date.now() - new Date(currentSetStartedAt).getTime()) / 1000, + ) : null; recordSetDuration(currentExerciseIndex, currentSetIndex, durationNoAnim); setCurrentSetStartedAt(null); @@ -1049,12 +1049,19 @@ export default function WorkoutSessionScreen() { setIndex: currentSetIndex, }; const durationAnim = currentSetStartedAt - ? Math.round((Date.now() - new Date(currentSetStartedAt).getTime()) / 1000) + ? Math.round( + (Date.now() - new Date(currentSetStartedAt).getTime()) / 1000, + ) : null; recordSetDuration(currentExerciseIndex, currentSetIndex, durationAnim); setCurrentSetStartedAt(null); nextSet(); - if (isFirstInSuperset || (hasNextSet && currentSet.restMinutes === 0 && currentSet.restSeconds === 0)) { + if ( + isFirstInSuperset || + (hasNextSet && + currentSet.restMinutes === 0 && + currentSet.restSeconds === 0) + ) { setCurrentSetStartedAt(new Date()); } @@ -1103,8 +1110,13 @@ export default function WorkoutSessionScreen() { (finished) => { "worklet"; isTransitioning.value = false; - if (finished) { - runOnJS(afterAnimation)(); + // Always clear the snapshot so the UI can never get permanently stuck + // if Reanimated fires finished=false (e.g. an animation edge case that + // would otherwise leave outgoingSnapshot set with the live panel + // hidden behind completionIncomingX = SCREEN_WIDTH). + scheduleOnRN(afterAnimation); + if (!finished) { + completionIncomingX.value = 0; } }, ); @@ -1155,7 +1167,7 @@ export default function WorkoutSessionScreen() { if (finished) { offsetX.value = 0; activeSlot.value = (activeSlot.value + 2) % 3; - runOnJS(onSwipePrevCommit)(); + scheduleOnRN(onSwipePrevCommit); } }, ); @@ -1173,7 +1185,7 @@ export default function WorkoutSessionScreen() { if (finished) { offsetX.value = 0; activeSlot.value = (activeSlot.value + 1) % 3; - runOnJS(onSwipeNextCommit)(); + scheduleOnRN(onSwipeNextCommit); } }, ); diff --git a/components/EditSetModal.tsx b/components/EditSetModal.tsx index 11ad49aa..ed2c5c53 100644 --- a/components/EditSetModal.tsx +++ b/components/EditSetModal.tsx @@ -207,9 +207,13 @@ export const EditSetModal: React.FC = ({ }; if (applyToAllSets) { - // Update all sets in the current exercise - exercise?.sets.forEach((_, sIndex) => { - updateSetInExercise(workoutIndex, exerciseId, sIndex, updatedSet); + exercise?.sets.forEach((s, sIndex) => { + if ( + (s.isWarmup ?? false) === isWarmup || + (setIndex !== null && sIndex === setIndex) + ) { + updateSetInExercise(workoutIndex, exerciseId, sIndex, updatedSet); + } }); } else if (setIndex !== null) { // Update only the selected set @@ -415,7 +419,7 @@ export const EditSetModal: React.FC = ({ }} /> - Apply to all sets + Apply to all {isWarmup ? "warmup" : "working"} sets diff --git a/components/StandaloneWorkoutListItem.tsx b/components/StandaloneWorkoutListItem.tsx index 9f1a24f3..192b488d 100644 --- a/components/StandaloneWorkoutListItem.tsx +++ b/components/StandaloneWorkoutListItem.tsx @@ -3,16 +3,24 @@ import { ThemedText } from "@/components/ThemedText"; import { Colors } from "@/constants/Colors"; import { Workout } from "@/store/workoutStore"; import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { useWorkoutDurationEstimate } from "@/hooks/useWorkoutDurationEstimate"; +import { formatDurationEstimate } from "@/utils/estimateWorkoutDuration"; interface StandaloneWorkoutListItemProps { workout: Workout; onPress: () => void; + countUnilateralDouble?: boolean; } export default function StandaloneWorkoutListItem({ workout, onPress, + countUnilateralDouble = false, }: StandaloneWorkoutListItemProps) { + const { estimate } = useWorkoutDurationEstimate( + workout.exercises, + countUnilateralDouble, + ); return ( @@ -31,6 +39,7 @@ export default function StandaloneWorkoutListItem({ {workout.exercises.length}{" "} {workout.exercises.length === 1 ? "exercise" : "exercises"} + {estimate ? ` · ~${formatDurationEstimate(estimate)}` : ""} (null); - const { estimate } = useWorkoutDurationEstimate(workout.exercises); + const { estimate } = useWorkoutDurationEstimate( + workout.exercises, + countUnilateralDouble, + ); const openMenu = (exerciseId: number) => setMenuVisible(exerciseId); const closeMenu = () => setMenuVisible(null); diff --git a/components/WorkoutHistoryCard.tsx b/components/WorkoutHistoryCard.tsx index 3be0cfd0..da91df29 100644 --- a/components/WorkoutHistoryCard.tsx +++ b/components/WorkoutHistoryCard.tsx @@ -16,7 +16,10 @@ export default function WorkoutHistoryCard({ excludeWarmup = false, }: WorkoutCardProps) { const setsCount = excludeWarmup - ? workout.exercises.reduce((t, e) => t + e.sets.filter((s) => !s.is_warmup).length, 0) + ? workout.exercises.reduce( + (t, e) => t + e.sets.filter((s) => !s.is_warmup).length, + 0, + ) : workout.total_sets_completed; return ( Duration: {Math.round(workout.duration / 60)} min - - Sets: {setsCount} - + Sets: {setsCount} ); @@ -43,15 +44,18 @@ export default function WorkoutHistoryCard({ const styles = StyleSheet.create({ cardContainer: { - marginRight: 16, + marginRight: 8, }, card: { width: 200, padding: 16, borderRadius: 6, backgroundColor: Colors.dark.cardBackground, - boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.2)", - elevation: 3, // For Android shadow + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 4, // For Android shadow }, workoutName: { fontSize: 16, diff --git a/components/stats/InsightsStrip.tsx b/components/stats/InsightsStrip.tsx index eec5ce4e..547c9c2e 100644 --- a/components/stats/InsightsStrip.tsx +++ b/components/stats/InsightsStrip.tsx @@ -33,7 +33,7 @@ export const InsightsStrip: React.FC = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const containerRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gainPillRef = useRef(null); + const pillRefs = useRef>({}); useEffect( () => () => { @@ -42,11 +42,12 @@ export const InsightsStrip: React.FC = ({ [], ); - const showTooltip = (text: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const showTooltip = (text: string, pillRef: any) => { if (dismissTimer.current) clearTimeout(dismissTimer.current); setTooltipHalfWidth(0); - gainPillRef.current?.measureInWindow( + pillRef?.measureInWindow( (pillX: number, _pillY: number, pillWidth: number) => { containerRef.current?.measureInWindow((containerX: number) => { setTooltipLeft(pillX - containerX + pillWidth / 2); @@ -70,7 +71,7 @@ export const InsightsStrip: React.FC = ({ pills.push({ label: "Best gain", value: biggestGainValue, - tooltip: biggestGainLabel, + tooltip: `${biggestGainLabel} 1RM`, }); } if (topBodyPart) { @@ -82,6 +83,49 @@ export const InsightsStrip: React.FC = ({ if (pills.length === 0) return null; + const renderPill = (pill: InsightPill) => ( + { + pillRefs.current[pill.label] = el; + } + : undefined + } + style={styles.pill} + onPress={ + pill.tooltip + ? () => showTooltip(pill.tooltip!, pillRefs.current[pill.label]) + : undefined + } + > + + {pill.label} + {pill.tooltip ? ( + {"ⓘ"} + ) : null} + + {pill.value} + + ); + + const useGrid = pills.length >= 4; + + const rows: InsightPill[][] = []; + if (useGrid) { + for (let i = 0; i < pills.length; i += 2) { + rows.push(pills.slice(i, i + 2)); + } + } + + const onLayout = ({ + nativeEvent, + }: { + nativeEvent: { layout: { height: number } }; + }) => setStripHeight(nativeEvent.layout.height); + return ( {tooltipText ? ( @@ -106,35 +150,24 @@ export const InsightsStrip: React.FC = ({ {tooltipText} ) : null} - setStripHeight(nativeEvent.layout.height)} - > - {pills.map((pill, i) => ( - showTooltip(pill.tooltip!) : undefined - } - > - - {pill.label} - {pill.tooltip ? ( - {"ⓘ"} - ) : null} + {useGrid ? ( + + {rows.map((row, ri) => ( + + {row.map((pill) => renderPill(pill))} - {pill.value} - - ))} - + ))} + + ) : ( + + {pills.map((pill) => renderPill(pill))} + + )} ); }; @@ -144,12 +177,20 @@ const styles = StyleSheet.create({ paddingVertical: 4, gap: 10, }, + grid: { + gap: 8, + paddingVertical: 4, + }, + row: { + flexDirection: "row", + gap: 8, + }, pill: { + flex: 1, backgroundColor: Colors.dark.cardBackground, borderRadius: 6, paddingVertical: 10, paddingHorizontal: 14, - minWidth: 110, alignItems: "center", borderWidth: 1, borderColor: Colors.dark.tint + "40", diff --git a/components/stats/StatsTile.tsx b/components/stats/StatsTile.tsx index 8f3bcc99..f25dca0a 100644 --- a/components/stats/StatsTile.tsx +++ b/components/stats/StatsTile.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { View, StyleSheet } from "react-native"; +import { StyleSheet } from "react-native"; import { Card } from "react-native-paper"; import { ThemedText } from "@/components/ThemedText"; import { Colors } from "@/constants/Colors"; @@ -9,9 +9,14 @@ interface StatsTileProps { value: string; delta?: number | null; deltaLabel?: string; + deltaText?: string; } -const DeltaText: React.FC<{ delta: number; label?: string }> = ({ delta, label }) => { +const DeltaText: React.FC<{ + delta: number; + label?: string; + deltaText?: string; +}> = ({ delta, label, deltaText }) => { const isPositive = delta > 0; const isNeutral = delta === 0; const color = isNeutral @@ -21,7 +26,8 @@ const DeltaText: React.FC<{ delta: number; label?: string }> = ({ delta, label } : Colors.dark.highlight; const prefix = isNeutral ? "─" : isPositive ? "▲" : "▼"; const absVal = Math.abs(delta); - const text = `${prefix} ${label ? `${absVal}${label}` : absVal}`; + const formatted = deltaText ?? (label ? `${absVal}${label}` : absVal); + const text = `${prefix} ${formatted}`; return {text}; }; @@ -31,13 +37,14 @@ export const StatsTile: React.FC = ({ value, delta, deltaLabel, + deltaText, }) => { return ( {value} {label} {delta != null && ( - + )} ); @@ -45,7 +52,8 @@ export const StatsTile: React.FC = ({ const styles = StyleSheet.create({ card: { - width: "48%", + flexBasis: "47%", + flexGrow: 1, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 6, diff --git a/components/stats/WorkoutHistorySection.tsx b/components/stats/WorkoutHistorySection.tsx index e1bcd8be..bb51085b 100644 --- a/components/stats/WorkoutHistorySection.tsx +++ b/components/stats/WorkoutHistorySection.tsx @@ -17,7 +17,9 @@ export const WorkoutHistorySection: React.FC = ({ }) => { if (completedWorkouts.length === 0) { return ( - No workouts completed yet. Start your first workout! + + No workouts completed yet. Start your first workout! + ); } @@ -26,7 +28,11 @@ export const WorkoutHistorySection: React.FC = ({ ( - onWorkoutPress(item.id)} excludeWarmup={excludeWarmup} /> + onWorkoutPress(item.id)} + excludeWarmup={excludeWarmup} + /> )} keyExtractor={(item: CompletedWorkout) => item.id.toString()} horizontal diff --git a/hooks/useCreatePlan.ts b/hooks/useCreatePlan.ts index c06dc8db..8af22895 100644 --- a/hooks/useCreatePlan.ts +++ b/hooks/useCreatePlan.ts @@ -18,14 +18,8 @@ export const useCreatePlan = (existingPlan?: Plan) => { const [planName, setPlanName] = useState(""); const [isError, setIsError] = useState(false); - const { - workouts, - clearWorkouts, - planImageUrl, - setPlanImageUrl, - planSchedule, - clearPlanSchedule, - } = useWorkoutStore(); + const { workouts, planImageUrl, setPlanImageUrl, planSchedule, clearDraft } = + useWorkoutStore(); useEffect(() => { if (existingPlan) { @@ -120,8 +114,7 @@ export const useCreatePlan = (existingPlan?: Plan) => { localError = true; } finally { if (localPlanSaved && !localError) { - clearWorkouts(); - clearPlanSchedule(); + clearDraft(); queryClient.invalidateQueries({ queryKey: ["plans"] }); queryClient.invalidateQueries({ queryKey: ["activePlan"] }); } diff --git a/hooks/useWorkoutDurationEstimate.ts b/hooks/useWorkoutDurationEstimate.ts index fd4886db..fbb49e34 100644 --- a/hooks/useWorkoutDurationEstimate.ts +++ b/hooks/useWorkoutDurationEstimate.ts @@ -8,7 +8,10 @@ import { import type { UserExercise } from "@/store/workoutStore"; import Bugsnag from "@bugsnag/expo"; -export function useWorkoutDurationEstimate(exercises: UserExercise[]): { +export function useWorkoutDurationEstimate( + exercises: UserExercise[], + countUnilateralDouble = false, +): { estimate: DurationEstimate | null; isLoading: boolean; } { @@ -33,8 +36,12 @@ export function useWorkoutDurationEstimate(exercises: UserExercise[]): { const estimate = useMemo(() => { if (exercises.length === 0) return null; - return computeWorkoutDurationEstimate(exercises, data ?? {}); - }, [exercises, data]); + return computeWorkoutDurationEstimate( + exercises, + data ?? {}, + countUnilateralDouble, + ); + }, [exercises, data, countUnilateralDouble]); return { estimate, isLoading }; } diff --git a/store/activeWorkoutStore.ts b/store/activeWorkoutStore.ts index f53cd0be..3e2b466e 100644 --- a/store/activeWorkoutStore.ts +++ b/store/activeWorkoutStore.ts @@ -73,7 +73,9 @@ interface ActiveWorkoutStore { timerRunning: boolean; timerExpiry: Date | null; currentSetStartedAt: Date | null; - setDurations: { [exerciseIndex: number]: { [setIndex: number]: number | null } }; + setDurations: { + [exerciseIndex: number]: { [setIndex: number]: number | null }; + }; appendedExerciseIndices: number[]; appendExercise: (exercise: UserExercise) => void; setWorkout: ( @@ -118,7 +120,11 @@ interface ActiveWorkoutStore { startTimer: (expiry: Date) => void; stopTimer: () => void; setCurrentSetStartedAt: (date: Date | null) => void; - recordSetDuration: (exerciseIndex: number, setIndex: number, duration: number | null) => void; + recordSetDuration: ( + exerciseIndex: number, + setIndex: number, + duration: number | null, + ) => void; clearPersistedStore: () => void; resumeWorkout: () => void; isWorkoutInProgress: () => boolean; @@ -384,18 +390,32 @@ const useActiveWorkoutStore = create()( while (nextExerciseIndex < workout.exercises.length) { const totalSets = workout.exercises[nextExerciseIndex].sets.length; - const isComplete = - updatedCompletedSets[nextExerciseIndex] && - Object.keys(updatedCompletedSets[nextExerciseIndex]) - .length === totalSets; + const completedCount = Object.values( + updatedCompletedSets[nextExerciseIndex] || {}, + ).filter((v) => v === true).length; + const isComplete = completedCount === totalSets; if (!isComplete) break; nextExerciseIndex++; } if (nextExerciseIndex < workout.exercises.length) { + const nextExTotalSets = + workout.exercises[nextExerciseIndex].sets.length; + const nextExCompleted = + updatedCompletedSets[nextExerciseIndex] || {}; + let firstUncompleted = 0; + for (let s = 0; s < nextExTotalSets; s++) { + if (!nextExCompleted[s]) { + firstUncompleted = s; + break; + } + } return { currentExerciseIndex: nextExerciseIndex, - currentSetIndices: updatedSetIndices, + currentSetIndices: { + ...updatedSetIndices, + [nextExerciseIndex]: firstUncompleted, + }, completedSets: updatedCompletedSets, weightAndReps: updatedWeightAndReps, }; @@ -507,15 +527,27 @@ const useActiveWorkoutStore = create()( let nextExerciseIndex = currentExerciseIndex + 1; while (nextExerciseIndex < workout.exercises.length) { const totalSets = workout.exercises[nextExerciseIndex].sets.length; - const isExerciseCompleted = - updatedCompletedSets[nextExerciseIndex] && - Object.keys(updatedCompletedSets[nextExerciseIndex]).length === - totalSets; + const completedCount = Object.values( + updatedCompletedSets[nextExerciseIndex] || {}, + ).filter((v) => v === true).length; + const isExerciseCompleted = completedCount === totalSets; if (!isExerciseCompleted) { + const nextExCompleted = + updatedCompletedSets[nextExerciseIndex] || {}; + let firstUncompleted = 0; + for (let s = 0; s < totalSets; s++) { + if (!nextExCompleted[s]) { + firstUncompleted = s; + break; + } + } return { currentExerciseIndex: nextExerciseIndex, - currentSetIndices: updatedSetIndices, + currentSetIndices: { + ...updatedSetIndices, + [nextExerciseIndex]: firstUncompleted, + }, completedSets: updatedCompletedSets, }; } diff --git a/store/workoutStore.ts b/store/workoutStore.ts index d95e3f59..63ddb8f5 100644 --- a/store/workoutStore.ts +++ b/store/workoutStore.ts @@ -1,7 +1,12 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; import { Exercise } from "@/utils/database"; import * as Crypto from "expo-crypto"; +export const DEFAULT_PLAN_IMAGE_URL = + "https://images.unsplash.com/photo-1581009146145-b5ef050c2e1e?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + export interface Set { repsMin: number | undefined; repsMax: number | undefined; @@ -28,6 +33,13 @@ export interface Workout { exercises: UserExercise[]; } +export interface DraftEntry { + workouts: Workout[]; + planImageUrl?: string; + planSchedule?: Record; + draftName?: string; +} + interface WorkoutStore { workouts: Workout[]; newExerciseId: number | null; @@ -57,6 +69,12 @@ interface WorkoutStore { exerciseId: number, set: Set, ) => void; + insertSetAtExercise: ( + workoutIndex: number, + exerciseId: number, + atIndex: number, + set: Set, + ) => void; updateSetInExercise: ( workoutIndex: number, exerciseId: number, @@ -79,346 +97,443 @@ interface WorkoutStore { setPlanSchedule: (schedule: Record) => void; clearPlanSchedule: () => void; syncScheduleOnRemoveWorkout: (removedIndex: number) => void; + // Draft persistence — one slot per context:id pair + drafts: Record; + saveDraftEntry: (key: string, entry: DraftEntry) => void; + clearDraftEntry: (key: string) => void; + clearDraft: () => void; } -const useWorkoutStore = create((set) => ({ - workouts: [], - newExerciseId: null, - setNewExerciseId: (id) => set((state) => ({ ...state, newExerciseId: id })), - planImageUrl: - "https://images.unsplash.com/photo-1581009146145-b5ef050c2e1e?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", // Default image URL - setPlanImageUrl: (url) => set({ planImageUrl: url }), - setWorkouts: (workouts) => set({ workouts }), - clearWorkouts: () => set({ workouts: [] }), - addWorkout: (workout) => - set((state) => ({ ...state, workouts: [...state.workouts, workout] })), - removeWorkout: (index) => - set((state) => { - const workouts = state.workouts.filter((_, i) => i !== index); - const planSchedule: Record = {}; - for (const [day, idx] of Object.entries(state.planSchedule)) { - const workoutIdx = Number(idx); - if (workoutIdx === index) continue; - planSchedule[Number(day)] = - workoutIdx > index ? workoutIdx - 1 : workoutIdx; - } - return { workouts, planSchedule }; - }), - reorderWorkouts: (fromIndex, toIndex) => - set((state) => { - const { length } = state.workouts; - if ( - fromIndex === toIndex || - fromIndex < 0 || - fromIndex >= length || - toIndex < 0 || - toIndex >= length - ) { - return state; - } - const updated = [...state.workouts]; - const [moved] = updated.splice(fromIndex, 1); - updated.splice(toIndex, 0, moved); +const useWorkoutStore = create()( + persist( + (set) => ({ + workouts: [], + newExerciseId: null, + setNewExerciseId: (id) => + set((state) => ({ ...state, newExerciseId: id })), + planImageUrl: DEFAULT_PLAN_IMAGE_URL, + setPlanImageUrl: (url) => set({ planImageUrl: url }), + setWorkouts: (workouts) => set({ workouts }), + clearWorkouts: () => set({ workouts: [] }), + addWorkout: (workout) => + set((state) => ({ ...state, workouts: [...state.workouts, workout] })), + removeWorkout: (index) => + set((state) => { + const workouts = state.workouts.filter((_, i) => i !== index); + const planSchedule: Record = {}; + for (const [day, idx] of Object.entries(state.planSchedule)) { + const workoutIdx = Number(idx); + if (workoutIdx === index) continue; + planSchedule[Number(day)] = + workoutIdx > index ? workoutIdx - 1 : workoutIdx; + } + return { workouts, planSchedule }; + }), + reorderWorkouts: (fromIndex, toIndex) => + set((state) => { + const { length } = state.workouts; + if ( + fromIndex === toIndex || + fromIndex < 0 || + fromIndex >= length || + toIndex < 0 || + toIndex >= length + ) { + return state; + } + const updated = [...state.workouts]; + const [moved] = updated.splice(fromIndex, 1); + updated.splice(toIndex, 0, moved); - // Remap schedule day → index entries to reflect the new workout order - const remappedSchedule: Record = {}; - for (const [day, idx] of Object.entries(state.planSchedule)) { - const dayKey = Number(day); - const workoutIdx = Number(idx); - if (workoutIdx === fromIndex) { - remappedSchedule[dayKey] = toIndex; - } else if ( - fromIndex < toIndex && - workoutIdx > fromIndex && - workoutIdx <= toIndex - ) { - remappedSchedule[dayKey] = workoutIdx - 1; - } else if ( - fromIndex > toIndex && - workoutIdx >= toIndex && - workoutIdx < fromIndex - ) { - remappedSchedule[dayKey] = workoutIdx + 1; - } else { - remappedSchedule[dayKey] = workoutIdx; - } - } + // Remap schedule day → index entries to reflect the new workout order + const remappedSchedule: Record = {}; + for (const [day, idx] of Object.entries(state.planSchedule)) { + const dayKey = Number(day); + const workoutIdx = Number(idx); + if (workoutIdx === fromIndex) { + remappedSchedule[dayKey] = toIndex; + } else if ( + fromIndex < toIndex && + workoutIdx > fromIndex && + workoutIdx <= toIndex + ) { + remappedSchedule[dayKey] = workoutIdx - 1; + } else if ( + fromIndex > toIndex && + workoutIdx >= toIndex && + workoutIdx < fromIndex + ) { + remappedSchedule[dayKey] = workoutIdx + 1; + } else { + remappedSchedule[dayKey] = workoutIdx; + } + } - return { ...state, workouts: updated, planSchedule: remappedSchedule }; - }), - changeWorkoutName: (index, name) => - set((state) => ({ - ...state, - workouts: state.workouts.map((w, i) => { - if (i === index) { - return { ...w, name }; - } - return w; - }), - })), - addExercise: (index, exercise) => { - set((state) => ({ - ...state, - workouts: state.workouts.map((w, i) => { - if (i === index) { - return { ...w, exercises: [...w.exercises, exercise] }; - } - return w; - }), - })); - }, - removeExercise: (index, exerciseId) => - set((state) => ({ - ...state, - workouts: state.workouts.map((w, i) => { - if (i === index) { return { - ...w, - exercises: w.exercises.filter((e) => e.exercise_id !== exerciseId), + ...state, + workouts: updated, + planSchedule: remappedSchedule, }; - } - return w; - }), - })), - replaceExercise: ( - workoutIndex, - exerciseIndex, - newExercise, - defaultSets, - defaultTimeSets, - ) => { - set((state) => { - const updatedWorkouts = [...state.workouts]; - const workout = updatedWorkouts[workoutIndex]; + }), + changeWorkoutName: (index, name) => + set((state) => ({ + ...state, + workouts: state.workouts.map((w, i) => { + if (i === index) { + return { ...w, name }; + } + return w; + }), + })), + addExercise: (index, exercise) => { + set((state) => ({ + ...state, + workouts: state.workouts.map((w, i) => { + if (i === index) { + return { ...w, exercises: [...w.exercises, exercise] }; + } + return w; + }), + })); + }, + removeExercise: (index, exerciseId) => + set((state) => ({ + ...state, + workouts: state.workouts.map((w, i) => { + if (i === index) { + return { + ...w, + exercises: w.exercises.filter( + (e) => e.exercise_id !== exerciseId, + ), + }; + } + return w; + }), + })), + replaceExercise: ( + workoutIndex, + exerciseIndex, + newExercise, + defaultSets, + defaultTimeSets, + ) => { + set((state) => { + const updatedWorkouts = [...state.workouts]; + const workout = updatedWorkouts[workoutIndex]; - if (!workout || !workout.exercises[exerciseIndex]) { - return { workouts: updatedWorkouts }; - } + if (!workout || !workout.exercises[exerciseIndex]) { + return { workouts: updatedWorkouts }; + } - const oldExercise = workout.exercises[exerciseIndex]; - let oldTrackingType = oldExercise.tracking_type; - let newTrackingType = newExercise.tracking_type; - if (oldTrackingType === "" || oldTrackingType === null) { - oldTrackingType = "weight"; - } - if (newTrackingType === "" || newTrackingType === null) { - newTrackingType = "weight"; - } + const oldExercise = workout.exercises[exerciseIndex]; + let oldTrackingType = oldExercise.tracking_type; + let newTrackingType = newExercise.tracking_type; + if (oldTrackingType === "" || oldTrackingType === null) { + oldTrackingType = "weight"; + } + if (newTrackingType === "" || newTrackingType === null) { + newTrackingType = "weight"; + } - if (oldTrackingType === newTrackingType) { - // Same tracking type: carry over old sets - newExercise.sets = oldExercise.sets; - } else if (newTrackingType === "time") { - newExercise.sets = defaultTimeSets; - } else if ( - (newTrackingType === "assisted" && oldTrackingType === "weight") || - (newTrackingType === "weight" && oldTrackingType === "assisted") - ) { - newExercise.sets = oldExercise.sets; - } else { - newExercise.sets = defaultSets; - } + if (oldTrackingType === newTrackingType) { + // Same tracking type: carry over old sets + newExercise.sets = oldExercise.sets; + } else if (newTrackingType === "time") { + newExercise.sets = defaultTimeSets; + } else if ( + (newTrackingType === "assisted" && oldTrackingType === "weight") || + (newTrackingType === "weight" && oldTrackingType === "assisted") + ) { + newExercise.sets = oldExercise.sets; + } else { + newExercise.sets = defaultSets; + } - const updatedExercises = [...workout.exercises]; - updatedExercises[exerciseIndex] = newExercise; - updatedWorkouts[workoutIndex] = { - ...workout, - exercises: updatedExercises, - }; + const updatedExercises = [...workout.exercises]; + updatedExercises[exerciseIndex] = newExercise; + updatedWorkouts[workoutIndex] = { + ...workout, + exercises: updatedExercises, + }; - return { workouts: updatedWorkouts }; - }); - }, - addSetToExercise: (workoutIndex: number, exerciseId: number, newSet: Set) => { - set((state) => { - const workout = state.workouts[workoutIndex]; - if (!workout) return state; + return { workouts: updatedWorkouts }; + }); + }, + addSetToExercise: ( + workoutIndex: number, + exerciseId: number, + newSet: Set, + ) => { + set((state) => { + const workout = state.workouts[workoutIndex]; + if (!workout) return state; - const targetExercise = workout.exercises.find( - (e) => e.exercise_id === exerciseId, - ); - const supersetGroupId = targetExercise?.supersetGroupId; - const partner = supersetGroupId - ? workout.exercises.find( - (e) => - e.exercise_id !== exerciseId && - e.supersetGroupId === supersetGroupId, - ) - : null; + const targetExercise = workout.exercises.find( + (e) => e.exercise_id === exerciseId, + ); + const supersetGroupId = targetExercise?.supersetGroupId; + const partner = supersetGroupId + ? workout.exercises.find( + (e) => + e.exercise_id !== exerciseId && + e.supersetGroupId === supersetGroupId, + ) + : null; - const workouts = state.workouts.map((w, wIndex) => { - if (wIndex !== workoutIndex) return w; - const exercises = w.exercises.map((exercise) => { - if (exercise.exercise_id === exerciseId) { - return { ...exercise, sets: [...exercise.sets, newSet] }; - } - // Mirror to superset partner: copy partner's last set as template - if (partner && exercise.exercise_id === partner.exercise_id) { - const lastSet = exercise.sets[exercise.sets.length - 1] ?? newSet; - return { ...exercise, sets: [...exercise.sets, { ...lastSet }] }; - } - return exercise; + const workouts = state.workouts.map((w, wIndex) => { + if (wIndex !== workoutIndex) return w; + const exercises = w.exercises.map((exercise) => { + if (exercise.exercise_id === exerciseId) { + return { ...exercise, sets: [...exercise.sets, newSet] }; + } + // Mirror to superset partner: copy partner's last set as template + if (partner && exercise.exercise_id === partner.exercise_id) { + const lastSet = + exercise.sets[exercise.sets.length - 1] ?? newSet; + return { + ...exercise, + sets: [...exercise.sets, { ...lastSet }], + }; + } + return exercise; + }); + return { ...w, exercises }; + }); + return { workouts }; }); - return { ...w, exercises }; - }); - return { workouts }; - }); - }, - updateSetInExercise: ( - workoutIndex: number, - exerciseId: number, - setIndex: number, - updatedSet: Set, - ) => { - set((state) => { - const workouts = state.workouts.map((workout, wIndex) => { - if (wIndex !== workoutIndex) { - return workout; - } - const exercises = workout.exercises.map((exercise) => { - if (exercise.exercise_id !== exerciseId) { - return exercise; - } - const sets = exercise.sets.map((set, sIndex) => { - if (sIndex !== setIndex) { - return set; + }, + insertSetAtExercise: (workoutIndex, exerciseId, atIndex, newSet) => { + set((state) => { + const workout = state.workouts[workoutIndex]; + if (!workout) return state; + const targetExercise = workout.exercises.find( + (e) => e.exercise_id === exerciseId, + ); + const partner = targetExercise?.supersetGroupId + ? workout.exercises.find( + (e) => + e.exercise_id !== exerciseId && + e.supersetGroupId === targetExercise.supersetGroupId, + ) + : null; + + const workouts = state.workouts.map((w, wIndex) => { + if (wIndex !== workoutIndex) return w; + const exercises = w.exercises.map((exercise) => { + if (exercise.exercise_id === exerciseId) { + const sets = [...exercise.sets]; + sets.splice(atIndex, 0, newSet); + return { ...exercise, sets }; + } + if (partner && exercise.exercise_id === partner.exercise_id) { + const lastSet = + exercise.sets[exercise.sets.length - 1] ?? newSet; + const clone = { + ...lastSet, + isWarmup: newSet.isWarmup, + isDropSet: newSet.isDropSet, + isToFailure: newSet.isToFailure, + }; + const sets = [...exercise.sets]; + sets.splice(atIndex, 0, clone); + return { ...exercise, sets }; + } + return exercise; + }); + return { ...w, exercises }; + }); + return { workouts }; + }); + }, + updateSetInExercise: ( + workoutIndex: number, + exerciseId: number, + setIndex: number, + updatedSet: Set, + ) => { + set((state) => { + const workouts = state.workouts.map((workout, wIndex) => { + if (wIndex !== workoutIndex) { + return workout; } - return updatedSet; + const exercises = workout.exercises.map((exercise) => { + if (exercise.exercise_id !== exerciseId) { + return exercise; + } + const sets = exercise.sets.map((set, sIndex) => { + if (sIndex !== setIndex) { + return set; + } + return updatedSet; + }); + return { + ...exercise, + sets, + }; + }); + return { + ...workout, + exercises, + }; }); - return { - ...exercise, - sets, - }; + return { workouts }; }); - return { - ...workout, - exercises, - }; - }); - return { workouts }; - }); - }, - removeSetFromExercise: ( - workoutIndex: number, - exerciseId: number, - setIndex: number, - ) => { - set((state) => { - const workout = state.workouts[workoutIndex]; - if (!workout) return state; + }, + removeSetFromExercise: ( + workoutIndex: number, + exerciseId: number, + setIndex: number, + ) => { + set((state) => { + const workout = state.workouts[workoutIndex]; + if (!workout) return state; - const targetExercise = workout.exercises.find( - (e) => e.exercise_id === exerciseId, - ); - const supersetGroupId = targetExercise?.supersetGroupId; - const partner = supersetGroupId - ? workout.exercises.find( - (e) => - e.exercise_id !== exerciseId && - e.supersetGroupId === supersetGroupId, - ) - : null; + const targetExercise = workout.exercises.find( + (e) => e.exercise_id === exerciseId, + ); + const supersetGroupId = targetExercise?.supersetGroupId; + const partner = supersetGroupId + ? workout.exercises.find( + (e) => + e.exercise_id !== exerciseId && + e.supersetGroupId === supersetGroupId, + ) + : null; - const workouts = state.workouts.map((w, wIndex) => { - if (wIndex !== workoutIndex) { - return w; - } - const exercises = w.exercises.map((exercise) => { - if (exercise.exercise_id === exerciseId) { - // Must keep at least 1 set - if (exercise.sets.length <= 1) return exercise; - return { - ...exercise, - sets: exercise.sets.filter((_, sIndex) => sIndex !== setIndex), - }; - } - // Mirror removal to superset partner - if (partner && exercise.exercise_id === partner.exercise_id) { - if (exercise.sets.length <= 1) return exercise; + const workouts = state.workouts.map((w, wIndex) => { + if (wIndex !== workoutIndex) { + return w; + } + const exercises = w.exercises.map((exercise) => { + if (exercise.exercise_id === exerciseId) { + // Must keep at least 1 set + if (exercise.sets.length <= 1) return exercise; + return { + ...exercise, + sets: exercise.sets.filter( + (_, sIndex) => sIndex !== setIndex, + ), + }; + } + // Mirror removal to superset partner + if (partner && exercise.exercise_id === partner.exercise_id) { + if (exercise.sets.length <= 1) return exercise; + return { + ...exercise, + sets: exercise.sets.filter( + (_, sIndex) => sIndex !== setIndex, + ), + }; + } + return exercise; + }); return { - ...exercise, - sets: exercise.sets.filter((_, sIndex) => sIndex !== setIndex), + ...w, + exercises, }; - } - return exercise; - }); - return { - ...w, - exercises, - }; - }); - return { workouts }; - }); - }, - createSuperset: (workoutIndex, exerciseIndex, newExercise) => - set((state) => { - const groupId = Crypto.randomUUID(); - const updatedWorkouts = state.workouts.map((w, i) => { - if (i !== workoutIndex) return w; - const exercises = [...w.exercises]; - exercises[exerciseIndex] = { - ...exercises[exerciseIndex], - supersetGroupId: groupId, - }; - exercises.splice(exerciseIndex + 1, 0, { - ...newExercise, - supersetGroupId: groupId, + }); + return { workouts }; }); - return { ...w, exercises }; - }); - return { workouts: updatedWorkouts }; - }), + }, + createSuperset: (workoutIndex, exerciseIndex, newExercise) => + set((state) => { + const groupId = Crypto.randomUUID(); + const updatedWorkouts = state.workouts.map((w, i) => { + if (i !== workoutIndex) return w; + const exercises = [...w.exercises]; + exercises[exerciseIndex] = { + ...exercises[exerciseIndex], + supersetGroupId: groupId, + }; + exercises.splice(exerciseIndex + 1, 0, { + ...newExercise, + supersetGroupId: groupId, + }); + return { ...w, exercises }; + }); + return { workouts: updatedWorkouts }; + }), - removeFromSuperset: (workoutIndex, exerciseIndex) => - set((state) => { - const updatedWorkouts = state.workouts.map((w, i) => { - if (i !== workoutIndex) return w; - const groupId = w.exercises[exerciseIndex]?.supersetGroupId; - if (!groupId) return w; - const exercises = w.exercises.map((e) => { - if (e.supersetGroupId !== groupId) return e; - const { supersetGroupId: _, ...rest } = e; - return rest as UserExercise; - }); - return { ...w, exercises }; - }); - return { workouts: updatedWorkouts }; - }), + removeFromSuperset: (workoutIndex, exerciseIndex) => + set((state) => { + const updatedWorkouts = state.workouts.map((w, i) => { + if (i !== workoutIndex) return w; + const groupId = w.exercises[exerciseIndex]?.supersetGroupId; + if (!groupId) return w; + const exercises = w.exercises.map((e) => { + if (e.supersetGroupId !== groupId) return e; + const { supersetGroupId: _, ...rest } = e; + return rest as UserExercise; + }); + return { ...w, exercises }; + }); + return { workouts: updatedWorkouts }; + }), - // Schedule actions - planSchedule: {}, - setPlanSchedule: (schedule) => - set((state) => { - const numWorkouts = state.workouts.length; - const validated: Record = {}; - for (const [day, idx] of Object.entries(schedule)) { - const dayKey = Number(day); - const workoutIdx = Number(idx); - if ( - Number.isInteger(dayKey) && - dayKey >= 0 && - dayKey <= 6 && - Number.isInteger(workoutIdx) && - workoutIdx >= 0 && - workoutIdx < numWorkouts - ) { - validated[dayKey] = workoutIdx; - } - } - return { planSchedule: validated }; - }), - clearPlanSchedule: () => set({ planSchedule: {} }), - syncScheduleOnRemoveWorkout: (removedIndex) => - set((state) => { - const updated: Record = {}; - for (const [day, idx] of Object.entries(state.planSchedule)) { - const workoutIdx = Number(idx); - if (workoutIdx === removedIndex) continue; // drop this day - updated[Number(day)] = - workoutIdx > removedIndex ? workoutIdx - 1 : workoutIdx; - } - return { planSchedule: updated }; + // Schedule actions + planSchedule: {}, + setPlanSchedule: (schedule) => + set((state) => { + const numWorkouts = state.workouts.length; + const validated: Record = {}; + for (const [day, idx] of Object.entries(schedule)) { + const dayKey = Number(day); + const workoutIdx = Number(idx); + if ( + Number.isInteger(dayKey) && + dayKey >= 0 && + dayKey <= 6 && + Number.isInteger(workoutIdx) && + workoutIdx >= 0 && + workoutIdx < numWorkouts + ) { + validated[dayKey] = workoutIdx; + } + } + return { planSchedule: validated }; + }), + clearPlanSchedule: () => set({ planSchedule: {} }), + syncScheduleOnRemoveWorkout: (removedIndex) => + set((state) => { + const updated: Record = {}; + for (const [day, idx] of Object.entries(state.planSchedule)) { + const workoutIdx = Number(idx); + if (workoutIdx === removedIndex) continue; // drop this day + updated[Number(day)] = + workoutIdx > removedIndex ? workoutIdx - 1 : workoutIdx; + } + return { planSchedule: updated }; + }), + + drafts: {}, + saveDraftEntry: (key, entry) => + set((state) => ({ drafts: { ...state.drafts, [key]: entry } })), + clearDraftEntry: (key) => + set((state) => { + const { [key]: _removed, ...rest } = state.drafts; + return { drafts: rest }; + }), + clearDraft: () => + set({ + workouts: [], + planImageUrl: DEFAULT_PLAN_IMAGE_URL, + planSchedule: {}, + }), }), -})); + { + name: "workout-draft-store", + version: 1, + migrate: (persistedState, version) => { + if (version < 1) return { drafts: {} }; + return persistedState as { drafts: Record }; + }, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + drafts: state.drafts, + }), + }, + ), +); export { useWorkoutStore }; diff --git a/utils/__tests__/loadPremadePlans.test.ts b/utils/__tests__/loadPremadePlans.test.ts index 3cf42524..92e39239 100644 --- a/utils/__tests__/loadPremadePlans.test.ts +++ b/utils/__tests__/loadPremadePlans.test.ts @@ -13,6 +13,58 @@ jest.mock( () => [{ app_plan_id: 2, name: "4 Day Split", workouts: [] }], { virtual: true }, ); +// 5-day-bro-split has one exercise so we can test ID translation + ensureAppExercisesExist +jest.mock( + "@/assets/data/5-day-bro-split.json", + () => [ + { + app_plan_id: 3, + name: "5 Day Bro Split", + workouts: [ + { + id: null, + plan_id: null, + name: "Day 1 – Chest", + is_deleted: false, + exercises: [ + { + id: null, + workout_id: null, + exercise_id: 11, + exercise_name: "Barbell Bench Press", + sets: [ + { repsMin: 8, repsMax: 10, restMinutes: 2, restSeconds: 0 }, + ], + exercise_order: 1, + is_deleted: false, + }, + ], + }, + ], + }, + ], + { virtual: true }, +); +jest.mock( + "@/assets/data/5-day-ppl.json", + () => [{ app_plan_id: 7, name: "5 Day PPL", workouts: [] }], + { virtual: true }, +); +jest.mock( + "@/assets/data/6-day-split.json", + () => [{ app_plan_id: 4, name: "6 Day Split", workouts: [] }], + { virtual: true }, +); +jest.mock( + "@/assets/data/body-weight.json", + () => [{ app_plan_id: 5, name: "Bodyweight (3 Days)", workouts: [] }], + { virtual: true }, +); +jest.mock( + "@/assets/data/dumbbell-only.json", + () => [{ app_plan_id: 6, name: "Dumbbell Only (4 Days)", workouts: [] }], + { virtual: true }, +); jest.mock("@bugsnag/expo"); const mockRunAsync = jest.fn(); @@ -20,18 +72,22 @@ const mockGetFirstAsync = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mockRunAsync.mockResolvedValue({ lastInsertRowId: 1 }); (openDatabase as jest.Mock).mockResolvedValue({ runAsync: mockRunAsync, getFirstAsync: mockGetFirstAsync, withExclusiveTransactionAsync: jest.fn((fn) => - fn({ runAsync: mockRunAsync }), + fn({ runAsync: mockRunAsync, getFirstAsync: mockGetFirstAsync }), ), }); }); -it("should insert plans and update data version if dataVersion is null", async () => { - mockGetFirstAsync.mockResolvedValue(null); // No dataVersion exists - mockRunAsync.mockResolvedValue({}); // Mock database writes +it("should insert v1.8 and v2.1 plans and update both data versions if dataVersion is null", async () => { + mockGetFirstAsync.mockImplementation((sql: string) => { + if (sql.includes("app_exercise_id = ?")) + return Promise.resolve({ exercise_id: 42 }); + return Promise.resolve(null); + }); await loadPremadePlans(); @@ -39,6 +95,10 @@ it("should insert plans and update data version if dataVersion is null", async ( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ["dataVersion", "1.8"], ); + expect(mockRunAsync).toHaveBeenCalledWith( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ["dataVersion", "2.1"], + ); expect(mockRunAsync).toHaveBeenCalledWith( expect.stringContaining("INSERT INTO user_plans"), expect.any(Array), @@ -46,21 +106,44 @@ it("should insert plans and update data version if dataVersion is null", async ( }); it("should handle database errors when inserting plans", async () => { - mockGetFirstAsync.mockResolvedValue(null); // No dataVersion exists + mockGetFirstAsync.mockResolvedValue(null); const dbError = new Error("Database insertion failed"); - mockRunAsync.mockRejectedValue(dbError); // Simulate database error + mockRunAsync.mockRejectedValue(dbError); await expect(loadPremadePlans()).rejects.toThrow("Database insertion failed"); - // Verify attempt was made to insert plans expect(mockRunAsync).toHaveBeenCalledWith( expect.stringContaining("INSERT INTO user_plans"), expect.any(Array), ); }); -it("should skip inserting plans if dataVersion is 1.8 or higher", async () => { - mockGetFirstAsync.mockResolvedValue({ value: "1.8" }); // Already up-to-date +it("should skip v1.8 plans but load v2.1 plans when dataVersion is 1.8", async () => { + mockGetFirstAsync.mockImplementation((sql: string) => { + if (sql.includes("key = ?")) return Promise.resolve({ value: "1.8" }); + if (sql.includes("app_exercise_id = ?")) + return Promise.resolve({ exercise_id: 42 }); + return Promise.resolve(null); + }); + + await loadPremadePlans(); + + expect(mockRunAsync).not.toHaveBeenCalledWith( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ["dataVersion", "1.8"], + ); + expect(mockRunAsync).toHaveBeenCalledWith( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ["dataVersion", "2.1"], + ); + expect(mockRunAsync).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO user_plans"), + expect.any(Array), + ); +}); + +it("should skip all plan insertions if dataVersion is 2.1 or higher", async () => { + mockGetFirstAsync.mockResolvedValue({ value: "2.1" }); await loadPremadePlans(); @@ -70,7 +153,7 @@ it("should skip inserting plans if dataVersion is 1.8 or higher", async () => { ); expect(mockRunAsync).not.toHaveBeenCalledWith( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", - ["dataVersion", "1.8"], + expect.any(Array), ); }); @@ -81,3 +164,75 @@ it("should handle database errors and notify Bugsnag", async () => { expect(Bugsnag.notify).toHaveBeenCalledWith(expect.any(Error)); }); + +it("should translate app exercise IDs to local userData IDs when inserting exercises", async () => { + const localExerciseId = 42; + mockGetFirstAsync.mockImplementation((sql: string) => { + if (sql.includes("key = ?")) return Promise.resolve({ value: "1.8" }); + if (sql.includes("app_exercise_id = ?")) + return Promise.resolve({ exercise_id: localExerciseId }); + return Promise.resolve(null); + }); + + await loadPremadePlans(); + + expect(mockRunAsync).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO user_workout_exercises"), + expect.arrayContaining([localExerciseId]), + ); +}); + +it("should throw and roll back when an exercise's app_exercise_id is not found in userData", async () => { + mockGetFirstAsync.mockImplementation((sql: string) => { + if (sql.includes("key = ?")) return Promise.resolve({ value: "1.8" }); + return Promise.resolve(null); // exercise not found anywhere + }); + + await expect(loadPremadePlans()).rejects.toThrow( + /Exercise with app_exercise_id=11 not found in userData\.db/, + ); + + expect(mockRunAsync).not.toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO user_workout_exercises"), + expect.any(Array), + ); +}); + +it("should copy missing exercises from appData3.db into userData.db", async () => { + const appExerciseRow = { + exercise_id: 11, + name: "Barbell Bench Press", + image: null, + local_animated_uri: null, + animated_url: null, + equipment: "barbell", + body_part: "chest", + target_muscle: "pectoralis major sternal head", + secondary_muscles: null, + description: null, + is_deleted: 0, + tracking_type: null, + is_unilateral: null, + double_weight: null, + }; + let exerciseCopied = false; + mockRunAsync.mockImplementation((sql: string) => { + if (sql.includes("INSERT INTO exercises")) exerciseCopied = true; + return Promise.resolve({ lastInsertRowId: 1 }); + }); + mockGetFirstAsync.mockImplementation((sql: string) => { + if (sql.includes("key = ?")) return Promise.resolve({ value: "1.8" }); + if (sql.includes("app_exercise_id = ?")) + return Promise.resolve(exerciseCopied ? { exercise_id: 11 } : null); + if (sql.includes("WHERE exercise_id = ?")) + return Promise.resolve(appExerciseRow); // found in appData3 + return Promise.resolve(null); + }); + + await loadPremadePlans(); + + expect(mockRunAsync).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO exercises"), + expect.arrayContaining([11, "Barbell Bench Press"]), + ); +}); diff --git a/utils/estimateWorkoutDuration.ts b/utils/estimateWorkoutDuration.ts index acea7763..d5e7bd32 100644 --- a/utils/estimateWorkoutDuration.ts +++ b/utils/estimateWorkoutDuration.ts @@ -115,6 +115,7 @@ function estimateSetWorkDuration( export function computeWorkoutDurationEstimate( exercises: UserExercise[], historyByExerciseId: Record, + countUnilateralDouble = false, ): DurationEstimate { // In a superset the transition from exercise A to B has no rest — only B's // rest counts (the between-round recovery). Collect the exercise_id of the @@ -145,8 +146,10 @@ export function computeWorkoutDurationEstimate( exercise.equipment, history, ); - totalMin += workMin + rest; - totalMax += workMax + rest; + const unilateralMul = + countUnilateralDouble && exercise.is_unilateral ? 2 : 1; + totalMin += workMin * unilateralMul + rest; + totalMax += workMax * unilateralMul + rest; } } @@ -167,7 +170,24 @@ export function formatDurationEstimate(estimate: DurationEstimate): string { const minStr = formatMinutes(estimate.minSeconds); const maxStr = formatMinutes(estimate.maxSeconds); if (minStr === maxStr) { - return `~${minStr}`; + return `${minStr}`; } return `${minStr} – ${maxStr}`; } + +function formatMinutesCompact(totalSeconds: number): string { + const totalMins = Math.ceil(totalSeconds / 60); + if (totalMins < 60) return `${totalMins}m`; + const hours = Math.floor(totalMins / 60); + const mins = totalMins % 60; + return mins === 0 ? `${hours}h` : `${hours}h${String(mins).padStart(2, "0")}`; +} + +export function formatDurationEstimateCompact( + estimate: DurationEstimate, +): string { + const minStr = formatMinutesCompact(estimate.minSeconds); + const maxStr = formatMinutesCompact(estimate.maxSeconds); + if (minStr === maxStr) return minStr; + return `${minStr}–${maxStr}`; +} diff --git a/utils/loadPremadePlans.ts b/utils/loadPremadePlans.ts index 365d8c03..2d7380c4 100644 --- a/utils/loadPremadePlans.ts +++ b/utils/loadPremadePlans.ts @@ -3,6 +3,52 @@ import { openDatabase } from "./database"; import { SQLiteDatabase } from "expo-sqlite"; import Bugsnag from "@bugsnag/expo"; +const ensureAppExercisesExist = async ( + userDb: SQLiteDatabase, + appExerciseIds: number[], +): Promise => { + const appDb = await openDatabase("appData3.db"); + for (const appId of appExerciseIds) { + const exists = await userDb.getFirstAsync( + "SELECT exercise_id FROM exercises WHERE app_exercise_id = ? LIMIT 1", + [appId], + ); + if (!exists) { + const row = await appDb.getFirstAsync>( + "SELECT * FROM exercises WHERE exercise_id = ? LIMIT 1", + [appId], + ); + if (row) { + await userDb.runAsync( + `INSERT INTO exercises (app_exercise_id, name, image, local_animated_uri, animated_url, equipment, body_part, target_muscle, secondary_muscles, description, is_deleted, tracking_type, is_unilateral, double_weight) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.exercise_id, + row.name, + row.image ?? null, + row.local_animated_uri ?? null, + row.animated_url ?? null, + row.equipment ?? null, + row.body_part ?? null, + row.target_muscle ?? null, + row.secondary_muscles ?? null, + row.description ?? null, + row.is_deleted ?? 0, + row.tracking_type ?? null, + row.is_unilateral ?? null, + row.double_weight ?? null, + ], + ); + console.log(`Copied exercise app_exercise_id=${appId} to userData.db`); + } else { + console.warn( + `Exercise with app_exercise_id=${appId} not found in appData3.db`, + ); + } + } + } +}; + const insertPlans = async (db: SQLiteDatabase, plans: Plan[]) => { const plansToInsert: Plan[] = []; @@ -54,13 +100,26 @@ const insertPlans = async (db: SQLiteDatabase, plans: Plan[]) => { // Step 4: Insert each exercise within this workout into user_workout_exercises for (const exercise of workout.exercises) { + // Translate appData exercise_id to local userData exercise_id + const localExercise = await txn.getFirstAsync<{ + exercise_id: number; + }>( + "SELECT exercise_id FROM exercises WHERE app_exercise_id = ? LIMIT 1", + [exercise.exercise_id], + ); + if (!localExercise) { + throw new Error( + `Exercise with app_exercise_id=${exercise.exercise_id} not found in userData.db (plan app_plan_id=${plan.app_plan_id})`, + ); + } + const setsJson = JSON.stringify(exercise.sets); // Convert sets array to JSON string await txn.runAsync( "INSERT INTO user_workout_exercises (workout_id, exercise_id, sets, exercise_order, is_deleted) VALUES (?, ?, ?, ?, ?)", [ workoutId, - exercise.exercise_id, + localExercise.exercise_id, setsJson, exercise.exercise_order ?? null, exercise.is_deleted ?? false, @@ -101,6 +160,47 @@ export const loadPremadePlans = async () => { "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ["dataVersion", "1.8"], ); + dataVersion = 1.8; + } + + if ((dataVersion ?? 0) < 2.1) { + console.log("Loading new premade plans (v2.1)..."); + const newPlanFiles = [ + require("@/assets/data/5-day-bro-split.json"), + require("@/assets/data/5-day-ppl.json"), + require("@/assets/data/6-day-split.json"), + require("@/assets/data/body-weight.json"), + require("@/assets/data/dumbbell-only.json"), + ]; + + // Collect all unique appData exercise IDs referenced in these plans + const allAppExerciseIds = new Set(); + for (const file of newPlanFiles) { + const plansArray: Plan[] = Array.isArray(file) ? file : [file]; + for (const plan of plansArray) { + for (const workout of plan.workouts) { + for (const exercise of workout.exercises) { + if (exercise.exercise_id != null) { + allAppExerciseIds.add(exercise.exercise_id); + } + } + } + } + } + + // Ensure all referenced exercises exist in userData.db + await ensureAppExercisesExist(db, Array.from(allAppExerciseIds)); + + for (const file of newPlanFiles) { + const plansArray = Array.isArray(file) ? file : [file]; + await insertPlans(db, plansArray); + } + + console.log("Updating data version to 2.1..."); + await db.runAsync( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ["dataVersion", "2.1"], + ); } } catch (error: any) { Bugsnag.notify(error);