workout screen WIP

This commit is contained in:
DocNR 2025-02-24 22:27:01 -05:00
parent 05d3c02523
commit 17cb416777
22 changed files with 2705 additions and 40 deletions

View File

@ -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<string | null>(null)
const [favoriteWorkouts, setFavoriteWorkouts] = useState<FavoriteTemplateData[]>([])
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 (
<TabScreen>
<Header
title="Workout"
rightElement={
<Button
variant="ghost"
size="icon"
onPress={() => console.log('New workout')}
>
<Plus className="text-foreground" />
</Button>
}
/>
<View className="flex-1 items-center justify-center">
<Text>Workout Screen</Text>
</View>
<Header title="Workout" />
<ScrollView
className="flex-1 px-4"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20 }}
>
<HomeWorkout
onStartBlank={handleQuickStart} // Use the new handler here
onSelectTemplate={() => router.push('/(workout)/template-select')}
/>
{/* Favorites section */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Favorites</CardTitle>
</CardHeader>
<CardContent>
{isLoadingFavorites ? (
<View className="p-6">
<Text className="text-muted-foreground text-center">
Loading favorites...
</Text>
</View>
) : favoriteWorkouts.length === 0 ? (
<View className="p-6">
<Text className="text-muted-foreground text-center">
Star workouts from your library to see them here
</Text>
</View>
) : (
<View className="gap-4">
{favoriteWorkouts.map(template => (
<FavoriteTemplate
key={template.id}
title={template.title}
exercises={template.exercises}
duration={template.duration}
exerciseCount={template.exerciseCount}
isFavorited={true}
onPress={() => handleStartWorkout(template.id)}
onFavoritePress={() => handleFavoritePress(template.id)}
/>
))}
</View>
)}
</CardContent>
</Card>
</ScrollView>
<AlertDialog open={showActiveWorkoutModal}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Active Workout</AlertDialogTitle>
<AlertDialogDescription>
You have an active workout in progress. Would you like to finish it first?
</AlertDialogDescription>
</AlertDialogHeader>
<View className="flex-row justify-end gap-3">
<AlertDialogCancel onPress={() => setShowActiveWorkoutModal(false)}>
<Text>Start New</Text>
</AlertDialogCancel>
<AlertDialogAction onPress={handleContinueExisting}>
<Text>Continue Workout</Text>
</AlertDialogAction>
</View>
</AlertDialogContent>
</AlertDialog>
</TabScreen>
);
)
}

View File

@ -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 (
<Stack>
<Stack
screenOptions={{
headerShown: false,
contentStyle: {
backgroundColor: theme.colors.background
},
presentation: 'modal', // Make all screens in this group modal by default
animation: 'slide_from_bottom',
gestureEnabled: true, // Allow gesture to dismiss
gestureDirection: 'vertical', // Swipe down to dismiss
}}
>
<Stack.Screen
name="new-exercise"
name="create"
options={{
title: 'New Exercise'
// Modal presentation for create screen
presentation: 'modal',
animation: 'slide_from_bottom',
gestureEnabled: true,
gestureDirection: 'vertical',
}}
/>
<Stack.Screen
name="template-select"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
gestureEnabled: true,
}}
/>
<Stack.Screen
name="add-exercises"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
gestureEnabled: true,
}}
/>
</Stack>
);
)
}

View File

