From 17cb416777ab0241f5636a44b91fbe4ec9bbf1a2 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 24 Feb 2025 22:27:01 -0500 Subject: [PATCH] workout screen WIP --- app/(tabs)/index.tsx | 240 ++++++++++- app/(workout)/_layout.tsx | 45 +- app/(workout)/add-exercises.tsx | 133 ++++++ app/(workout)/create.tsx | 364 ++++++++++++++++ app/(workout)/template-select.tsx | 109 +++++ components/EditableText.tsx | 116 +++++ components/workout/ExerciseTracker.tsx | 149 +++++++ components/workout/FavoriteTemplate.tsx | 91 ++++ components/workout/HomeWorkout.tsx | 41 ++ components/workout/RestTimer.tsx | 113 +++++ components/workout/SetInput.tsx | 164 ++++++++ components/workout/WorkoutHeader.tsx | 103 +++++ package-lock.json | 19 + package.json | 2 + stores/workoutStore.ts | 536 ++++++++++++++++++++++++ types/exercise.ts | 7 +- types/templates.ts | 75 +++- types/workout.ts | 242 +++++++++++ utils/createSelectors.ts | 17 + utils/formatTime.ts | 7 + utils/workout.ts | 49 +++ utils/workoutTitles.ts | 123 ++++++ 22 files changed, 2705 insertions(+), 40 deletions(-) create mode 100644 app/(workout)/add-exercises.tsx create mode 100644 app/(workout)/create.tsx create mode 100644 app/(workout)/template-select.tsx create mode 100644 components/EditableText.tsx create mode 100644 components/workout/ExerciseTracker.tsx create mode 100644 components/workout/FavoriteTemplate.tsx create mode 100644 components/workout/HomeWorkout.tsx create mode 100644 components/workout/RestTimer.tsx create mode 100644 components/workout/SetInput.tsx create mode 100644 components/workout/WorkoutHeader.tsx create mode 100644 stores/workoutStore.ts create mode 100644 types/workout.ts create mode 100644 utils/createSelectors.ts create mode 100644 utils/formatTime.ts create mode 100644 utils/workout.ts create mode 100644 utils/workoutTitles.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e4ad379..f3eee4f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,29 +1,223 @@ -// app/(tabs)/index.tsx (Workout tab) -import { View } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { TabScreen } from '@/components/layout/TabScreen'; -import Header from '@/components/Header'; -import { Plus } from 'lucide-react-native'; -import { Button } from '@/components/ui/button'; +// app/(tabs)/index.tsx +import { useState, useEffect } from 'react' +import { ScrollView, View } from 'react-native' +import { router } from 'expo-router' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { TabScreen } from '@/components/layout/TabScreen' +import Header from '@/components/Header' +import HomeWorkout from '@/components/workout/HomeWorkout' +import FavoriteTemplate from '@/components/workout/FavoriteTemplate' +import { useWorkoutStore } from '@/stores/workoutStore' +import type { WorkoutTemplate } from '@/types/templates' +import { Text } from '@/components/ui/text' +import { getRandomWorkoutTitle } from '@/utils/workoutTitles' + +interface FavoriteTemplateData { + id: string; + title: string; + description: string; + exercises: Array<{ + title: string; + sets: number; + reps: number; + }>; + exerciseCount: number; + duration?: number; + isFavorited: boolean; + lastUsed?: number; + source: 'local' | 'powr' | 'nostr'; +} export default function WorkoutScreen() { + const { startWorkout } = useWorkoutStore.getState(); + const [showActiveWorkoutModal, setShowActiveWorkoutModal] = useState(false) + const [pendingTemplateId, setPendingTemplateId] = useState(null) + const [favoriteWorkouts, setFavoriteWorkouts] = useState([]) + const [isLoadingFavorites, setIsLoadingFavorites] = useState(true) + + const { + getFavorites, + startWorkoutFromTemplate, + removeFavorite, + checkFavoriteStatus, + isActive, + endWorkout + } = useWorkoutStore() + + useEffect(() => { + loadFavorites() + }, []) + + const loadFavorites = async () => { + setIsLoadingFavorites(true) + try { + const favorites = await getFavorites() + + const workoutTemplates = favorites + .filter(f => f.content && f.content.id && checkFavoriteStatus(f.content.id)) + .map(f => { + const content = f.content; + return { + id: content.id, + title: content.title || 'Untitled Workout', + description: content.description || '', + exercises: content.exercises.map(ex => ({ + title: ex.exercise.title, + sets: ex.targetSets, + reps: ex.targetReps + })), + exerciseCount: content.exercises.length, + duration: content.metadata?.averageDuration, + isFavorited: true, + lastUsed: content.metadata?.lastUsed, + source: content.availability.source.includes('nostr') + ? 'nostr' + : content.availability.source.includes('powr') + ? 'powr' + : 'local' + } as FavoriteTemplateData; + }); + + setFavoriteWorkouts(workoutTemplates) + } catch (error) { + console.error('Error loading favorites:', error) + } finally { + setIsLoadingFavorites(false) + } + } + + const handleStartWorkout = async (templateId: string) => { + if (isActive) { + setPendingTemplateId(templateId) + setShowActiveWorkoutModal(true) + return + } + + try { + await startWorkoutFromTemplate(templateId) + router.push('/(workout)/create') + } catch (error) { + console.error('Error starting workout:', error) + } + } + + const handleStartNew = async () => { + if (!pendingTemplateId) return + + const templateToStart = pendingTemplateId + setShowActiveWorkoutModal(false) + setPendingTemplateId(null) + + await endWorkout() + await startWorkoutFromTemplate(templateToStart) + router.push('/(workout)/create') + } + + const handleContinueExisting = () => { + setShowActiveWorkoutModal(false) + setPendingTemplateId(null) + router.push('/(workout)/create') + } + + const handleFavoritePress = async (templateId: string) => { + try { + await removeFavorite(templateId) + await loadFavorites() + } catch (error) { + console.error('Error toggling favorite:', error) + } + } + + const handleQuickStart = () => { + // Initialize a new workout with a random funny title + startWorkout({ + title: getRandomWorkoutTitle(), + type: 'strength', + exercises: [] + }); + + router.push('/(workout)/create'); + }; + return ( -
console.log('New workout')} - > - - - } - /> - - Workout Screen - +
+ + + router.push('/(workout)/template-select')} + /> + + {/* Favorites section */} + + + Favorites + + + {isLoadingFavorites ? ( + + + Loading favorites... + + + ) : favoriteWorkouts.length === 0 ? ( + + + Star workouts from your library to see them here + + + ) : ( + + {favoriteWorkouts.map(template => ( + handleStartWorkout(template.id)} + onFavoritePress={() => handleFavoritePress(template.id)} + /> + ))} + + )} + + + + + + + + Active Workout + + You have an active workout in progress. Would you like to finish it first? + + + + setShowActiveWorkoutModal(false)}> + Start New + + + Continue Workout + + + + - ); + ) } \ No newline at end of file diff --git a/app/(workout)/_layout.tsx b/app/(workout)/_layout.tsx index ebd18e7..9511290 100644 --- a/app/(workout)/_layout.tsx +++ b/app/(workout)/_layout.tsx @@ -1,15 +1,50 @@ // app/(workout)/_layout.tsx -import { Stack } from 'expo-router'; +import React from 'react' +import { Stack } from 'expo-router' +import { useTheme } from '@react-navigation/native'; export default function WorkoutLayout() { + const theme = useTheme(); + return ( - + + + - ); + ) } \ No newline at end of file diff --git a/app/(workout)/add-exercises.tsx b/app/(workout)/add-exercises.tsx new file mode 100644 index 0000000..31b3ba0 --- /dev/null +++ b/app/(workout)/add-exercises.tsx @@ -0,0 +1,133 @@ +// app/(workout)/add-exercises.tsx +import React, { useState, useEffect } from 'react'; +import { View, ScrollView } from 'react-native'; +import { router } from 'expo-router'; +import { Text } from '@/components/ui/text'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { useSQLiteContext } from 'expo-sqlite'; +import { LibraryService } from '@/lib/db/services/LibraryService'; +import { TabScreen } from '@/components/layout/TabScreen'; +import { X } from 'lucide-react-native'; +import { BaseExercise } from '@/types/exercise'; + +export default function AddExercisesScreen() { + const db = useSQLiteContext(); + const [libraryService] = useState(() => new LibraryService(db)); + const [exercises, setExercises] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [search, setSearch] = useState(''); + + const { addExercises } = useWorkoutStore(); + + // Load exercises on mount + useEffect(() => { + const loadExercises = async () => { + try { + const data = await libraryService.getExercises(); + setExercises(data); + } catch (error) { + console.error('Failed to load exercises:', error); + } + }; + + loadExercises(); + }, [libraryService]); + + const filteredExercises = exercises.filter(e => + e.title.toLowerCase().includes(search.toLowerCase()) || + e.tags.some(t => t.toLowerCase().includes(search.toLowerCase())) + ); + + const handleToggleSelection = (id: string) => { + setSelectedIds(prev => + prev.includes(id) + ? prev.filter(i => i !== id) + : [...prev, id] + ); + }; + + const handleAddSelected = () => { + const selectedExercises = exercises.filter(e => selectedIds.includes(e.id)); + addExercises(selectedExercises); + + // Just go back - this will dismiss the modal and return to create screen + router.back(); + }; + + return ( + + + {/* Close button in the top right */} + + + + + + Add Exercises + + + + + + + Selected: {selectedIds.length} exercises + + + + {filteredExercises.map(exercise => ( + + + + + {exercise.title} + {exercise.category} + {exercise.equipment && ( + {exercise.equipment} + )} + + + + + + ))} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/app/(workout)/create.tsx b/app/(workout)/create.tsx new file mode 100644 index 0000000..e69ba40 --- /dev/null +++ b/app/(workout)/create.tsx @@ -0,0 +1,364 @@ +// app/(workout)/create.tsx +import React, { useState, useEffect } from 'react'; +import { View, ScrollView, StyleSheet } from 'react-native'; +import { router } from 'expo-router'; +import { TabScreen } from '@/components/layout/TabScreen'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { Plus, Pause, Play, MoreHorizontal, CheckCircle2 } from 'lucide-react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import EditableText from '@/components/EditableText'; +import { cn } from '@/lib/utils'; +import { generateId } from '@/utils/ids'; +import { WorkoutSet } from '@/types/workout'; +import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; + +export default function CreateWorkoutScreen() { + const { + status, + activeWorkout, + elapsedTime, + restTimer, + clearAutoSave + } = useWorkoutStore(); + + const { + pauseWorkout, + resumeWorkout, + completeWorkout, + updateWorkoutTitle, + updateSet + } = useWorkoutStore.getState(); + + const [showCancelDialog, setShowCancelDialog] = useState(false); + const insets = useSafeAreaInsets(); + + // Format time as mm:ss in monospace font + const formatTime = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + // Timer update effect + useEffect(() => { + let timerInterval: NodeJS.Timeout | null = null; + + if (status === 'active') { + timerInterval = setInterval(() => { + useWorkoutStore.getState().tick(1000); + }, 1000); + } + + return () => { + if (timerInterval) { + clearInterval(timerInterval); + } + }; + }, [status]); + + // Handler for adding a new set to an exercise + const handleAddSet = (exerciseIndex: number) => { + if (!activeWorkout) return; + + const exercise = activeWorkout.exercises[exerciseIndex]; + const lastSet = exercise.sets[exercise.sets.length - 1]; + + const newSet: WorkoutSet = { + id: generateId('local'), + weight: lastSet?.weight || 0, + reps: lastSet?.reps || 0, + type: 'normal', + isCompleted: false + }; + + updateSet(exerciseIndex, exercise.sets.length, newSet); + }; + + // Handler for completing a set + const handleCompleteSet = (exerciseIndex: number, setIndex: number) => { + if (!activeWorkout) return; + + const exercise = activeWorkout.exercises[exerciseIndex]; + const set = exercise.sets[setIndex]; + + updateSet(exerciseIndex, setIndex, { + ...set, + isCompleted: !set.isCompleted + }); + }; + + // Show empty state when no workout is active + if (!activeWorkout) { + return ( + + + + No active workout + + + + + ); + } + + return ( + + + {/* Swipe indicator */} + + + + + {/* Header with Title and Finish Button */} + + {/* Finish button in top right */} + + + + + {/* Full-width workout title */} + + updateWorkoutTitle(newTitle)} + placeholder="Workout Title" + textStyle={{ + fontSize: 24, + fontWeight: '700', + }} + /> + + + {/* Timer Display */} + + + {formatTime(elapsedTime)} + + + {status === 'active' ? ( + + ) : ( + + )} + + + + {/* Scrollable Exercises List */} + + {activeWorkout.exercises.length > 0 ? ( + activeWorkout.exercises.map((exercise, exerciseIndex) => ( + + {/* Exercise Header */} + + + {exercise.title} + + + + + {/* Sets Info */} + + + {exercise.sets.filter(s => s.isCompleted).length} sets completed + + + + {/* Set Headers */} + + SET + PREV + KG + REPS + + + + {/* Exercise Sets */} + + {exercise.sets.map((set, setIndex) => { + const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null; + return ( + + {/* Set Number */} + + {setIndex + 1} + + + {/* Previous Set */} + + {previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'} + + + {/* Weight Input */} + + + + {set.weight} + + + + + {/* Reps Input */} + + + + {set.reps} + + + + + {/* Complete Button */} + + + ); + })} + + + {/* Add Set Button */} + + + )) + ) : ( + + + No exercises added. Add exercises to start your workout. + + + )} + + + {/* Add Exercise FAB */} + + router.push('/(workout)/add-exercises')} + /> + + + {/* Cancel Workout Dialog */} + + + + Cancel Workout + + Are you sure you want to cancel this workout? All progress will be lost. + + + + setShowCancelDialog(false)} + > + Continue Workout + + { + await clearAutoSave(); + router.back(); + }} + > + Cancel Workout + + + + + + + ); +} + +const styles = StyleSheet.create({ + timerText: { + fontVariant: ['tabular-nums'] + } +}); \ No newline at end of file diff --git a/app/(workout)/template-select.tsx b/app/(workout)/template-select.tsx new file mode 100644 index 0000000..2e5eec4 --- /dev/null +++ b/app/(workout)/template-select.tsx @@ -0,0 +1,109 @@ +// app/(workout)/template-select.tsx +import React from 'react'; +import { View, ScrollView } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { useRouter } from 'expo-router'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { generateId } from '@/utils/ids'; +import type { TemplateType } from '@/types/templates'; + +// Temporary mock data - replace with actual template data +const MOCK_TEMPLATES = [ + { + id: '1', + title: 'Full Body Strength', + type: 'strength', + category: 'Strength', + exercises: [ + { title: 'Squat', sets: 3, reps: 8 }, + { title: 'Bench Press', sets: 3, reps: 8 }, + { title: 'Deadlift', sets: 3, reps: 8 } + ] + }, + { + id: '2', + title: 'Upper Body Push', + type: 'strength', + category: 'Push/Pull/Legs', + exercises: [ + { title: 'Bench Press', sets: 4, reps: 8 }, + { title: 'Shoulder Press', sets: 3, reps: 10 }, + { title: 'Tricep Extensions', sets: 3, reps: 12 } + ] + } +]; + +export default function TemplateSelectScreen() { + const router = useRouter(); + const startWorkout = useWorkoutStore.use.startWorkout(); + + const handleSelectTemplate = (template: typeof MOCK_TEMPLATES[0]) => { + startWorkout({ + title: template.title, + type: template.type as TemplateType, // Cast to proper type + exercises: template.exercises.map(ex => ({ + id: generateId('local'), + title: ex.title, + type: 'strength', + category: 'Push', + equipment: 'barbell', + tags: [], + format: { + weight: true, + reps: true, + rpe: true, + set_type: true + }, + format_units: { + weight: 'kg', + reps: 'count', + rpe: '0-10', + set_type: 'warmup|normal|drop|failure' + }, + sets: Array(ex.sets).fill({ + id: generateId('local'), + type: 'normal', + weight: 0, + reps: ex.reps, + isCompleted: false + }), + isCompleted: false, + availability: { + source: ['local'] + }, + created_at: Date.now() + })) + }); + router.back(); + }; + + return ( + + Recent Templates + + {MOCK_TEMPLATES.map(template => ( + + + {template.title} + {template.category} + + {/* Exercise Preview */} + + {template.exercises.map((exercise, index) => ( + + {exercise.title} - {exercise.sets}×{exercise.reps} + + ))} + + + + + + ))} + + ); +} \ No newline at end of file diff --git a/components/EditableText.tsx b/components/EditableText.tsx new file mode 100644 index 0000000..24366b4 --- /dev/null +++ b/components/EditableText.tsx @@ -0,0 +1,116 @@ +// components/EditableText.tsx +import React, { useState, useRef } from 'react'; +import { + TextInput, + TouchableOpacity, + StyleSheet, + View, + StyleProp, + ViewStyle, + TextStyle +} from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Check, Edit2 } from 'lucide-react-native'; +import { cn } from '@/lib/utils'; +import { useColorScheme } from '@/lib/useColorScheme'; + +interface EditableTextProps { + value: string; + onChangeText: (text: string) => void; + style?: StyleProp; + textStyle?: StyleProp; + inputStyle?: StyleProp; + placeholder?: string; + placeholderTextColor?: string; +} + +export default function EditableText({ + value, + onChangeText, + style, + textStyle, + inputStyle, + placeholder, + placeholderTextColor +}: EditableTextProps) { + const [isEditing, setIsEditing] = useState(false); + const [tempValue, setTempValue] = useState(value); + const inputRef = useRef(null); + const { isDarkColorScheme } = useColorScheme(); + + const handleSubmit = () => { + if (tempValue.trim()) { + onChangeText(tempValue); + } else { + setTempValue(value); + } + setIsEditing(false); + }; + + return ( + + {isEditing ? ( + + + + + + + ) : ( + setIsEditing(true)} + className="flex-col p-2 rounded-lg" + activeOpacity={0.7} + > + + {value || placeholder} + + + + + Edit + + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'column', + }, + input: { + flex: 1, + fontSize: 18, + fontWeight: '600', + padding: 0, + }, +}); \ No newline at end of file diff --git a/components/workout/ExerciseTracker.tsx b/components/workout/ExerciseTracker.tsx new file mode 100644 index 0000000..649cf02 --- /dev/null +++ b/components/workout/ExerciseTracker.tsx @@ -0,0 +1,149 @@ +// components/workout/ExerciseTracker.tsx +import React, { useCallback } from 'react'; +import { View, ScrollView } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight, Plus, TimerReset, Dumbbell } from 'lucide-react-native'; +import { Card, CardContent } from '@/components/ui/card'; +import SetInput from '@/components/workout/SetInput'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { generateId } from '@/utils/ids'; +import type { WorkoutSet } from '@/types/workout'; +import { cn } from '@/lib/utils'; +import { useRouter } from 'expo-router'; + +export default function ExerciseTracker() { + const router = useRouter(); + const activeWorkout = useWorkoutStore.use.activeWorkout(); + const currentExerciseIndex = useWorkoutStore.use.currentExerciseIndex(); + const { nextExercise, previousExercise, startRest, updateSet } = useWorkoutStore.getState(); + + // Handle adding a new set - define callback before any conditional returns + const handleAddSet = useCallback(() => { + if (!activeWorkout?.exercises[currentExerciseIndex]) return; + + const currentExercise = activeWorkout.exercises[currentExerciseIndex]; + const lastSet = currentExercise.sets[currentExercise.sets.length - 1]; + const newSet: WorkoutSet = { + id: generateId('local'), + weight: lastSet?.weight || 0, + reps: lastSet?.reps || 0, + type: 'normal', + isCompleted: false + }; + + updateSet(currentExerciseIndex, currentExercise.sets.length, newSet); + }, [activeWorkout, currentExerciseIndex, updateSet]); + + // Empty state check after hooks + if (!activeWorkout?.exercises || activeWorkout.exercises.length === 0) { + return ( + + + + + + No exercises added + + Tap the + button to add exercises to your workout + + + + ); + } + + // Prepare derivative state after hooks + const currentExercise = activeWorkout.exercises[currentExerciseIndex]; + const hasNextExercise = currentExerciseIndex < activeWorkout.exercises.length - 1; + const hasPreviousExercise = currentExerciseIndex > 0; + + if (!currentExercise) return null; + + return ( + + {/* Exercise Navigation */} + + + + + + {currentExercise.title} + + + {currentExercise.equipment} • {currentExercise.category} + + + + + + + {/* Sets List */} + + + + {/* Header Row */} + + Set + Prev + kg + Reps + + + + {/* Sets */} + {currentExercise.sets.map((set: WorkoutSet, index: number) => ( + 0 ? currentExercise.sets[index - 1] : undefined} + /> + ))} + + + + + {/* Bottom Controls */} + + + + + + + ); +} \ No newline at end of file diff --git a/components/workout/FavoriteTemplate.tsx b/components/workout/FavoriteTemplate.tsx new file mode 100644 index 0000000..1ebce2b --- /dev/null +++ b/components/workout/FavoriteTemplate.tsx @@ -0,0 +1,91 @@ +// components/workout/FavoriteTemplate.tsx +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Star, Clock, Dumbbell } from 'lucide-react-native'; +import type { GestureResponderEvent } from 'react-native'; + +interface FavoriteTemplateProps { + title: string; + exercises: Array<{ + title: string; + sets: number; + reps: number; + }>; + duration?: number; + exerciseCount: number; + isFavorited?: boolean; + onPress?: () => void; + onFavoritePress?: () => void; +} + +export default function FavoriteTemplate({ + title, + exercises, + duration, + exerciseCount, + isFavorited = false, + onPress, + onFavoritePress +}: FavoriteTemplateProps) { + return ( + + + + + + + {title} + + + + {exercises.slice(0, 3).map(ex => + `${ex.title} (${ex.sets}×${ex.reps})` + ).join(', ')} + {exercises.length > 3 && '...'} + + + + + + + {exerciseCount} exercises + + + + {duration && ( + + + + {duration} min + + + )} + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/workout/HomeWorkout.tsx b/components/workout/HomeWorkout.tsx new file mode 100644 index 0000000..3f48687 --- /dev/null +++ b/components/workout/HomeWorkout.tsx @@ -0,0 +1,41 @@ +// components/workout/HomeWorkout.tsx +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Play, Plus } from 'lucide-react-native' +import { Text } from '@/components/ui/text' // Import Text from our UI components + +interface HomeWorkoutProps { + onStartBlank?: () => void; + onSelectTemplate?: () => void; +} + +export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWorkoutProps) { + return ( + + + Start a Workout + Begin a new workout or choose from your templates + + + + + + + + ); +} \ No newline at end of file diff --git a/components/workout/RestTimer.tsx b/components/workout/RestTimer.tsx new file mode 100644 index 0000000..aa5cd4f --- /dev/null +++ b/components/workout/RestTimer.tsx @@ -0,0 +1,113 @@ +// components/workout/RestTimer.tsx +import React, { useEffect, useCallback } from 'react'; +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Plus, Square } from 'lucide-react-native'; +import * as Haptics from 'expo-haptics'; +import { useWorkoutStore } from '@/stores/workoutStore'; + +const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +}; + +const HAPTIC_THRESHOLDS = [30, 10, 5]; // Seconds remaining for haptic feedback + +export default function RestTimer() { + // Use selectors for reactive state + const restTimer = useWorkoutStore.use.restTimer(); + const activeWorkout = useWorkoutStore.use.activeWorkout(); + const currentExerciseIndex = useWorkoutStore.use.currentExerciseIndex(); + + // Get actions from store + const { stopRest, startRest, tick } = useWorkoutStore.getState(); + + const handleAddTime = useCallback(() => { + if (!restTimer.isActive) return; + startRest(restTimer.duration + 30); // Add 30 seconds + }, [restTimer.isActive, restTimer.duration]); + + useEffect(() => { + let interval: NodeJS.Timeout; + + if (restTimer.isActive && restTimer.remaining > 0) { + interval = setInterval(() => { + // Update the remaining time every second + tick(1); + + // Haptic feedback at thresholds + if (HAPTIC_THRESHOLDS.includes(restTimer.remaining)) { + Haptics.notificationAsync( + restTimer.remaining <= 5 + ? Haptics.NotificationFeedbackType.Warning + : Haptics.NotificationFeedbackType.Success + ); + } + + // Auto-stop timer when it reaches 0 + if (restTimer.remaining <= 0) { + stopRest(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + }, 1000); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [restTimer.isActive, restTimer.remaining]); + + // Get the next exercise if any + const nextExercise = activeWorkout && currentExerciseIndex < activeWorkout.exercises.length - 1 + ? activeWorkout.exercises[currentExerciseIndex + 1] + : null; + + return ( + + {/* Timer Display */} + + + Rest Timer + + + {formatTime(restTimer.remaining)} + + + + {/* Controls */} + + + + + + + {/* Next Exercise Preview */} + {nextExercise && ( + + + Next Exercise + + + {nextExercise.title} + + + )} + + ); +} \ No newline at end of file diff --git a/components/workout/SetInput.tsx b/components/workout/SetInput.tsx new file mode 100644 index 0000000..eeec03f --- /dev/null +++ b/components/workout/SetInput.tsx @@ -0,0 +1,164 @@ +// components/workout/SetInput.tsx +import React, { useState, useCallback } from 'react'; +import { View, TextInput, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Check } from 'lucide-react-native'; +import { cn } from '@/lib/utils'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import type { WorkoutSet } from '@/types/workout'; +import debounce from 'lodash/debounce'; + +interface SetInputProps { + exerciseIndex: number; + setIndex: number; + setNumber: number; + weight?: number; + reps?: number; + isCompleted?: boolean; + previousSet?: WorkoutSet; +} + +export default function SetInput({ + exerciseIndex, + setIndex, + setNumber, + weight = 0, + reps = 0, + isCompleted = false, + previousSet +}: SetInputProps) { + // Local state for controlled inputs + const [weightValue, setWeightValue] = useState(weight.toString()); + const [repsValue, setRepsValue] = useState(reps.toString()); + + // Get actions from store + const { updateSet, completeSet } = useWorkoutStore.getState(); + + // Debounced update functions to prevent too many state updates + const debouncedUpdateWeight = useCallback( + debounce((value: number) => { + updateSet(exerciseIndex, setIndex, { weight: value }); + }, 500), + [exerciseIndex, setIndex] + ); + + const debouncedUpdateReps = useCallback( + debounce((value: number) => { + updateSet(exerciseIndex, setIndex, { reps: value }); + }, 500), + [exerciseIndex, setIndex] + ); + + const handleWeightChange = (value: string) => { + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setWeightValue(value); + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + debouncedUpdateWeight(numValue); + } + } + }; + + const handleRepsChange = (value: string) => { + if (value === '' || /^\d*$/.test(value)) { + setRepsValue(value); + const numValue = parseInt(value, 10); + if (!isNaN(numValue)) { + debouncedUpdateReps(numValue); + } + } + }; + + const handleCompleteSet = useCallback(() => { + completeSet(exerciseIndex, setIndex); + }, [exerciseIndex, setIndex]); + + const handleCopyPreviousWeight = useCallback(() => { + if (previousSet?.weight) { + handleWeightChange(previousSet.weight.toString()); + } + }, [previousSet]); + + const handleCopyPreviousReps = useCallback(() => { + if (previousSet?.reps) { + handleRepsChange(previousSet.reps.toString()); + } + }, [previousSet]); + + return ( + + {/* Set Number */} + + {setNumber} + + + {/* Previous Set */} + + {previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'} + + + {/* Weight Input */} + + + + + {/* Reps Input */} + + + + + {/* Complete Button */} + + + ); +} \ No newline at end of file diff --git a/components/workout/WorkoutHeader.tsx b/components/workout/WorkoutHeader.tsx new file mode 100644 index 0000000..1ffa28a --- /dev/null +++ b/components/workout/WorkoutHeader.tsx @@ -0,0 +1,103 @@ +// components/workout/WorkoutHeader.tsx +import React from 'react'; +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Pause, Play, Square, ChevronLeft } from 'lucide-react-native'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { formatTime } from '@/utils/formatTime'; +import { cn } from '@/lib/utils'; +import { useRouter } from 'expo-router'; +import EditableText from '@/components/EditableText'; + +interface WorkoutHeaderProps { + title?: string; + onBack?: () => void; +} + +export default function WorkoutHeader({ title, onBack }: WorkoutHeaderProps) { + const router = useRouter(); + const status = useWorkoutStore.use.status(); + const activeWorkout = useWorkoutStore.use.activeWorkout(); + const elapsedTime = useWorkoutStore.use.elapsedTime(); + const { pauseWorkout, resumeWorkout, completeWorkout, updateWorkoutTitle } = useWorkoutStore.getState(); + + const handleBack = () => { + if (onBack) { + onBack(); + } else { + router.back(); + } + }; + + if (!activeWorkout) return null; + + return ( + + {/* Header Row */} + + + + + updateWorkoutTitle(newTitle)} + style={{ alignItems: 'center' }} + placeholder="Workout Title" + /> + + + + {status === 'active' ? ( + + ) : ( + + )} + + + + + + {/* Status Row */} + + + {formatTime(elapsedTime)} + + + + {activeWorkout.type} + + + + ); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 18bbaf9..98f21d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,9 +78,11 @@ "devDependencies": { "@babel/core": "^7.26.0", "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.15", "@types/react": "~18.3.12", "@types/react-native": "^0.72.8", "babel-plugin-module-resolver": "^5.0.2", + "expo-haptics": "^14.0.1", "typescript": "^5.3.3" } }, @@ -9045,6 +9047,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", @@ -11998,6 +12007,16 @@ "react": "*" } }, + "node_modules/expo-haptics": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-14.0.1.tgz", + "integrity": "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.2.tgz", diff --git a/package.json b/package.json index f4cedb1..348db74 100644 --- a/package.json +++ b/package.json @@ -92,9 +92,11 @@ "devDependencies": { "@babel/core": "^7.26.0", "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.15", "@types/react": "~18.3.12", "@types/react-native": "^0.72.8", "babel-plugin-module-resolver": "^5.0.2", + "expo-haptics": "^14.0.1", "typescript": "^5.3.3" }, "private": true diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts new file mode 100644 index 0000000..81666c8 --- /dev/null +++ b/stores/workoutStore.ts @@ -0,0 +1,536 @@ +// stores/workoutStore.ts + +import { create } from 'zustand'; +import { createSelectors } from '@/utils/createSelectors'; +import { generateId } from '@/utils/ids'; +import type { + Workout, + WorkoutState, + WorkoutAction, + RestTimer, + WorkoutSet, + WorkoutSummary, + WorkoutExercise +} from '@/types/workout'; +import type { + WorkoutTemplate, + TemplateType, + TemplateExerciseConfig +} from '@/types/templates'; +import type { BaseExercise } from '@/types/exercise'; // Add this import + +const AUTO_SAVE_INTERVAL = 30000; // 30 seconds + +interface FavoriteItem { + id: string; + content: WorkoutTemplate; + addedAt: number; +} + +interface ExtendedWorkoutState extends WorkoutState { + isActive: boolean; + favorites: FavoriteItem[]; +} + +interface WorkoutActions { + // Core Workout Flow + startWorkout: (workout: Partial) => void; + pauseWorkout: () => void; + resumeWorkout: () => void; + completeWorkout: () => void; + cancelWorkout: () => void; + reset: () => void; + + // Exercise and Set Management + updateSet: (exerciseIndex: number, setIndex: number, data: Partial) => void; + completeSet: (exerciseIndex: number, setIndex: number) => void; + nextExercise: () => void; + previousExercise: () => void; + + // Rest Timer + startRest: (duration: number) => void; + stopRest: () => void; + extendRest: (additionalSeconds: number) => void; + + // Timer Actions + tick: (elapsed: number) => void; + } + +interface ExtendedWorkoutActions extends WorkoutActions { + // Core Workout Flow from original implementation + startWorkout: (workout: Partial) => void; + pauseWorkout: () => void; + resumeWorkout: () => void; + completeWorkout: () => void; + cancelWorkout: () => void; + reset: () => void; + + // Exercise and Set Management from original implementation + updateSet: (exerciseIndex: number, setIndex: number, data: Partial) => void; + completeSet: (exerciseIndex: number, setIndex: number) => void; + nextExercise: () => void; + previousExercise: () => void; + addExercises: (exercises: BaseExercise[]) => void; + + // Rest Timer from original implementation + startRest: (duration: number) => void; + stopRest: () => void; + extendRest: (additionalSeconds: number) => void; + + // Timer Actions from original implementation + tick: (elapsed: number) => void; + + // New favorite management + getFavorites: () => Promise; + addFavorite: (template: WorkoutTemplate) => Promise; + removeFavorite: (templateId: string) => Promise; + checkFavoriteStatus: (templateId: string) => boolean; + + // New template management + startWorkoutFromTemplate: (templateId: string) => Promise; + + // Additional workout actions + endWorkout: () => Promise; + clearAutoSave: () => Promise; + updateWorkoutTitle: (title: string) => void; +} + +const initialState: ExtendedWorkoutState = { + status: 'idle', + activeWorkout: null, + currentExerciseIndex: 0, + currentSetIndex: 0, + elapsedTime: 0, + restTimer: { + isActive: false, + duration: 0, + remaining: 0 + }, + isActive: false, + favorites: [] +}; + +const useWorkoutStoreBase = create()((set, get) => ({ + ...initialState, + + // Core Workout Flow + startWorkout: (workoutData: Partial = {}) => { + const workout: Workout = { + id: generateId('local'), + title: workoutData.title || 'Quick Workout', + type: workoutData.type || 'strength', + exercises: workoutData.exercises || [], // Start with empty exercises array + startTime: Date.now(), + isCompleted: false, + created_at: Date.now(), + lastUpdated: Date.now(), + availability: { + source: ['local'] + }, + ...workoutData + }; + + set({ + status: 'active', + activeWorkout: workout, + currentExerciseIndex: 0, + elapsedTime: 0, + isActive: true + }); + }, + + pauseWorkout: () => { + const { status, activeWorkout } = get(); + if (status !== 'active' || !activeWorkout) return; + + set({ status: 'paused' }); + // Auto-save when pausing + saveWorkout(activeWorkout); + }, + + resumeWorkout: () => { + const { status, activeWorkout } = get(); + if (status !== 'paused' || !activeWorkout) return; + + set({ status: 'active' }); + }, + + completeWorkout: async () => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + const completedWorkout = { + ...activeWorkout, + isCompleted: true, + endTime: Date.now(), + lastUpdated: Date.now() + }; + + // Save final workout state + await saveWorkout(completedWorkout); + + // Calculate and save summary statistics + const summary = calculateWorkoutSummary(completedWorkout); + await saveSummary(summary); + + set({ + status: 'completed', + activeWorkout: completedWorkout + }); + }, + + cancelWorkout: () => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + // Save cancelled state for recovery if needed + saveWorkout({ + ...activeWorkout, + isCompleted: false, + endTime: Date.now(), + lastUpdated: Date.now() + }); + + set(initialState); + }, + + // Exercise and Set Management + updateSet: (exerciseIndex: number, setIndex: number, data: Partial) => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + const exercises = [...activeWorkout.exercises]; + const exercise = { ...exercises[exerciseIndex] }; + const sets = [...exercise.sets]; + + const now = Date.now(); + sets[setIndex] = { + ...sets[setIndex], + ...data, + lastUpdated: now + }; + + exercise.sets = sets; + exercise.lastUpdated = now; + exercises[exerciseIndex] = exercise; + + set({ + activeWorkout: { + ...activeWorkout, + exercises, + lastUpdated: now + } + }); + }, + + completeSet: (exerciseIndex: number, setIndex: number) => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + const exercises = [...activeWorkout.exercises]; + const exercise = { ...exercises[exerciseIndex] }; + const sets = [...exercise.sets]; + + const now = Date.now(); + sets[setIndex] = { + ...sets[setIndex], + isCompleted: true, + completedAt: now, + lastUpdated: now + }; + + exercise.sets = sets; + exercise.lastUpdated = now; + exercises[exerciseIndex] = exercise; + + set({ + activeWorkout: { + ...activeWorkout, + exercises, + lastUpdated: now + } + }); + }, + + nextExercise: () => { + const { activeWorkout, currentExerciseIndex } = get(); + if (!activeWorkout) return; + + const nextIndex = Math.min( + currentExerciseIndex + 1, + activeWorkout.exercises.length - 1 + ); + + set({ + currentExerciseIndex: nextIndex, + currentSetIndex: 0 + }); + }, + + previousExercise: () => { + const { currentExerciseIndex } = get(); + + set({ + currentExerciseIndex: Math.max(currentExerciseIndex - 1, 0), + currentSetIndex: 0 + }); + }, + + addExercises: (exercises: BaseExercise[]) => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + const now = Date.now(); + const newExercises: WorkoutExercise[] = exercises.map(ex => ({ + id: generateId('local'), + title: ex.title, + type: ex.type, + category: ex.category, + equipment: ex.equipment, + tags: ex.tags || [], + availability: { + source: ['local'] + }, + created_at: now, + lastUpdated: now, + sets: [ + { + id: generateId('local'), + type: 'normal', + weight: 0, + reps: 0, + isCompleted: false + } + ], + isCompleted: false + })); + + set({ + activeWorkout: { + ...activeWorkout, + exercises: [...activeWorkout.exercises, ...newExercises], + lastUpdated: now + } + }); + }, + + // Rest Timer + startRest: (duration: number) => set({ + restTimer: { + isActive: true, + duration, + remaining: duration + } + }), + + stopRest: () => set({ + restTimer: initialState.restTimer + }), + + extendRest: (additionalSeconds: number) => { + const { restTimer } = get(); + if (!restTimer.isActive) return; + + set({ + restTimer: { + ...restTimer, + duration: restTimer.duration + additionalSeconds, + remaining: restTimer.remaining + additionalSeconds + } + }); + }, + + // Timer Actions + tick: (elapsed: number) => { + const { status, restTimer } = get(); + + if (status === 'active') { + set((state: WorkoutState) => ({ + elapsedTime: state.elapsedTime + elapsed + })); + + // Update rest timer if active + if (restTimer.isActive) { + const remaining = Math.max(0, restTimer.remaining - elapsed/1000); + + if (remaining === 0) { + set({ restTimer: initialState.restTimer }); + } else { + set({ + restTimer: { + ...restTimer, + remaining + } + }); + } + } + } + }, + + // Template Management + startWorkoutFromTemplate: async (templateId: string) => { + // Get template from your template store/service + const template = await getTemplate(templateId); + if (!template) return; + + // Convert template exercises to workout exercises + const exercises: WorkoutExercise[] = template.exercises.map(templateExercise => ({ + id: generateId('local'), + title: templateExercise.exercise.title, + type: templateExercise.exercise.type, + category: templateExercise.exercise.category, + equipment: templateExercise.exercise.equipment, + tags: templateExercise.exercise.tags || [], + availability: { + source: ['local'] + }, + created_at: Date.now(), + sets: Array(templateExercise.targetSets || 3).fill({ + id: generateId('local'), + type: 'normal', + weight: 0, + reps: templateExercise.targetReps || 0, + isCompleted: false + }), + isCompleted: false, + notes: templateExercise.notes || '' + })); + + // Start workout with template data + get().startWorkout({ + title: template.title, + type: template.type || 'strength', + exercises, + templateId: template.id + }); + }, + + updateWorkoutTitle: (title: string) => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + set({ + activeWorkout: { + ...activeWorkout, + title, + lastUpdated: Date.now() + } + }); + }, + + // Favorite Management + getFavorites: async () => { + const { favorites } = get(); + return favorites; + }, + + addFavorite: async (template: WorkoutTemplate) => { + const favorites = [...get().favorites]; + favorites.push({ + id: template.id, + content: template, + addedAt: Date.now() + }); + set({ favorites }); + }, + + removeFavorite: async (templateId: string) => { + const favorites = get().favorites.filter(f => f.id !== templateId); + set({ favorites }); + }, + + checkFavoriteStatus: (templateId: string) => { + return get().favorites.some(f => f.id === templateId); + }, + + endWorkout: async () => { + const { activeWorkout } = get(); + if (!activeWorkout) return; + + await get().completeWorkout(); + set({ isActive: false }); + }, + + clearAutoSave: async () => { + // TODO: Implement clearing autosave from storage + set(initialState); + }, + + reset: () => set(initialState) +})); + +// Helper functions +async function getTemplate(templateId: string): Promise { + // This is a placeholder - you'll need to implement actual template fetching + // from your database/storage service + try { + // Example implementation: + // return await db.getTemplate(templateId); + return null; + } catch (error) { + console.error('Error fetching template:', error); + return null; + } + } + +async function saveWorkout(workout: Workout): Promise { + try { + // TODO: Implement actual save logic using our database service + console.log('Saving workout:', workout); + } catch (error) { + console.error('Error saving workout:', error); + } +} + +function calculateWorkoutSummary(workout: Workout): WorkoutSummary { + return { + id: generateId('local'), + title: workout.title, + type: workout.type, + duration: workout.endTime ? workout.endTime - workout.startTime : 0, + startTime: workout.startTime, + endTime: workout.endTime || Date.now(), + exerciseCount: workout.exercises.length, + completedExercises: workout.exercises.filter(e => e.isCompleted).length, + totalVolume: calculateTotalVolume(workout), + totalReps: calculateTotalReps(workout), + averageRpe: calculateAverageRpe(workout), + exerciseSummaries: [], + personalRecords: [] + }; +} + +function calculateTotalVolume(workout: Workout): number { + return workout.exercises.reduce((total, exercise) => { + return total + exercise.sets.reduce((setTotal, set) => { + return setTotal + (set.weight || 0) * (set.reps || 0); + }, 0); + }, 0); +} + +function calculateTotalReps(workout: Workout): number { + return workout.exercises.reduce((total, exercise) => { + return total + exercise.sets.reduce((setTotal, set) => { + return setTotal + (set.reps || 0); + }, 0); + }, 0); +} + +function calculateAverageRpe(workout: Workout): number { + const rpeSets = workout.exercises.reduce((sets, exercise) => { + return sets.concat(exercise.sets.filter(set => set.rpe !== undefined)); + }, [] as WorkoutSet[]); + + if (rpeSets.length === 0) return 0; + + const totalRpe = rpeSets.reduce((total, set) => total + (set.rpe || 0), 0); + return totalRpe / rpeSets.length; +} + +async function saveSummary(summary: WorkoutSummary) { + // TODO: Implement summary saving + console.log('Saving summary:', summary); +} + +// Create auto-generated selectors +export const useWorkoutStore = createSelectors(useWorkoutStoreBase); \ No newline at end of file diff --git a/types/exercise.ts b/types/exercise.ts index 421c43b..617c7b6 100644 --- a/types/exercise.ts +++ b/types/exercise.ts @@ -84,11 +84,12 @@ export interface WorkoutSet { */ export interface WorkoutExercise extends BaseExercise { sets: WorkoutSet[]; - totalWeight?: number; - notes?: string; - restTime?: number; // Rest time in seconds targetSets?: number; targetReps?: number; + notes?: string; + restTime?: number; + isCompleted?: boolean; + lastUpdated?: number; } /** diff --git a/types/templates.ts b/types/templates.ts index 70ae903..81b61a6 100644 --- a/types/templates.ts +++ b/types/templates.ts @@ -1,10 +1,11 @@ -// types/template.ts -import { BaseExercise, ExerciseCategory } from './exercise'; +// types/templates.ts +import { BaseExercise, Equipment, ExerciseCategory, SetType } from './exercise'; import { StorageSource, SyncableContent } from './shared'; import { generateId } from '@/utils/ids'; /** * Template Classifications + * Aligned with NIP-33402 */ export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap'; @@ -34,9 +35,11 @@ export interface TemplateExerciseConfig { targetReps: number; weight?: number; rpe?: number; - setType?: 'warmup' | 'normal' | 'drop' | 'failure'; + setType?: SetType; restSeconds?: number; notes?: string; + + // Format configuration from NIP-33401 format?: { weight?: boolean; reps?: boolean; @@ -49,6 +52,14 @@ export interface TemplateExerciseConfig { rpe?: '0-10'; set_type?: 'warmup|normal|drop|failure'; }; + + // For timed workouts + duration?: number; + interval?: number; + + // For circuit/EMOM + position?: number; + roundRest?: number; } /** @@ -71,12 +82,23 @@ export interface TemplateBase { type: TemplateType; category: TemplateCategory; description?: string; + notes?: string; tags: string[]; + + // Workout structure + rounds?: number; + duration?: number; + interval?: number; + restBetweenRounds?: number; + + // Metadata metadata?: { lastUsed?: number; useCount?: number; averageDuration?: number; + completionRate?: number; }; + author?: { name: string; pubkey?: string; @@ -114,12 +136,6 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent { set_type: 'warmup|normal|drop|failure'; }; - // Workout specific configuration - rounds?: number; - duration?: number; - interval?: number; - restBetweenRounds?: number; - // Template derivation sourceTemplate?: TemplateSource; derivatives?: { @@ -129,6 +145,7 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent { // Nostr integration nostrEventId?: string; + relayUrls?: string[]; } /** @@ -194,7 +211,20 @@ export function toWorkoutTemplate(template: Template): WorkoutTemplate { title: ex.title, type: 'strength', category: 'Push' as ExerciseCategory, + equipment: 'barbell' as Equipment, tags: [], + format: { + weight: true, + reps: true, + rpe: true, + set_type: true + }, + format_units: { + weight: 'kg', + reps: 'count', + rpe: '0-10', + set_type: 'warmup|normal|drop|failure' + }, availability: { source: ['local'] }, @@ -211,4 +241,31 @@ export function toWorkoutTemplate(template: Template): WorkoutTemplate { source: ['local'] } }; +} + +/** + * Creates a Nostr event from a template (NIP-33402) + */ +export function createNostrTemplateEvent(template: WorkoutTemplate) { + return { + kind: 33402, + content: template.description || '', + tags: [ + ['d', template.id], + ['title', template.title], + ['type', template.type], + ...(template.rounds ? [['rounds', template.rounds.toString()]] : []), + ...(template.duration ? [['duration', template.duration.toString()]] : []), + ...(template.interval ? [['interval', template.interval.toString()]] : []), + ...template.exercises.map(ex => [ + 'exercise', + `33401:${ex.exercise.id}`, + ex.targetSets.toString(), + ex.targetReps.toString(), + ex.setType || 'normal' + ]), + ...template.tags.map(tag => ['t', tag]) + ], + created_at: Math.floor(Date.now() / 1000) + }; } \ No newline at end of file diff --git a/types/workout.ts b/types/workout.ts new file mode 100644 index 0000000..1aeee06 --- /dev/null +++ b/types/workout.ts @@ -0,0 +1,242 @@ +// types/workout.ts +import type { WorkoutTemplate, TemplateType } from './templates'; +import type { BaseExercise } from './exercise'; +import type { SyncableContent } from './shared'; +import type { NostrEvent } from './nostr'; + +/** + * Core workout status types + */ +export type WorkoutStatus = 'idle' | 'active' | 'paused' | 'completed'; + +/** + * Individual workout set + */ +export interface WorkoutSet { + id: string; + weight?: number; + reps?: number; + rpe?: number; + type: 'warmup' | 'normal' | 'drop' | 'failure'; + isCompleted: boolean; + notes?: string; + timestamp?: number; + lastUpdated?: number; + completedAt?: number; +} + +/** + * Exercise within a workout + */ +export interface WorkoutExercise extends BaseExercise { + sets: WorkoutSet[]; + targetSets?: number; + targetReps?: number; + notes?: string; + restTime?: number; // Rest time in seconds + isCompleted?: boolean; + lastUpdated?: number; +} + +/** + * Active workout tracking + */ +export interface Workout extends SyncableContent { + id: string; + title: string; + type: TemplateType; + exercises: WorkoutExercise[]; + startTime: number; + endTime?: number; + notes?: string; + lastUpdated?: number; + tags?: string[]; + + // Template reference if workout was started from template + templateId?: string; + + // Workout configuration + rounds?: number; + duration?: number; // Total duration in seconds + interval?: number; // For EMOM/interval workouts + restBetweenRounds?: number; + + // Workout metrics + totalVolume?: number; + totalReps?: number; + averageRpe?: number; + + // Completion tracking + isCompleted: boolean; + roundsCompleted?: number; + exercisesCompleted?: number; + + // For Nostr integration + nostrEventId?: string; +} + +/** + * Personal Records + */ +export interface PersonalRecord { + id: string; + exerciseId: string; + metric: 'weight' | 'reps' | 'volume' | 'time'; + value: number; + workoutId: string; + achievedAt: number; + + // Context about the PR + exercise: { + title: string; + equipment?: string; + }; + previousValue?: number; + notes?: string; +} + +/** + * Workout Summary Statistics + */ +export interface WorkoutSummary { + id: string; + title: string; + type: TemplateType; + duration: number; // Total time in milliseconds + startTime: number; + endTime: number; + + // Overall stats + exerciseCount: number; + completedExercises: number; + totalVolume: number; + totalReps: number; + averageRpe?: number; + + // Exercise-specific summaries + exerciseSummaries: Array<{ + exerciseId: string; + title: string; + setCount: number; + completedSets: number; + volume: number; + peakWeight?: number; + totalReps: number; + averageRpe?: number; + }>; + + // Achievements + personalRecords: PersonalRecord[]; +} + +/** + * Rest Timer State + */ +export interface RestTimer { + isActive: boolean; + duration: number; // Total rest duration in seconds + remaining: number; // Remaining time in seconds + exerciseId?: string; // Associated exercise if any + setIndex?: number; // Associated set if any +} + +/** + * Global Workout State + */ +export interface WorkoutState { + status: WorkoutStatus; + activeWorkout: Workout | null; + currentExerciseIndex: number; + currentSetIndex: number; + elapsedTime: number; // Total workout time in milliseconds + lastSaved?: number; // Timestamp of last save + restTimer: RestTimer; +} + +/** + * Workout Actions + */ +export type WorkoutAction = + | { type: 'START_WORKOUT'; payload: Partial } + | { type: 'PAUSE_WORKOUT' } + | { type: 'RESUME_WORKOUT' } + | { type: 'COMPLETE_WORKOUT' } + | { type: 'UPDATE_SET'; payload: { exerciseIndex: number; setIndex: number; data: Partial } } + | { type: 'NEXT_EXERCISE' } + | { type: 'PREVIOUS_EXERCISE' } + | { type: 'START_REST'; payload: number } + | { type: 'STOP_REST' } + | { type: 'TICK'; payload: number } + | { type: 'RESET' }; + +/** + * Helper functions + */ + +/** + * Converts a template to an active workout + */ +export function templateToWorkout(template: WorkoutTemplate): Workout { + return { + id: crypto.randomUUID(), + title: template.title, + type: template.type, + exercises: template.exercises.map(ex => ({ + ...ex.exercise, + sets: Array(ex.targetSets).fill({ + id: crypto.randomUUID(), + type: 'normal', + reps: ex.targetReps, + isCompleted: false + }), + targetSets: ex.targetSets, + targetReps: ex.targetReps, + notes: ex.notes + })), + templateId: template.id, + startTime: Date.now(), + isCompleted: false, + rounds: template.rounds, + duration: template.duration, + interval: template.interval, + restBetweenRounds: template.restBetweenRounds, + created_at: Date.now(), + availability: { + source: ['local'] + } + }; +} + +/** + * Creates a Nostr workout record event + */ +export function createNostrWorkoutEvent(workout: Workout): NostrEvent { + const exerciseTags = workout.exercises.flatMap(exercise => + exercise.sets.map(set => [ + 'exercise', + `33401:${exercise.id}`, + set.weight?.toString() || '', + set.reps?.toString() || '', + set.rpe?.toString() || '', + set.type + ]) + ); + + const workoutTags = workout.tags ? workout.tags.map(tag => ['t', tag]) : []; + + return { + kind: 33403, + content: workout.notes || '', + tags: [ + ['d', workout.id], + ['title', workout.title], + ['type', workout.type], + ['start', Math.floor(workout.startTime / 1000).toString()], + ['end', Math.floor(workout.endTime! / 1000).toString()], + ['completed', workout.isCompleted.toString()], + ...exerciseTags, + ...workoutTags + ], + created_at: Math.floor(Date.now() / 1000) + }; + } \ No newline at end of file diff --git a/utils/createSelectors.ts b/utils/createSelectors.ts new file mode 100644 index 0000000..a4bba9e --- /dev/null +++ b/utils/createSelectors.ts @@ -0,0 +1,17 @@ +// utils/createSelectors.ts +import { StoreApi, UseBoundStore } from 'zustand'; + +type WithSelectors = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +export const createSelectors = >>( + _store: S, +) => { + let store = _store as WithSelectors; + store.use = {}; + for (let k of Object.keys(store.getState())) { + (store.use as any)[k] = () => store((s) => s[k as keyof typeof s]); + } + return store; +}; \ No newline at end of file diff --git a/utils/formatTime.ts b/utils/formatTime.ts new file mode 100644 index 0000000..8d686db --- /dev/null +++ b/utils/formatTime.ts @@ -0,0 +1,7 @@ +// utils/formatTime.ts +export function formatTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + } \ No newline at end of file diff --git a/utils/workout.ts b/utils/workout.ts new file mode 100644 index 0000000..dbff418 --- /dev/null +++ b/utils/workout.ts @@ -0,0 +1,49 @@ +// utils/workout.ts +import { generateId } from '@/utils/ids'; +import type { + Workout, + WorkoutSet, + WorkoutExercise +} from '@/types/workout'; +import type { WorkoutTemplate } from '@/types/templates'; + +export function convertTemplateToWorkout(template: WorkoutTemplate) { + // Convert template exercises to workout exercises with empty sets + const exercises: WorkoutExercise[] = template.exercises.map((templateExercise) => { + const now = Date.now(); + return { + id: generateId('local'), + title: templateExercise.exercise.title, + type: templateExercise.exercise.type, + category: templateExercise.exercise.category, + equipment: templateExercise.exercise.equipment, + tags: templateExercise.exercise.tags || [], + availability: { + source: ['local'] + }, + created_at: now, + // Create the specified number of sets from template + sets: Array.from({ length: templateExercise.targetSets }, (): WorkoutSet => ({ + id: generateId('local'), + weight: 0, // Start empty, but could use last workout weight + reps: templateExercise.targetReps, // Use target reps from template + type: 'normal', + isCompleted: false + })) + }; + }); + + return { + id: generateId('local'), + title: template.title, + type: template.type, + exercises, + description: template.description, + startTime: Date.now(), + isCompleted: false, + created_at: Date.now(), + availability: { + source: ['local'] + } + }; +} \ No newline at end of file diff --git a/utils/workoutTitles.ts b/utils/workoutTitles.ts new file mode 100644 index 0000000..0c077f6 --- /dev/null +++ b/utils/workoutTitles.ts @@ -0,0 +1,123 @@ +export const WORKOUT_TITLES = [ + // Action Movies + 'Die Hard Reps', + 'The Fast and The Furious: Deadlift Drift', + 'Mission: Impossible - Strength Protocol', + 'Rambo: The Strength Warrior', + 'Terminator: Reps Rising', + 'Mad Max: Fury Reps', + 'The Equalizer Set', + 'John Wick: Power Moves', + 'The Bourne Identity Workout', + 'The Rocketeer Push', + 'Lethal Weapon Reps', + 'Fast & Furious: Reps Drift', + 'The Expendables Strength', + 'Edge of Tomorrow Reps', + 'Commando Strength Challenge', + 'Speed: Rep Rush', + 'Taken: The Workout', + 'Heat: Reps Edition', + 'The A-Team Lifts', + 'Bad Boys for Life Squats', + + // Thriller Movies + 'The Dark Knight Lifting', + 'Inception Reps', + 'The Sixth Sense Workout', + 'Shutter Island Strength', + 'Gone Girl Gains', + 'The Prestige Pulls', + 'Se7en: The Seven Reps', + 'The Departed Squats', + 'Zodiac Killer Lifts', + 'Fight Club Reps', + 'Prisoners Power Pulls', + 'The Girl with the Dragon Tattoo Strength', + 'Panic Room Workout', + 'Nightcrawler Circuit', + 'Memento Lifting', + 'The Insider Workout', + 'Insomnia Reps', + 'Source Code Strength', + 'The Talented Mr. Ripley Lifts', + 'Shutter Island Push', + + // Western Movies + 'The Good, The Bad, and The Swole', + 'True Grit Gains', + 'Django Unchained Reps', + 'The Magnificent Seven Strength', + 'Unforgiven Squats', + 'Tombstone Power Pulls', + 'The Outlaw Josie Wales Lifting', + 'Butch Cassidy and the Sundance Rep', + 'For a Few Dollars More Strength', + 'Once Upon a Time in the West Squats', + 'High Noon Lifts', + 'The Wild Bunch Power Push', + 'Silverado Strength Challenge', + 'The Assassination of Jesse James Reps', + 'The Hateful Eight Lifting', + '3:10 to Yuma Power Pulls', + 'The Searchers Strength', + 'A Fistful of Dollars Lifts', + 'No Country for Old Men Squats', + 'Rango Power Reps', + + // Horror Movies + 'The Texas Chainsaw Massacre Reps', + 'Nightmare on Elm Street Strength', + 'Halloween Squats', + 'Friday the 13th Reps', + 'The Shining Strength Challenge', + 'It Chapter One: The Clown Reps', + 'The Exorcist Deadlift', + 'Scream Workout', + 'Psycho Lifting', + 'The Ring Circuit', + 'The Cabin in the Woods Lifts', + 'A Nightmare on Elm Street: Power Push', + 'Get Out Strength', + 'The Silence of the Lambs Reps', + 'Hereditary Push', + 'The Witch Lifting', + 'It Follows Squats', + 'Poltergeist Power Pulls', + 'The Conjuring Strength', + 'Midsommar Lifting', + 'The Babadook Reps', + + // Hybrid Action/Horror Movies + 'Aliens Lifting', + 'Predator Strength Challenge', + 'Terminator 2: Judgment Day Reps', + 'Resident Evil Power Pulls', + 'The Matrix: Reps Reloaded', + 'World War Z Workout', + '28 Days Later Strength', + 'The Mist Power Push', + 'The Road Reps', + 'I Am Legend Lifts', + 'The Walking Dead Strength Challenge', + 'The Thing Squats', + 'Dawn of the Dead Reps', + 'Event Horizon Push', + 'Daybreakers Power Lifts', + 'Land of the Dead Lifting', + 'The Strangers Reps', + 'Escape from New York Strength', + 'The Purge Circuit', + 'Zombie Land Lifting' +]; + +export function getRandomWorkoutTitle(): string { + const index = Math.floor(Math.random() * WORKOUT_TITLES.length); + return WORKOUT_TITLES[index]; +} + +// Get a random title from a specific genre +export function getRandomWorkoutTitleByGenre(genre: string): string { + // Implementation for future genre-specific selection + return getRandomWorkoutTitle(); +}