2025-03-22 23:21:26 -04:00
|
|
|
|
// app/(tabs)/profile/progress.tsx
|
|
|
|
|
import React, { useState, useEffect } from 'react';
|
2025-03-23 15:53:34 -04:00
|
|
|
|
import { View, ScrollView, Switch, TouchableOpacity } from 'react-native';
|
2025-03-22 23:21:26 -04:00
|
|
|
|
import { Text } from '@/components/ui/text';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
|
|
|
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
|
|
|
|
import { ActivityIndicator } from 'react-native';
|
|
|
|
|
import { useAnalytics } from '@/lib/hooks/useAnalytics';
|
2025-03-23 15:53:34 -04:00
|
|
|
|
import { WorkoutStats, PersonalRecord, analyticsService } from '@/lib/services/AnalyticsService';
|
|
|
|
|
import { CloudIcon } from 'lucide-react-native';
|
2025-03-22 23:21:26 -04:00
|
|
|
|
|
|
|
|
|
// Period selector component
|
|
|
|
|
function PeriodSelector({ period, setPeriod }: {
|
|
|
|
|
period: 'week' | 'month' | 'year' | 'all',
|
|
|
|
|
setPeriod: (period: 'week' | 'month' | 'year' | 'all') => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<View className="flex-row justify-center my-4">
|
|
|
|
|
<Button
|
|
|
|
|
variant={period === 'week' ? 'purple' : 'outline'}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mx-1"
|
|
|
|
|
onPress={() => setPeriod('week')}
|
|
|
|
|
>
|
|
|
|
|
<Text className={period === 'week' ? 'text-white' : 'text-foreground'}>Week</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant={period === 'month' ? 'purple' : 'outline'}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mx-1"
|
|
|
|
|
onPress={() => setPeriod('month')}
|
|
|
|
|
>
|
|
|
|
|
<Text className={period === 'month' ? 'text-white' : 'text-foreground'}>Month</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant={period === 'year' ? 'purple' : 'outline'}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mx-1"
|
|
|
|
|
onPress={() => setPeriod('year')}
|
|
|
|
|
>
|
|
|
|
|
<Text className={period === 'year' ? 'text-white' : 'text-foreground'}>Year</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant={period === 'all' ? 'purple' : 'outline'}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mx-1"
|
|
|
|
|
onPress={() => setPeriod('all')}
|
|
|
|
|
>
|
|
|
|
|
<Text className={period === 'all' ? 'text-white' : 'text-foreground'}>All</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
</View>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Format duration in hours and minutes
|
|
|
|
|
function formatDuration(milliseconds: number): string {
|
|
|
|
|
const hours = Math.floor(milliseconds / (1000 * 60 * 60));
|
|
|
|
|
const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
|
return `${hours}h ${minutes}m`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ProgressScreen() {
|
|
|
|
|
const { isAuthenticated } = useNDKCurrentUser();
|
|
|
|
|
const analytics = useAnalytics();
|
|
|
|
|
const [period, setPeriod] = useState<'week' | 'month' | 'year' | 'all'>('month');
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [stats, setStats] = useState<WorkoutStats | null>(null);
|
|
|
|
|
const [records, setRecords] = useState<PersonalRecord[]>([]);
|
2025-03-23 15:53:34 -04:00
|
|
|
|
const [includeNostr, setIncludeNostr] = useState(true);
|
2025-03-22 23:21:26 -04:00
|
|
|
|
|
2025-03-23 15:53:34 -04:00
|
|
|
|
// Load workout statistics when period or includeNostr changes
|
2025-03-22 23:21:26 -04:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
async function loadStats() {
|
|
|
|
|
if (!isAuthenticated) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
2025-03-23 15:53:34 -04:00
|
|
|
|
|
|
|
|
|
// Pass includeNostr flag to analytics service
|
|
|
|
|
analyticsService.setIncludeNostr(includeNostr);
|
|
|
|
|
|
2025-03-22 23:21:26 -04:00
|
|
|
|
const workoutStats = await analytics.getWorkoutStats(period);
|
|
|
|
|
setStats(workoutStats);
|
|
|
|
|
|
|
|
|
|
// Load personal records
|
|
|
|
|
const personalRecords = await analytics.getPersonalRecords(undefined, 5);
|
|
|
|
|
setRecords(personalRecords);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error loading analytics data:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadStats();
|
2025-03-23 15:53:34 -04:00
|
|
|
|
}, [isAuthenticated, period, includeNostr, analytics]);
|
2025-03-22 23:21:26 -04:00
|
|
|
|
|
|
|
|
|
// Workout frequency chart
|
|
|
|
|
const WorkoutFrequencyChart = () => {
|
|
|
|
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<View className="h-40 bg-muted rounded-lg items-center justify-center">
|
|
|
|
|
<Text className="text-muted-foreground">Workout Frequency Chart</Text>
|
|
|
|
|
<View className="flex-row justify-evenly w-full mt-2">
|
|
|
|
|
{stats?.frequencyByDay.map((count, index) => (
|
|
|
|
|
<View key={index} className="items-center">
|
|
|
|
|
<View
|
|
|
|
|
style={{
|
|
|
|
|
height: count * 8,
|
|
|
|
|
width: 20,
|
|
|
|
|
backgroundColor: 'hsl(var(--purple))',
|
|
|
|
|
borderRadius: 4
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Text className="text-xs text-muted-foreground mt-1">{days[index]}</Text>
|
|
|
|
|
</View>
|
|
|
|
|
))}
|
|
|
|
|
</View>
|
|
|
|
|
</View>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Exercise distribution chart
|
|
|
|
|
const ExerciseDistributionChart = () => {
|
|
|
|
|
// Sample exercise names for demonstration
|
|
|
|
|
const exerciseNames = [
|
|
|
|
|
'Bench Press', 'Squat', 'Deadlift', 'Pull-up', 'Shoulder Press'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Convert exercise distribution to percentages
|
|
|
|
|
const exerciseDistribution = stats?.exerciseDistribution || {};
|
|
|
|
|
const total = Object.values(exerciseDistribution).reduce((sum, count) => sum + count, 0) || 1;
|
|
|
|
|
const percentages = Object.entries(exerciseDistribution).reduce((acc, [id, count]) => {
|
|
|
|
|
acc[id] = Math.round((count / total) * 100);
|
|
|
|
|
return acc;
|
|
|
|
|
}, {} as Record<string, number>);
|
|
|
|
|
|
|
|
|
|
// Take top 5 exercises
|
|
|
|
|
const topExercises = Object.entries(percentages)
|
|
|
|
|
.sort(([, a], [, b]) => b - a)
|
|
|
|
|
.slice(0, 5);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<View className="h-40 bg-muted rounded-lg items-center justify-center">
|
|
|
|
|
<Text className="text-muted-foreground">Exercise Distribution</Text>
|
|
|
|
|
<View className="flex-row justify-evenly w-full mt-2">
|
|
|
|
|
{topExercises.map(([id, percentage], index) => (
|
|
|
|
|
<View key={index} className="items-center mx-1">
|
|
|
|
|
<View
|
|
|
|
|
style={{
|
|
|
|
|
height: percentage * 1.5,
|
|
|
|
|
width: 20,
|
|
|
|
|
backgroundColor: `hsl(${index * 50}, 70%, 50%)`,
|
|
|
|
|
borderRadius: 4
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Text className="text-xs text-muted-foreground mt-1 text-center">
|
|
|
|
|
{exerciseNames[index % exerciseNames.length].substring(0, 8)}
|
|
|
|
|
</Text>
|
|
|
|
|
</View>
|
|
|
|
|
))}
|
|
|
|
|
</View>
|
|
|
|
|
</View>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
return (
|
|
|
|
|
<View className="flex-1 items-center justify-center p-6">
|
|
|
|
|
<Text className="text-center text-muted-foreground">
|
|
|
|
|
Log in to view your progress
|
|
|
|
|
</Text>
|
|
|
|
|
</View>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<View className="flex-1 items-center justify-center">
|
|
|
|
|
<ActivityIndicator />
|
|
|
|
|
</View>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ScrollView className="flex-1 p-4">
|
2025-03-23 15:53:34 -04:00
|
|
|
|
<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>
|
2025-03-22 23:21:26 -04:00
|
|
|
|
|
|
|
|
|
{/* Workout Summary */}
|
2025-03-23 15:53:34 -04:00
|
|
|
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
2025-03-22 23:21:26 -04:00
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
<Text className="text-lg font-semibold mb-2">Workout Summary</Text>
|
|
|
|
|
<Text className="mb-1">Workouts: {stats?.workoutCount || 0}</Text>
|
|
|
|
|
<Text className="mb-1">Total Time: {formatDuration(stats?.totalDuration || 0)}</Text>
|
|
|
|
|
<Text className="mb-1">Total Volume: {(stats?.totalVolume || 0).toLocaleString()} lb</Text>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Workout Frequency Chart */}
|
2025-03-23 15:53:34 -04:00
|
|
|
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
2025-03-22 23:21:26 -04:00
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
<Text className="text-lg font-semibold mb-2">Workout Frequency</Text>
|
|
|
|
|
<WorkoutFrequencyChart />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Muscle Group Distribution */}
|
2025-03-23 15:53:34 -04:00
|
|
|
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
2025-03-22 23:21:26 -04:00
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
<Text className="text-lg font-semibold mb-2">Exercise Distribution</Text>
|
|
|
|
|
<ExerciseDistributionChart />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Personal Records */}
|
2025-03-23 15:53:34 -04:00
|
|
|
|
<Card className={`mb-4 ${isAuthenticated && includeNostr ? "border-primary" : ""}`}>
|
2025-03-22 23:21:26 -04:00
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
<Text className="text-lg font-semibold mb-2">Personal Records</Text>
|
|
|
|
|
{records.length === 0 ? (
|
|
|
|
|
<Text className="text-muted-foreground text-center py-4">
|
|
|
|
|
No personal records yet. Complete more workouts to see your progress.
|
|
|
|
|
</Text>
|
|
|
|
|
) : (
|
|
|
|
|
records.map((record) => (
|
|
|
|
|
<View key={record.id} className="py-2 border-b border-border">
|
|
|
|
|
<Text className="font-medium">{record.exerciseName}</Text>
|
|
|
|
|
<Text>{record.value} {record.unit} × {record.reps} reps</Text>
|
|
|
|
|
<Text className="text-muted-foreground text-sm">
|
|
|
|
|
{new Date(record.date).toLocaleDateString()}
|
|
|
|
|
</Text>
|
|
|
|
|
{record.previousRecord && (
|
|
|
|
|
<Text className="text-muted-foreground text-sm">
|
|
|
|
|
Previous: {record.previousRecord.value} {record.unit}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</View>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
2025-03-23 15:53:34 -04:00
|
|
|
|
{/* Nostr integration note */}
|
|
|
|
|
{isAuthenticated && includeNostr && (
|
|
|
|
|
<Card className="mb-4 border-primary">
|
|
|
|
|
<CardContent className="p-4 flex-row items-center">
|
|
|
|
|
<CloudIcon size={16} className="text-primary mr-2" />
|
|
|
|
|
<Text className="text-muted-foreground flex-1">
|
|
|
|
|
Analytics include workouts from Nostr. Toggle the switch above to view only local workouts.
|
|
|
|
|
</Text>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
2025-03-22 23:21:26 -04:00
|
|
|
|
</ScrollView>
|
|
|
|
|
);
|
|
|
|
|
}
|