@ -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<BaseExercise[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
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 (
<TabScreen>
<View className="flex-1 pt-12">
{/* Close button in the top right */}
<View className="absolute top-12 right-4 z-10">
<Button
variant="ghost"
size="icon"
onPress={() => router.back()}
>
<X className="text-foreground" />
</Button>
</View>
<View className="px-4 pt-4 pb-2">
<Text className="text-xl font-semibold mb-4">Add Exercises</Text>
<Input
placeholder="Search exercises..."
value={search}
onChangeText={setSearch}
className="text-foreground"
/>
</View>
<ScrollView className="flex-1">
<View className="px-4">
<Text className="mb-4 text-muted-foreground">
Selected: {selectedIds.length} exercises
</Text>
<View className="gap-3">
{filteredExercises.map(exercise => (
<Card key={exercise.id}>
<CardContent className="p-4">
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">{exercise.category}</Text>
{exercise.equipment && (
<Text className="text-xs text-muted-foreground mt-0.5">{exercise.equipment}</Text>
)}
</View>
<Button
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
onPress={() => handleToggleSelection(exercise.id)}
size="sm"
>
<Text className={selectedIds.includes(exercise.id) ? 'text-primary-foreground' : ''}>
{selectedIds.includes(exercise.id) ? 'Selected' : 'Add'}
</Text>
</Button>
</View>
</CardContent>
</Card>
))}
</View>
</View>
</ScrollView>
<View className="p-4 border-t border-border">
<Button
className="w-full"
onPress={handleAddSelected}
disabled={selectedIds.length === 0}
>
<Text className="text-primary-foreground">
Add {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
</Text>
</Button>
</View>
</View>
</TabScreen>
);
}

364
app/(workout)/create.tsx Normal file
View File

@ -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 (
<TabScreen>
<View className="flex-1 items-center justify-center p-6">
<Text className="text-xl font-semibold text-foreground text-center mb-4">
No active workout
</Text>
<Button
onPress={() => router.back()}
>
<Text className="text-primary-foreground">Go Back</Text>
</Button>
</View>
</TabScreen>
);
}
return (
<TabScreen>
<View style={{ flex: 1, paddingTop: insets.top }}>
{/* Swipe indicator */}
<View className="w-full items-center py-2">
<View className="w-10 h-1 rounded-full bg-muted-foreground/30" />
</View>
{/* Header with Title and Finish Button */}
<View className="px-4 py-3 border-b border-border">
{/* Finish button in top right */}
<View className="flex-row justify-end mb-2">
<Button
variant="purple"
className="px-4"
onPress={() => completeWorkout()}
>
<Text className="text-white font-medium">Finish</Text>
</Button>
</View>
{/* Full-width workout title */}
<View className="mb-3">
<EditableText
value={activeWorkout.title}
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
placeholder="Workout Title"
textStyle={{
fontSize: 24,
fontWeight: '700',
}}
/>
</View>
{/* Timer Display */}
<View className="flex-row items-center">
<Text style={styles.timerText} className={cn(
"text-2xl font-mono",
status === 'paused' ? "text-muted-foreground" : "text-foreground"
)}>
{formatTime(elapsedTime)}
</Text>
{status === 'active' ? (
<Button
variant="ghost"
size="icon"
className="ml-2"
onPress={pauseWorkout}
>
<Pause className="text-foreground" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="ml-2"
onPress={resumeWorkout}
>
<Play className="text-foreground" />
</Button>
)}
</View>
</View>
{/* Scrollable Exercises List */}
<ScrollView
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 16,
paddingBottom: insets.bottom + 20,
paddingTop: 16
}}
>
{activeWorkout.exercises.length > 0 ? (
activeWorkout.exercises.map((exercise, exerciseIndex) => (
<Card
key={exercise.id}
className="mb-6 overflow-hidden border border-border bg-card"
>
{/* Exercise Header */}
<View className="flex-row justify-between items-center px-4 py-3 border-b border-border">
<Text className="text-lg font-semibold text-purple">
{exercise.title}
</Text>
<Button
variant="ghost"
size="icon"
onPress={() => {
// Open exercise options menu
console.log('Open exercise options');
}}
>
<MoreHorizontal className="text-muted-foreground" size={20} />
</Button>
</View>
{/* Sets Info */}
<View className="px-4 py-2">
<Text className="text-sm text-muted-foreground">
{exercise.sets.filter(s => s.isCompleted).length} sets completed
</Text>
</View>
{/* Set Headers */}
<View className="flex-row px-4 py-2 border-t border-border bg-muted/30">
<Text className="w-16 text-sm font-medium text-muted-foreground">SET</Text>
<Text className="w-20 text-sm font-medium text-muted-foreground">PREV</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text>
<View style={{ width: 44 }} />
</View>
{/* Exercise Sets */}
<CardContent className="p-0">
{exercise.sets.map((set, setIndex) => {
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null;
return (
<View
key={set.id}
className={cn(
"flex-row items-center px-4 py-3 border-t border-border",
set.isCompleted && "bg-primary/5"
)}
>
{/* Set Number */}
<Text className="w-16 text-base font-medium text-foreground">
{setIndex + 1}
</Text>
{/* Previous Set */}
<Text className="w-20 text-sm text-muted-foreground">
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
</Text>
{/* Weight Input */}
<View className="flex-1 px-2">
<View className={cn(
"bg-secondary h-10 rounded-md px-3 justify-center",
set.isCompleted && "bg-primary/10"
)}>
<Text className="text-center text-foreground">
{set.weight}
</Text>
</View>
</View>
{/* Reps Input */}
<View className="flex-1 px-2">
<View className={cn(
"bg-secondary h-10 rounded-md px-3 justify-center",
set.isCompleted && "bg-primary/10"
)}>
<Text className="text-center text-foreground">
{set.reps}
</Text>
</View>
</View>
{/* Complete Button */}
<Button
variant="ghost"
size="icon"
className="w-11 h-11"
onPress={() => handleCompleteSet(exerciseIndex, setIndex)}
>
<CheckCircle2
className={set.isCompleted ? "text-purple" : "text-muted-foreground"}
fill={set.isCompleted ? "currentColor" : "none"}
size={22}
/>
</Button>
</View>
);
})}
</CardContent>
{/* Add Set Button */}
<Button
variant="ghost"
className="flex-row justify-center items-center py-3 border-t border-border"
onPress={() => handleAddSet(exerciseIndex)}
>
<Plus size={18} className="text-foreground mr-2" />
<Text className="text-foreground">Add Set</Text>
</Button>
</Card>
))
) : (
<View className="flex-1 items-center justify-center py-20">
<Text className="text-lg text-muted-foreground text-center">
No exercises added. Add exercises to start your workout.
</Text>
</View>
)}
</ScrollView>
{/* Add Exercise FAB */}
<View style={{
position: 'absolute',
right: 16,
bottom: insets.bottom + 16
}}>
<FloatingActionButton
icon={Plus}
onPress={() => router.push('/(workout)/add-exercises')}
/>
</View>
{/* Cancel Workout Dialog */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Workout</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this workout? All progress will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<View className="flex-row justify-end gap-3">
<AlertDialogAction
onPress={() => setShowCancelDialog(false)}
>
<Text>Continue Workout</Text>
</AlertDialogAction>
<AlertDialogAction
onPress={async () => {
await clearAutoSave();
router.back();
}}
>
<Text>Cancel Workout</Text>
</AlertDialogAction>
</View>
</AlertDialogContent>
</AlertDialog>
</View>
</TabScreen>
);
}
const styles = StyleSheet.create({
timerText: {
fontVariant: ['tabular-nums']
}
});

