1
0
mirror of https://github.com/DocNR/POWR.git synced 2025-05-21 09:22:05 +00:00
POWR/lib/db/services/WorkoutService.ts
DocNR 29c4dd1675 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.
2025-03-08 15:48:07 -05:00

516 lines
15 KiB
TypeScript

// 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
]
);
}
}
}
}
}