feat(database): Add workout and template persistence

Implements database tables and services for workout and template storage:
- Updates schema to version 5 with new workout and template tables
- Adds WorkoutService for CRUD operations on workouts
- Enhances TemplateService for template management
- Creates NostrWorkoutService for bidirectional Nostr event handling
- Implements React hooks for database access
- Connects workout store to database layer for persistence
- Improves offline support with publication queue

This change ensures workouts and templates are properly saved to SQLite
and can be referenced across app sessions, while maintaining Nostr
integration for social sharing.
This commit is contained in:
DocNR 2025-03-08 15:48:07 -05:00
parent 001cb3078d
commit 29c4dd1675
19 changed files with 2563 additions and 497 deletions

View File

@ -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/), 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). 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 # Changelog - March 6, 2025
## Added ## Added

View File

@ -6,12 +6,16 @@ import { ExerciseService } from '@/lib/db/services/ExerciseService';
import { DevSeederService } from '@/lib/db/services/DevSeederService'; import { DevSeederService } from '@/lib/db/services/DevSeederService';
import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService'; import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService';
import { FavoritesService } from '@/lib/db/services/FavoritesService'; 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 { logDatabaseInfo } from '@/lib/db/debug';
import { useNDKStore } from '@/lib/stores/ndk'; import { useNDKStore } from '@/lib/stores/ndk';
// Create context for services // Create context for services
interface DatabaseServicesContextValue { interface DatabaseServicesContextValue {
exerciseService: ExerciseService | null; exerciseService: ExerciseService | null;
workoutService: WorkoutService | null;
templateService: TemplateService | null;
devSeeder: DevSeederService | null; devSeeder: DevSeederService | null;
publicationQueue: PublicationQueueService | null; publicationQueue: PublicationQueueService | null;
favoritesService: FavoritesService | null; favoritesService: FavoritesService | null;
@ -20,6 +24,8 @@ interface DatabaseServicesContextValue {
const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({ const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({
exerciseService: null, exerciseService: null,
workoutService: null,
templateService: null,
devSeeder: null, devSeeder: null,
publicationQueue: null, publicationQueue: null,
favoritesService: null, favoritesService: null,
@ -35,6 +41,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [services, setServices] = React.useState<DatabaseServicesContextValue>({ const [services, setServices] = React.useState<DatabaseServicesContextValue>({
exerciseService: null, exerciseService: null,
workoutService: null,
templateService: null,
devSeeder: null, devSeeder: null,
publicationQueue: null, publicationQueue: null,
favoritesService: null, favoritesService: null,
@ -64,6 +72,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
// Initialize services // Initialize services
console.log('[DB] Initializing services...'); console.log('[DB] Initializing services...');
const exerciseService = new ExerciseService(db); const exerciseService = new ExerciseService(db);
const workoutService = new WorkoutService(db);
const templateService = new TemplateService(db);
const devSeeder = new DevSeederService(db, exerciseService); const devSeeder = new DevSeederService(db, exerciseService);
const publicationQueue = new PublicationQueueService(db); const publicationQueue = new PublicationQueueService(db);
const favoritesService = new FavoritesService(db); const favoritesService = new FavoritesService(db);
@ -80,6 +90,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
// Set services // Set services
setServices({ setServices({
exerciseService, exerciseService,
workoutService,
templateService,
devSeeder, devSeeder,
publicationQueue, publicationQueue,
favoritesService, favoritesService,
@ -140,6 +152,22 @@ export function useExerciseService() {
return context.exerciseService; 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() { export function useDevSeeder() {
const context = React.useContext(DatabaseServicesContext); const context = React.useContext(DatabaseServicesContext);
if (!context.devSeeder) { if (!context.devSeeder) {

View File

@ -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 (
<View className={cn(
"px-4 py-2 border-b border-border",
status === 'paused' && "bg-muted/50"
)}>
{/* Header Row */}
<View className="flex-row items-center justify-between mb-2">
<Button
variant="ghost"
size="icon"
onPress={handleBack}
>
<ChevronLeft className="text-foreground" />
</Button>
<View className="flex-1 px-4">
<EditableText
value={activeWorkout.title}
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
style={{ alignItems: 'center' }}
placeholder="Workout Title"
/>
</View>
<View className="flex-row gap-2">
{status === 'active' ? (
<Button
variant="ghost"
size="icon"
onPress={pauseWorkout}
>
<Pause className="text-foreground" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
onPress={resumeWorkout}
>
<Play className="text-foreground" />
</Button>
)}
<Button
variant="destructive"
size="icon"
onPress={completeWorkout}
>
<Square className="text-destructive-foreground" />
</Button>
</View>
</View>
{/* Status Row */}
<View className="flex-row items-center justify-between">
<Text className={cn(
"text-2xl font-bold",
status === 'paused' ? "text-muted-foreground" : "text-foreground"
)}>
{formatTime(elapsedTime)}
</Text>
<Text className="text-sm text-muted-foreground capitalize">
{activeWorkout.type}
</Text>
</View>
</View>
);
}

View File

@ -2,7 +2,7 @@
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
export const SCHEMA_VERSION = 1; export const SCHEMA_VERSION = 2; // Increment since we're adding new tables
class Schema { class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> { private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
@ -32,17 +32,8 @@ class Schema {
async createTables(db: SQLiteDatabase): Promise<void> { async createTables(db: SQLiteDatabase): Promise<void> {
try { try {
console.log(`[Schema] Initializing database on ${Platform.OS}`); console.log(`[Schema] Initializing database on ${Platform.OS}`);
const currentVersion = await this.getCurrentVersion(db);
// If we already have the current version, no need to recreate tables // First, ensure schema_version table exists since we need it for version checking
if (currentVersion === SCHEMA_VERSION) {
console.log(`[Schema] Database already at version ${SCHEMA_VERSION}`);
return;
}
console.log(`[Schema] Creating tables for version ${SCHEMA_VERSION}`);
// Schema version tracking
await db.execAsync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version ( CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
@ -50,16 +41,48 @@ class Schema {
); );
`); `);
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) {
console.log(`[Schema] Database already at version ${SCHEMA_VERSION}`);
return;
}
console.log(`[Schema] Upgrading from version ${currentVersion} to ${SCHEMA_VERSION}`);
try {
// Use a transaction to ensure all-or-nothing table creation
await db.withTransactionAsync(async () => {
// Drop all existing tables (except schema_version) // Drop all existing tables (except schema_version)
await this.dropAllTables(db); await this.dropAllTables(db);
// Create all tables in their latest form // Create all tables in their latest form
await this.createAllTables(db); 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 // Update schema version
await this.updateSchemaVersion(db); await this.updateSchemaVersion(db);
console.log(`[Schema] Database initialized at version ${SCHEMA_VERSION}`); console.log('[Schema] Non-transactional schema upgrade completed');
}
} catch (error) { } catch (error) {
console.error('[Schema] Error creating tables:', error); console.error('[Schema] Error creating tables:', error);
throw error; throw error;
@ -67,20 +90,38 @@ class Schema {
} }
private async dropAllTables(db: SQLiteDatabase): Promise<void> { private async dropAllTables(db: SQLiteDatabase): Promise<void> {
try {
console.log('[Schema] Getting list of tables to drop...');
// Get list of all tables excluding schema_version // Get list of all tables excluding schema_version
const tables = await db.getAllAsync<{ name: string }>( const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'" "SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
); );
console.log(`[Schema] Found ${tables.length} tables to drop`);
// Drop each table // Drop each table
for (const { name } of tables) { for (const { name } of tables) {
try {
await db.execAsync(`DROP TABLE IF EXISTS ${name}`); await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
console.log(`[Schema] Dropped table: ${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<void> { private async createAllTables(db: SQLiteDatabase): Promise<void> {
try {
console.log('[Schema] Creating all database tables...');
// Create exercises table // Create exercises table
console.log('[Schema] Creating exercises table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE exercises ( CREATE TABLE exercises (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -99,6 +140,7 @@ class Schema {
`); `);
// Create exercise_tags table // Create exercise_tags table
console.log('[Schema] Creating exercise_tags table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE exercise_tags ( CREATE TABLE exercise_tags (
exercise_id TEXT NOT NULL, exercise_id TEXT NOT NULL,
@ -110,6 +152,7 @@ class Schema {
`); `);
// Create nostr_events table // Create nostr_events table
console.log('[Schema] Creating nostr_events table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE nostr_events ( CREATE TABLE nostr_events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -124,6 +167,7 @@ class Schema {
`); `);
// Create event_tags table // Create event_tags table
console.log('[Schema] Creating event_tags table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE event_tags ( CREATE TABLE event_tags (
event_id TEXT NOT NULL, event_id TEXT NOT NULL,
@ -136,6 +180,7 @@ class Schema {
`); `);
// Create cache metadata table // Create cache metadata table
console.log('[Schema] Creating cache_metadata table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE cache_metadata ( CREATE TABLE cache_metadata (
content_id TEXT PRIMARY KEY, content_id TEXT PRIMARY KEY,
@ -147,6 +192,7 @@ class Schema {
`); `);
// Create media cache table // Create media cache table
console.log('[Schema] Creating exercise_media table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE exercise_media ( CREATE TABLE exercise_media (
exercise_id TEXT NOT NULL, exercise_id TEXT NOT NULL,
@ -159,6 +205,7 @@ class Schema {
`); `);
// Create user profiles table // Create user profiles table
console.log('[Schema] Creating user_profiles table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE user_profiles ( CREATE TABLE user_profiles (
pubkey TEXT PRIMARY KEY, pubkey TEXT PRIMARY KEY,
@ -175,6 +222,7 @@ class Schema {
`); `);
// Create user relays table // Create user relays table
console.log('[Schema] Creating user_relays table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE user_relays ( CREATE TABLE user_relays (
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
@ -188,6 +236,7 @@ class Schema {
`); `);
// Create publication queue table // Create publication queue table
console.log('[Schema] Creating publication_queue table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE publication_queue ( CREATE TABLE publication_queue (
event_id TEXT PRIMARY KEY, event_id TEXT PRIMARY KEY,
@ -201,6 +250,7 @@ class Schema {
`); `);
// Create app status table // Create app status table
console.log('[Schema] Creating app_status table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE app_status ( CREATE TABLE app_status (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@ -210,6 +260,7 @@ class Schema {
`); `);
// Create NDK cache table // Create NDK cache table
console.log('[Schema] Creating ndk_cache table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE ndk_cache ( CREATE TABLE ndk_cache (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -222,6 +273,7 @@ class Schema {
`); `);
// Create favorites table // Create favorites table
console.log('[Schema] Creating favorites table...');
await db.execAsync(` await db.execAsync(`
CREATE TABLE favorites ( CREATE TABLE favorites (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -235,9 +287,116 @@ class Schema {
CREATE INDEX idx_favorites_content_type ON favorites(content_type); CREATE INDEX idx_favorites_content_type ON favorites(content_type);
CREATE INDEX idx_favorites_content_id ON favorites(content_id); 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<void> { private async updateSchemaVersion(db: SQLiteDatabase): Promise<void> {
try {
console.log(`[Schema] Updating schema version to ${SCHEMA_VERSION}`);
// Delete any existing schema version records // Delete any existing schema version records
await db.runAsync('DELETE FROM schema_version'); await db.runAsync('DELETE FROM schema_version');
@ -246,17 +405,29 @@ class Schema {
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[SCHEMA_VERSION, Date.now()] [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<void> { async resetDatabase(db: SQLiteDatabase): Promise<void> {
if (!__DEV__) return; // Only allow in development if (!__DEV__) {
console.log('[Schema] Database reset is only available in development mode');
return;
}
try { try {
console.log('[Schema] Resetting database...'); console.log('[Schema] Resetting database...');
await this.dropAllTables(db); // Clear schema_version to force recreation of all tables
await this.createAllTables(db); await db.execAsync('DROP TABLE IF EXISTS schema_version');
await this.updateSchemaVersion(db); console.log('[Schema] Dropped schema_version table');
// Now create tables from scratch
await this.createTables(db);
console.log('[Schema] Database reset complete'); console.log('[Schema] Database reset complete');
} catch (error) { } catch (error) {

View File

@ -1,13 +1,21 @@
// lib/db/services/DevSeederService.ts // lib/db/services/DevSeederService.ts
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { ExerciseService } from './ExerciseService'; import { ExerciseService } from './ExerciseService';
import { EventCache } from '@/lib/db/services/EventCache';
import { WorkoutService } from './WorkoutService';
import { TemplateService } from './TemplateService';
import { logDatabaseInfo } from '../debug'; import { logDatabaseInfo } from '../debug';
import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises'; import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises';
import { DbService } from '../db-service';
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
export class DevSeederService { export class DevSeederService {
private db: SQLiteDatabase; private db: SQLiteDatabase;
private dbService: DbService;
private exerciseService: ExerciseService; private exerciseService: ExerciseService;
private workoutService: WorkoutService | null = null;
private templateService: TemplateService | null = null;
private eventCache: EventCache | null = null;
private ndk: NDK | null = null; private ndk: NDK | null = null;
constructor( constructor(
@ -15,7 +23,17 @@ export class DevSeederService {
exerciseService: ExerciseService exerciseService: ExerciseService
) { ) {
this.db = db; this.db = db;
this.dbService = new DbService(db);
this.exerciseService = exerciseService; 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) { setNDK(ndk: NDK) {
@ -36,9 +54,7 @@ export class DevSeederService {
if (existingCount > 0) { if (existingCount > 0) {
console.log('Database already seeded with', existingCount, 'exercises'); console.log('Database already seeded with', existingCount, 'exercises');
return; } else {
}
// Start transaction for all seeding operations // Start transaction for all seeding operations
await this.db.withTransactionAsync(async () => { await this.db.withTransactionAsync(async () => {
console.log('Seeding mock exercises...'); console.log('Seeding mock exercises...');
@ -51,7 +67,6 @@ export class DevSeederService {
Object.assign(event, eventData); Object.assign(event, eventData);
// Cache the event in NDK // Cache the event in NDK
if (this.ndk) {
const ndkEvent = new NDKEvent(this.ndk); const ndkEvent = new NDKEvent(this.ndk);
// Copy event properties // Copy event properties
@ -70,6 +85,14 @@ export class DevSeederService {
await ndkEvent.sign(); 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 // Create exercise from the mock data regardless of NDK availability
@ -79,6 +102,11 @@ export class DevSeederService {
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 // Log final database state
await logDatabaseInfo(); 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() { async clearDatabase() {
if (!__DEV__) return; if (!__DEV__) return;
@ -97,16 +189,29 @@ export class DevSeederService {
await this.db.withTransactionAsync(async () => { await this.db.withTransactionAsync(async () => {
const tables = [ const tables = [
// Original tables
'exercises', 'exercises',
'exercise_tags', 'exercise_tags',
'nostr_events', 'nostr_events',
'event_tags', 'event_tags',
'cache_metadata', '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) { for (const table of tables) {
try {
await this.db.runAsync(`DELETE FROM ${table}`); await this.db.runAsync(`DELETE FROM ${table}`);
console.log(`Cleared table: ${table}`);
} catch (error) {
console.log(`Table ${table} might not exist yet, skipping`);
}
} }
}); });

View File

@ -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<void> {
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<NostrEvent | null> {
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;
}
}
}

View File

@ -1,7 +1,10 @@
// lib/services/NostrWorkoutService.ts - updated // lib/services/NostrWorkoutService.ts
import { Workout } from '@/types/workout'; import { Workout, WorkoutExercise, WorkoutSet } from '@/types/workout';
import { WorkoutTemplate, TemplateExerciseConfig } from '@/types/templates';
import { NostrEvent } from '@/types/nostr'; 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 * Service for creating and handling Nostr workout events
*/ */
@ -10,60 +13,14 @@ export class NostrWorkoutService {
* Creates a complete Nostr workout event with all details * Creates a complete Nostr workout event with all details
*/ */
static createCompleteWorkoutEvent(workout: Workout): NostrEvent { static createCompleteWorkoutEvent(workout: Workout): NostrEvent {
return { return this.workoutToNostrEvent(workout, false);
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)
};
} }
/** /**
* Creates a limited Nostr workout event with reduced metrics for privacy * Creates a limited Nostr workout event with reduced metrics for privacy
*/ */
static createLimitedWorkoutEvent(workout: Workout): NostrEvent { static createLimitedWorkoutEvent(workout: Workout): NostrEvent {
return { return this.workoutToNostrEvent(workout, true);
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)
};
} }
/** /**
@ -83,4 +40,371 @@ export class NostrWorkoutService {
created_at: Math.floor(Date.now() / 1000) 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<Workout> = {
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<string, string[][]>();
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<WorkoutExercise> = {
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<WorkoutSet> = {
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<WorkoutTemplate> = {
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<TemplateExerciseConfig> = {
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] || ''
};
}
} }

View File

@ -1,50 +1,474 @@
// lib/db/services/TemplateService.ts // lib/db/services/TemplateService.ts
import { SQLiteDatabase, openDatabaseSync } from 'expo-sqlite';
import {
WorkoutTemplate,
TemplateExerciseConfig
} from '@/types/templates';
import { Workout } from '@/types/workout'; import { Workout } from '@/types/workout';
import { WorkoutTemplate } from '@/types/templates';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
import { DbService } from '../db-service';
/**
* Service for managing workout templates
*/
export class TemplateService { 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<boolean> { async getAllTemplates(limit: number = 50, offset: number = 0): Promise<WorkoutTemplate[]> {
try { try {
// This would require actual implementation with DB access const templates = await this.db.getAllAsync<{
// For now, this is a placeholder id: string;
console.log('Updating template from workout:', workout.id); title: string;
// Future: Implement with your DB service type: string;
return true; 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) { } catch (error) {
console.error('Error updating template:', error); console.error('Error getting templates:', error);
return false; return [];
} }
} }
/** /**
* Saves a workout as a new template * Get a template by ID
*/ */
static async saveAsNewTemplate(workout: Workout, name: string): Promise<string | null> { async getTemplate(id: string): Promise<WorkoutTemplate | null> {
try { try {
// This would require actual implementation with DB access const template = await this.db.getFirstAsync<{
// For now, this is a placeholder id: string;
console.log('Creating new template from workout:', workout.id, 'with name:', name); title: string;
// Future: Implement with your DB service type: string;
return generateId(); 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) { } catch (error) {
console.error('Error creating template:', error); console.error('Error getting template:', error);
return null; return null;
} }
} }
/** /**
* Detects if a workout has changes compared to its original template * Create a new template
*/ */
async createTemplate(template: Omit<WorkoutTemplate, 'id'>): Promise<string> {
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<WorkoutTemplate>): Promise<void> {
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<void> {
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<void> {
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<TemplateExerciseConfig[]> {
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<boolean> {
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<string | null> {
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 { static hasTemplateChanges(workout: Workout): boolean {
// This would require comparing with the original template // Simple implementation - in a real app, you'd compare with the original template
// For now, assume changes if there's a templateId return workout.templateId !== undefined;
return !!workout.templateId;
} }
} }

View File

@ -3,6 +3,7 @@ import { SQLiteDatabase } from 'expo-sqlite';
import { Workout } from '@/types/workout'; import { Workout } from '@/types/workout';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DbService } from '../db-service'; import { DbService } from '../db-service';
import { WorkoutExercise } from '@/types/exercise'; // Add this import
export class WorkoutHistoryService { export class WorkoutHistoryService {
private db: DbService; private db: DbService;
@ -21,21 +22,47 @@ export class WorkoutHistoryService {
title: string; title: string;
type: string; type: string;
start_time: number; start_time: number;
end_time: number; end_time: number | null;
is_completed: number; is_completed: number;
created_at: number; created_at: number;
last_updated: number; updated_at: number;
template_id: string | null; template_id: string | null;
total_volume: number | null; total_volume: number | null;
total_reps: number | null; total_reps: number | null;
source: string; source: string;
notes: string | null;
}>( }>(
`SELECT * FROM workouts `SELECT * FROM workouts
ORDER BY start_time DESC` ORDER BY start_time DESC`
); );
// Transform database records to Workout objects // 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) { } catch (error) {
console.error('Error getting workouts:', error); console.error('Error getting workouts:', error);
throw error; throw error;
@ -55,14 +82,15 @@ export class WorkoutHistoryService {
title: string; title: string;
type: string; type: string;
start_time: number; start_time: number;
end_time: number; end_time: number | null;
is_completed: number; is_completed: number;
created_at: number; created_at: number;
last_updated: number; updated_at: number;
template_id: string | null; template_id: string | null;
total_volume: number | null; total_volume: number | null;
total_reps: number | null; total_reps: number | null;
source: string; source: string;
notes: string | null;
}>( }>(
`SELECT * FROM workouts `SELECT * FROM workouts
WHERE start_time >= ? AND start_time <= ? WHERE start_time >= ? AND start_time <= ?
@ -70,7 +98,32 @@ export class WorkoutHistoryService {
[startOfDay, endOfDay] [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) { } catch (error) {
console.error('Error getting workouts by date:', error); console.error('Error getting workouts by date:', error);
throw error; throw error;
@ -88,7 +141,8 @@ export class WorkoutHistoryService {
const result = await this.db.getAllAsync<{ const result = await this.db.getAllAsync<{
start_time: number; 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 <= ?`, WHERE start_time >= ? AND start_time <= ?`,
[startOfMonth, endOfMonth] [startOfMonth, endOfMonth]
); );
@ -110,14 +164,15 @@ export class WorkoutHistoryService {
title: string; title: string;
type: string; type: string;
start_time: number; start_time: number;
end_time: number; end_time: number | null;
is_completed: number; is_completed: number;
created_at: number; created_at: number;
last_updated: number; updated_at: number;
template_id: string | null; template_id: string | null;
total_volume: number | null; total_volume: number | null;
total_reps: number | null; total_reps: number | null;
source: string; source: string;
notes: string | null;
}>( }>(
`SELECT * FROM workouts WHERE id = ?`, `SELECT * FROM workouts WHERE id = ?`,
[workoutId] [workoutId]
@ -126,18 +181,26 @@ export class WorkoutHistoryService {
if (!workout) return null; if (!workout) return null;
// Get exercises for this workout // Get exercises for this workout
// This is just a skeleton - you'll need to implement the actual query const exercises = await this.getWorkoutExercises(workoutId);
// based on your database schema
const exercises = await this.db.getAllAsync(
`SELECT * FROM workout_exercises WHERE workout_id = ?`,
[workoutId]
);
const workoutObj = this.mapRowToWorkout(workout); return {
// You would set the exercises property here based on your schema id: workout.id,
// workoutObj.exercises = exercises.map(...); title: workout.title,
type: workout.type as any,
return workoutObj; 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) { } catch (error) {
console.error('Error getting workout details:', error); console.error('Error getting workout details:', error);
throw error; throw error;
@ -160,39 +223,96 @@ export class WorkoutHistoryService {
} }
} }
/** // Helper method to load workout exercises and sets
* Helper method to map a database row to a Workout object private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
*/ try {
private mapRowToWorkout(row: { const exercises = await this.db.getAllAsync<{
id: string; 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; title: string;
type: string; type: string;
start_time: number; category: string;
end_time: number; 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; is_completed: number;
completed_at: number | null;
created_at: number; created_at: number;
last_updated?: number; updated_at: number;
template_id?: string | null; }>(
total_volume?: number | null; `SELECT * FROM workout_sets
total_reps?: number | null; WHERE workout_exercise_id = ?
source: string; ORDER BY id`,
}): Workout { [exercise.id]
return { );
id: row.id,
title: row.title, // Map sets to the correct format
type: row.type as any, // Cast to TemplateType const mappedSets = sets.map(set => ({
startTime: row.start_time, id: set.id,
endTime: row.end_time, type: set.type as any,
isCompleted: row.is_completed === 1, weight: set.weight || undefined,
created_at: row.created_at, reps: set.reps || undefined,
lastUpdated: row.last_updated, rpe: set.rpe || undefined,
templateId: row.template_id || undefined, duration: set.duration || undefined,
totalVolume: row.total_volume || undefined, isCompleted: Boolean(set.is_completed),
totalReps: row.total_reps || undefined, completedAt: set.completed_at || undefined,
availability: { lastUpdated: set.updated_at
source: [row.source as any] // Cast to StorageSource }));
},
exercises: [] // Exercises would be loaded separately 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 [];
}
} }
} }

View File

@ -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<void> {
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<Workout | null> {
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<Workout[]> {
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<Workout[]> {
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<void> {
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<void> {
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<number[]> {
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<void> {
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<WorkoutExercise[]> {
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<WorkoutSet[]> {
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<void> {
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<void> {
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
]
);
}
}
}
}
}

84
lib/hooks/useTemplates.ts Normal file
View File

@ -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<WorkoutTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<WorkoutTemplate, 'id'>) => {
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<WorkoutTemplate>) => {
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
};
}

108
lib/hooks/useWorkouts.ts Normal file
View File

@ -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<Workout[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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
};
}

View File

@ -1,20 +1,4 @@
// stores/workoutStore.ts // 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 { create } from 'zustand';
import { createSelectors } from '@/utils/createSelectors'; import { createSelectors } from '@/utils/createSelectors';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
@ -40,9 +24,26 @@ import { router } from 'expo-router';
import { useNDKStore } from '@/lib/stores/ndk'; import { useNDKStore } from '@/lib/stores/ndk';
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
import { TemplateService } from '@/lib//db/services/TemplateService'; import { TemplateService } from '@/lib//db/services/TemplateService';
import { WorkoutService } from '@/lib/db/services/WorkoutService'; // Add this import
import { NostrEvent } from '@/types/nostr'; import { NostrEvent } from '@/types/nostr';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; 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 const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
// Define a module-level timer reference for the workout timer // Define a module-level timer reference for the workout timer
@ -810,26 +811,23 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
})); }));
// Helper functions // Helper functions
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> { async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
try { try {
// Try to get it from favorites in the database // Try to get it from favorites in the database
const db = openDatabaseSync('powr.db'); const db = openDatabaseSync('powr.db');
const favoritesService = new FavoritesService(db);
const templateService = new TemplateService(db);
const result = await db.getFirstAsync<{ // First try to get from favorites
content: string const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId);
}>( if (favoriteResult) {
`SELECT content FROM favorites WHERE content_type = 'template' AND content_id = ?`, return favoriteResult;
[templateId]
);
if (result && result.content) {
return JSON.parse(result.content);
} }
// If not found in favorites, could implement fetching from template database // If not in favorites, try the templates table
// Example: return await db.getTemplate(templateId); const templateResult = await templateService.getTemplate(templateId);
console.log('Template not found in favorites:', templateId); return templateResult;
return null;
} catch (error) { } catch (error) {
console.error('Error fetching template:', error); console.error('Error fetching template:', error);
return null; return null;
@ -838,14 +836,31 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
async function saveWorkout(workout: Workout): Promise<void> { async function saveWorkout(workout: Workout): Promise<void> {
try { try {
// Make sure we're capturing the duration properly in what's saved
console.log('Saving workout with endTime:', workout.endTime); 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) { } catch (error) {
console.error('Error saving workout:', 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 { function calculateWorkoutSummary(workout: Workout): WorkoutSummary {
return { return {
id: generateId('local'), id: generateId('local'),
@ -891,11 +906,6 @@ function calculateAverageRpe(workout: Workout): number {
return totalRpe / rpeSets.length; return totalRpe / rpeSets.length;
} }
async function saveSummary(summary: WorkoutSummary) {
// TODO: Implement summary saving
console.log('Saving summary:', summary);
}
// Create auto-generated selectors // Create auto-generated selectors
export const useWorkoutStore = createSelectors(useWorkoutStoreBase); export const useWorkoutStore = createSelectors(useWorkoutStoreBase);

View File

@ -77,6 +77,9 @@ export interface WorkoutSet {
isCompleted: boolean; isCompleted: boolean;
notes?: string; notes?: string;
timestamp?: number; timestamp?: number;
duration?: number; // Add this property
completedAt?: number;
lastUpdated?: number;
} }
/** /**
@ -84,6 +87,7 @@ export interface WorkoutSet {
*/ */
export interface WorkoutExercise extends BaseExercise { export interface WorkoutExercise extends BaseExercise {
sets: WorkoutSet[]; sets: WorkoutSet[];
exerciseId?: string;
targetSets?: number; targetSets?: number;
targetReps?: number; targetReps?: number;
notes?: string; notes?: string;

View File

@ -31,9 +31,11 @@ export interface TemplateExerciseDisplay {
} }
export interface TemplateExerciseConfig { export interface TemplateExerciseConfig {
id?: string; // Add this line
exercise: BaseExercise; exercise: BaseExercise;
targetSets: number; targetSets?: number;
targetReps: number; targetReps?: number;
targetWeight?: number;
weight?: number; weight?: number;
rpe?: number; rpe?: number;
setType?: SetType; setType?: SetType;
@ -122,6 +124,8 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
exercises: TemplateExerciseConfig[]; exercises: TemplateExerciseConfig[];
isPublic: boolean; isPublic: boolean;
version: number; version: number;
lastUpdated?: number; // Add this line
parentId?: string; // Add this line
// Template configuration // Template configuration
format?: { format?: {
@ -184,8 +188,8 @@ export function toTemplateDisplay(template: WorkoutTemplate): Template {
description: template.description, description: template.description,
exercises: template.exercises.map(ex => ({ exercises: template.exercises.map(ex => ({
title: ex.exercise.title, title: ex.exercise.title,
targetSets: ex.targetSets, targetSets: ex.targetSets || 0, // Add default value
targetReps: ex.targetReps, targetReps: ex.targetReps || 0, // Add default value
notes: ex.notes notes: ex.notes
})), })),
tags: template.tags, tags: template.tags,
@ -261,8 +265,8 @@ export function createNostrTemplateEvent(template: WorkoutTemplate) {
...template.exercises.map(ex => [ ...template.exercises.map(ex => [
'exercise', 'exercise',
`33401:${ex.exercise.id}`, `33401:${ex.exercise.id}`,
ex.targetSets.toString(), (ex.targetSets || 0).toString(),
ex.targetReps.toString(), (ex.targetReps || 0).toString(),
ex.setType || 'normal' ex.setType || 'normal'
]), ]),
...template.tags.map(tag => ['t', tag]) ...template.tags.map(tag => ['t', tag])

View File

@ -23,12 +23,14 @@ export interface WorkoutSet {
timestamp?: number; timestamp?: number;
lastUpdated?: number; lastUpdated?: number;
completedAt?: number; completedAt?: number;
duration?: number;
} }
/** /**
* Exercise within a workout * Exercise within a workout
*/ */
export interface WorkoutExercise extends BaseExercise { export interface WorkoutExercise extends BaseExercise {
exerciseId?: string;
sets: WorkoutSet[]; sets: WorkoutSet[];
targetSets?: number; targetSets?: number;
targetReps?: number; targetReps?: number;
@ -53,12 +55,16 @@ export interface Workout extends SyncableContent {
tags?: string[]; tags?: string[];
// Template reference if workout was started from template // 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 // Workout configuration
rounds?: number; rounds?: number;
duration?: number; // Total duration in seconds duration?: number;
interval?: number; // For EMOM/interval workouts interval?: number;
restBetweenRounds?: number; restBetweenRounds?: number;
// Workout metrics // Workout metrics

65
utils/converter.ts Normal file
View File

@ -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);
}

30
utils/nostr-utils.ts Normal file
View File

@ -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] || ''
};
}

View File

@ -23,7 +23,7 @@ export function convertTemplateToWorkout(template: WorkoutTemplate) {
}, },
created_at: now, created_at: now,
// Create the specified number of sets from template // 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'), id: generateId('local'),
weight: 0, // Start empty, but could use last workout weight weight: 0, // Start empty, but could use last workout weight
reps: templateExercise.targetReps, // Use target reps from template reps: templateExercise.targetReps, // Use target reps from template