View File

@ -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 (
<ScrollView className="flex-1 p-4">
<Text className="text-lg font-semibold mb-4">Recent Templates</Text>
{MOCK_TEMPLATES.map(template => (
<Card key={template.id} className="mb-4">
<CardContent className="p-4">
<Text className="text-lg font-semibold">{template.title}</Text>
<Text className="text-sm text-muted-foreground mb-2">{template.category}</Text>
{/* Exercise Preview */}
<View className="mb-4">
{template.exercises.map((exercise, index) => (
<Text key={index} className="text-sm text-muted-foreground">
{exercise.title} - {exercise.sets}×{exercise.reps}
</Text>
))}
</View>
<Button onPress={() => handleSelectTemplate(template)}>
<Text>Start Workout</Text>
</Button>
</CardContent>
</Card>
))}
</ScrollView>
);
}

116
components/EditableText.tsx Normal file
View File

@ -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<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
inputStyle?: StyleProp<TextStyle>;
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<TextInput>(null);
const { isDarkColorScheme } = useColorScheme();
const handleSubmit = () => {
if (tempValue.trim()) {
onChangeText(tempValue);
} else {
setTempValue(value);
}
setIsEditing(false);
};
return (
<View style={[styles.container, style]}>
{isEditing ? (
<View className="flex-row items-center bg-secondary rounded-lg px-3 py-2">
<TextInput
ref={inputRef}
value={tempValue}
onChangeText={setTempValue}
onBlur={handleSubmit}
onSubmitEditing={handleSubmit}
autoFocus
selectTextOnFocus
style={[
styles.input,
{ color: isDarkColorScheme ? '#FFFFFF' : '#000000' },
textStyle,
inputStyle
]}
placeholder={placeholder}
placeholderTextColor={placeholderTextColor || isDarkColorScheme ? '#9CA3AF' : '#6B7280'}
/>
<TouchableOpacity
onPress={handleSubmit}
className="p-2 ml-2"
>
<Check className="text-primary" size={20} />
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
onPress={() => setIsEditing(true)}
className="flex-col p-2 rounded-lg"
activeOpacity={0.7}
>
<Text
className={cn(
"text-lg font-semibold text-foreground",
!value && "text-muted-foreground"
)}
style={textStyle}
numberOfLines={1}
>
{value || placeholder}
</Text>
<View className="mt-1">
<View className="flex-row items-center self-start px-1.5 py-1 rounded bg-muted/20">
<Edit2 size={14} className="text-muted-foreground" />
<Text className="text-xs text-muted-foreground ml-1">Edit</Text>
</View>
</View>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
},
input: {
flex: 1,
fontSize: 18,
fontWeight: '600',
padding: 0,
},
});

