Add NDK Mobile Cache Integration Plan and enhance offline functionality

This commit is contained in:
DocNR 2025-03-23 15:53:34 -04:00
parent a5d98ba251
commit 9043179643
37 changed files with 3918 additions and 1716 deletions

View File

@ -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 <Text> 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

View File

@ -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<Workout[]>([]);
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() {
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Nostr Login Prompt */}
{!isAuthenticated && (
<View className="mx-4 mt-4 p-4 bg-primary/5 rounded-lg border border-primary/20">
<Text className="text-foreground font-medium mb-2">
Connect with Nostr
</Text>
<Text className="text-muted-foreground mb-4">
Login with Nostr to see your workouts from other devices and back up your workout history.
</Text>
<Button
variant="purple"
onPress={() => setIsLoginSheetOpen(true)}
className="w-full"
>
<Text className="text-white">Login with Nostr</Text>
</Button>
</View>
)}
{isLoading && !refreshing ? (
<View className="items-center justify-center py-20">
<ActivityIndicator size="large" className="mb-4" />
@ -133,6 +230,27 @@ export default function HistoryScreen() {
</View>
)}
{isAuthenticated && (
<View className="flex-row justify-end mb-4">
<Pressable
onPress={() => setIncludeNostr(!includeNostr)}
style={{
backgroundColor: includeNostr ? primaryBgColor : mutedBgColor,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 9999,
}}
>
<Text style={{
color: includeNostr ? primaryTextColor : mutedTextColor,
fontSize: 14,
}}>
{includeNostr ? 'Showing All Workouts' : 'Local Workouts Only'}
</Text>
</Pressable>
</View>
)}
{groupedWorkouts.map(([month, monthWorkouts]) => (
<View key={month} className="mb-6">
<Text className="text-foreground text-xl font-semibold mb-4">
@ -155,6 +273,12 @@ export default function HistoryScreen() {
{/* Add bottom padding for better scrolling experience */}
<View className="h-20" />
</ScrollView>
{/* Nostr Login Sheet */}
<NostrLoginSheet
open={isLoginSheetOpen}
onClose={() => setIsLoginSheetOpen(false)}
/>
</View>
);
}

View File

@ -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<WorkoutStats | null>(null);
const [records, setRecords] = useState<PersonalRecord[]>([]);
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 (
<ScrollView className="flex-1 p-4">
<PeriodSelector period={period} setPeriod={setPeriod} />
<View className="flex-row justify-between items-center px-4 mb-2">
<PeriodSelector period={period} setPeriod={setPeriod} />
{isAuthenticated && (
<TouchableOpacity
onPress={() => setIncludeNostr(!includeNostr)}
className="flex-row items-center"
>
<CloudIcon
size={16}
className={includeNostr ? "text-primary" : "text-muted-foreground"}
/>
<Text
className={`ml-1 text-sm ${includeNostr ? "text-primary" : "text-muted-foreground"}`}
>
Nostr
</Text>
<Switch
value={includeNostr}
onValueChange={setIncludeNostr}
trackColor={{ false: '#767577', true: 'hsl(var(--purple))' }}
thumbColor={'#f4f3f4'}
className="ml-1"
/>
</TouchableOpacity>
)}
</View>
{/* Workout Summary */}
<Card className="mb-4">
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
<CardContent className="p-4">
<Text className="text-lg font-semibold mb-2">Workout Summary</Text>
<Text className="mb-1">Workouts: {stats?.workoutCount || 0}</Text>
@ -193,7 +225,7 @@ export default function ProgressScreen() {
</Card>
{/* Workout Frequency Chart */}
<Card className="mb-4">
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
<CardContent className="p-4">
<Text className="text-lg font-semibold mb-2">Workout Frequency</Text>
<WorkoutFrequencyChart />
@ -201,7 +233,7 @@ export default function ProgressScreen() {
</Card>
{/* Muscle Group Distribution */}
<Card className="mb-4">
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
<CardContent className="p-4">
<Text className="text-lg font-semibold mb-2">Exercise Distribution</Text>
<ExerciseDistributionChart />
@ -209,7 +241,7 @@ export default function ProgressScreen() {
</Card>
{/* Personal Records */}
<Card className="mb-4">
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
<CardContent className="p-4">
<Text className="text-lg font-semibold mb-2">Personal Records</Text>
{records.length === 0 ? (
@ -235,14 +267,17 @@ export default function ProgressScreen() {
</CardContent>
</Card>
{/* Note about future implementation */}
<Card className="mb-4">
<CardContent className="p-4">
<Text className="text-muted-foreground text-center">
Note: This is a placeholder UI. In the future, this tab will display real analytics based on your workout history.
</Text>
</CardContent>
</Card>
{/* Nostr integration note */}
{isAuthenticated && includeNostr && (
<Card className="mb-4 border-primary">
<CardContent className="p-4 flex-row items-center">
<CloudIcon size={16} className="text-primary mr-2" />
<Text className="text-muted-foreground flex-1">
Analytics include workouts from Nostr. Toggle the switch above to view only local workouts.
</Text>
</CardContent>
</Card>
)}
</ScrollView>
);
}

View File

@ -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() {
/>
</View>
);
}
}
// Export the component wrapped with the offline state HOC
export default withOfflineState(FollowingScreen);

View File

@ -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<FlatList>(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() {
/>
</View>
);
}
}
// Export the component wrapped with the offline state HOC
export default withOfflineState(GlobalScreen);

View File

@ -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() {
/>
</View>
);
}
}
// Export the component wrapped with the offline state HOC
export default withOfflineState(PowerScreen);

View File

@ -53,7 +53,16 @@ export default function WorkoutLayout() {
gestureDirection: 'horizontal',
}}
/>
<Stack.Screen
name="workout/[id]"
options={{
presentation: 'card',
animation: 'default',
gestureEnabled: true,
gestureDirection: 'horizontal',
}}
/>
</Stack>
</>
);
}
}

