mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 18:31:03 +00:00
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
// lib/db/services/NostrWorkoutHistoryService.ts
|
|
import { SQLiteDatabase } from 'expo-sqlite';
|
|
import { Workout } from '@/types/workout';
|
|
import { DbService } from '../db-service';
|
|
import { WorkoutExercise } from '@/types/exercise';
|
|
import { useNDKStore } from '@/lib/stores/ndk';
|
|
import { parseWorkoutRecord } from '@/types/nostr-workout';
|
|
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
|
|
import { generateId } from '@/utils/ids';
|
|
|
|
/**
|
|
* @deprecated This service is deprecated. Please use UnifiedWorkoutHistoryService instead.
|
|
* See docs/design/WorkoutHistory/MigrationGuide.md for migration instructions.
|
|
*/
|
|
export class NostrWorkoutHistoryService {
|
|
private db: DbService;
|
|
|
|
constructor(database: SQLiteDatabase) {
|
|
console.warn(
|
|
'NostrWorkoutHistoryService is deprecated. ' +
|
|
'Please use UnifiedWorkoutHistoryService instead. ' +
|
|
'See docs/design/WorkoutHistory/MigrationGuide.md for migration instructions.'
|
|
);
|
|
this.db = new DbService(database);
|
|
}
|
|
|
|
/**
|
|
* Get all workouts from both local database and Nostr
|
|
* @param options Options for filtering workouts
|
|
* @returns Promise with array of workouts
|
|
*/
|
|
async getAllWorkouts(options: {
|
|
limit?: number;
|
|
offset?: number;
|
|
includeNostr?: boolean;
|
|
isAuthenticated?: boolean;
|
|
} = {}): Promise<Workout[]> {
|
|
const {
|
|
limit = 50,
|
|
offset = 0,
|
|
includeNostr = true,
|
|
isAuthenticated = false
|
|
} = options;
|
|
|
|
// Get local workouts from database
|
|
const localWorkouts = await this.getLocalWorkouts();
|
|
|
|
// If not authenticated or not including Nostr, just return local workouts
|
|
if (!isAuthenticated || !includeNostr) {
|
|
return localWorkouts;
|
|
}
|
|
|
|
try {
|
|
// Get Nostr workouts
|
|
const nostrWorkouts = await this.getNostrWorkouts();
|
|
|
|
// Merge and deduplicate workouts
|
|
return this.mergeWorkouts(localWorkouts, nostrWorkouts);
|
|
} catch (error) {
|
|
console.error('Error fetching Nostr workouts:', error);
|
|
return localWorkouts;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get workouts from local database
|
|
*/
|
|
private async getLocalWorkouts(): 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;
|
|
total_volume: number | null;
|
|
total_reps: number | null;
|
|
source: string;
|
|
notes: string | null;
|
|
nostr_event_id: string | null;
|
|
nostr_published_at: number | null;
|
|
nostr_relay_count: number | null;
|
|
}>(
|
|
`SELECT * FROM workouts
|
|
ORDER BY start_time DESC`
|
|
);
|
|
|
|
// Transform database records to Workout objects
|
|
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],
|
|
nostrEventId: workout.nostr_event_id || undefined,
|
|
nostrPublishedAt: workout.nostr_published_at || undefined,
|
|
nostrRelayCount: workout.nostr_relay_count || undefined
|
|
}
|
|
});
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error getting local workouts:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get workouts from Nostr
|
|
*/
|
|
private async getNostrWorkouts(): Promise<Workout[]> {
|
|
try {
|
|
// Get current user
|
|
const currentUser = useNDKStore.getState().currentUser;
|
|
if (!currentUser?.pubkey) {
|
|
return [];
|
|
}
|
|
|
|
// Fetch workout events
|
|
const events = await useNDKStore.getState().fetchEventsByFilter({
|
|
kinds: [1301], // Workout records
|
|
authors: [currentUser.pubkey],
|
|
limit: 50
|
|
});
|
|
|
|
// Convert events to workouts
|
|
const workouts: Workout[] = [];
|
|
|
|
for (const event of events) {
|
|
try {
|
|
const parsedWorkout = parseWorkoutRecord(event);
|
|
|
|
if (!parsedWorkout) continue;
|
|
|
|
// Convert to Workout type
|
|
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
|
workouts.push(workout);
|
|
} catch (error) {
|
|
console.error('Error parsing workout event:', error);
|
|
}
|
|
}
|
|
|
|
return workouts;
|
|
} catch (error) {
|
|
console.error('Error fetching Nostr workouts:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a parsed workout record to a Workout object
|
|
*/
|
|
private convertParsedWorkoutToWorkout(parsedWorkout: any, event: NDKEvent): Workout {
|
|
return {
|
|
id: parsedWorkout.id,
|
|
title: parsedWorkout.title,
|
|
type: parsedWorkout.type as any,
|
|
startTime: parsedWorkout.startTime,
|
|
endTime: parsedWorkout.endTime,
|
|
isCompleted: parsedWorkout.completed,
|
|
notes: parsedWorkout.notes,
|
|
created_at: parsedWorkout.createdAt,
|
|
lastUpdated: parsedWorkout.createdAt,
|
|
|
|
// Convert exercises
|
|
exercises: parsedWorkout.exercises.map((ex: any) => ({
|
|
id: ex.id,
|
|
title: ex.name,
|
|
exerciseId: ex.id,
|
|
type: 'strength',
|
|
category: 'Core',
|
|
sets: [{
|
|
id: generateId('nostr'),
|
|
weight: ex.weight,
|
|
reps: ex.reps,
|
|
rpe: ex.rpe,
|
|
type: (ex.setType as any) || 'normal',
|
|
isCompleted: true
|
|
}],
|
|
isCompleted: true,
|
|
created_at: parsedWorkout.createdAt,
|
|
lastUpdated: parsedWorkout.createdAt,
|
|
availability: { source: ['nostr'] },
|
|
tags: []
|
|
})),
|
|
|
|
// Add Nostr-specific metadata
|
|
availability: {
|
|
source: ['nostr'],
|
|
nostrEventId: event.id,
|
|
nostrPublishedAt: event.created_at ? event.created_at * 1000 : Date.now()
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge local and Nostr workouts, removing duplicates
|
|
*/
|
|
private mergeWorkouts(localWorkouts: Workout[], nostrWorkouts: Workout[]): Workout[] {
|
|
// Create a map of workouts by ID for quick lookup
|
|
const workoutMap = new Map<string, Workout>();
|
|
|
|
// Add local workouts to the map
|
|
for (const workout of localWorkouts) {
|
|
workoutMap.set(workout.id, workout);
|
|
}
|
|
|
|
// Add Nostr workouts to the map, but only if they don't already exist
|
|
for (const workout of nostrWorkouts) {
|
|
if (!workoutMap.has(workout.id)) {
|
|
workoutMap.set(workout.id, workout);
|
|
} else {
|
|
// If the workout exists in both sources, merge the availability
|
|
const existingWorkout = workoutMap.get(workout.id)!;
|
|
|
|
// Combine the sources
|
|
const sources = new Set([
|
|
...(existingWorkout.availability?.source || []),
|
|
...(workout.availability?.source || [])
|
|
]);
|
|
|
|
// Update the availability
|
|
workoutMap.set(workout.id, {
|
|
...existingWorkout,
|
|
availability: {
|
|
...existingWorkout.availability,
|
|
source: Array.from(sources) as ('local' | 'nostr')[]
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert the map back to an array and sort by startTime (newest first)
|
|
return Array.from(workoutMap.values())
|
|
.sort((a, b) => b.startTime - a.startTime);
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
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 [];
|
|
}
|
|
}
|
|
}
|