updated new exercise template WIP

This commit is contained in:
DocNR 2025-02-05 21:59:03 -05:00
parent 18d5886e40
commit d9d8f238b1
7 changed files with 363 additions and 117 deletions

View File

@ -4,6 +4,58 @@ All notable changes to the POWR project will be documented in this file.
## [Unreleased] ## [Unreleased]
# Changelog
All notable changes to the POWR project will be documented in this file.
## [Unreleased]
### 2024-02-05
#### Added
- Basic exercise template creation functionality
- Added input validation for required fields
- Implemented schema-compliant field constraints
- Added native picker components for standardized inputs
- Enhanced error handling in database operations
- Added detailed SQLite error logging
- Improved transaction management
- Added proper error types and propagation
#### Changed
- Updated NewExerciseScreen with constrained inputs
- Added dropdowns for equipment selection
- Added movement pattern selection
- Added difficulty selection
- Added exercise type selection
- Improved DbService with better error handling
- Added proper SQLite error types
- Enhanced transaction rollback handling
- Added detailed debug logging
#### Technical Details
1. Database Schema Enforcement:
- Added CHECK constraints for equipment types
- Added CHECK constraints for exercise types
- Added CHECK constraints for categories
- Proper handling of foreign key constraints
2. Input Validation:
- Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other
- Exercise types: strength, cardio, bodyweight
- Categories: Push, Pull, Legs, Core
- Difficulty levels: beginner, intermediate, advanced
- Movement patterns: push, pull, squat, hinge, carry, rotation
3. Error Handling:
- Added SQLite error type definitions
- Improved error propagation in LibraryService
- Added transaction rollback on constraint violations
#### Migration Notes
- Exercise creation now enforces schema constraints
- Input validation prevents invalid data entry
- Enhanced error messages provide better debugging information
### 2024-02-04 ### 2024-02-04
#### Added #### Added

View File

@ -27,12 +27,12 @@ export default function TabLayout() {
marginBottom: Platform.OS === 'ios' ? 0 : 4, marginBottom: Platform.OS === 'ios' ? 0 : 4,
}, },
}}> }}>
<Tabs.Screen <Tabs.Screen
name="index" name="profile"
options={{ options={{
title: 'Workout', title: 'Profile',
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Dumbbell size={size} color={color} /> <User size={size} color={color} />
), ),
}} }}
/> />
@ -45,6 +45,15 @@ export default function TabLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="index"
options={{
title: 'Workout',
tabBarIcon: ({ color, size }) => (
<Dumbbell size={size} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="social" name="social"
options={{ options={{
@ -63,15 +72,6 @@ export default function TabLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<User size={size} color={color} />
),
}}
/>
</Tabs> </Tabs>
); );
} }

View File

