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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# Changelog - March 8, 2025
## Added
- Database schema upgrade to version 5
- Added workouts, workout_exercises, and workout_sets tables
- Added templates and template_exercises tables
- Added publication_queue table for offline-first functionality
- Added app_status table for connectivity tracking
- New database services
- WorkoutService for managing workout data persistence
- Enhanced TemplateService for template management
- NostrWorkoutService for Nostr event conversion
- Updated PublicationQueueService for offline publishing
- React hooks for database access
- useWorkouts hook for workout operations
- useTemplates hook for template operations
- Improved workout completion flow
- Three-tier storage approach (Local Only, Publish Complete, Publish Limited)
- Template modification options (keep original, update, save as new)
- Enhanced social sharing capabilities
- Detailed workout summary with statistics
- Enhanced database debugging tools
- Added proper error handling and logging
- Improved transaction management
- Added connectivity status tracking
## Fixed
- Missing workout and template table errors
- Incomplete data storage issues
- Template management synchronization
- Nostr event conversion between app models and Nostr protocol
- Workout persistence across app sessions
- Database transaction handling in workout operations
- Template reference handling in workout records
## Improved
- Workout store persistence layer
- Enhanced integration with database services
- Better error handling for database operations
- Improved Nostr connectivity detection
- Template management workflow
- Proper versioning and attribution
- Enhanced modification tracking
- Better user control over template sharing
- Overall data persistence architecture
- Consistent service-based approach
- Improved type safety
- Enhanced error propagation
# Changelog - March 6, 2025
## Added

