From 90ea708e9b42765119ae584ce6011cf4d740bed3 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 16 Feb 2025 23:53:28 -0500 Subject: [PATCH] add new exercises to sqlite db --- app/(tabs)/library/exercises.tsx | 122 +++---- app/(tabs)/library/programs.tsx | 281 ++++++++++++++- components/DatabaseDebug.tsx | 47 ++- components/exercises/ExerciseCard.tsx | 21 +- components/library/NewExerciseSheet.tsx | 12 +- lib/db/schema.ts | 124 +++++-- lib/db/services/ExerciseService.ts | 444 ++++++++++++++++++++++++ lib/db/types.ts | 36 ++ types/exercise.ts | 11 +- types/shared.ts | 4 +- 10 files changed, 956 insertions(+), 146 deletions(-) create mode 100644 lib/db/services/ExerciseService.ts create mode 100644 lib/db/types.ts diff --git a/app/(tabs)/library/exercises.tsx b/app/(tabs)/library/exercises.tsx index 4df25dc..7fb27ba 100644 --- a/app/(tabs)/library/exercises.tsx +++ b/app/(tabs)/library/exercises.tsx @@ -1,90 +1,82 @@ // app/(tabs)/library/exercises.tsx -import React, { useState } from 'react'; -import { View, ScrollView } from 'react-native'; +import React, { useState, useEffect } from 'react'; +import { View, ScrollView, SectionList } from 'react-native'; import { Text } from '@/components/ui/text'; import { ExerciseCard } from '@/components/exercises/ExerciseCard'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; import { Dumbbell } from 'lucide-react-native'; -import { Exercise } from '@/types/library'; -import { generateId } from '@/utils/ids'; -import DatabaseDebug from '@/components/DatabaseDebug'; // Add this import - -const initialExercises: Exercise[] = [ - { - id: '1', - title: 'Barbell Back Squat', - category: 'Legs', - equipment: 'barbell', - tags: ['compound', 'strength'], - source: 'local', - description: 'A compound exercise that primarily targets the quadriceps, hamstrings, and glutes.', - }, - { - id: '2', - title: 'Pull-ups', - category: 'Pull', - equipment: 'bodyweight', - tags: ['upper-body', 'compound'], - source: 'local', - description: 'An upper body pulling exercise that targets the latissimus dorsi and biceps.', - }, - { - id: '3', - title: 'Bench Press', - category: 'Push', - equipment: 'barbell', - tags: ['push', 'strength'], - source: 'nostr', - description: 'A compound pushing exercise that targets the chest, shoulders, and triceps.', - }, -]; +import { Exercise, BaseExercise } from '@/types/exercise'; +import { useSQLiteContext } from 'expo-sqlite'; +import { ExerciseService } from '@/lib/db/services/ExerciseService'; export default function ExercisesScreen() { - const [exercises, setExercises] = useState(initialExercises); + const db = useSQLiteContext(); + const exerciseService = React.useMemo(() => new ExerciseService(db), [db]); + + const [exercises, setExercises] = useState([]); const [showNewExercise, setShowNewExercise] = useState(false); - const handleAddExercise = (exerciseData: Omit) => { - const newExercise: Exercise = { - ...exerciseData, - id: generateId(), - source: 'local', - }; - setExercises(prev => [...prev, newExercise]); - setShowNewExercise(false); + useEffect(() => { + loadExercises(); + }, []); + + const loadExercises = async () => { + try { + const loadedExercises = await exerciseService.getAllExercises(); + setExercises(loadedExercises); + } catch (error) { + console.error('Error loading exercises:', error); + } }; - // Get recent exercises - const recentExercises = exercises.slice(0, 2); + const handleAddExercise = async (exerciseData: BaseExercise) => { + try { + await exerciseService.createExercise({ + ...exerciseData, + created_at: Date.now(), + source: 'local' + }); + await loadExercises(); + setShowNewExercise(false); + } catch (error) { + console.error('Error adding exercise:', error); + } + }; - const handleDelete = (id: string) => { - setExercises(current => current.filter(ex => ex.id !== id)); + const handleDelete = async (id: string) => { + try { + await exerciseService.deleteExercise(id); + await loadExercises(); + } catch (error) { + console.error('Error deleting exercise:', error); + } }; const handleExercisePress = (exerciseId: string) => { console.log('Selected exercise:', exerciseId); }; + const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + return ( - {__DEV__ && } {/* Only show in development */} - - {/* Recent Exercises Section */} - - Recent Exercises - - {recentExercises.map(exercise => ( - handleExercisePress(exercise.id)} - onDelete={() => handleDelete(exercise.id)} - /> - ))} - - + + {alphabet.map((letter) => ( + { + // TODO: Implement scroll to section + console.log('Scroll to:', letter); + }} + > + {letter} + + ))} + - {/* All Exercises Section */} + All Exercises diff --git a/app/(tabs)/library/programs.tsx b/app/(tabs)/library/programs.tsx index acc790a..c0dbed0 100644 --- a/app/(tabs)/library/programs.tsx +++ b/app/(tabs)/library/programs.tsx @@ -1,11 +1,282 @@ -// app/(tabs)/library/(tabs)/programs.tsx -import { View } from 'react-native'; +// app/(tabs)/library/programs.tsx +import React, { useState, useEffect } from 'react'; +import { View, ScrollView } from 'react-native'; import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2 } from 'lucide-react-native'; +import { useSQLiteContext } from 'expo-sqlite'; +import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; +import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types'; +import { schema } from '@/lib/db/schema'; + +interface TableInfo { + name: string; +} + +interface SchemaVersion { + version: number; +} + +interface ExerciseRow { + id: string; + title: string; + type: string; + category: string; + equipment: string | null; + description: string | null; + created_at: number; + updated_at: number; + format_json: string; + format_units_json: string; +} export default function ProgramsScreen() { + const db = useSQLiteContext(); + const [dbStatus, setDbStatus] = useState<{ + initialized: boolean; + tables: string[]; + error?: string; + }>({ + initialized: false, + tables: [], + }); + + const [testResults, setTestResults] = useState<{ + success: boolean; + message: string; + } | null>(null); + + useEffect(() => { + checkDatabase(); + }, []); + + const checkDatabase = async () => { + try { + // Check schema_version table + const version = await db.getFirstAsync( + 'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1' + ); + + // Get all tables + const tables = await db.getAllAsync( + "SELECT name FROM sqlite_master WHERE type='table'" + ); + + setDbStatus({ + initialized: !!version, + tables: tables.map(t => t.name), + }); + } catch (error) { + console.error('Error checking database:', error); + setDbStatus(prev => ({ + ...prev, + error: error instanceof Error ? error.message : 'Unknown error occurred', + })); + } + }; + + const resetDatabase = async () => { + try { + await db.withTransactionAsync(async () => { + // Drop all tables + const tables = await db.getAllAsync<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ); + + for (const { name } of tables) { + await db.execAsync(`DROP TABLE IF EXISTS ${name}`); + } + + // Recreate schema + await schema.createTables(db); + }); + + setTestResults({ + success: true, + message: 'Database reset successfully' + }); + + // Refresh database status + checkDatabase(); + } catch (error) { + console.error('Error resetting database:', error); + setTestResults({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error during reset' + }); + } + }; + + const runTestInsert = async () => { + try { + // Test exercise + const testExercise = { + title: "Test Squat", + type: "strength" as ExerciseType, + category: "Legs" as ExerciseCategory, + equipment: "barbell" as Equipment, + description: "Test exercise", + tags: ["test", "legs"], + format: { + weight: true, + reps: true + }, + format_units: { + weight: "kg" as const, + reps: "count" as const + } + }; + + const timestamp = Date.now(); + + // Insert exercise using withTransactionAsync + await db.withTransactionAsync(async () => { + // Insert exercise + await db.runAsync( + `INSERT INTO exercises ( + id, title, type, category, equipment, description, + format_json, format_units_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'test-1', + testExercise.title, + testExercise.type, + testExercise.category, + testExercise.equipment || null, + testExercise.description || null, + JSON.stringify(testExercise.format), + JSON.stringify(testExercise.format_units), + timestamp, + timestamp + ] + ); + + // Insert tags + for (const tag of testExercise.tags) { + await db.runAsync( + "INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)", + ['test-1', tag] + ); + } + }); + + // Verify insert + const result = await db.getFirstAsync( + "SELECT * FROM exercises WHERE id = ?", + ['test-1'] + ); + + setTestResults({ + success: true, + message: `Successfully inserted and verified test exercise: ${JSON.stringify(result, null, 2)}` + }); + + } catch (error) { + console.error('Test insert error:', error); + setTestResults({ + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } + }; + return ( - - Programs (Coming Soon) - + + + Database Debug Panel + + + + + + Database Status + + + + + Initialized: {dbStatus.initialized ? '✅' : '❌'} + Tables Found: {dbStatus.tables.length} + + {dbStatus.tables.map(table => ( + • {table} + ))} + + {dbStatus.error && ( + + + + Error + + {dbStatus.error} + + )} + + + + + + + + + Database Operations + + + + + + + + + {testResults && ( + + + {testResults.success ? ( + + ) : ( + + )} + + {testResults.success ? "Success" : "Error"} + + + + + {testResults.message} + + + + )} + + + + + ); } \ No newline at end of file diff --git a/components/DatabaseDebug.tsx b/components/DatabaseDebug.tsx index ca2c5ba..6d109f7 100644 --- a/components/DatabaseDebug.tsx +++ b/components/DatabaseDebug.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { AlertCircle, CheckCircle2 } from 'lucide-react-native'; import { useSQLiteContext } from 'expo-sqlite'; import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; +import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types'; interface TableInfo { name: string; @@ -15,6 +16,20 @@ interface SchemaVersion { version: number; } +interface ExerciseRow { + id: string; + title: string; + type: string; + category: string; + equipment: string | null; + description: string | null; + created_at: number; + updated_at: number; + source: string; + format_json: string; + format_units_json: string; +} + export default function DatabaseDebug() { const db = useSQLiteContext(); const [dbStatus, setDbStatus] = useState<{ @@ -52,6 +67,7 @@ export default function DatabaseDebug() { tables: tables.map(t => t.name), }); } catch (error) { + console.error('Error checking database:', error); setDbStatus(prev => ({ ...prev, error: error instanceof Error ? error.message : 'Unknown error occurred', @@ -78,17 +94,17 @@ export default function DatabaseDebug() { reps: "count" as const } }; - + + const timestamp = Date.now(); + + // Insert exercise using withTransactionAsync await db.withTransactionAsync(async () => { - const timestamp = Date.now(); - // Insert exercise await db.runAsync( `INSERT INTO exercises ( id, title, type, category, equipment, description, - created_at, updated_at, source, - format_json, format_units_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + format_json, format_units_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ 'test-1', testExercise.title, @@ -96,14 +112,13 @@ export default function DatabaseDebug() { testExercise.category, testExercise.equipment || null, testExercise.description || null, - timestamp, - timestamp, - 'local', JSON.stringify(testExercise.format), - JSON.stringify(testExercise.format_units) + JSON.stringify(testExercise.format_units), + timestamp, + timestamp ] ); - + // Insert tags for (const tag of testExercise.tags) { await db.runAsync( @@ -112,18 +127,20 @@ export default function DatabaseDebug() { ); } }); - + // Verify insert - const result = await db.getFirstAsync( - "SELECT * FROM exercises WHERE id = ?", + const result = await db.getFirstAsync( + "SELECT * FROM exercises WHERE id = ?", ['test-1'] ); - + setTestResults({ success: true, message: `Successfully inserted and verified test exercise: ${JSON.stringify(result, null, 2)}` }); + } catch (error) { + console.error('Test insert error:', error); setTestResults({ success: false, message: error instanceof Error ? error.message : 'Unknown error occurred' diff --git a/components/exercises/ExerciseCard.tsx b/components/exercises/ExerciseCard.tsx index c304a8f..f3f2d29 100644 --- a/components/exercises/ExerciseCard.tsx +++ b/components/exercises/ExerciseCard.tsx @@ -23,7 +23,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -import { Exercise } from '@/types/library'; +import { Exercise } from '@/types/exercise'; interface ExerciseCardProps extends Exercise { onPress: () => void; @@ -48,23 +48,17 @@ export function ExerciseCard({ const [showSheet, setShowSheet] = React.useState(false); const [showDeleteAlert, setShowDeleteAlert] = React.useState(false); - const handleDeletePress = () => { - setShowDeleteAlert(true); - }; - const handleConfirmDelete = () => { onDelete(id); setShowDeleteAlert(false); - }; - - const handleCardPress = () => { - setShowSheet(true); - onPress(); + if (showSheet) { + setShowSheet(false); // Close detail sheet if open + } }; return ( <> - + setShowSheet(true)} activeOpacity={0.7}> @@ -143,8 +137,8 @@ export function ExerciseCard({ @@ -175,7 +169,6 @@ export function ExerciseCard({ - {/* Bottom sheet section */} setShowSheet(false)}> diff --git a/components/library/NewExerciseSheet.tsx b/components/library/NewExerciseSheet.tsx index d339ea3..bb16520 100644 --- a/components/library/NewExerciseSheet.tsx +++ b/components/library/NewExerciseSheet.tsx @@ -52,18 +52,8 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet const handleSubmit = () => { if (!formData.title || !formData.equipment) return; - // Cast to any as a temporary workaround for the TypeScript error const exercise = { - // BaseExercise properties - title: formData.title, - type: formData.type, - category: formData.category, - equipment: formData.equipment, - description: formData.description, - tags: formData.tags, - format: formData.format, - format_units: formData.format_units, - // SyncableContent properties + ...formData, id: generateId('local'), created_at: Date.now(), availability: { diff --git a/lib/db/schema.ts b/lib/db/schema.ts index f2a55ef..9e08485 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,11 +1,25 @@ +// lib/db/schema.ts import { SQLiteDatabase } from 'expo-sqlite'; -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; class Schema { + private async getCurrentVersion(db: SQLiteDatabase): Promise { + try { + const version = await db.getFirstAsync<{ version: number }>( + 'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1' + ); + return version?.version ?? 0; + } catch (error) { + return 0; // If table doesn't exist yet + } + } + async createTables(db: SQLiteDatabase): Promise { try { - // Version tracking + const currentVersion = await this.getCurrentVersion(db); + + // Schema version tracking await db.execAsync(` CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, @@ -13,43 +27,87 @@ class Schema { ); `); - // Exercise Definitions - await db.execAsync(` - CREATE TABLE IF NOT EXISTS exercises ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - type TEXT NOT NULL, - category TEXT NOT NULL, - equipment TEXT, - description TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - `); + if (currentVersion === 0) { + // Drop existing tables if they exist + await db.execAsync(`DROP TABLE IF EXISTS exercise_tags`); + await db.execAsync(`DROP TABLE IF EXISTS exercises`); + await db.execAsync(`DROP TABLE IF EXISTS event_tags`); + await db.execAsync(`DROP TABLE IF EXISTS nostr_events`); + + // Create base tables + await db.execAsync(` + CREATE TABLE exercises ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')), + category TEXT NOT NULL, + equipment TEXT, + description TEXT, + format_json TEXT, + format_units_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'local' + ); + `); - // Exercise Tags - await db.execAsync(` - CREATE TABLE IF NOT EXISTS exercise_tags ( - exercise_id TEXT NOT NULL, - tag TEXT NOT NULL, - FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE, - UNIQUE(exercise_id, tag) - ); + await db.execAsync(` + CREATE TABLE exercise_tags ( + exercise_id TEXT NOT NULL, + tag TEXT NOT NULL, + FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE, + UNIQUE(exercise_id, tag) + ); + CREATE INDEX idx_exercise_tags ON exercise_tags(tag); + `); - CREATE INDEX IF NOT EXISTS idx_exercise_tags ON exercise_tags(tag); - `); - - // Set initial schema version if not exists - const version = await db.getFirstAsync<{ version: number }>( - 'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1' - ); - - if (!version) { + // Set initial version await db.runAsync( 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [SCHEMA_VERSION, Date.now()] + [1, Date.now()] ); } + + // Update to version 2 if needed + if (currentVersion < 2) { + await db.execAsync(` + CREATE TABLE IF NOT EXISTS nostr_events ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + content TEXT NOT NULL, + sig TEXT, + raw_event TEXT NOT NULL, + received_at INTEGER NOT NULL + ); + `); + + await db.execAsync(` + CREATE TABLE IF NOT EXISTS event_tags ( + event_id TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + index_num INTEGER NOT NULL, + FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_event_tags ON event_tags(name, value); + `); + + // Add Nostr reference to exercises if not exists + try { + await db.execAsync(`ALTER TABLE exercises ADD COLUMN nostr_event_id TEXT REFERENCES nostr_events(id)`); + } catch (e) { + // Column might already exist + } + + await db.runAsync( + 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', + [2, Date.now()] + ); + } + + console.log(`[Schema] Database initialized at version ${await this.getCurrentVersion(db)}`); } catch (error) { console.error('[Schema] Error creating tables:', error); throw error; diff --git a/lib/db/services/ExerciseService.ts b/lib/db/services/ExerciseService.ts new file mode 100644 index 0000000..979eac5 --- /dev/null +++ b/lib/db/services/ExerciseService.ts @@ -0,0 +1,444 @@ +// lib/db/services/ExerciseService.ts +import { SQLiteDatabase } from 'expo-sqlite'; +import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; +import { generateId } from '@/utils/ids'; + +export class ExerciseService { + constructor(private db: SQLiteDatabase) {} + + // Add this new method + async getAllExercises(): Promise { + try { + const exercises = await this.db.getAllAsync(` + SELECT * FROM exercises ORDER BY created_at DESC + `); + + const exerciseIds = exercises.map(e => e.id); + if (exerciseIds.length === 0) return []; + + const tags = await this.db.getAllAsync<{ exercise_id: string; tag: string }>( + `SELECT exercise_id, tag + FROM exercise_tags + WHERE exercise_id IN (${exerciseIds.map(() => '?').join(',')})`, + exerciseIds + ); + + const tagsByExercise = tags.reduce((acc, { exercise_id, tag }) => { + acc[exercise_id] = acc[exercise_id] || []; + acc[exercise_id].push(tag); + return acc; + }, {} as Record); + + 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] } + })); + } catch (error) { + console.error('Error getting all exercises:', error); + throw error; + } + } + + // Update createExercise to handle all required fields + async createExercise(exercise: Omit): Promise { + const id = generateId(); + const timestamp = Date.now(); + + try { + await this.db.withTransactionAsync(async () => { + await this.db.runAsync( + `INSERT INTO exercises ( + id, title, type, category, equipment, description, + format_json, format_units_json, created_at, updated_at, source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + exercise.title, + exercise.type, + exercise.category, + exercise.equipment || null, + exercise.description || null, + exercise.format ? JSON.stringify(exercise.format) : null, + exercise.format_units ? JSON.stringify(exercise.format_units) : null, + timestamp, + timestamp, + exercise.source || 'local' + ] + ); + + if (exercise.tags?.length) { + for (const tag of exercise.tags) { + await this.db.runAsync( + 'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)', + [id, tag] + ); + } + } + }); + + return id; + } catch (error) { + console.error('Error creating exercise:', error); + throw error; + } + } + + async getExercise(id: string): Promise { + try { + // Get exercise data + const exercise = await this.db.getFirstAsync( + `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): Promise { + 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 { + 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 { + try { + let sql = ` + SELECT DISTINCT e.* + FROM exercises e + LEFT JOIN exercise_tags et ON e.id = et.exercise_id + WHERE 1=1 + `; + const params: any[] = []; + + // Add search condition + if (query) { + sql += ' AND (e.title LIKE ? OR e.description LIKE ?)'; + params.push(`%${query}%`, `%${query}%`); + } + + // Add filter conditions + if (filters?.types?.length) { + sql += ` AND e.type IN (${filters.types.map(() => '?').join(',')})`; + params.push(...filters.types); + } + + if (filters?.categories?.length) { + sql += ` AND e.category IN (${filters.categories.map(() => '?').join(',')})`; + params.push(...filters.categories); + } + + if (filters?.equipment?.length) { + sql += ` AND e.equipment IN (${filters.equipment.map(() => '?').join(',')})`; + params.push(...filters.equipment); + } + + if (filters?.source) { + sql += ' AND e.source = ?'; + params.push(filters.source); + } + + // Handle tag filtering + if (filters?.tags?.length) { + sql += ` + AND e.id IN ( + SELECT exercise_id + FROM exercise_tags + WHERE tag IN (${filters.tags.map(() => '?').join(',')}) + GROUP BY exercise_id + HAVING COUNT(DISTINCT tag) = ? + ) + `; + params.push(...filters.tags, filters.tags.length); + } + + // Add ordering + sql += ' ORDER BY e.title ASC'; + + // Get exercises + const exercises = await this.db.getAllAsync(sql, params); + + // Get tags for all exercises + const exerciseIds = exercises.map(e => e.id); + if (exerciseIds.length) { + const tags = await this.db.getAllAsync<{ exercise_id: string; tag: string }>( + `SELECT exercise_id, tag + FROM exercise_tags + WHERE exercise_id IN (${exerciseIds.map(() => '?').join(',')})`, + exerciseIds + ); + + // Group tags by exercise + const tagsByExercise = tags.reduce((acc, { exercise_id, tag }) => { + acc[exercise_id] = acc[exercise_id] || []; + acc[exercise_id].push(tag); + return acc; + }, {} as Record); + + // 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] || [] + })); + } + + return exercises; + } catch (error) { + console.error('Error searching exercises:', error); + throw error; + } + } + + async getRecentExercises(limit: number = 10): Promise { + try { + const sql = ` + SELECT e.* + FROM exercises e + ORDER BY e.updated_at DESC + LIMIT ? + `; + + const exercises = await this.db.getAllAsync(sql, [limit]); + + // Get tags for these exercises + const exerciseIds = exercises.map(e => e.id); + if (exerciseIds.length) { + const tags = await this.db.getAllAsync<{ exercise_id: string; tag: string }>( + `SELECT exercise_id, tag + FROM exercise_tags + WHERE exercise_id IN (${exerciseIds.map(() => '?').join(',')})`, + exerciseIds + ); + + // Group tags by exercise + const tagsByExercise = tags.reduce((acc, { exercise_id, tag }) => { + acc[exercise_id] = acc[exercise_id] || []; + acc[exercise_id].push(tag); + return acc; + }, {} as Record); + + 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; + } 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 bulkImport(exercises: Omit[]): Promise { + const ids: string[] = []; + + try { + await this.db.withTransactionAsync(async () => { + for (const exercise of exercises) { + const id = await this.createExercise(exercise); + ids.push(id); + } + }); + + return ids; + } catch (error) { + console.error('Error bulk importing exercises:', error); + throw error; + } + } + + // Helper method to sync with Nostr events + async syncWithNostrEvent(eventId: string, exercise: Omit): Promise { + try { + // Check if we already have this exercise + const existing = await this.db.getFirstAsync<{ id: string }>( + 'SELECT id FROM exercises WHERE nostr_event_id = ?', + [eventId] + ); + + if (existing) { + // Update existing exercise + await this.updateExercise(existing.id, exercise); + return existing.id; + } else { + // Create new exercise with Nostr reference + const id = generateId(); + const timestamp = Date.now(); + + await this.db.withTransactionAsync(async () => { + await this.db.runAsync( + `INSERT INTO exercises ( + id, nostr_event_id, title, type, category, equipment, description, + format_json, format_units_json, created_at, updated_at, source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + eventId, + exercise.title, + exercise.type, + exercise.category, + exercise.equipment || null, + exercise.description || null, + exercise.format ? JSON.stringify(exercise.format) : null, + exercise.format_units ? JSON.stringify(exercise.format_units) : null, + timestamp, + timestamp, + 'nostr' + ] + ); + + if (exercise.tags?.length) { + for (const tag of exercise.tags) { + await this.db.runAsync( + 'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)', + [id, tag] + ); + } + } + }); + + return id; + } + } catch (error) { + console.error('Error syncing exercise with Nostr event:', error); + 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; \ No newline at end of file diff --git a/lib/db/types.ts b/lib/db/types.ts new file mode 100644 index 0000000..e5d6abd --- /dev/null +++ b/lib/db/types.ts @@ -0,0 +1,36 @@ +import { SQLiteDatabase } from 'expo-sqlite'; + +export interface SQLTransaction { + executeSql: ( + sqlStatement: string, + args?: any[], + callback?: (transaction: SQLTransaction, resultSet: SQLResultSet) => void, + errorCallback?: (transaction: SQLTransaction, error: SQLError) => boolean + ) => void; +} + +export interface SQLResultSet { + insertId?: number; + rowsAffected: number; + rows: { + length: number; + item: (index: number) => any; + _array: any[]; + }; +} + +export interface SQLError { + code: number; + message: string; +} + +// Extend the SQLiteDatabase type to include transaction methods +declare module 'expo-sqlite' { + interface SQLiteDatabase { + transaction( + callback: (transaction: SQLTransaction) => void, + errorCallback?: (error: SQLError) => void, + successCallback?: () => void + ): void; + } +} \ No newline at end of file diff --git a/types/exercise.ts b/types/exercise.ts index c730624..6119430 100644 --- a/types/exercise.ts +++ b/types/exercise.ts @@ -15,6 +15,15 @@ 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; @@ -26,7 +35,7 @@ export interface LibraryContent extends SyncableContent { }; category?: ExerciseCategory; equipment?: Equipment; - source: 'local' | 'pow' | 'nostr'; + source: 'local' | 'powr' | 'nostr'; tags: string[]; isPublic?: boolean; } diff --git a/types/shared.ts b/types/shared.ts index f673292..effb3d6 100644 --- a/types/shared.ts +++ b/types/shared.ts @@ -2,7 +2,7 @@ /** * Available storage sources for content */ -export type StorageSource = 'local' | 'backup' | 'nostr'; +export type StorageSource = 'local' | 'powr' | 'nostr'; /** * Nostr sync metadata @@ -51,4 +51,4 @@ export interface ContentMetadata { export interface SyncableContent extends ContentMetadata { id: string; availability: ContentAvailability; -} \ No newline at end of file +}