View File

@ -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 (
<View className="flex-1 items-center justify-center p-6">
<View className="items-center opacity-60">
<View className="w-24 h-24 rounded-full bg-primary/10 items-center justify-center mb-6">
<Dumbbell size={48} className="text-primary" />
</View>
<Text className="text-xl font-semibold mb-2">No exercises added</Text>
<Text className="text-muted-foreground text-center">
Tap the + button to add exercises to your workout
</Text>
</View>
</View>
);
}
// 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 (
<View className="flex-1">
{/* Exercise Navigation */}
<View className="flex-row items-center justify-between px-4 py-2 border-b border-border">
<Button
variant="ghost"
size="icon"
onPress={() => previousExercise()}
disabled={!hasPreviousExercise}
>
<ChevronLeft className="text-foreground" />
</Button>
<View className="flex-1 px-4">
<Text className="text-lg font-semibold text-center">
{currentExercise.title}
</Text>
<Text className="text-sm text-muted-foreground text-center">
{currentExercise.equipment} {currentExercise.category}
</Text>
</View>
<Button
variant="ghost"
size="icon"
onPress={() => nextExercise()}
disabled={!hasNextExercise}
>
<ChevronRight className="text-foreground" />
</Button>
</View>
{/* Sets List */}
<ScrollView className="flex-1 px-4 py-2">
<Card>
<CardContent className="p-0">
{/* Header Row */}
<View className={cn(
"flex-row items-center px-4 py-2 border-b border-border",
"bg-muted/50"
)}>
<Text className="w-8 text-sm font-medium text-muted-foreground">Set</Text>
<Text className="w-20 text-sm text-center text-muted-foreground">Prev</Text>
<Text className="flex-1 text-sm text-center text-muted-foreground">kg</Text>
<Text className="flex-1 text-sm text-center text-muted-foreground">Reps</Text>
<View className="w-10" />
</View>
{/* Sets */}
{currentExercise.sets.map((set: WorkoutSet, index: number) => (
<SetInput
key={set.id}
exerciseIndex={currentExerciseIndex}
setIndex={index}
setNumber={index + 1}
weight={set.weight}
reps={set.reps}
isCompleted={set.isCompleted}
previousSet={index > 0 ? currentExercise.sets[index - 1] : undefined}
/>
))}
</CardContent>
</Card>
</ScrollView>
{/* Bottom Controls */}
<View className="flex-row justify-center gap-2 p-4 border-t border-border">
<Button
variant="outline"
className="flex-1"
onPress={() => startRest(90)}
>
<TimerReset className="mr-2 text-foreground" />
<Text>Rest Timer</Text>
</Button>
<Button
variant="outline"
className="flex-1"
onPress={handleAddSet}
>
<Plus className="mr-2 text-foreground" />
<Text>Add Set</Text>
</Button>
</View>
</View>
);
}

View File

