From aa11312d1a8348e1d2086ca581c7293e550bd229 Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 19:29:28 +0100 Subject: [PATCH 01/26] feat: enhance workout duration estimation with unilateral exercise handling and compact formatting --- hooks/useWorkoutDurationEstimate.ts | 13 ++++++++++--- utils/estimateWorkoutDuration.ts | 24 ++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) 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/utils/estimateWorkoutDuration.ts b/utils/estimateWorkoutDuration.ts index acea7763..01fbb038 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; } } @@ -171,3 +174,20 @@ export function formatDurationEstimate(estimate: DurationEstimate): string { } 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}`; +} From 9afa2b2192bc261702bbbc584ca4991018262b42 Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 19:31:26 +0100 Subject: [PATCH 02/26] feat: integrate workout duration estimation with unilateral support across plans, home, workouts --- app/(app)/(tabs)/(plans)/index.tsx | 4 ++ app/(app)/(tabs)/(plans)/overview.tsx | 70 +++++++++++++++++------- app/(app)/(tabs)/index.tsx | 45 ++++++++++----- components/StandaloneWorkoutListItem.tsx | 9 +++ components/WorkoutCard.tsx | 6 +- 5 files changed, 99 insertions(+), 35 deletions(-) diff --git a/app/(app)/(tabs)/(plans)/index.tsx b/app/(app)/(tabs)/(plans)/index.tsx index 1a1370e2..9acfb1d5 100644 --- a/app/(app)/(tabs)/(plans)/index.tsx +++ b/app/(app)/(tabs)/(plans)/index.tsx @@ -11,9 +11,12 @@ import { useStandaloneWorkoutsQuery } from "@/hooks/useStandaloneWorkoutsQuery"; import StandaloneWorkoutListItem from "@/components/StandaloneWorkoutListItem"; import { Workout } from "@/store/workoutStore"; import Bugsnag from "@bugsnag/expo"; +import { useSettingsQuery } from "@/hooks/useSettingsQuery"; export default function PlansScreen() { const { data: plans, isLoading, isError, error } = useAllPlansQuery(); + const { data: settings } = useSettingsQuery(); + const countUnilateralDouble = settings?.countUnilateralDouble === "true"; const { data: standaloneWorkouts, isLoading: standaloneIsLoading, @@ -110,6 +113,7 @@ export default function PlansScreen() { key={item.id!.toString()} workout={item} onPress={() => handleViewWorkout(item)} + countUnilateralDouble={countUnilateralDouble} /> )) ) : ( diff --git a/app/(app)/(tabs)/(plans)/overview.tsx b/app/(app)/(tabs)/(plans)/overview.tsx index 628238d7..7b743351 100644 --- a/app/(app)/(tabs)/(plans)/overview.tsx +++ b/app/(app)/(tabs)/(plans)/overview.tsx @@ -26,13 +26,58 @@ import { import { useState } from "react"; import Bugsnag from "@bugsnag/expo"; import { Notes } from "@/components/Notes"; +import { useSettingsQuery } from "@/hooks/useSettingsQuery"; +import { useWorkoutDurationEstimate } from "@/hooks/useWorkoutDurationEstimate"; +import { formatDurationEstimate } from "@/utils/estimateWorkoutDuration"; +import type { Workout } from "@/store/workoutStore"; const fallbackImage = require("@/assets/images/placeholder.webp"); +function PlanWorkoutCard({ + workout, + index, + planId, + countUnilateralDouble, +}: { + workout: Workout; + index: number; + planId: string | string[]; + countUnilateralDouble: boolean; +}) { + const { estimate } = useWorkoutDurationEstimate( + workout.exercises, + countUnilateralDouble, + ); + return ( + + router.push({ + pathname: "/workout-details", + params: { + planId: String(planId), + workoutIndex: String(index), + }, + }) + } + style={styles.workoutCard} + > + + {workout.name || `Day ${index + 1}`} + + + {workout.exercises.length} Exercises + {estimate ? ` · ~${formatDurationEstimate(estimate)}` : ""} + + + ); +} + export default function PlanOverviewScreen() { const { planId } = useLocalSearchParams(); const { data: plan, isLoading, error } = usePlanQuery(Number(planId)); const { data: scheduleEntries = [] } = usePlanScheduleQuery(Number(planId)); + const { data: settings } = useSettingsQuery(); + const countUnilateralDouble = settings?.countUnilateralDouble === "true"; const deletePlanMutation = useDeletePlanMutation(); const setActivePlanMutation = useSetActivePlanMutation(); @@ -145,26 +190,13 @@ export default function PlanOverviewScreen() { {plan?.workouts.map((workout, index) => ( - - router.push({ - pathname: "/workout-details", - params: { - planId: String(planId), - workoutIndex: String(index), - }, - }) - } - style={styles.workoutCard} - > - - {workout.name || `Day ${index + 1}`} - - - {workout.exercises.length} Exercises - - + workout={workout} + index={index} + planId={planId} + countUnilateralDouble={countUnilateralDouble} + /> ))} + {exercises.length} Exercises + {estimate ? ` · ~${formatDurationEstimateCompact(estimate)}` : ""} + + ); +} + export default function HomeScreen() { const [isStartingWorkout, setIsStartingWorkout] = useState(false); const [pickerWorkouts, setPickerWorkouts] = useState([]); @@ -53,6 +76,7 @@ export default function HomeScreen() { const weightUnit = settings?.weightUnit || "kg"; const distanceUnit = settings?.distanceUnit || "m"; + const countUnilateralDouble = settings?.countUnilateralDouble === "true"; const { data: completedWorkouts, isLoading: completedWorkoutsLoading, @@ -472,9 +496,11 @@ export default function HomeScreen() { > {workout.name} - - {workout.exercises.length} Exercises - + - {/* */} 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); From cb912d93bbcce89d608843a3af32008ec519e122 Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 19:53:20 +0100 Subject: [PATCH 03/26] feat: implement draft management for workout and plan creation, including persistence and user prompts --- app/(app)/(create-plan)/create-workout.tsx | 80 ++- app/(app)/(create-plan)/create.tsx | 115 +++- hooks/useCreatePlan.ts | 13 +- store/workoutStore.ts | 695 +++++++++++---------- 4 files changed, 553 insertions(+), 350 deletions(-) diff --git a/app/(app)/(create-plan)/create-workout.tsx b/app/(app)/(create-plan)/create-workout.tsx index 930827d9..92cfd273 100644 --- a/app/(app)/(create-plan)/create-workout.tsx +++ b/app/(app)/(create-plan)/create-workout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { StyleSheet, View, @@ -45,14 +45,82 @@ export default function CreateWorkoutScreen() { addWorkout, changeWorkoutName, setWorkouts, + setDraftContext, + setDraftId, + clearDraft, } = useWorkoutStore(); 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"); + + const startFreshSession = useCallback(() => { + setDraftContext("standalone"); + setDraftId(existingWorkoutId); + }, [existingWorkoutId, setDraftContext, setDraftId]); + + // Check for a persisted draft once on mount, after store hydrates + useEffect(() => { + const checkDraft = () => { + const { + workouts: storedWorkouts, + draftContext, + draftId, + } = useWorkoutStore.getState(); + const hasDraft = + draftContext === "standalone" && + draftId === existingWorkoutId && + storedWorkouts.length > 0 && + (storedWorkouts[0].exercises.length > 0 || + storedWorkouts[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: () => { + clearDraft(); + startFreshSession(); + setDraftDecision("fresh"); + }, + }, + { + text: "Continue", + onPress: () => { + initializedWorkoutId.current = existingWorkoutId ?? null; + setDraftDecision("continue"); + }, + }, + ], + ); + } else { + if (draftContext !== null) clearDraft(); + startFreshSession(); + setDraftDecision("fresh"); + } + }; + + if (useWorkoutStore.persist.hasHydrated()) { + checkDraft(); + } else { + const unsub = useWorkoutStore.persist.onFinishHydration(checkDraft); + return unsub; + } + }, []); // 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 +140,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 +149,13 @@ export default function CreateWorkoutScreen() { if (savedRef.current) { savedRef.current = false; - clearWorkouts(); + clearDraft(); return navigation.dispatch(e.data.action); } const workout = workouts[0]; if (!workout || (!workout.exercises.length && !workout.name.trim())) { - clearWorkouts(); + clearDraft(); return navigation.dispatch(e.data.action); } @@ -100,7 +168,7 @@ export default function CreateWorkoutScreen() { text: "Discard", style: "destructive", onPress: () => { - clearWorkouts(); + clearDraft(); navigation.dispatch(e.data.action); }, }, @@ -108,7 +176,7 @@ export default function CreateWorkoutScreen() { ); }); return unsubscribe; - }, [navigation, workouts, clearWorkouts]); + }, [navigation, workouts, clearDraft]); const handleAddExercise = () => { router.push("/(app)/(create-plan)/exercises?index=0"); diff --git a/app/(app)/(create-plan)/create.tsx b/app/(app)/(create-plan)/create.tsx index f8b6def3..03993f4a 100644 --- a/app/(app)/(create-plan)/create.tsx +++ b/app/(app)/(create-plan)/create.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { StyleSheet, View, @@ -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,14 +51,16 @@ export default function CreatePlanScreen() { planImageUrl, setPlanImageUrl, setWorkouts, - clearWorkouts, addWorkout, removeWorkout, reorderWorkouts, changeWorkoutName, planSchedule, setPlanSchedule, - clearPlanSchedule, + setDraftContext, + setDraftId, + setDraftName, + clearDraft, } = useWorkoutStore(); const { data: settings } = useSettingsQuery(); const weeklyGoal = Number(settings?.weeklyGoal ?? 3); @@ -66,8 +68,85 @@ export default function CreatePlanScreen() { 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"); + + const startFreshSession = useCallback(() => { + const currentPlanId = planId ? Number(planId) : null; + setDraftContext("plan"); + setDraftId(currentPlanId); + }, [planId, setDraftContext, setDraftId]); + + // Check for a persisted draft once on mount, after store hydrates + useEffect(() => { + const checkDraft = () => { + const { + workouts: storedWorkouts, + draftContext, + draftId, + draftName, + } = useWorkoutStore.getState(); + const currentPlanId = planId ? Number(planId) : null; + const hasDraft = + draftContext === "plan" && + draftId === currentPlanId && + (storedWorkouts.length > 0 || 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: () => { + clearDraft(); + startFreshSession(); + setDraftDecision("fresh"); + if (!planId) setDataLoaded(true); + }, + }, + { + text: "Continue", + onPress: () => { + setPlanName(draftName); + setDraftDecision("continue"); + if (!planId) setDataLoaded(true); + }, + }, + ], + ); + } else { + // Clear stale draft from a different context + if (draftContext !== null) clearDraft(); + startFreshSession(); + 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 + + // Sync plan name into persisted draft so it survives app close + useEffect(() => { + if (draftDecision !== "pending") { + setDraftName(planName); + } + }, [planName, draftDecision, setDraftName]); + useEffect(() => { - if (existingPlan) { + if (!existingPlan || draftDecision === "pending") return; + + if (draftDecision === "fresh") { setPlanName(existingPlan.name); setPlanImageUrl(existingPlan.image_url); @@ -85,7 +164,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 +181,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 +209,13 @@ export default function CreatePlanScreen() { if (planSaved) { queryClient.invalidateQueries({ queryKey: ["plans"] }); queryClient.invalidateQueries({ queryKey: ["activePlan"] }); - clearWorkouts(); - clearPlanSchedule(); + clearDraft(); setPlanSaved(false); return navigation.dispatch(e.data.action); } if (!workouts.length && !planName.trim()) { + clearDraft(); return navigation.dispatch(e.data.action); } @@ -147,8 +228,7 @@ export default function CreatePlanScreen() { text: "Discard", style: "destructive", onPress: () => { - clearWorkouts(); - clearPlanSchedule(); + clearDraft(); setPlanSaved(false); navigation.dispatch(e.data.action); }, @@ -162,8 +242,7 @@ export default function CreatePlanScreen() { navigation, workouts, planName, - clearWorkouts, - clearPlanSchedule, + clearDraft, planSaved, setPlanSaved, queryClient, 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/store/workoutStore.ts b/store/workoutStore.ts index d95e3f59..2dfec2a7 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; @@ -79,346 +84,404 @@ interface WorkoutStore { setPlanSchedule: (schedule: Record) => void; clearPlanSchedule: () => void; syncScheduleOnRemoveWorkout: (removedIndex: number) => void; + // Draft persistence metadata + draftContext: "plan" | "standalone" | null; + draftId: number | null; + draftName: string; + setDraftContext: (context: "plan" | "standalone" | null) => void; + setDraftId: (id: number | null) => void; + setDraftName: (name: 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; + }, + 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 }; + }), + + draftContext: null, + draftId: null, + draftName: "", + setDraftContext: (context) => set({ draftContext: context }), + setDraftId: (id) => set({ draftId: id }), + setDraftName: (name) => set({ draftName: name }), + clearDraft: () => + set({ + workouts: [], + planImageUrl: DEFAULT_PLAN_IMAGE_URL, + planSchedule: {}, + draftContext: null, + draftId: null, + draftName: "", + }), }), -})); + { + name: "workout-draft-store", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + workouts: state.workouts, + planImageUrl: state.planImageUrl, + planSchedule: state.planSchedule, + draftContext: state.draftContext, + draftId: state.draftId, + draftName: state.draftName, + }), + }, + ), +); export { useWorkoutStore }; From 641ab388629da4f2c01542243be6a895a5873513 Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 19:59:30 +0100 Subject: [PATCH 04/26] fix: improve workout completion logic to handle undefined target values --- app/(app)/(tabs)/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(app)/(tabs)/index.tsx b/app/(app)/(tabs)/index.tsx index 02725c29..509bcc05 100644 --- a/app/(app)/(tabs)/index.tsx +++ b/app/(app)/(tabs)/index.tsx @@ -215,9 +215,9 @@ export default function HomeScreen() { const completedWorkoutsList: Workout[] = []; sortedWorkouts.forEach((workout) => { - const target = perWorkoutTarget.get(workout.id!) ?? 0; + const target = perWorkoutTarget.get(workout.id!); const completedTimes = completedWorkoutCounts.get(workout.id!) || 0; - const workoutCompleted = completedTimes >= target; + const workoutCompleted = target !== undefined && completedTimes >= target; if (workoutCompleted) { completedWorkoutsList.push(workout); From a10704cf035935adefa530a5d0cade9665951c4b Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 20:04:11 +0100 Subject: [PATCH 05/26] feat: add weekly workout streak tracking and insights to StatsScreen --- app/(app)/(tabs)/(stats)/index.tsx | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/(app)/(tabs)/(stats)/index.tsx b/app/(app)/(tabs)/(stats)/index.tsx index baaa6e96..d11e5628 100644 --- a/app/(app)/(tabs)/(stats)/index.tsx +++ b/app/(app)/(tabs)/(stats)/index.tsx @@ -9,6 +9,8 @@ import { useCompletedWorkoutsQuery, usePreviousPeriodWorkoutsQuery, } from "@/hooks/useCompletedWorkoutsQuery"; +import { startOfWeek, endOfWeek } from "date-fns"; +import { useWeeklyStreak } from "@/hooks/useWeeklyStreak"; import { useExercisesQuery } from "@/hooks/useExercisesQuery"; import { Colors } from "@/constants/Colors"; import { WorkoutHistorySection } from "@/components/stats/WorkoutHistorySection"; @@ -132,6 +134,30 @@ export default function StatsScreen() { parseInt(selectedTimeRange), ); + const { data: allWorkouts } = useCompletedWorkoutsQuery( + weightUnit, + distanceUnit, + ); + + const thisWeekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + const thisWeekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }); + const thisWeekWorkouts = allWorkouts?.filter((w) => { + const d = new Date(w.date_completed); + return d >= thisWeekStart && d <= thisWeekEnd; + }); + const uniqueWorkoutDaysCount = new Set( + thisWeekWorkouts?.map((w) => new Date(w.date_completed).toDateString()), + ).size; + const weeklyGoal = Number(settings?.weeklyGoal ?? 0); + const weeklyGoalReached = + uniqueWorkoutDaysCount >= weeklyGoal && weeklyGoal > 0; + const { streak } = useWeeklyStreak( + allWorkouts, + weeklyGoal, + uniqueWorkoutDaysCount, + weeklyGoalReached, + ); + const insights = useStatsInsights( completedWorkouts, trackedExercises, @@ -254,7 +280,7 @@ export default function StatsScreen() { biggestGainLabel={insights.biggestGainLabel} biggestGainValue={insights.biggestGainValue} topBodyPart={insights.topBodyPart} - streak={null} + streak={streak} weightUnit={weightUnit} /> From bc08d23322416d2f0bccf0bc4aaa1007cbe833ee Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 20:14:54 +0100 Subject: [PATCH 06/26] fix: clean up StatsTile component structure and improve readability --- components/stats/StatsTile.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/components/stats/StatsTile.tsx b/components/stats/StatsTile.tsx index 8f3bcc99..466f53d5 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"; @@ -11,7 +11,10 @@ interface StatsTileProps { deltaLabel?: string; } -const DeltaText: React.FC<{ delta: number; label?: string }> = ({ delta, label }) => { +const DeltaText: React.FC<{ delta: number; label?: string }> = ({ + delta, + label, +}) => { const isPositive = delta > 0; const isNeutral = delta === 0; const color = isNeutral @@ -36,16 +39,14 @@ export const StatsTile: React.FC = ({ {value} {label} - {delta != null && ( - - )} + {delta != null && } ); }; const styles = StyleSheet.create({ card: { - width: "48%", + flex: 1, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 6, From 5456e60da500fc3e6758edda8200b41cc2a95c3a Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 20:15:04 +0100 Subject: [PATCH 07/26] feat: enhance InsightsStrip layout with grid support for better pill display --- components/stats/InsightsStrip.tsx | 87 ++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/components/stats/InsightsStrip.tsx b/components/stats/InsightsStrip.tsx index eec5ce4e..fc6f151b 100644 --- a/components/stats/InsightsStrip.tsx +++ b/components/stats/InsightsStrip.tsx @@ -82,6 +82,38 @@ export const InsightsStrip: React.FC = ({ if (pills.length === 0) return null; + const renderPill = (pill: InsightPill, i: number) => ( + showTooltip(pill.tooltip!) : 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 +138,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, i) => renderPill(pill, ri * 2 + i))} - {pill.value} - - ))} - + ))} + + ) : ( + + {pills.map((pill, i) => renderPill(pill, i))} + + )} ); }; @@ -144,12 +165,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", From d072d5915061ac5a80edca38f80f23e6c6a9cf84 Mon Sep 17 00:00:00 2001 From: Joseph Bouqdib Date: Wed, 20 May 2026 21:48:32 +0100 Subject: [PATCH 08/26] feat: separate warmup and working sets in sets overview - Add "Add Warm-up" button that inserts at the warmup/working boundary, copying values from the last warmup set - Scope "apply to all" in edit modal to only affect sets of the same type (warmup or working), with a dynamic label reflecting the scope - Add blue left border and group divider to visually distinguish warmup sets from working sets - Move Details button to the navigation header - Add insertSetAtExercise store action for index-based set insertion --- app/(app)/(create-plan)/sets-overview.tsx | 145 +++++++++++++++------- components/EditSetModal.tsx | 9 +- store/workoutStore.ts | 43 +++++++ 3 files changed, 150 insertions(+), 47 deletions(-) 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() {