From 9043179643cd24b0099d5364f366d3429dc8f53c Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 23 Mar 2025 15:53:34 -0400 Subject: [PATCH] Add NDK Mobile Cache Integration Plan and enhance offline functionality --- CHANGELOG.md | 50 + app/(tabs)/history/workoutHistory.tsx | 184 ++- app/(tabs)/profile/progress.tsx | 69 +- app/(tabs)/social/following.tsx | 8 +- app/(tabs)/social/global.tsx | 12 +- app/(tabs)/social/powr.tsx | 8 +- app/(workout)/_layout.tsx | 11 +- app/(workout)/complete.tsx | 8 +- app/(workout)/workout/[id].tsx | 87 +- app/_layout.tsx | 118 +- components/OfflineIndicator.tsx | 114 ++ components/RelayInitializer.tsx | 32 +- components/SimpleSplashScreen.tsx | 78 +- components/social/SocialOfflineState.tsx | 77 ++ components/ui/card.tsx | 102 +- components/workout/WorkoutCard.tsx | 160 ++- components/workout/WorkoutDetailView.tsx | 284 +++++ components/workout/WorkoutOfflineState.tsx | 109 ++ .../ProfileTabEnhancementDesignDoc.md | 252 ++-- docs/design/WorkoutHistory/MigrationGuide.md | 188 +++ .../HistoryTabEnhancementDesignDoc.md | 101 +- docs/design/cache-management.md | 1119 +++-------------- .../add-nostr-fields-to-workouts.ts | 96 ++ lib/db/schema.ts | 31 +- lib/db/services/ConnectivityService.ts | 243 +++- lib/db/services/NostrWorkoutHistoryService.ts | 352 ++++++ ...ice.ts => UnifiedWorkoutHistoryService.ts} | 725 +++++++++-- lib/db/services/WorkoutHIstoryService.ts | 318 ----- lib/hooks/useAnalytics.ts | 35 +- lib/hooks/useNostrWorkoutHistory.ts | 48 + lib/hooks/useWorkoutHistory.ts | 160 +++ lib/initNDK.ts | 51 +- lib/services/AnalyticsService.ts | 361 +++++- types/feed.ts | 4 +- types/shared.ts | 5 + types/workout.ts | 7 +- utils/formatTime.ts | 27 +- 37 files changed, 3918 insertions(+), 1716 deletions(-) create mode 100644 components/OfflineIndicator.tsx create mode 100644 components/social/SocialOfflineState.tsx create mode 100644 components/workout/WorkoutDetailView.tsx create mode 100644 components/workout/WorkoutOfflineState.tsx create mode 100644 docs/design/WorkoutHistory/MigrationGuide.md create mode 100644 lib/db/migrations/add-nostr-fields-to-workouts.ts create mode 100644 lib/db/services/NostrWorkoutHistoryService.ts rename lib/db/services/{EnhancedWorkoutHistoryService.ts => UnifiedWorkoutHistoryService.ts} (50%) delete mode 100644 lib/db/services/WorkoutHIstoryService.ts create mode 100644 lib/hooks/useNostrWorkoutHistory.ts create mode 100644 lib/hooks/useWorkoutHistory.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b89c29..2b4a4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ 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 25, 2025 + +## Added +- NDK Mobile Cache Integration Plan + - Created comprehensive cache management documentation + - Designed profile image caching system + - Planned publication queue service enhancements + - Outlined social feed caching improvements + - Documented workout history caching strategy + - Planned exercise library and template caching + - Designed contact list and following caching + - Outlined general media cache service +- Enhanced offline functionality + - Added OfflineIndicator component for app-wide status display + - Created SocialOfflineState component for graceful social feed degradation + - Implemented WorkoutOfflineState component for workout screen fallbacks + - Enhanced ConnectivityService with better network detection + - Added offline mode detection in RelayInitializer + - Implemented graceful fallbacks for unavailable content + - Added cached data display when offline + - Created user-friendly offline messaging + +## Improved +- Splash screen reliability + - Enhanced SimpleSplashScreen with better error handling + - Improved platform detection for video vs. static splash + - Added fallback mechanisms for failed image loading + - Enhanced logging for better debugging + - Fixed Android-specific issues with splash screen +- Offline user experience + - Added visual indicators for offline state + - Implemented graceful degradation of network-dependent features + - Enhanced error handling for network failures + - Added automatic retry mechanisms when connectivity is restored + - Improved caching of previously viewed content + - Enhanced state persistence during offline periods + - Added connectivity-aware component rendering + +## Fixed +- Text rendering in React Native components + - Fixed "Text strings must be rendered within a component" error + - Improved card component to properly handle text children + - Enhanced error handling for text rendering issues + - Added better component composition for text containers +- Network-related crashes + - Fixed uncaught promise rejections in network requests + - Added proper error boundaries for network-dependent components + - Implemented timeout handling for stalled requests + - Enhanced error messaging for network failures + # Changelog - March 24, 2025 ## Added diff --git a/app/(tabs)/history/workoutHistory.tsx b/app/(tabs)/history/workoutHistory.tsx index 697c284..e9afb9a 100644 --- a/app/(tabs)/history/workoutHistory.tsx +++ b/app/(tabs)/history/workoutHistory.tsx @@ -1,12 +1,23 @@ // app/(tabs)/history/workoutHistory.tsx import React, { useState, useEffect } from 'react'; -import { View, ScrollView, ActivityIndicator, RefreshControl } from 'react-native'; +import { View, ScrollView, ActivityIndicator, RefreshControl, Pressable } from 'react-native'; import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; import { useSQLiteContext } from 'expo-sqlite'; import { Workout } from '@/types/workout'; import { format } from 'date-fns'; -import { WorkoutHistoryService } from '@/lib/db/services/WorkoutHistoryService'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; import WorkoutCard from '@/components/workout/WorkoutCard'; +import NostrLoginSheet from '@/components/sheets/NostrLoginSheet'; +import { useWorkoutHistory } from '@/lib/hooks/useWorkoutHistory'; + +// Define colors for icons and buttons +const primaryColor = "#8b5cf6"; // Purple color +const mutedColor = "#9ca3af"; // Gray color +const primaryBgColor = "#8b5cf6"; // Purple background +const primaryTextColor = "#ffffff"; // White text for purple background +const mutedBgColor = "#f3f4f6"; // Light gray background +const mutedTextColor = "#6b7280"; // Dark gray text for light background // Mock data for when database tables aren't yet created const mockWorkouts: Workout[] = [ @@ -14,7 +25,47 @@ const mockWorkouts: Workout[] = [ id: '1', title: 'Push 1', type: 'strength', - exercises: [], + exercises: [ + { + id: 'ex1', + exerciseId: 'bench-press', + title: 'Bench Press', + type: 'strength', + category: 'Push', + sets: [], + isCompleted: true, + created_at: new Date('2025-03-07T10:00:00').getTime(), + lastUpdated: new Date('2025-03-07T10:00:00').getTime(), + availability: { source: ['local'] }, + tags: ['compound', 'push'] + }, + { + id: 'ex2', + exerciseId: 'shoulder-press', + title: 'Shoulder Press', + type: 'strength', + category: 'Push', + sets: [], + isCompleted: true, + created_at: new Date('2025-03-07T10:00:00').getTime(), + lastUpdated: new Date('2025-03-07T10:00:00').getTime(), + availability: { source: ['local'] }, + tags: ['compound', 'push'] + }, + { + id: 'ex3', + exerciseId: 'tricep-extension', + title: 'Tricep Extension', + type: 'strength', + category: 'Push', + sets: [], + isCompleted: true, + created_at: new Date('2025-03-07T10:00:00').getTime(), + lastUpdated: new Date('2025-03-07T10:00:00').getTime(), + availability: { source: ['local'] }, + tags: ['isolation', 'push'] + } + ], startTime: new Date('2025-03-07T10:00:00').getTime(), endTime: new Date('2025-03-07T11:47:00').getTime(), isCompleted: true, @@ -26,7 +77,34 @@ const mockWorkouts: Workout[] = [ id: '2', title: 'Pull 1', type: 'strength', - exercises: [], + exercises: [ + { + id: 'ex4', + exerciseId: 'pull-up', + title: 'Pull Up', + type: 'strength', + category: 'Pull', + sets: [], + isCompleted: true, + created_at: new Date('2025-03-05T14:00:00').getTime(), + lastUpdated: new Date('2025-03-05T14:00:00').getTime(), + availability: { source: ['local'] }, + tags: ['compound', 'pull'] + }, + { + id: 'ex5', + exerciseId: 'barbell-row', + title: 'Barbell Row', + type: 'strength', + category: 'Pull', + sets: [], + isCompleted: true, + created_at: new Date('2025-03-05T14:00:00').getTime(), + lastUpdated: new Date('2025-03-05T14:00:00').getTime(), + availability: { source: ['local'] }, + tags: ['compound', 'pull'] + } + ], startTime: new Date('2025-03-05T14:00:00').getTime(), endTime: new Date('2025-03-05T15:36:00').getTime(), isCompleted: true, @@ -53,51 +131,52 @@ const groupWorkoutsByMonth = (workouts: Workout[]) => { export default function HistoryScreen() { const db = useSQLiteContext(); + const { isAuthenticated } = useNDKCurrentUser(); const [workouts, setWorkouts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [useMockData, setUseMockData] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [includeNostr, setIncludeNostr] = useState(true); + const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); - // Initialize workout history service - const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]); + // Use the unified workout history hook + const { + workouts: allWorkouts, + loading, + refreshing: hookRefreshing, + refresh, + error + } = useWorkoutHistory({ + includeNostr, + filters: includeNostr ? undefined : { source: ['local'] }, + realtime: true + }); - // Load workouts - const loadWorkouts = async () => { - try { + // Set workouts from the hook + useEffect(() => { + if (loading) { setIsLoading(true); - const allWorkouts = await workoutHistoryService.getAllWorkouts(); + } else { setWorkouts(allWorkouts); - setUseMockData(false); - } catch (error) { - console.error('Error loading workouts:', error); + setIsLoading(false); + setRefreshing(false); - // Check if the error is about missing tables - const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg.includes('no such table')) { - console.log('Using mock data because workout tables not yet created'); + // Check if we need to use mock data (empty workouts) + if (allWorkouts.length === 0 && !error) { + console.log('No workouts found, using mock data'); setWorkouts(mockWorkouts); setUseMockData(true); } else { - // For other errors, just show empty state - setWorkouts([]); setUseMockData(false); } - } finally { - setIsLoading(false); - setRefreshing(false); } - }; - - // Initial load - useEffect(() => { - loadWorkouts(); - }, [workoutHistoryService]); + }, [allWorkouts, loading, error]); // Pull to refresh handler const onRefresh = React.useCallback(() => { setRefreshing(true); - loadWorkouts(); - }, []); + refresh(); + }, [refresh]); // Group workouts by month const groupedWorkouts = groupWorkoutsByMonth(workouts); @@ -110,6 +189,24 @@ export default function HistoryScreen() { } > + {/* Nostr Login Prompt */} + {!isAuthenticated && ( + + + Connect with Nostr + + + Login with Nostr to see your workouts from other devices and back up your workout history. + + + + )} {isLoading && !refreshing ? ( @@ -133,6 +230,27 @@ export default function HistoryScreen() { )} + {isAuthenticated && ( + + setIncludeNostr(!includeNostr)} + style={{ + backgroundColor: includeNostr ? primaryBgColor : mutedBgColor, + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 9999, + }} + > + + {includeNostr ? 'Showing All Workouts' : 'Local Workouts Only'} + + + + )} + {groupedWorkouts.map(([month, monthWorkouts]) => ( @@ -155,6 +273,12 @@ export default function HistoryScreen() { {/* Add bottom padding for better scrolling experience */} + + {/* Nostr Login Sheet */} + setIsLoginSheetOpen(false)} + /> ); } diff --git a/app/(tabs)/profile/progress.tsx b/app/(tabs)/profile/progress.tsx index c28de83..a95f897 100644 --- a/app/(tabs)/profile/progress.tsx +++ b/app/(tabs)/profile/progress.tsx @@ -1,13 +1,14 @@ // app/(tabs)/profile/progress.tsx import React, { useState, useEffect } from 'react'; -import { View, ScrollView } from 'react-native'; +import { View, ScrollView, Switch, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; import { ActivityIndicator } from 'react-native'; import { useAnalytics } from '@/lib/hooks/useAnalytics'; -import { WorkoutStats, PersonalRecord } from '@/lib/services/AnalyticsService'; +import { WorkoutStats, PersonalRecord, analyticsService } from '@/lib/services/AnalyticsService'; +import { CloudIcon } from 'lucide-react-native'; // Period selector component function PeriodSelector({ period, setPeriod }: { @@ -66,14 +67,19 @@ export default function ProgressScreen() { const [loading, setLoading] = useState(true); const [stats, setStats] = useState(null); const [records, setRecords] = useState([]); + const [includeNostr, setIncludeNostr] = useState(true); - // Load workout statistics when period changes + // Load workout statistics when period or includeNostr changes useEffect(() => { async function loadStats() { if (!isAuthenticated) return; try { setLoading(true); + + // Pass includeNostr flag to analytics service + analyticsService.setIncludeNostr(includeNostr); + const workoutStats = await analytics.getWorkoutStats(period); setStats(workoutStats); @@ -88,7 +94,7 @@ export default function ProgressScreen() { } loadStats(); - }, [isAuthenticated, period, analytics]); + }, [isAuthenticated, period, includeNostr, analytics]); // Workout frequency chart const WorkoutFrequencyChart = () => { @@ -180,10 +186,36 @@ export default function ProgressScreen() { return ( - + + + + {isAuthenticated && ( + setIncludeNostr(!includeNostr)} + className="flex-row items-center" + > + + + Nostr + + + + )} + {/* Workout Summary */} - + Workout Summary Workouts: {stats?.workoutCount || 0} @@ -193,7 +225,7 @@ export default function ProgressScreen() { {/* Workout Frequency Chart */} - + Workout Frequency @@ -201,7 +233,7 @@ export default function ProgressScreen() { {/* Muscle Group Distribution */} - + Exercise Distribution @@ -209,7 +241,7 @@ export default function ProgressScreen() { {/* Personal Records */} - + Personal Records {records.length === 0 ? ( @@ -235,14 +267,17 @@ export default function ProgressScreen() { - {/* Note about future implementation */} - - - - Note: This is a placeholder UI. In the future, this tab will display real analytics based on your workout history. - - - + {/* Nostr integration note */} + {isAuthenticated && includeNostr && ( + + + + + Analytics include workouts from Nostr. Toggle the switch above to view only local workouts. + + + + )} ); } diff --git a/app/(tabs)/social/following.tsx b/app/(tabs)/social/following.tsx index 3e5bd7e..b250422 100644 --- a/app/(tabs)/social/following.tsx +++ b/app/(tabs)/social/following.tsx @@ -9,6 +9,7 @@ import { useNDKCurrentUser, useNDK } from '@/lib/hooks/useNDK'; import { useFollowingFeed } from '@/lib/hooks/useFeedHooks'; import { ChevronUp, Bug } from 'lucide-react-native'; import { AnyFeedEntry } from '@/types/feed'; +import { withOfflineState } from '@/components/social/SocialOfflineState'; // Define the conversion function here to avoid import issues function convertToLegacyFeedItem(entry: AnyFeedEntry) { @@ -21,7 +22,7 @@ function convertToLegacyFeedItem(entry: AnyFeedEntry) { }; } -export default function FollowingScreen() { +function FollowingScreen() { const { isAuthenticated, currentUser } = useNDKCurrentUser(); const { ndk } = useNDK(); const { @@ -261,4 +262,7 @@ export default function FollowingScreen() { /> ); -} \ No newline at end of file +} + +// Export the component wrapped with the offline state HOC +export default withOfflineState(FollowingScreen); diff --git a/app/(tabs)/social/global.tsx b/app/(tabs)/social/global.tsx index 7f6dd0f..061eb25 100644 --- a/app/(tabs)/social/global.tsx +++ b/app/(tabs)/social/global.tsx @@ -1,5 +1,5 @@ // app/(tabs)/social/global.tsx -import React, { useCallback, useState, useRef } from 'react'; +import React, { useCallback, useState, useRef, useEffect } from 'react'; import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import EnhancedSocialPost from '@/components/social/EnhancedSocialPost'; @@ -7,6 +7,7 @@ import { useGlobalFeed } from '@/lib/hooks/useFeedHooks'; import { router } from 'expo-router'; import { ChevronUp } from 'lucide-react-native'; import { AnyFeedEntry } from '@/types/feed'; +import { withOfflineState } from '@/components/social/SocialOfflineState'; // Define the conversion function here to avoid import issues function convertToLegacyFeedItem(entry: AnyFeedEntry) { @@ -19,7 +20,7 @@ function convertToLegacyFeedItem(entry: AnyFeedEntry) { }; } -export default function GlobalScreen() { +function GlobalScreen() { const { entries, newEntries, @@ -35,7 +36,7 @@ export default function GlobalScreen() { const listRef = useRef(null); // Show new entries button when we have new content - React.useEffect(() => { + useEffect(() => { if (newEntries.length > 0) { setShowNewButton(true); } @@ -127,4 +128,7 @@ export default function GlobalScreen() { /> ); -} \ No newline at end of file +} + +// Export the component wrapped with the offline state HOC +export default withOfflineState(GlobalScreen); diff --git a/app/(tabs)/social/powr.tsx b/app/(tabs)/social/powr.tsx index b516a05..13e2711 100644 --- a/app/(tabs)/social/powr.tsx +++ b/app/(tabs)/social/powr.tsx @@ -8,6 +8,7 @@ import POWRPackSection from '@/components/social/POWRPackSection'; import { usePOWRFeed } from '@/lib/hooks/useFeedHooks'; import { router } from 'expo-router'; import { AnyFeedEntry } from '@/types/feed'; +import { withOfflineState } from '@/components/social/SocialOfflineState'; // Define the conversion function here to avoid import issues function convertToLegacyFeedItem(entry: AnyFeedEntry) { @@ -20,7 +21,7 @@ function convertToLegacyFeedItem(entry: AnyFeedEntry) { }; } -export default function PowerScreen() { +function PowerScreen() { const { entries, newEntries, @@ -146,4 +147,7 @@ export default function PowerScreen() { /> ); -} \ No newline at end of file +} + +// Export the component wrapped with the offline state HOC +export default withOfflineState(PowerScreen); diff --git a/app/(workout)/_layout.tsx b/app/(workout)/_layout.tsx index 1c9677a..3c8c38d 100644 --- a/app/(workout)/_layout.tsx +++ b/app/(workout)/_layout.tsx @@ -53,7 +53,16 @@ export default function WorkoutLayout() { gestureDirection: 'horizontal', }} /> + ); -} \ No newline at end of file +} diff --git a/app/(workout)/complete.tsx b/app/(workout)/complete.tsx index 9e4552e..557346e 100644 --- a/app/(workout)/complete.tsx +++ b/app/(workout)/complete.tsx @@ -35,11 +35,11 @@ export default function CompleteWorkoutScreen() { return null; } + // Get the completeWorkout function from the store + const { completeWorkout } = useWorkoutStore(); + // Handle complete with options const handleComplete = async (options: WorkoutCompletionOptions) => { - // Get a fresh reference to completeWorkout - const { completeWorkout } = useWorkoutStore.getState(); - // Complete the workout with the provided options await completeWorkout(options); }; @@ -81,4 +81,4 @@ export default function CompleteWorkoutScreen() { ); -} \ No newline at end of file +} diff --git a/app/(workout)/workout/[id].tsx b/app/(workout)/workout/[id].tsx index e598fd6..f9a8f20 100644 --- a/app/(workout)/workout/[id].tsx +++ b/app/(workout)/workout/[id].tsx @@ -4,15 +4,16 @@ 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 { useWorkoutHistory } from '@/lib/hooks/useWorkoutHistory'; 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'; +import { withWorkoutOfflineState } from '@/components/workout/WorkoutOfflineState'; -export default function WorkoutDetailScreen() { +function WorkoutDetailScreen() { // Add error state const [error, setError] = useState(null); const { id } = useLocalSearchParams<{ id: string }>(); @@ -28,8 +29,8 @@ export default function WorkoutDetailScreen() { const [isImporting, setIsImporting] = useState(false); const [isExporting, setIsExporting] = useState(false); - // Initialize service - const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]); + // Use the unified workout history hook + const { getWorkoutDetails, publishWorkoutToNostr, service: workoutHistoryService } = useWorkoutHistory(); // Load workout details useEffect(() => { @@ -44,7 +45,7 @@ export default function WorkoutDetailScreen() { try { setIsLoading(true); setError(null); // Reset error state - console.log('Calling workoutHistoryService.getWorkoutDetails...'); + console.log('Calling getWorkoutDetails...'); // Add timeout to prevent infinite loading const timeoutPromise = new Promise((_, reject) => { @@ -53,7 +54,7 @@ export default function WorkoutDetailScreen() { // Race the workout details fetch against the timeout const workoutDetails = await Promise.race([ - workoutHistoryService.getWorkoutDetails(id), + getWorkoutDetails(id), timeoutPromise ]) as Workout | null; @@ -77,11 +78,11 @@ export default function WorkoutDetailScreen() { }; loadWorkout(); - }, [id, workoutHistoryService]); + }, [id, getWorkoutDetails]); // Handle publishing to Nostr const handlePublish = async () => { - if (!workout || !ndk || !isAuthenticated) { + if (!workout || !isAuthenticated) { alert('You need to be logged in to Nostr to publish workouts'); return; } @@ -89,32 +90,17 @@ export default function WorkoutDetailScreen() { try { setIsPublishing(true); - // Create Nostr event - const nostrEvent = NostrWorkoutService.createCompleteWorkoutEvent(workout); + // Use the hook's publishWorkoutToNostr method + const eventId = await publishWorkoutToNostr(workout.id); - // 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; + if (eventId) { + // Reload the workout to get the updated data + const updatedWorkout = await workoutHistoryService.getWorkoutDetails(workout.id); + if (updatedWorkout) { + setWorkout(updatedWorkout); + } - // 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}`); + console.log(`Workout published to Nostr with event ID: ${eventId}`); alert('Workout published successfully!'); } } catch (error) { @@ -127,19 +113,39 @@ export default function WorkoutDetailScreen() { // Handle importing from Nostr to local const handleImport = async () => { - if (!workout) return; + if (!workout || !workout.availability?.nostrEventId) 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'); + // Use WorkoutHistoryService to update the workout's source to include both local and nostr + const workoutId = workout.id; - // For now, just show a message - alert('Workout import functionality will be available in a future update'); + // Get the workout sync status + const syncStatus = await workoutHistoryService.getWorkoutSyncStatus(workoutId); + + if (syncStatus && !syncStatus.isLocal) { + // Update the workout to be available locally as well + await workoutHistoryService.updateWorkoutNostrStatus( + workoutId, + workout.availability.nostrEventId || '', + syncStatus.relayCount || 1 + ); + } + + if (workoutId) { + // Reload the workout to get the updated data + const updatedWorkout = await workoutHistoryService.getWorkoutDetails(workoutId); + if (updatedWorkout) { + setWorkout(updatedWorkout); + } + + console.log(`Workout imported to local database with ID: ${workoutId}`); + alert('Workout imported successfully!'); + } } catch (error) { console.error('Error importing workout:', error); + alert('Failed to import workout. Please try again.'); } finally { setIsImporting(false); } @@ -237,4 +243,7 @@ export default function WorkoutDetailScreen() { )} ); -} \ No newline at end of file +} + +// Export the component wrapped with the offline state HOC +export default withWorkoutOfflineState(WorkoutDetailScreen); diff --git a/app/_layout.tsx b/app/_layout.tsx index 853c801..c18cf61 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,7 +5,7 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import * as React from 'react'; -import { View, Text, Platform } from 'react-native'; +import { View, Text, Platform, ActivityIndicator } from 'react-native'; import { NAV_THEME } from '@/lib/theme/constants'; import { useColorScheme } from '@/lib/theme/useColorScheme'; import { PortalHost } from '@rn-primitives/portal'; @@ -16,24 +16,48 @@ import { ErrorBoundary } from '@/components/ErrorBoundary'; import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext'; import SettingsDrawer from '@/components/SettingsDrawer'; import RelayInitializer from '@/components/RelayInitializer'; +import OfflineIndicator from '@/components/OfflineIndicator'; import { useNDKStore } from '@/lib/stores/ndk'; import { useWorkoutStore } from '@/stores/workoutStore'; -// Import splash screens with fallback mechanism +import { ConnectivityService } from '@/lib/db/services/ConnectivityService'; +// Import splash screens with improved fallback mechanism let SplashComponent: React.ComponentType<{onFinish: () => void}>; +let useVideoSplash = false; -// First try to import the video splash screen -try { - // Try to dynamically import the Video component - const Video = require('expo-av').Video; - // If successful, import the VideoSplashScreen - SplashComponent = require('@/components/VideoSplashScreen').default; - console.log('Successfully imported VideoSplashScreen'); -} catch (e) { - console.warn('Failed to import VideoSplashScreen or expo-av:', e); - // If that fails, use the simple splash screen +// Determine if we should use video splash based on platform +if (Platform.OS === 'ios') { + // On iOS, try to use the video splash screen + try { + // Check if expo-av is available + require('expo-av'); + useVideoSplash = true; + console.log('expo-av is available, will use VideoSplashScreen on iOS'); + } catch (e) { + console.warn('expo-av not available on iOS:', e); + useVideoSplash = false; + } +} else { + // On Android, directly use SimpleSplashScreen to avoid issues + console.log('Android platform detected, using SimpleSplashScreen'); + useVideoSplash = false; +} + +// Import the appropriate splash screen component +if (useVideoSplash) { + try { + SplashComponent = require('@/components/VideoSplashScreen').default; + console.log('Successfully imported VideoSplashScreen'); + } catch (e) { + console.warn('Failed to import VideoSplashScreen:', e); + useVideoSplash = false; + } +} + +// If video splash is not available or failed to import, use simple splash +if (!useVideoSplash) { try { SplashComponent = require('@/components/SimpleSplashScreen').default; - console.log('Using SimpleSplashScreen as fallback'); + console.log('Using SimpleSplashScreen'); } catch (simpleSplashError) { console.warn('Failed to import SimpleSplashScreen:', simpleSplashError); // Last resort fallback is an inline component @@ -42,13 +66,27 @@ try { // Call onFinish after a short delay const timer = setTimeout(() => { onFinish(); - }, 500); + }, 1000); return () => clearTimeout(timer); }, [onFinish]); return ( - - Loading POWR... + + POWR + ); }; @@ -85,16 +123,51 @@ export default function RootLayout() { } setAndroidNavigationBar(colorScheme); - // Initialize NDK - await init(); + // Initialize connectivity service first + const connectivityService = ConnectivityService.getInstance(); + const isOnline = await connectivityService.checkNetworkStatus(); + console.log(`Network connectivity: ${isOnline ? 'online' : 'offline'}`); - // Load favorites from SQLite - await useWorkoutStore.getState().loadFavorites(); + // Start database initialization and NDK initialization in parallel + const initPromises = []; + + // Initialize NDK with timeout + const ndkPromise = init().catch(error => { + console.error('NDK initialization error:', error); + // Continue even if NDK fails + return { offlineMode: true }; + }); + initPromises.push(ndkPromise); + + // Load favorites from SQLite (local operation) + const favoritesPromise = useWorkoutStore.getState().loadFavorites() + .catch(error => { + console.error('Error loading favorites:', error); + // Continue even if loading favorites fails + }); + initPromises.push(favoritesPromise); + + // Wait for all initialization tasks with a timeout + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Initialization timeout')), 10000) + ); + + try { + // Use Promise.allSettled to continue even if some promises fail + await Promise.race([ + Promise.allSettled(initPromises), + timeoutPromise + ]); + } catch (error) { + console.warn('Some initialization tasks timed out, continuing anyway:', error); + } console.log('App initialization completed!'); setIsInitialized(true); } catch (error) { console.error('Failed to initialize:', error); + // Still mark as initialized to prevent hanging + setIsInitialized(true); } })(); } @@ -156,6 +229,9 @@ export default function RootLayout() { {/* Add RelayInitializer here - it loads relay data once NDK is available */} + {/* Add OfflineIndicator to show network status */} + + ); -} \ No newline at end of file +} diff --git a/components/OfflineIndicator.tsx b/components/OfflineIndicator.tsx new file mode 100644 index 0000000..1c12d6f --- /dev/null +++ b/components/OfflineIndicator.tsx @@ -0,0 +1,114 @@ +// components/OfflineIndicator.tsx +import React, { useEffect, useRef, useState } from 'react'; +import { View, Text, Animated, TouchableOpacity, Platform, StatusBar, SafeAreaView } from 'react-native'; +import { useConnectivity } from '@/lib/db/services/ConnectivityService'; +import { ConnectivityService } from '@/lib/db/services/ConnectivityService'; +import { WifiOffIcon, RefreshCwIcon } from 'lucide-react-native'; + +/** + * A component that displays an offline indicator when the app is offline + * This should be placed high in the component tree + */ +export default function OfflineIndicator() { + const { isOnline, lastOnlineTime, checkConnection } = useConnectivity(); + const slideAnim = useRef(new Animated.Value(-60)).current; + const [visibleOffline, setVisibleOffline] = useState(false); + const hideTimerRef = useRef(null); + + // Add a delay before hiding the indicator to ensure connectivity is stable + useEffect(() => { + if (!isOnline) { + // Show immediately when offline + setVisibleOffline(true); + // Clear any existing hide timer + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + } else if (isOnline && visibleOffline) { + // Add a delay before hiding when coming back online + hideTimerRef.current = setTimeout(() => { + setVisibleOffline(false); + }, 2000); // 2 second delay before hiding + } + + return () => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [isOnline, visibleOffline]); + + // Animate the indicator in and out based on visibility + useEffect(() => { + if (visibleOffline) { + // Slide in from the top + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true + }).start(); + } else { + // Slide out to the top + Animated.timing(slideAnim, { + toValue: -60, + duration: 300, + useNativeDriver: true + }).start(); + } + }, [visibleOffline, slideAnim]); + + // Format last online time + const lastOnlineText = lastOnlineTime + ? `Last online: ${new Date(lastOnlineTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}` + : 'Not connected recently'; + + // Handle manual refresh attempt + const handleRefresh = () => { + checkConnection(); + }; + + // Don't render anything if online and animation has completed + if (isOnline && !visibleOffline) return null; + + // Calculate header height to position the indicator below the header + // Standard header heights: iOS ~44-48, Android ~56 + const headerHeight = Platform.OS === 'ios' ? 48 : 56; + + return ( + + + + + + Offline Mode + {lastOnlineText} + + + + + + + + + ); +} diff --git a/components/RelayInitializer.tsx b/components/RelayInitializer.tsx index 5e8367e..f42293f 100644 --- a/components/RelayInitializer.tsx +++ b/components/RelayInitializer.tsx @@ -3,6 +3,8 @@ import React, { useEffect } from 'react'; import { View } from 'react-native'; import { useRelayStore } from '@/lib/stores/relayStore'; import { useNDKStore } from '@/lib/stores/ndk'; +import { useConnectivity } from '@/lib/db/services/ConnectivityService'; +import { ConnectivityService } from '@/lib/db/services/ConnectivityService'; /** * A component to initialize and load relay data when the app starts @@ -11,17 +13,37 @@ import { useNDKStore } from '@/lib/stores/ndk'; export default function RelayInitializer() { const { loadRelays } = useRelayStore(); const { ndk } = useNDKStore(); + const { isOnline } = useConnectivity(); - // Load relays when NDK is initialized + // Load relays when NDK is initialized and network is available useEffect(() => { - if (ndk) { - console.log('[RelayInitializer] NDK available, loading relays...'); + if (ndk && isOnline) { + console.log('[RelayInitializer] NDK available and online, loading relays...'); loadRelays().catch(error => console.error('[RelayInitializer] Error loading relays:', error) ); + } else if (ndk) { + console.log('[RelayInitializer] NDK available but offline, skipping relay loading'); } - }, [ndk]); + }, [ndk, isOnline]); + + // Register for connectivity restoration events + useEffect(() => { + if (!ndk) return; + + // Add sync listener to retry when connectivity is restored + const removeListener = ConnectivityService.getInstance().addSyncListener(() => { + if (ndk) { + console.log('[RelayInitializer] Network connectivity restored, attempting to load relays'); + loadRelays().catch(error => + console.error('[RelayInitializer] Error loading relays on reconnect:', error) + ); + } + }); + + return removeListener; + }, [ndk, loadRelays]); // This component doesn't render anything return null; -} \ No newline at end of file +} diff --git a/components/SimpleSplashScreen.tsx b/components/SimpleSplashScreen.tsx index d5835c6..44b497c 100644 --- a/components/SimpleSplashScreen.tsx +++ b/components/SimpleSplashScreen.tsx @@ -1,11 +1,11 @@ // components/SimpleSplashScreen.tsx -import React, { useEffect } from 'react'; -import { View, Image, ActivityIndicator, StyleSheet } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, Image, ActivityIndicator, StyleSheet, Platform, Text } from 'react-native'; import * as SplashScreen from 'expo-splash-screen'; // Keep the splash screen visible while we fetch resources -SplashScreen.preventAutoHideAsync().catch(() => { - /* ignore error */ +SplashScreen.preventAutoHideAsync().catch((error) => { + console.warn('Error preventing auto hide of splash screen:', error); }); interface SplashScreenProps { @@ -13,33 +13,68 @@ interface SplashScreenProps { } const SimpleSplashScreen: React.FC = ({ onFinish }) => { + const [imageLoaded, setImageLoaded] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { - // Hide the native splash screen - SplashScreen.hideAsync().catch(() => { - /* ignore error */ - }); + console.log('SimpleSplashScreen mounted'); + + try { + // Hide the native splash screen + SplashScreen.hideAsync().catch((error) => { + console.warn('Error hiding native splash screen:', error); + }); + } catch (e) { + console.error('Exception hiding splash screen:', e); + } // Simulate video duration with a timeout const timer = setTimeout(() => { + console.log('SimpleSplashScreen timer complete, calling onFinish'); onFinish(); - }, 2000); // 2 seconds splash display + }, 3000); // 3 seconds splash display for better visibility - return () => clearTimeout(timer); + return () => { + console.log('SimpleSplashScreen unmounting, clearing timer'); + clearTimeout(timer); + }; }, [onFinish]); + const handleImageLoad = () => { + console.log('Splash image loaded successfully'); + setImageLoaded(true); + }; + + const handleImageError = (e: any) => { + console.error('Error loading splash image:', e); + setError('Failed to load splash image'); + }; + return ( - {/* Use a static image as fallback */} + {/* Logo image */} + + {/* Show app name as text for better reliability */} + POWR + + {/* Loading indicator */} + + {/* Error message if image fails to load */} + {error && ( + {error} + )} ); }; @@ -52,13 +87,24 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, image: { - width: '80%', - height: '80%', + width: Platform.OS === 'android' ? '70%' : '80%', + height: Platform.OS === 'android' ? '70%' : '80%', + }, + appName: { + color: '#ffffff', + fontSize: 32, + fontWeight: 'bold', + marginTop: 20, }, loader: { - position: 'absolute', - bottom: 100, + marginTop: 30, }, + errorText: { + color: '#ff6666', + marginTop: 20, + textAlign: 'center', + paddingHorizontal: 20, + } }); -export default SimpleSplashScreen; \ No newline at end of file +export default SimpleSplashScreen; diff --git a/components/social/SocialOfflineState.tsx b/components/social/SocialOfflineState.tsx new file mode 100644 index 0000000..b1fdc65 --- /dev/null +++ b/components/social/SocialOfflineState.tsx @@ -0,0 +1,77 @@ +// components/social/SocialOfflineState.tsx +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { WifiOffIcon, RefreshCwIcon } from 'lucide-react-native'; +import { useConnectivity } from '@/lib/db/services/ConnectivityService'; + +/** + * A component to display when social features are unavailable due to offline status + */ +export default function SocialOfflineState() { + const { isOnline, lastOnlineTime, checkConnection } = useConnectivity(); + + // Format last online time + const lastOnlineText = lastOnlineTime + ? `Last online: ${new Date(lastOnlineTime).toLocaleDateString()} at ${new Date(lastOnlineTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}` + : 'Not connected recently'; + + // Handle manual refresh attempt + const handleRefresh = () => { + checkConnection(); + }; + + return ( + + + + + + You're offline + + + + Social features require an internet connection. Your workouts are still being saved locally and will sync when you're back online. + + + + {lastOnlineText} + + + + + + Check Connection + + + + + ); +} + +/** + * A higher-order component that wraps social screens to handle offline state + */ +export function withOfflineState