@ -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 (
<TouchableOpacity
activeOpacity={0.7}
onPress={onPress}
>
<Card>
<CardContent className="p-4">
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="text-lg font-semibold text-card-foreground mb-1">
{title}
</Text>
<Text className="text-sm text-muted-foreground mb-2">
{exercises.slice(0, 3).map(ex =>
`${ex.title} (${ex.sets}×${ex.reps})`
).join(', ')}
{exercises.length > 3 && '...'}
</Text>
<View className="flex-row items-center gap-4">
<View className="flex-row items-center gap-1">
<Dumbbell size={16} className="text-muted-foreground" />
<Text className="text-sm text-muted-foreground">
{exerciseCount} exercises
</Text>
</View>
{duration && (
<View className="flex-row items-center gap-1">
<Clock size={16} className="text-muted-foreground" />
<Text className="text-sm text-muted-foreground">
{duration} min
</Text>
</View>
)}
</View>
</View>
<Button
variant="ghost"
size="icon"
onPress={(e: GestureResponderEvent) => {
e.stopPropagation();
onFavoritePress?.();
}}
>
<Star
size={20}
className={isFavorited ? "text-primary" : "text-muted-foreground"}
fill={isFavorited ? "currentColor" : "none"}
/>
</Button>
</View>
</CardContent>
</Card>
</TouchableOpacity>
);
}

View File

@ -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 (
<Card>
<CardHeader>
<CardTitle>Start a Workout</CardTitle>
<CardDescription>Begin a new workout or choose from your templates</CardDescription>
</CardHeader>
<CardContent className="flex-col gap-4">
<Button
size="lg"
className="w-full items-center justify-center gap-2"
onPress={onStartBlank}
>
<Play className="h-5 w-5" />
<Text className="text-primary-foreground">Quick Start</Text>
</Button>
<Button
variant="outline"
size="lg"
className="w-full items-center justify-center gap-2"
onPress={onSelectTemplate}
>
<Plus className="h-5 w-5" />
<Text>Use Template</Text>
</Button>
</CardContent>
</Card>
);
}

View File

@ -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 (
<View className="flex-1 items-center justify-center bg-background/80">
{/* Timer Display */}
<View className="items-center mb-8">
<Text className="text-4xl font-bold text-foreground mb-2">
Rest Timer
</Text>
<Text className="text-6xl font-bold text-primary">
{formatTime(restTimer.remaining)}
</Text>
</View>
{/* Controls */}
<View className="flex-row gap-4">
<Button
size="lg"
variant="outline"
onPress={stopRest}
>
<Square className="mr-2 text-foreground" />
<Text>Skip</Text>
</Button>
<Button
size="lg"
variant="outline"
onPress={handleAddTime}
>
<Plus className="mr-2 text-foreground" />
<Text>Add 30s</Text>
</Button>
</View>
{/* Next Exercise Preview */}
{nextExercise && (
<View className="mt-8 items-center">
<Text className="text-sm text-muted-foreground mb-1">
Next Exercise
</Text>
<Text className="text-lg font-semibold text-foreground">
{nextExercise.title}
</Text>
</View>
)}
</View>
);
}

View File

@ -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 (
<View className={cn(
"flex-row items-center px-4 py-2 border-b border-border",
isCompleted && "bg-primary/5"
)}>
{/* Set Number */}
<Text className="w-8 text-sm font-medium text-muted-foreground">
{setNumber}
</Text>
{/* Previous Set */}
<Text className="w-20 text-sm text-center text-muted-foreground">
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
</Text>
{/* Weight Input */}
<TouchableOpacity
className="flex-1 mx-1"
activeOpacity={0.7}
onLongPress={handleCopyPreviousWeight}
>
<TextInput
className={cn(
"h-10 px-3 rounded-md text-center text-foreground",
"bg-secondary border border-border",
isCompleted && "bg-primary/10 border-primary/20"
)}
value={weightValue === '0' ? '' : weightValue}
onChangeText={handleWeightChange}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor="text-muted-foreground"
returnKeyType="next"
selectTextOnFocus
/>
</TouchableOpacity>
{/* Reps Input */}
<TouchableOpacity
className="flex-1 mx-1"
activeOpacity={0.7}
onLongPress={handleCopyPreviousReps}
>
<TextInput
className={cn(
"h-10 px-3 rounded-md text-center text-foreground",
"bg-secondary border border-border",
isCompleted && "bg-primary/10 border-primary/20"
)}
value={repsValue === '0' ? '' : repsValue}
onChangeText={handleRepsChange}
keyboardType="number-pad"
placeholder="0"
placeholderTextColor="text-muted-foreground"
returnKeyType="done"
selectTextOnFocus
/>
</TouchableOpacity>
{/* Complete Button */}
<Button
variant={isCompleted ? "secondary" : "ghost"}
size="icon"
className="w-10 h-10"
onPress={handleCompleteSet}
>
<Check
className={cn(
"w-4 h-4",
isCompleted ? "text-primary" : "text-muted-foreground"
)}
/>
</Button>
</View>
);
}

