mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 10:51:19 +00:00
Fix history tab navigation issues and improve workout detail error handling
This commit is contained in:
parent
e4aa59a07e
commit
3004bcd4f8
24
CHANGELOG.md
24
CHANGELOG.md
@ -5,6 +5,30 @@ 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 23, 2025
|
||||
|
||||
## Fixed
|
||||
- History tab navigation issues
|
||||
- Fixed nested screens warning by renaming "history" screen to "workouts" in history tab
|
||||
- Updated initialRouteName to match the new screen name
|
||||
- Improved navigation between history tab and workout detail screen
|
||||
- Workout detail screen improvements
|
||||
- Added timeout to prevent infinite loading state
|
||||
- Enhanced error handling with proper error state display
|
||||
- Added "Go Back" button for error recovery
|
||||
- Fixed TypeScript errors with proper imports
|
||||
- Enhanced workout history service
|
||||
- Added detailed logging for exercise loading process
|
||||
- Added checks to verify if exercises exist in the database
|
||||
- Fixed TypeScript errors in exercise existence checks
|
||||
- Improved error handling throughout the service
|
||||
|
||||
## Improved
|
||||
- Enhanced debugging capabilities
|
||||
- Added comprehensive logging in EnhancedWorkoutHistoryService
|
||||
- Improved error state handling in workout detail screen
|
||||
- Better error messages for troubleshooting
|
||||
|
||||
# Changelog - March 22, 2025
|
||||
|
||||
## Added
|
||||
|
@ -17,7 +17,7 @@ export default function HistoryLayout() {
|
||||
<Header useLogo={true} showNotifications={true} />
|
||||
|
||||
<Tab.Navigator
|
||||
initialRouteName="history"
|
||||
initialRouteName="workouts"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
@ -41,7 +41,7 @@ export default function HistoryLayout() {
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="history"
|
||||
name="workouts" // Changed from "history"
|
||||
component={HistoryScreen}
|
||||
options={{ title: 'History' }}
|
||||
/>
|
||||
@ -53,4 +53,4 @@ export default function HistoryLayout() {
|
||||
</Tab.Navigator>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
240
app/(workout)/workout/[id].tsx
Normal file
240
app/(workout)/workout/[id].tsx
Normal file
@ -0,0 +1,240 @@
|
||||
// app/(workout)/workout/[id].tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useLocalSearchParams, Stack, useRouter } from 'expo-router';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { WorkoutHistoryService } from '@/lib/db/services/EnhancedWorkoutHistoryService';
|
||||
import WorkoutDetailView from '@/components/workout/WorkoutDetailView';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { useNDK, useNDKAuth, useNDKEvents } from '@/lib/hooks/useNDK';
|
||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import { Share } from 'react-native';
|
||||
|
||||
export default function WorkoutDetailScreen() {
|
||||
// Add error state
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const db = useSQLiteContext();
|
||||
const { ndk } = useNDK();
|
||||
const { isAuthenticated } = useNDKAuth();
|
||||
const { publishEvent } = useNDKEvents();
|
||||
|
||||
const [workout, setWorkout] = useState<Workout | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Initialize service
|
||||
const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]);
|
||||
|
||||
// Load workout details
|
||||
useEffect(() => {
|
||||
const loadWorkout = async () => {
|
||||
if (!id) {
|
||||
console.log('No workout ID provided');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Loading workout details for ID: ${id}`);
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null); // Reset error state
|
||||
console.log('Calling workoutHistoryService.getWorkoutDetails...');
|
||||
|
||||
// Add timeout to prevent infinite loading
|
||||
const timeoutPromise = new Promise<null>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout loading workout details')), 10000);
|
||||
});
|
||||
|
||||
// Race the workout details fetch against the timeout
|
||||
const workoutDetails = await Promise.race([
|
||||
workoutHistoryService.getWorkoutDetails(id),
|
||||
timeoutPromise
|
||||
]) as Workout | null;
|
||||
|
||||
console.log('getWorkoutDetails returned:', workoutDetails ? 'workout found' : 'workout not found');
|
||||
|
||||
if (workoutDetails) {
|
||||
console.log(`Workout title: ${workoutDetails.title}`);
|
||||
console.log(`Workout has ${workoutDetails.exercises?.length || 0} exercises`);
|
||||
setWorkout(workoutDetails);
|
||||
} else {
|
||||
console.error('Workout not found');
|
||||
setError('Workout not found. It may have been deleted.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading workout:', error);
|
||||
setError(`Error loading workout: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
console.log('Setting isLoading to false');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWorkout();
|
||||
}, [id, workoutHistoryService]);
|
||||
|
||||
// Handle publishing to Nostr
|
||||
const handlePublish = async () => {
|
||||
if (!workout || !ndk || !isAuthenticated) {
|
||||
alert('You need to be logged in to Nostr to publish workouts');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPublishing(true);
|
||||
|
||||
// Create Nostr event
|
||||
const nostrEvent = NostrWorkoutService.createCompleteWorkoutEvent(workout);
|
||||
|
||||
// Publish event using the kind, content, and tags from the created event
|
||||
const publishedEvent = await publishEvent(
|
||||
nostrEvent.kind,
|
||||
nostrEvent.content,
|
||||
nostrEvent.tags
|
||||
);
|
||||
|
||||
if (publishedEvent?.id) {
|
||||
// Update local database with Nostr event ID
|
||||
const relayCount = ndk.pool?.relays.size || 0;
|
||||
|
||||
// Update workout in memory
|
||||
setWorkout({
|
||||
...workout,
|
||||
availability: {
|
||||
...workout.availability,
|
||||
nostrEventId: publishedEvent.id,
|
||||
nostrPublishedAt: Date.now(),
|
||||
nostrRelayCount: relayCount
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Workout published to Nostr with event ID: ${publishedEvent.id}`);
|
||||
alert('Workout published successfully!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error publishing workout to Nostr:', error);
|
||||
alert('Failed to publish workout. Please try again.');
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle importing from Nostr to local
|
||||
const handleImport = async () => {
|
||||
if (!workout) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
|
||||
// Import workout to local database
|
||||
// This would be implemented in a future version
|
||||
console.log('Importing workout from Nostr to local database');
|
||||
|
||||
// For now, just show a message
|
||||
alert('Workout import functionality will be available in a future update');
|
||||
} catch (error) {
|
||||
console.error('Error importing workout:', error);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle exporting workout data
|
||||
const handleExport = async (format: 'csv' | 'json') => {
|
||||
if (!workout) return;
|
||||
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
// Create export data
|
||||
let exportData = '';
|
||||
|
||||
if (format === 'json') {
|
||||
// Export as JSON
|
||||
exportData = JSON.stringify(workout, null, 2);
|
||||
} else {
|
||||
// Export as CSV
|
||||
const headers = 'ID,Title,Type,Start Time,End Time,Completed,Volume,Reps,Notes\n';
|
||||
const row = [
|
||||
workout.id,
|
||||
`"${workout.title.replace(/"/g, '""')}"`, // Escape quotes in title
|
||||
workout.type,
|
||||
new Date(workout.startTime).toISOString(),
|
||||
workout.endTime ? new Date(workout.endTime).toISOString() : '',
|
||||
workout.isCompleted ? 'Yes' : 'No',
|
||||
workout.totalVolume || '',
|
||||
workout.totalReps || '',
|
||||
workout.notes ? `"${workout.notes.replace(/"/g, '""')}"` : '' // Escape quotes in notes
|
||||
];
|
||||
|
||||
exportData = headers + row.join(',');
|
||||
}
|
||||
|
||||
// Share the exported data
|
||||
await Share.share({
|
||||
message: exportData,
|
||||
title: `${workout.title} - Workout Export (${format.toUpperCase()})`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error exporting workout as ${format}:`, error);
|
||||
alert(`Failed to export workout as ${format}. Please try again.`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: workout?.title || 'Workout Details',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" className="mb-4" />
|
||||
<Text className="text-muted-foreground">Loading workout details...</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className="flex-1 items-center justify-center p-4">
|
||||
<Text className="text-foreground text-lg mb-2">Error</Text>
|
||||
<Text className="text-muted-foreground text-center mb-4">{error}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="bg-primary px-4 py-2 rounded-md"
|
||||
>
|
||||
<Text className="text-primary-foreground">Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : workout ? (
|
||||
<WorkoutDetailView
|
||||
workout={workout}
|
||||
onPublish={handlePublish}
|
||||
onImport={handleImport}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center p-4">
|
||||
<Text className="text-foreground text-lg mb-2">Workout not found</Text>
|
||||
<Text className="text-muted-foreground text-center mb-4">
|
||||
The workout you're looking for could not be found or may have been deleted.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="bg-primary px-4 py-2 rounded-md"
|
||||
>
|
||||
<Text className="text-primary-foreground">Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
758
lib/db/services/EnhancedWorkoutHistoryService.ts
Normal file
758
lib/db/services/EnhancedWorkoutHistoryService.ts
Normal file
@ -0,0 +1,758 @@
|
||||
// lib/db/services/EnhancedWorkoutHistoryService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { format, startOfDay, endOfDay, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { DbService } from '../db-service';
|
||||
import { WorkoutExercise } from '@/types/exercise';
|
||||
|
||||
// Define workout filter interface
|
||||
export interface WorkoutFilters {
|
||||
type?: string[];
|
||||
dateRange?: { start: Date; end: Date };
|
||||
exercises?: string[];
|
||||
tags?: string[];
|
||||
source?: ('local' | 'nostr' | 'both')[];
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
// Define workout sync status interface
|
||||
export interface WorkoutSyncStatus {
|
||||
isLocal: boolean;
|
||||
isPublished: boolean;
|
||||
eventId?: string;
|
||||
relayCount?: number;
|
||||
lastPublished?: number;
|
||||
}
|
||||
|
||||
// Define export format type
|
||||
export type ExportFormat = 'csv' | 'json';
|
||||
|
||||
export class WorkoutHistoryService {
|
||||
private db: DbService;
|
||||
|
||||
constructor(database: SQLiteDatabase) {
|
||||
this.db = new DbService(database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search workouts by title, exercise name, or notes
|
||||
*/
|
||||
async searchWorkouts(query: string): Promise<Workout[]> {
|
||||
try {
|
||||
if (!query || query.trim() === '') {
|
||||
return this.getAllWorkouts();
|
||||
}
|
||||
|
||||
const searchTerm = `%${query.trim()}%`;
|
||||
|
||||
// Search in workout titles and notes
|
||||
const workoutIds = await this.db.getAllAsync<{ id: string }>(
|
||||
`SELECT DISTINCT w.id
|
||||
FROM workouts w
|
||||
LEFT JOIN workout_exercises we ON w.id = we.workout_id
|
||||
LEFT JOIN exercises e ON we.exercise_id = e.id
|
||||
WHERE w.title LIKE ?
|
||||
OR w.notes LIKE ?
|
||||
OR e.title LIKE ?
|
||||
ORDER BY w.start_time DESC`,
|
||||
[searchTerm, searchTerm, searchTerm]
|
||||
);
|
||||
|
||||
// Get full workout details for each matching ID
|
||||
const result: Workout[] = [];
|
||||
for (const { id } of workoutIds) {
|
||||
const workout = await this.getWorkoutDetails(id);
|
||||
if (workout) {
|
||||
result.push(workout);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error searching workouts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter workouts based on various criteria
|
||||
*/
|
||||
async filterWorkouts(filters: WorkoutFilters): Promise<Workout[]> {
|
||||
try {
|
||||
// Start with a base query
|
||||
let query = `
|
||||
SELECT DISTINCT w.*
|
||||
FROM workouts w
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
// Add joins if needed
|
||||
if (filters.exercises && filters.exercises.length > 0) {
|
||||
query += `
|
||||
JOIN workout_exercises we ON w.id = we.workout_id
|
||||
`;
|
||||
}
|
||||
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
query += `
|
||||
JOIN workout_exercises we2 ON w.id = we2.workout_id
|
||||
JOIN exercise_tags et ON we2.exercise_id = et.exercise_id
|
||||
`;
|
||||
}
|
||||
|
||||
// Add type filter
|
||||
if (filters.type && filters.type.length > 0) {
|
||||
conditions.push(`w.type IN (${filters.type.map(() => '?').join(', ')})`);
|
||||
params.push(...filters.type);
|
||||
}
|
||||
|
||||
// Add date range filter
|
||||
if (filters.dateRange) {
|
||||
const { start, end } = filters.dateRange;
|
||||
conditions.push('w.start_time >= ? AND w.start_time <= ?');
|
||||
params.push(startOfDay(start).getTime(), endOfDay(end).getTime());
|
||||
}
|
||||
|
||||
// Add exercise filter
|
||||
if (filters.exercises && filters.exercises.length > 0) {
|
||||
conditions.push(`we.exercise_id IN (${filters.exercises.map(() => '?').join(', ')})`);
|
||||
params.push(...filters.exercises);
|
||||
}
|
||||
|
||||
// Add tag filter
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
conditions.push(`et.tag IN (${filters.tags.map(() => '?').join(', ')})`);
|
||||
params.push(...filters.tags);
|
||||
}
|
||||
|
||||
// Add source filter
|
||||
if (filters.source && filters.source.length > 0) {
|
||||
// Handle 'both' specially
|
||||
if (filters.source.includes('both')) {
|
||||
conditions.push(`(w.source = 'local' OR w.source = 'nostr')`);
|
||||
} else {
|
||||
conditions.push(`w.source IN (${filters.source.map(() => '?').join(', ')})`);
|
||||
params.push(...filters.source);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search term filter
|
||||
if (filters.searchTerm && filters.searchTerm.trim() !== '') {
|
||||
const searchTerm = `%${filters.searchTerm.trim()}%`;
|
||||
conditions.push('(w.title LIKE ? OR w.notes LIKE ?)');
|
||||
params.push(searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
// Add WHERE clause if there are conditions
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
query += ' ORDER BY w.start_time DESC';
|
||||
|
||||
// Execute the query
|
||||
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;
|
||||
}>(query, params);
|
||||
|
||||
// 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 filtering workouts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workouts that include a specific exercise
|
||||
*/
|
||||
async getWorkoutsByExercise(exerciseId: string): Promise<Workout[]> {
|
||||
try {
|
||||
const workoutIds = await this.db.getAllAsync<{ workout_id: string }>(
|
||||
`SELECT DISTINCT workout_id
|
||||
FROM workout_exercises
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
[exerciseId]
|
||||
);
|
||||
|
||||
const result: Workout[] = [];
|
||||
for (const { workout_id } of workoutIds) {
|
||||
const workout = await this.getWorkoutDetails(workout_id);
|
||||
if (workout) {
|
||||
result.push(workout);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error getting workouts by exercise:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export workout history in the specified format
|
||||
*/
|
||||
async exportWorkoutHistory(format: ExportFormat): Promise<string> {
|
||||
try {
|
||||
const workouts = await this.getAllWorkouts();
|
||||
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(workouts, null, 2);
|
||||
} else if (format === 'csv') {
|
||||
// Create CSV header
|
||||
let csv = 'ID,Title,Type,Start Time,End Time,Completed,Volume,Reps,Notes\n';
|
||||
|
||||
// Add workout rows
|
||||
for (const workout of workouts) {
|
||||
const row = [
|
||||
workout.id,
|
||||
`"${workout.title.replace(/"/g, '""')}"`, // Escape quotes in title
|
||||
workout.type,
|
||||
new Date(workout.startTime).toISOString(),
|
||||
workout.endTime ? new Date(workout.endTime).toISOString() : '',
|
||||
workout.isCompleted ? 'Yes' : 'No',
|
||||
workout.totalVolume || '',
|
||||
workout.totalReps || '',
|
||||
workout.notes ? `"${workout.notes.replace(/"/g, '""')}"` : '' // Escape quotes in notes
|
||||
];
|
||||
|
||||
csv += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
return csv;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported export format: ${format}`);
|
||||
} catch (error) {
|
||||
console.error('Error exporting workout history:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout streak data
|
||||
*/
|
||||
async getWorkoutStreak(): Promise<{ current: number; longest: number; lastWorkoutDate: Date | null }> {
|
||||
try {
|
||||
// Get all workout dates sorted by date
|
||||
const workoutDates = await this.db.getAllAsync<{ workout_date: number }>(
|
||||
`SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as workout_date
|
||||
FROM workouts
|
||||
ORDER BY workout_date DESC`
|
||||
);
|
||||
|
||||
if (workoutDates.length === 0) {
|
||||
return { current: 0, longest: 0, lastWorkoutDate: null };
|
||||
}
|
||||
|
||||
const dates = workoutDates.map(row => new Date(row.workout_date));
|
||||
|
||||
// Calculate current streak
|
||||
let currentStreak = 1;
|
||||
let lastDate = dates[0];
|
||||
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const currentDate = dates[i];
|
||||
const dayDiff = Math.floor((lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (dayDiff === 1) {
|
||||
// Consecutive day
|
||||
currentStreak++;
|
||||
lastDate = currentDate;
|
||||
} else if (dayDiff > 1) {
|
||||
// Streak broken
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate longest streak
|
||||
let longestStreak = 1;
|
||||
let currentLongestStreak = 1;
|
||||
lastDate = dates[0];
|
||||
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const currentDate = dates[i];
|
||||
const dayDiff = Math.floor((lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (dayDiff === 1) {
|
||||
// Consecutive day
|
||||
currentLongestStreak++;
|
||||
lastDate = currentDate;
|
||||
|
||||
if (currentLongestStreak > longestStreak) {
|
||||
longestStreak = currentLongestStreak;
|
||||
}
|
||||
} else if (dayDiff > 1) {
|
||||
// Streak broken
|
||||
currentLongestStreak = 1;
|
||||
lastDate = currentDate;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentStreak,
|
||||
longest: longestStreak,
|
||||
lastWorkoutDate: dates[0]
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting workout streak:', error);
|
||||
return { current: 0, longest: 0, lastWorkoutDate: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout sync status
|
||||
*/
|
||||
async getWorkoutSyncStatus(workoutId: string): Promise<WorkoutSyncStatus> {
|
||||
try {
|
||||
const workout = await this.db.getFirstAsync<{
|
||||
source: string;
|
||||
nostr_event_id: string | null;
|
||||
nostr_published_at: number | null;
|
||||
nostr_relay_count: number | null;
|
||||
}>(
|
||||
`SELECT source, nostr_event_id, nostr_published_at, nostr_relay_count
|
||||
FROM workouts
|
||||
WHERE id = ?`,
|
||||
[workoutId]
|
||||
);
|
||||
|
||||
if (!workout) {
|
||||
return { isLocal: false, isPublished: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isLocal: workout.source === 'local' || workout.source === 'both',
|
||||
isPublished: Boolean(workout.nostr_event_id),
|
||||
eventId: workout.nostr_event_id || undefined,
|
||||
relayCount: workout.nostr_relay_count || undefined,
|
||||
lastPublished: workout.nostr_published_at || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting workout sync status:', error);
|
||||
return { isLocal: false, isPublished: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a workout to Nostr
|
||||
* This method updates the database with Nostr publication details
|
||||
* The actual publishing is handled by NostrWorkoutService
|
||||
*/
|
||||
async updateWorkoutNostrStatus(
|
||||
workoutId: string,
|
||||
eventId: string,
|
||||
relayCount: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.db.runAsync(
|
||||
`UPDATE workouts
|
||||
SET nostr_event_id = ?,
|
||||
nostr_published_at = ?,
|
||||
nostr_relay_count = ?
|
||||
WHERE id = ?`,
|
||||
[eventId, Date.now(), relayCount, workoutId]
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating workout Nostr status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workouts, sorted by date in descending order
|
||||
*/
|
||||
async getAllWorkouts(): 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 workouts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workouts for a specific date
|
||||
*/
|
||||
async getWorkoutsByDate(date: Date): Promise<Workout[]> {
|
||||
try {
|
||||
const startOfDayTime = startOfDay(date).getTime();
|
||||
const endOfDayTime = endOfDay(date).getTime();
|
||||
|
||||
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
|
||||
WHERE start_time >= ? AND start_time <= ?
|
||||
ORDER BY start_time DESC`,
|
||||
[startOfDayTime, endOfDayTime]
|
||||
);
|
||||
|
||||
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 workouts by date:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dates that have workouts within a month
|
||||
*/
|
||||
async getWorkoutDatesInMonth(year: number, month: number): Promise<Date[]> {
|
||||
try {
|
||||
const startOfMonthTime = startOfMonth(new Date(year, month)).getTime();
|
||||
const endOfMonthTime = endOfMonth(new Date(year, month)).getTime();
|
||||
|
||||
const result = 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 <= ?`,
|
||||
[startOfMonthTime, endOfMonthTime]
|
||||
);
|
||||
|
||||
return result.map(row => new Date(row.start_time));
|
||||
} catch (error) {
|
||||
console.error('Error getting workout dates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout details including exercises
|
||||
*/
|
||||
async getWorkoutDetails(workoutId: 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;
|
||||
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 WHERE id = ?`,
|
||||
[workoutId]
|
||||
);
|
||||
|
||||
if (!workout) return null;
|
||||
|
||||
// Get exercises for this workout
|
||||
const exercises = await this.getWorkoutExercises(workoutId);
|
||||
|
||||
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],
|
||||
nostrEventId: workout.nostr_event_id || undefined,
|
||||
nostrPublishedAt: workout.nostr_published_at || undefined,
|
||||
nostrRelayCount: workout.nostr_relay_count || undefined
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting workout details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of workouts
|
||||
*/
|
||||
async getWorkoutCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db.getFirstAsync<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM workouts`
|
||||
);
|
||||
|
||||
return result?.count || 0;
|
||||
} catch (error) {
|
||||
console.error('Error getting workout count:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to load workout exercises and sets
|
||||
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
|
||||
try {
|
||||
console.log(`[EnhancedWorkoutHistoryService] Getting exercises for workout: ${workoutId}`);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
console.log(`[EnhancedWorkoutHistoryService] Found ${exercises.length} exercises for workout ${workoutId}`);
|
||||
|
||||
const result: WorkoutExercise[] = [];
|
||||
|
||||
for (const exercise of exercises) {
|
||||
console.log(`[EnhancedWorkoutHistoryService] Processing exercise: ${exercise.id}, exercise_id: ${exercise.exercise_id}`);
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
console.log(`[EnhancedWorkoutHistoryService] Base exercise lookup result: ${baseExercise ? JSON.stringify(baseExercise) : 'null'}`);
|
||||
|
||||
// If base exercise not found, check if it exists in the exercises table
|
||||
if (!baseExercise) {
|
||||
const exerciseExists = await this.db.getFirstAsync<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM exercises WHERE id = ?`,
|
||||
[exercise.exercise_id]
|
||||
);
|
||||
console.log(`[EnhancedWorkoutHistoryService] Exercise ${exercise.exercise_id} exists in exercises table: ${(exerciseExists?.count ?? 0) > 0}`);
|
||||
}
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
console.log(`[EnhancedWorkoutHistoryService] Found ${tags.length} tags for exercise ${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]
|
||||
);
|
||||
|
||||
console.log(`[EnhancedWorkoutHistoryService] Found ${sets.length} sets for exercise ${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
|
||||
}));
|
||||
|
||||
const exerciseTitle = baseExercise?.title || 'Unknown Exercise';
|
||||
console.log(`[EnhancedWorkoutHistoryService] Using title: ${exerciseTitle} for exercise ${exercise.id}`);
|
||||
|
||||
result.push({
|
||||
id: exercise.id,
|
||||
exerciseId: exercise.exercise_id,
|
||||
title: exerciseTitle,
|
||||
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'] }
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[EnhancedWorkoutHistoryService] Returning ${result.length} processed exercises for workout ${workoutId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[EnhancedWorkoutHistoryService] Error getting workout exercises:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user