View File

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

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 { Platform } from 'react-native';
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2; // Increment since we're adding new tables
class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
@ -32,7 +32,17 @@ class Schema {
async createTables(db: SQLiteDatabase): Promise<void> {
try {
console.log(`[Schema] Initializing database on ${Platform.OS}`);
// First, ensure schema_version table exists since we need it for version checking
await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
updated_at INTEGER NOT NULL
);
`);
const currentVersion = await this.getCurrentVersion(db);
console.log(`[Schema] Current version: ${currentVersion}, Target version: ${SCHEMA_VERSION}`);
// If we already have the current version, no need to recreate tables
if (currentVersion === SCHEMA_VERSION) {
@ -40,26 +50,39 @@ class Schema {
return;
}
console.log(`[Schema] Creating tables for version ${SCHEMA_VERSION}`);
console.log(`[Schema] Upgrading from version ${currentVersion} to ${SCHEMA_VERSION}`);
// Schema version tracking
await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
updated_at INTEGER NOT NULL
);
`);
// Drop all existing tables (except schema_version)
await this.dropAllTables(db);
// Create all tables in their latest form
await this.createAllTables(db);
// Update schema version
await this.updateSchemaVersion(db);
console.log(`[Schema] Database initialized at version ${SCHEMA_VERSION}`);
try {
// Use a transaction to ensure all-or-nothing table creation
await db.withTransactionAsync(async () => {
// Drop all existing tables (except schema_version)
await this.dropAllTables(db);
// Create all tables in their latest form
await this.createAllTables(db);
// Update schema version at the end of the transaction
await this.updateSchemaVersion(db);
});
console.log(`[Schema] Database successfully upgraded to version ${SCHEMA_VERSION}`);
} catch (txError) {
console.error('[Schema] Transaction error during schema upgrade:', txError);
// If transaction failed, try a non-transactional approach as fallback
console.log('[Schema] Attempting non-transactional schema upgrade...');
// Drop all existing tables except schema_version
await this.dropAllTables(db);
// Create all tables in their latest form
await this.createAllTables(db);
// Update schema version
await this.updateSchemaVersion(db);
console.log('[Schema] Non-transactional schema upgrade completed');
}
} catch (error) {
console.error('[Schema] Error creating tables:', error);
throw error;
@ -67,196 +90,344 @@ class Schema {
}
private async dropAllTables(db: SQLiteDatabase): Promise<void> {
// Get list of all tables excluding schema_version
const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
);
// Drop each table
for (const { name } of tables) {
await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
console.log(`[Schema] Dropped table: ${name}`);
try {
console.log('[Schema] Getting list of tables to drop...');
// Get list of all tables excluding schema_version
const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
);
console.log(`[Schema] Found ${tables.length} tables to drop`);
// Drop each table
for (const { name } of tables) {
try {
await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
console.log(`[Schema] Dropped table: ${name}`);
} catch (dropError) {
console.error(`[Schema] Error dropping table ${name}:`, dropError);
// Continue with other tables even if one fails
}
}
} catch (error) {
console.error('[Schema] Error in dropAllTables:', error);
throw error;
}
}
private async createAllTables(db: SQLiteDatabase): Promise<void> {
// Create exercises table
await db.execAsync(`
CREATE TABLE exercises (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')),
category TEXT NOT NULL,
equipment TEXT,
description TEXT,
format_json TEXT,
format_units_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'local',
nostr_event_id TEXT
);
`);
try {
console.log('[Schema] Creating all database tables...');
// Create exercises table
console.log('[Schema] Creating exercises table...');
await db.execAsync(`
CREATE TABLE exercises (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')),
category TEXT NOT NULL,
equipment TEXT,
description TEXT,
format_json TEXT,
format_units_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'local',
nostr_event_id TEXT
);
`);
// Create exercise_tags table
await db.execAsync(`
CREATE TABLE exercise_tags (
exercise_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
UNIQUE(exercise_id, tag)
);
CREATE INDEX idx_exercise_tags ON exercise_tags(tag);
`);
// Create nostr_events table
await db.execAsync(`
CREATE TABLE nostr_events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
sig TEXT,
raw_event TEXT NOT NULL,
received_at INTEGER NOT NULL
);
`);
// Create exercise_tags table
console.log('[Schema] Creating exercise_tags table...');
await db.execAsync(`
CREATE TABLE exercise_tags (
exercise_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
UNIQUE(exercise_id, tag)
);
CREATE INDEX idx_exercise_tags ON exercise_tags(tag);
`);
// Create nostr_events table
console.log('[Schema] Creating nostr_events table...');
await db.execAsync(`
CREATE TABLE nostr_events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
sig TEXT,
raw_event TEXT NOT NULL,
received_at INTEGER NOT NULL
);
`);
// Create event_tags table
await db.execAsync(`
CREATE TABLE event_tags (
event_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
index_num INTEGER NOT NULL,
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
);
CREATE INDEX idx_event_tags ON event_tags(name, value);
`);
// Create cache metadata table
await db.execAsync(`
CREATE TABLE cache_metadata (
content_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
last_accessed INTEGER NOT NULL,
access_count INTEGER NOT NULL DEFAULT 0,
cache_priority INTEGER NOT NULL DEFAULT 0
);
`);
// Create event_tags table
console.log('[Schema] Creating event_tags table...');
await db.execAsync(`
CREATE TABLE event_tags (
event_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
index_num INTEGER NOT NULL,
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
);
CREATE INDEX idx_event_tags ON event_tags(name, value);
`);
// Create cache metadata table
console.log('[Schema] Creating cache_metadata table...');
await db.execAsync(`
CREATE TABLE cache_metadata (
content_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
last_accessed INTEGER NOT NULL,
access_count INTEGER NOT NULL DEFAULT 0,
cache_priority INTEGER NOT NULL DEFAULT 0
);
`);
// Create media cache table
await db.execAsync(`
CREATE TABLE exercise_media (
exercise_id TEXT NOT NULL,
media_type TEXT NOT NULL,
content BLOB NOT NULL,
thumbnail BLOB,
created_at INTEGER NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);
`);
// Create user profiles table
await db.execAsync(`
CREATE TABLE user_profiles (
pubkey TEXT PRIMARY KEY,
name TEXT,
display_name TEXT,
about TEXT,
website TEXT,
picture TEXT,
nip05 TEXT,
lud16 TEXT,
last_updated INTEGER
);
CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC);
`);
// Create media cache table
console.log('[Schema] Creating exercise_media table...');
await db.execAsync(`
CREATE TABLE exercise_media (
exercise_id TEXT NOT NULL,
media_type TEXT NOT NULL,
content BLOB NOT NULL,
thumbnail BLOB,
created_at INTEGER NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);
`);
// Create user profiles table
console.log('[Schema] Creating user_profiles table...');
await db.execAsync(`
CREATE TABLE user_profiles (
pubkey TEXT PRIMARY KEY,
name TEXT,
display_name TEXT,
about TEXT,
website TEXT,
picture TEXT,
nip05 TEXT,
lud16 TEXT,
last_updated INTEGER
);
CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC);
`);
// Create user relays table
await db.execAsync(`
CREATE TABLE user_relays (
pubkey TEXT NOT NULL,
relay_url TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT 1,
write BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
PRIMARY KEY (pubkey, relay_url),
FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE
);
`);
// Create publication queue table
await db.execAsync(`
CREATE TABLE publication_queue (
event_id TEXT PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_attempt INTEGER,
payload TEXT NOT NULL,
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
);
CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC);
`);
// Create user relays table
console.log('[Schema] Creating user_relays table...');
await db.execAsync(`
CREATE TABLE user_relays (
pubkey TEXT NOT NULL,
relay_url TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT 1,
write BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
PRIMARY KEY (pubkey, relay_url),
FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE
);
`);
// Create publication queue table
console.log('[Schema] Creating publication_queue table...');
await db.execAsync(`
CREATE TABLE publication_queue (
event_id TEXT PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_attempt INTEGER,
payload TEXT NOT NULL,
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
);
CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC);
`);
// Create app status table
await db.execAsync(`
CREATE TABLE app_status (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Create NDK cache table
await db.execAsync(`
CREATE TABLE ndk_cache (
id TEXT PRIMARY KEY,
event TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL
);
CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind);
CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at);
`);
// Create favorites table
await db.execAsync(`
CREATE TABLE favorites (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content_id TEXT NOT NULL,
content TEXT NOT NULL,
pubkey TEXT,
created_at INTEGER NOT NULL,
UNIQUE(content_type, content_id)
);
CREATE INDEX idx_favorites_content_type ON favorites(content_type);
CREATE INDEX idx_favorites_content_id ON favorites(content_id);
`);
// Create app status table
console.log('[Schema] Creating app_status table...');
await db.execAsync(`
CREATE TABLE app_status (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Create NDK cache table
console.log('[Schema] Creating ndk_cache table...');
await db.execAsync(`
CREATE TABLE ndk_cache (
id TEXT PRIMARY KEY,
event TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL
);
CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind);
CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at);
`);
// Create favorites table
console.log('[Schema] Creating favorites table...');
await db.execAsync(`
CREATE TABLE favorites (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content_id TEXT NOT NULL,
content TEXT NOT NULL,
pubkey TEXT,
created_at INTEGER NOT NULL,
UNIQUE(content_type, content_id)
);
CREATE INDEX idx_favorites_content_type ON favorites(content_type);
CREATE INDEX idx_favorites_content_id ON favorites(content_id);
`);
// === NEW TABLES === //
// Create workouts table
console.log('[Schema] Creating workouts table...');
await db.execAsync(`
CREATE TABLE workouts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER,
is_completed BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
template_id TEXT,
nostr_event_id TEXT,
share_status TEXT NOT NULL DEFAULT 'local',
source TEXT NOT NULL DEFAULT 'local',
total_volume REAL,
total_reps INTEGER,
notes TEXT
);
CREATE INDEX idx_workouts_start_time ON workouts(start_time);
CREATE INDEX idx_workouts_template_id ON workouts(template_id);
`);
// Create workout_exercises table
console.log('[Schema] Creating workout_exercises table...');
await db.execAsync(`
CREATE TABLE workout_exercises (
id TEXT PRIMARY KEY,
workout_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
display_order INTEGER NOT NULL,
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(workout_id) REFERENCES workouts(id) ON DELETE CASCADE
);
CREATE INDEX idx_workout_exercises_workout_id ON workout_exercises(workout_id);
`);
// Create workout_sets table
console.log('[Schema] Creating workout_sets table...');
await db.execAsync(`
CREATE TABLE workout_sets (
id TEXT PRIMARY KEY,
workout_exercise_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'normal',
weight REAL,
reps INTEGER,
rpe REAL,
duration INTEGER,
is_completed BOOLEAN NOT NULL DEFAULT 0,
completed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(workout_exercise_id) REFERENCES workout_exercises(id) ON DELETE CASCADE
);
CREATE INDEX idx_workout_sets_exercise_id ON workout_sets(workout_exercise_id);
`);
// Create templates table
console.log('[Schema] Creating templates table...');
await db.execAsync(`
CREATE TABLE templates (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
nostr_event_id TEXT,
source TEXT NOT NULL DEFAULT 'local',
parent_id TEXT
);
CREATE INDEX idx_templates_updated_at ON templates(updated_at);
`);
// Create template_exercises table
console.log('[Schema] Creating template_exercises table...');
await db.execAsync(`
CREATE TABLE template_exercises (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
display_order INTEGER NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_weight REAL,
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE INDEX idx_template_exercises_template_id ON template_exercises(template_id);
`);
console.log('[Schema] All tables created successfully');
} catch (error) {
console.error('[Schema] Error in createAllTables:', error);
throw error;
}
}
private async updateSchemaVersion(db: SQLiteDatabase): Promise<void> {
// Delete any existing schema version records
await db.runAsync('DELETE FROM schema_version');
// Insert the current version
await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[SCHEMA_VERSION, Date.now()]
);
try {
console.log(`[Schema] Updating schema version to ${SCHEMA_VERSION}`);
// Delete any existing schema version records
await db.runAsync('DELETE FROM schema_version');
// Insert the current version
await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[SCHEMA_VERSION, Date.now()]
);
console.log('[Schema] Schema version updated successfully');
} catch (error) {
console.error('[Schema] Error updating schema version:', error);
throw error;
}
}
async resetDatabase(db: SQLiteDatabase): Promise<void> {
if (!__DEV__) return; // Only allow in development
if (!__DEV__) {
console.log('[Schema] Database reset is only available in development mode');
return;
}
try {
console.log('[Schema] Resetting database...');
await this.dropAllTables(db);
await this.createAllTables(db);
await this.updateSchemaVersion(db);
// Clear schema_version to force recreation of all tables
await db.execAsync('DROP TABLE IF EXISTS schema_version');
console.log('[Schema] Dropped schema_version table');
// Now create tables from scratch
await this.createTables(db);
console.log('[Schema] Database reset complete');
} catch (error) {

View File

@ -1,13 +1,21 @@
// lib/db/services/DevSeederService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { ExerciseService } from './ExerciseService';
import { EventCache } from '@/lib/db/services/EventCache';
import { WorkoutService } from './WorkoutService';
import { TemplateService } from './TemplateService';
import { logDatabaseInfo } from '../debug';
import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises';
import { DbService } from '../db-service';
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
export class DevSeederService {
private db: SQLiteDatabase;
private dbService: DbService;
private exerciseService: ExerciseService;
private workoutService: WorkoutService | null = null;
private templateService: TemplateService | null = null;
private eventCache: EventCache | null = null;
private ndk: NDK | null = null;
constructor(
@ -15,7 +23,17 @@ export class DevSeederService {
exerciseService: ExerciseService
) {
this.db = db;
this.dbService = new DbService(db);
this.exerciseService = exerciseService;
// Try to initialize other services if needed
try {
this.workoutService = new WorkoutService(db);
this.templateService = new TemplateService(db);
this.eventCache = new EventCache(db);
} catch (error) {
console.log('Some services not available yet:', error);
}
}
setNDK(ndk: NDK) {
@ -36,22 +54,19 @@ export class DevSeederService {
if (existingCount > 0) {
console.log('Database already seeded with', existingCount, 'exercises');
return;
}
// Start transaction for all seeding operations
await this.db.withTransactionAsync(async () => {
console.log('Seeding mock exercises...');
// Process all events within the same transaction
for (const eventData of mockExerciseEvents) {
if (this.ndk) {
// If NDK is available, use it to cache the event
const event = new NDKEvent(this.ndk);
Object.assign(event, eventData);
// Cache the event in NDK
} else {
// Start transaction for all seeding operations
await this.db.withTransactionAsync(async () => {
console.log('Seeding mock exercises...');
// Process all events within the same transaction
for (const eventData of mockExerciseEvents) {
if (this.ndk) {
// If NDK is available, use it to cache the event
const event = new NDKEvent(this.ndk);
Object.assign(event, eventData);
// Cache the event in NDK
const ndkEvent = new NDKEvent(this.ndk);
// Copy event properties
@ -70,15 +85,28 @@ export class DevSeederService {
await ndkEvent.sign();
}
}
// Cache the event if possible
if (this.eventCache) {
try {
await this.eventCache.setEvent(eventData, true);
} catch (error) {
console.log('Error caching event:', error);
}
}
// Create exercise from the mock data regardless of NDK availability
const exercise = convertNostrToExercise(eventData);
await this.exerciseService.createExercise(exercise, true);
}
// Create exercise from the mock data regardless of NDK availability
const exercise = convertNostrToExercise(eventData);
await this.exerciseService.createExercise(exercise, true);
}
console.log('Successfully seeded', mockExerciseEvents.length, 'exercises');
});
}
console.log('Successfully seeded', mockExerciseEvents.length, 'exercises');
});
// Seed workout and template tables
await this.seedWorkoutTables();
await this.seedTemplates();
// Log final database state
await logDatabaseInfo();
@ -89,6 +117,70 @@ export class DevSeederService {
}
}
async seedWorkoutTables() {
if (!__DEV__) return;
try {
console.log('Checking workout tables seeding...');
// Check if we already have workout data
try {
const hasWorkouts = await this.dbService.getFirstAsync<{ count: number }>(
'SELECT COUNT(*) as count FROM workouts'
);
if (hasWorkouts && hasWorkouts.count > 0) {
console.log('Workout tables already seeded with', hasWorkouts.count, 'workouts');
return;
}
console.log('No workout data found, but tables should be created');
// Optional: Add mock workout data here
// if (this.workoutService) {
// // Create mock workouts
// // await this.workoutService.saveWorkout(mockWorkout);
// }
} catch (error) {
console.log('Workout tables may not exist yet - will be created in schema update');
}
} catch (error) {
console.error('Error checking workout tables:', error);
}
}
async seedTemplates() {
if (!__DEV__) return;
try {
console.log('Checking template tables seeding...');
// Check if templates table exists and has data
try {
const hasTemplates = await this.dbService.getFirstAsync<{ count: number }>(
'SELECT COUNT(*) as count FROM templates'
);
if (hasTemplates && hasTemplates.count > 0) {
console.log('Template tables already seeded with', hasTemplates.count, 'templates');
return;
}
console.log('No template data found, but tables should be created');
// Optional: Add mock template data here
// if (this.templateService) {
// // Create mock templates
// // await this.templateService.createTemplate(mockTemplate);
// }
} catch (error) {
console.log('Template tables may not exist yet - will be created in schema update');
}
} catch (error) {
console.error('Error checking template tables:', error);
}
}
async clearDatabase() {
if (!__DEV__) return;
@ -97,16 +189,29 @@ export class DevSeederService {
await this.db.withTransactionAsync(async () => {
const tables = [
// Original tables
'exercises',
'exercise_tags',
'nostr_events',
'event_tags',
'cache_metadata',
'ndk_cache' // Add the NDK Mobile cache table
'ndk_cache',
// New tables
'workouts',
'workout_exercises',
'workout_sets',
'templates',
'template_exercises'
];
for (const table of tables) {
await this.db.runAsync(`DELETE FROM ${table}`);
try {
await this.db.runAsync(`DELETE FROM ${table}`);
console.log(`Cleared table: ${table}`);
} catch (error) {
console.log(`Table ${table} might not exist yet, skipping`);
}
}
});

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
import { Workout } from '@/types/workout';
// lib/services/NostrWorkoutService.ts
import { Workout, WorkoutExercise, WorkoutSet } from '@/types/workout';
import { WorkoutTemplate, TemplateExerciseConfig } from '@/types/templates';
import { NostrEvent } from '@/types/nostr';
import { generateId } from '@/utils/ids';
import { ExerciseCategory, ExerciseType } from '@/types/exercise';
import { TemplateType } from '@/types/templates';
/**
* Service for creating and handling Nostr workout events
*/
@ -10,60 +13,14 @@ export class NostrWorkoutService {
* Creates a complete Nostr workout event with all details
*/
static createCompleteWorkoutEvent(workout: Workout): NostrEvent {
return {
kind: 1301, // As per NIP-4e spec
content: workout.notes || '',
tags: [
['d', workout.id],
['title', workout.title],
['type', workout.type],
['start', Math.floor(workout.startTime / 1000).toString()],
['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''],
['completed', 'true'],
// Add all exercise data with complete metrics
...workout.exercises.flatMap(exercise =>
exercise.sets.map(set => [
'exercise',
`33401:${exercise.id}`,
set.weight?.toString() || '',
set.reps?.toString() || '',
set.rpe?.toString() || '',
set.type
])
),
// Include template reference if workout was based on template
...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []),
// Add any tags from the workout
...(workout.tags ? workout.tags.map(tag => ['t', tag]) : [])
],
created_at: Math.floor(Date.now() / 1000)
};
return this.workoutToNostrEvent(workout, false);
}
/**
* Creates a limited Nostr workout event with reduced metrics for privacy
*/
static createLimitedWorkoutEvent(workout: Workout): NostrEvent {
return {
kind: 1301,
content: workout.notes || '',
tags: [
['d', workout.id],
['title', workout.title],
['type', workout.type],
['start', Math.floor(workout.startTime / 1000).toString()],
['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''],
['completed', 'true'],
// Add limited exercise data - just exercise names without metrics
...workout.exercises.map(exercise => [
'exercise',
`33401:${exercise.id}`
]),
...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []),
...(workout.tags ? workout.tags.map(tag => ['t', tag]) : [])
],
created_at: Math.floor(Date.now() / 1000)
};
return this.workoutToNostrEvent(workout, true);
}
/**
@ -83,4 +40,371 @@ export class NostrWorkoutService {
created_at: Math.floor(Date.now() / 1000)
};
}
/**
* Generic method to convert a workout to a Nostr event
* @param workout The workout data
* @param isLimited Whether to include limited data only
* @returns A NostrEvent (kind 1301)
*/
static workoutToNostrEvent(workout: Workout, isLimited: boolean = false): NostrEvent {
// Format start and end dates as Unix timestamps in seconds
const startTime = Math.floor(workout.startTime / 1000);
const endTime = workout.endTime ? Math.floor(workout.endTime / 1000) : undefined;
// Prepare tags for Nostr event
const tags: string[][] = [
["d", workout.id],
["title", workout.title],
["type", workout.type],
["start", startTime.toString()],
["end", endTime ? endTime.toString() : ""],
["completed", workout.isCompleted ? "true" : "false"]
];
// Add template reference if available
if (workout.templateId) {
tags.push(["template", `33402:${workout.templatePubkey || ''}:${workout.templateId}`, ""]);
}
// Add exercise data
if (isLimited) {
// Limited data - just exercise names without metrics
workout.exercises.forEach(exercise => {
tags.push(["exercise", `33401:${exercise.exerciseId || exercise.id}`]);
});
} else {
// Full data - include all metrics
workout.exercises.forEach(exercise =>
exercise.sets.forEach(set => {
const exerciseTag = [
"exercise",
`33401:${exercise.exerciseId || exercise.id}`,
""
];
// Add set data
if (set.weight !== undefined) exerciseTag.push(set.weight.toString());
if (set.reps !== undefined) exerciseTag.push(set.reps.toString());
if (set.rpe !== undefined) exerciseTag.push(set.rpe.toString());
if (set.type) exerciseTag.push(set.type);
tags.push(exerciseTag);
})
);
}
// Add any workout tags
workout.tags?.forEach(tag => {
tags.push(["t", tag]);
});
return {
kind: 1301,
content: workout.notes || "",
created_at: Math.floor(Date.now() / 1000),
tags
};
}
/**
* Convert a Nostr event to a workout
*/
static nostrEventToWorkout(event: NostrEvent): Workout {
if (event.kind !== 1301) {
throw new Error('Event is not a workout record (kind 1301)');
}
// Find tag values
const findTagValue = (name: string): string | null => {
const tag = event.tags.find(t => t[0] === name);
return tag && tag.length > 1 ? tag[1] : null;
};
// Parse dates
const startTimeStr = findTagValue('start');
const endTimeStr = findTagValue('end');
const startTime = startTimeStr ? parseInt(startTimeStr) * 1000 : Date.now();
const endTime = endTimeStr && endTimeStr !== '' ? parseInt(endTimeStr) * 1000 : undefined;
// Create base workout object
const workout: Partial<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
import { SQLiteDatabase, openDatabaseSync } from 'expo-sqlite';
import {
WorkoutTemplate,
TemplateExerciseConfig
} from '@/types/templates';
import { Workout } from '@/types/workout';
import { WorkoutTemplate } from '@/types/templates';
import { generateId } from '@/utils/ids';
import { DbService } from '../db-service';
/**
* Service for managing workout templates
*/
export class TemplateService {
private db: DbService;
constructor(database: SQLiteDatabase) {
this.db = new DbService(database);
}
/**
* Updates an existing template based on workout changes
* Get all templates
*/
static async updateExistingTemplate(workout: Workout): Promise<boolean> {
async getAllTemplates(limit: number = 50, offset: number = 0): Promise<WorkoutTemplate[]> {
try {
// This would require actual implementation with DB access
// For now, this is a placeholder
console.log('Updating template from workout:', workout.id);
// Future: Implement with your DB service
return true;
const templates = await this.db.getAllAsync<{
id: string;
title: string;
type: string;
description: string;
created_at: number;
updated_at: number;
nostr_event_id: string | null;
source: string;
parent_id: string | null;
}>(
`SELECT * FROM templates ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
[limit, offset]
);
const result: WorkoutTemplate[] = [];
for (const template of templates) {
// Get exercises for this template
const exercises = await this.getTemplateExercises(template.id);
result.push({
id: template.id,
title: template.title,
type: template.type as any,
description: template.description,
category: 'Custom', // Add this line
created_at: template.created_at,
lastUpdated: template.updated_at,
nostrEventId: template.nostr_event_id || undefined,
parentId: template.parent_id || undefined,
exercises,
availability: {
source: [template.source as any]
},
isPublic: false,
version: 1,
tags: []
});
}
return result;
} catch (error) {
console.error('Error updating template:', error);
return false;
console.error('Error getting templates:', error);
return [];
}
}
/**
* Saves a workout as a new template
* Get a template by ID
*/
static async saveAsNewTemplate(workout: Workout, name: string): Promise<string | null> {
async getTemplate(id: string): Promise<WorkoutTemplate | null> {
try {
// This would require actual implementation with DB access
// For now, this is a placeholder
console.log('Creating new template from workout:', workout.id, 'with name:', name);
// Future: Implement with your DB service
return generateId();
const template = await this.db.getFirstAsync<{
id: string;
title: string;
type: string;
description: string;
created_at: number;
updated_at: number;
nostr_event_id: string | null;
source: string;
parent_id: string | null;
}>(
`SELECT * FROM templates WHERE id = ?`,
[id]
);
if (!template) return null;
// Get exercises for this template
const exercises = await this.getTemplateExercises(id);
return {
id: template.id,
title: template.title,
type: template.type as any,
description: template.description,
category: 'Custom',
created_at: template.created_at,
lastUpdated: template.updated_at,
nostrEventId: template.nostr_event_id || undefined,
parentId: template.parent_id || undefined,
exercises,
availability: {
source: [template.source as any]
},
isPublic: false,
version: 1,
tags: []
};
} catch (error) {
console.error('Error creating template:', error);
console.error('Error getting template:', error);
return null;
}
}
/**
* Detects if a workout has changes compared to its original template
* Create a new template
*/
async createTemplate(template: Omit<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 {
// This would require comparing with the original template
// For now, assume changes if there's a templateId
return !!workout.templateId;
// Simple implementation - in a real app, you'd compare with the original template
return workout.templateId !== undefined;
}
}

View File

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

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
/**
* Workout Store
*
* This store manages the state for active workouts including:
* - Starting, pausing, and completing workouts
* - Managing exercise sets and completion status
* - Handling workout timing and duration tracking
* - Publishing workout data to Nostr when requested
* - Tracking favorite templates
*
* The store uses a timestamp-based approach for duration tracking,
* capturing start and end times to accurately represent workout duration
* even when accounting for time spent in completion flow.
*/
import { create } from 'zustand';
import { createSelectors } from '@/utils/createSelectors';
import { generateId } from '@/utils/ids';
@ -40,9 +24,26 @@ import { router } from 'expo-router';
import { useNDKStore } from '@/lib/stores/ndk';
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
import { TemplateService } from '@/lib//db/services/TemplateService';
import { WorkoutService } from '@/lib/db/services/WorkoutService'; // Add this import
import { NostrEvent } from '@/types/nostr';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
/**
* Workout Store
*
* This store manages the state for active workouts including:
* - Starting, pausing, and completing workouts
* - Managing exercise sets and completion status
* - Handling workout timing and duration tracking
* - Publishing workout data to Nostr when requested
* - Tracking favorite templates
*
* The store uses a timestamp-based approach for duration tracking,
* capturing start and end times to accurately represent workout duration
* even when accounting for time spent in completion flow.
*/
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
// Define a module-level timer reference for the workout timer
@ -810,26 +811,23 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
}));
// Helper functions
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
try {
// Try to get it from favorites in the database
const db = openDatabaseSync('powr.db');
const favoritesService = new FavoritesService(db);
const templateService = new TemplateService(db);
const result = await db.getFirstAsync<{
content: string
}>(
`SELECT content FROM favorites WHERE content_type = 'template' AND content_id = ?`,
[templateId]
);
if (result && result.content) {
return JSON.parse(result.content);
// First try to get from favorites
const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId);
if (favoriteResult) {
return favoriteResult;
}
// If not found in favorites, could implement fetching from template database
// Example: return await db.getTemplate(templateId);
console.log('Template not found in favorites:', templateId);
return null;
// If not in favorites, try the templates table
const templateResult = await templateService.getTemplate(templateId);
return templateResult;
} catch (error) {
console.error('Error fetching template:', error);
return null;
@ -838,14 +836,31 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
async function saveWorkout(workout: Workout): Promise<void> {
try {
// Make sure we're capturing the duration properly in what's saved
console.log('Saving workout with endTime:', workout.endTime);
// TODO: Implement actual save logic using our database service
// Use the workout service to save the workout
const db = openDatabaseSync('powr.db');
const workoutService = new WorkoutService(db);
await workoutService.saveWorkout(workout);
} catch (error) {
console.error('Error saving workout:', error);
}
}
async function saveSummary(summary: WorkoutSummary) {
try {
// Use the workout service to save summary metrics
const db = openDatabaseSync('powr.db');
const workoutService = new WorkoutService(db);
await workoutService.saveWorkoutSummary(summary.id, summary);
console.log('Workout summary saved successfully:', summary.id);
} catch (error) {
console.error('Error saving workout summary:', error);
}
}
function calculateWorkoutSummary(workout: Workout): WorkoutSummary {
return {
id: generateId('local'),
@ -891,11 +906,6 @@ function calculateAverageRpe(workout: Workout): number {
return totalRpe / rpeSets.length;
}
async function saveSummary(summary: WorkoutSummary) {
// TODO: Implement summary saving
console.log('Saving summary:', summary);
}
// Create auto-generated selectors
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);

View File

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

View File

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

View File

@ -23,12 +23,14 @@ export interface WorkoutSet {
timestamp?: number;
lastUpdated?: number;
completedAt?: number;
duration?: number;
}
/**
* Exercise within a workout
*/
export interface WorkoutExercise extends BaseExercise {
exerciseId?: string;
sets: WorkoutSet[];
targetSets?: number;
targetReps?: number;
@ -53,12 +55,16 @@ export interface Workout extends SyncableContent {
tags?: string[];
// Template reference if workout was started from template
templateId?: string;
templateId?: string; // Keep only one templateId property
templatePubkey?: string; // Add this for template references
// Add shareStatus property
shareStatus?: 'local' | 'public' | 'limited';
// Workout configuration
rounds?: number;
duration?: number; // Total duration in seconds
interval?: number; // For EMOM/interval workouts
duration?: number;
interval?: number;
restBetweenRounds?: number;
// Workout metrics

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,
// Create the specified number of sets from template
sets: Array.from({ length: templateExercise.targetSets }, (): WorkoutSet => ({
sets: Array.from({ length: templateExercise.targetSets || 0 }, (): WorkoutSet => ({
id: generateId('local'),
weight: 0, // Start empty, but could use last workout weight
reps: templateExercise.targetReps, // Use target reps from template