mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-03 15:52:06 +00:00
Add NDK Mobile Cache Integration Plan and enhance offline functionality
This commit is contained in:
parent
a5d98ba251
commit
9043179643
50
CHANGELOG.md
50
CHANGELOG.md
@ -5,6 +5,56 @@ All notable changes to the POWR project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
# Changelog - March 25, 2025
|
||||
|
||||
## Added
|
||||
- NDK Mobile Cache Integration Plan
|
||||
- Created comprehensive cache management documentation
|
||||
- Designed profile image caching system
|
||||
- Planned publication queue service enhancements
|
||||
- Outlined social feed caching improvements
|
||||
- Documented workout history caching strategy
|
||||
- Planned exercise library and template caching
|
||||
- Designed contact list and following caching
|
||||
- Outlined general media cache service
|
||||
- Enhanced offline functionality
|
||||
- Added OfflineIndicator component for app-wide status display
|
||||
- Created SocialOfflineState component for graceful social feed degradation
|
||||
- Implemented WorkoutOfflineState component for workout screen fallbacks
|
||||
- Enhanced ConnectivityService with better network detection
|
||||
- Added offline mode detection in RelayInitializer
|
||||
- Implemented graceful fallbacks for unavailable content
|
||||
- Added cached data display when offline
|
||||
- Created user-friendly offline messaging
|
||||
|
||||
## Improved
|
||||
- Splash screen reliability
|
||||
- Enhanced SimpleSplashScreen with better error handling
|
||||
- Improved platform detection for video vs. static splash
|
||||
- Added fallback mechanisms for failed image loading
|
||||
- Enhanced logging for better debugging
|
||||
- Fixed Android-specific issues with splash screen
|
||||
- Offline user experience
|
||||
- Added visual indicators for offline state
|
||||
- Implemented graceful degradation of network-dependent features
|
||||
- Enhanced error handling for network failures
|
||||
- Added automatic retry mechanisms when connectivity is restored
|
||||
- Improved caching of previously viewed content
|
||||
- Enhanced state persistence during offline periods
|
||||
- Added connectivity-aware component rendering
|
||||
|
||||
## Fixed
|
||||
- Text rendering in React Native components
|
||||
- Fixed "Text strings must be rendered within a <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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
118
app/_layout.tsx
118
app/_layout.tsx
@ -5,7 +5,7 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import * as React from 'react';
|
||||
import { View, Text, Platform } from 'react-native';
|
||||
import { View, Text, Platform, ActivityIndicator } from 'react-native';
|
||||
import { NAV_THEME } from '@/lib/theme/constants';
|
||||
import { useColorScheme } from '@/lib/theme/useColorScheme';
|
||||
import { PortalHost } from '@rn-primitives/portal';
|
||||
@ -16,24 +16,48 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext';
|
||||
import SettingsDrawer from '@/components/SettingsDrawer';
|
||||
import RelayInitializer from '@/components/RelayInitializer';
|
||||
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
// Import splash screens with fallback mechanism
|
||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||
// Import splash screens with improved fallback mechanism
|
||||
let SplashComponent: React.ComponentType<{onFinish: () => void}>;
|
||||
let useVideoSplash = false;
|
||||
|
||||
// First try to import the video splash screen
|
||||
try {
|
||||
// Try to dynamically import the Video component
|
||||
const Video = require('expo-av').Video;
|
||||
// If successful, import the VideoSplashScreen
|
||||
SplashComponent = require('@/components/VideoSplashScreen').default;
|
||||
console.log('Successfully imported VideoSplashScreen');
|
||||
} catch (e) {
|
||||
console.warn('Failed to import VideoSplashScreen or expo-av:', e);
|
||||
// If that fails, use the simple splash screen
|
||||
// Determine if we should use video splash based on platform
|
||||
if (Platform.OS === 'ios') {
|
||||
// On iOS, try to use the video splash screen
|
||||
try {
|
||||
// Check if expo-av is available
|
||||
require('expo-av');
|
||||
useVideoSplash = true;
|
||||
console.log('expo-av is available, will use VideoSplashScreen on iOS');
|
||||
} catch (e) {
|
||||
console.warn('expo-av not available on iOS:', e);
|
||||
useVideoSplash = false;
|
||||
}
|
||||
} else {
|
||||
// On Android, directly use SimpleSplashScreen to avoid issues
|
||||
console.log('Android platform detected, using SimpleSplashScreen');
|
||||
useVideoSplash = false;
|
||||
}
|
||||
|
||||
// Import the appropriate splash screen component
|
||||
if (useVideoSplash) {
|
||||
try {
|
||||
SplashComponent = require('@/components/VideoSplashScreen').default;
|
||||
console.log('Successfully imported VideoSplashScreen');
|
||||
} catch (e) {
|
||||
console.warn('Failed to import VideoSplashScreen:', e);
|
||||
useVideoSplash = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If video splash is not available or failed to import, use simple splash
|
||||
if (!useVideoSplash) {
|
||||
try {
|
||||
SplashComponent = require('@/components/SimpleSplashScreen').default;
|
||||
console.log('Using SimpleSplashScreen as fallback');
|
||||
console.log('Using SimpleSplashScreen');
|
||||
} catch (simpleSplashError) {
|
||||
console.warn('Failed to import SimpleSplashScreen:', simpleSplashError);
|
||||
// Last resort fallback is an inline component
|
||||
@ -42,13 +66,27 @@ try {
|
||||
// Call onFinish after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
onFinish();
|
||||
}, 500);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onFinish]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
114
components/OfflineIndicator.tsx
Normal file
114
components/OfflineIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
77
components/social/SocialOfflineState.tsx
Normal file
77
components/social/SocialOfflineState.tsx
Normal 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} />;
|
||||
};
|
||||
}
|
@ -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 };
|
||||
|
@ -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;
|
||||
|
284
components/workout/WorkoutDetailView.tsx
Normal file
284
components/workout/WorkoutDetailView.tsx
Normal 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;
|
109
components/workout/WorkoutOfflineState.tsx
Normal file
109
components/workout/WorkoutOfflineState.tsx
Normal 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} />;
|
||||
};
|
||||
}
|
@ -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.
|
||||
|
188
docs/design/WorkoutHistory/MigrationGuide.md
Normal file
188
docs/design/WorkoutHistory/MigrationGuide.md
Normal 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)
|
@ -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
96
lib/db/migrations/add-nostr-fields-to-workouts.ts
Normal file
96
lib/db/migrations/add-nostr-fields-to-workouts.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
352
lib/db/services/NostrWorkoutHistoryService.ts
Normal file
352
lib/db/services/NostrWorkoutHistoryService.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}), []);
|
||||
|
48
lib/hooks/useNostrWorkoutHistory.ts
Normal file
48
lib/hooks/useNostrWorkoutHistory.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
160
lib/hooks/useWorkoutHistory.ts
Normal file
160
lib/hooks/useWorkoutHistory.ts
Normal 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
|
||||
};
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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`;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user