( + Component: React.ComponentType

+): React.FC

{ + return (props: P) => { + const { isOnline } = useConnectivity(); + + if (!isOnline) { + return ; + } + + return ; + }; +} diff --git a/components/ui/card.tsx b/components/ui/card.tsx index 72f6abf..e342ad2 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -1,24 +1,72 @@ import type { TextRef, ViewRef } from '@rn-primitives/types'; import * as React from 'react'; -import { Text, TextProps, View, ViewProps } from 'react-native'; -import { TextClassContext } from '@/components/ui/text'; +import { TextProps, View, ViewProps } from 'react-native'; +import { Text, TextClassContext } from '@/components/ui/text'; import { cn } from '@/lib/utils'; -const Card = React.forwardRef(({ className, ...props }, ref) => ( - -)); +// Extended ViewProps interface that includes children +interface ViewPropsWithChildren extends ViewProps { + children?: React.ReactNode; +} + +// Helper function to recursively wrap text nodes in Text components +const wrapTextNodes = (children: React.ReactNode): React.ReactNode => { + // If it's a string or number, wrap it in a Text component + if (typeof children === 'string' || typeof children === 'number') { + return {children}; + } + + // If it's an array, map over it and recursively wrap each child + if (Array.isArray(children)) { + return children.map((child, index) => ( + {wrapTextNodes(child)} + )); + } + + // If it's a React element + if (React.isValidElement(children)) { + // If it's already a Text component or a native element, return it as is + if (children.type === Text || typeof children.type === 'string') { + return children; + } + + // Otherwise, recursively wrap its children + if (children.props.children) { + return React.cloneElement( + children, + { ...children.props }, + wrapTextNodes(children.props.children) + ); + } + } + + // For everything else, return as is + return children; +}; + +const Card = React.forwardRef(({ className, children, ...props }, ref) => { + return ( + + {wrapTextNodes(children)} + + ); +}); Card.displayName = 'Card'; -const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( - -)); +const CardHeader = React.forwardRef(({ className, children, ...props }, ref) => { + return ( + + {wrapTextNodes(children)} + + ); +}); CardHeader.displayName = 'CardHeader'; const CardTitle = React.forwardRef>( @@ -42,16 +90,24 @@ const CardDescription = React.forwardRef(({ className, ...pr )); CardDescription.displayName = 'CardDescription'; -const CardContent = React.forwardRef(({ className, ...props }, ref) => ( - - - -)); +const CardContent = React.forwardRef(({ className, children, ...props }, ref) => { + return ( + + + {wrapTextNodes(children)} + + + ); +}); CardContent.displayName = 'CardContent'; -const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( - -)); +const CardFooter = React.forwardRef(({ className, children, ...props }, ref) => { + return ( + + {wrapTextNodes(children)} + + ); +}); CardFooter.displayName = 'CardFooter'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/components/workout/WorkoutCard.tsx b/components/workout/WorkoutCard.tsx index 4362cd6..0c8902f 100644 --- a/components/workout/WorkoutCard.tsx +++ b/components/workout/WorkoutCard.tsx @@ -1,16 +1,26 @@ // components/workout/WorkoutCard.tsx import React from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; -import { ChevronRight } from 'lucide-react-native'; +import { View, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { ChevronRight, CloudIcon, SmartphoneIcon, CloudOffIcon } from 'lucide-react-native'; import { Card, CardContent } from '@/components/ui/card'; import { Workout } from '@/types/workout'; import { format } from 'date-fns'; import { useRouter } from 'expo-router'; +import { cn } from '@/lib/utils'; -interface WorkoutCardProps { +export interface EnhancedWorkoutCardProps { workout: Workout; showDate?: boolean; showExercises?: boolean; + source?: 'local' | 'nostr' | 'both'; + publishStatus?: { + isPublished: boolean; + relayCount?: number; + lastPublished?: number; + }; + onShare?: () => void; + onImport?: () => void; } // Calculate duration in hours and minutes @@ -25,29 +35,91 @@ const formatDuration = (startTime: number, endTime: number) => { return `${minutes}m`; }; -export const WorkoutCard: React.FC = ({ +export const WorkoutCard: React.FC = ({ workout, showDate = true, - showExercises = true + showExercises = true, + source, + publishStatus, + onShare, + onImport }) => { const router = useRouter(); const handlePress = () => { // Navigate to workout details console.log(`Navigate to workout ${workout.id}`); - // Implement navigation when endpoint is available - // router.push(`/workout/${workout.id}`); + router.push(`/workout/${workout.id}`); }; + // Determine source if not explicitly provided + const workoutSource = source || + (workout.availability?.source?.includes('nostr') && workout.availability?.source?.includes('local') + ? 'both' + : workout.availability?.source?.includes('nostr') + ? 'nostr' + : 'local'); + + // Determine publish status if not explicitly provided + const workoutPublishStatus = publishStatus || { + isPublished: Boolean(workout.availability?.nostrEventId), + relayCount: workout.availability?.nostrRelayCount, + lastPublished: workout.availability?.nostrPublishedAt + }; + + // Debug: Log exercises + console.log(`WorkoutCard for ${workout.id} has ${workout.exercises?.length || 0} exercises`); + if (workout.exercises && workout.exercises.length > 0) { + console.log(`First exercise: ${workout.exercises[0].title}`); + } + + // Define colors for icons + const primaryColor = "#8b5cf6"; // Purple color + const mutedColor = "#9ca3af"; // Gray color + return ( - - - - {workout.title} - - - - + + + + + + + {workout.title} + + + {/* Source indicator */} + + {workoutSource === 'local' && ( + + )} + {workoutSource === 'nostr' && ( + + )} + {workoutSource === 'both' && ( + + + + + + )} + + + + + {showDate && ( @@ -55,6 +127,57 @@ export const WorkoutCard: React.FC = ({ )} + {/* Publish status indicator */} + {workoutSource !== 'nostr' && ( + + {workoutPublishStatus.isPublished ? ( + + + + Published to {workoutPublishStatus.relayCount || 0} relays + {workoutPublishStatus.lastPublished && + ` on ${format(workoutPublishStatus.lastPublished, 'MMM d')}`} + + + {onShare && ( + + Republish + + )} + + ) : ( + + + Local only + + {onShare && ( + + Publish + + )} + + )} + + )} + + {/* Import button for Nostr-only workouts */} + {workoutSource === 'nostr' && onImport && ( + + + Import to local + + + )} + @@ -108,9 +231,10 @@ export const WorkoutCard: React.FC = ({ )} )} - - + + + ); }; -export default WorkoutCard; \ No newline at end of file +export default WorkoutCard; diff --git a/components/workout/WorkoutDetailView.tsx b/components/workout/WorkoutDetailView.tsx new file mode 100644 index 0000000..5323c78 --- /dev/null +++ b/components/workout/WorkoutDetailView.tsx @@ -0,0 +1,284 @@ +// components/workout/WorkoutDetailView.tsx +import React from 'react'; +import { View, ScrollView, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { format } from 'date-fns'; +import { Card, CardContent } from '@/components/ui/card'; +import { Workout, WorkoutExercise, WorkoutSet } from '@/types/workout'; +import { CloudIcon, SmartphoneIcon, CloudOffIcon, Share2Icon, DownloadIcon } from 'lucide-react-native'; +import { formatDuration } from '@/utils/formatTime'; + +interface WorkoutDetailViewProps { + workout: Workout; + onPublish?: () => void; + onImport?: () => void; + onExport?: (format: 'csv' | 'json') => void; +} + +export const WorkoutDetailView: React.FC = ({ + workout, + onPublish, + onImport, + onExport +}) => { + // Determine source + const workoutSource = + (workout.availability?.source?.includes('nostr') && workout.availability?.source?.includes('local') + ? 'both' + : workout.availability?.source?.includes('nostr') + ? 'nostr' + : 'local'); + + // Determine publish status + const isPublished = Boolean(workout.availability?.nostrEventId); + const relayCount = workout.availability?.nostrRelayCount || 0; + const lastPublished = workout.availability?.nostrPublishedAt; + + // Format workout duration + const duration = workout.endTime && workout.startTime + ? formatDuration(workout.endTime - workout.startTime) + : 'N/A'; + + // Render a set + const renderSet = (set: WorkoutSet, index: number) => ( + + + {index + 1} + + + {set.weight && ( + {set.weight} lb + )} + {set.reps && ( + {set.reps} reps + )} + {set.rpe && ( + RPE {set.rpe} + )} + + + + {set.isCompleted ? 'Completed' : 'Skipped'} + + + + ); + + // Render an exercise with its sets + const renderExercise = (exercise: WorkoutExercise, index: number) => ( + + + + + {index + 1}. {exercise.title} + + + + {exercise.sets.length} sets + + + + + {exercise.notes && ( + + {exercise.notes} + + )} + + + {/* Set header */} + + + Set + + + Weight/Reps + + + Status + + + + {/* Sets */} + {exercise.sets.map((set, idx) => renderSet(set, idx))} + + + + ); + + return ( + + + {/* Header */} + + + {workout.title} + + {/* Source indicator */} + + {workoutSource === 'local' && ( + <> + + Local + + )} + {workoutSource === 'nostr' && ( + <> + + Nostr + + )} + {workoutSource === 'both' && ( + <> + + + Both + + )} + + + + + {format(workout.startTime, 'EEEE, MMMM d, yyyy')} at {format(workout.startTime, 'h:mm a')} + + + {/* Publish status */} + {workoutSource !== 'nostr' && ( + + {isPublished ? ( + + + + Published to {relayCount} relays + {lastPublished && + ` on ${format(lastPublished, 'MMM d, yyyy')}`} + + + ) : ( + + + Local only + + )} + + )} + + {/* Action buttons */} + + {/* Publish button for local workouts */} + {workoutSource !== 'nostr' && !isPublished && onPublish && ( + + + Publish to Nostr + + )} + + {/* Republish button for already published workouts */} + {workoutSource !== 'nostr' && isPublished && onPublish && ( + + + Republish + + )} + + {/* Import button for Nostr-only workouts */} + {workoutSource === 'nostr' && onImport && ( + + + Import to local + + )} + + {/* Export buttons */} + {onExport && ( + + onExport('json')} + className="mr-2 mb-2 flex-row items-center bg-muted px-3 py-2 rounded" + > + Export JSON + + + onExport('csv')} + className="mr-2 mb-2 flex-row items-center bg-muted px-3 py-2 rounded" + > + Export CSV + + + )} + + + + {/* Workout stats */} + + + Duration + {duration} + + + + Total Volume + + {workout.totalVolume ? `${workout.totalVolume} lb` : 'N/A'} + + + + + Total Reps + + {workout.totalReps || 'N/A'} + + + + + Exercises + + {workout.exercises?.length || 0} + + + + + {/* Notes */} + {workout.notes && ( + + Notes + + + {workout.notes} + + + + )} + + {/* Exercises */} + + Exercises + {workout.exercises && workout.exercises.length > 0 ? ( + workout.exercises.map((exercise, idx) => renderExercise(exercise, idx)) + ) : ( + + + No exercises recorded + + + )} + + + {/* Add bottom padding for better scrolling experience */} + + + + ); +}; + +export default WorkoutDetailView; diff --git a/components/workout/WorkoutOfflineState.tsx b/components/workout/WorkoutOfflineState.tsx new file mode 100644 index 0000000..38354a9 --- /dev/null +++ b/components/workout/WorkoutOfflineState.tsx @@ -0,0 +1,109 @@ +// components/workout/WorkoutOfflineState.tsx +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { WifiOffIcon, RefreshCwIcon, ArrowLeftIcon } from 'lucide-react-native'; +import { useConnectivity } from '@/lib/db/services/ConnectivityService'; +import { useRouter } from 'expo-router'; + +interface WorkoutOfflineStateProps { + workoutId?: string; +} + +export default function WorkoutOfflineState({ workoutId }: WorkoutOfflineStateProps) { + const { lastOnlineTime, checkConnection } = useConnectivity(); + const router = useRouter(); + + // Format last online time + const lastOnlineText = lastOnlineTime + ? `Last online: ${new Date(lastOnlineTime).toLocaleDateString()} at ${new Date(lastOnlineTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}` + : 'Not connected recently'; + + // Handle manual refresh attempt + const handleRefresh = () => { + checkConnection(); + }; + + // Handle go back + const handleGoBack = () => { + router.back(); + }; + + return ( + + + + + + You're offline + + + + {workoutId + ? "This workout can't be loaded while you're offline. Please check your connection and try again." + : "Workout details can't be loaded while you're offline. Please check your connection and try again."} + + + + {lastOnlineText} + + + + + + + Go Back + + + + + + + Check Connection + + + + + + ); +} + +/** + * A higher-order component that wraps workout detail screens to handle offline state + */ +export function withWorkoutOfflineState

