diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a74d1..9b20051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ All notable changes to the POWR project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# Changelog - March 8, 2025 + +## Added +- Database schema upgrade to version 5 + - Added workouts, workout_exercises, and workout_sets tables + - Added templates and template_exercises tables + - Added publication_queue table for offline-first functionality + - Added app_status table for connectivity tracking +- New database services + - WorkoutService for managing workout data persistence + - Enhanced TemplateService for template management + - NostrWorkoutService for Nostr event conversion + - Updated PublicationQueueService for offline publishing +- React hooks for database access + - useWorkouts hook for workout operations + - useTemplates hook for template operations +- Improved workout completion flow + - Three-tier storage approach (Local Only, Publish Complete, Publish Limited) + - Template modification options (keep original, update, save as new) + - Enhanced social sharing capabilities + - Detailed workout summary with statistics +- Enhanced database debugging tools + - Added proper error handling and logging + - Improved transaction management + - Added connectivity status tracking + +## Fixed +- Missing workout and template table errors +- Incomplete data storage issues +- Template management synchronization +- Nostr event conversion between app models and Nostr protocol +- Workout persistence across app sessions +- Database transaction handling in workout operations +- Template reference handling in workout records + +## Improved +- Workout store persistence layer + - Enhanced integration with database services + - Better error handling for database operations + - Improved Nostr connectivity detection +- Template management workflow + - Proper versioning and attribution + - Enhanced modification tracking + - Better user control over template sharing +- Overall data persistence architecture + - Consistent service-based approach + - Improved type safety + - Enhanced error propagation + # Changelog - March 6, 2025 ## Added diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx index 9886e23..8bb624e 100644 --- a/components/DatabaseProvider.tsx +++ b/components/DatabaseProvider.tsx @@ -6,12 +6,16 @@ import { ExerciseService } from '@/lib/db/services/ExerciseService'; import { DevSeederService } from '@/lib/db/services/DevSeederService'; import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService'; import { FavoritesService } from '@/lib/db/services/FavoritesService'; +import { WorkoutService } from '@/lib/db/services/WorkoutService'; +import { TemplateService } from '@/lib/db/services/TemplateService'; import { logDatabaseInfo } from '@/lib/db/debug'; import { useNDKStore } from '@/lib/stores/ndk'; // Create context for services interface DatabaseServicesContextValue { exerciseService: ExerciseService | null; + workoutService: WorkoutService | null; + templateService: TemplateService | null; devSeeder: DevSeederService | null; publicationQueue: PublicationQueueService | null; favoritesService: FavoritesService | null; @@ -20,6 +24,8 @@ interface DatabaseServicesContextValue { const DatabaseServicesContext = React.createContext({ exerciseService: null, + workoutService: null, + templateService: null, devSeeder: null, publicationQueue: null, favoritesService: null, @@ -35,6 +41,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { const [error, setError] = React.useState(null); const [services, setServices] = React.useState({ exerciseService: null, + workoutService: null, + templateService: null, devSeeder: null, publicationQueue: null, favoritesService: null, @@ -64,6 +72,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { // Initialize services console.log('[DB] Initializing services...'); const exerciseService = new ExerciseService(db); + const workoutService = new WorkoutService(db); + const templateService = new TemplateService(db); const devSeeder = new DevSeederService(db, exerciseService); const publicationQueue = new PublicationQueueService(db); const favoritesService = new FavoritesService(db); @@ -80,6 +90,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { // Set services setServices({ exerciseService, + workoutService, + templateService, devSeeder, publicationQueue, favoritesService, @@ -140,6 +152,22 @@ export function useExerciseService() { return context.exerciseService; } +export function useWorkoutService() { + const context = React.useContext(DatabaseServicesContext); + if (!context.workoutService) { + throw new Error('Workout service not initialized'); + } + return context.workoutService; +} + +export function useTemplateService() { + const context = React.useContext(DatabaseServicesContext); + if (!context.templateService) { + throw new Error('Template service not initialized'); + } + return context.templateService; +} + export function useDevSeeder() { const context = React.useContext(DatabaseServicesContext); if (!context.devSeeder) { diff --git a/components/workout/WorkoutHeader.tsx b/components/workout/WorkoutHeader.tsx deleted file mode 100644 index 1ffa28a..0000000 --- a/components/workout/WorkoutHeader.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// components/workout/WorkoutHeader.tsx -import React from 'react'; -import { View } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Pause, Play, Square, ChevronLeft } from 'lucide-react-native'; -import { useWorkoutStore } from '@/stores/workoutStore'; -import { formatTime } from '@/utils/formatTime'; -import { cn } from '@/lib/utils'; -import { useRouter } from 'expo-router'; -import EditableText from '@/components/EditableText'; - -interface WorkoutHeaderProps { - title?: string; - onBack?: () => void; -} - -export default function WorkoutHeader({ title, onBack }: WorkoutHeaderProps) { - const router = useRouter(); - const status = useWorkoutStore.use.status(); - const activeWorkout = useWorkoutStore.use.activeWorkout(); - const elapsedTime = useWorkoutStore.use.elapsedTime(); - const { pauseWorkout, resumeWorkout, completeWorkout, updateWorkoutTitle } = useWorkoutStore.getState(); - - const handleBack = () => { - if (onBack) { - onBack(); - } else { - router.back(); - } - }; - - if (!activeWorkout) return null; - - return ( - - {/* Header Row */} - - - - - updateWorkoutTitle(newTitle)} - style={{ alignItems: 'center' }} - placeholder="Workout Title" - /> - - - - {status === 'active' ? ( - - ) : ( - - )} - - - - - - {/* Status Row */} - - - {formatTime(elapsedTime)} - - - - {activeWorkout.type} - - - - ); -} \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 6399a85..9f434cb 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -2,7 +2,7 @@ import { SQLiteDatabase } from 'expo-sqlite'; import { Platform } from 'react-native'; -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; // Increment since we're adding new tables class Schema { private async getCurrentVersion(db: SQLiteDatabase): Promise { @@ -32,7 +32,17 @@ class Schema { async createTables(db: SQLiteDatabase): Promise { try { console.log(`[Schema] Initializing database on ${Platform.OS}`); + + // First, ensure schema_version table exists since we need it for version checking + await db.execAsync(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + updated_at INTEGER NOT NULL + ); + `); + const currentVersion = await this.getCurrentVersion(db); + console.log(`[Schema] Current version: ${currentVersion}, Target version: ${SCHEMA_VERSION}`); // If we already have the current version, no need to recreate tables if (currentVersion === SCHEMA_VERSION) { @@ -40,26 +50,39 @@ class Schema { return; } - console.log(`[Schema] Creating tables for version ${SCHEMA_VERSION}`); + console.log(`[Schema] Upgrading from version ${currentVersion} to ${SCHEMA_VERSION}`); - // Schema version tracking - await db.execAsync(` - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - updated_at INTEGER NOT NULL - ); - `); - - // Drop all existing tables (except schema_version) - await this.dropAllTables(db); - - // Create all tables in their latest form - await this.createAllTables(db); - - // Update schema version - await this.updateSchemaVersion(db); - - console.log(`[Schema] Database initialized at version ${SCHEMA_VERSION}`); + try { + // Use a transaction to ensure all-or-nothing table creation + await db.withTransactionAsync(async () => { + // Drop all existing tables (except schema_version) + await this.dropAllTables(db); + + // Create all tables in their latest form + await this.createAllTables(db); + + // Update schema version at the end of the transaction + await this.updateSchemaVersion(db); + }); + + console.log(`[Schema] Database successfully upgraded to version ${SCHEMA_VERSION}`); + } catch (txError) { + console.error('[Schema] Transaction error during schema upgrade:', txError); + + // If transaction failed, try a non-transactional approach as fallback + console.log('[Schema] Attempting non-transactional schema upgrade...'); + + // Drop all existing tables except schema_version + await this.dropAllTables(db); + + // Create all tables in their latest form + await this.createAllTables(db); + + // Update schema version + await this.updateSchemaVersion(db); + + console.log('[Schema] Non-transactional schema upgrade completed'); + } } catch (error) { console.error('[Schema] Error creating tables:', error); throw error; @@ -67,196 +90,344 @@ class Schema { } private async dropAllTables(db: SQLiteDatabase): Promise { - // Get list of all tables excluding schema_version - const tables = await db.getAllAsync<{ name: string }>( - "SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'" - ); - - // Drop each table - for (const { name } of tables) { - await db.execAsync(`DROP TABLE IF EXISTS ${name}`); - console.log(`[Schema] Dropped table: ${name}`); + try { + console.log('[Schema] Getting list of tables to drop...'); + + // Get list of all tables excluding schema_version + const tables = await db.getAllAsync<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'" + ); + + console.log(`[Schema] Found ${tables.length} tables to drop`); + + // Drop each table + for (const { name } of tables) { + try { + await db.execAsync(`DROP TABLE IF EXISTS ${name}`); + console.log(`[Schema] Dropped table: ${name}`); + } catch (dropError) { + console.error(`[Schema] Error dropping table ${name}:`, dropError); + // Continue with other tables even if one fails + } + } + } catch (error) { + console.error('[Schema] Error in dropAllTables:', error); + throw error; } } private async createAllTables(db: SQLiteDatabase): Promise { - // Create exercises table - 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', - nostr_event_id TEXT - ); - `); + try { + console.log('[Schema] Creating all database tables...'); + + // Create exercises table + console.log('[Schema] Creating exercises table...'); + 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', + nostr_event_id TEXT + ); + `); - // Create exercise_tags table - 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 nostr_events table - await db.execAsync(` - CREATE TABLE 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 - ); - `); + // Create exercise_tags table + console.log('[Schema] Creating exercise_tags table...'); + 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 nostr_events table + console.log('[Schema] Creating nostr_events table...'); + await db.execAsync(` + CREATE TABLE 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 + ); + `); - // Create event_tags table - await db.execAsync(` - CREATE TABLE 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 idx_event_tags ON event_tags(name, value); - `); - - // Create cache metadata table - await db.execAsync(` - CREATE TABLE cache_metadata ( - content_id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - last_accessed INTEGER NOT NULL, - access_count INTEGER NOT NULL DEFAULT 0, - cache_priority INTEGER NOT NULL DEFAULT 0 - ); - `); + // Create event_tags table + console.log('[Schema] Creating event_tags table...'); + await db.execAsync(` + CREATE TABLE 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 idx_event_tags ON event_tags(name, value); + `); + + // Create cache metadata table + console.log('[Schema] Creating cache_metadata table...'); + await db.execAsync(` + CREATE TABLE cache_metadata ( + content_id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + last_accessed INTEGER NOT NULL, + access_count INTEGER NOT NULL DEFAULT 0, + cache_priority INTEGER NOT NULL DEFAULT 0 + ); + `); - // Create media cache table - await db.execAsync(` - CREATE TABLE exercise_media ( - exercise_id TEXT NOT NULL, - media_type TEXT NOT NULL, - content BLOB NOT NULL, - thumbnail BLOB, - created_at INTEGER NOT NULL, - FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - `); - - // Create user profiles table - await db.execAsync(` - CREATE TABLE user_profiles ( - pubkey TEXT PRIMARY KEY, - name TEXT, - display_name TEXT, - about TEXT, - website TEXT, - picture TEXT, - nip05 TEXT, - lud16 TEXT, - last_updated INTEGER - ); - CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC); - `); + // Create media cache table + console.log('[Schema] Creating exercise_media table...'); + await db.execAsync(` + CREATE TABLE exercise_media ( + exercise_id TEXT NOT NULL, + media_type TEXT NOT NULL, + content BLOB NOT NULL, + thumbnail BLOB, + created_at INTEGER NOT NULL, + FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE + ); + `); + + // Create user profiles table + console.log('[Schema] Creating user_profiles table...'); + await db.execAsync(` + CREATE TABLE user_profiles ( + pubkey TEXT PRIMARY KEY, + name TEXT, + display_name TEXT, + about TEXT, + website TEXT, + picture TEXT, + nip05 TEXT, + lud16 TEXT, + last_updated INTEGER + ); + CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC); + `); - // Create user relays table - await db.execAsync(` - CREATE TABLE user_relays ( - pubkey TEXT NOT NULL, - relay_url TEXT NOT NULL, - read BOOLEAN NOT NULL DEFAULT 1, - write BOOLEAN NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - PRIMARY KEY (pubkey, relay_url), - FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE - ); - `); - - // Create publication queue table - await db.execAsync(` - CREATE TABLE publication_queue ( - event_id TEXT PRIMARY KEY, - attempts INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - last_attempt INTEGER, - payload TEXT NOT NULL, - FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE - ); - CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC); - `); + // Create user relays table + console.log('[Schema] Creating user_relays table...'); + await db.execAsync(` + CREATE TABLE user_relays ( + pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + read BOOLEAN NOT NULL DEFAULT 1, + write BOOLEAN NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + PRIMARY KEY (pubkey, relay_url), + FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE + ); + `); + + // Create publication queue table + console.log('[Schema] Creating publication_queue table...'); + await db.execAsync(` + CREATE TABLE publication_queue ( + event_id TEXT PRIMARY KEY, + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_attempt INTEGER, + payload TEXT NOT NULL, + FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE + ); + CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC); + `); - // Create app status table - await db.execAsync(` - CREATE TABLE app_status ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - `); - - // Create NDK cache table - await db.execAsync(` - CREATE TABLE ndk_cache ( - id TEXT PRIMARY KEY, - event TEXT NOT NULL, - created_at INTEGER NOT NULL, - kind INTEGER NOT NULL - ); - CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind); - CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at); - `); - - // Create favorites table - await db.execAsync(` - CREATE TABLE favorites ( - id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - content_id TEXT NOT NULL, - content TEXT NOT NULL, - pubkey TEXT, - created_at INTEGER NOT NULL, - UNIQUE(content_type, content_id) - ); - CREATE INDEX idx_favorites_content_type ON favorites(content_type); - CREATE INDEX idx_favorites_content_id ON favorites(content_id); - `); + // Create app status table + console.log('[Schema] Creating app_status table...'); + await db.execAsync(` + CREATE TABLE app_status ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + + // Create NDK cache table + console.log('[Schema] Creating ndk_cache table...'); + await db.execAsync(` + CREATE TABLE ndk_cache ( + id TEXT PRIMARY KEY, + event TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL + ); + CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind); + CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at); + `); + + // Create favorites table + console.log('[Schema] Creating favorites table...'); + await db.execAsync(` + CREATE TABLE favorites ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + content_id TEXT NOT NULL, + content TEXT NOT NULL, + pubkey TEXT, + created_at INTEGER NOT NULL, + UNIQUE(content_type, content_id) + ); + CREATE INDEX idx_favorites_content_type ON favorites(content_type); + CREATE INDEX idx_favorites_content_id ON favorites(content_id); + `); + + // === NEW TABLES === // + + // Create workouts table + console.log('[Schema] Creating workouts table...'); + await db.execAsync(` + CREATE TABLE workouts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER, + is_completed BOOLEAN NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + template_id TEXT, + nostr_event_id TEXT, + share_status TEXT NOT NULL DEFAULT 'local', + source TEXT NOT NULL DEFAULT 'local', + total_volume REAL, + total_reps INTEGER, + notes TEXT + ); + CREATE INDEX idx_workouts_start_time ON workouts(start_time); + CREATE INDEX idx_workouts_template_id ON workouts(template_id); + `); + + // Create workout_exercises table + console.log('[Schema] Creating workout_exercises table...'); + await db.execAsync(` + CREATE TABLE workout_exercises ( + id TEXT PRIMARY KEY, + workout_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + display_order INTEGER NOT NULL, + notes TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(workout_id) REFERENCES workouts(id) ON DELETE CASCADE + ); + CREATE INDEX idx_workout_exercises_workout_id ON workout_exercises(workout_id); + `); + + // Create workout_sets table + console.log('[Schema] Creating workout_sets table...'); + await db.execAsync(` + CREATE TABLE workout_sets ( + id TEXT PRIMARY KEY, + workout_exercise_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'normal', + weight REAL, + reps INTEGER, + rpe REAL, + duration INTEGER, + is_completed BOOLEAN NOT NULL DEFAULT 0, + completed_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(workout_exercise_id) REFERENCES workout_exercises(id) ON DELETE CASCADE + ); + CREATE INDEX idx_workout_sets_exercise_id ON workout_sets(workout_exercise_id); + `); + + // Create templates table + console.log('[Schema] Creating templates table...'); + await db.execAsync(` + CREATE TABLE templates ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + nostr_event_id TEXT, + source TEXT NOT NULL DEFAULT 'local', + parent_id TEXT + ); + CREATE INDEX idx_templates_updated_at ON templates(updated_at); + `); + + // Create template_exercises table + console.log('[Schema] Creating template_exercises table...'); + await db.execAsync(` + CREATE TABLE template_exercises ( + id TEXT PRIMARY KEY, + template_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + display_order INTEGER NOT NULL, + target_sets INTEGER, + target_reps INTEGER, + target_weight REAL, + notes TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE + ); + CREATE INDEX idx_template_exercises_template_id ON template_exercises(template_id); + `); + + console.log('[Schema] All tables created successfully'); + } catch (error) { + console.error('[Schema] Error in createAllTables:', error); + throw error; + } } private async updateSchemaVersion(db: SQLiteDatabase): Promise { - // Delete any existing schema version records - await db.runAsync('DELETE FROM schema_version'); - - // Insert the current version - await db.runAsync( - 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [SCHEMA_VERSION, Date.now()] - ); + try { + console.log(`[Schema] Updating schema version to ${SCHEMA_VERSION}`); + + // Delete any existing schema version records + await db.runAsync('DELETE FROM schema_version'); + + // Insert the current version + await db.runAsync( + 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', + [SCHEMA_VERSION, Date.now()] + ); + + console.log('[Schema] Schema version updated successfully'); + } catch (error) { + console.error('[Schema] Error updating schema version:', error); + throw error; + } } async resetDatabase(db: SQLiteDatabase): Promise { - if (!__DEV__) return; // Only allow in development + if (!__DEV__) { + console.log('[Schema] Database reset is only available in development mode'); + return; + } try { console.log('[Schema] Resetting database...'); - await this.dropAllTables(db); - await this.createAllTables(db); - await this.updateSchemaVersion(db); + // Clear schema_version to force recreation of all tables + await db.execAsync('DROP TABLE IF EXISTS schema_version'); + console.log('[Schema] Dropped schema_version table'); + + // Now create tables from scratch + await this.createTables(db); console.log('[Schema] Database reset complete'); } catch (error) { diff --git a/lib/db/services/DevSeederService.ts b/lib/db/services/DevSeederService.ts index eacc1f0..6dd5dce 100644 --- a/lib/db/services/DevSeederService.ts +++ b/lib/db/services/DevSeederService.ts @@ -1,13 +1,21 @@ // lib/db/services/DevSeederService.ts import { SQLiteDatabase } from 'expo-sqlite'; import { ExerciseService } from './ExerciseService'; +import { EventCache } from '@/lib/db/services/EventCache'; +import { WorkoutService } from './WorkoutService'; +import { TemplateService } from './TemplateService'; import { logDatabaseInfo } from '../debug'; import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises'; +import { DbService } from '../db-service'; import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; export class DevSeederService { private db: SQLiteDatabase; + private dbService: DbService; private exerciseService: ExerciseService; + private workoutService: WorkoutService | null = null; + private templateService: TemplateService | null = null; + private eventCache: EventCache | null = null; private ndk: NDK | null = null; constructor( @@ -15,7 +23,17 @@ export class DevSeederService { exerciseService: ExerciseService ) { this.db = db; + this.dbService = new DbService(db); this.exerciseService = exerciseService; + + // Try to initialize other services if needed + try { + this.workoutService = new WorkoutService(db); + this.templateService = new TemplateService(db); + this.eventCache = new EventCache(db); + } catch (error) { + console.log('Some services not available yet:', error); + } } setNDK(ndk: NDK) { @@ -36,22 +54,19 @@ export class DevSeederService { if (existingCount > 0) { console.log('Database already seeded with', existingCount, 'exercises'); - return; - } - - // Start transaction for all seeding operations - await this.db.withTransactionAsync(async () => { - console.log('Seeding mock exercises...'); - - // Process all events within the same transaction - for (const eventData of mockExerciseEvents) { - if (this.ndk) { - // If NDK is available, use it to cache the event - const event = new NDKEvent(this.ndk); - Object.assign(event, eventData); - - // Cache the event in NDK + } else { + // Start transaction for all seeding operations + await this.db.withTransactionAsync(async () => { + console.log('Seeding mock exercises...'); + + // Process all events within the same transaction + for (const eventData of mockExerciseEvents) { if (this.ndk) { + // If NDK is available, use it to cache the event + const event = new NDKEvent(this.ndk); + Object.assign(event, eventData); + + // Cache the event in NDK const ndkEvent = new NDKEvent(this.ndk); // Copy event properties @@ -70,15 +85,28 @@ export class DevSeederService { await ndkEvent.sign(); } } + + // Cache the event if possible + if (this.eventCache) { + try { + await this.eventCache.setEvent(eventData, true); + } catch (error) { + console.log('Error caching event:', error); + } + } + + // Create exercise from the mock data regardless of NDK availability + const exercise = convertNostrToExercise(eventData); + await this.exerciseService.createExercise(exercise, true); } - - // Create exercise from the mock data regardless of NDK availability - const exercise = convertNostrToExercise(eventData); - await this.exerciseService.createExercise(exercise, true); - } + + console.log('Successfully seeded', mockExerciseEvents.length, 'exercises'); + }); + } - console.log('Successfully seeded', mockExerciseEvents.length, 'exercises'); - }); + // Seed workout and template tables + await this.seedWorkoutTables(); + await this.seedTemplates(); // Log final database state await logDatabaseInfo(); @@ -89,6 +117,70 @@ export class DevSeederService { } } + async seedWorkoutTables() { + if (!__DEV__) return; + + try { + console.log('Checking workout tables seeding...'); + + // Check if we already have workout data + try { + const hasWorkouts = await this.dbService.getFirstAsync<{ count: number }>( + 'SELECT COUNT(*) as count FROM workouts' + ); + + if (hasWorkouts && hasWorkouts.count > 0) { + console.log('Workout tables already seeded with', hasWorkouts.count, 'workouts'); + return; + } + + console.log('No workout data found, but tables should be created'); + + // Optional: Add mock workout data here + // if (this.workoutService) { + // // Create mock workouts + // // await this.workoutService.saveWorkout(mockWorkout); + // } + } catch (error) { + console.log('Workout tables may not exist yet - will be created in schema update'); + } + } catch (error) { + console.error('Error checking workout tables:', error); + } + } + + async seedTemplates() { + if (!__DEV__) return; + + try { + console.log('Checking template tables seeding...'); + + // Check if templates table exists and has data + try { + const hasTemplates = await this.dbService.getFirstAsync<{ count: number }>( + 'SELECT COUNT(*) as count FROM templates' + ); + + if (hasTemplates && hasTemplates.count > 0) { + console.log('Template tables already seeded with', hasTemplates.count, 'templates'); + return; + } + + console.log('No template data found, but tables should be created'); + + // Optional: Add mock template data here + // if (this.templateService) { + // // Create mock templates + // // await this.templateService.createTemplate(mockTemplate); + // } + } catch (error) { + console.log('Template tables may not exist yet - will be created in schema update'); + } + } catch (error) { + console.error('Error checking template tables:', error); + } + } + async clearDatabase() { if (!__DEV__) return; @@ -97,16 +189,29 @@ export class DevSeederService { await this.db.withTransactionAsync(async () => { const tables = [ + // Original tables 'exercises', 'exercise_tags', 'nostr_events', 'event_tags', 'cache_metadata', - 'ndk_cache' // Add the NDK Mobile cache table + 'ndk_cache', + + // New tables + 'workouts', + 'workout_exercises', + 'workout_sets', + 'templates', + 'template_exercises' ]; for (const table of tables) { - await this.db.runAsync(`DELETE FROM ${table}`); + try { + await this.db.runAsync(`DELETE FROM ${table}`); + console.log(`Cleared table: ${table}`); + } catch (error) { + console.log(`Table ${table} might not exist yet, skipping`); + } } }); diff --git a/lib/db/services/EventCache.ts b/lib/db/services/EventCache.ts new file mode 100644 index 0000000..6d71551 --- /dev/null +++ b/lib/db/services/EventCache.ts @@ -0,0 +1,121 @@ +// lib/db/services/EventCache.ts +import { SQLiteDatabase } from 'expo-sqlite'; +import { NostrEvent } from '@/types/nostr'; +import { DbService } from '../db-service'; + +export class EventCache { + private db: DbService; + + constructor(database: SQLiteDatabase) { + this.db = new DbService(database); + } + + /** + * Store a Nostr event in the cache + */ + async setEvent(event: NostrEvent, skipExisting: boolean = false): Promise { + if (!event.id) return; + + try { + // Check if event already exists + if (skipExisting) { + const exists = await this.db.getFirstAsync<{ id: string }>( + 'SELECT id FROM nostr_events WHERE id = ?', + [event.id] + ); + + if (exists) return; + } + + // Store the event + await this.db.withTransactionAsync(async () => { + await this.db.runAsync( + `INSERT OR REPLACE INTO nostr_events + (id, pubkey, kind, created_at, content, sig, raw_event, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + event.id, + event.pubkey || '', + event.kind, + event.created_at, + event.content, + event.sig || '', + JSON.stringify(event), + Date.now() + ] + ); + + // Store event tags + if (event.tags && event.tags.length > 0) { + // Delete existing tags first + await this.db.runAsync( + 'DELETE FROM event_tags WHERE event_id = ?', + [event.id] + ); + + // Insert new tags + for (let i = 0; i < event.tags.length; i++) { + const tag = event.tags[i]; + if (tag.length >= 2) { + await this.db.runAsync( + 'INSERT INTO event_tags (event_id, name, value, index_num) VALUES (?, ?, ?, ?)', + [event.id, tag[0], tag[1], i] + ); + } + } + } + }); + } catch (error) { + console.error('Error caching event:', error); + throw error; + } + } + + /** + * Get an event from the cache by ID + */ + async getEvent(id: string): Promise { + try { + const event = await this.db.getFirstAsync<{ + id: string; + pubkey: string; + kind: number; + created_at: number; + content: string; + sig: string; + raw_event: string; + }>( + 'SELECT * FROM nostr_events WHERE id = ?', + [id] + ); + + if (!event) return null; + + // Get tags + const tags = await this.db.getAllAsync<{ + name: string; + value: string; + index_num: number; + }>( + 'SELECT name, value, index_num FROM event_tags WHERE event_id = ? ORDER BY index_num', + [id] + ); + + // Build the event object + const nostrEvent: NostrEvent = { + id: event.id, + pubkey: event.pubkey, + kind: event.kind, + created_at: event.created_at, + content: event.content, + sig: event.sig, + tags: tags.map(tag => [tag.name, tag.value]) + }; + + return nostrEvent; + } catch (error) { + console.error('Error retrieving event:', error); + return null; + } + } +} \ No newline at end of file diff --git a/lib/db/services/NostrWorkoutService.ts b/lib/db/services/NostrWorkoutService.ts index cd7f1a2..db6e817 100644 --- a/lib/db/services/NostrWorkoutService.ts +++ b/lib/db/services/NostrWorkoutService.ts @@ -1,7 +1,10 @@ -// lib/services/NostrWorkoutService.ts - updated -import { Workout } from '@/types/workout'; +// lib/services/NostrWorkoutService.ts +import { Workout, WorkoutExercise, WorkoutSet } from '@/types/workout'; +import { WorkoutTemplate, TemplateExerciseConfig } from '@/types/templates'; import { NostrEvent } from '@/types/nostr'; - +import { generateId } from '@/utils/ids'; +import { ExerciseCategory, ExerciseType } from '@/types/exercise'; +import { TemplateType } from '@/types/templates'; /** * Service for creating and handling Nostr workout events */ @@ -10,60 +13,14 @@ export class NostrWorkoutService { * Creates a complete Nostr workout event with all details */ static createCompleteWorkoutEvent(workout: Workout): NostrEvent { - return { - kind: 1301, // As per NIP-4e spec - content: workout.notes || '', - tags: [ - ['d', workout.id], - ['title', workout.title], - ['type', workout.type], - ['start', Math.floor(workout.startTime / 1000).toString()], - ['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''], - ['completed', 'true'], - // Add all exercise data with complete metrics - ...workout.exercises.flatMap(exercise => - exercise.sets.map(set => [ - 'exercise', - `33401:${exercise.id}`, - set.weight?.toString() || '', - set.reps?.toString() || '', - set.rpe?.toString() || '', - set.type - ]) - ), - // Include template reference if workout was based on template - ...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []), - // Add any tags from the workout - ...(workout.tags ? workout.tags.map(tag => ['t', tag]) : []) - ], - created_at: Math.floor(Date.now() / 1000) - }; + return this.workoutToNostrEvent(workout, false); } /** * Creates a limited Nostr workout event with reduced metrics for privacy */ static createLimitedWorkoutEvent(workout: Workout): NostrEvent { - return { - kind: 1301, - content: workout.notes || '', - tags: [ - ['d', workout.id], - ['title', workout.title], - ['type', workout.type], - ['start', Math.floor(workout.startTime / 1000).toString()], - ['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''], - ['completed', 'true'], - // Add limited exercise data - just exercise names without metrics - ...workout.exercises.map(exercise => [ - 'exercise', - `33401:${exercise.id}` - ]), - ...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []), - ...(workout.tags ? workout.tags.map(tag => ['t', tag]) : []) - ], - created_at: Math.floor(Date.now() / 1000) - }; + return this.workoutToNostrEvent(workout, true); } /** @@ -83,4 +40,371 @@ export class NostrWorkoutService { created_at: Math.floor(Date.now() / 1000) }; } + + /** + * Generic method to convert a workout to a Nostr event + * @param workout The workout data + * @param isLimited Whether to include limited data only + * @returns A NostrEvent (kind 1301) + */ + static workoutToNostrEvent(workout: Workout, isLimited: boolean = false): NostrEvent { + // Format start and end dates as Unix timestamps in seconds + const startTime = Math.floor(workout.startTime / 1000); + const endTime = workout.endTime ? Math.floor(workout.endTime / 1000) : undefined; + + // Prepare tags for Nostr event + const tags: string[][] = [ + ["d", workout.id], + ["title", workout.title], + ["type", workout.type], + ["start", startTime.toString()], + ["end", endTime ? endTime.toString() : ""], + ["completed", workout.isCompleted ? "true" : "false"] + ]; + + // Add template reference if available + if (workout.templateId) { + tags.push(["template", `33402:${workout.templatePubkey || ''}:${workout.templateId}`, ""]); + } + + // Add exercise data + if (isLimited) { + // Limited data - just exercise names without metrics + workout.exercises.forEach(exercise => { + tags.push(["exercise", `33401:${exercise.exerciseId || exercise.id}`]); + }); + } else { + // Full data - include all metrics + workout.exercises.forEach(exercise => + exercise.sets.forEach(set => { + const exerciseTag = [ + "exercise", + `33401:${exercise.exerciseId || exercise.id}`, + "" + ]; + + // Add set data + if (set.weight !== undefined) exerciseTag.push(set.weight.toString()); + if (set.reps !== undefined) exerciseTag.push(set.reps.toString()); + if (set.rpe !== undefined) exerciseTag.push(set.rpe.toString()); + if (set.type) exerciseTag.push(set.type); + + tags.push(exerciseTag); + }) + ); + } + + // Add any workout tags + workout.tags?.forEach(tag => { + tags.push(["t", tag]); + }); + + return { + kind: 1301, + content: workout.notes || "", + created_at: Math.floor(Date.now() / 1000), + tags + }; + } + + /** + * Convert a Nostr event to a workout + */ + static nostrEventToWorkout(event: NostrEvent): Workout { + if (event.kind !== 1301) { + throw new Error('Event is not a workout record (kind 1301)'); + } + + // Find tag values + const findTagValue = (name: string): string | null => { + const tag = event.tags.find(t => t[0] === name); + return tag && tag.length > 1 ? tag[1] : null; + }; + + // Parse dates + const startTimeStr = findTagValue('start'); + const endTimeStr = findTagValue('end'); + + const startTime = startTimeStr ? parseInt(startTimeStr) * 1000 : Date.now(); + const endTime = endTimeStr && endTimeStr !== '' ? parseInt(endTimeStr) * 1000 : undefined; + + // Create base workout object + const workout: Partial = { + id: findTagValue('d') || generateId('nostr'), + title: findTagValue('title') || 'Untitled Workout', + type: (findTagValue('type') || 'strength') as TemplateType, + startTime, + endTime, + isCompleted: findTagValue('completed') === 'true', + notes: event.content, + created_at: event.created_at * 1000, + lastUpdated: Date.now(), + nostrEventId: event.id, + availability: { source: ['nostr'] }, + exercises: [], + shareStatus: 'public' + }; + + // Parse template reference + const templateTag = event.tags.find(t => t[0] === 'template'); + if (templateTag && templateTag.length > 1) { + const parts = templateTag[1].split(':'); + if (parts.length === 3) { + workout.templateId = parts[2]; + workout.templatePubkey = parts[1]; + } + } + + // Parse exercises and sets + const exerciseTags = event.tags.filter(t => t[0] === 'exercise'); + + // Group exercise tags by exercise ID + const exerciseMap = new Map(); + + exerciseTags.forEach(tag => { + if (tag.length < 2) return; + + const exerciseRef = tag[1]; + let exerciseId = exerciseRef; + + // Parse exercise ID from reference + if (exerciseRef.includes(':')) { + const parts = exerciseRef.split(':'); + if (parts.length === 3) { + exerciseId = parts[2]; + } + } + + if (!exerciseMap.has(exerciseId)) { + exerciseMap.set(exerciseId, []); + } + + exerciseMap.get(exerciseId)?.push(tag); + }); + + // Convert each unique exercise to a WorkoutExercise + workout.exercises = Array.from(exerciseMap.entries()).map(([exerciseId, tags]) => { + // Create a basic exercise + const exercise: Partial = { + id: generateId('nostr'), + exerciseId: exerciseId, + title: `Exercise ${exerciseId}`, // Placeholder - would be updated when loading full details + type: 'strength' as ExerciseType, + category: 'Other' as ExerciseCategory, // Default + created_at: workout.created_at || Date.now(), + lastUpdated: workout.lastUpdated, + isCompleted: true, // Default for past workouts + availability: { source: ['nostr'] }, + tags: [], // Add empty tags array + sets: [] + }; + + // Parse sets from tags + exercise.sets = tags.map(tag => { + const set: Partial = { + id: generateId('nostr'), + type: 'normal', + isCompleted: true, + lastUpdated: workout.lastUpdated + }; + + // Extract set data if available (weight, reps, rpe, type) + if (tag.length > 3) set.weight = parseFloat(tag[3]) || undefined; + if (tag.length > 4) set.reps = parseInt(tag[4]) || undefined; + if (tag.length > 5) set.rpe = parseFloat(tag[5]) || undefined; + if (tag.length > 6) set.type = tag[6] as any; + + return set as WorkoutSet; + }); + + // If no sets were found, add a default one + if (exercise.sets.length === 0) { + exercise.sets.push({ + id: generateId('nostr'), + type: 'normal', + isCompleted: true, + lastUpdated: workout.lastUpdated + }); + } + + return exercise as WorkoutExercise; + }); + + return workout as Workout; + } + + /** + * Convert a template to a Nostr event (kind 33402) + */ + static templateToNostrEvent(template: WorkoutTemplate): NostrEvent { + // Prepare tags for Nostr event + const tags: string[][] = [ + ["d", template.id], + ["title", template.title], + ["type", template.type || "strength"] + ]; + + // Add optional tags + if (template.rounds) { + tags.push(["rounds", template.rounds.toString()]); + } + + if (template.duration) { + tags.push(["duration", template.duration.toString()]); + } + + if (template.interval) { + tags.push(["interval", template.interval.toString()]); + } + + if (template.restBetweenRounds) { + tags.push(["rest_between_rounds", template.restBetweenRounds.toString()]); + } + + // Add exercise configurations + template.exercises.forEach(ex => { + const exerciseTag = [ + "exercise", + `33401:${ex.exercise.id}`, + "" + ]; + + // Add target parameters if available + if (ex.targetSets) exerciseTag.push(ex.targetSets.toString()); + if (ex.targetReps) exerciseTag.push(ex.targetReps.toString()); + if (ex.targetWeight) exerciseTag.push(ex.targetWeight.toString()); + + tags.push(exerciseTag); + }); + + // Add any template tags + template.tags?.forEach(tag => { + tags.push(["t", tag]); + }); + + return { + kind: 33402, + content: template.description || "", + created_at: Math.floor(Date.now() / 1000), + tags + }; + } + + /** + * Convert a Nostr event to a template + */ + static nostrEventToTemplate(event: NostrEvent): WorkoutTemplate { + if (event.kind !== 33402) { + throw new Error('Event is not a workout template (kind 33402)'); + } + + // Find tag values + const findTagValue = (name: string): string | null => { + const tag = event.tags.find(t => t[0] === name); + return tag && tag.length > 1 ? tag[1] : null; + }; + + // Create base template object + const template: Partial = { + id: findTagValue('d') || generateId('nostr'), + title: findTagValue('title') || 'Untitled Template', + type: (findTagValue('type') || 'strength') as TemplateType, + description: event.content, + created_at: event.created_at * 1000, + lastUpdated: Date.now(), + nostrEventId: event.id, + availability: { source: ['nostr'] }, + exercises: [], + isPublic: true, + version: 1, + tags: [] + }; + + // Parse optional parameters + const roundsStr = findTagValue('rounds'); + if (roundsStr) template.rounds = parseInt(roundsStr); + + const durationStr = findTagValue('duration'); + if (durationStr) template.duration = parseInt(durationStr); + + const intervalStr = findTagValue('interval'); + if (intervalStr) template.interval = parseInt(intervalStr); + + const restStr = findTagValue('rest_between_rounds'); + if (restStr) template.restBetweenRounds = parseInt(restStr); + + // Parse exercises + const exerciseTags = event.tags.filter(t => t[0] === 'exercise'); + + template.exercises = exerciseTags.map(tag => { + if (tag.length < 2) { + // Skip invalid tags + return null; + } + + const exerciseRef = tag[1]; + let exerciseId = exerciseRef; + let exercisePubkey = ''; + + // Parse exercise ID from reference + if (exerciseRef.includes(':')) { + const parts = exerciseRef.split(':'); + if (parts.length === 3) { + exerciseId = parts[2]; + exercisePubkey = parts[1]; + } + } + + // Create exercise config + const config: Partial = { + id: generateId('nostr'), + exercise: { + id: exerciseId, + title: `Exercise ${exerciseId}`, // Placeholder - would be updated when loading full details + type: 'strength', + category: 'Other' as ExerciseCategory, + tags: [], // Add empty tags array + availability: { source: ['nostr'] }, + created_at: Date.now() + } + }; + + // Parse target parameters if available + if (tag.length > 3) config.targetSets = parseInt(tag[3]) || undefined; + if (tag.length > 4) config.targetReps = parseInt(tag[4]) || undefined; + if (tag.length > 5) config.targetWeight = parseFloat(tag[5]) || undefined; + + return config as TemplateExerciseConfig; + }).filter(Boolean) as TemplateExerciseConfig[]; // Filter out null values + + return template as WorkoutTemplate; + } + + /** + * Helper function to find a tag value in a Nostr event + */ + static findTagValue(tags: string[][], name: string): string | null { + const tag = tags.find(t => t[0] === name); + return tag && tag.length > 1 ? tag[1] : null; + } + + /** + * Helper function to get all values for a specific tag name + */ + static getTagValues(tags: string[][], name: string): string[] { + return tags.filter(t => t[0] === name).map(t => t[1]); + } + + /** + * Helper function to get template tag information + */ + static getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined { + const templateTag = tags.find(t => t[0] === 'template'); + if (!templateTag || templateTag.length < 3) return undefined; + + return { + reference: templateTag[1], + relay: templateTag[2] || '' + }; + } } \ No newline at end of file diff --git a/lib/db/services/TemplateService.ts b/lib/db/services/TemplateService.ts index 20b6ee7..6458860 100644 --- a/lib/db/services/TemplateService.ts +++ b/lib/db/services/TemplateService.ts @@ -1,50 +1,474 @@ // lib/db/services/TemplateService.ts +import { SQLiteDatabase, openDatabaseSync } from 'expo-sqlite'; +import { + WorkoutTemplate, + TemplateExerciseConfig +} from '@/types/templates'; import { Workout } from '@/types/workout'; -import { WorkoutTemplate } from '@/types/templates'; import { generateId } from '@/utils/ids'; +import { DbService } from '../db-service'; -/** - * Service for managing workout templates - */ export class TemplateService { + private db: DbService; + + constructor(database: SQLiteDatabase) { + this.db = new DbService(database); + } + /** - * Updates an existing template based on workout changes + * Get all templates */ - static async updateExistingTemplate(workout: Workout): Promise { + async getAllTemplates(limit: number = 50, offset: number = 0): Promise { try { - // This would require actual implementation with DB access - // For now, this is a placeholder - console.log('Updating template from workout:', workout.id); - // Future: Implement with your DB service - return true; + const templates = await this.db.getAllAsync<{ + id: string; + title: string; + type: string; + description: string; + created_at: number; + updated_at: number; + nostr_event_id: string | null; + source: string; + parent_id: string | null; + }>( + `SELECT * FROM templates ORDER BY updated_at DESC LIMIT ? OFFSET ?`, + [limit, offset] + ); + + const result: WorkoutTemplate[] = []; + + for (const template of templates) { + // Get exercises for this template + const exercises = await this.getTemplateExercises(template.id); + + result.push({ + id: template.id, + title: template.title, + type: template.type as any, + description: template.description, + category: 'Custom', // Add this line + created_at: template.created_at, + lastUpdated: template.updated_at, + nostrEventId: template.nostr_event_id || undefined, + parentId: template.parent_id || undefined, + exercises, + availability: { + source: [template.source as any] + }, + isPublic: false, + version: 1, + tags: [] + }); + } + + return result; } catch (error) { - console.error('Error updating template:', error); - return false; + console.error('Error getting templates:', error); + return []; } } - + /** - * Saves a workout as a new template + * Get a template by ID */ - static async saveAsNewTemplate(workout: Workout, name: string): Promise { + async getTemplate(id: string): Promise { try { - // This would require actual implementation with DB access - // For now, this is a placeholder - console.log('Creating new template from workout:', workout.id, 'with name:', name); - // Future: Implement with your DB service - return generateId(); + const template = await this.db.getFirstAsync<{ + id: string; + title: string; + type: string; + description: string; + created_at: number; + updated_at: number; + nostr_event_id: string | null; + source: string; + parent_id: string | null; + }>( + `SELECT * FROM templates WHERE id = ?`, + [id] + ); + + if (!template) return null; + + // Get exercises for this template + const exercises = await this.getTemplateExercises(id); + + return { + id: template.id, + title: template.title, + type: template.type as any, + description: template.description, + category: 'Custom', + created_at: template.created_at, + lastUpdated: template.updated_at, + nostrEventId: template.nostr_event_id || undefined, + parentId: template.parent_id || undefined, + exercises, + availability: { + source: [template.source as any] + }, + isPublic: false, + version: 1, + tags: [] + }; } catch (error) { - console.error('Error creating template:', error); + console.error('Error getting template:', error); return null; } } - + /** - * Detects if a workout has changes compared to its original template + * Create a new template */ + async createTemplate(template: Omit): Promise { + try { + const id = generateId(); + const timestamp = Date.now(); + + await this.db.withTransactionAsync(async () => { + // Insert template + await this.db.runAsync( + `INSERT INTO templates ( + id, title, type, description, created_at, updated_at, + nostr_event_id, source, parent_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + template.title, + template.type || 'strength', + template.description || null, + timestamp, + timestamp, + template.nostrEventId || null, + template.availability?.source[0] || 'local', + template.parentId || null + ] + ); + + // Insert exercises + if (template.exercises?.length) { + for (let i = 0; i < template.exercises.length; i++) { + const exercise = template.exercises[i]; + + await this.db.runAsync( + `INSERT INTO template_exercises ( + id, template_id, exercise_id, display_order, + target_sets, target_reps, target_weight, notes, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + exercise.id || generateId(), + id, + exercise.exercise.id, + i, + exercise.targetSets || null, + exercise.targetReps || null, + exercise.targetWeight || null, + exercise.notes || null, + timestamp, + timestamp + ] + ); + } + } + }); + + return id; + } catch (error) { + console.error('Error creating template:', error); + throw error; + } + } + + /** + * Update an existing template + */ + async updateTemplate(id: string, updates: Partial): Promise { + try { + const timestamp = Date.now(); + + await this.db.withTransactionAsync(async () => { + // Update template record + const updateFields: string[] = []; + const updateValues: any[] = []; + + if (updates.title !== undefined) { + updateFields.push('title = ?'); + updateValues.push(updates.title); + } + + if (updates.type !== undefined) { + updateFields.push('type = ?'); + updateValues.push(updates.type); + } + + if (updates.description !== undefined) { + updateFields.push('description = ?'); + updateValues.push(updates.description); + } + + if (updates.nostrEventId !== undefined) { + updateFields.push('nostr_event_id = ?'); + updateValues.push(updates.nostrEventId); + } + + // Always update the timestamp + updateFields.push('updated_at = ?'); + updateValues.push(timestamp); + + // Add the ID for the WHERE clause + updateValues.push(id); + + if (updateFields.length > 0) { + await this.db.runAsync( + `UPDATE templates SET ${updateFields.join(', ')} WHERE id = ?`, + updateValues + ); + } + + // Update exercises if provided + if (updates.exercises) { + // Delete existing exercises + await this.db.runAsync( + 'DELETE FROM template_exercises WHERE template_id = ?', + [id] + ); + + // Insert new exercises + if (updates.exercises.length > 0) { + for (let i = 0; i < updates.exercises.length; i++) { + const exercise = updates.exercises[i]; + + await this.db.runAsync( + `INSERT INTO template_exercises ( + id, template_id, exercise_id, display_order, + target_sets, target_reps, target_weight, notes, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + exercise.id || generateId(), + id, + exercise.exercise.id, + i, + exercise.targetSets || null, + exercise.targetReps || null, + exercise.targetWeight || null, + exercise.notes || null, + timestamp, + timestamp + ] + ); + } + } + } + }); + } catch (error) { + console.error('Error updating template:', error); + throw error; + } + } + + /** + * Delete a template + */ + async deleteTemplate(id: string): Promise { + try { + await this.db.withTransactionAsync(async () => { + // Delete template exercises + await this.db.runAsync( + 'DELETE FROM template_exercises WHERE template_id = ?', + [id] + ); + + // Delete template + await this.db.runAsync( + 'DELETE FROM templates WHERE id = ?', + [id] + ); + }); + } catch (error) { + console.error('Error deleting template:', error); + throw error; + } + } + + /** + * Update template with Nostr event ID + */ + async updateNostrEventId(templateId: string, eventId: string): Promise { + try { + await this.db.runAsync( + `UPDATE templates SET nostr_event_id = ? WHERE id = ?`, + [eventId, templateId] + ); + } catch (error) { + console.error('Error updating template nostr event ID:', error); + throw error; + } + } + + // Helper methods + private async getTemplateExercises(templateId: string): Promise { + try { + const exercises = await this.db.getAllAsync<{ + id: string; + exercise_id: string; + target_sets: number | null; + target_reps: number | null; + target_weight: number | null; + notes: string | null; + }>( + `SELECT + te.id, te.exercise_id, te.target_sets, te.target_reps, + te.target_weight, te.notes + FROM template_exercises te + WHERE te.template_id = ? + ORDER BY te.display_order`, + [templateId] + ); + + const result: TemplateExerciseConfig[] = []; + + for (const ex of exercises) { + // Get exercise details + const exercise = await this.db.getFirstAsync<{ + title: string; + type: string; + category: string; + equipment: string | null; + }>( + `SELECT title, type, category, equipment FROM exercises WHERE id = ?`, + [ex.exercise_id] + ); + + result.push({ + id: ex.id, + exercise: { + id: ex.exercise_id, + title: exercise?.title || 'Unknown Exercise', + type: exercise?.type as any || 'strength', + category: exercise?.category as any || 'Other', + equipment: exercise?.equipment as any || undefined, + tags: [], // Required property + availability: { source: ['local'] }, // Required property + created_at: Date.now() // Required property + }, + targetSets: ex.target_sets || undefined, + targetReps: ex.target_reps || undefined, + targetWeight: ex.target_weight || undefined, + notes: ex.notes || undefined + }); + } + + return result; + } catch (error) { + console.error('Error getting template exercises:', error); + return []; + } + } + + // Static helper methods used by the workout store + static async updateExistingTemplate(workout: Workout): Promise { + try { + // Make sure workout has a templateId + if (!workout.templateId) { + return false; + } + + // Get database access + const db = openDatabaseSync('powr.db'); + const service = new TemplateService(db); + + // Get the existing template + const template = await service.getTemplate(workout.templateId); + if (!template) { + console.log('Template not found for updating:', workout.templateId); + return false; + } + + // Convert workout exercises to template format + const exercises: TemplateExerciseConfig[] = workout.exercises.map(ex => ({ + id: generateId(), + exercise: { + id: ex.id, + title: ex.title, + type: ex.type, + category: ex.category, + equipment: ex.equipment, + tags: ex.tags || [], // Required property + availability: { source: ['local'] }, // Required property + created_at: ex.created_at // Required property + }, + targetSets: ex.sets.length, + targetReps: ex.sets[0]?.reps || 0, + targetWeight: ex.sets[0]?.weight || 0 + })); + + // Update the template + await service.updateTemplate(template.id, { + lastUpdated: Date.now(), + exercises + }); + + console.log('Template updated successfully:', template.id); + return true; + } catch (error) { + console.error('Error updating template from workout:', error); + return false; + } + } + + static async saveAsNewTemplate(workout: Workout, name: string): Promise { + try { + // Get database access + const db = openDatabaseSync('powr.db'); + const service = new TemplateService(db); + + // Convert workout exercises to template format + const exercises: TemplateExerciseConfig[] = workout.exercises.map(ex => ({ + id: generateId(), + exercise: { + id: ex.id, + title: ex.title, + type: ex.type, + category: ex.category, + equipment: ex.equipment, + tags: ex.tags || [], // Required property + availability: { source: ['local'] }, // Required property + created_at: ex.created_at // Required property + }, + targetSets: ex.sets.length, + targetReps: ex.sets[0]?.reps || 0, + targetWeight: ex.sets[0]?.weight || 0 + })); + + // Create the new template + const templateId = await service.createTemplate({ + title: name, + type: workout.type, + description: workout.notes, + category: 'Custom', + exercises, + created_at: Date.now(), + parentId: workout.templateId, // Link to original template if this was derived + availability: { + source: ['local'] + }, + isPublic: false, + version: 1, + tags: [] + }); + + console.log('New template created from workout:', templateId); + return templateId; + } catch (error) { + console.error('Error creating template from workout:', error); + return null; + } + } + static hasTemplateChanges(workout: Workout): boolean { - // This would require comparing with the original template - // For now, assume changes if there's a templateId - return !!workout.templateId; + // Simple implementation - in a real app, you'd compare with the original template + return workout.templateId !== undefined; } } \ No newline at end of file diff --git a/lib/db/services/WorkoutHIstoryService.ts b/lib/db/services/WorkoutHIstoryService.ts index ce4003e..56b55d2 100644 --- a/lib/db/services/WorkoutHIstoryService.ts +++ b/lib/db/services/WorkoutHIstoryService.ts @@ -3,6 +3,7 @@ import { SQLiteDatabase } from 'expo-sqlite'; import { Workout } from '@/types/workout'; import { format } from 'date-fns'; import { DbService } from '../db-service'; +import { WorkoutExercise } from '@/types/exercise'; // Add this import export class WorkoutHistoryService { private db: DbService; @@ -21,21 +22,47 @@ export class WorkoutHistoryService { title: string; type: string; start_time: number; - end_time: number; + end_time: number | null; is_completed: number; created_at: number; - last_updated: number; + updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; + notes: string | null; }>( `SELECT * FROM workouts ORDER BY start_time DESC` ); // Transform database records to Workout objects - return workouts.map(row => this.mapRowToWorkout(row)); + const result: Workout[] = []; + + for (const workout of workouts) { + const exercises = await this.getWorkoutExercises(workout.id); + + result.push({ + id: workout.id, + title: workout.title, + type: workout.type as any, + startTime: workout.start_time, + endTime: workout.end_time || undefined, + isCompleted: Boolean(workout.is_completed), + created_at: workout.created_at, + lastUpdated: workout.updated_at, + templateId: workout.template_id || undefined, + totalVolume: workout.total_volume || undefined, + totalReps: workout.total_reps || undefined, + notes: workout.notes || undefined, + exercises, + availability: { + source: [workout.source as any] + } + }); + } + + return result; } catch (error) { console.error('Error getting workouts:', error); throw error; @@ -55,14 +82,15 @@ export class WorkoutHistoryService { title: string; type: string; start_time: number; - end_time: number; + end_time: number | null; is_completed: number; created_at: number; - last_updated: number; + updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; + notes: string | null; }>( `SELECT * FROM workouts WHERE start_time >= ? AND start_time <= ? @@ -70,7 +98,32 @@ export class WorkoutHistoryService { [startOfDay, endOfDay] ); - return workouts.map(row => this.mapRowToWorkout(row)); + const result: Workout[] = []; + + for (const workout of workouts) { + const exercises = await this.getWorkoutExercises(workout.id); + + result.push({ + id: workout.id, + title: workout.title, + type: workout.type as any, + startTime: workout.start_time, + endTime: workout.end_time || undefined, + isCompleted: Boolean(workout.is_completed), + created_at: workout.created_at, + lastUpdated: workout.updated_at, + templateId: workout.template_id || undefined, + totalVolume: workout.total_volume || undefined, + totalReps: workout.total_reps || undefined, + notes: workout.notes || undefined, + exercises, + availability: { + source: [workout.source as any] + } + }); + } + + return result; } catch (error) { console.error('Error getting workouts by date:', error); throw error; @@ -88,7 +141,8 @@ export class WorkoutHistoryService { const result = await this.db.getAllAsync<{ start_time: number; }>( - `SELECT DISTINCT start_time FROM workouts + `SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as start_time + FROM workouts WHERE start_time >= ? AND start_time <= ?`, [startOfMonth, endOfMonth] ); @@ -110,14 +164,15 @@ export class WorkoutHistoryService { title: string; type: string; start_time: number; - end_time: number; + end_time: number | null; is_completed: number; created_at: number; - last_updated: number; + updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; + notes: string | null; }>( `SELECT * FROM workouts WHERE id = ?`, [workoutId] @@ -126,18 +181,26 @@ export class WorkoutHistoryService { if (!workout) return null; // Get exercises for this workout - // This is just a skeleton - you'll need to implement the actual query - // based on your database schema - const exercises = await this.db.getAllAsync( - `SELECT * FROM workout_exercises WHERE workout_id = ?`, - [workoutId] - ); + const exercises = await this.getWorkoutExercises(workoutId); - const workoutObj = this.mapRowToWorkout(workout); - // You would set the exercises property here based on your schema - // workoutObj.exercises = exercises.map(...); - - return workoutObj; + return { + id: workout.id, + title: workout.title, + type: workout.type as any, + startTime: workout.start_time, + endTime: workout.end_time || undefined, + isCompleted: Boolean(workout.is_completed), + created_at: workout.created_at, + lastUpdated: workout.updated_at, + templateId: workout.template_id || undefined, + totalVolume: workout.total_volume || undefined, + totalReps: workout.total_reps || undefined, + notes: workout.notes || undefined, + exercises, + availability: { + source: [workout.source as any] + } + }; } catch (error) { console.error('Error getting workout details:', error); throw error; @@ -160,39 +223,96 @@ export class WorkoutHistoryService { } } - /** - * Helper method to map a database row to a Workout object - */ - private mapRowToWorkout(row: { - id: string; - title: string; - type: string; - start_time: number; - end_time: number; - is_completed: number; - created_at: number; - last_updated?: number; - template_id?: string | null; - total_volume?: number | null; - total_reps?: number | null; - source: string; - }): Workout { - return { - id: row.id, - title: row.title, - type: row.type as any, // Cast to TemplateType - startTime: row.start_time, - endTime: row.end_time, - isCompleted: row.is_completed === 1, - created_at: row.created_at, - lastUpdated: row.last_updated, - templateId: row.template_id || undefined, - totalVolume: row.total_volume || undefined, - totalReps: row.total_reps || undefined, - availability: { - source: [row.source as any] // Cast to StorageSource - }, - exercises: [] // Exercises would be loaded separately - }; - } + // Helper method to load workout exercises and sets + private async getWorkoutExercises(workoutId: string): Promise { + try { + const exercises = await this.db.getAllAsync<{ + id: string; + exercise_id: string; + display_order: number; + notes: string | null; + created_at: number; + updated_at: number; + }>( + `SELECT we.* FROM workout_exercises we + WHERE we.workout_id = ? + ORDER BY we.display_order`, + [workoutId] + ); + + const result: WorkoutExercise[] = []; + + for (const exercise of exercises) { + // Get the base exercise info + const baseExercise = await this.db.getFirstAsync<{ + title: string; + type: string; + category: string; + equipment: string | null; + }>( + `SELECT title, type, category, equipment FROM exercises WHERE id = ?`, + [exercise.exercise_id] + ); + + // Get the tags for this exercise + const tags = await this.db.getAllAsync<{ tag: string }>( + `SELECT tag FROM exercise_tags WHERE exercise_id = ?`, + [exercise.exercise_id] + ); + + // Get the sets for this exercise + const sets = await this.db.getAllAsync<{ + id: string; + type: string; + weight: number | null; + reps: number | null; + rpe: number | null; + duration: number | null; + is_completed: number; + completed_at: number | null; + created_at: number; + updated_at: number; + }>( + `SELECT * FROM workout_sets + WHERE workout_exercise_id = ? + ORDER BY id`, + [exercise.id] + ); + + // Map sets to the correct format + const mappedSets = sets.map(set => ({ + id: set.id, + type: set.type as any, + weight: set.weight || undefined, + reps: set.reps || undefined, + rpe: set.rpe || undefined, + duration: set.duration || undefined, + isCompleted: Boolean(set.is_completed), + completedAt: set.completed_at || undefined, + lastUpdated: set.updated_at + })); + + result.push({ + id: exercise.id, + exerciseId: exercise.exercise_id, + title: baseExercise?.title || 'Unknown Exercise', + type: baseExercise?.type as any || 'strength', + category: baseExercise?.category as any || 'Other', + equipment: baseExercise?.equipment as any, + notes: exercise.notes || undefined, + tags: tags.map(t => t.tag), // Add the tags array here + sets: mappedSets, + created_at: exercise.created_at, + lastUpdated: exercise.updated_at, + isCompleted: mappedSets.every(set => set.isCompleted), + availability: { source: ['local'] } + }); + } + + return result; + } catch (error) { + console.error('Error getting workout exercises:', error); + return []; + } + } } \ No newline at end of file diff --git a/lib/db/services/WorkoutService.ts b/lib/db/services/WorkoutService.ts new file mode 100644 index 0000000..8b8fe40 --- /dev/null +++ b/lib/db/services/WorkoutService.ts @@ -0,0 +1,516 @@ +// lib/db/services/WorkoutService.ts +import { SQLiteDatabase } from 'expo-sqlite'; +import { Workout, WorkoutExercise, WorkoutSet, WorkoutSummary } from '@/types/workout'; +import { generateId } from '@/utils/ids'; +import { DbService } from '../db-service'; + +export class WorkoutService { + private db: DbService; + + constructor(database: SQLiteDatabase) { + this.db = new DbService(database); + } + + /** + * Save a workout to the database + */ + async saveWorkout(workout: Workout): Promise { + try { + await this.db.withTransactionAsync(async () => { + // Check if workout exists (for update vs insert) + const existingWorkout = await this.db.getFirstAsync<{ id: string }>( + 'SELECT id FROM workouts WHERE id = ?', + [workout.id] + ); + + const timestamp = Date.now(); + + if (existingWorkout) { + // Update existing workout + await this.db.runAsync( + `UPDATE workouts SET + title = ?, type = ?, start_time = ?, end_time = ?, + is_completed = ?, updated_at = ?, template_id = ?, + share_status = ?, notes = ? + WHERE id = ?`, + [ + workout.title, + workout.type, + workout.startTime, + workout.endTime || null, + workout.isCompleted ? 1 : 0, + timestamp, + workout.templateId || null, + workout.shareStatus || 'local', + workout.notes || null, + workout.id + ] + ); + + // Delete existing exercises and sets to recreate them + await this.deleteWorkoutExercises(workout.id); + } else { + // Insert new workout + await this.db.runAsync( + `INSERT INTO workouts ( + id, title, type, start_time, end_time, is_completed, + created_at, updated_at, template_id, source, share_status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + workout.id, + workout.title, + workout.type, + workout.startTime, + workout.endTime || null, + workout.isCompleted ? 1 : 0, + timestamp, + timestamp, + workout.templateId || null, + workout.availability?.source[0] || 'local', + workout.shareStatus || 'local', + workout.notes || null + ] + ); + } + + // Save exercises and sets + if (workout.exercises?.length) { + await this.saveWorkoutExercises(workout.id, workout.exercises); + } + }); + + console.log('Workout saved successfully:', workout.id); + } catch (error) { + console.error('Error saving workout:', error); + throw error; + } + } + + /** + * Get a workout by ID + */ + async getWorkout(id: string): Promise { + try { + const workout = await this.db.getFirstAsync<{ + id: string; + title: string; + type: string; + start_time: number; + end_time: number | null; + is_completed: number; + created_at: number; + updated_at: number; + template_id: string | null; + source: string; + share_status: string; + notes: string | null; + }>( + `SELECT * FROM workouts WHERE id = ?`, + [id] + ); + + if (!workout) return null; + + // Get exercises and sets + const exercises = await this.getWorkoutExercises(id); + + return { + id: workout.id, + title: workout.title, + type: workout.type as any, + startTime: workout.start_time, + endTime: workout.end_time || undefined, + isCompleted: Boolean(workout.is_completed), + created_at: workout.created_at, + lastUpdated: workout.updated_at, + templateId: workout.template_id || undefined, + shareStatus: workout.share_status as any, + notes: workout.notes || undefined, + exercises, + availability: { + source: [workout.source as any] + } + }; + } catch (error) { + console.error('Error getting workout:', error); + return null; + } + } + + /** + * Get all workouts + */ + async getAllWorkouts(limit: number = 50, offset: number = 0): Promise { + try { + const workouts = await this.db.getAllAsync<{ + id: string; + title: string; + type: string; + start_time: number; + end_time: number | null; + is_completed: number; + created_at: number; + updated_at: number; + template_id: string | null; + source: string; + share_status: string; + notes: string | null; + }>( + `SELECT * FROM workouts ORDER BY start_time DESC LIMIT ? OFFSET ?`, + [limit, offset] + ); + + const result: Workout[] = []; + + for (const workout of workouts) { + const exercises = await this.getWorkoutExercises(workout.id); + + result.push({ + id: workout.id, + title: workout.title, + type: workout.type as any, + startTime: workout.start_time, + endTime: workout.end_time || undefined, + isCompleted: Boolean(workout.is_completed), + created_at: workout.created_at, + lastUpdated: workout.updated_at, + templateId: workout.template_id || undefined, + shareStatus: workout.share_status as any, + notes: workout.notes || undefined, + exercises, + availability: { + source: [workout.source as any] + } + }); + } + + return result; + } catch (error) { + console.error('Error getting all workouts:', error); + return []; + } + } + + /** + * Get workouts for a specific date range + */ + async getWorkoutsByDateRange(startDate: number, endDate: number): Promise { + try { + const workouts = await this.db.getAllAsync<{ + id: string; + title: string; + type: string; + start_time: number; + end_time: number | null; + is_completed: number; + created_at: number; + updated_at: number; + template_id: string | null; + source: string; + share_status: string; + notes: string | null; + }>( + `SELECT * FROM workouts + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time DESC`, + [startDate, endDate] + ); + + const result: Workout[] = []; + + for (const workout of workouts) { + const exercises = await this.getWorkoutExercises(workout.id); + + result.push({ + id: workout.id, + title: workout.title, + type: workout.type as any, + startTime: workout.start_time, + endTime: workout.end_time || undefined, + isCompleted: Boolean(workout.is_completed), + created_at: workout.created_at, + lastUpdated: workout.updated_at, + templateId: workout.template_id || undefined, + shareStatus: workout.share_status as any, + notes: workout.notes || undefined, + exercises, + availability: { + source: [workout.source as any] + } + }); + } + + return result; + } catch (error) { + console.error('Error getting workouts by date range:', error); + return []; + } + } + + /** + * Delete a workout + */ + async deleteWorkout(id: string): Promise { + try { + await this.db.withTransactionAsync(async () => { + // Delete exercises and sets first due to foreign key constraints + await this.deleteWorkoutExercises(id); + + // Delete the workout + await this.db.runAsync( + 'DELETE FROM workouts WHERE id = ?', + [id] + ); + }); + } catch (error) { + console.error('Error deleting workout:', error); + throw error; + } + } + + /** + * Save workout summary metrics + */ + async saveWorkoutSummary(workoutId: string, summary: WorkoutSummary): Promise { + try { + await this.db.runAsync( + `UPDATE workouts SET + total_volume = ?, + total_reps = ? + WHERE id = ?`, + [ + summary.totalVolume || 0, + summary.totalReps || 0, + workoutId + ] + ); + } catch (error) { + console.error('Error saving workout summary:', error); + throw error; + } + } + + /** + * Get dates that have workouts + */ + async getWorkoutDates(startDate: number, endDate: number): Promise { + try { + const dates = await this.db.getAllAsync<{ start_time: number }>( + `SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as start_time + FROM workouts + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time`, + [startDate, endDate] + ); + + return dates.map(d => d.start_time); + } catch (error) { + console.error('Error getting workout dates:', error); + return []; + } + } + + /** + * Update Nostr event ID for a workout + */ + async updateNostrEventId(workoutId: string, eventId: string): Promise { + try { + await this.db.runAsync( + `UPDATE workouts SET nostr_event_id = ? WHERE id = ?`, + [eventId, workoutId] + ); + } catch (error) { + console.error('Error updating Nostr event ID:', error); + throw error; + } + } + + // Helper methods + + /** + * Get exercises for a workout + */ + private async getWorkoutExercises(workoutId: string): Promise { + try { + const exercises = await this.db.getAllAsync<{ + id: string; + exercise_id: string; + display_order: number; + notes: string | null; + created_at: number; + updated_at: number; + }>( + `SELECT we.* FROM workout_exercises we + WHERE we.workout_id = ? + ORDER BY we.display_order`, + [workoutId] + ); + + const result: WorkoutExercise[] = []; + + for (const exercise of exercises) { + // Get the base exercise info + const baseExercise = await this.db.getFirstAsync<{ + title: string; + type: string; + category: string; + equipment: string | null; + }>( + `SELECT title, type, category, equipment FROM exercises WHERE id = ?`, + [exercise.exercise_id] + ); + + // Get the sets for this exercise + const sets = await this.getWorkoutSets(exercise.id); + + result.push({ + id: exercise.id, + exerciseId: exercise.exercise_id, + title: baseExercise?.title || 'Unknown Exercise', + type: baseExercise?.type as any || 'strength', + category: baseExercise?.category as any || 'Other', + equipment: baseExercise?.equipment as any, + notes: exercise.notes || undefined, + sets, + created_at: exercise.created_at, + lastUpdated: exercise.updated_at, + isCompleted: sets.every(set => set.isCompleted), + availability: { source: ['local'] }, + tags: [] // Required property + }); + } + + return result; + } catch (error) { + console.error('Error getting workout exercises:', error); + return []; + } + } + + /** + * Get sets for a workout exercise + */ + private async getWorkoutSets(workoutExerciseId: string): Promise { + try { + const sets = await this.db.getAllAsync<{ + id: string; + type: string; + weight: number | null; + reps: number | null; + rpe: number | null; + duration: number | null; + is_completed: number; + completed_at: number | null; + created_at: number; + updated_at: number; + }>( + `SELECT * FROM workout_sets + WHERE workout_exercise_id = ? + ORDER BY id`, + [workoutExerciseId] + ); + + return sets.map(set => ({ + id: set.id, + type: set.type as any, + weight: set.weight || undefined, + reps: set.reps || undefined, + rpe: set.rpe || undefined, + duration: set.duration || undefined, + isCompleted: Boolean(set.is_completed), + completedAt: set.completed_at || undefined, + lastUpdated: set.updated_at + })); + } catch (error) { + console.error('Error getting workout sets:', error); + return []; + } + } + + /** + * Delete exercises and sets for a workout + */ + private async deleteWorkoutExercises(workoutId: string): Promise { + try { + // Get all workout exercise IDs + const exercises = await this.db.getAllAsync<{ id: string }>( + 'SELECT id FROM workout_exercises WHERE workout_id = ?', + [workoutId] + ); + + // Delete sets for each exercise + for (const exercise of exercises) { + await this.db.runAsync( + 'DELETE FROM workout_sets WHERE workout_exercise_id = ?', + [exercise.id] + ); + } + + // Delete the exercises + await this.db.runAsync( + 'DELETE FROM workout_exercises WHERE workout_id = ?', + [workoutId] + ); + } catch (error) { + console.error('Error deleting workout exercises:', error); + throw error; + } + } + + /** + * Save exercises and sets for a workout + */ + private async saveWorkoutExercises(workoutId: string, exercises: WorkoutExercise[]): Promise { + const timestamp = Date.now(); + + for (let i = 0; i < exercises.length; i++) { + const exercise = exercises[i]; + const exerciseId = exercise.id || generateId('local'); + + // Save exercise + await this.db.runAsync( + `INSERT INTO workout_exercises ( + id, workout_id, exercise_id, display_order, notes, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + exerciseId, + workoutId, + exercise.exerciseId || exercise.id, // Use exerciseId if available + i, // Display order + exercise.notes || null, + timestamp, + timestamp + ] + ); + + // Save sets + if (exercise.sets?.length) { + for (const set of exercise.sets) { + const setId = set.id || generateId('local'); + + await this.db.runAsync( + `INSERT INTO workout_sets ( + id, workout_exercise_id, type, weight, reps, + rpe, duration, is_completed, completed_at, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + setId, + exerciseId, + set.type || 'normal', + set.weight || null, + set.reps || null, + set.rpe || null, + set.duration || null, + set.isCompleted ? 1 : 0, + set.completedAt || null, + timestamp, + timestamp + ] + ); + } + } + } + } +} \ No newline at end of file diff --git a/lib/hooks/useTemplates.ts b/lib/hooks/useTemplates.ts new file mode 100644 index 0000000..4dffd40 --- /dev/null +++ b/lib/hooks/useTemplates.ts @@ -0,0 +1,84 @@ +// lib/hooks/useTemplates.ts +import { useState, useCallback, useEffect } from 'react'; +import { WorkoutTemplate } from '@/types/templates'; +import { useTemplateService } from '@/components/DatabaseProvider'; + +export function useTemplates() { + const templateService = useTemplateService(); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadTemplates = useCallback(async (limit: number = 50, offset: number = 0) => { + try { + setLoading(true); + const data = await templateService.getAllTemplates(limit, offset); + setTemplates(data); + setError(null); + } catch (err) { + console.error('Error loading templates:', err); + setError(err instanceof Error ? err : new Error('Failed to load templates')); + // Use empty array if database isn't ready + setTemplates([]); + } finally { + setLoading(false); + } + }, [templateService]); + + const getTemplate = useCallback(async (id: string) => { + try { + return await templateService.getTemplate(id); + } catch (err) { + console.error('Error getting template:', err); + throw err; + } + }, [templateService]); + + const createTemplate = useCallback(async (template: Omit) => { + try { + const id = await templateService.createTemplate(template); + await loadTemplates(); // Refresh the list + return id; + } catch (err) { + console.error('Error creating template:', err); + throw err; + } + }, [templateService, loadTemplates]); + + const updateTemplate = useCallback(async (id: string, updates: Partial) => { + try { + await templateService.updateTemplate(id, updates); + await loadTemplates(); // Refresh the list + } catch (err) { + console.error('Error updating template:', err); + throw err; + } + }, [templateService, loadTemplates]); + + const deleteTemplate = useCallback(async (id: string) => { + try { + await templateService.deleteTemplate(id); + setTemplates(current => current.filter(t => t.id !== id)); + } catch (err) { + console.error('Error deleting template:', err); + throw err; + } + }, [templateService]); + + // Initial load + useEffect(() => { + loadTemplates(); + }, [loadTemplates]); + + return { + templates, + loading, + error, + loadTemplates, + getTemplate, + createTemplate, + updateTemplate, + deleteTemplate, + refreshTemplates: loadTemplates + }; +} \ No newline at end of file diff --git a/lib/hooks/useWorkouts.ts b/lib/hooks/useWorkouts.ts new file mode 100644 index 0000000..03c15bb --- /dev/null +++ b/lib/hooks/useWorkouts.ts @@ -0,0 +1,108 @@ +// lib/hooks/useWorkouts.ts +import { useState, useCallback, useEffect } from 'react'; +import { Workout } from '@/types/workout'; +import { useWorkoutService } from '@/components/DatabaseProvider'; + +export function useWorkouts() { + const workoutService = useWorkoutService(); + const [workouts, setWorkouts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadWorkouts = useCallback(async (limit: number = 50, offset: number = 0) => { + try { + setLoading(true); + const data = await workoutService.getAllWorkouts(limit, offset); + setWorkouts(data); + setError(null); + } catch (err) { + console.error('Error loading workouts:', err); + setError(err instanceof Error ? err : new Error('Failed to load workouts')); + // Use mock data in dev mode if database is not ready + if (__DEV__) { + console.log('Using mock data because workout tables not yet created'); + setWorkouts([]); + } + } finally { + setLoading(false); + } + }, [workoutService]); + + const getWorkout = useCallback(async (id: string) => { + try { + return await workoutService.getWorkout(id); + } catch (err) { + console.error('Error getting workout:', err); + throw err; + } + }, [workoutService]); + + const getWorkoutsByDate = useCallback(async (date: Date) => { + try { + // Create start and end of day timestamps + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + return await workoutService.getWorkoutsByDateRange( + startOfDay.getTime(), + endOfDay.getTime() + ); + } catch (err) { + console.error('Error loading workouts for date:', err); + throw err; + } + }, [workoutService]); + + const getWorkoutDates = useCallback(async (startDate: Date, endDate: Date) => { + try { + return await workoutService.getWorkoutDates( + startDate.getTime(), + endDate.getTime() + ); + } catch (err) { + console.error('Error getting workout dates:', err); + return []; + } + }, [workoutService]); + + const saveWorkout = useCallback(async (workout: Workout) => { + try { + await workoutService.saveWorkout(workout); + await loadWorkouts(); // Refresh the list + } catch (err) { + console.error('Error saving workout:', err); + throw err; + } + }, [workoutService, loadWorkouts]); + + const deleteWorkout = useCallback(async (id: string) => { + try { + await workoutService.deleteWorkout(id); + setWorkouts(current => current.filter(w => w.id !== id)); + } catch (err) { + console.error('Error deleting workout:', err); + throw err; + } + }, [workoutService]); + + // Initial load + useEffect(() => { + loadWorkouts(); + }, [loadWorkouts]); + + return { + workouts, + loading, + error, + loadWorkouts, + getWorkout, + getWorkoutsByDate, + getWorkoutDates, + saveWorkout, + deleteWorkout, + refreshWorkouts: loadWorkouts + }; +} \ No newline at end of file diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts index 8c82e7d..97e8b1b 100644 --- a/stores/workoutStore.ts +++ b/stores/workoutStore.ts @@ -1,20 +1,4 @@ // stores/workoutStore.ts - -/** - * Workout Store - * - * This store manages the state for active workouts including: - * - Starting, pausing, and completing workouts - * - Managing exercise sets and completion status - * - Handling workout timing and duration tracking - * - Publishing workout data to Nostr when requested - * - Tracking favorite templates - * - * The store uses a timestamp-based approach for duration tracking, - * capturing start and end times to accurately represent workout duration - * even when accounting for time spent in completion flow. - */ - import { create } from 'zustand'; import { createSelectors } from '@/utils/createSelectors'; import { generateId } from '@/utils/ids'; @@ -40,9 +24,26 @@ import { router } from 'expo-router'; import { useNDKStore } from '@/lib/stores/ndk'; import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; import { TemplateService } from '@/lib//db/services/TemplateService'; +import { WorkoutService } from '@/lib/db/services/WorkoutService'; // Add this import import { NostrEvent } from '@/types/nostr'; import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; +/** + * Workout Store + * + * This store manages the state for active workouts including: + * - Starting, pausing, and completing workouts + * - Managing exercise sets and completion status + * - Handling workout timing and duration tracking + * - Publishing workout data to Nostr when requested + * - Tracking favorite templates + * + * The store uses a timestamp-based approach for duration tracking, + * capturing start and end times to accurately represent workout duration + * even when accounting for time spent in completion flow. + */ + + const AUTO_SAVE_INTERVAL = 30000; // 30 seconds // Define a module-level timer reference for the workout timer @@ -810,26 +811,23 @@ const useWorkoutStoreBase = create { try { // Try to get it from favorites in the database const db = openDatabaseSync('powr.db'); + const favoritesService = new FavoritesService(db); + const templateService = new TemplateService(db); - const result = await db.getFirstAsync<{ - content: string - }>( - `SELECT content FROM favorites WHERE content_type = 'template' AND content_id = ?`, - [templateId] - ); - - if (result && result.content) { - return JSON.parse(result.content); + // First try to get from favorites + const favoriteResult = await favoritesService.getContentById('template', templateId); + if (favoriteResult) { + return favoriteResult; } - // If not found in favorites, could implement fetching from template database - // Example: return await db.getTemplate(templateId); - console.log('Template not found in favorites:', templateId); - return null; + // If not in favorites, try the templates table + const templateResult = await templateService.getTemplate(templateId); + return templateResult; } catch (error) { console.error('Error fetching template:', error); return null; @@ -838,14 +836,31 @@ async function getTemplate(templateId: string): Promise async function saveWorkout(workout: Workout): Promise { try { - // Make sure we're capturing the duration properly in what's saved console.log('Saving workout with endTime:', workout.endTime); - // TODO: Implement actual save logic using our database service + + // Use the workout service to save the workout + const db = openDatabaseSync('powr.db'); + const workoutService = new WorkoutService(db); + + await workoutService.saveWorkout(workout); } catch (error) { console.error('Error saving workout:', error); } } +async function saveSummary(summary: WorkoutSummary) { + try { + // Use the workout service to save summary metrics + const db = openDatabaseSync('powr.db'); + const workoutService = new WorkoutService(db); + + await workoutService.saveWorkoutSummary(summary.id, summary); + console.log('Workout summary saved successfully:', summary.id); + } catch (error) { + console.error('Error saving workout summary:', error); + } +} + function calculateWorkoutSummary(workout: Workout): WorkoutSummary { return { id: generateId('local'), @@ -891,11 +906,6 @@ function calculateAverageRpe(workout: Workout): number { return totalRpe / rpeSets.length; } -async function saveSummary(summary: WorkoutSummary) { - // TODO: Implement summary saving - console.log('Saving summary:', summary); -} - // Create auto-generated selectors export const useWorkoutStore = createSelectors(useWorkoutStoreBase); diff --git a/types/exercise.ts b/types/exercise.ts index 617c7b6..8ee8222 100644 --- a/types/exercise.ts +++ b/types/exercise.ts @@ -77,6 +77,9 @@ export interface WorkoutSet { isCompleted: boolean; notes?: string; timestamp?: number; + duration?: number; // Add this property + completedAt?: number; + lastUpdated?: number; } /** @@ -84,6 +87,7 @@ export interface WorkoutSet { */ export interface WorkoutExercise extends BaseExercise { sets: WorkoutSet[]; + exerciseId?: string; targetSets?: number; targetReps?: number; notes?: string; diff --git a/types/templates.ts b/types/templates.ts index c4fc79b..df217e1 100644 --- a/types/templates.ts +++ b/types/templates.ts @@ -31,9 +31,11 @@ export interface TemplateExerciseDisplay { } export interface TemplateExerciseConfig { + id?: string; // Add this line exercise: BaseExercise; - targetSets: number; - targetReps: number; + targetSets?: number; + targetReps?: number; + targetWeight?: number; weight?: number; rpe?: number; setType?: SetType; @@ -122,6 +124,8 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent { exercises: TemplateExerciseConfig[]; isPublic: boolean; version: number; + lastUpdated?: number; // Add this line + parentId?: string; // Add this line // Template configuration format?: { @@ -184,8 +188,8 @@ export function toTemplateDisplay(template: WorkoutTemplate): Template { description: template.description, exercises: template.exercises.map(ex => ({ title: ex.exercise.title, - targetSets: ex.targetSets, - targetReps: ex.targetReps, + targetSets: ex.targetSets || 0, // Add default value + targetReps: ex.targetReps || 0, // Add default value notes: ex.notes })), tags: template.tags, @@ -261,8 +265,8 @@ export function createNostrTemplateEvent(template: WorkoutTemplate) { ...template.exercises.map(ex => [ 'exercise', `33401:${ex.exercise.id}`, - ex.targetSets.toString(), - ex.targetReps.toString(), + (ex.targetSets || 0).toString(), + (ex.targetReps || 0).toString(), ex.setType || 'normal' ]), ...template.tags.map(tag => ['t', tag]) diff --git a/types/workout.ts b/types/workout.ts index 7c3f517..8e9be73 100644 --- a/types/workout.ts +++ b/types/workout.ts @@ -23,12 +23,14 @@ export interface WorkoutSet { timestamp?: number; lastUpdated?: number; completedAt?: number; + duration?: number; } /** * Exercise within a workout */ export interface WorkoutExercise extends BaseExercise { + exerciseId?: string; sets: WorkoutSet[]; targetSets?: number; targetReps?: number; @@ -53,12 +55,16 @@ export interface Workout extends SyncableContent { tags?: string[]; // Template reference if workout was started from template - templateId?: string; + templateId?: string; // Keep only one templateId property + templatePubkey?: string; // Add this for template references + + // Add shareStatus property + shareStatus?: 'local' | 'public' | 'limited'; // Workout configuration rounds?: number; - duration?: number; // Total duration in seconds - interval?: number; // For EMOM/interval workouts + duration?: number; + interval?: number; restBetweenRounds?: number; // Workout metrics diff --git a/utils/converter.ts b/utils/converter.ts new file mode 100644 index 0000000..53049a5 --- /dev/null +++ b/utils/converter.ts @@ -0,0 +1,65 @@ +// utils/converter.ts - Simplified to just forward to NostrWorkoutService + +import { Workout } from '@/types/workout'; +import { WorkoutTemplate } from '@/types/templates'; +import { NostrEvent } from '@/types/nostr'; +import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; + +/** + * Helper function to find a tag value in a Nostr event + * @deprecated Use NostrWorkoutService.findTagValue instead + */ +export function findTagValue(tags: string[][], name: string): string | null { + return NostrWorkoutService.findTagValue(tags, name); +} + +/** + * Get all values for a specific tag name + * @deprecated Use NostrWorkoutService.getTagValues instead + */ +export function getTagValues(tags: string[][], name: string): string[] { + return NostrWorkoutService.getTagValues(tags, name); +} + +/** + * Get template tag information + * @deprecated Use NostrWorkoutService.getTemplateTag instead + */ +export function getTagValueByName(tags: string[][], name: string): string | null { + return NostrWorkoutService.findTagValue(tags, name); +} + +/** + * Get tag values matching a pattern + */ +export function getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined { + return NostrWorkoutService.getTemplateTag(tags); +} + +/** + * Convert a workout to a Nostr event + */ +export function workoutToNostrEvent(workout: Workout, isLimited: boolean = false): NostrEvent { + return NostrWorkoutService.workoutToNostrEvent(workout, isLimited); +} + +/** + * Convert a Nostr event to a workout + */ +export function nostrEventToWorkout(event: NostrEvent): Workout { + return NostrWorkoutService.nostrEventToWorkout(event); +} + +/** + * Convert a template to a Nostr event + */ +export function templateToNostrEvent(template: WorkoutTemplate): NostrEvent { + return NostrWorkoutService.templateToNostrEvent(template); +} + +/** + * Convert a Nostr event to a template + */ +export function nostrEventToTemplate(event: NostrEvent): WorkoutTemplate { + return NostrWorkoutService.nostrEventToTemplate(event); +} \ No newline at end of file diff --git a/utils/nostr-utils.ts b/utils/nostr-utils.ts new file mode 100644 index 0000000..b1c578a --- /dev/null +++ b/utils/nostr-utils.ts @@ -0,0 +1,30 @@ +// utils/nostr-utils.ts +import { NostrEvent } from '@/types/nostr'; + +/** + * Helper function to find a tag value in a Nostr event + */ +export function findTagValue(tags: string[][], name: string): string | null { + const tag = tags.find(t => t[0] === name); + return tag && tag.length > 1 ? tag[1] : null; +} + +/** + * Get all values for a specific tag name + */ +export function getTagValues(tags: string[][], name: string): string[] { + return tags.filter(t => t[0] === name).map(t => t[1]); +} + +/** + * Get template tag information + */ +export function getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined { + const templateTag = tags.find(t => t[0] === 'template'); + if (!templateTag || templateTag.length < 3) return undefined; + + return { + reference: templateTag[1], + relay: templateTag[2] || '' + }; +} \ No newline at end of file diff --git a/utils/workout.ts b/utils/workout.ts index dbff418..115c35e 100644 --- a/utils/workout.ts +++ b/utils/workout.ts @@ -23,7 +23,7 @@ export function convertTemplateToWorkout(template: WorkoutTemplate) { }, created_at: now, // Create the specified number of sets from template - sets: Array.from({ length: templateExercise.targetSets }, (): WorkoutSet => ({ + sets: Array.from({ length: templateExercise.targetSets || 0 }, (): WorkoutSet => ({ id: generateId('local'), weight: 0, // Start empty, but could use last workout weight reps: templateExercise.targetReps, // Use target reps from template