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]
# 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
#### Added

View File

@ -27,12 +27,12 @@ export default function TabLayout() {
marginBottom: Platform.OS === 'ios' ? 0 : 4,
},
}}>
<Tabs.Screen
name="index"
<Tabs.Screen
name="profile"
options={{
title: 'Workout',
title: 'Profile',
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
name="social"
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>
);
);
}

View File

@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { useColorScheme } from '@/hooks/useColorScheme';
import { router, useLocalSearchParams } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { Plus } from 'lucide-react-native';
import { ThemedText } from '@/components/ThemedText';
import TabLayout from '@/components/TabLayout';
@ -71,16 +72,20 @@ export default function LibraryScreen() {
const defaultSection = TABS.find(tab => tab.key === currentSection) ?? TABS[0];
const [activeSection, setActiveSection] = useState<number>(defaultSection.index);
// Load library content
// load library content
const loadContent = useCallback(async () => {
if (mounted) {
console.log('Starting content load');
setIsLoading(true);
try {
const [exercises, templates] = await Promise.all([
libraryService.getExercises(),
libraryService.getTemplates()
]);
console.log('Loaded exercises:', exercises.length);
console.log('Loaded templates:', templates.length);
const exerciseContent: LibraryContent[] = exercises.map(exercise => ({
id: exercise.id,
title: exercise.title,
@ -95,8 +100,15 @@ export default function LibraryScreen() {
source: ['local']
}
}));
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) {
console.error('Error loading library content:', error);
} finally {
@ -105,11 +117,28 @@ export default function LibraryScreen() {
}
}, [mounted]);
useEffect(() => {
loadContent();
}, [loadContent]);
useFocusEffect(
useCallback(() => {
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(() => {
console.log('Initial content load');
if (mounted) {
loadContent();
}
}, [mounted]);
useEffect(() => {
console.log('Filtering content:', content.length);
const filtered = content.filter(item => {
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
@ -140,6 +169,7 @@ export default function LibraryScreen() {
return true;
});
console.log('Filtered content length:', filtered.length);
setFilteredContent(filtered);
}, [content, searchQuery, filterOptions]);
@ -176,7 +206,7 @@ export default function LibraryScreen() {
}
};
const handleAddContent = (type: 'exercise' | 'template') => {
const handleAddContent = useCallback((type: 'exercise' | 'template') => {
setShowAddContent(false);
if (type === 'exercise') {
router.push('/(workout)/new-exercise' as const);
@ -185,7 +215,27 @@ export default function LibraryScreen() {
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) => {
setContent(prevContent =>
@ -232,6 +282,19 @@ export default function LibraryScreen() {
onChangeText={setSearchQuery}
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>
<Pager
@ -241,14 +304,14 @@ export default function LibraryScreen() {
onPageSelected={handlePageSelected}
>
<View key="my-library" style={styles.pageContainer}>
<MyLibrary
savedContent={filteredContent}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
onDeleteContent={handleDeleteContent}
isLoading={isLoading}
isVisible={activeSection === 0}
/>
<MyLibrary
savedContent={filteredContent}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
onDeleteContent={handleDeleteContent}
isLoading={isLoading}
isVisible={activeSection === 0}
/>
</View>
<View key="programs" style={styles.pageContainer}>
@ -354,4 +417,10 @@ const styles = StyleSheet.create({
fontWeight: '500',
paddingHorizontal: spacing.small,
},
clearButton: {
marginTop: spacing.small,
padding: spacing.small,
borderRadius: 8,
alignItems: 'center',
},
});

View File

@ -73,12 +73,19 @@ export default function NewExerciseScreen() {
try {
setError(null);
setIsSubmitting(true);
if (!title.trim()) {
setError('Exercise name is required');
return;
}
// Create unique tags array
const tags = Array.from(new Set([
difficulty,
movementPattern,
category.toLowerCase()
]));
const exerciseTemplate = {
title: title.trim(),
type: exerciseType,
@ -86,11 +93,7 @@ export default function NewExerciseScreen() {
equipment,
difficulty,
description: instructions.trim(),
tags: [
difficulty,
movementPattern,
category.toLowerCase()
],
tags, // Now using deduplicated tags
format: {
weight: true,
reps: true,

View File

@ -1,6 +1,6 @@
// components/library/MyLibrary.tsx
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 { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
@ -23,15 +23,32 @@ export default function MyLibrary({
onContentPress,
onFavoritePress,
onDeleteContent,
isLoading = false,
isVisible = true
}: MyLibraryProps) {
const { colors } = useColorScheme();
console.log('MyLibrary render:', {
contentLength: savedContent.length,
isVisible,
isLoading
});
// Don't render anything if not visible
if (!isVisible) {
console.log('MyLibrary not visible, returning 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) => {
try {
if (content.type === 'exercise') {
@ -48,6 +65,12 @@ export default function MyLibrary({
const exercises = savedContent.filter(content => content.type === 'exercise');
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[]) => {
if (items.length === 0) return null;
@ -66,7 +89,7 @@ export default function MyLibrary({
onDelete={item.type === 'exercise' ? () => handleDelete(item) : undefined}
/>
)}
keyExtractor={item => item.id}
keyExtractor={(item, index) => `${title}-${item.id}-${index}`}
scrollEnabled={false}
/>
</View>
@ -129,4 +152,9 @@ const styles = StyleSheet.create({
textAlign: 'center',
maxWidth: '80%',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@ -10,6 +10,7 @@ import {
import { WorkoutTemplate } from '@/types/workout';
import { StorageSource } from '@/types/shared';
import { SQLiteError } from '@/types/sqlite';
import { schema } from '@/utils/db/schema';
class LibraryService {
private db: DbService;
@ -22,15 +23,18 @@ class LibraryService {
async addExercise(exercise: Omit<BaseExercise, 'id' | 'created_at' | 'availability'>): Promise<string> {
const id = generateId();
const timestamp = Date.now();
// Deduplicate tags
const uniqueTags = Array.from(new Set(exercise.tags));
if (this.DEBUG) {
console.log('Creating exercise with payload:', {
id,
timestamp,
exercise,
exercise: { ...exercise, tags: uniqueTags },
});
}
try {
await this.db.withTransaction(async () => {
// 1. First insert main exercise data
@ -79,19 +83,19 @@ class LibraryService {
}
// 3. Insert tags if provided
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
if (uniqueTags?.length) {
for (const tag of uniqueTags) {
const tagResult = await this.db.executeWrite(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[id, tag]
);
if (this.DEBUG) {
console.log('Tag insert result:', tagResult);
}
}
}
// 4. Insert instructions if provided
if (exercise.instructions?.length) {
for (const [index, instruction] of exercise.instructions.entries()) {
@ -217,8 +221,16 @@ class LibraryService {
GROUP BY e.id
ORDER BY e.title
`;
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) => {
const row = result.rows.item(i);
@ -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) {
console.error('Error getting templates:', 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<{
exercise: BaseExercise;
targetSets: number;

View File

@ -117,77 +117,108 @@ class Schema {
}
async migrate(): Promise<void> {
const currentVersion = await this.getCurrentVersion();
if (currentVersion < SCHEMA_VERSION) {
if (currentVersion < 1) {
await this.createTables();
await this.setVersion(1);
}
try {
const currentVersion = await this.getCurrentVersion();
// Migration to version 2 - Add format table
if (currentVersion < 2) {
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
if (currentVersion < 3) {
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,
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)
);`
console.log('Current database version:', currentVersion);
console.log('Target database version:', SCHEMA_VERSION);
if (currentVersion < SCHEMA_VERSION) {
console.log('Starting database migration...');
// Initial migration
if (currentVersion < 1) {
console.log('Running migration to version 1...');
await this.createTables();
await this.setVersion(1);
}
// Migration to version 2 - Add format table
if (currentVersion < 2) {
console.log('Running migration to version 2...');
try {
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);
console.log('Migration to version 2 completed');
} catch (error) {
console.error('Error in version 2 migration:', error);
throw error;
}
]);
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;
}
}