View File

@ -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() {
</View>
</Modal>
);
}
}

View File

@ -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<string | null>(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<null>((_, 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() {
)}
</View>
);
}
}
// Export the component wrapped with the offline state HOC
export default withWorkoutOfflineState(WorkoutDetailScreen);

View File

@ -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 (
<View className="flex-1 items-center justify-center bg-black">
<Text className="text-white text-xl">Loading POWR...</Text>
<View style={{
flex: 1,
backgroundColor: '#000000',
alignItems: 'center',
justifyContent: 'center',
}}>
<Text style={{
color: '#ffffff',
fontSize: 32,
fontWeight: 'bold',
}}>POWR</Text>
<ActivityIndicator
size="large"
color="#ffffff"
style={{ marginTop: 30 }}
/>
</View>
);
};
@ -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 */}
<RelayInitializer />
{/* Add OfflineIndicator to show network status */}
<OfflineIndicator />
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen
@ -176,4 +252,4 @@ export default function RootLayout() {
</GestureHandlerRootView>
</ErrorBoundary>
);
}
}

View File

@ -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<NodeJS.Timeout | null>(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 (
<Animated.View
style={{
transform: [{ translateY: slideAnim }],
position: 'absolute',
top: headerHeight, // Position below the header
left: 0,
right: 0,
zIndex: 50,
}}
className="bg-yellow-500/90 dark:bg-yellow-600/90"
>
<View className="flex-row items-center justify-between px-4 py-2">
<View className="flex-row items-center flex-1">
<WifiOffIcon size={18} color="#ffffff" style={{ marginRight: 8 }} />
<View className="flex-1">
<Text style={{ color: '#ffffff', fontWeight: '500', fontSize: 14 }}>Offline Mode</Text>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12 }}>{lastOnlineText}</Text>
</View>
</View>
<TouchableOpacity
onPress={handleRefresh}
style={{
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 9999,
padding: 8,
marginLeft: 8
}}
>
<RefreshCwIcon size={16} color="#ffffff" />
</TouchableOpacity>
</View>
</Animated.View>
);
}

View File

@ -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;
}
}

View File

@ -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<SplashScreenProps> = ({ onFinish }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [error, setError] = useState<string | null>(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 (
<View style={styles.container}>
{/* Use a static image as fallback */}
{/* Logo image */}
<Image
source={require('../assets/images/splash.png')}
style={styles.image}
resizeMode="contain"
onLoad={handleImageLoad}
onError={handleImageError}
/>
{/* Show app name as text for better reliability */}
<Text style={styles.appName}>POWR</Text>
{/* Loading indicator */}
<ActivityIndicator
size="large"
color="#ffffff"
style={styles.loader}
/>
{/* Error message if image fails to load */}
{error && (
<Text style={styles.errorText}>{error}</Text>
)}
</View>
);
};
@ -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;
export default SimpleSplashScreen;

View File

@ -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 (
<View className="flex-1 items-center justify-center p-6">
<View className="bg-muted rounded-xl p-6 items-center max-w-md w-full">
<WifiOffIcon size={48} color="#666" style={{ marginBottom: 16 }} />
<Text style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 8, color: '#333' }}>
You're offline
</Text>
<Text style={{ textAlign: 'center', marginBottom: 16, color: '#666' }}>
Social features require an internet connection. Your workouts are still being saved locally and will sync when you're back online.
</Text>
<Text style={{ fontSize: 12, marginBottom: 24, color: 'rgba(102,102,102,0.7)' }}>
{lastOnlineText}
</Text>
<TouchableOpacity
onPress={handleRefresh}
style={{
backgroundColor: '#007bff',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center'
}}
>
<RefreshCwIcon size={16} color="#fff" style={{ marginRight: 8 }} />
<Text style={{ color: '#fff', fontWeight: '500' }}>
Check Connection
</Text>
</TouchableOpacity>
</View>
</View>
);
}
/**
* A higher-order component that wraps social screens to handle offline state
*/
export function withOfflineState<P extends object>(
Component: React.ComponentType<P>
): React.FC<P> {
return (props: P) => {
const { isOnline } = useConnectivity();
if (!isOnline) {
return <SocialOfflineState />;
}
return <Component {...props} />;
};
}