View File

@ -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 (
<View className={cn(
"px-4 py-2 border-b border-border",
status === 'paused' && "bg-muted/50"
)}>
{/* Header Row */}
<View className="flex-row items-center justify-between mb-2">
<Button
variant="ghost"
size="icon"
onPress={handleBack}
>
<ChevronLeft className="text-foreground" />
</Button>
<View className="flex-1 px-4">
<EditableText
value={activeWorkout.title}
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
style={{ alignItems: 'center' }}
placeholder="Workout Title"
/>
</View>
<View className="flex-row gap-2">
{status === 'active' ? (
<Button
variant="ghost"
size="icon"
onPress={pauseWorkout}
>
<Pause className="text-foreground" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
onPress={resumeWorkout}
>
<Play className="text-foreground" />
</Button>
)}
<Button
variant="destructive"
size="icon"
onPress={completeWorkout}
>
<Square className="text-destructive-foreground" />
</Button>
</View>
</View>
{/* Status Row */}
<View className="flex-row items-center justify-between">
<Text className={cn(
"text-2xl font-bold",
status === 'paused' ? "text-muted-foreground" : "text-foreground"
)}>
{formatTime(elapsedTime)}
</Text>
<Text className="text-sm text-muted-foreground capitalize">
{activeWorkout.type}
</Text>
</View>
</View>
);
}

19
package-lock.json generated
View File

@ -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",

View File

@ -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

536
stores/workoutStore.ts Normal file
View File

@ -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<Workout>) => void;
pauseWorkout: () => void;
resumeWorkout: () => void;
completeWorkout: () => void;
cancelWorkout: () => void;
reset: () => void;
// Exercise and Set Management
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => 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<Workout>) => 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<WorkoutSet>) => 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<FavoriteItem[]>;
addFavorite: (template: WorkoutTemplate) => Promise<void>;
removeFavorite: (templateId: string) => Promise<void>;
checkFavoriteStatus: (templateId: string) => boolean;
// New template management
startWorkoutFromTemplate: (templateId: string) => Promise<void>;
// Additional workout actions
endWorkout: () => Promise<void>;
clearAutoSave: () => Promise<void>;
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<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
...initialState,
// Core Workout Flow
startWorkout: (workoutData: Partial<Workout> = {}) => {
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<WorkoutSet>) => {
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<WorkoutTemplate | null> {
// 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<void> {
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);

View File

@ -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;
}
/**

View File

@ -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)
};
}

242
types/workout.ts Normal file
View File

@ -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<Workout> }
| { type: 'PAUSE_WORKOUT' }
| { type: 'RESUME_WORKOUT' }
| { type: 'COMPLETE_WORKOUT' }
| { type: 'UPDATE_SET'; payload: { exerciseIndex: number; setIndex: number; data: Partial<WorkoutSet> } }
| { 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)
};
}

17
utils/createSelectors.ts Normal file
View File

@ -0,0 +1,17 @@
// utils/createSelectors.ts
import { StoreApi, UseBoundStore } from 'zustand';
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S,
) => {
let store = _store as WithSelectors<typeof _store>;
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;
};

7
utils/formatTime.ts Normal file
View File

@ -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')}`;
}

49
utils/workout.ts Normal file
View File

@ -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']
}
};
}

123
utils/workoutTitles.ts Normal file
View File

@ -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();
}