Library tab MVP finished

This commit is contained in:
DocNR 2025-02-19 21:39:47 -05:00
parent 083367e872
commit f26a59f569
20 changed files with 2653 additions and 1286 deletions

View File

@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Detailed SQLite error logging
- Improved transaction management
- Added proper error types and propagation
- Template management features
- Basic template creation interface
- Favorite template functionality
- Template categories and filtering
- Quick-start template actions
### Changed
- Improved exercise library interface
@ -49,6 +54,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added proper SQLite error types
- Enhanced transaction rollback handling
- Added detailed debug logging
- Updated type system for better data handling
- Consolidated exercise and template types
- Added proper type guards
- Improved type safety in components
- Enhanced template display UI
- Added category pills for filtering
- Improved spacing and layout
- Better visual hierarchy for favorites
### Fixed
- Exercise deletion functionality
@ -57,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- TypeScript parameter typing in database services
- Null value handling in database operations
- Development seeding duplicate prevention
- Template category spacing issues
- Exercise list rendering on iOS
- Database reset and reseeding behavior
### Technical Details
1. Database Schema Enforcement:
@ -87,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Exercise creation now enforces schema constraints
- Input validation prevents invalid data entry
- Enhanced error messages provide better debugging information
- Template management requires updated type definitions
## [0.1.0] - 2024-02-09

View File