View File

@ -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<ViewRef, ViewProps>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
'rounded-lg border border-border bg-card shadow-sm shadow-foreground/10',
className
)}
{...props}
/>
));
// 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 <Text>{children}</Text>;
}
// If it's an array, map over it and recursively wrap each child
if (Array.isArray(children)) {
return children.map((child, index) => (
<React.Fragment key={index}>{wrapTextNodes(child)}</React.Fragment>
));
}
// 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<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
return (
<View
ref={ref}
className={cn(
'rounded-lg border border-border bg-card shadow-sm shadow-foreground/10',
className
)}
{...props}
>
{wrapTextNodes(children)}
</View>
);
});
Card.displayName = 'Card';
const CardHeader = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
));
const CardHeader = React.forwardRef<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
return (
<View ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props}>
{wrapTextNodes(children)}
</View>
);
});
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<TextRef, React.ComponentPropsWithoutRef<typeof Text>>(
@ -42,16 +90,24 @@ const CardDescription = React.forwardRef<TextRef, TextProps>(({ className, ...pr
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
<TextClassContext.Provider value='text-card-foreground'>
<View ref={ref} className={cn('p-6 pt-0', className)} {...props} />
</TextClassContext.Provider>
));
const CardContent = React.forwardRef<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
return (
<TextClassContext.Provider value='text-card-foreground'>
<View ref={ref} className={cn('p-6 pt-0', className)} {...props}>
{wrapTextNodes(children)}
</View>
</TextClassContext.Provider>
);
});
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('flex flex-row items-center p-6 pt-0', className)} {...props} />
));
const CardFooter = React.forwardRef<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
return (
<View ref={ref} className={cn('flex flex-row items-center p-6 pt-0', className)} {...props}>
{wrapTextNodes(children)}
</View>
);
});
CardFooter.displayName = 'CardFooter';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View File

