mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-05 16:52:07 +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/),
|
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).
|
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
|
# Changelog - March 24, 2025
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
// app/(tabs)/history/workoutHistory.tsx
|
// app/(tabs)/history/workoutHistory.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
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 { Text } from '@/components/ui/text';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { Workout } from '@/types/workout';
|
import { Workout } from '@/types/workout';
|
||||||
import { format } from 'date-fns';
|
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 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
|
// Mock data for when database tables aren't yet created
|
||||||
const mockWorkouts: Workout[] = [
|
const mockWorkouts: Workout[] = [
|
||||||
@ -14,7 +25,47 @@ const mockWorkouts: Workout[] = [
|
|||||||
id: '1',
|
id: '1',
|
||||||
title: 'Push 1',
|
title: 'Push 1',
|
||||||
type: 'strength',
|
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(),
|
startTime: new Date('2025-03-07T10:00:00').getTime(),
|
||||||
endTime: new Date('2025-03-07T11:47:00').getTime(),
|
endTime: new Date('2025-03-07T11:47:00').getTime(),
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
@ -26,7 +77,34 @@ const mockWorkouts: Workout[] = [
|
|||||||
id: '2',
|
id: '2',
|
||||||
title: 'Pull 1',
|
title: 'Pull 1',
|
||||||
type: 'strength',
|
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(),
|
startTime: new Date('2025-03-05T14:00:00').getTime(),
|
||||||
endTime: new Date('2025-03-05T15:36:00').getTime(),
|
endTime: new Date('2025-03-05T15:36:00').getTime(),
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
@ -53,51 +131,52 @@ const groupWorkoutsByMonth = (workouts: Workout[]) => {
|
|||||||
|
|
||||||
export default function HistoryScreen() {
|
export default function HistoryScreen() {
|
||||||
const db = useSQLiteContext();
|
const db = useSQLiteContext();
|
||||||
|
const { isAuthenticated } = useNDKCurrentUser();
|
||||||
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [useMockData, setUseMockData] = useState(false);
|
const [useMockData, setUseMockData] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [includeNostr, setIncludeNostr] = useState(true);
|
||||||
|
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||||
|
|
||||||
// Initialize workout history service
|
// Use the unified workout history hook
|
||||||
const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]);
|
const {
|
||||||
|
workouts: allWorkouts,
|
||||||
|
loading,
|
||||||
|
refreshing: hookRefreshing,
|
||||||
|
refresh,
|
||||||
|
error
|
||||||
|
} = useWorkoutHistory({
|
||||||
|
includeNostr,
|
||||||
|
filters: includeNostr ? undefined : { source: ['local'] },
|
||||||
|
realtime: true
|
||||||
|
});
|
||||||
|
|
||||||
// Load workouts
|
// Set workouts from the hook
|
||||||
const loadWorkouts = async () => {
|
useEffect(() => {
|
||||||
try {
|
if (loading) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const allWorkouts = await workoutHistoryService.getAllWorkouts();
|
} else {
|
||||||
setWorkouts(allWorkouts);
|
setWorkouts(allWorkouts);
|
||||||
setUseMockData(false);
|
setIsLoading(false);
|
||||||
} catch (error) {
|
setRefreshing(false);
|
||||||
console.error('Error loading workouts:', error);
|
|
||||||
|
|
||||||
// Check if the error is about missing tables
|
// Check if we need to use mock data (empty workouts)
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
if (allWorkouts.length === 0 && !error) {
|
||||||
if (errorMsg.includes('no such table')) {
|
console.log('No workouts found, using mock data');
|
||||||
console.log('Using mock data because workout tables not yet created');
|
|
||||||
setWorkouts(mockWorkouts);
|
setWorkouts(mockWorkouts);
|
||||||
setUseMockData(true);
|
setUseMockData(true);
|
||||||
} else {
|
} else {
|
||||||
// For other errors, just show empty state
|
|
||||||
setWorkouts([]);
|
|
||||||
setUseMockData(false);
|
setUseMockData(false);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [allWorkouts, loading, error]);
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
|
||||||
loadWorkouts();
|
|
||||||
}, [workoutHistoryService]);
|
|
||||||
|
|
||||||
// Pull to refresh handler
|
// Pull to refresh handler
|
||||||
const onRefresh = React.useCallback(() => {
|
const onRefresh = React.useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
loadWorkouts();
|
refresh();
|
||||||
}, []);
|
}, [refresh]);
|
||||||
|
|
||||||
// Group workouts by month
|
// Group workouts by month
|
||||||
const groupedWorkouts = groupWorkoutsByMonth(workouts);
|
const groupedWorkouts = groupWorkoutsByMonth(workouts);
|
||||||
@ -110,6 +189,24 @@ export default function HistoryScreen() {
|
|||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<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 ? (
|
{isLoading && !refreshing ? (
|
||||||
<View className="items-center justify-center py-20">
|
<View className="items-center justify-center py-20">
|
||||||
<ActivityIndicator size="large" className="mb-4" />
|
<ActivityIndicator size="large" className="mb-4" />
|
||||||
@ -133,6 +230,27 @@ export default function HistoryScreen() {
|
|||||||
</View>
|
</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]) => (
|
{groupedWorkouts.map(([month, monthWorkouts]) => (
|
||||||
<View key={month} className="mb-6">
|
<View key={month} className="mb-6">
|
||||||
<Text className="text-foreground text-xl font-semibold mb-4">
|
<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 */}
|
{/* Add bottom padding for better scrolling experience */}
|
||||||
<View className="h-20" />
|
<View className="h-20" />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Nostr Login Sheet */}
|
||||||
|
<NostrLoginSheet
|
||||||
|
open={isLoginSheetOpen}
|
||||||
|
onClose={() => setIsLoginSheetOpen(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
// app/(tabs)/profile/progress.tsx
|
// app/(tabs)/profile/progress.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
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 { Text } from '@/components/ui/text';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||||
import { ActivityIndicator } from 'react-native';
|
import { ActivityIndicator } from 'react-native';
|
||||||
import { useAnalytics } from '@/lib/hooks/useAnalytics';
|
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
|
// Period selector component
|
||||||
function PeriodSelector({ period, setPeriod }: {
|
function PeriodSelector({ period, setPeriod }: {
|
||||||
@ -66,14 +67,19 @@ export default function ProgressScreen() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [stats, setStats] = useState<WorkoutStats | null>(null);
|
const [stats, setStats] = useState<WorkoutStats | null>(null);
|
||||||
const [records, setRecords] = useState<PersonalRecord[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// Pass includeNostr flag to analytics service
|
||||||
|
analyticsService.setIncludeNostr(includeNostr);
|
||||||
|
|
||||||
const workoutStats = await analytics.getWorkoutStats(period);
|
const workoutStats = await analytics.getWorkoutStats(period);
|
||||||
setStats(workoutStats);
|
setStats(workoutStats);
|
||||||
|
|
||||||
@ -88,7 +94,7 @@ export default function ProgressScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadStats();
|
loadStats();
|
||||||
}, [isAuthenticated, period, analytics]);
|
}, [isAuthenticated, period, includeNostr, analytics]);
|
||||||
|
|
||||||
// Workout frequency chart
|
// Workout frequency chart
|
||||||
const WorkoutFrequencyChart = () => {
|
const WorkoutFrequencyChart = () => {
|
||||||
@ -180,10 +186,36 @@ export default function ProgressScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 p-4">
|
<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 */}
|
{/* Workout Summary */}
|
||||||
<Card className="mb-4">
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<Text className="text-lg font-semibold mb-2">Workout Summary</Text>
|
<Text className="text-lg font-semibold mb-2">Workout Summary</Text>
|
||||||
<Text className="mb-1">Workouts: {stats?.workoutCount || 0}</Text>
|
<Text className="mb-1">Workouts: {stats?.workoutCount || 0}</Text>
|
||||||
@ -193,7 +225,7 @@ export default function ProgressScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Workout Frequency Chart */}
|
{/* Workout Frequency Chart */}
|
||||||
<Card className="mb-4">
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<Text className="text-lg font-semibold mb-2">Workout Frequency</Text>
|
<Text className="text-lg font-semibold mb-2">Workout Frequency</Text>
|
||||||
<WorkoutFrequencyChart />
|
<WorkoutFrequencyChart />
|
||||||
@ -201,7 +233,7 @@ export default function ProgressScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Muscle Group Distribution */}
|
{/* Muscle Group Distribution */}
|
||||||
<Card className="mb-4">
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<Text className="text-lg font-semibold mb-2">Exercise Distribution</Text>
|
<Text className="text-lg font-semibold mb-2">Exercise Distribution</Text>
|
||||||
<ExerciseDistributionChart />
|
<ExerciseDistributionChart />
|
||||||
@ -209,7 +241,7 @@ export default function ProgressScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Personal Records */}
|
{/* Personal Records */}
|
||||||
<Card className="mb-4">
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<Text className="text-lg font-semibold mb-2">Personal Records</Text>
|
<Text className="text-lg font-semibold mb-2">Personal Records</Text>
|
||||||
{records.length === 0 ? (
|
{records.length === 0 ? (
|
||||||
@ -235,14 +267,17 @@ export default function ProgressScreen() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Note about future implementation */}
|
{/* Nostr integration note */}
|
||||||
<Card className="mb-4">
|
{isAuthenticated && includeNostr && (
|
||||||
<CardContent className="p-4">
|
<Card className="mb-4 border-primary">
|
||||||
<Text className="text-muted-foreground text-center">
|
<CardContent className="p-4 flex-row items-center">
|
||||||
Note: This is a placeholder UI. In the future, this tab will display real analytics based on your workout history.
|
<CloudIcon size={16} className="text-primary mr-2" />
|
||||||
</Text>
|
<Text className="text-muted-foreground flex-1">
|
||||||
</CardContent>
|
Analytics include workouts from Nostr. Toggle the switch above to view only local workouts.
|
||||||
</Card>
|
</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import { useNDKCurrentUser, useNDK } from '@/lib/hooks/useNDK';
|
|||||||
import { useFollowingFeed } from '@/lib/hooks/useFeedHooks';
|
import { useFollowingFeed } from '@/lib/hooks/useFeedHooks';
|
||||||
import { ChevronUp, Bug } from 'lucide-react-native';
|
import { ChevronUp, Bug } from 'lucide-react-native';
|
||||||
import { AnyFeedEntry } from '@/types/feed';
|
import { AnyFeedEntry } from '@/types/feed';
|
||||||
|
import { withOfflineState } from '@/components/social/SocialOfflineState';
|
||||||
|
|
||||||
// Define the conversion function here to avoid import issues
|
// Define the conversion function here to avoid import issues
|
||||||
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||||
@ -21,7 +22,7 @@ function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FollowingScreen() {
|
function FollowingScreen() {
|
||||||
const { isAuthenticated, currentUser } = useNDKCurrentUser();
|
const { isAuthenticated, currentUser } = useNDKCurrentUser();
|
||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const {
|
const {
|
||||||
@ -261,4 +262,7 @@ export default function FollowingScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the component wrapped with the offline state HOC
|
||||||
|
export default withOfflineState(FollowingScreen);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// app/(tabs)/social/global.tsx
|
// 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 { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
||||||
@ -7,6 +7,7 @@ import { useGlobalFeed } from '@/lib/hooks/useFeedHooks';
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { ChevronUp } from 'lucide-react-native';
|
import { ChevronUp } from 'lucide-react-native';
|
||||||
import { AnyFeedEntry } from '@/types/feed';
|
import { AnyFeedEntry } from '@/types/feed';
|
||||||
|
import { withOfflineState } from '@/components/social/SocialOfflineState';
|
||||||
|
|
||||||
// Define the conversion function here to avoid import issues
|
// Define the conversion function here to avoid import issues
|
||||||
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||||
@ -19,7 +20,7 @@ function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GlobalScreen() {
|
function GlobalScreen() {
|
||||||
const {
|
const {
|
||||||
entries,
|
entries,
|
||||||
newEntries,
|
newEntries,
|
||||||
@ -35,7 +36,7 @@ export default function GlobalScreen() {
|
|||||||
const listRef = useRef<FlatList>(null);
|
const listRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
// Show new entries button when we have new content
|
// Show new entries button when we have new content
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (newEntries.length > 0) {
|
if (newEntries.length > 0) {
|
||||||
setShowNewButton(true);
|
setShowNewButton(true);
|
||||||
}
|
}
|
||||||
@ -127,4 +128,7 @@ export default function GlobalScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</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 { usePOWRFeed } from '@/lib/hooks/useFeedHooks';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { AnyFeedEntry } from '@/types/feed';
|
import { AnyFeedEntry } from '@/types/feed';
|
||||||
|
import { withOfflineState } from '@/components/social/SocialOfflineState';
|
||||||
|
|
||||||
// Define the conversion function here to avoid import issues
|
// Define the conversion function here to avoid import issues
|
||||||
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||||
@ -20,7 +21,7 @@ function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PowerScreen() {
|
function PowerScreen() {
|
||||||
const {
|
const {
|
||||||
entries,
|
entries,
|
||||||
newEntries,
|
newEntries,
|
||||||
@ -146,4 +147,7 @@ export default function PowerScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the component wrapped with the offline state HOC
|
||||||
|
export default withOfflineState(PowerScreen);
|
||||||
|
@ -53,7 +53,16 @@ export default function WorkoutLayout() {
|
|||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="workout/[id]"
|
||||||
|
options={{
|
||||||
|
presentation: 'card',
|
||||||
|
animation: 'default',
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,11 +35,11 @@ export default function CompleteWorkoutScreen() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the completeWorkout function from the store
|
||||||
|
const { completeWorkout } = useWorkoutStore();
|
||||||
|
|
||||||
// Handle complete with options
|
// Handle complete with options
|
||||||
const handleComplete = async (options: WorkoutCompletionOptions) => {
|
const handleComplete = async (options: WorkoutCompletionOptions) => {
|
||||||
// Get a fresh reference to completeWorkout
|
|
||||||
const { completeWorkout } = useWorkoutStore.getState();
|
|
||||||
|
|
||||||
// Complete the workout with the provided options
|
// Complete the workout with the provided options
|
||||||
await completeWorkout(options);
|
await completeWorkout(options);
|
||||||
};
|
};
|
||||||
@ -81,4 +81,4 @@ export default function CompleteWorkoutScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,15 +4,16 @@ import { View, ActivityIndicator, TouchableOpacity } from 'react-native';
|
|||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { useLocalSearchParams, Stack, useRouter } from 'expo-router';
|
import { useLocalSearchParams, Stack, useRouter } from 'expo-router';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
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 WorkoutDetailView from '@/components/workout/WorkoutDetailView';
|
||||||
import { Workout } from '@/types/workout';
|
import { Workout } from '@/types/workout';
|
||||||
import { useNDK, useNDKAuth, useNDKEvents } from '@/lib/hooks/useNDK';
|
import { useNDK, useNDKAuth, useNDKEvents } from '@/lib/hooks/useNDK';
|
||||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||||
import { useNDKStore } from '@/lib/stores/ndk';
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||||||
import { Share } from 'react-native';
|
import { Share } from 'react-native';
|
||||||
|
import { withWorkoutOfflineState } from '@/components/workout/WorkoutOfflineState';
|
||||||
|
|
||||||
export default function WorkoutDetailScreen() {
|
function WorkoutDetailScreen() {
|
||||||
// Add error state
|
// Add error state
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
@ -28,8 +29,8 @@ export default function WorkoutDetailScreen() {
|
|||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
// Initialize service
|
// Use the unified workout history hook
|
||||||
const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]);
|
const { getWorkoutDetails, publishWorkoutToNostr, service: workoutHistoryService } = useWorkoutHistory();
|
||||||
|
|
||||||
// Load workout details
|
// Load workout details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -44,7 +45,7 @@ export default function WorkoutDetailScreen() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null); // Reset error state
|
setError(null); // Reset error state
|
||||||
console.log('Calling workoutHistoryService.getWorkoutDetails...');
|
console.log('Calling getWorkoutDetails...');
|
||||||
|
|
||||||
// Add timeout to prevent infinite loading
|
// Add timeout to prevent infinite loading
|
||||||
const timeoutPromise = new Promise<null>((_, reject) => {
|
const timeoutPromise = new Promise<null>((_, reject) => {
|
||||||
@ -53,7 +54,7 @@ export default function WorkoutDetailScreen() {
|
|||||||
|
|
||||||
// Race the workout details fetch against the timeout
|
// Race the workout details fetch against the timeout
|
||||||
const workoutDetails = await Promise.race([
|
const workoutDetails = await Promise.race([
|
||||||
workoutHistoryService.getWorkoutDetails(id),
|
getWorkoutDetails(id),
|
||||||
timeoutPromise
|
timeoutPromise
|
||||||
]) as Workout | null;
|
]) as Workout | null;
|
||||||
|
|
||||||
@ -77,11 +78,11 @@ export default function WorkoutDetailScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadWorkout();
|
loadWorkout();
|
||||||
}, [id, workoutHistoryService]);
|
}, [id, getWorkoutDetails]);
|
||||||
|
|
||||||
// Handle publishing to Nostr
|
// Handle publishing to Nostr
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!workout || !ndk || !isAuthenticated) {
|
if (!workout || !isAuthenticated) {
|
||||||
alert('You need to be logged in to Nostr to publish workouts');
|
alert('You need to be logged in to Nostr to publish workouts');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -89,32 +90,17 @@ export default function WorkoutDetailScreen() {
|
|||||||
try {
|
try {
|
||||||
setIsPublishing(true);
|
setIsPublishing(true);
|
||||||
|
|
||||||
// Create Nostr event
|
// Use the hook's publishWorkoutToNostr method
|
||||||
const nostrEvent = NostrWorkoutService.createCompleteWorkoutEvent(workout);
|
const eventId = await publishWorkoutToNostr(workout.id);
|
||||||
|
|
||||||
// Publish event using the kind, content, and tags from the created event
|
if (eventId) {
|
||||||
const publishedEvent = await publishEvent(
|
// Reload the workout to get the updated data
|
||||||
nostrEvent.kind,
|
const updatedWorkout = await workoutHistoryService.getWorkoutDetails(workout.id);
|
||||||
nostrEvent.content,
|
if (updatedWorkout) {
|
||||||
nostrEvent.tags
|
setWorkout(updatedWorkout);
|
||||||
);
|
}
|
||||||
|
|
||||||
if (publishedEvent?.id) {
|
|
||||||
// Update local database with Nostr event ID
|
|
||||||
const relayCount = ndk.pool?.relays.size || 0;
|
|
||||||
|
|
||||||
// Update workout in memory
|
console.log(`Workout published to Nostr with event ID: ${eventId}`);
|
||||||
setWorkout({
|
|
||||||
...workout,
|
|
||||||
availability: {
|
|
||||||
...workout.availability,
|
|
||||||
nostrEventId: publishedEvent.id,
|
|
||||||
nostrPublishedAt: Date.now(),
|
|
||||||
nostrRelayCount: relayCount
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Workout published to Nostr with event ID: ${publishedEvent.id}`);
|
|
||||||
alert('Workout published successfully!');
|
alert('Workout published successfully!');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -127,19 +113,39 @@ export default function WorkoutDetailScreen() {
|
|||||||
|
|
||||||
// Handle importing from Nostr to local
|
// Handle importing from Nostr to local
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!workout) return;
|
if (!workout || !workout.availability?.nostrEventId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
|
|
||||||
// Import workout to local database
|
// Use WorkoutHistoryService to update the workout's source to include both local and nostr
|
||||||
// This would be implemented in a future version
|
const workoutId = workout.id;
|
||||||
console.log('Importing workout from Nostr to local database');
|
|
||||||
|
|
||||||
// For now, just show a message
|
// Get the workout sync status
|
||||||
alert('Workout import functionality will be available in a future update');
|
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) {
|
} catch (error) {
|
||||||
console.error('Error importing workout:', error);
|
console.error('Error importing workout:', error);
|
||||||
|
alert('Failed to import workout. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}
|
}
|
||||||
@ -237,4 +243,7 @@ export default function WorkoutDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</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 { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import * as React from 'react';
|
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 { NAV_THEME } from '@/lib/theme/constants';
|
||||||
import { useColorScheme } from '@/lib/theme/useColorScheme';
|
import { useColorScheme } from '@/lib/theme/useColorScheme';
|
||||||
import { PortalHost } from '@rn-primitives/portal';
|
import { PortalHost } from '@rn-primitives/portal';
|
||||||
@ -16,24 +16,48 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|||||||
import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext';
|
import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext';
|
||||||
import SettingsDrawer from '@/components/SettingsDrawer';
|
import SettingsDrawer from '@/components/SettingsDrawer';
|
||||||
import RelayInitializer from '@/components/RelayInitializer';
|
import RelayInitializer from '@/components/RelayInitializer';
|
||||||
|
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||||
import { useNDKStore } from '@/lib/stores/ndk';
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
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 SplashComponent: React.ComponentType<{onFinish: () => void}>;
|
||||||
|
let useVideoSplash = false;
|
||||||
|
|
||||||
// First try to import the video splash screen
|
// Determine if we should use video splash based on platform
|
||||||
try {
|
if (Platform.OS === 'ios') {
|
||||||
// Try to dynamically import the Video component
|
// On iOS, try to use the video splash screen
|
||||||
const Video = require('expo-av').Video;
|
try {
|
||||||
// If successful, import the VideoSplashScreen
|
// Check if expo-av is available
|
||||||
SplashComponent = require('@/components/VideoSplashScreen').default;
|
require('expo-av');
|
||||||
console.log('Successfully imported VideoSplashScreen');
|
useVideoSplash = true;
|
||||||
} catch (e) {
|
console.log('expo-av is available, will use VideoSplashScreen on iOS');
|
||||||
console.warn('Failed to import VideoSplashScreen or expo-av:', e);
|
} catch (e) {
|
||||||
// If that fails, use the simple splash screen
|
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 {
|
try {
|
||||||
SplashComponent = require('@/components/SimpleSplashScreen').default;
|
SplashComponent = require('@/components/SimpleSplashScreen').default;
|
||||||
console.log('Using SimpleSplashScreen as fallback');
|
console.log('Using SimpleSplashScreen');
|
||||||
} catch (simpleSplashError) {
|
} catch (simpleSplashError) {
|
||||||
console.warn('Failed to import SimpleSplashScreen:', simpleSplashError);
|
console.warn('Failed to import SimpleSplashScreen:', simpleSplashError);
|
||||||
// Last resort fallback is an inline component
|
// Last resort fallback is an inline component
|
||||||
@ -42,13 +66,27 @@ try {
|
|||||||
// Call onFinish after a short delay
|
// Call onFinish after a short delay
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
onFinish();
|
onFinish();
|
||||||
}, 500);
|
}, 1000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [onFinish]);
|
}, [onFinish]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center bg-black">
|
<View style={{
|
||||||
<Text className="text-white text-xl">Loading POWR...</Text>
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -85,16 +123,51 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
setAndroidNavigationBar(colorScheme);
|
setAndroidNavigationBar(colorScheme);
|
||||||
|
|
||||||
// Initialize NDK
|
// Initialize connectivity service first
|
||||||
await init();
|
const connectivityService = ConnectivityService.getInstance();
|
||||||
|
const isOnline = await connectivityService.checkNetworkStatus();
|
||||||
|
console.log(`Network connectivity: ${isOnline ? 'online' : 'offline'}`);
|
||||||
|
|
||||||
// Load favorites from SQLite
|
// Start database initialization and NDK initialization in parallel
|
||||||
await useWorkoutStore.getState().loadFavorites();
|
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!');
|
console.log('App initialization completed!');
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize:', error);
|
console.error('Failed to initialize:', error);
|
||||||
|
// Still mark as initialized to prevent hanging
|
||||||
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@ -156,6 +229,9 @@ export default function RootLayout() {
|
|||||||
{/* Add RelayInitializer here - it loads relay data once NDK is available */}
|
{/* Add RelayInitializer here - it loads relay data once NDK is available */}
|
||||||
<RelayInitializer />
|
<RelayInitializer />
|
||||||
|
|
||||||
|
{/* Add OfflineIndicator to show network status */}
|
||||||
|
<OfflineIndicator />
|
||||||
|
|
||||||
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@ -176,4 +252,4 @@ export default function RootLayout() {
|
|||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</ErrorBoundary>
|
</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 { View } from 'react-native';
|
||||||
import { useRelayStore } from '@/lib/stores/relayStore';
|
import { useRelayStore } from '@/lib/stores/relayStore';
|
||||||
import { useNDKStore } from '@/lib/stores/ndk';
|
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
|
* 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() {
|
export default function RelayInitializer() {
|
||||||
const { loadRelays } = useRelayStore();
|
const { loadRelays } = useRelayStore();
|
||||||
const { ndk } = useNDKStore();
|
const { ndk } = useNDKStore();
|
||||||
|
const { isOnline } = useConnectivity();
|
||||||
|
|
||||||
// Load relays when NDK is initialized
|
// Load relays when NDK is initialized and network is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ndk) {
|
if (ndk && isOnline) {
|
||||||
console.log('[RelayInitializer] NDK available, loading relays...');
|
console.log('[RelayInitializer] NDK available and online, loading relays...');
|
||||||
loadRelays().catch(error =>
|
loadRelays().catch(error =>
|
||||||
console.error('[RelayInitializer] Error loading relays:', 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
|
// This component doesn't render anything
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
// components/SimpleSplashScreen.tsx
|
// components/SimpleSplashScreen.tsx
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Image, ActivityIndicator, StyleSheet } from 'react-native';
|
import { View, Image, ActivityIndicator, StyleSheet, Platform, Text } from 'react-native';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
|
||||||
// Keep the splash screen visible while we fetch resources
|
// Keep the splash screen visible while we fetch resources
|
||||||
SplashScreen.preventAutoHideAsync().catch(() => {
|
SplashScreen.preventAutoHideAsync().catch((error) => {
|
||||||
/* ignore error */
|
console.warn('Error preventing auto hide of splash screen:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SplashScreenProps {
|
interface SplashScreenProps {
|
||||||
@ -13,33 +13,68 @@ interface SplashScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SimpleSplashScreen: React.FC<SplashScreenProps> = ({ onFinish }) => {
|
const SimpleSplashScreen: React.FC<SplashScreenProps> = ({ onFinish }) => {
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hide the native splash screen
|
console.log('SimpleSplashScreen mounted');
|
||||||
SplashScreen.hideAsync().catch(() => {
|
|
||||||
/* ignore error */
|
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
|
// Simulate video duration with a timeout
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
console.log('SimpleSplashScreen timer complete, calling onFinish');
|
||||||
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]);
|
}, [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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Use a static image as fallback */}
|
{/* Logo image */}
|
||||||
<Image
|
<Image
|
||||||
source={require('../assets/images/splash.png')}
|
source={require('../assets/images/splash.png')}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Show app name as text for better reliability */}
|
||||||
|
<Text style={styles.appName}>POWR</Text>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
size="large"
|
size="large"
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
style={styles.loader}
|
style={styles.loader}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Error message if image fails to load */}
|
||||||
|
{error && (
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -52,13 +87,24 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
width: '80%',
|
width: Platform.OS === 'android' ? '70%' : '80%',
|
||||||
height: '80%',
|
height: Platform.OS === 'android' ? '70%' : '80%',
|
||||||
|
},
|
||||||
|
appName: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 20,
|
||||||
},
|
},
|
||||||
loader: {
|
loader: {
|
||||||
position: 'absolute',
|
marginTop: 30,
|
||||||
bottom: 100,
|
|
||||||
},
|
},
|
||||||
|
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 type { TextRef, ViewRef } from '@rn-primitives/types';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Text, TextProps, View, ViewProps } from 'react-native';
|
import { TextProps, View, ViewProps } from 'react-native';
|
||||||
import { TextClassContext } from '@/components/ui/text';
|
import { Text, TextClassContext } from '@/components/ui/text';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Card = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
|
// Extended ViewProps interface that includes children
|
||||||
<View
|
interface ViewPropsWithChildren extends ViewProps {
|
||||||
ref={ref}
|
children?: React.ReactNode;
|
||||||
className={cn(
|
}
|
||||||
'rounded-lg border border-border bg-card shadow-sm shadow-foreground/10',
|
|
||||||
className
|
// Helper function to recursively wrap text nodes in Text components
|
||||||
)}
|
const wrapTextNodes = (children: React.ReactNode): React.ReactNode => {
|
||||||
{...props}
|
// 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';
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
|
const CardHeader = React.forwardRef<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
|
||||||
<View ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
return (
|
||||||
));
|
<View ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props}>
|
||||||
|
{wrapTextNodes(children)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
CardHeader.displayName = 'CardHeader';
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<TextRef, React.ComponentPropsWithoutRef<typeof Text>>(
|
const CardTitle = React.forwardRef<TextRef, React.ComponentPropsWithoutRef<typeof Text>>(
|
||||||
@ -42,16 +90,24 @@ const CardDescription = React.forwardRef<TextRef, TextProps>(({ className, ...pr
|
|||||||
));
|
));
|
||||||
CardDescription.displayName = 'CardDescription';
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
const CardContent = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
|
const CardContent = React.forwardRef<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
|
||||||
<TextClassContext.Provider value='text-card-foreground'>
|
return (
|
||||||
<View ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
<TextClassContext.Provider value='text-card-foreground'>
|
||||||
</TextClassContext.Provider>
|
<View ref={ref} className={cn('p-6 pt-0', className)} {...props}>
|
||||||
));
|
{wrapTextNodes(children)}
|
||||||
|
</View>
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
CardContent.displayName = 'CardContent';
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<ViewRef, ViewProps>(({ className, ...props }, ref) => (
|
const CardFooter = React.forwardRef<ViewRef, ViewPropsWithChildren>(({ className, children, ...props }, ref) => {
|
||||||
<View ref={ref} className={cn('flex flex-row items-center p-6 pt-0', className)} {...props} />
|
return (
|
||||||
));
|
<View ref={ref} className={cn('flex flex-row items-center p-6 pt-0', className)} {...props}>
|
||||||
|
{wrapTextNodes(children)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
CardFooter.displayName = 'CardFooter';
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||||
|
@ -1,16 +1,26 @@
|
|||||||
// components/workout/WorkoutCard.tsx
|
// components/workout/WorkoutCard.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
import { ChevronRight } from 'lucide-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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Workout } from '@/types/workout';
|
import { Workout } from '@/types/workout';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface WorkoutCardProps {
|
export interface EnhancedWorkoutCardProps {
|
||||||
workout: Workout;
|
workout: Workout;
|
||||||
showDate?: boolean;
|
showDate?: boolean;
|
||||||
showExercises?: boolean;
|
showExercises?: boolean;
|
||||||
|
source?: 'local' | 'nostr' | 'both';
|
||||||
|
publishStatus?: {
|
||||||
|
isPublished: boolean;
|
||||||
|
relayCount?: number;
|
||||||
|
lastPublished?: number;
|
||||||
|
};
|
||||||
|
onShare?: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate duration in hours and minutes
|
// Calculate duration in hours and minutes
|
||||||
@ -25,29 +35,91 @@ const formatDuration = (startTime: number, endTime: number) => {
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkoutCard: React.FC<WorkoutCardProps> = ({
|
export const WorkoutCard: React.FC<EnhancedWorkoutCardProps> = ({
|
||||||
workout,
|
workout,
|
||||||
showDate = true,
|
showDate = true,
|
||||||
showExercises = true
|
showExercises = true,
|
||||||
|
source,
|
||||||
|
publishStatus,
|
||||||
|
onShare,
|
||||||
|
onImport
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
// Navigate to workout details
|
// Navigate to workout details
|
||||||
console.log(`Navigate to workout ${workout.id}`);
|
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 (
|
return (
|
||||||
<Card className="mb-4">
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7} testID={`workout-card-${workout.id}`}>
|
||||||
<CardContent className="p-4">
|
<Card
|
||||||
<View className="flex-row justify-between items-center mb-2">
|
className={cn(
|
||||||
<Text className="text-foreground text-lg font-semibold">{workout.title}</Text>
|
"mb-4",
|
||||||
<TouchableOpacity onPress={handlePress}>
|
workoutSource === 'nostr' && "border-primary border-2",
|
||||||
<ChevronRight className="text-muted-foreground" size={20} />
|
workoutSource === 'both' && "border-primary border"
|
||||||
</TouchableOpacity>
|
)}
|
||||||
</View>
|
>
|
||||||
|
<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 && (
|
{showDate && (
|
||||||
<Text className="text-muted-foreground mb-2">
|
<Text className="text-muted-foreground mb-2">
|
||||||
@ -55,6 +127,57 @@ export const WorkoutCard: React.FC<WorkoutCardProps> = ({
|
|||||||
</Text>
|
</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 mt-2">
|
||||||
<View className="flex-row items-center mr-4">
|
<View className="flex-row items-center mr-4">
|
||||||
<View className="w-6 h-6 items-center justify-center mr-1">
|
<View className="w-6 h-6 items-center justify-center mr-1">
|
||||||
@ -108,9 +231,10 @@ export const WorkoutCard: React.FC<WorkoutCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
## 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
|
## Tab Structure
|
||||||
2. Displays workout activity in a social feed format
|
|
||||||
3. Provides analytics and progress tracking
|
|
||||||
4. Offers easy access to settings and preferences
|
|
||||||
|
|
||||||
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
|
The Overview section will serve as the landing page for the Profile Tab and will include:
|
||||||
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
|
|
||||||
|
|
||||||
### 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
|
The Activity section will display the user's personal social feed:
|
||||||
- Activity: Activity icon
|
|
||||||
- Progress: BarChart2 icon
|
|
||||||
- Settings: Settings icon
|
|
||||||
|
|
||||||
### 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
|
The Progress section will provide detailed analytics and visualizations:
|
||||||
2. **WorkoutService**: For accessing workout history
|
|
||||||
3. **AnalyticsService**: For calculating statistics and progress metrics
|
|
||||||
|
|
||||||
## 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**:
|
### Settings Section
|
||||||
- `app/(tabs)/profile/_layout.tsx`: Defines the tab structure and navigation
|
|
||||||
|
|
||||||
2. **Tab Screens**:
|
The Settings section will include:
|
||||||
- `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
|
|
||||||
|
|
||||||
3. **Services**:
|
- Profile information management
|
||||||
- `lib/services/AnalyticsService.ts`: Service for calculating workout statistics and progress data
|
- Nostr account connection and management
|
||||||
- `lib/hooks/useAnalytics.ts`: React hook for accessing the analytics service
|
- 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.
|
1. Create the tab navigation structure with the four main sections
|
||||||
2. **Exercise Progress**: Track progress for specific exercises over time
|
2. Implement the Overview section with basic profile information
|
||||||
3. **Personal Records**: Identify and track personal records for exercises
|
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
|
1. Implement the Activity section with the personal feed
|
||||||
- All tabs show appropriate UI for unauthenticated users
|
2. Add post management functionality
|
||||||
- The NostrLoginSheet is accessible from the Overview tab
|
3. Integrate with the global social feed
|
||||||
|
4. Implement interaction features
|
||||||
|
|
||||||
## User Experience
|
### Phase 4: Nostr Integration
|
||||||
|
|
||||||
### Overview Tab
|
1. Enhance Nostr connectivity for profile data
|
||||||
|
2. Implement cross-device synchronization for progress data
|
||||||
The Overview tab provides a comprehensive view of the user's profile:
|
3. Add backup and restore functionality via Nostr
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## Technical Considerations
|
## 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
|
### Performance
|
||||||
|
|
||||||
- The AnalyticsService uses caching to minimize recalculations
|
- Optimize chart rendering for smooth performance
|
||||||
- Data is loaded asynchronously to keep the UI responsive
|
- Implement pagination for social feed
|
||||||
- Charts and visualizations use efficient rendering techniques
|
- Use memoization for expensive calculations
|
||||||
|
|
||||||
### Data Privacy
|
### Privacy
|
||||||
|
|
||||||
- Analytics are calculated locally on the device
|
- Clear user control over what data is shared
|
||||||
- Sharing controls allow users to decide what data is public
|
- Secure handling of personal information
|
||||||
- Personal records can be selectively shared
|
- 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
|
## 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
|
- Allows specialized UI for each view type
|
||||||
- Analytics and progress tracking will be moved to the Profile tab for better user context
|
- 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.
|
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:
|
Rationale:
|
||||||
@ -44,7 +53,7 @@ Rationale:
|
|||||||
- Separates presentation logic from data processing
|
- Separates presentation logic from data processing
|
||||||
- Supports both history visualization and profile analytics
|
- 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.
|
Focus on providing clear, chronological views of workout history with rich filtering and search capabilities.
|
||||||
|
|
||||||
Rationale:
|
Rationale:
|
||||||
@ -53,7 +62,7 @@ Rationale:
|
|||||||
- Filtering by exercise, type, and other attributes enables targeted review
|
- Filtering by exercise, type, and other attributes enables targeted review
|
||||||
- Integration with Profile tab analytics provides deeper insights when needed
|
- 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.
|
Implement a tiered approach to Nostr integration, starting with basic publishing capabilities in the MVP and expanding to full synchronization in future versions.
|
||||||
|
|
||||||
Rationale:
|
Rationale:
|
||||||
@ -62,8 +71,60 @@ Rationale:
|
|||||||
- Addresses core user needs first
|
- Addresses core user needs first
|
||||||
- Builds foundation for more advanced features
|
- 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
|
## 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
|
### Core Components
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@ -334,12 +395,46 @@ Note: Analytics Dashboard and Progress Tracking features have been moved to the
|
|||||||
- Full cross-device synchronization via Nostr
|
- Full cross-device synchronization via Nostr
|
||||||
- Collaborative workouts with friends
|
- 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
|
### Known Limitations
|
||||||
- Performance may degrade with very large workout histories
|
- Performance may degrade with very large workout histories
|
||||||
- Complex analytics require significant processing
|
- Complex analytics require significant processing
|
||||||
- Limited by available device storage
|
- Limited by available device storage
|
||||||
- Some features require online connectivity
|
- Some features require online connectivity
|
||||||
- Nostr relay availability affects sync reliability
|
- 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
|
## 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 { SQLiteDatabase } from 'expo-sqlite';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 10;
|
export const SCHEMA_VERSION = 11;
|
||||||
|
|
||||||
class Schema {
|
class Schema {
|
||||||
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
||||||
@ -127,6 +127,24 @@ class Schema {
|
|||||||
throw error;
|
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> {
|
async createTables(db: SQLiteDatabase): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -184,6 +202,11 @@ class Schema {
|
|||||||
await this.migrate_v10(db);
|
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
|
// Update schema version at the end of the transaction
|
||||||
await this.updateSchemaVersion(db);
|
await this.updateSchemaVersion(db);
|
||||||
});
|
});
|
||||||
@ -217,6 +240,11 @@ class Schema {
|
|||||||
await this.migrate_v10(db);
|
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
|
// Update schema version
|
||||||
await this.updateSchemaVersion(db);
|
await this.updateSchemaVersion(db);
|
||||||
|
|
||||||
@ -616,4 +644,3 @@ class Schema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const schema = new Schema();
|
export const schema = new Schema();
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// lib/services/ConnectivityService.ts
|
// 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 NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
||||||
import { openDatabaseSync } from 'expo-sqlite';
|
import { openDatabaseSync } from 'expo-sqlite';
|
||||||
|
|
||||||
@ -9,7 +9,11 @@ import { openDatabaseSync } from 'expo-sqlite';
|
|||||||
export class ConnectivityService {
|
export class ConnectivityService {
|
||||||
private static instance: ConnectivityService;
|
private static instance: ConnectivityService;
|
||||||
private isOnline: boolean = false;
|
private isOnline: boolean = false;
|
||||||
|
private lastOnlineTime: number | null = null;
|
||||||
private listeners: Set<(isOnline: boolean) => void> = new Set();
|
private listeners: Set<(isOnline: boolean) => void> = new Set();
|
||||||
|
private syncListeners: Set<() => void> = new Set();
|
||||||
|
private checkingStatus: boolean = false;
|
||||||
|
private offlineMode: boolean = false;
|
||||||
|
|
||||||
// Singleton pattern
|
// Singleton pattern
|
||||||
static getInstance(): ConnectivityService {
|
static getInstance(): ConnectivityService {
|
||||||
@ -39,27 +43,132 @@ export class ConnectivityService {
|
|||||||
* Handle network state changes
|
* Handle network state changes
|
||||||
*/
|
*/
|
||||||
private handleNetworkChange = (state: NetInfoState): void => {
|
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;
|
const newOnlineStatus = state.isConnected === true && state.isInternetReachable !== false;
|
||||||
|
|
||||||
// Only trigger updates if status actually changed
|
// Only trigger updates if status actually changed
|
||||||
if (this.isOnline !== newOnlineStatus) {
|
if (this.isOnline !== newOnlineStatus) {
|
||||||
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.updateStatusInDatabase(newOnlineStatus);
|
||||||
this.notifyListeners();
|
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 {
|
try {
|
||||||
|
this.checkingStatus = true;
|
||||||
|
|
||||||
|
// First get the network state from NetInfo
|
||||||
const state = await NetInfo.fetch();
|
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) {
|
} catch (error) {
|
||||||
console.error('[ConnectivityService] Error checking network status:', error);
|
console.error('[ConnectivityService] Error checking network status:', error);
|
||||||
this.isOnline = false;
|
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> {
|
private async updateStatusInDatabase(isOnline: boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const db = openDatabaseSync('powr.db');
|
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(
|
await db.runAsync(
|
||||||
`INSERT OR REPLACE INTO app_status (key, value, updated_at)
|
`INSERT OR REPLACE INTO app_status (key, value, updated_at)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?)`,
|
||||||
['online_status', isOnline ? 'online' : 'offline', Date.now()]
|
['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) {
|
} catch (error) {
|
||||||
console.error('[ConnectivityService] Error updating status in database:', 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
|
* Get current network connectivity status
|
||||||
*/
|
*/
|
||||||
@ -99,6 +240,13 @@ export class ConnectivityService {
|
|||||||
return this.isOnline;
|
return this.isOnline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last time the device was online
|
||||||
|
*/
|
||||||
|
getLastOnlineTime(): number | null {
|
||||||
|
return this.lastOnlineTime;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a listener for connectivity changes
|
* Register a listener for connectivity changes
|
||||||
*/
|
*/
|
||||||
@ -110,6 +258,14 @@ export class ConnectivityService {
|
|||||||
this.listeners.delete(listener);
|
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();
|
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(() => {
|
useEffect(() => {
|
||||||
// Register listener for updates
|
// 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
|
// 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 { SQLiteDatabase } from 'expo-sqlite';
|
||||||
|
import { NDK, NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk-mobile';
|
||||||
import { Workout } from '@/types/workout';
|
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 { DbService } from '../db-service';
|
||||||
import { WorkoutExercise } from '@/types/exercise';
|
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
|
// Define workout filter interface
|
||||||
export interface WorkoutFilters {
|
export interface WorkoutFilters {
|
||||||
@ -27,11 +31,20 @@ export interface WorkoutSyncStatus {
|
|||||||
// Define export format type
|
// Define export format type
|
||||||
export type ExportFormat = 'csv' | 'json';
|
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 db: DbService;
|
||||||
|
private ndk?: NDK;
|
||||||
|
private activeSubscriptions: Map<string, NDKSubscription> = new Map();
|
||||||
|
|
||||||
constructor(database: SQLiteDatabase) {
|
constructor(database: SQLiteDatabase) {
|
||||||
this.db = new DbService(database);
|
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) {
|
if (filters.source && filters.source.length > 0) {
|
||||||
// Handle 'both' specially
|
// Handle 'both' specially
|
||||||
if (filters.source.includes('both')) {
|
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 {
|
} else {
|
||||||
conditions.push(`w.source IN (${filters.source.map(() => '?').join(', ')})`);
|
conditions.push(`w.source IN (${filters.source.map(() => '?').join(', ')})`);
|
||||||
params.push(...filters.source);
|
params.push(...filters.source);
|
||||||
@ -371,7 +384,7 @@ export class WorkoutHistoryService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isLocal: workout.source === 'local' || workout.source === 'both',
|
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,
|
eventId: workout.nostr_event_id || undefined,
|
||||||
relayCount: workout.nostr_relay_count || undefined,
|
relayCount: workout.nostr_relay_count || undefined,
|
||||||
lastPublished: workout.nostr_published_at || undefined
|
lastPublished: workout.nostr_published_at || undefined
|
||||||
@ -383,9 +396,7 @@ export class WorkoutHistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a workout to Nostr
|
* Update the Nostr status of a workout in the local database
|
||||||
* This method updates the database with Nostr publication details
|
|
||||||
* The actual publishing is handled by NostrWorkoutService
|
|
||||||
*/
|
*/
|
||||||
async updateWorkoutNostrStatus(
|
async updateWorkoutNostrStatus(
|
||||||
workoutId: string,
|
workoutId: string,
|
||||||
@ -397,7 +408,12 @@ export class WorkoutHistoryService {
|
|||||||
`UPDATE workouts
|
`UPDATE workouts
|
||||||
SET nostr_event_id = ?,
|
SET nostr_event_id = ?,
|
||||||
nostr_published_at = ?,
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
[eventId, Date.now(), relayCount, workoutId]
|
[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 {
|
try {
|
||||||
const workouts = await this.db.getAllAsync<{
|
const workouts = await this.db.getAllAsync<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -467,11 +521,219 @@ export class WorkoutHistoryService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting workouts:', error);
|
console.error('Error getting local workouts:', error);
|
||||||
throw 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
|
* Get workouts for a specific date
|
||||||
*/
|
*/
|
||||||
@ -622,137 +884,350 @@ export class WorkoutHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total number of workouts
|
* Get the total number of workouts
|
||||||
*/
|
*/
|
||||||
async getWorkoutCount(): Promise<number> {
|
async getWorkoutCount(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const result = await this.db.getFirstAsync<{ count: number }>(
|
const result = await this.db.getFirstAsync<{ count: number }>(
|
||||||
`SELECT COUNT(*) as count FROM workouts`
|
`SELECT COUNT(*) as count FROM workouts`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result?.count || 0;
|
return result?.count || 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting workout count:', error);
|
console.error('Error getting workout count:', error);
|
||||||
return 0;
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
// Helper method to load workout exercises and sets
|
* Publish a local workout to Nostr
|
||||||
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
|
* @param workoutId ID of the workout to publish
|
||||||
try {
|
* @returns Promise with the event ID if successful
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Getting exercises for workout: ${workoutId}`);
|
*/
|
||||||
|
async publishWorkoutToNostr(workoutId: string): Promise<string> {
|
||||||
|
if (!this.ndk) {
|
||||||
|
throw new Error('NDK not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
const exercises = await this.db.getAllAsync<{
|
// Get the workout from the local database
|
||||||
id: string;
|
const workout = await this.getWorkoutDetails(workoutId);
|
||||||
exercise_id: string;
|
|
||||||
display_order: number;
|
|
||||||
notes: string | null;
|
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
|
||||||
}>(
|
|
||||||
`SELECT we.* FROM workout_exercises we
|
|
||||||
WHERE we.workout_id = ?
|
|
||||||
ORDER BY we.display_order`,
|
|
||||||
[workoutId]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Found ${exercises.length} exercises for workout ${workoutId}`);
|
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) {
|
// Set the kind to workout record
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Processing exercise: ${exercise.id}, exercise_id: ${exercise.exercise_id}`);
|
event.kind = 1301;
|
||||||
|
|
||||||
// Get the base exercise info
|
// Set the content to the workout notes
|
||||||
const baseExercise = await this.db.getFirstAsync<{
|
event.content = workout.notes || '';
|
||||||
title: string;
|
|
||||||
type: string;
|
// Add tags
|
||||||
category: string;
|
event.tags = [
|
||||||
equipment: string | null;
|
['d', workout.id],
|
||||||
}>(
|
['title', workout.title],
|
||||||
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
|
['type', workout.type],
|
||||||
[exercise.exercise_id]
|
['start', Math.floor(workout.startTime / 1000).toString()],
|
||||||
);
|
['completed', workout.isCompleted.toString()]
|
||||||
|
];
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Base exercise lookup result: ${baseExercise ? JSON.stringify(baseExercise) : 'null'}`);
|
|
||||||
|
// Add end time if available
|
||||||
// If base exercise not found, check if it exists in the exercises table
|
if (workout.endTime) {
|
||||||
if (!baseExercise) {
|
event.tags.push(['end', Math.floor(workout.endTime / 1000).toString()]);
|
||||||
const exerciseExists = await this.db.getFirstAsync<{ count: number }>(
|
}
|
||||||
`SELECT COUNT(*) as count FROM exercises WHERE id = ?`,
|
|
||||||
[exercise.exercise_id]
|
// Add exercise tags
|
||||||
);
|
for (const exercise of workout.exercises) {
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Exercise ${exercise.exercise_id} exists in exercises table: ${(exerciseExists?.count ?? 0) > 0}`);
|
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 }>(
|
// Add workout tags if available
|
||||||
`SELECT tag FROM exercise_tags WHERE exercise_id = ?`,
|
if (workout.tags && workout.tags.length > 0) {
|
||||||
[exercise.exercise_id]
|
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}`);
|
if (existingWorkout) {
|
||||||
|
// Workout already exists, update it
|
||||||
// Get the sets for this exercise
|
await this.db.runAsync(
|
||||||
const sets = await this.db.getAllAsync<{
|
`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;
|
id: string;
|
||||||
type: string;
|
exercise_id: string;
|
||||||
weight: number | null;
|
display_order: number;
|
||||||
reps: number | null;
|
notes: string | null;
|
||||||
rpe: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
is_completed: number;
|
|
||||||
completed_at: number | null;
|
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}>(
|
}>(
|
||||||
`SELECT * FROM workout_sets
|
`SELECT we.* FROM workout_exercises we
|
||||||
WHERE workout_exercise_id = ?
|
WHERE we.workout_id = ?
|
||||||
ORDER BY id`,
|
ORDER BY we.display_order`,
|
||||||
[exercise.id]
|
[workoutId]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Found ${sets.length} sets for exercise ${exercise.id}`);
|
const result: WorkoutExercise[] = [];
|
||||||
|
|
||||||
// Map sets to the correct format
|
for (const exercise of exercises) {
|
||||||
const mappedSets = sets.map(set => ({
|
// Get the base exercise info
|
||||||
id: set.id,
|
const baseExercise = await this.db.getFirstAsync<{
|
||||||
type: set.type as any,
|
title: string;
|
||||||
weight: set.weight || undefined,
|
type: string;
|
||||||
reps: set.reps || undefined,
|
category: string;
|
||||||
rpe: set.rpe || undefined,
|
equipment: string | null;
|
||||||
duration: set.duration || undefined,
|
}>(
|
||||||
isCompleted: Boolean(set.is_completed),
|
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
|
||||||
completedAt: set.completed_at || undefined,
|
[exercise.exercise_id]
|
||||||
lastUpdated: set.updated_at
|
);
|
||||||
}));
|
|
||||||
|
// 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';
|
return result;
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Using title: ${exerciseTitle} for exercise ${exercise.id}`);
|
} catch (error) {
|
||||||
|
console.error('Error getting workout exercises:', error);
|
||||||
result.push({
|
return [];
|
||||||
id: exercise.id,
|
|
||||||
exerciseId: exercise.exercise_id,
|
|
||||||
title: exerciseTitle,
|
|
||||||
type: baseExercise?.type as any || 'strength',
|
|
||||||
category: baseExercise?.category as any || 'Other',
|
|
||||||
equipment: baseExercise?.equipment as any,
|
|
||||||
notes: exercise.notes || undefined,
|
|
||||||
tags: tags.map(t => t.tag), // Add the tags array here
|
|
||||||
sets: mappedSets,
|
|
||||||
created_at: exercise.created_at,
|
|
||||||
lastUpdated: exercise.updated_at,
|
|
||||||
isCompleted: mappedSets.every(set => set.isCompleted),
|
|
||||||
availability: { source: ['local'] }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[EnhancedWorkoutHistoryService] Returning ${result.length} processed exercises for workout ${workoutId}`);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[EnhancedWorkoutHistoryService] Error getting workout exercises:', error);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
@ -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 { useEffect, useMemo } from 'react';
|
||||||
import { analyticsService } from '@/lib/services/AnalyticsService';
|
import { analyticsService } from '@/lib/services/AnalyticsService';
|
||||||
import { useWorkoutService } from '@/components/DatabaseProvider';
|
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
|
* Hook to provide access to the analytics service
|
||||||
@ -10,19 +13,24 @@ import { useWorkoutService } from '@/components/DatabaseProvider';
|
|||||||
*/
|
*/
|
||||||
export function useAnalytics() {
|
export function useAnalytics() {
|
||||||
const workoutService = useWorkoutService();
|
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
|
// Initialize the analytics service with the necessary services
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
analyticsService.setWorkoutService(workoutService);
|
analyticsService.setWorkoutService(workoutService);
|
||||||
|
analyticsService.setNostrWorkoutHistoryService(unifiedWorkoutHistoryService);
|
||||||
// We could also set the NostrWorkoutService here if needed
|
|
||||||
// analyticsService.setNostrWorkoutService(nostrWorkoutService);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Clear the cache when the component unmounts
|
// Clear the cache when the component unmounts
|
||||||
analyticsService.invalidateCache();
|
analyticsService.invalidateCache();
|
||||||
};
|
};
|
||||||
}, [workoutService]);
|
}, [workoutService, unifiedWorkoutHistoryService]);
|
||||||
|
|
||||||
// Create a memoized object with the analytics methods
|
// Create a memoized object with the analytics methods
|
||||||
const analytics = useMemo(() => ({
|
const analytics = useMemo(() => ({
|
||||||
@ -41,6 +49,25 @@ export function useAnalytics() {
|
|||||||
getPersonalRecords: (exerciseIds?: string[], limit?: number) =>
|
getPersonalRecords: (exerciseIds?: string[], limit?: number) =>
|
||||||
analyticsService.getPersonalRecords(exerciseIds, limit),
|
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
|
// Cache management
|
||||||
invalidateCache: () => analyticsService.invalidateCache()
|
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 * as SecureStore from 'expo-secure-store';
|
||||||
import { RelayService, DEFAULT_RELAYS } from '@/lib/db/services/RelayService';
|
import { RelayService, DEFAULT_RELAYS } from '@/lib/db/services/RelayService';
|
||||||
import { extendNDK } from '@/types/ndk-extensions';
|
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
|
* Initialize NDK with relays
|
||||||
@ -46,12 +50,43 @@ export async function initializeNDK() {
|
|||||||
// Set the NDK instance in the RelayService
|
// Set the NDK instance in the RelayService
|
||||||
relayService.setNDK(ndk);
|
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 {
|
try {
|
||||||
console.log('[NDK] Connecting to relays...');
|
console.log('[NDK] Connecting to relays with timeout...');
|
||||||
await ndk.connect();
|
|
||||||
|
|
||||||
// Wait a moment for connections to establish
|
// Create a promise that will reject after the timeout
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
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
|
// Get updated relay statuses
|
||||||
const relaysWithStatus = await relayService.getAllRelaysWithStatus();
|
const relaysWithStatus = await relayService.getAllRelaysWithStatus();
|
||||||
@ -73,7 +108,8 @@ export async function initializeNDK() {
|
|||||||
ndk,
|
ndk,
|
||||||
relayService,
|
relayService,
|
||||||
connectedRelayCount: connectedRelays.length,
|
connectedRelayCount: connectedRelays.length,
|
||||||
connectedRelays
|
connectedRelays,
|
||||||
|
offlineMode: connectedRelays.length === 0
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[NDK] Error during connection:', error);
|
console.error('[NDK] Error during connection:', error);
|
||||||
@ -82,7 +118,8 @@ export async function initializeNDK() {
|
|||||||
ndk,
|
ndk,
|
||||||
relayService,
|
relayService,
|
||||||
connectedRelayCount: 0,
|
connectedRelayCount: 0,
|
||||||
connectedRelays: []
|
connectedRelays: [],
|
||||||
|
offlineMode: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
import { Workout } from '@/types/workout';
|
import { Workout } from '@/types/workout';
|
||||||
import { WorkoutService } from '@/lib/db/services/WorkoutService';
|
import { WorkoutService } from '@/lib/db/services/WorkoutService';
|
||||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
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
|
* Workout statistics data structure
|
||||||
@ -15,6 +17,29 @@ export interface WorkoutStats {
|
|||||||
frequencyByDay: number[]; // 0 = Sunday, 6 = Saturday
|
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
|
* Progress point for tracking exercise progress
|
||||||
*/
|
*/
|
||||||
@ -40,6 +65,20 @@ export interface PersonalRecord {
|
|||||||
value: number;
|
value: number;
|
||||||
date: 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 {
|
export class AnalyticsService {
|
||||||
private workoutService: WorkoutService | null = null;
|
private workoutService: WorkoutService | null = null;
|
||||||
private nostrWorkoutService: NostrWorkoutService | null = null;
|
private nostrWorkoutService: NostrWorkoutService | null = null;
|
||||||
|
private nostrWorkoutHistoryService: NostrWorkoutHistoryService | null = null;
|
||||||
|
private unifiedWorkoutHistoryService: UnifiedWorkoutHistoryService | null = null;
|
||||||
private cache = new Map<string, any>();
|
private cache = new Map<string, any>();
|
||||||
|
private includeNostr: boolean = true;
|
||||||
|
|
||||||
// Set the workout service (called from React components)
|
// Set the workout service (called from React components)
|
||||||
setWorkoutService(service: WorkoutService): void {
|
setWorkoutService(service: WorkoutService): void {
|
||||||
@ -60,6 +102,26 @@ export class AnalyticsService {
|
|||||||
this.nostrWorkoutService = service;
|
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
|
* Get workout statistics for a given period
|
||||||
*/
|
*/
|
||||||
@ -278,7 +340,23 @@ export class AnalyticsService {
|
|||||||
break;
|
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[] = [];
|
let localWorkouts: Workout[] = [];
|
||||||
if (this.workoutService) {
|
if (this.workoutService) {
|
||||||
localWorkouts = await this.workoutService.getWorkoutsByDateRange(startDate.getTime(), now.getTime());
|
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);
|
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
|
* Invalidate cache when new workouts are added
|
||||||
*/
|
*/
|
||||||
|
@ -61,7 +61,7 @@ export type UpdateEntryFn = (id: string, updater: (entry: AnyFeedEntry) => AnyFe
|
|||||||
|
|
||||||
// Feed filter options
|
// Feed filter options
|
||||||
export interface FeedFilterOptions {
|
export interface FeedFilterOptions {
|
||||||
feedType: 'following' | 'powr' | 'global' | 'user-activity';
|
feedType: 'following' | 'powr' | 'global' | 'user-activity' | 'workout-history';
|
||||||
since?: number;
|
since?: number;
|
||||||
until?: number;
|
until?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@ -78,5 +78,5 @@ export interface FeedOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
filterFn?: FeedEntryFilterFn;
|
filterFn?: FeedEntryFilterFn;
|
||||||
sortFn?: (a: AnyFeedEntry, b: AnyFeedEntry) => number;
|
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 {
|
export interface ContentAvailability {
|
||||||
source: StorageSource[];
|
source: StorageSource[];
|
||||||
lastSynced?: LastSyncedInfo;
|
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 { BaseExercise } from './exercise';
|
||||||
import type { SyncableContent } from './shared';
|
import type { SyncableContent } from './shared';
|
||||||
import type { NostrEvent } from './nostr';
|
import type { NostrEvent } from './nostr';
|
||||||
|
import { generateId } from '@/utils/ids';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core workout status types
|
* Core workout status types
|
||||||
@ -200,13 +201,13 @@ export type WorkoutAction =
|
|||||||
*/
|
*/
|
||||||
export function templateToWorkout(template: WorkoutTemplate): Workout {
|
export function templateToWorkout(template: WorkoutTemplate): Workout {
|
||||||
return {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: generateId('nostr'),
|
||||||
title: template.title,
|
title: template.title,
|
||||||
type: template.type,
|
type: template.type,
|
||||||
exercises: template.exercises.map(ex => ({
|
exercises: template.exercises.map(ex => ({
|
||||||
...ex.exercise,
|
...ex.exercise,
|
||||||
sets: Array(ex.targetSets).fill({
|
sets: Array(ex.targetSets).fill({
|
||||||
id: crypto.randomUUID(),
|
id: generateId('nostr'),
|
||||||
type: 'normal',
|
type: 'normal',
|
||||||
reps: ex.targetReps,
|
reps: ex.targetReps,
|
||||||
isCompleted: false
|
isCompleted: false
|
||||||
@ -261,4 +262,4 @@ export function createNostrWorkoutEvent(workout: Workout): NostrEvent {
|
|||||||
],
|
],
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,24 @@
|
|||||||
// utils/formatTime.ts
|
// utils/formatTime.ts
|
||||||
|
/**
|
||||||
|
* Format milliseconds into MM:SS format
|
||||||
|
*/
|
||||||
export function formatTime(ms: number): string {
|
export function formatTime(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
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