@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native'; import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { Plus } from 'lucide-react-native'; import { Plus } from 'lucide-react-native';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import TabLayout from '@/components/TabLayout'; import TabLayout from '@/components/TabLayout';
@ -71,9 +72,10 @@ export default function LibraryScreen() {
const defaultSection = TABS.find(tab => tab.key === currentSection) ?? TABS[0]; const defaultSection = TABS.find(tab => tab.key === currentSection) ?? TABS[0];
const [activeSection, setActiveSection] = useState<number>(defaultSection.index); const [activeSection, setActiveSection] = useState<number>(defaultSection.index);
// Load library content // load library content
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
if (mounted) { if (mounted) {
console.log('Starting content load');
setIsLoading(true); setIsLoading(true);
try { try {
const [exercises, templates] = await Promise.all([ const [exercises, templates] = await Promise.all([
@ -81,6 +83,9 @@ export default function LibraryScreen() {
libraryService.getTemplates() libraryService.getTemplates()
]); ]);
console.log('Loaded exercises:', exercises.length);
console.log('Loaded templates:', templates.length);
const exerciseContent: LibraryContent[] = exercises.map(exercise => ({ const exerciseContent: LibraryContent[] = exercises.map(exercise => ({
id: exercise.id, id: exercise.id,
title: exercise.title, title: exercise.title,
@ -96,7 +101,14 @@ export default function LibraryScreen() {
} }
})); }));
setContent([...exerciseContent, ...templates]); const newContent = [...exerciseContent, ...templates];
console.log('Setting new content:', newContent.length);
setContent(newContent);
// Force a re-filter of content
setFilteredContent(newContent.filter(item => {
// ... your existing filter logic ...
}));
} catch (error) { } catch (error) {
console.error('Error loading library content:', error); console.error('Error loading library content:', error);
} finally { } finally {
@ -105,11 +117,28 @@ export default function LibraryScreen() {
} }
}, [mounted]); }, [mounted]);
useEffect(() => { useFocusEffect(
loadContent(); useCallback(() => {
}, [loadContent]); console.log('Library screen focused, checking mount state:', mounted);
if (mounted) {
console.log('Loading content due to screen focus');
loadContent();
}
return () => {
console.log('Library screen unfocused');
};
}, [mounted, loadContent])
);
useEffect(() => { useEffect(() => {
console.log('Initial content load');
if (mounted) {
loadContent();
}
}, [mounted]);
useEffect(() => {
console.log('Filtering content:', content.length);
const filtered = content.filter(item => { const filtered = content.filter(item => {
if (searchQuery) { if (searchQuery) {
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
@ -140,6 +169,7 @@ export default function LibraryScreen() {
return true; return true;
}); });
console.log('Filtered content length:', filtered.length);
setFilteredContent(filtered); setFilteredContent(filtered);
}, [content, searchQuery, filterOptions]); }, [content, searchQuery, filterOptions]);
@ -176,7 +206,7 @@ export default function LibraryScreen() {
} }
}; };
const handleAddContent = (type: 'exercise' | 'template') => { const handleAddContent = useCallback((type: 'exercise' | 'template') => {
setShowAddContent(false); setShowAddContent(false);
if (type === 'exercise') { if (type === 'exercise') {
router.push('/(workout)/new-exercise' as const); router.push('/(workout)/new-exercise' as const);
@ -185,7 +215,27 @@ export default function LibraryScreen() {
pathname: '/(workout)/create-template' as const, pathname: '/(workout)/create-template' as const,
}); });
} }
}; }, []);
// Then enhance the useFocusEffect to be more robust
useFocusEffect(
useCallback(() => {
console.log('Library screen focused, checking mount state:', mounted);
const loadIfMounted = async () => {
if (mounted) {
console.log('Loading content due to screen focus');
await loadContent();
console.log('Content load complete');
}
};
loadIfMounted();
return () => {
console.log('Library screen unfocused');
};
}, [mounted, loadContent])
);
const handleDeleteContent = useCallback((deletedContent: LibraryContent) => { const handleDeleteContent = useCallback((deletedContent: LibraryContent) => {
setContent(prevContent => setContent(prevContent =>
@ -232,6 +282,19 @@ export default function LibraryScreen() {
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
onFilterPress={() => setShowFilters(true)} onFilterPress={() => setShowFilters(true)}
/> />
<TouchableOpacity
style={[styles.clearButton, { backgroundColor: colors.error }]}
onPress={async () => {
try {
await libraryService.clearDatabase();
await loadContent();
} catch (error) {
console.error('Error clearing database:', error);
}
}}
>
<ThemedText style={{ color: '#FFFFFF' }}>Clear Database</ThemedText>
</TouchableOpacity>
</View> </View>
<Pager <Pager
@ -241,14 +304,14 @@ export default function LibraryScreen() {
onPageSelected={handlePageSelected} onPageSelected={handlePageSelected}
> >
<View key="my-library" style={styles.pageContainer}> <View key="my-library" style={styles.pageContainer}>
<MyLibrary <MyLibrary
savedContent={filteredContent} savedContent={filteredContent}
onContentPress={handleContentPress} onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress} onFavoritePress={handleFavoritePress}
onDeleteContent={handleDeleteContent} onDeleteContent={handleDeleteContent}
isLoading={isLoading} isLoading={isLoading}
isVisible={activeSection === 0} isVisible={activeSection === 0}
/> />
</View> </View>
<View key="programs" style={styles.pageContainer}> <View key="programs" style={styles.pageContainer}>
@ -354,4 +417,10 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
paddingHorizontal: spacing.small, paddingHorizontal: spacing.small,
}, },
clearButton: {
marginTop: spacing.small,
padding: spacing.small,
borderRadius: 8,
alignItems: 'center',
},
}); });

View File

@ -79,6 +79,13 @@ export default function NewExerciseScreen() {
return; return;
} }
// Create unique tags array
const tags = Array.from(new Set([
difficulty,
movementPattern,
category.toLowerCase()
]));
const exerciseTemplate = { const exerciseTemplate = {
title: title.trim(), title: title.trim(),
type: exerciseType, type: exerciseType,
@ -86,11 +93,7 @@ export default function NewExerciseScreen() {
equipment, equipment,
difficulty, difficulty,
description: instructions.trim(), description: instructions.trim(),
tags: [ tags, // Now using deduplicated tags
difficulty,
movementPattern,
category.toLowerCase()
],
format: { format: {
weight: true, weight: true,
reps: true, reps: true,

View File

@ -1,6 +1,6 @@
// components/library/MyLibrary.tsx // components/library/MyLibrary.tsx
import React from 'react'; import React from 'react';
import { View, FlatList, StyleSheet, Platform } from 'react-native'; import { View, FlatList, StyleSheet, Platform, ActivityIndicator } from 'react-native';
import { Feather } from '@expo/vector-icons'; import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise'; import { LibraryContent } from '@/types/exercise';
@ -23,15 +23,32 @@ export default function MyLibrary({
onContentPress, onContentPress,
onFavoritePress, onFavoritePress,
onDeleteContent, onDeleteContent,
isLoading = false,
isVisible = true isVisible = true
}: MyLibraryProps) { }: MyLibraryProps) {
const { colors } = useColorScheme(); const { colors } = useColorScheme();
console.log('MyLibrary render:', {
contentLength: savedContent.length,
isVisible,
isLoading
});
// Don't render anything if not visible // Don't render anything if not visible
if (!isVisible) { if (!isVisible) {
console.log('MyLibrary not visible, returning null');
return null; return null;
} }
// Show loading state
if (isLoading) {
return (
<View style={[styles.loadingContainer, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
const handleDelete = async (content: LibraryContent) => { const handleDelete = async (content: LibraryContent) => {
try { try {
if (content.type === 'exercise') { if (content.type === 'exercise') {
@ -48,6 +65,12 @@ export default function MyLibrary({
const exercises = savedContent.filter(content => content.type === 'exercise'); const exercises = savedContent.filter(content => content.type === 'exercise');
const workouts = savedContent.filter(content => content.type === 'workout'); const workouts = savedContent.filter(content => content.type === 'workout');
console.log('Content breakdown:', {
total: savedContent.length,
exercises: exercises.length,
workouts: workouts.length
});
const renderSection = (title: string, items: LibraryContent[]) => { const renderSection = (title: string, items: LibraryContent[]) => {
if (items.length === 0) return null; if (items.length === 0) return null;
@ -66,7 +89,7 @@ export default function MyLibrary({
onDelete={item.type === 'exercise' ? () => handleDelete(item) : undefined} onDelete={item.type === 'exercise' ? () => handleDelete(item) : undefined}
/> />
)} )}
keyExtractor={item => item.id} keyExtractor={(item, index) => `${title}-${item.id}-${index}`}
scrollEnabled={false} scrollEnabled={false}
/> />
</View> </View>
@ -129,4 +152,9 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
maxWidth: '80%', maxWidth: '80%',
}, },
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
}); });

View File

@ -10,6 +10,7 @@ import {
import { WorkoutTemplate } from '@/types/workout'; import { WorkoutTemplate } from '@/types/workout';
import { StorageSource } from '@/types/shared'; import { StorageSource } from '@/types/shared';
import { SQLiteError } from '@/types/sqlite'; import { SQLiteError } from '@/types/sqlite';
import { schema } from '@/utils/db/schema';
class LibraryService { class LibraryService {
private db: DbService; private db: DbService;
@ -23,11 +24,14 @@ class LibraryService {
const id = generateId(); const id = generateId();
const timestamp = Date.now(); const timestamp = Date.now();
// Deduplicate tags
const uniqueTags = Array.from(new Set(exercise.tags));
if (this.DEBUG) { if (this.DEBUG) {
console.log('Creating exercise with payload:', { console.log('Creating exercise with payload:', {
id, id,
timestamp, timestamp,
exercise, exercise: { ...exercise, tags: uniqueTags },
}); });
} }
@ -79,8 +83,8 @@ class LibraryService {
} }
// 3. Insert tags if provided // 3. Insert tags if provided
if (exercise.tags?.length) { if (uniqueTags?.length) {
for (const tag of exercise.tags) { for (const tag of uniqueTags) {
const tagResult = await this.db.executeWrite( const tagResult = await this.db.executeWrite(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`, `INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[id, tag] [id, tag]
@ -220,6 +224,14 @@ class LibraryService {
const result = await this.db.executeSql(query, category ? [category] : []); const result = await this.db.executeSql(query, category ? [category] : []);
// Add this logging
console.log(`Found ${result.rows.length} exercises in database:`,
Array.from({ length: result.rows.length }, (_, i) => ({
id: result.rows.item(i).id,
title: result.rows.item(i).title
}))
);
return Array.from({ length: result.rows.length }, (_, i) => { return Array.from({ length: result.rows.length }, (_, i) => {
const row = result.rows.item(i); const row = result.rows.item(i);
return { return {
@ -295,7 +307,15 @@ class LibraryService {
}; };
}); });
return [...exerciseContent, ...templateContent]; const allContent = [...exerciseContent, ...templateContent];
// Ensure no duplicates by ID
const uniqueContent = Array.from(
new Map(allContent.map(item => [item.id, item])).values()
);
return uniqueContent;
} catch (error) { } catch (error) {
console.error('Error getting templates:', error); console.error('Error getting templates:', error);
throw error; throw error;
@ -349,6 +369,49 @@ class LibraryService {
} }
} }
async clearDatabase(): Promise<void> {
if (this.DEBUG) {
console.log('Clearing database...');
}
try {
// Disable foreign key constraints
await this.db.executeWrite('PRAGMA foreign_keys = OFF;');
// Drop all tables
const dropQueries = [
'DROP TABLE IF EXISTS template_tags',
'DROP TABLE IF EXISTS template_exercises',
'DROP TABLE IF EXISTS exercise_tags',
'DROP TABLE IF EXISTS exercise_instructions',
'DROP TABLE IF EXISTS exercise_format',
'DROP TABLE IF EXISTS templates',
'DROP TABLE IF EXISTS exercises',
'DROP TABLE IF EXISTS schema_version'
];
for (const query of dropQueries) {
if (this.DEBUG) {
console.log('Executing:', query);
}
await this.db.executeWrite(query);
}
// Re-enable foreign key constraints
await this.db.executeWrite('PRAGMA foreign_keys = ON;');
// Recreate schema using the schema utility
await schema.migrate();
if (this.DEBUG) {
console.log('Database cleared and reinitialized successfully');
}
} catch (error) {
console.error('Error clearing database:', error);
throw error;
}
}
private async getTemplateExercises(templateId: string): Promise<Array<{ private async getTemplateExercises(templateId: string): Promise<Array<{
exercise: BaseExercise; exercise: BaseExercise;
targetSets: number; targetSets: number;

View File

@ -117,77 +117,108 @@ class Schema {
} }
async migrate(): Promise<void> { async migrate(): Promise<void> {
const currentVersion = await this.getCurrentVersion(); try {
const currentVersion = await this.getCurrentVersion();
if (currentVersion < SCHEMA_VERSION) { console.log('Current database version:', currentVersion);
if (currentVersion < 1) { console.log('Target database version:', SCHEMA_VERSION);
await this.createTables();
await this.setVersion(1);
}
// Migration to version 2 - Add format table if (currentVersion < SCHEMA_VERSION) {
if (currentVersion < 2) { console.log('Starting database migration...');
await this.db.executeWrite(`
CREATE TABLE IF NOT EXISTS exercise_format (
exercise_id TEXT PRIMARY KEY,
format_json TEXT NOT NULL,
units_json TEXT,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);
`);
await this.setVersion(2);
}
// Migration to version 3 - Add template tables // Initial migration
if (currentVersion < 3) { if (currentVersion < 1) {
await this.db.executeWriteMany([ console.log('Running migration to version 1...');
{ await this.createTables();
sql: `CREATE TABLE IF NOT EXISTS templates ( await this.setVersion(1);
id TEXT PRIMARY KEY, }
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')), // Migration to version 2 - Add format table
category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')), if (currentVersion < 2) {
description TEXT, console.log('Running migration to version 2...');
author_name TEXT, try {
author_pubkey TEXT, await this.db.executeWrite(`
rounds INTEGER, CREATE TABLE IF NOT EXISTS exercise_format (
duration INTEGER, exercise_id TEXT PRIMARY KEY,
interval_time INTEGER, format_json TEXT NOT NULL,
rest_between_rounds INTEGER, units_json TEXT,
is_public BOOLEAN NOT NULL DEFAULT 0, FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
created_at INTEGER NOT NULL, );
updated_at INTEGER NOT NULL, `);
metadata_json TEXT, await this.setVersion(2);
availability_json TEXT NOT NULL, console.log('Migration to version 2 completed');
source TEXT NOT NULL DEFAULT 'local' } catch (error) {
);` console.error('Error in version 2 migration:', error);
}, throw error;
{
sql: `CREATE TABLE IF NOT EXISTS template_exercises (
template_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_weight REAL,
target_rpe INTEGER CHECK(target_rpe BETWEEN 0 AND 10),
notes TEXT,
display_order INTEGER NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
PRIMARY KEY(template_id, exercise_id, display_order)
);`
},
{
sql: `CREATE TABLE IF NOT EXISTS template_tags (
template_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
UNIQUE(template_id, tag)
);`
} }
]); }
await this.setVersion(3);
// Migration to version 3 - Add template tables
if (currentVersion < 3) {
console.log('Running migration to version 3...');
try {
await this.db.executeWriteMany([
{
sql: `CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')),
category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')),
description TEXT,
notes TEXT,
author_name TEXT,
author_pubkey TEXT,
rounds INTEGER,
duration INTEGER,
interval_time INTEGER,
rest_between_rounds INTEGER,
is_public BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata_json TEXT,
availability_json TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'local'
)`
},
{
sql: `CREATE TABLE IF NOT EXISTS template_exercises (
template_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_weight REAL,
target_rpe INTEGER CHECK(target_rpe BETWEEN 0 AND 10),
notes TEXT,
display_order INTEGER NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
PRIMARY KEY(template_id, exercise_id, display_order)
)`
},
{
sql: `CREATE TABLE IF NOT EXISTS template_tags (
template_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
UNIQUE(template_id, tag)
)`
}
]);
await this.setVersion(3);
console.log('Migration to version 3 completed');
} catch (error) {
console.error('Error in version 3 migration:', error);
throw error;
}
}
console.log('All migrations completed successfully');
} else {
console.log('Database is up to date');
} }
} catch (error) {
console.error('Migration failed:', error);
throw error;
} }
} }