@ -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<WorkoutCardProps> = ({
export const WorkoutCard: React.FC<EnhancedWorkoutCardProps> = ({
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 (
<Card className="mb-4">
<CardContent className="p-4">
<View className="flex-row justify-between items-center mb-2">
<Text className="text-foreground text-lg font-semibold">{workout.title}</Text>
<TouchableOpacity onPress={handlePress}>
<ChevronRight className="text-muted-foreground" size={20} />
</TouchableOpacity>
</View>
<TouchableOpacity onPress={handlePress} activeOpacity={0.7} testID={`workout-card-${workout.id}`}>
<Card
className={cn(
"mb-4",
workoutSource === 'nostr' && "border-primary border-2",
workoutSource === 'both' && "border-primary border"
)}
>
<CardContent className="p-4">
<View className="flex-row justify-between items-center mb-2">
<View className="flex-row items-center">
<Text
className={cn(
"text-lg font-semibold",
workoutSource === 'nostr' || workoutSource === 'both'
? "text-primary"
: "text-foreground"
)}
>
{workout.title}
</Text>
{/* Source indicator */}
<View className="ml-2">
{workoutSource === 'local' && (
<SmartphoneIcon size={16} color={mutedColor} />
)}
{workoutSource === 'nostr' && (
<CloudIcon size={16} color={primaryColor} />
)}
{workoutSource === 'both' && (
<View className="flex-row">
<SmartphoneIcon size={16} color={mutedColor} />
<View style={{ width: 4 }} />
<CloudIcon size={16} color={primaryColor} />
</View>
)}
</View>
</View>
<ChevronRight size={20} color={mutedColor} />
</View>
{showDate && (
<Text className="text-muted-foreground mb-2">
@ -55,6 +127,57 @@ export const WorkoutCard: React.FC<WorkoutCardProps> = ({
</Text>
)}
{/* Publish status indicator */}
{workoutSource !== 'nostr' && (
<View className="flex-row items-center mb-2">
{workoutPublishStatus.isPublished ? (
<View className="flex-row items-center">
<CloudIcon size={14} color={primaryColor} style={{ marginRight: 4 }} />
<Text className="text-xs text-muted-foreground">
Published to {workoutPublishStatus.relayCount || 0} relays
{workoutPublishStatus.lastPublished &&
` on ${format(workoutPublishStatus.lastPublished, 'MMM d')}`}
</Text>
{onShare && (
<TouchableOpacity
onPress={onShare}
className="ml-2 px-2 py-1 bg-primary/10 rounded"
>
<Text className="text-xs text-primary">Republish</Text>
</TouchableOpacity>
)}
</View>
) : (
<View className="flex-row items-center">
<CloudOffIcon size={14} color={mutedColor} style={{ marginRight: 4 }} />
<Text className="text-xs text-muted-foreground">Local only</Text>
{onShare && (
<TouchableOpacity
onPress={onShare}
className="ml-2 px-2 py-1 bg-primary/10 rounded"
>
<Text className="text-xs text-primary">Publish</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
)}
{/* Import button for Nostr-only workouts */}
{workoutSource === 'nostr' && onImport && (
<View className="mb-2">
<TouchableOpacity
onPress={onImport}
className="px-2 py-1 bg-primary/10 rounded self-start"
>
<Text className="text-xs text-primary">Import to local</Text>
</TouchableOpacity>
</View>
)}
<View className="flex-row items-center mt-2">
<View className="flex-row items-center mr-4">
<View className="w-6 h-6 items-center justify-center mr-1">
@ -108,9 +231,10 @@ export const WorkoutCard: React.FC<WorkoutCardProps> = ({
)}
</View>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</TouchableOpacity>
);
};
export default WorkoutCard;
export default WorkoutCard;

View File

@ -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<WorkoutDetailViewProps> = ({
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) => (
<View key={set.id} className="flex-row items-center py-2 border-b border-border">
<View className="w-10">
<Text className="text-foreground font-medium">{index + 1}</Text>
</View>
<View className="flex-1 flex-row">
{set.weight && (
<Text className="text-foreground mr-4">{set.weight} lb</Text>
)}
{set.reps && (
<Text className="text-foreground mr-4">{set.reps} reps</Text>
)}
{set.rpe && (
<Text className="text-foreground">RPE {set.rpe}</Text>
)}
</View>
<View>
<Text className={`${set.isCompleted ? 'text-green-500' : 'text-muted-foreground'}`}>
{set.isCompleted ? 'Completed' : 'Skipped'}
</Text>
</View>
</View>
);
// Render an exercise with its sets
const renderExercise = (exercise: WorkoutExercise, index: number) => (
<Card key={exercise.id} className="mb-4">
<CardContent className="p-4">
<View className="flex-row justify-between items-center mb-2">
<Text className="text-foreground text-lg font-semibold">
{index + 1}. {exercise.title}
</Text>
<View className="bg-muted px-2 py-1 rounded">
<Text className="text-xs text-muted-foreground">
{exercise.sets.length} sets
</Text>
</View>
</View>
{exercise.notes && (
<View className="mb-4 bg-muted/50 p-2 rounded">
<Text className="text-muted-foreground text-sm">{exercise.notes}</Text>
</View>
)}
<View className="mt-2">
{/* Set header */}
<View className="flex-row items-center py-2 border-b border-border">
<View className="w-10">
<Text className="text-muted-foreground font-medium">Set</Text>
</View>
<View className="flex-1">
<Text className="text-muted-foreground">Weight/Reps</Text>
</View>
<View>
<Text className="text-muted-foreground">Status</Text>
</View>
</View>
{/* Sets */}
{exercise.sets.map((set, idx) => renderSet(set, idx))}
</View>
</CardContent>
</Card>
);
return (
<ScrollView className="flex-1 bg-background">
<View className="p-4">
{/* Header */}
<View className="mb-6">
<View className="flex-row justify-between items-center mb-2">
<Text className="text-foreground text-2xl font-bold">{workout.title}</Text>
{/* Source indicator */}
<View className="flex-row items-center bg-muted px-3 py-1 rounded">
{workoutSource === 'local' && (
<>
<SmartphoneIcon size={16} className="text-muted-foreground mr-1" />
<Text className="text-muted-foreground text-sm">Local</Text>
</>
)}
{workoutSource === 'nostr' && (
<>
<CloudIcon size={16} className="text-primary mr-1" />
<Text className="text-primary text-sm">Nostr</Text>
</>
)}
{workoutSource === 'both' && (
<>
<SmartphoneIcon size={16} className="text-muted-foreground mr-1" />
<CloudIcon size={16} className="text-primary ml-1 mr-1" />
<Text className="text-muted-foreground text-sm">Both</Text>
</>
)}
</View>
</View>
<Text className="text-muted-foreground mb-4">
{format(workout.startTime, 'EEEE, MMMM d, yyyy')} at {format(workout.startTime, 'h:mm a')}
</Text>
{/* Publish status */}
{workoutSource !== 'nostr' && (
<View className="flex-row items-center mb-4">
{isPublished ? (
<View className="flex-row items-center">
<CloudIcon size={16} className="text-primary mr-2" />
<Text className="text-muted-foreground">
Published to {relayCount} relays
{lastPublished &&
` on ${format(lastPublished, 'MMM d, yyyy')}`}
</Text>
</View>
) : (
<View className="flex-row items-center">
<CloudOffIcon size={16} className="text-muted-foreground mr-2" />
<Text className="text-muted-foreground">Local only</Text>
</View>
)}
</View>
)}
{/* Action buttons */}
<View className="flex-row flex-wrap">
{/* Publish button for local workouts */}
{workoutSource !== 'nostr' && !isPublished && onPublish && (
<TouchableOpacity
onPress={onPublish}
className="mr-2 mb-2 flex-row items-center bg-primary px-3 py-2 rounded"
>
<Share2Icon size={16} className="text-primary-foreground mr-1" />
<Text className="text-primary-foreground">Publish to Nostr</Text>
</TouchableOpacity>
)}
{/* Republish button for already published workouts */}
{workoutSource !== 'nostr' && isPublished && onPublish && (
<TouchableOpacity
onPress={onPublish}
className="mr-2 mb-2 flex-row items-center bg-primary/10 px-3 py-2 rounded"
>
<Share2Icon size={16} className="text-primary mr-1" />
<Text className="text-primary">Republish</Text>
</TouchableOpacity>
)}
{/* Import button for Nostr-only workouts */}
{workoutSource === 'nostr' && onImport && (
<TouchableOpacity
onPress={onImport}
className="mr-2 mb-2 flex-row items-center bg-primary px-3 py-2 rounded"
>
<DownloadIcon size={16} className="text-primary-foreground mr-1" />
<Text className="text-primary-foreground">Import to local</Text>
</TouchableOpacity>
)}
{/* Export buttons */}
{onExport && (
<View className="flex-row">
<TouchableOpacity
onPress={() => onExport('json')}
className="mr-2 mb-2 flex-row items-center bg-muted px-3 py-2 rounded"
>
<Text className="text-muted-foreground">Export JSON</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onExport('csv')}
className="mr-2 mb-2 flex-row items-center bg-muted px-3 py-2 rounded"
>
<Text className="text-muted-foreground">Export CSV</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
{/* Workout stats */}
<View className="flex-row flex-wrap mb-6">
<View className="bg-muted p-3 rounded mr-2 mb-2">
<Text className="text-muted-foreground text-xs mb-1">Duration</Text>
<Text className="text-foreground font-semibold">{duration}</Text>
</View>
<View className="bg-muted p-3 rounded mr-2 mb-2">
<Text className="text-muted-foreground text-xs mb-1">Total Volume</Text>
<Text className="text-foreground font-semibold">
{workout.totalVolume ? `${workout.totalVolume} lb` : 'N/A'}
</Text>
</View>
<View className="bg-muted p-3 rounded mr-2 mb-2">
<Text className="text-muted-foreground text-xs mb-1">Total Reps</Text>
<Text className="text-foreground font-semibold">
{workout.totalReps || 'N/A'}
</Text>
</View>
<View className="bg-muted p-3 rounded mb-2">
<Text className="text-muted-foreground text-xs mb-1">Exercises</Text>
<Text className="text-foreground font-semibold">
{workout.exercises?.length || 0}
</Text>
</View>
</View>
{/* Notes */}
{workout.notes && (
<View className="mb-6">
<Text className="text-foreground text-lg font-semibold mb-2">Notes</Text>
<Card>
<CardContent className="p-4">
<Text className="text-foreground">{workout.notes}</Text>
</CardContent>
</Card>
</View>
)}
{/* Exercises */}
<View className="mb-6">
<Text className="text-foreground text-lg font-semibold mb-2">Exercises</Text>
{workout.exercises && workout.exercises.length > 0 ? (
workout.exercises.map((exercise, idx) => renderExercise(exercise, idx))
) : (
<Card>
<CardContent className="p-4">
<Text className="text-muted-foreground">No exercises recorded</Text>
</CardContent>
</Card>
)}
</View>
{/* Add bottom padding for better scrolling experience */}
<View className="h-20" />
</View>
</ScrollView>
);
};
export default WorkoutDetailView;

View File

@ -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 (
<View className="flex-1 items-center justify-center p-6 bg-background">
<View className="bg-muted rounded-xl p-6 items-center max-w-md w-full">
<WifiOffIcon size={48} color="#666" style={{ marginBottom: 16 }} />
<Text style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 8, color: '#333' }}>
You're offline
</Text>
<Text style={{ textAlign: 'center', marginBottom: 16, color: '#666' }}>
{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."}
</Text>
<Text style={{ fontSize: 12, marginBottom: 24, color: 'rgba(102,102,102,0.7)' }}>
{lastOnlineText}
</Text>
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
onPress={handleGoBack}
style={{
backgroundColor: 'rgba(200,200,200,0.5)',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center',
marginRight: 8
}}
>
<ArrowLeftIcon size={16} color="#666" style={{ marginRight: 8 }} />
<Text style={{ color: '#666', fontWeight: '500' }}>
Go Back
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleRefresh}
style={{
backgroundColor: '#007bff',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center'
}}
>
<RefreshCwIcon size={16} color="#fff" style={{ marginRight: 8 }} />
<Text style={{ color: '#fff', fontWeight: '500' }}>
Check Connection
</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
/**
* A higher-order component that wraps workout detail screens to handle offline state
*/
export function withWorkoutOfflineState<P extends object>(
Component: React.ComponentType<P>
): React.FC<P & { workoutId?: string; workout?: any }> {
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 <Component {...props} />;
}
// Otherwise show the offline state
return <WorkoutOfflineState workoutId={props.workoutId} />;
};
}

View File

@ -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.

View File

@ -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)

View File

@ -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 ? (
<View className="p-4 mb-4 border border-primary rounded-md">
<Text className="text-foreground mb-2">
Login with Nostr to access more features:
</Text>
<Text className="text-muted-foreground mb-4">
• Sync workouts across devices
• Back up your workout history
• Share workouts with friends
</Text>
<Button
variant="purple"
onPress={() => setIsLoginSheetOpen(true)}
>
<Text className="text-white">Login with Nostr</Text>
</Button>
</View>
) : 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

File diff suppressed because it is too large Load Diff

View File

@ -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<void> {
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<void> {
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;
}
}

View File

@ -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<number> {
@ -127,6 +127,24 @@ class Schema {
throw error;
}
}
async migrate_v11(db: SQLiteDatabase): Promise<void> {
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<void> {
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();

View File

@ -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<void> {
async checkNetworkStatus(): Promise<boolean> {
// 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<void> {
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<number | null>(() => {
// 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 };
}
// Function to manually check network status
const checkConnection = useCallback(async (): Promise<boolean> => {
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
};
}

View File

@ -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<Workout[]> {
const {
limit = 50,
offset = 0,
includeNostr = true,
isAuthenticated = false
} = options;
// Get local workouts from database
const localWorkouts = await this.getLocalWorkouts();
// If not authenticated or not including Nostr, just return local workouts
if (!isAuthenticated || !includeNostr) {
return localWorkouts;
}
try {
// Get Nostr workouts
const nostrWorkouts = await this.getNostrWorkouts();
// Merge and deduplicate workouts
return this.mergeWorkouts(localWorkouts, nostrWorkouts);
} catch (error) {
console.error('Error fetching Nostr workouts:', error);
return localWorkouts;
}
}
/**
* Get workouts from local database
*/
private async getLocalWorkouts(): Promise<Workout[]> {
try {
const workouts = await this.db.getAllAsync<{
id: string;
title: string;
type: string;
start_time: number;
end_time: number | null;
is_completed: number;
created_at: number;
updated_at: number;
template_id: string | null;
total_volume: number | null;
total_reps: number | null;
source: string;
notes: string | null;
nostr_event_id: string | null;
nostr_published_at: number | null;
nostr_relay_count: number | null;
}>(
`SELECT * FROM workouts
ORDER BY start_time DESC`
);
// Transform database records to Workout objects
const result: Workout[] = [];
for (const workout of workouts) {
const exercises = await this.getWorkoutExercises(workout.id);
result.push({
id: workout.id,
title: workout.title,
type: workout.type as any,
startTime: workout.start_time,
endTime: workout.end_time || undefined,
isCompleted: Boolean(workout.is_completed),
created_at: workout.created_at,
lastUpdated: workout.updated_at,
templateId: workout.template_id || undefined,
totalVolume: workout.total_volume || undefined,
totalReps: workout.total_reps || undefined,
notes: workout.notes || undefined,
exercises,
availability: {
source: [workout.source as any],
nostrEventId: workout.nostr_event_id || undefined,
nostrPublishedAt: workout.nostr_published_at || undefined,
nostrRelayCount: workout.nostr_relay_count || undefined
}
});
}
return result;
} catch (error) {
console.error('Error getting local workouts:', error);
throw error;
}
}
/**
* Get workouts from Nostr
*/
private async getNostrWorkouts(): Promise<Workout[]> {
try {
// Get current user
const currentUser = useNDKStore.getState().currentUser;
if (!currentUser?.pubkey) {
return [];
}
// Fetch workout events
const events = await useNDKStore.getState().fetchEventsByFilter({
kinds: [1301], // Workout records
authors: [currentUser.pubkey],
limit: 50
});
// Convert events to workouts
const workouts: Workout[] = [];
for (const event of events) {
try {
const parsedWorkout = parseWorkoutRecord(event);
if (!parsedWorkout) continue;
// Convert to Workout type
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event);
workouts.push(workout);
} catch (error) {
console.error('Error parsing workout event:', error);
}
}
return workouts;
} catch (error) {
console.error('Error fetching Nostr workouts:', error);
return [];
}
}
/**
* Convert a parsed workout record to a Workout object
*/
private convertParsedWorkoutToWorkout(parsedWorkout: any, event: NDKEvent): Workout {
return {
id: parsedWorkout.id,
title: parsedWorkout.title,
type: parsedWorkout.type as any,
startTime: parsedWorkout.startTime,
endTime: parsedWorkout.endTime,
isCompleted: parsedWorkout.completed,
notes: parsedWorkout.notes,
created_at: parsedWorkout.createdAt,
lastUpdated: parsedWorkout.createdAt,
// Convert exercises
exercises: parsedWorkout.exercises.map((ex: any) => ({
id: ex.id,
title: ex.name,
exerciseId: ex.id,
type: 'strength',
category: 'Core',
sets: [{
id: generateId('nostr'),
weight: ex.weight,
reps: ex.reps,
rpe: ex.rpe,
type: (ex.setType as any) || 'normal',
isCompleted: true
}],
isCompleted: true,
created_at: parsedWorkout.createdAt,
lastUpdated: parsedWorkout.createdAt,
availability: { source: ['nostr'] },
tags: []
})),
// Add Nostr-specific metadata
availability: {
source: ['nostr'],
nostrEventId: event.id,
nostrPublishedAt: event.created_at ? event.created_at * 1000 : Date.now()
}
};
}
/**
* Merge local and Nostr workouts, removing duplicates
*/
private mergeWorkouts(localWorkouts: Workout[], nostrWorkouts: Workout[]): Workout[] {
// Create a map of workouts by ID for quick lookup
const workoutMap = new Map<string, Workout>();
// Add local workouts to the map
for (const workout of localWorkouts) {
workoutMap.set(workout.id, workout);
}
// Add Nostr workouts to the map, but only if they don't already exist
for (const workout of nostrWorkouts) {
if (!workoutMap.has(workout.id)) {
workoutMap.set(workout.id, workout);
} else {
// If the workout exists in both sources, merge the availability
const existingWorkout = workoutMap.get(workout.id)!;
// Combine the sources
const sources = new Set([
...(existingWorkout.availability?.source || []),
...(workout.availability?.source || [])
]);
// Update the availability
workoutMap.set(workout.id, {
...existingWorkout,
availability: {
...existingWorkout.availability,
source: Array.from(sources) as ('local' | 'nostr')[]
}
});
}
}
// Convert the map back to an array and sort by startTime (newest first)
return Array.from(workoutMap.values())
.sort((a, b) => b.startTime - a.startTime);
}
/**
* Helper method to load workout exercises and sets
*/
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
try {
const exercises = await this.db.getAllAsync<{
id: string;
exercise_id: string;
display_order: number;
notes: string | null;
created_at: number;
updated_at: number;
}>(
`SELECT we.* FROM workout_exercises we
WHERE we.workout_id = ?
ORDER BY we.display_order`,
[workoutId]
);
const result: WorkoutExercise[] = [];
for (const exercise of exercises) {
// Get the base exercise info
const baseExercise = await this.db.getFirstAsync<{
title: string;
type: string;
category: string;
equipment: string | null;
}>(
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
[exercise.exercise_id]
);
// Get the tags for this exercise
const tags = await this.db.getAllAsync<{ tag: string }>(
`SELECT tag FROM exercise_tags WHERE exercise_id = ?`,
[exercise.exercise_id]
);
// Get the sets for this exercise
const sets = await this.db.getAllAsync<{
id: string;
type: string;
weight: number | null;
reps: number | null;
rpe: number | null;
duration: number | null;
is_completed: number;
completed_at: number | null;
created_at: number;
updated_at: number;
}>(
`SELECT * FROM workout_sets
WHERE workout_exercise_id = ?
ORDER BY id`,
[exercise.id]
);
// Map sets to the correct format
const mappedSets = sets.map(set => ({
id: set.id,
type: set.type as any,
weight: set.weight || undefined,
reps: set.reps || undefined,
rpe: set.rpe || undefined,
duration: set.duration || undefined,
isCompleted: Boolean(set.is_completed),
completedAt: set.completed_at || undefined,
lastUpdated: set.updated_at
}));
result.push({
id: exercise.id,
exerciseId: exercise.exercise_id,
title: baseExercise?.title || 'Unknown Exercise',
type: baseExercise?.type as any || 'strength',
category: baseExercise?.category as any || 'Other',
equipment: baseExercise?.equipment as any,
notes: exercise.notes || undefined,
tags: tags.map(t => t.tag),
sets: mappedSets,
created_at: exercise.created_at,
lastUpdated: exercise.updated_at,
isCompleted: mappedSets.every(set => set.isCompleted),
availability: { source: ['local'] }
});
}
return result;
} catch (error) {
console.error('Error getting workout exercises:', error);
return [];
}
}
}

View File

@ -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<string, NDKSubscription> = 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<Workout[]> {
async getAllWorkouts(options: {
limit?: number;
offset?: number;
includeNostr?: boolean;
isAuthenticated?: boolean;
} = {}): Promise<Workout[]> {
const {
limit = 50,
offset = 0,
includeNostr = true,
isAuthenticated = false
} = options;
// Get local workouts from database
const localWorkouts = await this.getLocalWorkouts();
// If not authenticated or not including Nostr, just return local workouts
if (!isAuthenticated || !includeNostr || !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<Workout[]> {
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<Workout[]> {
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<string, string> = {
'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<string, Workout>();
// Add local workouts to the map
for (const workout of localWorkouts) {
workoutMap.set(workout.id, workout);
}
// Add Nostr workouts to the map, but only if they don't already exist
for (const workout of nostrWorkouts) {
if (!workoutMap.has(workout.id)) {
workoutMap.set(workout.id, workout);
} else {
// If the workout exists in both sources, merge the availability
const existingWorkout = workoutMap.get(workout.id)!;
// Combine the sources
const sources = new Set([
...(existingWorkout.availability?.source || []),
...(workout.availability?.source || [])
]);
// Update the availability
workoutMap.set(workout.id, {
...existingWorkout,
availability: {
...existingWorkout.availability,
source: Array.from(sources) as ('local' | 'nostr')[]
}
});
}
}
// Convert the map back to an array and sort by startTime (newest first)
return Array.from(workoutMap.values())
.sort((a, b) => b.startTime - a.startTime);
}
/**
* Get workouts for a specific date
*/
@ -622,137 +884,350 @@ export class WorkoutHistoryService {
}
}
/**
/**
* Get the total number of workouts
*/
async getWorkoutCount(): Promise<number> {
try {
const result = await this.db.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM workouts`
);
return result?.count || 0;
} catch (error) {
console.error('Error getting workout count:', error);
return 0;
async getWorkoutCount(): Promise<number> {
try {
const result = await this.db.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM workouts`
);
return result?.count || 0;
} catch (error) {
console.error('Error getting workout count:', error);
return 0;
}
}
}
// Helper method to load workout exercises and sets
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
try {
console.log(`[EnhancedWorkoutHistoryService] Getting exercises for workout: ${workoutId}`);
/**
* 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<string> {
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<string> {
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<string> {
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<WorkoutExercise[]> {
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 [];
}
}
}

View File

@ -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<Workout[]> {
try {
const workouts = await this.db.getAllAsync<{
id: string;
title: string;
type: string;
start_time: number;
end_time: number | null;
is_completed: number;
created_at: number;
updated_at: number;
template_id: string | null;
total_volume: number | null;
total_reps: number | null;
source: string;
notes: string | null;
}>(
`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<Workout[]> {
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<Date[]> {
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<Workout | null> {
try {
const workout = await this.db.getFirstAsync<{
id: string;
title: string;
type: string;
start_time: number;
end_time: number | null;
is_completed: number;
created_at: number;
updated_at: number;
template_id: string | null;
total_volume: number | null;
total_reps: number | null;
source: string;
notes: string | null;
}>(
`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<number> {
try {
const result = await this.db.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM workouts`
);
return result?.count || 0;
} catch (error) {
console.error('Error getting workout count:', error);
return 0;
}
}
// Helper method to load workout exercises and sets
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
try {
const exercises = await this.db.getAllAsync<{
id: string;
exercise_id: string;
display_order: number;
notes: string | null;
created_at: number;
updated_at: number;
}>(
`SELECT we.* FROM workout_exercises we
WHERE we.workout_id = ?
ORDER BY we.display_order`,
[workoutId]
);
const result: WorkoutExercise[] = [];
for (const exercise of exercises) {
// Get the base exercise info
const baseExercise = await this.db.getFirstAsync<{
title: string;
type: string;
category: string;
equipment: string | null;
}>(
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
[exercise.exercise_id]
);
// Get the tags for this exercise
const tags = await this.db.getAllAsync<{ tag: string }>(
`SELECT tag FROM exercise_tags WHERE exercise_id = ?`,
[exercise.exercise_id]
);
// Get the sets for this exercise
const sets = await this.db.getAllAsync<{
id: string;
type: string;
weight: number | null;
reps: number | null;
rpe: number | null;
duration: number | null;
is_completed: number;
completed_at: number | null;
created_at: number;
updated_at: number;
}>(
`SELECT * FROM workout_sets
WHERE workout_exercise_id = ?
ORDER BY id`,
[exercise.id]
);
// Map sets to the correct format
const mappedSets = sets.map(set => ({
id: set.id,
type: set.type as any,
weight: set.weight || undefined,
reps: set.reps || undefined,
rpe: set.rpe || undefined,
duration: set.duration || undefined,
isCompleted: Boolean(set.is_completed),
completedAt: set.completed_at || undefined,
lastUpdated: set.updated_at
}));
result.push({
id: exercise.id,
exerciseId: exercise.exercise_id,
title: baseExercise?.title || 'Unknown Exercise',
type: baseExercise?.type as any || 'strength',
category: baseExercise?.category as any || 'Other',
equipment: baseExercise?.equipment as any,
notes: exercise.notes || undefined,
tags: tags.map(t => t.tag), // Add the tags array here
sets: mappedSets,
created_at: exercise.created_at,
lastUpdated: exercise.updated_at,
isCompleted: mappedSets.every(set => set.isCompleted),
availability: { source: ['local'] }
});
}
return result;
} catch (error) {
console.error('Error getting workout exercises:', error);
return [];
}
}
}

View File

@ -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()
}), []);

View File

@ -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<Workout[]> => {
return unifiedWorkoutHistoryService.getAllWorkouts(options);
},
getWorkoutsByDate: (date: Date): Promise<Workout[]> => {
return unifiedWorkoutHistoryService.getWorkoutsByDate(date);
},
getWorkoutDatesInMonth: (year: number, month: number): Promise<Date[]> => {
return unifiedWorkoutHistoryService.getWorkoutDatesInMonth(year, month);
},
getWorkoutDetails: (workoutId: string): Promise<Workout | null> => {
return unifiedWorkoutHistoryService.getWorkoutDetails(workoutId);
}
};
}

View File

@ -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<Workout[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<Workout[]> => {
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<Workout | null> => {
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<string> => {
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<string> => {
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
};
}

View File

@ -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
};
}
}
}

View File

@ -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<string, any>();
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<AnalyticsData[]> {
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<string, AnalyticsData>();
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<AnalyticsData[]> {
// 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<SummaryStatistics> {
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<string>();
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<string, { name: string; count: number }>();
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
*/

View File

@ -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';
}

View File

@ -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;
}
/**

View File

@ -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)
};
}
}

View File

@ -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')}`;
}
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`;
}