@ -1,129 +1,78 @@
// app/(tabs)/library/exercises.tsx
import React, { useRef, useState, useCallback } from 'react';
import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native';
import React, { useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { ExerciseCard } from '@/components/exercises/ExerciseCard';
import { Input } from '@/components/ui/input';
import { Search, Dumbbell } from 'lucide-react-native';
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
import { Dumbbell } from 'lucide-react-native';
import { BaseExercise, Exercise } from '@/types/exercise';
import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList';
import { ExerciseDetails } from '@/components/exercises/ExerciseDetails';
import { ExerciseDisplay, ExerciseType, BaseExercise } from '@/types/exercise';
import { useExercises } from '@/lib/hooks/useExercises';
export default function ExercisesScreen() {
const sectionListRef = useRef<SectionList>(null);
const [showNewExercise, setShowNewExercise] = useState(false);
const [currentSection, setCurrentSection] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState<ExerciseType | null>(null);
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
const {
exercises,
loading,
error,
stats,
createExercise,
deleteExercise,
refreshExercises
refreshExercises,
updateFilters,
clearFilters
} = useExercises();
// Organize exercises into sections
const sections = React.useMemo(() => {
const exercisesByLetter = exercises.reduce((acc, exercise) => {
const firstLetter = exercise.title[0].toUpperCase();
if (!acc[firstLetter]) {
acc[firstLetter] = [];
// Filter exercises based on search query
React.useEffect(() => {
if (searchQuery) {
updateFilters({ searchQuery });
} else {
updateFilters({ searchQuery: undefined });
}
}, [searchQuery, updateFilters]);
// Update type filter when activeFilter changes
React.useEffect(() => {
if (activeFilter) {
updateFilters({ type: [activeFilter] });
} else {
clearFilters();
}
}, [activeFilter, updateFilters, clearFilters]);
const handleExercisePress = (exercise: ExerciseDisplay) => {
setSelectedExercise(exercise);
};
const handleEdit = async () => {
// TODO: Implement edit functionality
setSelectedExercise(null);
};
const handleCreateExercise = async (exerciseData: BaseExercise) => {
// Convert BaseExercise to include required source information
const exerciseWithSource: Omit<BaseExercise, 'id'> = {
...exerciseData,
availability: {
source: ['local']
}
acc[firstLetter].push(exercise);
return acc;
}, {} as Record<string, Exercise[]>);
return Object.entries(exercisesByLetter)
.map(([letter, exercises]) => ({
title: letter,
data: exercises.sort((a, b) => a.title.localeCompare(b.title))
}))
.sort((a, b) => a.title.localeCompare(b.title));
}, [exercises]);
const handleViewableItemsChanged = useCallback(({
viewableItems
}: {
viewableItems: ViewToken[];
}) => {
const firstSection = viewableItems.find(item => item.section)?.section?.title;
if (firstSection) {
setCurrentSection(firstSection);
}
}, []);
const scrollToSection = useCallback((letter: string) => {
const sectionIndex = sections.findIndex(section => section.title === letter);
if (sectionIndex !== -1 && sectionListRef.current) {
// Try to scroll to section
sectionListRef.current.scrollToLocation({
animated: true,
sectionIndex,
itemIndex: 0,
viewPosition: 0, // 0 means top of the view
});
// Log for debugging
if (__DEV__) {
console.log('Scrolling to section:', {
letter,
sectionIndex,
totalSections: sections.length
});
}
}
}, [sections]);
// Add getItemLayout to optimize scrolling
const getItemLayout = useCallback((data: any, index: number) => ({
length: 100, // Approximate height of each item
offset: 100 * index,
index,
}), []);
const handleAddExercise = async (exerciseData: Omit<BaseExercise, 'id' | 'availability' | 'created_at'>) => {
try {
const newExercise: Omit<Exercise, 'id'> = {
...exerciseData,
source: 'local',
created_at: Date.now(),
availability: {
source: ['local']
},
format_json: exerciseData.format ? JSON.stringify(exerciseData.format) : undefined,
format_units_json: exerciseData.format_units ? JSON.stringify(exerciseData.format_units) : undefined
};
await createExercise(newExercise);
setShowNewExercise(false);
} catch (error) {
console.error('Error adding exercise:', error);
}
};
await createExercise(exerciseWithSource);
setShowNewExercise(false);
};
const handleDelete = async (id: string) => {
try {
await deleteExercise(id);
} catch (error) {
console.error('Error deleting exercise:', error);
}
};
const handleExercisePress = (exerciseId: string) => {
console.log('Selected exercise:', exerciseId);
};
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const availableLetters = new Set(sections.map(section => section.title));
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-background">
<Text>Loading exercises...</Text>
<ActivityIndicator size="large" color="#8B5CF6" />
<Text className="mt-4 text-muted-foreground">Loading exercises...</Text>
</View>
);
}
@ -135,7 +84,7 @@ export default function ExercisesScreen() {
{error.message}
</Text>
<Button onPress={refreshExercises}>
<Text>Retry</Text>
<Text className="text-primary-foreground">Retry</Text>
</Button>
</View>
);
@ -143,122 +92,94 @@ export default function ExercisesScreen() {
return (
<View className="flex-1 bg-background">
{/* Stats Bar */}
<View className="flex-row justify-between items-center p-4 bg-card border-b border-border">
<View>
<Text className="text-sm text-muted-foreground">Total Exercises</Text>
<Text className="text-2xl font-bold">{stats.totalCount}</Text>
</View>
<View className="flex-row gap-4">
<View>
<Text className="text-xs text-muted-foreground">Push</Text>
<Text className="text-base font-medium">{stats.byCategory['Push'] || 0}</Text>
{/* Search bar */}
<View className="px-4 py-3">
<View className="relative flex-row items-center bg-muted rounded-xl">
<View className="absolute left-3 z-10">
<Search size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-xs text-muted-foreground">Pull</Text>
<Text className="text-base font-medium">{stats.byCategory['Pull'] || 0}</Text>
</View>
<View>
<Text className="text-xs text-muted-foreground">Legs</Text>
<Text className="text-base font-medium">{stats.byCategory['Legs'] || 0}</Text>
</View>
<View>
<Text className="text-xs text-muted-foreground">Core</Text>
<Text className="text-base font-medium">{stats.byCategory['Core'] || 0}</Text>
</View>
</View>
</View>
{/* Exercise List with Alphabet Scroll */}
<View className="flex-1 flex-row">
{/* Main List */}
<View className="flex-1">
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
renderSectionHeader={({ section }) => (
<View className="py-2 px-4 bg-background/80">
<Text className="text-lg font-semibold text-foreground">
{section.title}
</Text>
</View>
)}
renderItem={({ item }) => (
<View className="px-4 py-1">
<ExerciseCard
{...item}
onPress={() => handleExercisePress(item.id)}
onDelete={() => handleDelete(item.id)}
/>
</View>
)}
stickySectionHeadersEnabled
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
onViewableItemsChanged={handleViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}
<Input
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search"
className="pl-9 bg-transparent h-10 flex-1"
/>
</View>
{/* Alphabet List */}
<View
className="w-8 justify-center bg-transparent px-1"
onStartShouldSetResponder={() => true}
onResponderMove={(evt) => {
const touch = evt.nativeEvent;
const element = evt.target;
// Get the layout of the alphabet bar
if (element) {
const elementPosition = (element as any).measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => {
// Calculate which letter we're touching based on position
const totalHeight = height;
const letterHeight = totalHeight / alphabet.length;
const touchY = touch.pageY - pageY;
const index = Math.min(
Math.max(Math.floor(touchY / letterHeight), 0),
alphabet.length - 1
);
const letter = alphabet[index];
if (availableLetters.has(letter)) {
scrollToSection(letter);
}
});
}
}}
>
{alphabet.map((letter) => (
<Text
key={letter}
className={
letter === currentSection
? 'text-xs text-center text-primary font-bold py-0.5'
: availableLetters.has(letter)
? 'text-xs text-center text-primary font-medium py-0.5'
: 'text-xs text-center text-muted-foreground py-0.5'
}
>
{letter}
</Text>
))}
</View>
</View>
{/* Filter buttons */}
<View className="flex-row px-4 pb-2 gap-2">
<Button
variant={activeFilter === null ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(null)}
>
<Text className={activeFilter === null ? "text-primary-foreground" : ""}>
All
</Text>
</Button>
<Button
variant={activeFilter === "strength" ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(activeFilter === "strength" ? null : "strength")}
>
<Text className={activeFilter === "strength" ? "text-primary-foreground" : ""}>
Strength
</Text>
</Button>
<Button
variant={activeFilter === "bodyweight" ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(activeFilter === "bodyweight" ? null : "bodyweight")}
>
<Text className={activeFilter === "bodyweight" ? "text-primary-foreground" : ""}>
Bodyweight
</Text>
</Button>
<Button
variant={activeFilter === "cardio" ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(activeFilter === "cardio" ? null : "cardio")}
>
<Text className={activeFilter === "cardio" ? "text-primary-foreground" : ""}>
Cardio
</Text>
</Button>
</View>
{/* Exercises list */}
<SimplifiedExerciseList
exercises={exercises}
onExercisePress={handleExercisePress}
/>
{/* Exercise details sheet */}
{selectedExercise && (
<ExerciseDetails
exercise={selectedExercise}
open={!!selectedExercise}
onOpenChange={(open) => {
if (!open) setSelectedExercise(null);
}}
onEdit={handleEdit}
/>
)}
{/* FAB for adding new exercise */}
<FloatingActionButton
icon={Dumbbell}
onPress={() => setShowNewExercise(true)}
/>
{/* New exercise sheet */}
<NewExerciseSheet
isOpen={showNewExercise}
onClose={() => setShowNewExercise(false)}
onSubmit={handleAddExercise}
onSubmit={handleCreateExercise}
/>
</View>
);

View File

@ -1,12 +1,28 @@
// app/(tabs)/library/templates.tsx
import { View, ScrollView } from 'react-native';
import { useState } from 'react';
import React, { useState } from 'react';
import { View, ScrollView, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text';
import { TemplateCard } from '@/components/templates/TemplateCard';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Search, Plus } from 'lucide-react-native';
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
import { Plus } from 'lucide-react-native';
import { Template } from '@/types/library';
import { TemplateCard } from '@/components/templates/TemplateCard';
import { TemplateDetails } from '@/components/templates/TemplateDetails';
import {
Template,
WorkoutTemplate,
TemplateCategory,
toWorkoutTemplate
} from '@/types/templates';
const TEMPLATE_CATEGORIES: TemplateCategory[] = [
'Full Body',
'Push/Pull/Legs',
'Upper/Lower',
'Conditioning',
'Custom'
];
// Mock data - move to a separate file later
const initialTemplates: Template[] = [
@ -43,14 +59,16 @@ const initialTemplates: Template[] = [
export default function TemplatesScreen() {
const [showNewTemplate, setShowNewTemplate] = useState(false);
const [templates, setTemplates] = useState(initialTemplates);
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<TemplateCategory | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<WorkoutTemplate | null>(null);
const handleDelete = (id: string) => {
setTemplates(current => current.filter(t => t.id !== id));
};
const handleTemplatePress = (template: Template) => {
// TODO: Show template details
console.log('Selected template:', template);
setSelectedTemplate(toWorkoutTemplate(template));
};
const handleStartWorkout = (template: Template) => {
@ -73,17 +91,74 @@ export default function TemplatesScreen() {
setShowNewTemplate(false);
};
// Filter templates based on category and search
const filteredTemplates = templates.filter(template => {
const matchesCategory = !activeCategory || template.category === activeCategory;
const matchesSearch = !searchQuery ||
template.title.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
// Separate favorites and regular templates
const favoriteTemplates = templates.filter(t => t.isFavorite);
const regularTemplates = templates.filter(t => !t.isFavorite);
const favoriteTemplates = filteredTemplates.filter(t => t.isFavorite);
const regularTemplates = filteredTemplates.filter(t => !t.isFavorite);
return (
<View className="flex-1 bg-background">
<ScrollView className="flex-1">
{/* Search bar */}
<View className="px-4 py-2">
<View className="relative flex-row items-center bg-muted rounded-xl">
<View className="absolute left-3 z-10">
<Search size={18} className="text-muted-foreground" />
</View>
<Input
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search templates"
className="pl-9 bg-transparent h-10 flex-1"
/>
</View>
</View>
{/* Category filters */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-4 py-2 border-b border-border"
>
<View className="flex-row gap-2">
<Button
variant={activeCategory === null ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveCategory(null)}
>
<Text className={activeCategory === null ? "text-primary-foreground" : ""}>
All
</Text>
</Button>
{TEMPLATE_CATEGORIES.map((category) => (
<Button
key={category}
variant={activeCategory === category ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveCategory(activeCategory === category ? null : category)}
>
<Text className={activeCategory === category ? "text-primary-foreground" : ""}>
{category}
</Text>
</Button>
))}
</View>
</ScrollView>
{/* Templates list */}
<ScrollView>
{/* Favorites Section */}
{favoriteTemplates.length > 0 && (
<View className="py-4">
<Text className="text-lg font-semibold mb-4 px-4">
<View>
<Text className="text-lg font-semibold px-4 py-2">
Favorites
</Text>
<View className="gap-3">
@ -102,8 +177,8 @@ export default function TemplatesScreen() {
)}
{/* All Templates Section */}
<View className="py-4">
<Text className="text-lg font-semibold mb-4 px-4">
<View>
<Text className="text-lg font-semibold px-4 py-2">
All Templates
</Text>
{regularTemplates.length > 0 ? (
@ -132,6 +207,17 @@ export default function TemplatesScreen() {
<View className="h-20" />
</ScrollView>
{/* Rest of the components (sheets & FAB) remain the same */}
{selectedTemplate && (
<TemplateDetails
template={selectedTemplate}
open={!!selectedTemplate}
onOpenChange={(open) => {
if (!open) setSelectedTemplate(null);
}}
/>
)}
<FloatingActionButton
icon={Plus}
onPress={() => setShowNewTemplate(true)}

View File

@ -1,189 +0,0 @@
// components/exercises/ExerciseCard.tsx
import React, { useState } 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 { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Trash2, Star, Info } from 'lucide-react-native';
import { Exercise } from '@/types/exercise';
interface ExerciseCardProps extends Exercise {
onPress: () => void;
onDelete: () => void;
onFavorite?: () => void;
}
export function ExerciseCard({
id,
title,
type,
category,
equipment,
description,
tags = [],
source,
instructions = [],
onPress,
onDelete,
onFavorite
}: ExerciseCardProps) {
const [showDetails, setShowDetails] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const handleDelete = () => {
onDelete();
setShowDeleteAlert(false);
};
return (
<>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<Card>
<CardContent className="p-4">
<View className="flex-row justify-between items-start">
<View className="flex-1">
{/* Title and Source Badge */}
<View className="flex-row items-center gap-2 mb-2">
<Text className="text-lg font-semibold text-foreground">{title}</Text>
<Badge variant={source === 'local' ? 'outline' : 'secondary'} className="capitalize">
<Text>{source}</Text>
</Badge>
</View>
{/* Category & Equipment */}
<View className="flex-row flex-wrap gap-2 mb-2">
<Badge variant="outline">
<Text>{category}</Text>
</Badge>
{equipment && (
<Badge variant="outline">
<Text>{equipment}</Text>
</Badge>
)}
</View>
{/* Description Preview */}
{description && (
<Text className="text-sm text-muted-foreground mb-2 native:pr-12" numberOfLines={2}>
{description}
</Text>
)}
{/* Tags */}
{tags.length > 0 && (
<View className="flex-row flex-wrap gap-1">
{tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">
<Text>{tag}</Text>
</Badge>
))}
</View>
)}
</View>
{/* Action Buttons */}
<View className="flex-row gap-1">
{onFavorite && (
<Button variant="ghost" size="icon" onPress={onFavorite} className="h-9 w-9">
<Star className="text-muted-foreground" size={18} />
</Button>
)}
<Button
variant="ghost"
size="icon"
onPress={() => setShowDetails(true)}
className="h-9 w-9"
>
<Info className="text-muted-foreground" size={18} />
</Button>
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<Trash2 className="text-destructive" size={18} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Exercise</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {title}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<View className="flex-row justify-end gap-3 mt-4">
<AlertDialogCancel asChild>
<Button variant="outline">
<Text>Cancel</Text>
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onPress={handleDelete}>
<Text className="text-white">Delete</Text>
</Button>
</AlertDialogAction>
</View>
</AlertDialogContent>
</AlertDialog>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Details Sheet */}
<Sheet isOpen={showDetails} onClose={() => setShowDetails(false)}>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<SheetContent>
<View className="gap-6">
{description && (
<View>
<Text className="text-base font-semibold mb-2">Description</Text>
<Text className="text-base">{description}</Text>
</View>
)}
<View>
<Text className="text-base font-semibold mb-2">Details</Text>
<View className="gap-2">
<Text className="text-base">Type: {type}</Text>
<Text className="text-base">Category: {category}</Text>
{equipment && <Text className="text-base">Equipment: {equipment}</Text>}
<Text className="text-base">Source: {source}</Text>
</View>
</View>
{instructions.length > 0 && (
<View>
<Text className="text-base font-semibold mb-2">Instructions</Text>
<View className="gap-2">
{instructions.map((instruction, index) => (
<Text key={index} className="text-base">
{index + 1}. {instruction}
</Text>
))}
</View>
</View>
)}
{tags.length > 0 && (
<View>
<Text className="text-base font-semibold mb-2">Tags</Text>
<View className="flex-row flex-wrap gap-2">
{tags.map(tag => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
</View>
</SheetContent>
</Sheet>
</>
);
}

View File

@ -0,0 +1,351 @@
// components/exercises/ExerciseDetails.tsx
import React from 'react';
import { View, ScrollView } from 'react-native';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import {
Edit2,
Dumbbell,
Target,
Calendar,
Hash,
AlertCircle,
LineChart,
Settings
} from 'lucide-react-native';
import { ExerciseDisplay } from '@/types/exercise';
import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme';
const Tab = createMaterialTopTabNavigator();
interface ExerciseDetailsProps {
exercise: ExerciseDisplay;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit?: () => void;
}
// Info Tab Component
function InfoTab({ exercise, onEdit }: { exercise: ExerciseDisplay; onEdit?: () => void }) {
const {
title,
type,
category,
equipment,
description,
instructions = [],
tags = [],
source = 'local',
usageCount,
lastUsed
} = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={source === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{source}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category & Equipment Section */}
<View className="space-y-4">
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{equipment && (
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Dumbbell size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Equipment</Text>
<Text className="text-base font-medium text-foreground capitalize">{equipment}</Text>
</View>
</View>
)}
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map((tag: string) => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Usage Stats Section */}
{(usageCount || lastUsed) && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{usageCount && (
<Text className="text-base text-muted-foreground">
Used {usageCount} times
</Text>
)}
{lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {lastUsed.toLocaleDateString()}
</Text>
)}
</View>
</View>
)}
{/* Edit Button */}
{onEdit && (
<Button
onPress={onEdit}
className="w-full mt-2"
>
<Edit2 size={18} className="mr-2 text-primary-foreground" />
<Text className="text-primary-foreground font-semibold">
Edit Exercise
</Text>
</Button>
)}
</View>
</ScrollView>
);
}
// Progress Tab Component
function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Placeholder for Charts */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<LineChart size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">Progress charts coming soon</Text>
</View>
{/* Personal Records Section */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Personal Records</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Weight</Text>
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Reps</Text>
<Text className="text-lg font-semibold text-foreground">--</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Best Volume</Text>
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
// Form Tab Component
function FormTab({ exercise }: { exercise: ExerciseDisplay }) {
const { instructions = [] } = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Instructions Section */}
{instructions.length > 0 ? (
<View>
<Text className="text-base font-semibold text-foreground mb-4">Instructions</Text>
<View className="gap-4">
{instructions.map((instruction: string, index: number) => (
<View key={index} className="flex-row gap-3">
<Text className="text-sm font-medium text-muted-foreground min-w-[24px]">
{index + 1}.
</Text>
<Text className="text-base text-foreground flex-1">{instruction}</Text>
</View>
))}
</View>
</View>
) : (
<View className="items-center justify-center py-8">
<AlertCircle size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">No form instructions available</Text>
</View>
)}
{/* Placeholder for Media */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<Text className="text-muted-foreground">Video demos coming soon</Text>
</View>
</View>
</ScrollView>
);
}
// Settings Tab Component
function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Format Settings */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Exercise Settings</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Format</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format && Object.entries(exercise.format).map(([key, enabled]) => (
enabled && (
<Badge key={key} variant="secondary">
<Text>{key}</Text>
</Badge>
)
))}
</View>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Units</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => (
<Badge key={key} variant="secondary">
<Text>{key}: {String(unit)}</Text>
</Badge>
))}
</View>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
export function ExerciseDetails({
exercise,
open,
onOpenChange,
onEdit
}: ExerciseDetailsProps) {
const theme = useTheme() as CustomTheme;
return (
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
<SheetHeader>
<SheetTitle>
<Text className="text-xl font-bold text-foreground">{exercise.title}</Text>
</SheetTitle>
</SheetHeader>
<SheetContent>
<View style={{ flex: 1, minHeight: 400 }} className="rounded-t-[10px]">
<Tab.Navigator
style={{ flex: 1 }}
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen
name="info"
options={{ title: 'Info' }}
>
{() => <InfoTab exercise={exercise} onEdit={onEdit} />}
</Tab.Screen>
<Tab.Screen
name="progress"
options={{ title: 'Progress' }}
>
{() => <ProgressTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen
name="form"
options={{ title: 'Form' }}
>
{() => <FormTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen
name="settings"
options={{ title: 'Settings' }}
>
{() => <SettingsTab exercise={exercise} />}
</Tab.Screen>
</Tab.Navigator>
</View>
</SheetContent>
</Sheet>
);
}

View File

@ -1,77 +0,0 @@
// components/exercises/ExerciseList.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { View, SectionList } from 'react-native';
import { Text } from '@/components/ui/text';
import { Badge } from '@/components/ui/badge';
import { ExerciseCard } from '@/components/exercises/ExerciseCard';
import { Exercise } from '@/types/exercise';
interface ExerciseListProps {
exercises: Exercise[];
onExercisePress: (exercise: Exercise) => void;
onExerciseDelete: (id: string) => void;
}
const ExerciseList = ({
exercises,
onExercisePress,
onExerciseDelete
}: ExerciseListProps) => {
const [sections, setSections] = useState<{title: string, data: Exercise[]}[]>([]);
const organizeExercises = useCallback(() => {
// Group by first letter
const grouped = exercises.reduce((acc, exercise) => {
const firstLetter = exercise.title[0].toUpperCase();
const section = acc.find(s => s.title === firstLetter);
if (section) {
section.data.push(exercise);
} else {
acc.push({title: firstLetter, data: [exercise]});
}
return acc;
}, [] as {title: string, data: Exercise[]}[]);
// Sort sections alphabetically
grouped.sort((a,b) => a.title.localeCompare(b.title));
// Sort exercises within sections
grouped.forEach(section => {
section.data.sort((a,b) => a.title.localeCompare(b.title));
});
setSections(grouped);
}, [exercises]);
useEffect(() => {
organizeExercises();
}, [organizeExercises]);
const renderSectionHeader = ({ section }: { section: {title: string} }) => (
<View className="sticky top-0 z-10 bg-background border-b border-border py-2 px-4">
<Text className="text-lg font-semibold">{section.title}</Text>
</View>
);
const renderExercise = ({ item }: { item: Exercise }) => (
<View className="px-4 py-1">
<ExerciseCard
{...item}
onPress={() => onExercisePress(item)}
onDelete={() => onExerciseDelete(item.id)}
/>
</View>
);
return (
<SectionList
sections={sections}
renderItem={renderExercise}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled
keyExtractor={item => item.id}
/>
);
};
export default ExerciseList;

View File

@ -0,0 +1,102 @@
// components/exercises/SimplifiedExerciseCard.tsx
import React from 'react';
import { View, TouchableOpacity, Image } from 'react-native';
import { Text } from '@/components/ui/text';
import { Badge } from '@/components/ui/badge';
import { ExerciseDisplay } from '@/types/exercise';
interface SimplifiedExerciseCardProps {
exercise: ExerciseDisplay;
onPress: () => void;
}
export function SimplifiedExerciseCard({ exercise, onPress }: SimplifiedExerciseCardProps) {
const {
title,
category,
equipment,
type,
source,
} = exercise;
const firstLetter = title.charAt(0).toUpperCase();
// Helper to check if exercise has workout-specific properties
const isWorkoutExercise = 'sets' in exercise && Array.isArray((exercise as any).sets);
// Access sets safely if available
const workoutExercise = isWorkoutExercise ?
(exercise as ExerciseDisplay & { sets: Array<{weight?: number, reps?: number}> }) :
null;
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={onPress}
className="flex-row items-center py-3 border-b border-border"
>
{/* Image placeholder or first letter */}
<View className="w-12 h-12 rounded-full bg-card flex items-center justify-center mr-3 overflow-hidden">
<Text className="text-2xl font-bold text-foreground">
{firstLetter}
</Text>
</View>
<View className="flex-1">
{/* Title */}
<Text className="text-base font-semibold text-foreground mb-1">
{title}
</Text>
{/* Tags row */}
<View className="flex-row flex-wrap gap-1">
{/* Category Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{category}</Text>
</Badge>
{/* Equipment Badge (if available) */}
{equipment && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{equipment}</Text>
</Badge>
)}
{/* Type Badge */}
{type && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{type}</Text>
</Badge>
)}
{/* Source Badge - colored for 'powr' */}
{source && (
<Badge
variant={source === 'powr' ? 'default' : 'secondary'}
className={`rounded-full py-0.5 ${
source === 'powr' ? 'bg-violet-500' : ''
}`}
>
<Text className={`text-xs ${
source === 'powr' ? 'text-white' : ''
}`}>
{source}
</Text>
</Badge>
)}
</View>
</View>
{/* Weight/Reps information if available from sets */}
{workoutExercise?.sets?.[0] && (
<View className="items-end">
<Text className="text-muted-foreground text-sm">
{workoutExercise.sets[0].weight && `${workoutExercise.sets[0].weight} lb`}
{workoutExercise.sets[0].weight && workoutExercise.sets[0].reps && ' '}
{workoutExercise.sets[0].reps && `(×${workoutExercise.sets[0].reps})`}
</Text>
</View>
)}
</TouchableOpacity>
);
}

View File

@ -0,0 +1,225 @@
// components/exercises/SimplifiedExerciseList.tsx
import React, { useRef, useState, useCallback } from 'react';
import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native';
import { Text } from '@/components/ui/text';
import { Badge } from '@/components/ui/badge';
import { ExerciseDisplay, WorkoutExercise } from '@/types/exercise';
// Create a combined interface for exercises that could have workout data
interface DisplayWorkoutExercise extends ExerciseDisplay, WorkoutExercise {}
interface SimplifiedExerciseListProps {
exercises: ExerciseDisplay[];
onExercisePress: (exercise: ExerciseDisplay) => void;
}
export const SimplifiedExerciseList = ({
exercises,
onExercisePress
}: SimplifiedExerciseListProps) => {
const sectionListRef = useRef<SectionList>(null);
const [currentSection, setCurrentSection] = useState<string>('');
// Organize exercises into sections
const sections = React.useMemo(() => {
const exercisesByLetter = exercises.reduce((acc, exercise) => {
const firstLetter = exercise.title[0].toUpperCase();
if (!acc[firstLetter]) {
acc[firstLetter] = [];
}
acc[firstLetter].push(exercise);
return acc;
}, {} as Record<string, ExerciseDisplay[]>);
return Object.entries(exercisesByLetter)
.map(([letter, exercises]) => ({
title: letter,
data: exercises.sort((a, b) => a.title.localeCompare(b.title))
}))
.sort((a, b) => a.title.localeCompare(b.title));
}, [exercises]);
const handleViewableItemsChanged = useCallback(({
viewableItems
}: {
viewableItems: ViewToken[];
}) => {
const firstSection = viewableItems.find(item => item.section)?.section?.title;
if (firstSection) {
setCurrentSection(firstSection);
}
}, []);
const scrollToSection = useCallback((letter: string) => {
const sectionIndex = sections.findIndex(section => section.title === letter);
if (sectionIndex !== -1 && sectionListRef.current) {
sectionListRef.current.scrollToLocation({
animated: true,
sectionIndex,
itemIndex: 0,
viewPosition: 0,
});
}
}, [sections]);
const getItemLayout = useCallback((data: any, index: number) => ({
length: 85, // Approximate height of each item
offset: 85 * index,
index,
}), []);
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const availableLetters = new Set(sections.map(section => section.title));
// Updated type guard
function isWorkoutExercise(exercise: ExerciseDisplay): exercise is DisplayWorkoutExercise {
return 'sets' in exercise && Array.isArray((exercise as any).sets);
}
const renderExerciseItem = ({ item }: { item: ExerciseDisplay }) => {
const firstLetter = item.title.charAt(0).toUpperCase();
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => onExercisePress(item)}
className="flex-row items-center px-4 py-3 border-b border-border"
>
{/* Image placeholder or first letter */}
<View className="w-12 h-12 rounded-full bg-card flex items-center justify-center mr-3 overflow-hidden">
<Text className="text-2xl font-bold text-foreground">
{firstLetter}
</Text>
</View>
<View className="flex-1">
{/* Title */}
<Text className="text-base font-semibold text-foreground mb-1">
{item.title}
</Text>
{/* Tags row */}
<View className="flex-row flex-wrap gap-1">
{/* Category Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{item.category}</Text>
</Badge>
{/* Equipment Badge (if available) */}
{item.equipment && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{item.equipment}</Text>
</Badge>
)}
{/* Type Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{item.type}</Text>
</Badge>
{/* Source Badge - colored for 'powr' */}
{item.source && (
<Badge
variant={item.source === 'powr' ? 'default' : 'secondary'}
className={`rounded-full py-0.5 ${
item.source === 'powr' ? 'bg-violet-500' : ''
}`}
>
<Text className={`text-xs ${
item.source === 'powr' ? 'text-white' : ''
}`}>
{item.source}
</Text>
</Badge>
)}
</View>
</View>
{/* Weight/Rep information if it was a WorkoutExercise */}
{isWorkoutExercise(item) && (
<View className="items-end">
<Text className="text-muted-foreground text-sm">
{item.sets?.[0]?.weight && `${item.sets[0].weight} lb`}
{item.sets?.[0]?.weight && item.sets?.[0]?.reps && ' '}
{item.sets?.[0]?.reps && `(×${item.sets[0].reps})`}
</Text>
</View>
)}
</TouchableOpacity>
);
};
return (
<View className="flex-1 flex-row bg-background">
{/* Main List */}
<View className="flex-1">
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
renderSectionHeader={({ section }) => (
<View className="py-2 px-4 bg-muted/80 border-b border-border">
<Text className="text-base font-semibold text-foreground">
{section.title}
</Text>
</View>
)}
renderItem={renderExerciseItem}
stickySectionHeadersEnabled
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={5}
onViewableItemsChanged={handleViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}
/>
</View>
{/* Alphabet List */}
<View
className="w-8 justify-center bg-transparent px-1"
onStartShouldSetResponder={() => true}
onResponderMove={(evt) => {
const touch = evt.nativeEvent;
const element = evt.target;
if (element) {
(element as any).measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => {
const totalHeight = height;
const letterHeight = totalHeight / alphabet.length;
const touchY = touch.pageY - pageY;
const index = Math.min(
Math.max(Math.floor(touchY / letterHeight), 0),
alphabet.length - 1
);
const letter = alphabet[index];
if (availableLetters.has(letter)) {
scrollToSection(letter);
}
});
}
}}
>
{alphabet.map((letter) => (
<Text
key={letter}
className={
letter === currentSection
? 'text-xs text-center text-primary font-bold py-0.5'
: availableLetters.has(letter)
? 'text-xs text-center text-primary font-medium py-0.5'
: 'text-xs text-center text-muted-foreground py-0.5'
}
>
{letter}
</Text>
))}
</View>
</View>
);
};
export default SimplifiedExerciseList;

View File

@ -1,19 +1,25 @@
// components/library/NewExerciseSheet.tsx
import React from 'react';
import { View, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import React, { useState } from 'react';
import { View, ScrollView } from 'react-native';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { BaseExercise, ExerciseType, ExerciseCategory, Equipment, Exercise } from '@/types/exercise';
import { StorageSource } from '@/types/shared';
import { Textarea } from '@/components/ui/textarea';
import { generateId } from '@/utils/ids';
import {
BaseExercise,
ExerciseType,
ExerciseCategory,
Equipment,
ExerciseFormat,
ExerciseFormatUnits
} from '@/types/exercise';
import { StorageSource } from '@/types/shared';
interface NewExerciseSheetProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (exercise: Omit<Exercise, 'id'>) => void; // Changed from BaseExercise
onSubmit: (exercise: BaseExercise) => void;
}
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
@ -29,7 +35,7 @@ const EQUIPMENT_OPTIONS: Equipment[] = [
];
export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) {
const [formData, setFormData] = React.useState({
const [formData, setFormData] = useState({
title: '',
type: 'strength' as ExerciseType,
category: 'Push' as ExerciseCategory,
@ -41,40 +47,39 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
reps: true,
rpe: true,
set_type: true
},
} as ExerciseFormat,
format_units: {
weight: 'kg' as const,
reps: 'count' as const,
rpe: '0-10' as const,
set_type: 'warmup|normal|drop|failure' as const
}
weight: 'kg',
reps: 'count',
rpe: '0-10',
set_type: 'warmup|normal|drop|failure'
} as ExerciseFormatUnits
});
const handleSubmit = () => {
if (!formData.title || !formData.equipment) return;
// Transform the form data into an Exercise type
const exerciseData: Omit<Exercise, 'id'> = {
const timestamp = Date.now();
// Create BaseExercise
const exercise: BaseExercise = {
id: generateId(),
title: formData.title,
type: formData.type,
category: formData.category,
equipment: formData.equipment,
description: formData.description,
tags: formData.tags,
tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()],
format: formData.format,
format_units: formData.format_units,
// Add required Exercise fields
source: 'local',
created_at: Date.now(),
created_at: timestamp,
availability: {
source: ['local']
},
format_json: JSON.stringify(formData.format),
format_units_json: JSON.stringify(formData.format_units)
source: ['local' as StorageSource],
lastSynced: undefined
}
};
onSubmit(exerciseData);
onClose();
onSubmit(exercise);
// Reset form
setFormData({
@ -97,6 +102,8 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
set_type: 'warmup|normal|drop|failure'
}
});
onClose();
};
return (
@ -105,17 +112,15 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
<SheetTitle>New Exercise</SheetTitle>
</SheetHeader>
<SheetContent>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView className="gap-4">
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<View>
<Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input
value={formData.title}
onChangeText={(text: string) => setFormData(prev => ({ ...prev, title: text }))}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/>
</View>
@ -125,10 +130,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
{EXERCISE_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'purple' : 'outline'}
variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
>
<Text className={formData.type === type ? 'text-white' : ''}>
<Text className={formData.type === type ? 'text-primary-foreground' : ''}>
{type}
</Text>
</Button>
@ -142,10 +147,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'purple' : 'outline'}
variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
>
<Text className={formData.category === category ? 'text-white' : ''}>
<Text className={formData.category === category ? 'text-primary-foreground' : ''}>
{category}
</Text>
</Button>
@ -159,10 +164,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
{EQUIPMENT_OPTIONS.map((eq) => (
<Button
key={eq}
variant={formData.equipment === eq ? 'purple' : 'outline'}
variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
>
<Text className={formData.equipment === eq ? 'text-white' : ''}>
<Text className={formData.equipment === eq ? 'text-primary-foreground' : ''}>
{eq}
</Text>
</Button>
@ -172,26 +177,25 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Textarea
<Input
value={formData.description}
onChangeText={(text: string) => setFormData(prev => ({ ...prev, description: text }))}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..."
numberOfLines={6}
className="min-h-[120px]"
style={{ maxHeight: 200 }}
multiline
numberOfLines={4}
/>
</View>
<Button
className="mt-4"
variant='purple'
variant='default'
onPress={handleSubmit}
disabled={!formData.title || !formData.equipment}
>
<Text className="text-white font-semibold">Create Exercise</Text>
<Text className="text-primary-foreground font-semibold">Create Exercise</Text>
</Button>
</ScrollView>
</KeyboardAvoidingView>
</View>
</ScrollView>
</SheetContent>
</Sheet>
);

View File

@ -1,13 +1,23 @@
// components/library/NewTemplateSheet.tsx
import React from 'react';
import { View } from 'react-native';
import React, { useState, useEffect } from 'react';
import { View, ScrollView, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import {
Template,
TemplateType,
TemplateCategory,
TemplateExerciseDisplay
} from '@/types/templates';
import { ExerciseDisplay } from '@/types/exercise';
import { generateId } from '@/utils/ids';
import { TemplateType, TemplateCategory } from '@/types/library';
import { cn } from '@/lib/utils';
import { useSQLiteContext } from 'expo-sqlite';
import { LibraryService } from '@/lib/db/services/LibraryService';
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List } from 'lucide-react-native';
interface NewTemplateSheetProps {
isOpen: boolean;
@ -15,131 +25,629 @@ interface NewTemplateSheetProps {
onSubmit: (template: Template) => void;
}
const WORKOUT_TYPES: TemplateType[] = ['strength', 'circuit', 'emom', 'amrap'];
// Steps in template creation
type CreationStep = 'type' | 'info' | 'exercises' | 'config' | 'review';
const CATEGORIES: TemplateCategory[] = [
'Full Body',
'Upper/Lower',
'Push/Pull/Legs',
'Cardio',
'CrossFit',
'Strength',
'Conditioning',
'Custom'
];
// Step 0: Workout Type Selection
interface WorkoutTypeStepProps {
onSelectType: (type: TemplateType) => void;
onCancel: () => void;
}
function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
const workoutTypes = [
{
type: 'strength' as TemplateType,
title: 'Strength Workout',
description: 'Traditional sets and reps with rest periods',
icon: Dumbbell,
available: true
},
{
type: 'circuit' as TemplateType,
title: 'Circuit Training',
description: 'Multiple exercises performed in sequence',
icon: RotateCw,
available: false
},
{
type: 'emom' as TemplateType,
title: 'EMOM Workout',
description: 'Every Minute On the Minute timed exercises',
icon: Clock,
available: false
},
{
type: 'amrap' as TemplateType,
title: 'AMRAP Workout',
description: 'As Many Rounds As Possible in a time cap',
icon: List,
available: false
}
];
return (
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<Text className="text-base mb-4">Select the type of workout template you want to create:</Text>
<View className="gap-3">
{workoutTypes.map(workout => (
<View key={workout.type} className="p-4 bg-card border border-border rounded-md">
{workout.available ? (
<TouchableOpacity
onPress={() => onSelectType(workout.type)}
className="flex-row justify-between items-center"
>
<View className="flex-row items-center gap-3">
<workout.icon size={24} className="text-foreground" />
<View className="flex-1">
<Text className="text-lg font-semibold">{workout.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">
{workout.description}
</Text>
</View>
</View>
<View className="pl-2 pr-1">
<ChevronRight className="text-muted-foreground" size={20} />
</View>
</TouchableOpacity>
) : (
<View>
<View className="flex-row items-center gap-3 opacity-70">
<workout.icon size={24} className="text-muted-foreground" />
<View className="flex-1">
<Text className="text-lg font-semibold">{workout.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">
{workout.description}
</Text>
</View>
</View>
<View className="mt-3 opacity-70">
<Badge variant="outline" className="bg-muted self-start">
<Text className="text-xs">Coming Soon</Text>
</Badge>
</View>
</View>
)}
</View>
))}
</View>
<Button variant="outline" onPress={onCancel} className="mt-4">
<Text>Cancel</Text>
</Button>
</View>
</ScrollView>
);
}
// Step 1: Basic Info
interface BasicInfoStepProps {
title: string;
description: string;
category: TemplateCategory;
onTitleChange: (title: string) => void;
onDescriptionChange: (description: string) => void;
onCategoryChange: (category: string) => void;
onNext: () => void;
onCancel: () => void;
}
function BasicInfoStep({
title,
description,
category,
onTitleChange,
onDescriptionChange,
onCategoryChange,
onNext,
onCancel
}: BasicInfoStepProps) {
const categories: TemplateCategory[] = ['Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Conditioning'];
return (
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<View>
<Text className="text-base font-medium mb-2">Workout Name</Text>
<Input
value={title}
onChangeText={onTitleChange}
placeholder="e.g., Full Body Strength"
className="text-foreground"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Description (Optional)</Text>
<Textarea
value={description}
onChangeText={onDescriptionChange}
placeholder="Describe this workout..."
numberOfLines={4}
className="bg-input placeholder:text-muted-foreground"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{categories.map((cat) => (
<Button
key={cat}
variant={category === cat ? 'default' : 'outline'}
onPress={() => onCategoryChange(cat)}
>
<Text className={category === cat ? 'text-primary-foreground' : ''}>
{cat}
</Text>
</Button>
))}
</View>
</View>
<View className="flex-row justify-end gap-3 mt-4">
<Button variant="outline" onPress={onCancel}>
<Text>Cancel</Text>
</Button>
<Button onPress={onNext} disabled={!title}>
<Text className="text-primary-foreground">Next</Text>
</Button>
</View>
</View>
</ScrollView>
);
}
// Step 2: Exercise Selection
interface ExerciseSelectionStepProps {
exercises: ExerciseDisplay[];
onExercisesSelected: (selected: ExerciseDisplay[]) => void;
onBack: () => void;
}
function ExerciseSelectionStep({
exercises,
onExercisesSelected,
onBack
}: ExerciseSelectionStepProps) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [search, setSearch] = useState('');
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 handleContinue = () => {
const selected = exercises.filter(e => selectedIds.includes(e.id));
onExercisesSelected(selected);
};
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-2">
<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 => (
<View key={exercise.id} className="p-4 bg-card border border-border rounded-md">
<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>
</View>
))}
</View>
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button
onPress={handleContinue}
disabled={selectedIds.length === 0}
>
<Text className="text-primary-foreground">
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
</Text>
</Button>
</View>
</View>
);
}
// Step 3: Exercise Configuration
interface ExerciseConfigStepProps {
exercises: ExerciseDisplay[];
config: TemplateExerciseDisplay[];
onUpdateConfig: (index: number, sets: number, reps: number) => void;
onNext: () => void;
onBack: () => void;
}
function ExerciseConfigStep({
exercises,
config,
onUpdateConfig,
onNext,
onBack
}: ExerciseConfigStepProps) {
return (
<View className="flex-1">
<ScrollView className="flex-1">
<View className="px-4 gap-3">
{exercises.map((exercise, index) => (
<View key={exercise.id} className="p-4 bg-card border border-border rounded-md">
<Text className="text-lg font-semibold mb-2">{exercise.title}</Text>
<View className="flex-row gap-4 mt-2">
<View className="flex-1">
<Text className="text-sm text-muted-foreground mb-1">Sets</Text>
<Input
keyboardType="numeric"
value={config[index]?.targetSets ? config[index].targetSets.toString() : ''}
onChangeText={(text) => {
const sets = text ? parseInt(text) : 0;
const reps = config[index]?.targetReps || 0;
onUpdateConfig(index, sets, reps);
}}
placeholder="Optional"
className="bg-input placeholder:text-muted-foreground"
/>
</View>
<View className="flex-1">
<Text className="text-sm text-muted-foreground mb-1">Reps</Text>
<Input
keyboardType="numeric"
value={config[index]?.targetReps ? config[index].targetReps.toString() : ''}
onChangeText={(text) => {
const reps = text ? parseInt(text) : 0;
const sets = config[index]?.targetSets || 0;
onUpdateConfig(index, sets, reps);
}}
placeholder="Optional"
className="bg-input placeholder:text-muted-foreground"
/>
</View>
</View>
</View>
))}
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button onPress={onNext}>
<Text className="text-primary-foreground">Review Template</Text>
</Button>
</View>
</View>
);
}
// Step 4: Review
interface ReviewStepProps {
title: string;
description: string;
category: TemplateCategory;
type: TemplateType;
exercises: Template['exercises'];
onSubmit: () => void;
onBack: () => void;
}
function ReviewStep({
title,
description,
category,
type,
exercises,
onSubmit,
onBack
}: ReviewStepProps) {
return (
<View className="flex-1">
<ScrollView className="flex-1">
<View className="p-4 gap-6">
<View className="mb-6">
<Text className="text-xl font-bold">{title}</Text>
{description ? (
<Text className="mt-2 text-muted-foreground">{description}</Text>
) : null}
<View className="flex-row gap-2 mt-3">
<Badge variant="outline">
<Text className="text-xs">{type}</Text>
</Badge>
<Badge variant="outline">
<Text className="text-xs">{category}</Text>
</Badge>
</View>
</View>
<View>
<Text className="text-lg font-semibold mb-4">Exercises</Text>
<View className="gap-3">
{exercises.map((exercise, index) => (
<View key={index} className="p-4 bg-card border border-border rounded-md">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm mt-1 text-muted-foreground">
{exercise.targetSets || exercise.targetReps ?
`${exercise.targetSets || ''} sets × ${exercise.targetReps || ''} reps` :
'No prescription set'
}
</Text>
</View>
))}
</View>
</View>
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button onPress={onSubmit}>
<Text className="text-primary-foreground">Create Template</Text>
</Button>
</View>
</View>
);
}
export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheetProps) {
const [formData, setFormData] = React.useState({
const db = useSQLiteContext();
const [libraryService] = useState(() => new LibraryService(db));
const [step, setStep] = useState<CreationStep>('type');
const [workoutType, setWorkoutType] = useState<TemplateType>('strength');
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]);
const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]);
// Template info
const [templateInfo, setTemplateInfo] = useState<{
title: string;
description: string;
category: TemplateCategory;
tags: string[];
}>({
title: '',
type: '' as TemplateType,
category: '' as TemplateCategory,
description: '',
category: 'Full Body',
tags: ['strength']
});
const handleSubmit = () => {
const template: Template = {
id: generateId(),
title: formData.title,
type: formData.type,
category: formData.category,
description: formData.description,
exercises: [],
tags: [],
source: 'local',
isFavorite: false,
created_at: Date.now(),
// 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);
}
};
if (isOpen) {
loadExercises();
}
}, [isOpen, libraryService]);
onSubmit(template);
onClose();
setFormData({
title: '',
type: '' as TemplateType,
category: '' as TemplateCategory,
description: '',
// Reset state when sheet closes
useEffect(() => {
if (!isOpen) {
setStep('type');
setWorkoutType('strength');
setSelectedExercises([]);
setConfiguredExercises([]);
setTemplateInfo({
title: '',
description: '',
category: 'Full Body',
tags: ['strength']
});
}
}, [isOpen]);
const handleGoBack = () => {
switch(step) {
case 'info': setStep('type'); break;
case 'exercises': setStep('info'); break;
case 'config': setStep('exercises'); break;
case 'review': setStep('config'); break;
}
};
const handleSelectType = (type: TemplateType) => {
setWorkoutType(type);
setStep('info');
};
const handleSubmitInfo = () => {
if (!templateInfo.title) return;
setStep('exercises');
};
const handleSelectExercises = (selected: ExerciseDisplay[]) => {
setSelectedExercises(selected);
// Pre-populate configured exercises
const initialConfig = selected.map(exercise => ({
title: exercise.title,
targetSets: 0,
targetReps: 0
}));
setConfiguredExercises(initialConfig);
setStep('config');
};
const handleUpdateExerciseConfig = (index: number, sets: number, reps: number) => {
setConfiguredExercises(prev => {
const updated = [...prev];
updated[index] = {
...updated[index],
targetSets: sets,
targetReps: reps
};
return updated;
});
};
const handleConfigComplete = () => {
setStep('review');
};
const handleCreateTemplate = () => {
const newTemplate: Template = {
id: generateId(),
title: templateInfo.title,
description: templateInfo.description,
type: workoutType,
category: templateInfo.category,
exercises: configuredExercises,
tags: templateInfo.tags,
source: 'local',
isFavorite: false
};
onSubmit(newTemplate);
onClose();
};
// Render different content based on current step
const renderContent = () => {
switch (step) {
case 'type':
return (
<WorkoutTypeStep
onSelectType={handleSelectType}
onCancel={onClose}
/>
);
case 'info':
return (
<BasicInfoStep
title={templateInfo.title}
description={templateInfo.description}
category={templateInfo.category}
onTitleChange={(title) => setTemplateInfo(prev => ({ ...prev, title }))}
onDescriptionChange={(description) => setTemplateInfo(prev => ({ ...prev, description }))}
onCategoryChange={(category) => setTemplateInfo(prev => ({ ...prev, category: category as TemplateCategory }))}
onNext={handleSubmitInfo}
onCancel={onClose}
/>
);
case 'exercises':
return (
<ExerciseSelectionStep
exercises={exercises}
onExercisesSelected={handleSelectExercises}
onBack={() => setStep('info')}
/>
);
case 'config':
return (
<ExerciseConfigStep
exercises={selectedExercises}
config={configuredExercises}
onUpdateConfig={handleUpdateExerciseConfig}
onNext={handleConfigComplete}
onBack={() => setStep('exercises')}
/>
);
case 'review':
return (
<ReviewStep
title={templateInfo.title}
description={templateInfo.description}
category={templateInfo.category}
type={workoutType}
exercises={configuredExercises}
onSubmit={handleCreateTemplate}
onBack={() => setStep('config')}
/>
);
}
};
// Get title based on current step
const getStepTitle = () => {
switch (step) {
case 'type': return 'Select Workout Type';
case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`;
case 'exercises': return 'Select Exercises';
case 'config': return 'Configure Exercises';
case 'review': return 'Review Template';
}
};
// Show back button for all steps except the first
const showBackButton = step !== 'type';
return (
<Sheet isOpen={isOpen} onClose={onClose}>
<SheetHeader>
<SheetTitle>New Template</SheetTitle>
<View className="flex-row items-center">
{showBackButton && (
<Button
variant="ghost"
size="icon"
className="mr-2"
onPress={handleGoBack}
>
<ChevronLeft className="text-foreground" size={20} />
</Button>
)}
<SheetTitle>{getStepTitle()}</SheetTitle>
</View>
</SheetHeader>
<SheetContent>
<View className="gap-4">
<View>
<Text className="text-base font-medium mb-2">Template Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Full Body Strength"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Workout Type</Text>
<View className="flex-row flex-wrap gap-2">
{WORKOUT_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'purple' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
>
<Text
className={cn(
"text-base font-medium capitalize",
formData.type === type ? "text-white" : "text-foreground"
)}
>
{type}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'purple' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
>
<Text
className={cn(
"text-base font-medium",
formData.category === category ? "text-white" : "text-foreground"
)}
>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Template description..."
multiline
numberOfLines={4}
/>
</View>
<Button
variant="purple"
className="mt-4"
onPress={handleSubmit}
disabled={!formData.title || !formData.type || !formData.category}
>
<Text className="text-white font-semibold">Create Template</Text>
</Button>
</View>
{renderContent()}
</SheetContent>
</Sheet>
);

View File

@ -18,7 +18,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Template } from '@/types/library';
import { Template, TemplateExerciseDisplay } from '@/types/templates';
interface TemplateCardProps {
template: Template;
@ -47,10 +47,12 @@ export function TemplateCard({
description,
tags = [],
source,
lastUsed,
metadata,
isFavorite
} = template;
const lastUsed = metadata?.lastUsed ? new Date(metadata.lastUsed) : undefined;
const handleConfirmDelete = () => {
onDelete(id);
setShowDeleteAlert(false);
@ -95,7 +97,7 @@ export function TemplateCard({
Exercises:
</Text>
<View className="gap-1">
{exercises.slice(0, 3).map((exercise, index) => (
{exercises.slice(0, 3).map((exercise: TemplateExerciseDisplay, index: number) => (
<Text key={index} className="text-sm text-muted-foreground">
{exercise.title} ({exercise.targetSets}×{exercise.targetReps})
</Text>
@ -117,7 +119,7 @@ export function TemplateCard({
{tags.length > 0 && (
<View className="flex-row flex-wrap gap-2 mt-2">
{tags.map(tag => (
{tags.map((tag: string) => (
<Badge key={tag} variant="outline" className="text-xs">
<Text>{tag}</Text>
</Badge>
@ -216,12 +218,15 @@ export function TemplateCard({
<Text className="text-base">Type: {type}</Text>
<Text className="text-base">Category: {category}</Text>
<Text className="text-base">Source: {source}</Text>
{metadata?.useCount && (
<Text className="text-base">Times Used: {metadata.useCount}</Text>
)}
</View>
</View>
<View>
<Text className="text-base font-semibold mb-2">Exercises</Text>
<View className="gap-2">
{exercises.map((exercise, index) => (
{exercises.map((exercise: TemplateExerciseDisplay, index: number) => (
<Text key={index} className="text-base">
{exercise.title} ({exercise.targetSets}×{exercise.targetReps})
</Text>
@ -232,7 +237,7 @@ export function TemplateCard({
<View>
<Text className="text-base font-semibold mb-2">Tags</Text>
<View className="flex-row flex-wrap gap-2">
{tags.map(tag => (
{tags.map((tag: string) => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>

View File

@ -0,0 +1,373 @@
// components/templates/TemplateDetails.tsx
import React from 'react';
import { View, ScrollView } from 'react-native';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import {
Edit2,
Dumbbell,
Target,
Calendar,
Hash,
ClipboardList,
Settings
} from 'lucide-react-native';
import {
WorkoutTemplate,
TemplateSource,
getSourceDisplay
} from '@/types/templates';
import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme';
const Tab = createMaterialTopTabNavigator();
interface TemplateDetailsProps {
template: WorkoutTemplate;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit?: () => void;
}
// Overview Tab Component
function OverviewTab({ template, onEdit }: { template: WorkoutTemplate; onEdit?: () => void }) {
const {
title,
type,
category,
description,
exercises = [],
tags = [],
metadata,
availability // Replace source with availability
} = template;
// Calculate source type from availability
const sourceType = availability.source.includes('nostr')
? 'nostr'
: availability.source.includes('powr')
? 'powr'
: 'local';
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={sourceType === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{getSourceDisplay(template)}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category Section */}
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Exercises Section */}
<View>
<View className="flex-row items-center gap-2 mb-2">
<Dumbbell size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Exercises</Text>
</View>
<View className="gap-2">
{exercises.map((exerciseConfig, index) => (
<View key={index} className="bg-card p-3 rounded-lg">
<Text className="text-base font-medium text-foreground">
{exerciseConfig.exercise.title}
</Text>
<Text className="text-sm text-muted-foreground">
{exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps
</Text>
{exerciseConfig.notes && (
<Text className="text-sm text-muted-foreground mt-1">
{exerciseConfig.notes}
</Text>
)}
</View>
))}
</View>
</View>
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map(tag => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Usage Stats Section */}
{metadata && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{metadata.useCount && (
<Text className="text-base text-muted-foreground">
Used {metadata.useCount} times
</Text>
)}
{metadata.lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {new Date(metadata.lastUsed).toLocaleDateString()}
</Text>
)}
{metadata.averageDuration && (
<Text className="text-base text-muted-foreground">
Average duration: {Math.round(metadata.averageDuration / 60)} minutes
</Text>
)}
</View>
</View>
)}
{/* Edit Button */}
{onEdit && (
<Button
onPress={onEdit}
className="w-full mt-2"
>
<Edit2 size={18} className="mr-2 text-primary-foreground" />
<Text className="text-primary-foreground font-semibold">
Edit Template
</Text>
</Button>
)}
</View>
</ScrollView>
);
}
// Function to format template source for display
function formatTemplateSource(source: TemplateSource | undefined, templateSource: 'local' | 'powr' | 'nostr'): string {
if (!source) {
return templateSource === 'local' ? 'Local Template' : templateSource.toUpperCase();
}
const author = source.authorName || 'Unknown Author';
if (source.version) {
return `Modified from ${author} (v${source.version})`;
}
return `Original by ${author}`;
}
// History Tab Component
function HistoryTab({ template }: { template: WorkoutTemplate }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Performance Stats */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text>
<View className="flex-row gap-4">
<View className="flex-1 bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Avg. Duration</Text>
<Text className="text-lg font-semibold text-foreground">
{template.metadata?.averageDuration
? `${Math.round(template.metadata.averageDuration / 60)}m`
: '--'}
</Text>
</View>
<View className="flex-1 bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Completion Rate</Text>
<Text className="text-lg font-semibold text-foreground">--</Text>
</View>
</View>
</View>
{/* History List */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Recent Workouts</Text>
<View className="gap-3">
{/* Placeholder for when no history exists */}
<View className="bg-muted p-8 rounded-lg items-center justify-center">
<ClipboardList size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground text-center">
No workout history available yet
</Text>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
// Settings Tab Component
function SettingsTab({ template }: { template: WorkoutTemplate }) {
const {
type,
rounds,
duration,
interval,
restBetweenRounds,
} = template;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Workout Configuration */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Workout Settings</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Type</Text>
<Text className="text-base font-medium text-foreground capitalize">{type}</Text>
</View>
{rounds && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Rounds</Text>
<Text className="text-base font-medium text-foreground">{rounds}</Text>
</View>
)}
{duration && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Duration</Text>
<Text className="text-base font-medium text-foreground">
{Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')}
</Text>
</View>
)}
{interval && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Interval</Text>
<Text className="text-base font-medium text-foreground">
{Math.floor(interval / 60)}:{(interval % 60).toString().padStart(2, '0')}
</Text>
</View>
)}
{restBetweenRounds && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Rest Between Rounds</Text>
<Text className="text-base font-medium text-foreground">
{Math.floor(restBetweenRounds / 60)}:{(restBetweenRounds % 60).toString().padStart(2, '0')}
</Text>
</View>
)}
</View>
</View>
</View>
</ScrollView>
);
}
export function TemplateDetails({
template,
open,
onOpenChange,
onEdit
}: TemplateDetailsProps) {
const theme = useTheme() as CustomTheme;
return (
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
<SheetHeader>
<SheetTitle>
<Text className="text-xl font-bold text-foreground">{template.title}</Text>
</SheetTitle>
</SheetHeader>
<SheetContent>
<View style={{ flex: 1, minHeight: 400 }}>
<Tab.Navigator
style={{ flex: 1 }}
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen
name="overview"
options={{ title: 'Overview' }}
>
{() => <OverviewTab template={template} onEdit={onEdit} />}
</Tab.Screen>
<Tab.Screen
name="history"
options={{ title: 'History' }}
>
{() => <HistoryTab template={template} />}
</Tab.Screen>
<Tab.Screen
name="settings"
options={{ title: 'Settings' }}
>
{() => <SettingsTab template={template} />}
</Tab.Screen>
</Tab.Navigator>
</View>
</SheetContent>
</Sheet>
);
}

View File

@ -1,343 +1,186 @@
# POWR Library Tab PRD
Updated: 2025-02-19
## Overview
### Problem Statement
Users need a centralized location to manage their fitness content (exercises and workout templates) while supporting both local content creation and Nostr-based content discovery. The library must maintain usability in offline scenarios while preparing for future social features.
### Goals
1. Provide organized access to exercises and workout templates
2. Enable efficient content discovery and reuse
3. Support clear content ownership and source tracking
4. Maintain offline-first functionality
5. Prepare for future Nostr integration
### Goals Status
1. ✅ Provide organized access to exercises and workout templates
- Implemented main navigation and content organization
- Search and filtering working
- Basic content management in place
## Feature Requirements
2. ✅ Enable efficient content discovery and reuse
- Search functionality implemented
- Category filtering working
- Alphabetical organization with quick scroll
### Navigation Structure
- Material Top Tabs navigation with three sections:
- Templates (default tab)
- Exercises
- Programs (placeholder for future implementation)
3. 🟡 Support clear content ownership and source tracking
- Basic source badges implemented
- Nostr attribution pending
- Content history tracking in progress
4. 🟡 Maintain offline-first functionality
- Basic local storage implemented
- SQLite integration complete
- Advanced offline features pending
5. ❌ Prepare for future Nostr integration
- Types and schemas defined
- Implementation not started
## Current Implementation Status
### Navigation Structure ✅
- Material Top Tabs navigation implemented with three sections:
- Templates (default tab) - COMPLETE
- Exercises - COMPLETE
- Programs - PLACEHOLDER
### Templates Tab
#### Content Organization
- Favorites section
- Recently performed section
- Alphabetical list of remaining templates
- Clear source badges (Local/POWR/Nostr)
- ✅ Basic template list with sections
- ✅ Favorites section
- ✅ Source badges (Local/POWR/Nostr)
- 🟡 Recently performed section (partial)
- ❌ Usage statistics
#### Template Item Display
- Template title
- Workout type (strength, circuit, EMOM, etc.)
- Preview of included exercises (first 3)
- Source badge
- Favorite star button
- Usage stats
- Template title
- ✅ Workout type indication
- ✅ Exercise preview (first 3)
- Source badges
- ✅ Favorite functionality
- 🟡 Usage stats (partial)
#### Search & Filtering
- Persistent search bar with real-time filtering
- Filter options:
- Workout type
- Equipment needed
- Tags
- ✅ Real-time search
- ✅ Basic filtering options
- 🟡 Advanced filters (partial)
### Exercises Tab
#### Content Organization
- Recent section (10 most recent exercises)
- Alphabetical list of all exercises
- Tag-based categorization
- Clear source badges
- ✅ Alphabetical list with quick scroll
- ✅ Categorization system
- ✅ Source badges
- ❌ Recent section
- ❌ Usage tracking
#### Exercise Item Display
- Exercise name
- Category/tags
- Equipment type
- Source badge
- Usage stats
- Exercise name
- Category/tags
- Equipment type
- Source badge
- Usage stats
#### Search & Filtering
- Persistent search bar with real-time filtering
- Filter options:
- Equipment
- Tags
- Source
- ✅ Real-time search
- ✅ Basic filters
- 🟡 Advanced filtering options
### Programs Tab (Future)
- Placeholder implementation
- "Coming Soon" messaging
- Basic description of future functionality
### Technical Implementation
## Content Interaction
#### Data Layer ✅
- SQLite integration complete
- Basic schema implemented
- CRUD operations working
- Development seeding functional
### Progressive Disclosure Pattern
#### Content Management
- ✅ Exercise/template creation
- ✅ Basic content validation
- 🟡 Tag management
- ❌ Media support
#### 1. Card Display
- Basic info
- Source badge (Local/POWR/Nostr)
- Quick stats/preview
- Favorite button (templates only)
#### State Management
- ✅ Basic state handling
- ✅ Form state management
- 🟡 Complex state interactions
- ❌ Global state optimization
#### 2. Quick Preview (Hover/Long Press)
- Extended preview info
- Key stats
- Quick actions
## Next Development Priorities
#### 3. Bottom Sheet Details
- Basic Information:
- Full title and description
- Category/tags
- Equipment requirements
- Stats & History:
- Personal records
- Usage history
- Performance trends
- Source Information:
- For local content:
- Creation date
- Last modified
- For Nostr content:
- Author information
- Original post date
- Relay source
- Action Buttons:
- For local content:
- Start Workout (templates)
- Edit
- Publish to Nostr
- Delete
- For Nostr content:
- Start Workout (templates)
- Delete from Library
### Phase 1: Core Enhancements
1. Template Management
- Implement history tracking
- Add usage statistics
- Enhance template details view
#### 4. Full Details Modal
- Comprehensive view
- Complete history
- Advanced options
2. Progressive Disclosure
- Add long press preview
- Improve bottom sheet details
- Create full screen edit mode
## Technical Requirements
3. Exercise Management
- Implement usage tracking
- Add performance metrics
- Enhance filtering system
### Data Storage
- SQLite for local storage
- Schema supporting:
- Exercise templates
- Workout templates
- Usage history
- Source tracking
- Nostr metadata
### Phase 2: Advanced Features
1. Media Support
- Image/video linking
- Local caching
- Placeholder system
### Content Management
- No limit on custom exercises/templates
- Tag character limit: 30 characters
- Support for external media links (images/videos)
- Local caching of Nostr content
2. History & Stats
- Usage tracking
- Performance metrics
- Trend visualization
### Media Content Handling
- For Nostr content:
- Store media URLs in metadata
- Cache images locally when saved
- Lazy load images when online
- Show placeholders when offline
- For local content:
- Optional image/video links
- No direct media upload in MVP
### Offline Capabilities
- Full functionality without internet
- Local-first architecture
- Graceful degradation of Nostr features
- Clear offline state indicators
## User Interface Components
### Core Components
1. MaterialTopTabs navigation
2. Persistent search header
3. Filter button and sheet
4. Content cards
5. Bottom sheet previews
6. Tab-specific FABs:
- Templates Tab: FAB for creating new workout templates
- Exercises Tab: FAB for creating new custom exercises
- Programs Tab: FAB for creating training programs (future)
### Component Details
#### Templates Tab FAB
- Primary action: Create new workout template
- Icon: Layout/Template icon
- Navigation: Routes to template creation flow
- Fixed position at bottom right
#### Exercises Tab FAB
- Primary action: Create new exercise
- Icon: Dumbbell icon
- Navigation: Routes to exercise creation flow
- Fixed position at bottom right
#### Programs Tab FAB (Future)
- Primary action: Create new program
- Icon: Calendar/Program icon
- Navigation: Routes to program creation flow
- Fixed position at bottom right
### Component States
1. Loading states
2. Empty states
3. Error states
4. Offline states
5. Content creation/editing modes
## Implementation Phases
### Phase 1: Core Structure
1. Tab navigation setup
2. Basic content display
3. Search and filtering
4. Local content management
### Phase 2: Enhanced Features
1. Favorite system
2. History tracking
3. Performance stats
4. Tag management
3. Enhanced Filtering
- Combined filters
- Smart suggestions
- Recent searches
### Phase 3: Nostr Integration
1. Content syncing
2. Publishing flow
3. Author attribution
4. Media handling
1. Event Handling
- Event processing
- Content validation
- Relay management
## Success Metrics
2. Sync System
- Content synchronization
- Conflict resolution
- Offline handling
### Performance
- Search response: < 100ms
- Scroll performance: 60fps
- Image load time: < 500ms
## MVP Assessment
### User Experience
- Content discovery time
- Search success rate
- Template reuse rate
- Exercise reference frequency
### Current MVP Features (✅ Complete)
1. Core Navigation
- Tab structure
- Content organization
- Basic routing
### Technical
- Offline reliability
- Storage efficiency
- Cache hit rate
- Sync success rate
2. Exercise Management
- Create/edit/delete
- Categorization
- Search/filter
## Future Considerations
3. Template Management
- Template creation
- Exercise inclusion
- Favorites system
### Programs Tab Development
- Program creation
- Calendar integration
- Progress tracking
- Social sharing
4. Data Foundation
- SQLite integration
- Basic CRUD
- Schema structure
### Enhanced Social Features
- Content recommendations
- Author following
- Usage analytics
- Community features
### MVP Technical Metrics
- Search response: < 100ms
- Scroll performance: 60fps ✅
- Database operations: < 50ms
### Additional Enhancements
- Advanced media support
- Custom collections
- Export/import functionality
- Backup solutions
### Known Limitations
1. No media support in current version
2. Limited performance tracking
3. Basic filtering only
4. No offline state handling
5. No Nostr integration
2025-02-09 Update
Progress Analysis:
✅ COMPLETED:
1. Navigation Structure
- Implemented Material Top Tabs with Templates, Exercises, and Programs sections
- Clear visual hierarchy with proper styling
2. Basic Content Management
- Search functionality
- Filter system with proper categorization
- Source badges (Local/POWR/Nostr)
- Basic CRUD operations for exercises and templates
3. UI Components
- SearchHeader component
- FilterSheet with proper categorization
- Content cards with consistent styling
- FAB for content creation
- Sheet components for new content creation
🟡 IN PROGRESS/PARTIAL:
1. Content Organization
- We have basic favorites for templates but need to implement:
- Recently performed section
- Usage stats tracking
- Better categorization system
2. Progressive Disclosure Pattern
- We have basic cards and creation sheets but need:
- Quick Preview on long press
- Bottom Sheet Details view
- Full Details Modal
3. Content Interaction
- Basic CRUD operations exist but need:
- Performance tracking
- History integration
- Better stats visualization
❌ NOT STARTED:
1. Technical Implementation
- Nostr integration preparation
- SQLite database setup
- Proper caching system
- Offline capabilities
2. Advanced Features
- Performance tracking
- Usage history
- Media content handling
- Import/export functionality
Recommended Next Steps:
1. Data Layer Implementation
```typescript
// First set up SQLite database schema and service
class LibraryService {
// Exercise management
getExercises(): Promise<Exercise[]>
createExercise(exercise: Exercise): Promise<string>
updateExercise(id: string, exercise: Partial<Exercise>): Promise<void>
deleteExercise(id: string): Promise<void>
// Template management
getTemplates(): Promise<Template[]>
createTemplate(template: Template): Promise<string>
updateTemplate(id: string, template: Partial<Template>): Promise<void>
deleteTemplate(id: string): Promise<void>
// Usage tracking
logExerciseUse(exerciseId: string): Promise<void>
logTemplateUse(templateId: string): Promise<void>
getExerciseHistory(exerciseId: string): Promise<ExerciseHistory[]>
getTemplateHistory(templateId: string): Promise<TemplateHistory[]>
}
```
2. Detail Views
- Create a detailed view component for exercises and templates
- Implement proper state management for tracking usage
- Add performance metrics visualization
3. Progressive Disclosure
- Implement long press preview
- Create bottom sheet details view
- Add full screen modal for editing
## Conclusion
The Library tab has reached MVP status with core functionality implemented and working. While several planned features remain to be implemented, the current version provides the essential functionality needed to support workout creation and management. Recommendation is to proceed with Workout component development while maintaining a backlog of Library enhancements for future iterations.

View File

@ -1,13 +1,19 @@
// lib/db/services/ExerciseService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import {
BaseExercise,
ExerciseDisplay,
ExerciseType,
ExerciseCategory,
Equipment,
toExerciseDisplay
} from '@/types/exercise';
import { generateId } from '@/utils/ids';
export class ExerciseService {
constructor(private db: SQLiteDatabase) {}
// Add this new method
async getAllExercises(): Promise<Exercise[]> {
async getAllExercises(): Promise<ExerciseDisplay[]> {
try {
const exercises = await this.db.getAllAsync<any>(`
SELECT * FROM exercises ORDER BY created_at DESC
@ -29,27 +35,60 @@ export class ExerciseService {
return acc;
}, {} as Record<string, string[]>);
return exercises.map(exercise => ({
...exercise,
tags: tagsByExercise[exercise.id] || [],
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
availability: { source: [exercise.source] }
}));
return exercises.map(exercise => {
const baseExercise: BaseExercise = {
...exercise,
tags: tagsByExercise[exercise.id] || [],
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
});
} catch (error) {
console.error('Error getting all exercises:', error);
throw error;
}
}
// Update createExercise to handle all required fields
async getExercise(id: string): Promise<ExerciseDisplay | null> {
try {
// Get exercise data
const exercise = await this.db.getFirstAsync<any>(
`SELECT * FROM exercises WHERE id = ?`,
[id]
);
if (!exercise) return null;
// Get tags
const tags = await this.db.getAllAsync<{ tag: string }>(
'SELECT tag FROM exercise_tags WHERE exercise_id = ?',
[id]
);
const baseExercise: BaseExercise = {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tags.map(t => t.tag),
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
} catch (error) {
console.error('Error getting exercise:', error);
throw error;
}
}
async createExercise(
exercise: Omit<Exercise, 'id' | 'availability'>,
exercise: Omit<BaseExercise, 'id'>,
inTransaction: boolean = false
): Promise<string> {
const id = generateId();
const timestamp = Date.now();
try {
const runQueries = async () => {
await this.db.runAsync(
@ -68,10 +107,10 @@ export class ExerciseService {
exercise.format_units ? JSON.stringify(exercise.format_units) : null,
timestamp,
timestamp,
exercise.source || 'local'
exercise.availability.source[0]
]
);
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
await this.db.runAsync(
@ -81,13 +120,13 @@ export class ExerciseService {
}
}
};
if (inTransaction) {
await runQueries();
} else {
await this.db.withTransactionAsync(runQueries);
}
return id;
} catch (error) {
console.error('Error creating exercise:', error);
@ -95,135 +134,13 @@ export class ExerciseService {
}
}
async getExercise(id: string): Promise<Exercise | null> {
try {
// Get exercise data
const exercise = await this.db.getFirstAsync<Exercise>(
`SELECT * FROM exercises WHERE id = ?`,
[id]
);
if (!exercise) return null;
// Get tags
const tags = await this.db.getAllAsync<{ tag: string }>(
'SELECT tag FROM exercise_tags WHERE exercise_id = ?',
[id]
);
return {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tags.map(t => t.tag)
};
} catch (error) {
console.error('Error getting exercise:', error);
throw error;
}
}
async updateExercise(id: string, exercise: Partial<Exercise>): Promise<void> {
const timestamp = Date.now();
try {
await this.db.withTransactionAsync(async () => {
// Build update query dynamically based on provided fields
const updates: string[] = [];
const values: any[] = [];
if (exercise.title !== undefined) {
updates.push('title = ?');
values.push(exercise.title);
}
if (exercise.type !== undefined) {
updates.push('type = ?');
values.push(exercise.type);
}
if (exercise.category !== undefined) {
updates.push('category = ?');
values.push(exercise.category);
}
if (exercise.equipment !== undefined) {
updates.push('equipment = ?');
values.push(exercise.equipment);
}
if (exercise.description !== undefined) {
updates.push('description = ?');
values.push(exercise.description);
}
if (exercise.format !== undefined) {
updates.push('format_json = ?');
values.push(JSON.stringify(exercise.format));
}
if (exercise.format_units !== undefined) {
updates.push('format_units_json = ?');
values.push(JSON.stringify(exercise.format_units));
}
if (updates.length > 0) {
updates.push('updated_at = ?');
values.push(timestamp);
// Add id to values array
values.push(id);
await this.db.runAsync(
`UPDATE exercises SET ${updates.join(', ')} WHERE id = ?`,
values
);
}
// Update tags if provided
if (exercise.tags !== undefined) {
// Delete existing tags
await this.db.runAsync(
'DELETE FROM exercise_tags WHERE exercise_id = ?',
[id]
);
// Insert new tags
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
}
});
} catch (error) {
console.error('Error updating exercise:', error);
throw error;
}
}
async deleteExercise(id: string): Promise<void> {
try {
console.log('Deleting exercise:', id);
await this.db.withTransactionAsync(async () => {
// Due to ON DELETE CASCADE, we only need to delete from exercises
const result = await this.db.runAsync('DELETE FROM exercises WHERE id = ?', [id]);
console.log('Delete result:', result);
});
} catch (error) {
console.error('Error deleting exercise:', error);
throw error;
}
}
async searchExercises(query?: string, filters?: {
types?: ExerciseType[];
categories?: ExerciseCategory[];
equipment?: Equipment[];
tags?: string[];
source?: 'local' | 'powr' | 'nostr';
}): Promise<Exercise[]> {
}): Promise<ExerciseDisplay[]> {
try {
let sql = `
SELECT DISTINCT e.*
@ -278,7 +195,7 @@ export class ExerciseService {
sql += ' ORDER BY e.title ASC';
// Get exercises
const exercises = await this.db.getAllAsync<Exercise>(sql, params);
const exercises = await this.db.getAllAsync<any>(sql, params);
// Get tags for all exercises
const exerciseIds = exercises.map(e => e.id);
@ -297,23 +214,27 @@ export class ExerciseService {
return acc;
}, {} as Record<string, string[]>);
// Add tags to exercises
return exercises.map(exercise => ({
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tagsByExercise[exercise.id] || []
}));
// Convert to ExerciseDisplay
return exercises.map(exercise => {
const baseExercise: BaseExercise = {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tagsByExercise[exercise.id] || [],
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
});
}
return exercises;
return [];
} catch (error) {
console.error('Error searching exercises:', error);
throw error;
}
}
async getRecentExercises(limit: number = 10): Promise<Exercise[]> {
async getRecentExercises(limit: number = 10): Promise<ExerciseDisplay[]> {
try {
const sql = `
SELECT e.*
@ -322,7 +243,7 @@ export class ExerciseService {
LIMIT ?
`;
const exercises = await this.db.getAllAsync<Exercise>(sql, [limit]);
const exercises = await this.db.getAllAsync<any>(sql, [limit]);
// Get tags for these exercises
const exerciseIds = exercises.map(e => e.id);
@ -341,55 +262,111 @@ export class ExerciseService {
return acc;
}, {} as Record<string, string[]>);
return exercises.map(exercise => ({
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tagsByExercise[exercise.id] || []
}));
return exercises.map(exercise => {
const baseExercise: BaseExercise = {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tagsByExercise[exercise.id] || [],
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
});
}
return exercises;
return [];
} catch (error) {
console.error('Error getting recent exercises:', error);
throw error;
}
}
async getExerciseTags(): Promise<{ tag: string; count: number }[]> {
try {
return await this.db.getAllAsync<{ tag: string; count: number }>(
`SELECT tag, COUNT(*) as count
FROM exercise_tags
GROUP BY tag
ORDER BY count DESC, tag ASC`
);
} catch (error) {
console.error('Error getting exercise tags:', error);
throw error;
}
}
async updateExercise(id: string, exercise: Partial<BaseExercise>): Promise<void> {
const timestamp = Date.now();
async bulkImport(exercises: Omit<Exercise, 'id'>[]): Promise<string[]> {
const ids: string[] = [];
try {
await this.db.withTransactionAsync(async () => {
for (const exercise of exercises) {
const id = await this.createExercise(exercise);
ids.push(id);
// Build update query dynamically based on provided fields
const updates: string[] = [];
const values: any[] = [];
if (exercise.title !== undefined) {
updates.push('title = ?');
values.push(exercise.title);
}
if (exercise.type !== undefined) {
updates.push('type = ?');
values.push(exercise.type);
}
if (exercise.category !== undefined) {
updates.push('category = ?');
values.push(exercise.category);
}
if (exercise.equipment !== undefined) {
updates.push('equipment = ?');
values.push(exercise.equipment);
}
if (exercise.description !== undefined) {
updates.push('description = ?');
values.push(exercise.description);
}
if (exercise.format !== undefined) {
updates.push('format_json = ?');
values.push(JSON.stringify(exercise.format));
}
if (exercise.format_units !== undefined) {
updates.push('format_units_json = ?');
values.push(JSON.stringify(exercise.format_units));
}
if (exercise.availability !== undefined) {
updates.push('source = ?');
values.push(exercise.availability.source[0]);
}
if (updates.length > 0) {
updates.push('updated_at = ?');
values.push(timestamp);
// Add id to values array
values.push(id);
await this.db.runAsync(
`UPDATE exercises SET ${updates.join(', ')} WHERE id = ?`,
values
);
}
// Update tags if provided
if (exercise.tags !== undefined) {
// Delete existing tags
await this.db.runAsync(
'DELETE FROM exercise_tags WHERE exercise_id = ?',
[id]
);
// Insert new tags
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
}
});
return ids;
} catch (error) {
console.error('Error bulk importing exercises:', error);
console.error('Error updating exercise:', error);
throw error;
}
}
// Helper method to sync with Nostr events
async syncWithNostrEvent(eventId: string, exercise: Omit<Exercise, 'id'>): Promise<string> {
async syncWithNostrEvent(eventId: string, exercise: Omit<BaseExercise, 'id'>): Promise<string> {
try {
// Check if we already have this exercise
const existing = await this.db.getFirstAsync<{ id: string }>(
@ -445,9 +422,4 @@ export class ExerciseService {
throw error;
}
}
}
// Helper function to create an instance
export const createExerciseService = (db: SQLiteDatabase) => new ExerciseService(db);
// Also export a type for the service if needed
export type ExerciseServiceType = ExerciseService;
}

View File

@ -1,7 +1,7 @@
// lib/db/services/LibraryService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { DbService } from '../db-service';
import { BaseExercise, Exercise } from '@/types/exercise';
import { BaseExercise, ExerciseDisplay } from '@/types/exercise';
import { StorageSource } from '@/types/shared';
import { generateId } from '@/utils/ids';
@ -13,7 +13,7 @@ export class LibraryService {
this.db = new DbService(database);
}
async getExercises(): Promise<Exercise[]> {
async getExercises(): Promise<ExerciseDisplay[]> {
try {
const result = await this.db.getAllAsync<{
id: string;
@ -41,19 +41,19 @@ export class LibraryService {
return result.map(row => ({
id: row.id,
title: row.title,
type: row.type as Exercise['type'],
category: row.category as Exercise['category'],
equipment: row.equipment as Exercise['equipment'] || undefined,
type: row.type as ExerciseDisplay['type'],
category: row.category as ExerciseDisplay['category'],
equipment: row.equipment as ExerciseDisplay['equipment'] || undefined,
description: row.description || undefined,
instructions: row.instructions ? row.instructions.split(',') : undefined,
tags: row.tags ? row.tags.split(',') : [],
created_at: row.created_at,
source: row.source as Exercise['source'],
source: row.source as ExerciseDisplay['source'],
availability: {
source: [row.source as StorageSource]
},
format_json: row.format_json || undefined,
format_units_json: row.format_units_json || undefined
format: row.format_json ? JSON.parse(row.format_json) : undefined,
format_units: row.format_units_json ? JSON.parse(row.format_units_json) : undefined
}));
} catch (error) {
console.error('Error getting exercises:', error);
@ -61,7 +61,7 @@ export class LibraryService {
}
}
async addExercise(exercise: Omit<Exercise, 'id'>): Promise<string> {
async addExercise(exercise: Omit<ExerciseDisplay, 'id'>): Promise<string> {
try {
const id = generateId();
const timestamp = Date.now(); // Use same timestamp for both created_at and updated_at initially
@ -83,8 +83,8 @@ export class LibraryService {
timestamp, // created_at
timestamp, // updated_at
exercise.source,
exercise.format_json || null,
exercise.format_units_json || null
exercise.format ? JSON.stringify(exercise.format) : null,
exercise.format_units ? JSON.stringify(exercise.format_units) : null
]
);

View File

@ -1,7 +1,14 @@
// lib/hooks/useExercises.ts
import React, { useState, useCallback, useEffect } from 'react';
import { useSQLiteContext } from 'expo-sqlite';
import { Exercise, ExerciseCategory, Equipment, ExerciseType } from '@/types/exercise';
import {
ExerciseDisplay,
ExerciseCategory,
Equipment,
ExerciseType,
BaseExercise,
toExerciseDisplay
} from '@/types/exercise';
import { LibraryService } from '../db/services/LibraryService';
// Filtering types
@ -10,29 +17,29 @@ export interface ExerciseFilters {
category?: ExerciseCategory[];
equipment?: Equipment[];
tags?: string[];
source?: Exercise['source'][];
source?: ('local' | 'powr' | 'nostr')[];
searchQuery?: string;
}
interface ExerciseStats {
totalCount: number;
byCategory: Record<ExerciseCategory, number>;
byType: Record<ExerciseType, number>;
byEquipment: Record<Equipment, number>;
byCategory: Partial<Record<ExerciseCategory, number>>;
byType: Partial<Record<ExerciseType, number>>;
byEquipment: Partial<Record<Equipment, number>>;
}
const initialStats: ExerciseStats = {
totalCount: 0,
byCategory: {} as Record<ExerciseCategory, number>,
byType: {} as Record<ExerciseType, number>,
byEquipment: {} as Record<Equipment, number>,
byCategory: {},
byType: {},
byEquipment: {},
};
export function useExercises() {
const db = useSQLiteContext();
const libraryService = React.useMemo(() => new LibraryService(db), [db]);
const [exercises, setExercises] = useState<Exercise[]>([]);
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<ExerciseFilters>({});
@ -46,19 +53,31 @@ export function useExercises() {
setExercises(allExercises);
// Calculate stats
const newStats = allExercises.reduce((acc: ExerciseStats, exercise: Exercise) => {
const newStats = allExercises.reduce((acc: ExerciseStats, exercise: ExerciseDisplay) => {
// Increment total count
acc.totalCount++;
acc.byCategory[exercise.category] = (acc.byCategory[exercise.category] || 0) + 1;
acc.byType[exercise.type] = (acc.byType[exercise.type] || 0) + 1;
// Update category stats with type checking
if (exercise.category) {
acc.byCategory[exercise.category] = (acc.byCategory[exercise.category] || 0) + 1;
}
// Update type stats with type checking
if (exercise.type) {
acc.byType[exercise.type] = (acc.byType[exercise.type] || 0) + 1;
}
// Update equipment stats with type checking
if (exercise.equipment) {
acc.byEquipment[exercise.equipment] = (acc.byEquipment[exercise.equipment] || 0) + 1;
}
return acc;
}, {
totalCount: 0,
byCategory: {} as Record<ExerciseCategory, number>,
byType: {} as Record<ExerciseType, number>,
byEquipment: {} as Record<Equipment, number>,
byCategory: {},
byType: {},
byEquipment: {},
});
setStats(newStats);
@ -89,7 +108,7 @@ export function useExercises() {
}
// Tags filter
if (filters.tags?.length && !filters.tags.some(tag => exercise.tags.includes(tag))) {
if (filters.tags?.length && !exercise.tags.some((tag: string) => filters.tags?.includes(tag))) {
return false;
}
@ -103,8 +122,8 @@ export function useExercises() {
const query = filters.searchQuery.toLowerCase();
return (
exercise.title.toLowerCase().includes(query) ||
exercise.description?.toLowerCase().includes(query) ||
exercise.tags.some(tag => tag.toLowerCase().includes(query))
(exercise.description?.toLowerCase() || '').includes(query) ||
exercise.tags.some((tag: string) => tag.toLowerCase().includes(query))
);
}
@ -113,9 +132,16 @@ export function useExercises() {
}, [exercises, filters]);
// Create a new exercise
const createExercise = useCallback(async (exercise: Omit<Exercise, 'id'>) => {
const createExercise = useCallback(async (exercise: Omit<BaseExercise, 'id'>) => {
try {
const id = await libraryService.addExercise(exercise);
// Create a display version of the exercise with source
const displayExercise: Omit<ExerciseDisplay, 'id'> = {
...exercise,
source: 'local', // Set default source for new exercises
isFavorite: false
};
const id = await libraryService.addExercise(displayExercise);
await loadExercises(); // Reload all exercises to update stats
return id;
} catch (err) {

View File

@ -1,6 +1,12 @@
// lib/mocks/exercises.ts
import { NostrEvent } from '@/types/nostr';
import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import {
ExerciseDisplay,
ExerciseType,
ExerciseCategory,
Equipment,
BaseExercise
} from '@/types/exercise';
import { generateId } from '@/utils/ids';
// Mock exercise definitions that will become our initial POWR library
@ -215,7 +221,7 @@ export const mockExerciseEvents: NostrEvent[] = [
}
];
function getTagValue(tags: string[][], name: string): string | undefined {
function getTagValue(tags: string[][], name: string): string | undefined {
const tag = tags.find((tag: string[]) => tag[0] === name);
return tag ? tag[1] : undefined;
}
@ -226,8 +232,8 @@ function getTagValue(tags: string[][], name: string): string | undefined {
.map((tag: string[]) => tag[1]);
}
export function convertNostrToExercise(event: NostrEvent): Exercise {
return {
export function convertNostrToExercise(event: NostrEvent): ExerciseDisplay {
const baseExercise: BaseExercise = {
id: event.id || '',
title: getTagValue(event.tags, 'title') || '',
type: getTagValue(event.tags, 'equipment') === 'bodyweight'
@ -252,26 +258,33 @@ function getTagValue(tags: string[][], name: string): string | undefined {
availability: {
source: ['powr']
},
created_at: event.created_at * 1000,
source: 'powr'
created_at: event.created_at * 1000
};
// Convert to ExerciseDisplay
return {
...baseExercise,
source: 'powr',
isFavorite: false,
usageCount: 0
};
}
// Export pre-converted exercises for easy testing
export const mockExercises = mockExerciseEvents.map(convertNostrToExercise);
// Helper to seed the database
export async function seedExercises(exerciseService: any) {
try {
const existingCount = (await exerciseService.getAllExercises()).length;
if (existingCount === 0) {
console.log('Seeding database with mock exercises...');
for (const exercise of mockExercises) {
await exerciseService.createExercise(exercise);
// Export pre-converted exercises for easy testing
export const mockExercises = mockExerciseEvents.map(convertNostrToExercise);
// Helper to seed the database
export async function seedExercises(exerciseService: any) {
try {
const existingCount = (await exerciseService.getAllExercises()).length;
if (existingCount === 0) {
console.log('Seeding database with mock exercises...');
for (const exercise of mockExercises) {
await exerciseService.createExercise(exercise);
}
console.log('Successfully seeded database');
}
console.log('Successfully seeded database');
} catch (error) {
console.error('Error seeding database:', error);
}
} catch (error) {
console.error('Error seeding database:', error);
}
}
}

View File

@ -1,11 +1,14 @@
// types/exercise.ts - handles everything about individual exercises
/* import { NostrEventKind } from './events';
*/
import { SyncableContent, StorageSource } from './shared';
// types/exercise.ts
import { SyncableContent } from './shared';
// Exercise classification types
/**
* Core Exercise Classifications
* These types define the fundamental ways we categorize exercises
*/
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
export type Equipment =
| 'bodyweight'
| 'barbell'
@ -15,32 +18,28 @@ export type Equipment =
| 'cable'
| 'other';
export interface Exercise extends BaseExercise {
source: 'local' | 'powr' | 'nostr';
usageCount?: number;
lastUsed?: Date;
format_json?: string; // For database storage
format_units_json?: string; // For database storage
nostr_event_id?: string; // For Nostr integration
}
// Base library content interface
export interface LibraryContent extends SyncableContent {
title: string;
type: 'exercise' | 'workout' | 'program';
description?: string;
author?: {
name: string;
pubkey?: string;
};
category?: ExerciseCategory;
equipment?: Equipment;
source: 'local' | 'powr' | 'nostr';
tags: string[];
isPublic?: boolean;
/**
* Exercise Format Configuration
* Defines how an exercise should be tracked and measured
*/
export interface ExerciseFormat {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
}
// Basic exercise definition
export interface ExerciseFormatUnits {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
}
/**
* Base Exercise Definition
* Contains the core properties that define an exercise
*/
export interface BaseExercise extends SyncableContent {
title: string;
type: ExerciseType;
@ -49,21 +48,24 @@ export interface BaseExercise extends SyncableContent {
description?: string;
instructions?: string[];
tags: string[];
format?: {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
};
format?: ExerciseFormat;
format_units?: ExerciseFormatUnits;
}
// Set types and formats
/**
* Exercise UI Display
* Extends BaseExercise with UI-specific properties
*/
export interface ExerciseDisplay extends BaseExercise {
source: 'local' | 'powr' | 'nostr';
usageCount?: number;
lastUsed?: Date;
isFavorite?: boolean;
}
/**
* Set Types and Tracking
*/
export type SetType = 'warmup' | 'normal' | 'drop' | 'failure';
export interface WorkoutSet {
@ -77,7 +79,9 @@ export interface WorkoutSet {
timestamp?: number;
}
// Exercise with workout-specific data
/**
* Exercise with active workout data
*/
export interface WorkoutExercise extends BaseExercise {
sets: WorkoutSet[];
totalWeight?: number;
@ -87,7 +91,9 @@ export interface WorkoutExercise extends BaseExercise {
targetReps?: number;
}
// Exercise template specific types
/**
* Exercise Template with recommendations and progression
*/
export interface ExerciseTemplate extends BaseExercise {
defaultSets?: {
type: SetType;
@ -110,7 +116,9 @@ export interface ExerciseTemplate extends BaseExercise {
};
}
// Exercise history and progress tracking
/**
* Exercise History Tracking
*/
export interface ExerciseHistory {
exerciseId: string;
entries: Array<{
@ -146,4 +154,19 @@ export function isWorkoutExercise(exercise: any): exercise is WorkoutExercise {
export function isExerciseTemplate(exercise: any): exercise is ExerciseTemplate {
return exercise && 'recommendations' in exercise;
}
/**
* Converts a BaseExercise to ExerciseDisplay
*/
export function toExerciseDisplay(exercise: BaseExercise): ExerciseDisplay {
return {
...exercise, // Include all BaseExercise properties
source: exercise.availability.source.includes('nostr')
? 'nostr'
: exercise.availability.source.includes('powr')
? 'powr'
: 'local',
isFavorite: false
};
}

View File

@ -1,50 +0,0 @@
// types/library.ts
interface TemplateExercise {
title: string;
targetSets: number;
targetReps: number;
}
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
export type TemplateCategory = 'Full Body' | 'Custom' | 'Push/Pull/Legs' | 'Upper/Lower' | 'Conditioning';
export type ContentSource = 'local' | 'powr' | 'nostr';
export interface Template {
id: string;
title: string;
type: TemplateType;
category: TemplateCategory;
exercises: Array<{
title: string;
targetSets: number;
targetReps: number;
}>;
description?: string;
tags: string[];
source: ContentSource;
isFavorite?: boolean;
lastUsed?: Date;
}
export interface FilterOptions {
equipment: string[];
tags: string[];
source: ContentSource[];
}
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
export type ExerciseEquipment = 'bodyweight' | 'barbell' | 'dumbbell' | 'kettlebell' | 'machine' | 'cable' | 'other';
export interface Exercise {
id: string;
title: string;
category: ExerciseCategory;
type?: ExerciseType;
equipment?: ExerciseEquipment;
description?: string;
tags: string[];
source: ContentSource;
usageCount?: number;
lastUsed?: Date;
}

214
types/templates.ts Normal file
View File

@ -0,0 +1,214 @@
// types/template.ts
import { BaseExercise, ExerciseCategory } from './exercise';
import { StorageSource, SyncableContent } from './shared';
import { generateId } from '@/utils/ids';
/**
* Template Classifications
*/
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
export type TemplateCategory =
| 'Full Body'
| 'Push/Pull/Legs'
| 'Upper/Lower'
| 'Custom'
| 'Cardio'
| 'CrossFit'
| 'Strength'
| 'Conditioning';
/**
* Exercise configurations within templates
*/
export interface TemplateExerciseDisplay {
title: string;
targetSets: number;
targetReps: number;
notes?: string;
}
export interface TemplateExerciseConfig {
exercise: BaseExercise;
targetSets: number;
targetReps: number;
weight?: number;
rpe?: number;
setType?: 'warmup' | 'normal' | 'drop' | 'failure';
restSeconds?: number;
notes?: string;
format?: {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
};
}
/**
* Template versioning and derivation tracking
*/
export interface TemplateSource {
id: string;
eventId?: string;
authorName?: string;
authorPubkey?: string;
version?: number;
}
/**
* Base template properties shared between UI and database
*/
export interface TemplateBase {
id: string;
title: string;
type: TemplateType;
category: TemplateCategory;
description?: string;
tags: string[];
metadata?: {
lastUsed?: number;
useCount?: number;
averageDuration?: number;
};
author?: {
name: string;
pubkey?: string;
};
}
/**
* UI Template - Used for display and interaction
*/
export interface Template extends TemplateBase {
exercises: TemplateExerciseDisplay[];
source: 'local' | 'powr' | 'nostr';
isFavorite: boolean; // Required for UI state
}
/**
* Full Template - Used for database storage and Nostr events
*/
export interface WorkoutTemplate extends TemplateBase, SyncableContent {
exercises: TemplateExerciseConfig[];
isPublic: boolean;
version: number;
// Template configuration
format?: {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight: 'kg' | 'lbs';
reps: 'count';
rpe: '0-10';
set_type: 'warmup|normal|drop|failure';
};
// Workout specific configuration
rounds?: number;
duration?: number;
interval?: number;
restBetweenRounds?: number;
// Template derivation
sourceTemplate?: TemplateSource;
derivatives?: {
count: number;
lastCreated: number;
};
// Nostr integration
nostrEventId?: string;
}
/**
* Helper Functions
*/
/**
* Gets a display string for the template source
*/
export function getSourceDisplay(template: WorkoutTemplate): string {
if (!template.sourceTemplate) {
return template.availability.source.includes('nostr')
? 'NOSTR'
: template.availability.source.includes('powr')
? 'POWR'
: 'Local Template';
}
const author = template.sourceTemplate.authorName || 'Unknown Author';
if (template.sourceTemplate.version) {
return `Modified from ${author} (v${template.sourceTemplate.version})`;
}
return `Original by ${author}`;
}
/**
* Converts a WorkoutTemplate to Template for UI display
*/
export function toTemplateDisplay(template: WorkoutTemplate): Template {
return {
id: template.id,
title: template.title,
type: template.type,
category: template.category,
description: template.description,
exercises: template.exercises.map(ex => ({
title: ex.exercise.title,
targetSets: ex.targetSets,
targetReps: ex.targetReps,
notes: ex.notes
})),
tags: template.tags,
source: template.availability.source.includes('nostr')
? 'nostr'
: template.availability.source.includes('powr')
? 'powr'
: 'local',
isFavorite: false,
metadata: template.metadata,
author: template.author
};
}
/**
* Converts a Template to WorkoutTemplate for storage/sync
*/
export function toWorkoutTemplate(template: Template): WorkoutTemplate {
return {
...template,
exercises: template.exercises.map(ex => ({
exercise: {
id: generateId(),
title: ex.title,
type: 'strength',
category: 'Push' as ExerciseCategory,
tags: [],
availability: {
source: ['local']
},
created_at: Date.now()
},
targetSets: ex.targetSets,
targetReps: ex.targetReps,
notes: ex.notes
})),
isPublic: false,
version: 1,
created_at: Date.now(),
availability: {
source: ['local']
}
};
}