( + Component: React.ComponentType

+): React.FC

{ + return (props: P & { workoutId?: string; workout?: any }) => { + const { isOnline } = useConnectivity(); + + // If we're online or we already have the workout data locally, show the component + if (isOnline || props.workout) { + return ; + } + + // Otherwise show the offline state + return ; + }; +} diff --git a/docs/design/ProfileTab/ProfileTabEnhancementDesignDoc.md b/docs/design/ProfileTab/ProfileTabEnhancementDesignDoc.md index ab69ca2..83f734f 100644 --- a/docs/design/ProfileTab/ProfileTabEnhancementDesignDoc.md +++ b/docs/design/ProfileTab/ProfileTabEnhancementDesignDoc.md @@ -2,146 +2,188 @@ ## Overview -This document outlines the design and implementation of the enhanced Profile tab for the POWR app. The enhancement includes a tabbed interface with separate screens for profile overview, activity feed, progress analytics, and settings. +The Profile Tab will be enhanced to include user analytics, progress tracking, and personal social feed. This document outlines the design and implementation plan for these enhancements. -## Motivation +## Goals -The original Profile tab was limited to displaying basic user information. With the growing social and analytics features of the app, we need a more comprehensive profile experience that: +- Provide users with a comprehensive view of their workout progress and analytics +- Create a personalized social feed experience within the profile tab +- Improve user engagement by showcasing growth and achievements +- Integrate Nostr functionality for cross-device synchronization -1. Showcases the user's identity and achievements -2. Displays workout activity in a social feed format -3. Provides analytics and progress tracking -4. Offers easy access to settings and preferences +## Tab Structure -By moving analytics and progress tracking to the Profile tab, we create a more cohesive user experience that focuses on personal growth and achievement. +The Profile Tab will be organized into the following sections: -## Design +1. **Overview** - User profile information and summary statistics +2. **Activity** - Personal social feed showing the user's workout posts +3. **Progress** - Analytics and progress tracking visualizations +4. **Settings** - Account settings, preferences, and Nostr integration -### Tab Structure +## Detailed Design -The enhanced Profile tab is organized into four sub-tabs: +### Overview Section -1. **Overview**: Displays user profile information, stats summary, and quick access to recent records and activity -2. **Activity**: Shows a chronological feed of the user's workout posts -3. **Progress**: Provides analytics and progress tracking with charts and personal records -4. **Settings**: Contains profile editing, privacy controls, and app preferences +The Overview section will serve as the landing page for the Profile Tab and will include: -### Navigation +- User profile photo, name, and bio +- Summary statistics: + - Total workouts completed + - Total volume lifted + - Workout streak + - Favorite exercises +- Quick access buttons to key features +- Nostr connection status -The tabs are implemented using Expo Router's `Tabs` component, with appropriate icons for each tab: +### Activity Section -- Overview: User icon -- Activity: Activity icon -- Progress: BarChart2 icon -- Settings: Settings icon +The Activity section will display the user's personal social feed: -### Data Flow +- Chronological list of the user's workout posts +- Ability to view, edit, and delete posts +- Interaction metrics (likes, comments) +- Options to share workouts to the global feed +- Filter options for viewing different types of activities -The Profile tab components interact with several services: +### Progress Section -1. **NDK Services**: For user profile data and authentication -2. **WorkoutService**: For accessing workout history -3. **AnalyticsService**: For calculating statistics and progress metrics +The Progress section will provide detailed analytics and visualizations: -## Implementation Details +- **Workout Volume Chart** + - Weekly/monthly volume progression + - Filterable by exercise category or specific exercises + +- **Strength Progress Tracking** + - Personal records for key exercises + - Progression charts for main lifts + - Comparison to previous periods + +- **Workout Consistency** + - Calendar heatmap showing workout frequency + - Streak tracking and milestone celebrations + - Weekly workout distribution -### New Components and Files +- **Body Metrics** (future enhancement) + - Weight tracking + - Body measurements + - Progress photos -1. **Tab Layout**: - - `app/(tabs)/profile/_layout.tsx`: Defines the tab structure and navigation +### Settings Section -2. **Tab Screens**: - - `app/(tabs)/profile/overview.tsx`: Profile information and summary - - `app/(tabs)/profile/activity.tsx`: Workout activity feed - - `app/(tabs)/profile/progress.tsx`: Analytics and progress tracking - - `app/(tabs)/profile/settings.tsx`: User settings and preferences +The Settings section will include: -3. **Services**: - - `lib/services/AnalyticsService.ts`: Service for calculating workout statistics and progress data - - `lib/hooks/useAnalytics.ts`: React hook for accessing the analytics service +- Profile information management +- Nostr account connection and management +- Data synchronization preferences +- Privacy settings for social sharing +- App preferences and customization +- Export and backup options -### Analytics Service +## Implementation Plan -The AnalyticsService provides methods for: +### Phase 1: Core Structure -1. **Workout Statistics**: Calculate aggregate statistics like total workouts, duration, volume, etc. -2. **Exercise Progress**: Track progress for specific exercises over time -3. **Personal Records**: Identify and track personal records for exercises +1. Create the tab navigation structure with the four main sections +2. Implement the Overview section with basic profile information +3. Set up the Settings section with account management -The service is designed to work with both local and Nostr-based workout data, providing a unified view of the user's progress. +### Phase 2: Analytics and Progress -### Authentication Integration +1. Implement data collection and processing for analytics +2. Create visualization components for progress tracking +3. Develop the Progress section with charts and metrics +4. Add personal records tracking and milestone celebrations -The Profile tab is integrated with the Nostr authentication system: +### Phase 3: Personal Social Feed -- Unauthenticated users see a login prompt in the Overview tab -- All tabs show appropriate UI for unauthenticated users -- The NostrLoginSheet is accessible from the Overview tab +1. Implement the Activity section with the personal feed +2. Add post management functionality +3. Integrate with the global social feed +4. Implement interaction features -## User Experience +### Phase 4: Nostr Integration -### Overview Tab - -The Overview tab provides a comprehensive view of the user's profile: - -- Profile picture and banner image -- Display name and username -- About/bio text -- Summary statistics (workouts, templates, programs) -- Recent personal records -- Recent activity -- Quick actions for profile management - -### Activity Tab - -The Activity tab displays the user's workout posts in a chronological feed: - -- Each post shows the workout details -- Posts are formatted similar to the social feed -- Empty state for users with no activity - -### Progress Tab - -The Progress tab visualizes the user's fitness journey: - -- Period selector (week, month, year, all-time) -- Workout summary statistics -- Workout frequency chart -- Exercise distribution chart -- Personal records list -- Empty states for users with no data - -### Settings Tab - -The Settings tab provides access to user preferences: - -- Profile information editing -- Privacy settings -- Notification preferences -- Account management - -## Future Enhancements - -1. **Workout Streaks**: Track and display workout consistency -2. **Goal Setting**: Allow users to set and track fitness goals -3. **Comparison Analytics**: Compare current performance with past periods -4. **Social Integration**: Show followers/following counts and management -5. **Achievement Badges**: Gamification elements for workout milestones +1. Enhance Nostr connectivity for profile data +2. Implement cross-device synchronization for progress data +3. Add backup and restore functionality via Nostr ## Technical Considerations +### Data Storage + +- Local SQLite database for workout and progress data +- Nostr for cross-device synchronization and backup +- Efficient querying for analytics calculations + ### Performance -- The AnalyticsService uses caching to minimize recalculations -- Data is loaded asynchronously to keep the UI responsive -- Charts and visualizations use efficient rendering techniques +- Optimize chart rendering for smooth performance +- Implement pagination for social feed +- Use memoization for expensive calculations -### Data Privacy +### Privacy -- Analytics are calculated locally on the device -- Sharing controls allow users to decide what data is public -- Personal records can be selectively shared +- Clear user control over what data is shared +- Secure handling of personal information +- Transparent data synchronization options + +## UI/UX Design + +### Overview Section + +``` ++---------------------------------------+ +| | +| [Profile Photo] Username | +| Bio | +| | ++---------------------------------------+ +| | +| Total Workouts Total Volume | +| 123 45,678 lbs | +| | +| Current Streak Favorite Exercise | +| 7 days Bench Press | +| | ++---------------------------------------+ +| | +| [Quick Actions] | +| | ++---------------------------------------+ +``` + +### Progress Section + +``` ++---------------------------------------+ +| | +| [Time Period Selector] | +| | ++---------------------------------------+ +| | +| Volume Progression | +| | +| [Chart] | +| | ++---------------------------------------+ +| | +| Strength Progress | +| | +| [Exercise Selector] | +| | +| [Progress Chart] | +| | ++---------------------------------------+ +| | +| Workout Consistency | +| | +| [Calendar Heatmap] | +| | ++---------------------------------------+ +``` ## Conclusion -The enhanced Profile tab transforms the user experience by providing a comprehensive view of the user's identity, activity, and progress. By centralizing these features in the Profile tab, we create a more intuitive and engaging experience that encourages users to track their fitness journey and celebrate their achievements. +The enhanced Profile Tab will provide users with a comprehensive view of their fitness journey, combining social elements with detailed analytics and progress tracking. By centralizing these features in the Profile Tab, users will have a more cohesive experience that emphasizes personal growth and achievement. + +The implementation will be phased to ensure each component is properly developed and integrated, with a focus on performance and user experience throughout the process. diff --git a/docs/design/WorkoutHistory/MigrationGuide.md b/docs/design/WorkoutHistory/MigrationGuide.md new file mode 100644 index 0000000..24ef9da --- /dev/null +++ b/docs/design/WorkoutHistory/MigrationGuide.md @@ -0,0 +1,188 @@ +# Workout History API Migration Guide + +## Overview + +We've consolidated our workout history services and hooks to provide a more consistent and maintainable API. This guide will help you migrate from the old APIs to the new unified API. + +## Why We're Consolidating + +The previous implementation had several overlapping services: + +1. **WorkoutHistoryService** - Basic service for local database operations +2. **EnhancedWorkoutHistoryService** - Extended service with advanced features +3. **NostrWorkoutHistoryService** - Service for Nostr integration +4. **useNostrWorkoutHistory Hook** - Hook for fetching workouts from Nostr + +This led to: +- Duplicate code and functionality +- Inconsistent APIs +- Confusion about which service to use for what purpose +- Difficulty maintaining and extending the codebase + +## New Architecture + +The new architecture consists of: + +1. **UnifiedWorkoutHistoryService** - A single service that combines all functionality +2. **useWorkoutHistory** - A single hook that provides access to all workout history features + +## Service Migration + +### Before: + +```typescript +// Using WorkoutHistoryService +const workoutHistoryService = new WorkoutHistoryService(db); +const localWorkouts = await workoutHistoryService.getAllWorkouts(); + +// Using NostrWorkoutHistoryService +const nostrWorkoutHistoryService = new NostrWorkoutHistoryService(db); +const allWorkouts = await nostrWorkoutHistoryService.getAllWorkouts({ + includeNostr: true, + isAuthenticated: true +}); +``` + +### After: + +```typescript +// Using unified UnifiedWorkoutHistoryService +const workoutHistoryService = new UnifiedWorkoutHistoryService(db); + +// For local workouts only +const localWorkouts = await workoutHistoryService.getAllWorkouts({ + includeNostr: false +}); + +// For all workouts (local + Nostr) +const allWorkouts = await workoutHistoryService.getAllWorkouts({ + includeNostr: true, + isAuthenticated: true +}); +``` + +## Hook Migration + +### Before: + +```typescript +// Using useNostrWorkoutHistory +const { workouts, loading } = useNostrWorkoutHistory(); + +// Or manually creating a service in a component +const db = useSQLiteContext(); +const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]); + +// Then loading workouts +const loadWorkouts = async () => { + const workouts = await workoutHistoryService.getAllWorkouts(); + setWorkouts(workouts); +}; +``` + +### After: + +```typescript +// Using unified useWorkoutHistory +const { + workouts, + loading, + refresh, + getWorkoutsByDate, + publishWorkoutToNostr +} = useWorkoutHistory({ + includeNostr: true, + realtime: true, + filters: { type: ['strength'] } // Optional filters +}); +``` + +## Key Benefits of the New API + +1. **Simplified Interface**: One service and one hook to learn and use +2. **Real-time Updates**: Built-in support for real-time Nostr updates +3. **Consistent Filtering**: Unified filtering across local and Nostr workouts +4. **Better Type Safety**: Improved TypeScript types and interfaces +5. **Reduced Boilerplate**: Less code needed in components + +## Examples of Updated Components + +### Calendar View + +```typescript +// Before +const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]); +const loadWorkouts = async () => { + const allWorkouts = await workoutHistoryService.getAllWorkouts(); + setWorkouts(allWorkouts); +}; + +// After +const { + workouts: allWorkouts, + loading, + refresh, + getWorkoutsByDate +} = useWorkoutHistory({ + includeNostr: true, + realtime: true +}); +``` + +### Workout History Screen + +```typescript +// Before +const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]); +const loadWorkouts = async () => { + let allWorkouts = []; + if (includeNostr) { + allWorkouts = await workoutHistoryService.getAllWorkouts(); + } else { + allWorkouts = await workoutHistoryService.filterWorkouts({ + source: ['local'] + }); + } + setWorkouts(allWorkouts); +}; + +// After +const { + workouts: allWorkouts, + loading, + refresh +} = useWorkoutHistory({ + includeNostr, + filters: includeNostr ? undefined : { source: ['local'] }, + realtime: true +}); +``` + +### Workout Detail Screen + +```typescript +// Before +const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]); +const loadWorkout = async () => { + const workoutDetails = await workoutHistoryService.getWorkoutDetails(id); + setWorkout(workoutDetails); +}; + +// After +const { getWorkoutDetails, publishWorkoutToNostr } = useWorkoutHistory(); +const loadWorkout = async () => { + const workoutDetails = await getWorkoutDetails(id); + setWorkout(workoutDetails); +}; +``` + +## Timeline + +- The old APIs are now deprecated and will be removed in a future release +- Please migrate to the new APIs as soon as possible +- If you encounter any issues during migration, please contact the development team + +## Additional Resources + +- [UnifiedWorkoutHistoryService Documentation](../api/UnifiedWorkoutHistoryService.md) +- [useWorkoutHistory Hook Documentation](../api/useWorkoutHistory.md) diff --git a/docs/design/WorkoutTab/HistoryTabEnhancementDesignDoc.md b/docs/design/WorkoutTab/HistoryTabEnhancementDesignDoc.md index 5b80727..f4abaf9 100644 --- a/docs/design/WorkoutTab/HistoryTabEnhancementDesignDoc.md +++ b/docs/design/WorkoutTab/HistoryTabEnhancementDesignDoc.md @@ -35,7 +35,16 @@ Rationale: - Allows specialized UI for each view type - Analytics and progress tracking will be moved to the Profile tab for better user context -### 2. Data Aggregation Strategy +### 2. Authentication State Handling +Show only local workouts when the user is not authenticated with Nostr, with clear messaging about the benefits of logging in. + +Rationale: +- Provides immediate value without requiring authentication +- Creates a clear upgrade path for users +- Simplifies initial implementation +- Follows progressive disclosure principles + +### 3. Data Aggregation Strategy Implement a dedicated analytics service that pre-processes workout data for visualization. This service will be shared with the Profile tab's analytics features. Rationale: @@ -44,7 +53,7 @@ Rationale: - Separates presentation logic from data processing - Supports both history visualization and profile analytics -### 3. History Visualization Approach +### 4. History Visualization Approach Focus on providing clear, chronological views of workout history with rich filtering and search capabilities. Rationale: @@ -53,7 +62,7 @@ Rationale: - Filtering by exercise, type, and other attributes enables targeted review - Integration with Profile tab analytics provides deeper insights when needed -### 4. Nostr Integration Strategy +### 5. Nostr Integration Strategy Implement a tiered approach to Nostr integration, starting with basic publishing capabilities in the MVP and expanding to full synchronization in future versions. Rationale: @@ -62,8 +71,60 @@ Rationale: - Addresses core user needs first - Builds foundation for more advanced features +### 6. Visual Indicators for Nostr Workouts +Use the app's primary purple color as a visual indicator for Nostr-published workouts, applied to strategic UI elements. + +Rationale: +- Creates clear visual distinction between local and Nostr workouts +- Leverages existing brand color for positive association +- Provides consistent visual language across the app +- Enhances scannability of workout history + ## Technical Design +### Visual Design for Nostr Integration + +#### Workout Source Indicators +We will use the following visual indicators to clearly communicate workout source: + +1. **Local-only workouts**: Standard card with gray icon +2. **Nostr-published workouts**: + - Primary purple border or accent + - Purple cloud icon + - Optional purple title text +3. **Nostr-only workouts** (not stored locally): + - Full purple background with white text + - Cloud download icon for import action + +#### Authentication State UI +When not authenticated: +- Show only local workouts +- Display a banner with login prompt +- Use the NostrLoginSheet component for consistent login experience +- Provide clear messaging about benefits of Nostr login + +```tsx +// Example authentication state handling +{!isAuthenticated ? ( + + + Login with Nostr to access more features: + + + • Sync workouts across devices + • Back up your workout history + • Share workouts with friends + + + +) : null} +``` + ### Core Components ```typescript @@ -334,12 +395,46 @@ Note: Analytics Dashboard and Progress Tracking features have been moved to the - Full cross-device synchronization via Nostr - Collaborative workouts with friends +### Advanced Nostr Integration (Future Epochs) +- **Two-Way Synchronization**: + - Automatic sync of workouts between devices + - Conflict resolution for workouts modified on multiple devices + - Background sync with configurable frequency + - Offline queue for changes made without connectivity + +- **Relay Selection & Management**: + - User-configurable relay preferences + - Performance-based relay prioritization + - Automatic relay discovery + - Relay health monitoring and fallback strategies + +- **Enhanced Privacy Controls**: + - Granular sharing permissions for workout data + - Private/public workout toggles + - Selective metric sharing (e.g., share exercises but not weights) + - Time-limited sharing options + +- **Data Portability & Backup**: + - Automated backup to preferred relays + - Export/import of complete workout history + - Migration tools between apps supporting the same Nostr standards + - Archiving options for older workouts + +- **Social Features**: + - Workout sharing with specific users or groups + - Collaborative workout planning + - Training partner matching + - Coach/client relationship management + - Achievement sharing and celebrations + ### Known Limitations - Performance may degrade with very large workout histories - Complex analytics require significant processing - Limited by available device storage - Some features require online connectivity - Nostr relay availability affects sync reliability +- Initial implementation will have limited cross-device sync capabilities +- Relay selection and management will be simplified in early versions ## Integration with Profile Tab diff --git a/docs/design/cache-management.md b/docs/design/cache-management.md index 4d62b4d..2d42f23 100644 --- a/docs/design/cache-management.md +++ b/docs/design/cache-management.md @@ -1,960 +1,179 @@ -# POWR App: Cache Management Implementation Guide - -This document outlines the implementation of cache management features in the POWR fitness app, including data synchronization options and cache clearing functions. - -## 1. Overview - -The cache management system will allow users to: -1. Sync their library data from Nostr on demand -2. Clear different levels of cached data -3. View storage usage information -4. Configure automatic sync behavior - -## 2. Data Services Implementation - -### 2.1 CacheService Class - -Create a new service to handle cache management operations: - -```typescript -// lib/services/CacheService.ts -import { SQLiteDatabase } from 'expo-sqlite'; -import { schema } from '@/lib/db/schema'; - -export enum CacheClearLevel { - RELAY_CACHE = 'relay_cache', // Just temporary relay data - NETWORK_CONTENT = 'network', // Other users' content - EVERYTHING = 'everything' // Reset the entire database (except user credentials) -} - -export class CacheService { - private db: SQLiteDatabase; - - constructor(db: SQLiteDatabase) { - this.db = db; - } - - /** - * Get storage usage statistics by category - */ - async getStorageStats(): Promise<{ - userContent: number; // bytes used by user's content - networkContent: number; // bytes used by other users' content - temporaryCache: number; // bytes used by temporary cache - total: number; // total bytes used - }> { - // Implementation to calculate database size by category - // This is a placeholder - actual implementation would depend on platform-specific APIs - - // For SQLite, you'd typically query the page_count and page_size - // from sqlite_master to estimate database size - try { - const dbSize = await this.db.getFirstAsync<{ size: number }>( - "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()" - ); - - // For a more detailed breakdown, you'd need to query each table size - // This is simplified - const userContentSize = dbSize?.size ? Math.floor(dbSize.size * 0.4) : 0; - const networkContentSize = dbSize?.size ? Math.floor(dbSize.size * 0.4) : 0; - const tempCacheSize = dbSize?.size ? Math.floor(dbSize.size * 0.2) : 0; - - return { - userContent: userContentSize, - networkContent: networkContentSize, - temporaryCache: tempCacheSize, - total: dbSize?.size || 0 - }; - } catch (error) { - console.error('Error getting storage stats:', error); - return { - userContent: 0, - networkContent: 0, - temporaryCache: 0, - total: 0 - }; - } - } - - /** - * Clears cache based on the specified level - */ - async clearCache(level: CacheClearLevel, currentUserPubkey?: string): Promise { - switch(level) { - case CacheClearLevel.RELAY_CACHE: - // Clear temporary relay cache but keep all local content - await this.clearRelayCache(); - break; - - case CacheClearLevel.NETWORK_CONTENT: - // Clear other users' content but keep user's own content - if (!currentUserPubkey) throw new Error('User pubkey required for this operation'); - await this.clearNetworkContent(currentUserPubkey); - break; - - case CacheClearLevel.EVERYTHING: - // Reset everything except user credentials - await this.resetDatabase(); - break; - } - } - - /** - * Clears only temporary cache entries - */ - private async clearRelayCache(): Promise { - await this.db.withTransactionAsync(async () => { - // Clear cache_metadata table - await this.db.runAsync('DELETE FROM cache_metadata'); - }); - } - - /** - * Clears network content from other users - */ - private async clearNetworkContent(userPubkey: string): Promise { - await this.db.withTransactionAsync(async () => { - // Delete events from other users - await this.db.runAsync( - 'DELETE FROM nostr_events WHERE pubkey != ?', - [userPubkey] - ); - - // Delete references to those events - await this.db.runAsync( - `DELETE FROM event_tags - WHERE event_id NOT IN ( - SELECT id FROM nostr_events - )` - ); - - // Delete exercises that reference deleted events - await this.db.runAsync( - `DELETE FROM exercises - WHERE source = 'nostr' - AND nostr_event_id NOT IN ( - SELECT id FROM nostr_events - )` - ); - - // Delete tags for those exercises - await this.db.runAsync( - `DELETE FROM exercise_tags - WHERE exercise_id NOT IN ( - SELECT id FROM exercises - )` - ); - }); - } - - /** - * Resets the entire database but preserves user credentials - */ - private async resetDatabase(): Promise { - // Save user credentials before reset - const userProfiles = await this.db.getAllAsync( - 'SELECT * FROM user_profiles' - ); - - const userRelays = await this.db.getAllAsync( - 'SELECT * FROM user_relays' - ); - - // Reset schema (keeping user credentials) - await this.db.withTransactionAsync(async () => { - // Drop all content tables - await this.db.execAsync('DROP TABLE IF EXISTS exercises'); - await this.db.execAsync('DROP TABLE IF EXISTS exercise_tags'); - await this.db.execAsync('DROP TABLE IF EXISTS nostr_events'); - await this.db.execAsync('DROP TABLE IF EXISTS event_tags'); - await this.db.execAsync('DROP TABLE IF EXISTS cache_metadata'); - - // Recreate schema - await schema.createTables(this.db); - }); - - // Restore user profiles and relays - if (userProfiles.length > 0) { - await this.db.withTransactionAsync(async () => { - for (const profile of userProfiles) { - await this.db.runAsync( - `INSERT INTO user_profiles ( - pubkey, name, display_name, about, website, - picture, nip05, lud16, last_updated - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - profile.pubkey, - profile.name, - profile.display_name, - profile.about, - profile.website, - profile.picture, - profile.nip05, - profile.lud16, - profile.last_updated - ] - ); - } - - for (const relay of userRelays) { - await this.db.runAsync( - `INSERT INTO user_relays ( - pubkey, relay_url, read, write, created_at - ) VALUES (?, ?, ?, ?, ?)`, - [ - relay.pubkey, - relay.relay_url, - relay.read, - relay.write, - relay.created_at - ] - ); - } - }); - } - } -} -``` - -### 2.2 NostrSyncService Class - -Create a service for syncing content from Nostr: - -```typescript -// lib/services/NostrSyncService.ts -import { SQLiteDatabase } from 'expo-sqlite'; -import { EventCache } from '@/lib/db/services/EventCache'; -import { ExerciseService } from '@/lib/db/services/ExerciseService'; -import { NostrEvent } from '@/types/nostr'; -import { convertNostrToExercise } from '@/utils/converters'; - -export interface SyncProgress { - total: number; - processed: number; - status: 'idle' | 'syncing' | 'complete' | 'error'; - message?: string; -} - -export class NostrSyncService { - private db: SQLiteDatabase; - private eventCache: EventCache; - private exerciseService: ExerciseService; - private syncStatus: SyncProgress = { - total: 0, - processed: 0, - status: 'idle' - }; - - constructor(db: SQLiteDatabase) { - this.db = db; - this.eventCache = new EventCache(db); - this.exerciseService = new ExerciseService(db); - } - - /** - * Get current sync status - */ - getSyncStatus(): SyncProgress { - return { ...this.syncStatus }; - } - - /** - * Synchronize user's library from Nostr - */ - async syncUserLibrary( - pubkey: string, - ndk: any, // Replace with NDK type - progressCallback?: (progress: SyncProgress) => void - ): Promise { - try { - this.syncStatus = { - total: 0, - processed: 0, - status: 'syncing', - message: 'Starting sync...' - }; - - if (progressCallback) progressCallback(this.syncStatus); - - // 1. Fetch exercise events (kind 33401) - this.syncStatus.message = 'Fetching exercises...'; - if (progressCallback) progressCallback(this.syncStatus); - - const exercises = await this.fetchUserExercises(pubkey, ndk); - - this.syncStatus.total = exercises.length; - this.syncStatus.message = `Processing ${exercises.length} exercises...`; - if (progressCallback) progressCallback(this.syncStatus); - - // 2. Process each exercise - for (const exercise of exercises) { - await this.processExercise(exercise); - this.syncStatus.processed++; - - if (progressCallback) progressCallback(this.syncStatus); - } - - // 3. Update final status - this.syncStatus.status = 'complete'; - this.syncStatus.message = 'Sync completed successfully'; - if (progressCallback) progressCallback(this.syncStatus); - - } catch (error) { - this.syncStatus.status = 'error'; - this.syncStatus.message = `Sync error: ${error instanceof Error ? error.message : 'Unknown error'}`; - if (progressCallback) progressCallback(this.syncStatus); - throw error; - } - } - - /** - * Fetch user's exercise events from Nostr - */ - private async fetchUserExercises(pubkey: string, ndk: any): Promise { - // Use NDK subscription to fetch exercise events (kind 33401) - return new Promise((resolve) => { - const exercises: NostrEvent[] = []; - const filter = { kinds: [33401], authors: [pubkey] }; - const subscription = ndk.subscribe(filter); - - subscription.on('event', (event: NostrEvent) => { - exercises.push(event); - }); - - subscription.on('eose', () => { - resolve(exercises); - }); - }); - } - - /** - * Process and store an exercise event - */ - private async processExercise(event: NostrEvent): Promise { - // 1. Check if we already have this event - const existingEvent = await this.eventCache.getEvent(event.id); - if (existingEvent) return; - - // 2. Store the event - await this.eventCache.setEvent(event); - - // 3. Convert to Exercise and store in exercises table - const exercise = convertNostrToExercise(event); - await this.exerciseService.createExercise(exercise); - } -} -``` - -## 3. UI Components - -### 3.1 Modify SettingsDrawer.tsx - -Update the existing SettingsDrawer component to include the new cache-related menu items: - -```typescript -// Add these imports -import { useSQLiteContext } from 'expo-sqlite'; -import { CacheService, CacheClearLevel } from '@/lib/services/CacheService'; -import { NostrSyncService } from '@/lib/services/NostrSyncService'; -import { formatBytes } from '@/utils/format'; - -// Update the menuItems array to include Data Management options: - -const menuItems: MenuItem[] = [ - // ... existing menu items - - // Replace the "Data Sync" item with this: - { - id: 'data-management', - icon: Database, - label: 'Data Management', - onPress: () => { - closeDrawer(); - router.push('/settings/data-management'); - }, - }, - - // ... other menu items -]; -``` - -### 3.2 Create DataManagementScreen Component - -Create a new screen for data management: - -```typescript -// app/settings/data-management.tsx -import React, { useState, useEffect } from 'react'; -import { View, ScrollView, ActivityIndicator } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { useSQLiteContext } from 'expo-sqlite'; -import { useNDK, useNDKCurrentUser } from '@/lib/hooks/useNDK'; -import { CacheService, CacheClearLevel } from '@/lib/services/CacheService'; -import { NostrSyncService, SyncProgress } from '@/lib/services/NostrSyncService'; -import { formatBytes } from '@/utils/format'; -import { - RefreshCw, Trash2, Database, AlertTriangle, CheckCircle, AlertCircle -} from 'lucide-react-native'; -import { Progress } from '@/components/ui/progress'; -import { Switch } from '@/components/ui/switch'; -import { Separator } from '@/components/ui/separator'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; - -export default function DataManagementScreen() { - const db = useSQLiteContext(); - const { ndk } = useNDK(); - const { currentUser, isAuthenticated } = useNDKCurrentUser(); - - const [storageStats, setStorageStats] = useState({ - userContent: 0, - networkContent: 0, - temporaryCache: 0, - total: 0 - }); - - const [loading, setLoading] = useState(true); - const [syncing, setSyncing] = useState(false); - const [syncProgress, setSyncProgress] = useState({ - total: 0, - processed: 0, - status: 'idle' - }); - - const [showClearCacheAlert, setShowClearCacheAlert] = useState(false); - const [clearCacheLevel, setClearCacheLevel] = useState(CacheClearLevel.RELAY_CACHE); - const [clearCacheLoading, setClearCacheLoading] = useState(false); - - // Auto-sync settings - const [autoSyncEnabled, setAutoSyncEnabled] = useState(true); - - // Load storage stats - useEffect(() => { - const loadStats = async () => { - try { - setLoading(true); - const cacheService = new CacheService(db); - const stats = await cacheService.getStorageStats(); - setStorageStats(stats); - } catch (error) { - console.error('Error loading storage stats:', error); - } finally { - setLoading(false); - } - }; - - loadStats(); - }, [db]); - - // Handle manual sync - const handleSync = async () => { - if (!isAuthenticated || !currentUser?.pubkey || !ndk) return; - - try { - setSyncing(true); - - const syncService = new NostrSyncService(db); - await syncService.syncUserLibrary( - currentUser.pubkey, - ndk, - (progress) => { - setSyncProgress(progress); - } - ); - } catch (error) { - console.error('Sync error:', error); - } finally { - setSyncing(false); - } - }; - - // Trigger clear cache alert - const handleClearCacheClick = (level: CacheClearLevel) => { - setClearCacheLevel(level); - setShowClearCacheAlert(true); - }; - - // Handle clear cache action - const handleClearCache = async () => { - if (!isAuthenticated && clearCacheLevel !== CacheClearLevel.RELAY_CACHE) { - return; // Only allow clearing relay cache if not authenticated - } - - try { - setClearCacheLoading(true); - - const cacheService = new CacheService(db); - await cacheService.clearCache( - clearCacheLevel, - isAuthenticated ? currentUser?.pubkey : undefined - ); - - // Refresh stats - const stats = await cacheService.getStorageStats(); - setStorageStats(stats); - - setShowClearCacheAlert(false); - } catch (error) { - console.error('Error clearing cache:', error); - } finally { - setClearCacheLoading(false); - } - }; - - // Calculate sync progress percentage - const syncPercentage = syncProgress.total > 0 - ? Math.round((syncProgress.processed / syncProgress.total) * 100) - : 0; - - return ( - - Data Management - - {/* Storage Usage Section */} - - - - - Storage Usage - - - {loading ? ( - - - Loading storage statistics... - - ) : ( - <> - - User Content - - - - {formatBytes(storageStats.userContent)} - - - - Network Content - - - - {formatBytes(storageStats.networkContent)} - - - - Temporary Cache - - - - {formatBytes(storageStats.temporaryCache)} - - - - - - - - Total Storage - {formatBytes(storageStats.total)} - - - )} - - - - {/* Sync Section */} - - - - - Sync - - - {!isAuthenticated ? ( - - Login with Nostr to sync your library across devices. - - ) : ( - <> - {/* Auto-sync settings */} - - - Auto-sync on startup - - Automatically sync data when you open the app - - - - - - - - {/* Sync status and controls */} - {syncing ? ( - - - - {syncProgress.message || 'Syncing...'} - - - {syncProgress.processed}/{syncProgress.total} - - - - - {syncPercentage}% complete - - - ) : ( - <> - {syncProgress.status === 'complete' && ( - - - Last sync completed successfully - - )} - - {syncProgress.status === 'error' && ( - - - {syncProgress.message} - - )} - - )} - - - - )} - - - - {/* Cache Section */} - - - - - Clear Cache - - - - - - - Clears temporary data without affecting your workouts, exercises, or templates. - - - - - - - Clears exercises and templates from other users while keeping your own content. - - - - - - - Warning: This will delete ALL your local data. Your Nostr identity will be preserved, - but you'll need to re-sync your library from the network. - - - - - - - {/* Clear Cache Alert Dialog */} - - - - - {clearCacheLevel === CacheClearLevel.RELAY_CACHE && "Clear Temporary Cache?"} - {clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && "Clear Network Content?"} - {clearCacheLevel === CacheClearLevel.EVERYTHING && "Reset All Data?"} - - - {clearCacheLevel === CacheClearLevel.RELAY_CACHE && ( - - This will clear temporary data from the app. Your workouts, exercises, and templates will not be affected. - - )} - {clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && ( - - This will clear exercises and templates from other users. Your own content will be preserved. - - )} - {clearCacheLevel === CacheClearLevel.EVERYTHING && ( - - - - Warning: This is destructive! - - - This will delete ALL your local data. Your Nostr identity will be preserved, - but you'll need to re-sync your library from the network. - - - )} - - - - setShowClearCacheAlert(false)}> - Cancel - - - - {clearCacheLoading ? "Clearing..." : "Clear"} - - - - - - - ); -} -``` - -### 3.3 Add Formatting Utility - -Create a utility function to format byte sizes: - -```typescript -// utils/format.ts - -/** - * Format bytes to a human-readable string (KB, MB, etc.) - */ -export function formatBytes(bytes: number, decimals: number = 2): string { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} -``` - -### 3.4 Add Progress Component - -If you don't have a Progress component yet, create one: - -```typescript -// components/ui/progress.tsx -import React from 'react'; -import { View, StyleSheet } from 'react-native'; -import { useTheme } from '@react-navigation/native'; -import { cn } from '@/lib/utils'; - -interface ProgressProps { - value?: number; - max?: number; - className?: string; - indicatorClassName?: string; -} - -export function Progress({ - value = 0, - max = 100, - className, - indicatorClassName, - ...props -}: ProgressProps) { - const theme = useTheme(); - const percentage = Math.min(Math.max(0, (value / max) * 100), 100); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - indicator: { - transition: 'width 0.2s ease-in-out', - }, -}); -``` - -## 4. Implementation Steps - -### 4.1 Database Modifications - -1. Ensure your schema has the necessary tables: - - `nostr_events` - for storing raw Nostr events - - `event_tags` - for storing event tags - - `cache_metadata` - for tracking cache item usage - -2. Add cache-related columns to existing tables: - - Add `source` to exercises table (if not already present) - - Add `last_accessed` timestamp where relevant - -### 4.2 Implement Services - -1. Create `CacheService.ts` with methods for: - - Getting storage statistics - - Clearing different levels of cache - - Resetting database - -2. Create `NostrSyncService.ts` with methods for: - - Syncing user's library from Nostr - - Tracking sync progress - - Processing different types of Nostr events - -### 4.3 Add UI Components - -1. Update `SettingsDrawer.tsx` to include a "Data Management" option -2. Create `/settings/data-management.tsx` screen with: - - Storage usage visualization - - Sync controls - - Cache clearing options - -3. Create supporting components: - - Progress bar - - Alert dialogs for confirming destructive actions - -### 4.4 Integration with NDK - -1. Update the login flow to trigger library sync after successful login -2. Implement background sync based on user preferences -3. Add event handling to track when new events come in from subscriptions - -## 5. Testing Considerations - -1. Test with both small and large datasets: - - Create test accounts with varying amounts of data - - Test sync and clear operations with hundreds or thousands of events - -2. Test edge cases: - - Network disconnections during sync - - Interruptions during cache clearing - - Database corruption recovery - -3. Performance testing: - - Measure sync time for different dataset sizes - - Monitor memory usage during sync operations - - Test on low-end devices to ensure performance is acceptable - -4. Cross-platform testing: - - Ensure SQLite operations work consistently on iOS, Android, and web - - Test UI rendering on different screen sizes - - Verify that progress indicators update correctly on all platforms - -5. Data integrity testing: - - Verify that user content is preserved after clearing network cache - - Confirm that identity information persists after database reset - - Test that synced data matches what's available on relays - -## 6. User Experience Considerations - -1. Feedback and transparency: - - Always show clear feedback during long-running operations - - Display last sync time and status - - Make it obvious what will happen with each cache-clearing option - -2. Error handling: - - Provide clear error messages when sync fails - - Offer retry options for failed operations - - Include options to report sync issues - -3. Progressive disclosure: - - Hide advanced/dangerous options unless explicitly expanded - - Use appropriate warning colors for destructive actions - - Implement confirmation dialogs with clear explanations +# NDK Mobile Cache Integration Plan -4. Accessibility: - - Ensure progress indicators have appropriate ARIA labels - - Maintain adequate contrast for all text and UI elements - - Support screen readers for all status updates +This document outlines our plan to leverage the NDK mobile SQLite cache system throughout the POWR app to improve offline functionality, reduce network usage, and enhance performance. -## 7. Future Enhancements +## Overview -1. Selective sync: - - Allow users to choose which content types to sync (exercises, templates, etc.) - - Implement priority-based sync for most important content first +The NDK mobile library provides a robust SQLite-based caching system that includes: -2. Smart caching: - - Automatically prune rarely-used network content - - Keep frequently accessed content even when clearing other cache +1. **Profile Caching**: Stores user profiles with metadata +2. **Event Caching**: Stores Nostr events with efficient indexing +3. **Unpublished Event Queue**: Manages events pending publication +4. **Web of Trust Storage**: Maintains relationship scores -3. Backup and restore: - - Add export/import functionality for local backup - - Implement scheduled automatic backups +We will integrate this caching system across multiple components of our app to provide a better offline experience. -4. Advanced sync controls: - - Allow selection of specific relays for sync operations - - Implement bandwidth usage limits for sync +## Implementation Priorities -5. Conflict resolution: - - Develop a UI for handling conflicts when the same event has different versions - - Add options for manual content merging +### 1. Profile Image Caching -## 8. Conclusion +**Files to Modify:** +- `components/UserAvatar.tsx` +- Create new: `lib/db/services/ProfileImageCache.ts` -This implementation provides a robust solution for managing cache and synchronization in the POWR fitness app. By giving users clear control over their data and implementing efficient sync mechanisms, the app can provide a better experience across devices while respecting user preferences and device constraints. +**Functions to Implement:** +- `getProfileImageUri(pubkey, imageUrl)`: Get a cached image URI or download if needed +- `clearOldCache(maxAgeDays)`: Remove old cached images -The approach keeps user data secure while allowing for flexible network content management, ensuring that the app remains responsive and efficient even as the user's library grows. \ No newline at end of file +**Integration Points:** +- Update `UserAvatar` to use the cache service +- Add cache invalidation based on profile updates + +### 2. Publication Queue Service + +**Files to Modify:** +- `lib/db/services/PublicationQueueService.ts` + +**Functions to Enhance:** +- `queueEvent(event)`: Use NDK's unpublished events system +- `processQueue()`: Process events from NDK cache +- `getPendingEvents(limit)`: Get events from NDK cache +- `getPendingCount()`: Get count from NDK cache + +**Migration Strategy:** +1. Add NDK cache support +2. Dual-write period +3. Migrate existing queue +4. Remove custom implementation + +### 3. Social Feed Caching + +**Files to Modify:** +- `lib/social/socialFeedService.ts` +- `lib/hooks/useSocialFeed.ts` + +**Functions to Enhance:** +- `subscribeFeed(options)`: Check cache before subscription +- `getComments(eventId)`: Use cache for comments +- `resolveQuotedContent(event)`: Use cache for quoted content + +**Benefits:** +- Immediate display of previously viewed content +- Reduced network requests +- Offline browsing of previously viewed feeds + +### 4. Workout History + +**Files to Modify:** +- `lib/db/services/UnifiedWorkoutHistoryService.ts` + +**Functions to Enhance:** +- `getNostrWorkouts()`: Use NDK cache directly +- `importNostrWorkoutToLocal(eventId)`: Leverage cache for imports +- `subscribeToNostrWorkouts(pubkey, callback)`: Use cache for initial data + +**Benefits:** +- Faster workout history loading +- Offline access to workout history +- Reduced network usage + +### 5. Exercise Library + +**Files to Modify:** +- `lib/db/services/ExerciseService.ts` +- `lib/hooks/useExercises.ts` + +**Functions to Implement:** +- `getExercisesFromNostr()`: Use cache for exercises +- `getExerciseDetails(id)`: Get details from cache + +**Benefits:** +- Offline access to exercise library +- Faster exercise loading + +### 6. Workout Templates + +**Files to Modify:** +- `lib/db/services/TemplateService.ts` +- `lib/hooks/useTemplates.ts` + +**Functions to Enhance:** +- `getTemplateFromNostr(id)`: Use cache for templates +- `getTemplatesFromNostr()`: Get templates from cache + +**Benefits:** +- Offline access to templates +- Faster template loading + +### 7. Contact List & Following + +**Files to Modify:** +- `lib/hooks/useContactList.ts` +- `lib/hooks/useFeedState.ts` + +**Functions to Enhance:** +- `getContactList()`: Use cache for contact list +- `getFollowingList()`: Use cache for following list + +**Benefits:** +- Offline access to contacts +- Faster contact list loading + +### 8. General Media Cache + +**Files to Create:** +- `lib/db/services/MediaCacheService.ts` + +**Functions to Implement:** +- `cacheMedia(url, mimeType)`: Download and cache media +- `getMediaUri(url)`: Get cached media URI +- `clearOldCache(maxAgeDays)`: Remove old cached media + +**Integration Points:** +- Profile banners +- Exercise images +- Other media content + +## Implementation Approach + +For each component, we will: + +1. **Analyze Current Implementation**: Understand how data is currently fetched and stored +2. **Design Cache Integration**: Determine how to leverage NDK cache +3. **Implement Changes**: Modify code to use cache +4. **Test Offline Functionality**: Verify behavior when offline +5. **Measure Performance**: Compare before and after metrics + +## Technical Considerations + +### Cache Size Management + +- Implement cache size limits +- Add cache eviction policies +- Prioritize frequently accessed data + +### Cache Invalidation + +- Track data freshness +- Implement TTL (Time To Live) for cached data +- Update cache when new data is received + +### Error Handling + +- Graceful fallbacks when cache misses +- Recovery from cache corruption +- Logging for debugging + +## Success Metrics + +- Reduced network requests +- Faster app startup time +- Improved offline experience +- Reduced data usage +- Better battery life + +## Next Steps + +1. Begin with Profile Image Cache implementation +2. Move to Publication Queue Service +3. Continue with remaining components in priority order diff --git a/lib/db/migrations/add-nostr-fields-to-workouts.ts b/lib/db/migrations/add-nostr-fields-to-workouts.ts new file mode 100644 index 0000000..496e8ae --- /dev/null +++ b/lib/db/migrations/add-nostr-fields-to-workouts.ts @@ -0,0 +1,96 @@ +// lib/db/migrations/add-nostr-fields-to-workouts.ts +import { SQLiteDatabase } from 'expo-sqlite'; + +/** + * Add Nostr-specific fields to the workouts table + * This migration adds fields for tracking Nostr publication status + */ +export async function addNostrFieldsToWorkouts(db: SQLiteDatabase): Promise { + try { + console.log('[Migration] Adding Nostr fields to workouts table'); + + // Check if the workouts table exists + const tableExists = await db.getFirstAsync<{ count: number }>( + `SELECT count(*) as count FROM sqlite_master + WHERE type='table' AND name='workouts'` + ); + + if (!tableExists || tableExists.count === 0) { + console.log('[Migration] Workouts table does not exist, skipping migration'); + return; + } + + // Get current columns in the workouts table + const columns = await db.getAllAsync<{ name: string }>( + "PRAGMA table_info(workouts)" + ); + + const columnNames = columns.map(col => col.name); + + // Add nostr_published_at if it doesn't exist + if (!columnNames.includes('nostr_published_at')) { + console.log('[Migration] Adding nostr_published_at column to workouts table'); + await db.execAsync('ALTER TABLE workouts ADD COLUMN nostr_published_at INTEGER'); + } + + // Add nostr_relay_count if it doesn't exist + if (!columnNames.includes('nostr_relay_count')) { + console.log('[Migration] Adding nostr_relay_count column to workouts table'); + await db.execAsync('ALTER TABLE workouts ADD COLUMN nostr_relay_count INTEGER'); + } + + // Add nostr_event_id if it doesn't exist (this might already exist but we check anyway) + if (!columnNames.includes('nostr_event_id')) { + console.log('[Migration] Adding nostr_event_id column to workouts table'); + await db.execAsync('ALTER TABLE workouts ADD COLUMN nostr_event_id TEXT'); + } + + console.log('[Migration] Successfully added Nostr fields to workouts table'); + } catch (error) { + console.error('[Migration] Error adding Nostr fields to workouts:', error); + throw error; + } +} + +/** + * Create a table for tracking Nostr workout events + * This table will store Nostr event IDs and their associated workout IDs + */ +export async function createNostrWorkoutsTable(db: SQLiteDatabase): Promise { + try { + console.log('[Migration] Creating nostr_workouts table'); + + // Check if the nostr_workouts table already exists + const tableExists = await db.getFirstAsync<{ count: number }>( + `SELECT count(*) as count FROM sqlite_master + WHERE type='table' AND name='nostr_workouts'` + ); + + if (tableExists && tableExists.count > 0) { + console.log('[Migration] nostr_workouts table already exists, skipping creation'); + return; + } + + // Create the nostr_workouts table + await db.execAsync(` + CREATE TABLE nostr_workouts ( + id TEXT PRIMARY KEY, + workout_id TEXT NOT NULL, + event_id TEXT NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + relay_urls TEXT, + status TEXT NOT NULL DEFAULT 'published', + FOREIGN KEY(workout_id) REFERENCES workouts(id) ON DELETE CASCADE + ); + CREATE INDEX idx_nostr_workouts_workout_id ON nostr_workouts(workout_id); + CREATE INDEX idx_nostr_workouts_event_id ON nostr_workouts(event_id); + `); + + console.log('[Migration] Successfully created nostr_workouts table'); + } catch (error) { + console.error('[Migration] Error creating nostr_workouts table:', error); + throw error; + } +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index caeb31c..47e52ef 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -2,7 +2,7 @@ import { SQLiteDatabase } from 'expo-sqlite'; import { Platform } from 'react-native'; -export const SCHEMA_VERSION = 10; +export const SCHEMA_VERSION = 11; class Schema { private async getCurrentVersion(db: SQLiteDatabase): Promise { @@ -127,6 +127,24 @@ class Schema { throw error; } } + + async migrate_v11(db: SQLiteDatabase): Promise { + try { + console.log('[Schema] Running migration v11 - Adding Nostr fields to workouts'); + + // Import the migration functions + const { addNostrFieldsToWorkouts, createNostrWorkoutsTable } = await import('./migrations/add-nostr-fields-to-workouts'); + + // Run the migrations + await addNostrFieldsToWorkouts(db); + await createNostrWorkoutsTable(db); + + console.log('[Schema] Migration v11 completed successfully'); + } catch (error) { + console.error('[Schema] Error in migration v11:', error); + throw error; + } + } async createTables(db: SQLiteDatabase): Promise { try { @@ -184,6 +202,11 @@ class Schema { await this.migrate_v10(db); } + if (currentVersion < 11) { + console.log(`[Schema] Running migration from version ${currentVersion} to 11`); + await this.migrate_v11(db); + } + // Update schema version at the end of the transaction await this.updateSchemaVersion(db); }); @@ -217,6 +240,11 @@ class Schema { await this.migrate_v10(db); } + if (currentVersion < 11) { + console.log(`[Schema] Running migration from version ${currentVersion} to 11`); + await this.migrate_v11(db); + } + // Update schema version await this.updateSchemaVersion(db); @@ -616,4 +644,3 @@ class Schema { } export const schema = new Schema(); - diff --git a/lib/db/services/ConnectivityService.ts b/lib/db/services/ConnectivityService.ts index 5c5f878..655f095 100644 --- a/lib/db/services/ConnectivityService.ts +++ b/lib/db/services/ConnectivityService.ts @@ -1,5 +1,5 @@ // lib/services/ConnectivityService.ts -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; import { openDatabaseSync } from 'expo-sqlite'; @@ -9,7 +9,11 @@ import { openDatabaseSync } from 'expo-sqlite'; export class ConnectivityService { private static instance: ConnectivityService; private isOnline: boolean = false; + private lastOnlineTime: number | null = null; private listeners: Set<(isOnline: boolean) => void> = new Set(); + private syncListeners: Set<() => void> = new Set(); + private checkingStatus: boolean = false; + private offlineMode: boolean = false; // Singleton pattern static getInstance(): ConnectivityService { @@ -39,27 +43,132 @@ export class ConnectivityService { * Handle network state changes */ private handleNetworkChange = (state: NetInfoState): void => { + // Skip if in forced offline mode + if (this.offlineMode) { + return; + } + + const previousStatus = this.isOnline; const newOnlineStatus = state.isConnected === true && state.isInternetReachable !== false; // Only trigger updates if status actually changed if (this.isOnline !== newOnlineStatus) { this.isOnline = newOnlineStatus; + + // Update last online time if we're going online + if (newOnlineStatus) { + this.lastOnlineTime = Date.now(); + } + this.updateStatusInDatabase(newOnlineStatus); this.notifyListeners(); + + // If we're coming back online, trigger sync + if (newOnlineStatus && !previousStatus) { + console.log('[ConnectivityService] Network connection restored, triggering sync'); + this.triggerSync(); + } } } /** - * Perform an initial network status check + * Perform a network status check + * This can be called manually to force a check */ - private async checkNetworkStatus(): Promise { + async checkNetworkStatus(): Promise { + // Skip if already checking + if (this.checkingStatus) { + return this.isOnline; + } + + // Skip if in forced offline mode + if (this.offlineMode) { + return false; + } + try { + this.checkingStatus = true; + + // First get the network state from NetInfo const state = await NetInfo.fetch(); - this.isOnline = state.isConnected === true && state.isInternetReachable !== false; - this.updateStatusInDatabase(this.isOnline); + + // Perform a more thorough check if NetInfo says we're connected + let isReachable = state.isConnected === true && state.isInternetReachable !== false; + + // If NetInfo says we're connected, do an additional check with a fetch request + if (isReachable) { + try { + // Try to fetch a small resource with a timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + // Use a reliable endpoint that should always be available + const response = await fetch('https://www.google.com/generate_204', { + method: 'HEAD', + signal: controller.signal, + cache: 'no-cache', + }); + + clearTimeout(timeoutId); + + // If we get a response, we're definitely online + isReachable = response.status === 204 || response.ok; + } catch (fetchError) { + // If the fetch fails, we might not have real connectivity + console.log('[ConnectivityService] Fetch check failed:', fetchError); + isReachable = false; + } + } + + const previousStatus = this.isOnline; + this.isOnline = isReachable; + + // Update last online time if we're online + if (this.isOnline) { + this.lastOnlineTime = Date.now(); + } + + // Update database and notify if status changed + if (previousStatus !== this.isOnline) { + this.updateStatusInDatabase(this.isOnline); + this.notifyListeners(); + + // If we're coming back online, trigger sync + if (this.isOnline && !previousStatus) { + console.log('[ConnectivityService] Network connection restored, triggering sync'); + this.triggerSync(); + } + } + + return this.isOnline; } catch (error) { console.error('[ConnectivityService] Error checking network status:', error); this.isOnline = false; + return false; + } finally { + this.checkingStatus = false; + } + } + + /** + * Set forced offline mode (for testing or battery saving) + */ + setOfflineMode(enabled: boolean): void { + this.offlineMode = enabled; + + if (enabled) { + // Force offline status + const previousStatus = this.isOnline; + this.isOnline = false; + + // Update database and notify if status changed + if (previousStatus) { + this.updateStatusInDatabase(false); + this.notifyListeners(); + } + } else { + // Re-check network status when disabling offline mode + this.checkNetworkStatus(); } } @@ -69,11 +178,30 @@ export class ConnectivityService { private async updateStatusInDatabase(isOnline: boolean): Promise { try { const db = openDatabaseSync('powr.db'); + + // Create the app_status table if it doesn't exist + await db.runAsync(` + CREATE TABLE IF NOT EXISTS app_status ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at INTEGER + ) + `); + await db.runAsync( `INSERT OR REPLACE INTO app_status (key, value, updated_at) VALUES (?, ?, ?)`, ['online_status', isOnline ? 'online' : 'offline', Date.now()] ); + + // Also store last online time if we're online + if (isOnline && this.lastOnlineTime) { + await db.runAsync( + `INSERT OR REPLACE INTO app_status (key, value, updated_at) + VALUES (?, ?, ?)`, + ['last_online_time', this.lastOnlineTime.toString(), Date.now()] + ); + } } catch (error) { console.error('[ConnectivityService] Error updating status in database:', error); } @@ -92,6 +220,19 @@ export class ConnectivityService { }); } + /** + * Trigger sync operations when coming back online + */ + private triggerSync(): void { + this.syncListeners.forEach(listener => { + try { + listener(); + } catch (error) { + console.error('[ConnectivityService] Error in sync listener:', error); + } + }); + } + /** * Get current network connectivity status */ @@ -99,6 +240,13 @@ export class ConnectivityService { return this.isOnline; } + /** + * Get the last time the device was online + */ + getLastOnlineTime(): number | null { + return this.lastOnlineTime; + } + /** * Register a listener for connectivity changes */ @@ -110,6 +258,14 @@ export class ConnectivityService { this.listeners.delete(listener); }; } + + /** + * Register a sync listener that will be called when connectivity is restored + */ + addSyncListener(listener: () => void): () => void { + this.syncListeners.add(listener); + return () => this.syncListeners.delete(listener); + } } /** @@ -121,13 +277,82 @@ export function useConnectivity() { return ConnectivityService.getInstance().getConnectionStatus(); }); + const [lastOnlineTime, setLastOnlineTime] = useState(() => { + // Initialize with last online time + return ConnectivityService.getInstance().getLastOnlineTime(); + }); + + // Use a ref to track if we're currently checking connectivity + const isCheckingRef = useRef(false); + useEffect(() => { // Register listener for updates - const removeListener = ConnectivityService.getInstance().addListener(setIsOnline); + const removeListener = ConnectivityService.getInstance().addListener((online) => { + setIsOnline(online); + if (online) { + setLastOnlineTime(Date.now()); + } + }); + + // Perform an initial check when the component mounts + if (!isCheckingRef.current) { + isCheckingRef.current = true; + ConnectivityService.getInstance().checkNetworkStatus() + .then(online => { + setIsOnline(online); + if (online) { + setLastOnlineTime(Date.now()); + } + }) + .finally(() => { + isCheckingRef.current = false; + }); + } + + // Set up periodic checks while the component is mounted + const intervalId = setInterval(() => { + if (!isCheckingRef.current) { + isCheckingRef.current = true; + ConnectivityService.getInstance().checkNetworkStatus() + .then(online => { + setIsOnline(online); + if (online) { + setLastOnlineTime(Date.now()); + } + }) + .finally(() => { + isCheckingRef.current = false; + }); + } + }, 30000); // Check every 30 seconds // Clean up on unmount - return removeListener; + return () => { + removeListener(); + clearInterval(intervalId); + }; }, []); - return { isOnline }; -} \ No newline at end of file + // Function to manually check network status + const checkConnection = useCallback(async (): Promise => { + if (isCheckingRef.current) return isOnline; + + isCheckingRef.current = true; + try { + const online = await ConnectivityService.getInstance().checkNetworkStatus(); + setIsOnline(online); + if (online) { + setLastOnlineTime(Date.now()); + } + return online; + } finally { + isCheckingRef.current = false; + } + }, [isOnline]); + + return { + isOnline, + lastOnlineTime, + checkConnection + }; +} diff --git a/lib/db/services/NostrWorkoutHistoryService.ts b/lib/db/services/NostrWorkoutHistoryService.ts new file mode 100644 index 0000000..9037842 --- /dev/null +++ b/lib/db/services/NostrWorkoutHistoryService.ts @@ -0,0 +1,352 @@ +// 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 { + 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 { + 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 { + 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(); + + // 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 { + 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 []; + } + } +} diff --git a/lib/db/services/EnhancedWorkoutHistoryService.ts b/lib/db/services/UnifiedWorkoutHistoryService.ts similarity index 50% rename from lib/db/services/EnhancedWorkoutHistoryService.ts rename to lib/db/services/UnifiedWorkoutHistoryService.ts index 6e03168..8a3fc07 100644 --- a/lib/db/services/EnhancedWorkoutHistoryService.ts +++ b/lib/db/services/UnifiedWorkoutHistoryService.ts @@ -1,9 +1,13 @@ -// lib/db/services/EnhancedWorkoutHistoryService.ts +// lib/db/services/UnifiedWorkoutHistoryService.ts import { SQLiteDatabase } from 'expo-sqlite'; +import { NDK, NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk-mobile'; import { Workout } from '@/types/workout'; -import { format, startOfDay, endOfDay, startOfMonth, endOfMonth } from 'date-fns'; +import { parseWorkoutRecord, ParsedWorkoutRecord } from '@/types/nostr-workout'; import { DbService } from '../db-service'; import { WorkoutExercise } from '@/types/exercise'; +import { useNDKStore } from '@/lib/stores/ndk'; +import { generateId } from '@/utils/ids'; +import { format, startOfDay, endOfDay, startOfMonth, endOfMonth } from 'date-fns'; // Define workout filter interface export interface WorkoutFilters { @@ -27,11 +31,20 @@ export interface WorkoutSyncStatus { // Define export format type export type ExportFormat = 'csv' | 'json'; -export class WorkoutHistoryService { +/** + * Unified service for managing workout history from both local database and Nostr + * This service combines the functionality of WorkoutHistoryService, EnhancedWorkoutHistoryService, + * and NostrWorkoutHistoryService into a single, comprehensive service. + */ +export class UnifiedWorkoutHistoryService { private db: DbService; + private ndk?: NDK; + private activeSubscriptions: Map = new Map(); constructor(database: SQLiteDatabase) { this.db = new DbService(database); + // Use type assertion to handle NDK type mismatch + this.ndk = useNDKStore.getState().ndk as unknown as NDK | undefined; } /** @@ -131,7 +144,7 @@ export class WorkoutHistoryService { if (filters.source && filters.source.length > 0) { // Handle 'both' specially if (filters.source.includes('both')) { - conditions.push(`(w.source = 'local' OR w.source = 'nostr')`); + conditions.push(`(w.source = 'local' OR w.source = 'nostr' OR w.source = 'both')`); } else { conditions.push(`w.source IN (${filters.source.map(() => '?').join(', ')})`); params.push(...filters.source); @@ -371,7 +384,7 @@ export class WorkoutHistoryService { return { isLocal: workout.source === 'local' || workout.source === 'both', - isPublished: Boolean(workout.nostr_event_id), + isPublished: workout.source === 'nostr' || workout.source === 'both', eventId: workout.nostr_event_id || undefined, relayCount: workout.nostr_relay_count || undefined, lastPublished: workout.nostr_published_at || undefined @@ -383,9 +396,7 @@ export class WorkoutHistoryService { } /** - * Publish a workout to Nostr - * This method updates the database with Nostr publication details - * The actual publishing is handled by NostrWorkoutService + * Update the Nostr status of a workout in the local database */ async updateWorkoutNostrStatus( workoutId: string, @@ -397,7 +408,12 @@ export class WorkoutHistoryService { `UPDATE workouts SET nostr_event_id = ?, nostr_published_at = ?, - nostr_relay_count = ? + nostr_relay_count = ?, + source = CASE + WHEN source = 'local' THEN 'both' + WHEN source IS NULL THEN 'nostr' + ELSE source + END WHERE id = ?`, [eventId, Date.now(), relayCount, workoutId] ); @@ -410,9 +426,47 @@ export class WorkoutHistoryService { } /** - * Get all workouts, sorted by date in descending order + * Get all workouts from both local database and Nostr + * @param options Options for filtering workouts + * @returns Promise with array of workouts */ - async getAllWorkouts(): Promise { + async getAllWorkouts(options: { + limit?: number; + offset?: number; + includeNostr?: boolean; + isAuthenticated?: boolean; + } = {}): Promise { + 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 || !this.ndk) { + 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 { try { const workouts = await this.db.getAllAsync<{ id: string; @@ -467,11 +521,219 @@ export class WorkoutHistoryService { return result; } catch (error) { - console.error('Error getting workouts:', error); + console.error('Error getting local workouts:', error); throw error; } } + /** + * Get workouts from Nostr + */ + private async getNostrWorkouts(): Promise { + if (!this.ndk) { + return []; + } + + 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: ParsedWorkoutRecord, 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 => { + // Get a human-readable name for the exercise + // If ex.name is an ID (typically starts with numbers or contains special characters), + // use a more descriptive name based on the exercise type + const isLikelyId = /^[0-9]|:|\//.test(ex.name); + const exerciseName = isLikelyId + ? this.getExerciseNameFromId(ex.id) || `Exercise ${ex.id.substring(0, 4)}` + : ex.name; + + return { + id: ex.id, + title: exerciseName, + 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() + } + }; + } + + /** + * Try to get a human-readable exercise name from the exercise ID + * This method attempts to look up the exercise in the local database + * or use a mapping of common exercise IDs to names + */ + private getExerciseNameFromId(exerciseId: string): string | null { + try { + // Common exercise name mappings + const commonExercises: Record = { + 'bench-press': 'Bench Press', + 'squat': 'Squat', + 'deadlift': 'Deadlift', + 'shoulder-press': 'Shoulder Press', + 'pull-up': 'Pull Up', + 'push-up': 'Push Up', + 'barbell-row': 'Barbell Row', + 'leg-press': 'Leg Press', + 'lat-pulldown': 'Lat Pulldown', + 'bicep-curl': 'Bicep Curl', + 'tricep-extension': 'Tricep Extension', + 'leg-curl': 'Leg Curl', + 'leg-extension': 'Leg Extension', + 'calf-raise': 'Calf Raise', + 'sit-up': 'Sit Up', + 'plank': 'Plank', + 'lunge': 'Lunge', + 'dip': 'Dip', + 'chin-up': 'Chin Up', + 'military-press': 'Military Press' + }; + + // Check if it's a common exercise + for (const [key, name] of Object.entries(commonExercises)) { + if (exerciseId.includes(key)) { + return name; + } + } + + // Handle specific format seen in logs: "Exercise m8l4pk" + if (exerciseId.match(/^m[0-9a-z]{5,6}$/)) { + // This appears to be a short ID, return a generic name with a number + const shortId = exerciseId.substring(1, 4).toUpperCase(); + return `Exercise ${shortId}`; + } + + // If not found in common exercises, try to extract a name from the ID + // Remove any numeric prefixes and special characters + const cleanId = exerciseId.replace(/^[0-9]+:?/, '').replace(/[-_]/g, ' '); + + // Capitalize each word + if (cleanId && cleanId.length > 0) { + return cleanId + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + return "Generic Exercise"; + } catch (error) { + console.error('Error getting exercise name from ID:', error); + return "Unknown Exercise"; + } + } + + /** + * 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(); + + // 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); + } + /** * Get workouts for a specific date */ @@ -622,137 +884,350 @@ export class WorkoutHistoryService { } } -/** + /** * Get the total number of workouts */ -async getWorkoutCount(): Promise { - 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; + async getWorkoutCount(): Promise { + 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 { - try { - console.log(`[EnhancedWorkoutHistoryService] Getting exercises for workout: ${workoutId}`); + + /** + * Publish a local workout to Nostr + * @param workoutId ID of the workout to publish + * @returns Promise with the event ID if successful + */ + async publishWorkoutToNostr(workoutId: string): Promise { + if (!this.ndk) { + throw new Error('NDK not initialized'); + } - 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] - ); + // Get the workout from the local database + const workout = await this.getWorkoutDetails(workoutId); - console.log(`[EnhancedWorkoutHistoryService] Found ${exercises.length} exercises for workout ${workoutId}`); + if (!workout) { + throw new Error(`Workout with ID ${workoutId} not found`); + } - const result: WorkoutExercise[] = []; + // Create a new NDK event + const event = new NDKEvent(this.ndk as any); - 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}`); + // Set the kind to workout record + event.kind = 1301; + + // Set the content to the workout notes + event.content = workout.notes || ''; + + // Add tags + event.tags = [ + ['d', workout.id], + ['title', workout.title], + ['type', workout.type], + ['start', Math.floor(workout.startTime / 1000).toString()], + ['completed', workout.isCompleted.toString()] + ]; + + // Add end time if available + if (workout.endTime) { + event.tags.push(['end', Math.floor(workout.endTime / 1000).toString()]); + } + + // Add exercise tags + for (const exercise of workout.exercises) { + for (const set of exercise.sets) { + event.tags.push([ + 'exercise', + `33401:${exercise.id}`, + '', + set.weight?.toString() || '', + set.reps?.toString() || '', + set.rpe?.toString() || '', + set.type + ]); } - - // 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] + } + + // Add workout tags if available + if (workout.tags && workout.tags.length > 0) { + for (const tag of workout.tags) { + event.tags.push(['t', tag]); + } + } + + // Publish the event + await event.publish(); + + // Update the workout in the local database with the Nostr event ID + await this.updateWorkoutNostrStatus(workoutId, event.id || '', 1); + + return event.id || ''; + } + + /** + * Import a Nostr workout into the local database + * @param eventId Nostr event ID to import + * @returns Promise with the local workout ID + */ + async importNostrWorkoutToLocal(eventId: string): Promise { + if (!this.ndk) { + throw new Error('NDK not initialized'); + } + + // Fetch the event from Nostr + const events = await useNDKStore.getState().fetchEventsByFilter({ + ids: [eventId] + }); + + if (events.length === 0) { + throw new Error(`No event found with ID ${eventId}`); + } + + const event = events[0]; + + // Parse the workout + const parsedWorkout = parseWorkoutRecord(event as any); + + if (!parsedWorkout) { + throw new Error(`Failed to parse workout from event ${eventId}`); + } + + // Convert to Workout type + const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event); + + // Update the source to include both local and Nostr + workout.availability.source = ['nostr', 'local']; + + // Save the workout to the local database + const workoutId = await this.saveNostrWorkoutToLocal(workout); + + return workoutId; + } + + /** + * Save a Nostr workout to the local database + * @param workout Workout to save + * @returns Promise with the local workout ID + */ + private async saveNostrWorkoutToLocal(workout: Workout): Promise { + try { + // First check if the workout already exists + const existingWorkout = await this.db.getFirstAsync<{ id: string }>( + `SELECT id FROM workouts WHERE id = ? OR nostr_event_id = ?`, + [workout.id, workout.availability?.nostrEventId] ); - console.log(`[EnhancedWorkoutHistoryService] Found ${tags.length} tags for exercise ${exercise.exercise_id}`); - - // Get the sets for this exercise - const sets = await this.db.getAllAsync<{ + if (existingWorkout) { + // Workout already exists, update it + await this.db.runAsync( + `UPDATE workouts + SET title = ?, + type = ?, + start_time = ?, + end_time = ?, + is_completed = ?, + notes = ?, + nostr_event_id = ?, + nostr_published_at = ?, + nostr_relay_count = ?, + source = 'both' + WHERE id = ?`, + [ + workout.title, + workout.type, + workout.startTime, + workout.endTime || null, + workout.isCompleted ? 1 : 0, + workout.notes || '', + workout.availability?.nostrEventId, + workout.availability?.nostrPublishedAt, + 1, + existingWorkout.id + ] + ); + + return existingWorkout.id; + } else { + // Workout doesn't exist, insert it + await this.db.runAsync( + `INSERT INTO workouts ( + id, title, type, start_time, end_time, is_completed, notes, + nostr_event_id, nostr_published_at, nostr_relay_count, source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + workout.id, + workout.title, + workout.type, + workout.startTime, + workout.endTime || null, + workout.isCompleted ? 1 : 0, + workout.notes || '', + workout.availability?.nostrEventId, + workout.availability?.nostrPublishedAt, + 1, + 'both' + ] + ); + + return workout.id; + } + } catch (error) { + console.error('Error saving Nostr workout to local database:', error); + throw error; + } + } + + /** + * Create a subscription for real-time Nostr workout updates + * @param pubkey User's public key + * @param callback Function to call when new workouts are received + * @returns Subscription ID that can be used to unsubscribe + */ + subscribeToNostrWorkouts(pubkey: string, callback: (workout: Workout) => void): string { + if (!this.ndk) { + console.warn('NDK not initialized, cannot subscribe to Nostr workouts'); + return ''; + } + + const subId = `workout-sub-${generateId('local')}`; + + // Create subscription + const sub = (this.ndk as any).subscribe({ + kinds: [1301], // Workout records + authors: [pubkey], + limit: 50 + }, { closeOnEose: false }); + + // Handle events + sub.on('event', (event: NDKEvent) => { + try { + const parsedWorkout = parseWorkoutRecord(event); + if (parsedWorkout) { + const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event); + callback(workout); + } + } catch (error) { + console.error('Error processing Nostr workout event:', error); + } + }); + + // Store subscription for later cleanup + this.activeSubscriptions.set(subId, sub); + + return subId; + } + + /** + * Unsubscribe from Nostr workout updates + * @param subId Subscription ID returned from subscribeToNostrWorkouts + */ + unsubscribeFromNostrWorkouts(subId: string): void { + const sub = this.activeSubscriptions.get(subId); + if (sub) { + sub.stop(); + this.activeSubscriptions.delete(subId); + } + } + + /** + * Helper method to load workout exercises and sets + */ + private async getWorkoutExercises(workoutId: string): Promise { + try { + const exercises = 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; + exercise_id: string; + display_order: number; + notes: string | null; created_at: number; updated_at: number; }>( - `SELECT * FROM workout_sets - WHERE workout_exercise_id = ? - ORDER BY id`, - [exercise.id] + `SELECT we.* FROM workout_exercises we + WHERE we.workout_id = ? + ORDER BY we.display_order`, + [workoutId] ); - console.log(`[EnhancedWorkoutHistoryService] Found ${sets.length} sets for exercise ${exercise.id}`); + const result: WorkoutExercise[] = []; - // 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 - })); + 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'] } + }); + } - 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'] } - }); + return result; + } catch (error) { + console.error('Error getting workout exercises:', error); + return []; } - - console.log(`[EnhancedWorkoutHistoryService] Returning ${result.length} processed exercises for workout ${workoutId}`); - return result; - } catch (error) { - console.error('[EnhancedWorkoutHistoryService] Error getting workout exercises:', error); - return []; } } -} \ No newline at end of file diff --git a/lib/db/services/WorkoutHIstoryService.ts b/lib/db/services/WorkoutHIstoryService.ts deleted file mode 100644 index 56b55d2..0000000 --- a/lib/db/services/WorkoutHIstoryService.ts +++ /dev/null @@ -1,318 +0,0 @@ -// lib/db/services/WorkoutHistoryService.ts -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; - - constructor(database: SQLiteDatabase) { - this.db = new DbService(database); - } - - /** - * Get all workouts, sorted by date in descending order - */ - async getAllWorkouts(): Promise { - 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; - }>( - `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] - } - }); - } - - return result; - } catch (error) { - console.error('Error getting workouts:', error); - throw error; - } - } - - /** - * Get workouts for a specific date - */ - async getWorkoutsByDate(date: Date): Promise { - try { - const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - const endOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999).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; - }>( - `SELECT * FROM workouts - WHERE start_time >= ? AND start_time <= ? - ORDER BY start_time DESC`, - [startOfDay, endOfDay] - ); - - 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; - } - } - - /** - * Get all dates that have workouts within a month - */ - async getWorkoutDatesInMonth(year: number, month: number): Promise { - try { - const startOfMonth = new Date(year, month, 1).getTime(); - const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59, 999).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 <= ?`, - [startOfMonth, endOfMonth] - ); - - 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 { - 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; - }>( - `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] - } - }; - } catch (error) { - console.error('Error getting workout details:', error); - throw error; - } - } - - /** - * Get the total number of workouts - */ - async getWorkoutCount(): Promise { - 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 { - 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 []; - } - } -} \ No newline at end of file diff --git a/lib/hooks/useAnalytics.ts b/lib/hooks/useAnalytics.ts index 440e32d..af64fd9 100644 --- a/lib/hooks/useAnalytics.ts +++ b/lib/hooks/useAnalytics.ts @@ -2,6 +2,9 @@ import { useEffect, useMemo } from 'react'; import { analyticsService } from '@/lib/services/AnalyticsService'; import { useWorkoutService } from '@/components/DatabaseProvider'; +import { useSQLiteContext } from 'expo-sqlite'; +import { UnifiedWorkoutHistoryService } from '@/lib/db/services/UnifiedWorkoutHistoryService'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; /** * Hook to provide access to the analytics service @@ -10,19 +13,24 @@ import { useWorkoutService } from '@/components/DatabaseProvider'; */ export function useAnalytics() { const workoutService = useWorkoutService(); + const db = useSQLiteContext(); + const { isAuthenticated } = useNDKCurrentUser(); + + // Create UnifiedWorkoutHistoryService instance + const unifiedWorkoutHistoryService = useMemo(() => { + return new UnifiedWorkoutHistoryService(db); + }, [db]); // Initialize the analytics service with the necessary services useEffect(() => { analyticsService.setWorkoutService(workoutService); - - // We could also set the NostrWorkoutService here if needed - // analyticsService.setNostrWorkoutService(nostrWorkoutService); + analyticsService.setNostrWorkoutHistoryService(unifiedWorkoutHistoryService); return () => { // Clear the cache when the component unmounts analyticsService.invalidateCache(); }; - }, [workoutService]); + }, [workoutService, unifiedWorkoutHistoryService]); // Create a memoized object with the analytics methods const analytics = useMemo(() => ({ @@ -41,6 +49,25 @@ export function useAnalytics() { getPersonalRecords: (exerciseIds?: string[], limit?: number) => analyticsService.getPersonalRecords(exerciseIds, limit), + // New methods for Profile tab + getWorkoutFrequency: (period: 'daily' | 'weekly' | 'monthly', limit?: number) => + analyticsService.getWorkoutFrequency(period, limit), + + getVolumeProgression: (period: 'daily' | 'weekly' | 'monthly', limit?: number) => + analyticsService.getVolumeProgression(period, limit), + + getStreakMetrics: () => + analyticsService.getStreakMetrics(), + + getSummaryStatistics: () => + analyticsService.getSummaryStatistics(), + + getMostFrequentExercises: (limit?: number) => + analyticsService.getMostFrequentExercises(limit), + + getWorkoutsByDayOfWeek: () => + analyticsService.getWorkoutsByDayOfWeek(), + // Cache management invalidateCache: () => analyticsService.invalidateCache() }), []); diff --git a/lib/hooks/useNostrWorkoutHistory.ts b/lib/hooks/useNostrWorkoutHistory.ts new file mode 100644 index 0000000..8b0f31d --- /dev/null +++ b/lib/hooks/useNostrWorkoutHistory.ts @@ -0,0 +1,48 @@ +// lib/hooks/useNostrWorkoutHistory.ts +import { useMemo } from 'react'; +import { useSQLiteContext } from 'expo-sqlite'; +import { UnifiedWorkoutHistoryService } from '@/lib/db/services/UnifiedWorkoutHistoryService'; +import { Workout } from '@/types/workout'; + +/** + * @deprecated This hook is deprecated. Please use useWorkoutHistory instead. + * See docs/design/WorkoutHistory/MigrationGuide.md for migration instructions. + */ +export function useNostrWorkoutHistory() { + console.warn( + 'useNostrWorkoutHistory is deprecated. ' + + 'Please use useWorkoutHistory instead. ' + + 'See docs/design/WorkoutHistory/MigrationGuide.md for migration instructions.' + ); + + const db = useSQLiteContext(); + + // Create UnifiedWorkoutHistoryService instance + const unifiedWorkoutHistoryService = useMemo(() => { + return new UnifiedWorkoutHistoryService(db); + }, [db]); + + // Return a compatibility layer that mimics the old API + return { + getAllWorkouts: (options?: { + limit?: number; + offset?: number; + includeNostr?: boolean; + isAuthenticated?: boolean; + }): Promise => { + return unifiedWorkoutHistoryService.getAllWorkouts(options); + }, + + getWorkoutsByDate: (date: Date): Promise => { + return unifiedWorkoutHistoryService.getWorkoutsByDate(date); + }, + + getWorkoutDatesInMonth: (year: number, month: number): Promise => { + return unifiedWorkoutHistoryService.getWorkoutDatesInMonth(year, month); + }, + + getWorkoutDetails: (workoutId: string): Promise => { + return unifiedWorkoutHistoryService.getWorkoutDetails(workoutId); + } + }; +} diff --git a/lib/hooks/useWorkoutHistory.ts b/lib/hooks/useWorkoutHistory.ts new file mode 100644 index 0000000..6b53378 --- /dev/null +++ b/lib/hooks/useWorkoutHistory.ts @@ -0,0 +1,160 @@ +// lib/hooks/useWorkoutHistory.ts +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useSQLiteContext } from 'expo-sqlite'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; +import { UnifiedWorkoutHistoryService, WorkoutFilters } from '@/lib/db/services/UnifiedWorkoutHistoryService'; +import { Workout } from '@/types/workout'; + +interface UseWorkoutHistoryOptions { + includeNostr?: boolean; + filters?: WorkoutFilters; + realtime?: boolean; +} + +/** + * Hook for fetching and managing workout history from both local database and Nostr + * This hook uses the UnifiedWorkoutHistoryService to provide a consistent interface + * for working with workouts from multiple sources. + */ +export function useWorkoutHistory(options: UseWorkoutHistoryOptions = {}) { + const { + includeNostr = true, + filters, + realtime = false + } = options; + + const db = useSQLiteContext(); + const { currentUser, isAuthenticated } = useNDKCurrentUser(); + const [workouts, setWorkouts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Initialize service + const workoutHistoryService = useMemo(() => new UnifiedWorkoutHistoryService(db), [db]); + + // Load workouts function + const loadWorkouts = useCallback(async () => { + try { + setLoading(true); + setError(null); + + let result: Workout[]; + + if (filters) { + // Use filters if provided + result = await workoutHistoryService.filterWorkouts(filters); + } else { + // Otherwise get all workouts + result = await workoutHistoryService.getAllWorkouts({ + includeNostr, + isAuthenticated + }); + } + + setWorkouts(result); + return result; + } catch (err) { + console.error('Error loading workouts:', err); + setError(err instanceof Error ? err : new Error(String(err))); + return []; + } finally { + setLoading(false); + setRefreshing(false); + } + }, [workoutHistoryService, filters, includeNostr, isAuthenticated]); + + // Initial load + useEffect(() => { + loadWorkouts(); + }, [loadWorkouts]); + + // Set up real-time subscription if enabled + useEffect(() => { + if (!realtime || !isAuthenticated || !currentUser?.pubkey || !includeNostr) { + return; + } + + // Subscribe to real-time updates + const subId = workoutHistoryService.subscribeToNostrWorkouts( + currentUser.pubkey, + (newWorkout) => { + setWorkouts(prev => { + // Check if workout already exists + const exists = prev.some(w => w.id === newWorkout.id); + if (exists) { + // Update existing workout + return prev.map(w => w.id === newWorkout.id ? newWorkout : w); + } else { + // Add new workout + return [newWorkout, ...prev]; + } + }); + } + ); + + // Clean up subscription + return () => { + workoutHistoryService.unsubscribeFromNostrWorkouts(subId); + }; + }, [workoutHistoryService, currentUser?.pubkey, isAuthenticated, realtime, includeNostr]); + + // Refresh function for pull-to-refresh + const refresh = useCallback(() => { + setRefreshing(true); + return loadWorkouts(); + }, [loadWorkouts]); + + // Get workouts for a specific date + const getWorkoutsByDate = useCallback(async (date: Date): Promise => { + try { + return await workoutHistoryService.getWorkoutsByDate(date); + } catch (err) { + console.error('Error getting workouts by date:', err); + return []; + } + }, [workoutHistoryService]); + + // Get workout details + const getWorkoutDetails = useCallback(async (workoutId: string): Promise => { + try { + return await workoutHistoryService.getWorkoutDetails(workoutId); + } catch (err) { + console.error('Error getting workout details:', err); + return null; + } + }, [workoutHistoryService]); + + // Publish workout to Nostr + const publishWorkoutToNostr = useCallback(async (workoutId: string): Promise => { + try { + return await workoutHistoryService.publishWorkoutToNostr(workoutId); + } catch (err) { + console.error('Error publishing workout to Nostr:', err); + throw err; + } + }, [workoutHistoryService]); + + // Import Nostr workout to local + const importNostrWorkoutToLocal = useCallback(async (eventId: string): Promise => { + try { + return await workoutHistoryService.importNostrWorkoutToLocal(eventId); + } catch (err) { + console.error('Error importing Nostr workout:', err); + throw err; + } + }, [workoutHistoryService]); + + return { + workouts, + loading, + error, + refreshing, + refresh, + getWorkoutsByDate, + getWorkoutDetails, + publishWorkoutToNostr, + importNostrWorkoutToLocal, + service: workoutHistoryService + }; +} diff --git a/lib/initNDK.ts b/lib/initNDK.ts index 8e6e136..19f1170 100644 --- a/lib/initNDK.ts +++ b/lib/initNDK.ts @@ -4,6 +4,10 @@ import NDK, { NDKCacheAdapterSqlite } from '@nostr-dev-kit/ndk-mobile'; import * as SecureStore from 'expo-secure-store'; import { RelayService, DEFAULT_RELAYS } from '@/lib/db/services/RelayService'; import { extendNDK } from '@/types/ndk-extensions'; +import { ConnectivityService } from '@/lib/db/services/ConnectivityService'; + +// Connection timeout in milliseconds +const CONNECTION_TIMEOUT = 5000; /** * Initialize NDK with relays @@ -46,12 +50,43 @@ export async function initializeNDK() { // Set the NDK instance in the RelayService relayService.setNDK(ndk); + // Check network connectivity before attempting to connect + const connectivityService = ConnectivityService.getInstance(); + const isOnline = await connectivityService.checkNetworkStatus(); + + if (!isOnline) { + console.log('[NDK] No network connectivity detected, skipping relay connections'); + return { + ndk, + relayService, + connectedRelayCount: 0, + connectedRelays: [], + offlineMode: true + }; + } + try { - console.log('[NDK] Connecting to relays...'); - await ndk.connect(); + console.log('[NDK] Connecting to relays with timeout...'); - // Wait a moment for connections to establish - await new Promise(resolve => setTimeout(resolve, 2000)); + // Create a promise that will reject after the timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Connection timeout')), CONNECTION_TIMEOUT); + }); + + // Race the connect operation against the timeout + await Promise.race([ + ndk.connect(), + timeoutPromise + ]).catch(error => { + if (error.message === 'Connection timeout') { + console.warn('[NDK] Connection timeout reached, continuing in offline mode'); + throw error; // Re-throw to be caught by outer try/catch + } + throw error; + }); + + // Wait a moment for connections to establish (but with a shorter timeout) + await new Promise(resolve => setTimeout(resolve, 1000)); // Get updated relay statuses const relaysWithStatus = await relayService.getAllRelaysWithStatus(); @@ -73,7 +108,8 @@ export async function initializeNDK() { ndk, relayService, connectedRelayCount: connectedRelays.length, - connectedRelays + connectedRelays, + offlineMode: connectedRelays.length === 0 }; } catch (error) { console.error('[NDK] Error during connection:', error); @@ -82,7 +118,8 @@ export async function initializeNDK() { ndk, relayService, connectedRelayCount: 0, - connectedRelays: [] + connectedRelays: [], + offlineMode: true }; } -} \ No newline at end of file +} diff --git a/lib/services/AnalyticsService.ts b/lib/services/AnalyticsService.ts index bc59649..ec9587c 100644 --- a/lib/services/AnalyticsService.ts +++ b/lib/services/AnalyticsService.ts @@ -2,6 +2,8 @@ import { Workout } from '@/types/workout'; import { WorkoutService } from '@/lib/db/services/WorkoutService'; import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; +import { NostrWorkoutHistoryService } from '@/lib/db/services/NostrWorkoutHistoryService'; +import { UnifiedWorkoutHistoryService } from '@/lib/db/services/UnifiedWorkoutHistoryService'; /** * Workout statistics data structure @@ -15,6 +17,29 @@ export interface WorkoutStats { frequencyByDay: number[]; // 0 = Sunday, 6 = Saturday } +/** + * Analytics data for a specific period + */ +export interface AnalyticsData { + date: Date; + workoutCount: number; + totalVolume: number; + totalDuration: number; + exerciseCount: number; +} + +/** + * Summary statistics for the profile overview + */ +export interface SummaryStatistics { + totalWorkouts: number; + totalVolume: number; + totalExercises: number; + averageDuration: number; + currentStreak: number; + longestStreak: number; +} + /** * Progress point for tracking exercise progress */ @@ -40,6 +65,20 @@ export interface PersonalRecord { value: number; date: number; }; + metric?: 'weight' | 'reps' | 'volume'; +} + +/** + * Exercise progress data structure + */ +export interface ExerciseProgress { + exerciseId: string; + exerciseName: string; + dataPoints: { + date: Date; + value: number; + workoutId: string; + }[]; } /** @@ -48,7 +87,10 @@ export interface PersonalRecord { export class AnalyticsService { private workoutService: WorkoutService | null = null; private nostrWorkoutService: NostrWorkoutService | null = null; + private nostrWorkoutHistoryService: NostrWorkoutHistoryService | null = null; + private unifiedWorkoutHistoryService: UnifiedWorkoutHistoryService | null = null; private cache = new Map(); + private includeNostr: boolean = true; // Set the workout service (called from React components) setWorkoutService(service: WorkoutService): void { @@ -60,6 +102,26 @@ export class AnalyticsService { this.nostrWorkoutService = service; } + // Set the Nostr workout history service (called from React components) + setNostrWorkoutHistoryService(service: NostrWorkoutHistoryService | UnifiedWorkoutHistoryService): void { + if (service instanceof NostrWorkoutHistoryService) { + this.nostrWorkoutHistoryService = service; + } else { + this.unifiedWorkoutHistoryService = service; + } + } + + // Set the Unified workout history service (called from React components) + setUnifiedWorkoutHistoryService(service: UnifiedWorkoutHistoryService): void { + this.unifiedWorkoutHistoryService = service; + } + + // Set whether to include Nostr workouts in analytics + setIncludeNostr(include: boolean): void { + this.includeNostr = include; + this.invalidateCache(); // Clear cache when this setting changes + } + /** * Get workout statistics for a given period */ @@ -278,7 +340,23 @@ export class AnalyticsService { break; } - // Get workouts from both local and Nostr sources + // If we have the UnifiedWorkoutHistoryService, use it to get all workouts + if (this.unifiedWorkoutHistoryService) { + return this.unifiedWorkoutHistoryService.getAllWorkouts({ + includeNostr: this.includeNostr, + isAuthenticated: true + }); + } + + // Fallback to NostrWorkoutHistoryService if UnifiedWorkoutHistoryService is not available + if (this.nostrWorkoutHistoryService) { + return this.nostrWorkoutHistoryService.getAllWorkouts({ + includeNostr: this.includeNostr, + isAuthenticated: true + }); + } + + // Fallback to using WorkoutService if NostrWorkoutHistoryService is not available let localWorkouts: Workout[] = []; if (this.workoutService) { localWorkouts = await this.workoutService.getWorkoutsByDateRange(startDate.getTime(), now.getTime()); @@ -301,6 +379,287 @@ export class AnalyticsService { return allWorkouts.sort((a, b) => b.startTime - a.startTime); } + /** + * Get workout frequency data for a specific period + */ + async getWorkoutFrequency(period: 'daily' | 'weekly' | 'monthly', limit: number = 30): Promise { + const cacheKey = `frequency-${period}-${limit}`; + if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); + + const now = new Date(); + let startDate: Date; + + // Determine date range based on period + switch (period) { + case 'daily': + startDate = new Date(now); + startDate.setDate(now.getDate() - limit); + break; + case 'weekly': + startDate = new Date(now); + startDate.setDate(now.getDate() - (limit * 7)); + break; + case 'monthly': + startDate = new Date(now); + startDate.setMonth(now.getMonth() - limit); + break; + } + + // Get workouts in the date range + const workouts = await this.getWorkoutsForPeriod('all'); + const filteredWorkouts = workouts.filter(w => w.startTime >= startDate.getTime()); + + // Group workouts by period + const groupedData = new Map(); + + filteredWorkouts.forEach(workout => { + const date = new Date(workout.startTime); + let key: string; + + // Format date key based on period + switch (period) { + case 'daily': + key = date.toISOString().split('T')[0]; // YYYY-MM-DD + break; + case 'weekly': + // Get the week number (approximate) + const weekNum = Math.floor(date.getDate() / 7); + key = `${date.getFullYear()}-${date.getMonth() + 1}-W${weekNum}`; + break; + case 'monthly': + key = `${date.getFullYear()}-${date.getMonth() + 1}`; + break; + } + + // Initialize or update group data + if (!groupedData.has(key)) { + groupedData.set(key, { + date: new Date(date), + workoutCount: 0, + totalVolume: 0, + totalDuration: 0, + exerciseCount: 0 + }); + } + + const data = groupedData.get(key)!; + data.workoutCount++; + data.totalVolume += workout.totalVolume || 0; + data.totalDuration += (workout.endTime || date.getTime()) - workout.startTime; + data.exerciseCount += workout.exercises?.length || 0; + }); + + // Convert to array and sort by date + const result = Array.from(groupedData.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + this.cache.set(cacheKey, result); + return result; + } + + /** + * Get volume progression data for a specific period + */ + async getVolumeProgression(period: 'daily' | 'weekly' | 'monthly', limit: number = 30): Promise { + // This uses the same data as getWorkoutFrequency but is separated for clarity + return this.getWorkoutFrequency(period, limit); + } + + /** + * Get streak metrics (current and longest streak) + */ + async getStreakMetrics(): Promise<{ current: number; longest: number }> { + const cacheKey = 'streak-metrics'; + if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); + + // Get all workouts + const workouts = await this.getWorkoutsForPeriod('all'); + + // Extract dates and sort them + const dates = workouts.map(w => new Date(w.startTime).toISOString().split('T')[0]); + const uniqueDates = [...new Set(dates)].sort(); + + // Calculate current streak + let currentStreak = 0; + let longestStreak = 0; + let currentStreakCount = 0; + + // Get today's date in YYYY-MM-DD format + const today = new Date().toISOString().split('T')[0]; + + // Check if the most recent workout was today or yesterday + if (uniqueDates.length > 0) { + const lastWorkoutDate = uniqueDates[uniqueDates.length - 1]; + const lastWorkoutTime = new Date(lastWorkoutDate).getTime(); + const todayTime = new Date(today).getTime(); + + // If the last workout was within the last 48 hours, count the streak + if (todayTime - lastWorkoutTime <= 48 * 60 * 60 * 1000) { + currentStreakCount = 1; + + // Count consecutive days backwards + for (let i = uniqueDates.length - 2; i >= 0; i--) { + const currentDate = new Date(uniqueDates[i]); + const nextDate = new Date(uniqueDates[i + 1]); + + // Check if dates are consecutive + const diffTime = nextDate.getTime() - currentDate.getTime(); + const diffDays = diffTime / (1000 * 60 * 60 * 24); + + if (diffDays <= 1.1) { // Allow for some time zone differences + currentStreakCount++; + } else { + break; + } + } + } + } + + // Calculate longest streak + let tempStreak = 1; + for (let i = 1; i < uniqueDates.length; i++) { + const currentDate = new Date(uniqueDates[i - 1]); + const nextDate = new Date(uniqueDates[i]); + + // Check if dates are consecutive + const diffTime = nextDate.getTime() - currentDate.getTime(); + const diffDays = diffTime / (1000 * 60 * 60 * 24); + + if (diffDays <= 1.1) { // Allow for some time zone differences + tempStreak++; + } else { + if (tempStreak > longestStreak) { + longestStreak = tempStreak; + } + tempStreak = 1; + } + } + + // Check if the final streak is the longest + if (tempStreak > longestStreak) { + longestStreak = tempStreak; + } + + const result = { + current: currentStreakCount, + longest: longestStreak + }; + + this.cache.set(cacheKey, result); + return result; + } + + /** + * Get summary statistics for the profile overview + */ + async getSummaryStatistics(): Promise { + const cacheKey = 'summary-statistics'; + if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); + + // Get all workouts + const workouts = await this.getWorkoutsForPeriod('all'); + + // Calculate total workouts + const totalWorkouts = workouts.length; + + // Calculate total volume + const totalVolume = workouts.reduce((sum, workout) => sum + (workout.totalVolume || 0), 0); + + // Calculate total unique exercises + const exerciseIds = new Set(); + workouts.forEach(workout => { + workout.exercises?.forEach(exercise => { + exerciseIds.add(exercise.exerciseId || exercise.id); + }); + }); + const totalExercises = exerciseIds.size; + + // Calculate average duration + const totalDuration = workouts.reduce((sum, workout) => { + const duration = (workout.endTime || workout.startTime) - workout.startTime; + return sum + duration; + }, 0); + const averageDuration = totalWorkouts > 0 ? totalDuration / totalWorkouts : 0; + + // Get streak metrics + const { current, longest } = await this.getStreakMetrics(); + + const result = { + totalWorkouts, + totalVolume, + totalExercises, + averageDuration, + currentStreak: current, + longestStreak: longest + }; + + this.cache.set(cacheKey, result); + return result; + } + + /** + * Get most frequent exercises + */ + async getMostFrequentExercises(limit: number = 5): Promise<{ exerciseId: string; exerciseName: string; count: number }[]> { + const cacheKey = `frequent-exercises-${limit}`; + if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); + + // Get all workouts + const workouts = await this.getWorkoutsForPeriod('all'); + + // Count exercise occurrences + const exerciseCounts = new Map(); + + workouts.forEach(workout => { + workout.exercises?.forEach(exercise => { + const id = exercise.exerciseId || exercise.id; + if (!exerciseCounts.has(id)) { + exerciseCounts.set(id, { name: exercise.title, count: 0 }); + } + exerciseCounts.get(id)!.count++; + }); + }); + + // Convert to array and sort by count + const result = Array.from(exerciseCounts.entries()) + .map(([id, data]) => ({ + exerciseId: id, + exerciseName: data.name, + count: data.count + })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + + this.cache.set(cacheKey, result); + return result; + } + + /** + * Get workout distribution by day of week + */ + async getWorkoutsByDayOfWeek(): Promise<{ day: number; count: number }[]> { + const cacheKey = 'workouts-by-day'; + if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); + + // Get all workouts + const workouts = await this.getWorkoutsForPeriod('all'); + + // Initialize counts for each day (0 = Sunday, 6 = Saturday) + const dayCounts = [0, 0, 0, 0, 0, 0, 0]; + + // Count workouts by day + workouts.forEach(workout => { + const day = new Date(workout.startTime).getDay(); + dayCounts[day]++; + }); + + // Convert to result format + const result = dayCounts.map((count, day) => ({ day, count })); + + this.cache.set(cacheKey, result); + return result; + } + /** * Invalidate cache when new workouts are added */ diff --git a/types/feed.ts b/types/feed.ts index a1fb0f2..dc71fbb 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -61,7 +61,7 @@ export type UpdateEntryFn = (id: string, updater: (entry: AnyFeedEntry) => AnyFe // Feed filter options export interface FeedFilterOptions { - feedType: 'following' | 'powr' | 'global' | 'user-activity'; + feedType: 'following' | 'powr' | 'global' | 'user-activity' | 'workout-history'; since?: number; until?: number; limit?: number; @@ -78,5 +78,5 @@ export interface FeedOptions { enabled?: boolean; filterFn?: FeedEntryFilterFn; sortFn?: (a: AnyFeedEntry, b: AnyFeedEntry) => number; - feedType?: 'following' | 'powr' | 'global' | 'user-activity'; // Added this property + feedType?: 'following' | 'powr' | 'global' | 'user-activity' | 'workout-history'; } diff --git a/types/shared.ts b/types/shared.ts index b02106f..abb23d8 100644 --- a/types/shared.ts +++ b/types/shared.ts @@ -34,6 +34,11 @@ export interface LastSyncedInfo { export interface ContentAvailability { source: StorageSource[]; lastSynced?: LastSyncedInfo; + + // Nostr-specific fields for enhanced tracking + nostrEventId?: string; + nostrPublishedAt?: number; + nostrRelayCount?: number; } /** diff --git a/types/workout.ts b/types/workout.ts index 8e9be73..02574c8 100644 --- a/types/workout.ts +++ b/types/workout.ts @@ -3,6 +3,7 @@ import type { WorkoutTemplate, TemplateType } from './templates'; import type { BaseExercise } from './exercise'; import type { SyncableContent } from './shared'; import type { NostrEvent } from './nostr'; +import { generateId } from '@/utils/ids'; /** * Core workout status types @@ -200,13 +201,13 @@ export type WorkoutAction = */ export function templateToWorkout(template: WorkoutTemplate): Workout { return { - id: crypto.randomUUID(), + id: generateId('nostr'), title: template.title, type: template.type, exercises: template.exercises.map(ex => ({ ...ex.exercise, sets: Array(ex.targetSets).fill({ - id: crypto.randomUUID(), + id: generateId('nostr'), type: 'normal', reps: ex.targetReps, isCompleted: false @@ -261,4 +262,4 @@ export function createNostrWorkoutEvent(workout: Workout): NostrEvent { ], created_at: Math.floor(Date.now() / 1000) }; - } \ No newline at end of file + } diff --git a/utils/formatTime.ts b/utils/formatTime.ts index 8d686db..425e98c 100644 --- a/utils/formatTime.ts +++ b/utils/formatTime.ts @@ -1,7 +1,24 @@ // utils/formatTime.ts +/** + * Format milliseconds into MM:SS format + */ export function formatTime(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - } \ No newline at end of file + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +/** + * Format milliseconds into a human-readable duration (e.g., "1h 30m") + */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +}