3 and 4 completed
This commit is contained in:
1 parent
3573709d99
commit
97436c6823
21 files changed
+2168
-558
No files matched your search
@@ -1,157 +1,243 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../styles/theme';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useUser } from "@clerk/clerk-expo";
|
||||
import { theme } from "../styles/theme";
|
||||
import { getUserStatistics } from "../api/statistics";
|
||||
import type { UserStatisticsResponse } from "../api/types";
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const { width } = Dimensions.get("window");
|
||||
|
||||
interface ActivityWidgetProps {
|
||||
steps: number;
|
||||
calories: number;
|
||||
duration: number; // in minutes
|
||||
steps?: number;
|
||||
calories: number;
|
||||
duration?: number; // in minutes
|
||||
}
|
||||
|
||||
export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.dark}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Daily Activity</Text>
|
||||
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
export function ActivityWidget({
|
||||
steps = 0,
|
||||
calories,
|
||||
duration = 0,
|
||||
}: ActivityWidgetProps) {
|
||||
const { user } = useUser();
|
||||
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(59, 130, 246, 0.2)' }]}>
|
||||
<Ionicons name="footsteps" size={20} color="#3b82f6" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{steps.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>Steps</Text>
|
||||
</View>
|
||||
useEffect(() => {
|
||||
const loadStatistics = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
<View style={styles.divider} />
|
||||
try {
|
||||
const stats = await getUserStatistics(user.id);
|
||||
setStatistics(stats);
|
||||
} catch (error) {
|
||||
console.error("Failed to load statistics:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.2)' }]}>
|
||||
<Ionicons name="flame" size={20} color="#ef4444" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{calories}</Text>
|
||||
<Text style={styles.statLabel}>Kcal</Text>
|
||||
</View>
|
||||
loadStatistics();
|
||||
}, [user?.id]);
|
||||
|
||||
<View style={styles.divider} />
|
||||
// Calculate weekly activity bars from weekly trend data
|
||||
const getWeeklyBars = () => {
|
||||
if (!statistics || statistics.weeklyTrend.length === 0) {
|
||||
// Fallback mock data
|
||||
return [0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7];
|
||||
}
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(16, 185, 129, 0.2)' }]}>
|
||||
<Ionicons name="time" size={20} color="#10b981" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{duration}m</Text>
|
||||
<Text style={styles.statLabel}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
// Get last 7 weeks and normalize to 0-1 scale
|
||||
const last7Weeks = statistics.weeklyTrend.slice(-7);
|
||||
const maxCheckIns = Math.max(...last7Weeks.map((w) => w.checkIns), 1);
|
||||
|
||||
{/* Simple Bar Chart Visualization */}
|
||||
<View style={styles.chartContainer}>
|
||||
{[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => (
|
||||
<View key={index} style={styles.barContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primaryVertical}
|
||||
style={[styles.bar, { height: height * 60 }]}
|
||||
/>
|
||||
<Text style={styles.dayLabel}>
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
return last7Weeks.map((week) => {
|
||||
// Normalize check-ins to 0.2-1.0 range for better visualization
|
||||
const normalized = week.checkIns / maxCheckIns;
|
||||
return Math.max(normalized * 0.8 + 0.2, 0.2);
|
||||
});
|
||||
};
|
||||
|
||||
const weeklyBars = getWeeklyBars();
|
||||
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
|
||||
const currentStreak = statistics?.attendance.currentStreak || 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.dark}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Daily Activity</Text>
|
||||
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: "rgba(59, 130, 246, 0.2)" },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#3b82f6" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{checkInsThisWeek}</Text>
|
||||
<Text style={styles.statLabel}>This Week</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: "rgba(239, 68, 68, 0.2)" },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="flame" size={20} color="#ef4444" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{calories}</Text>
|
||||
<Text style={styles.statLabel}>Kcal</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: "rgba(16, 185, 129, 0.2)" },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="trophy" size={20} color="#10b981" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{currentStreak}</Text>
|
||||
<Text style={styles.statLabel}>Day Streak</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Weekly Bar Chart */}
|
||||
<View style={styles.chartContainer}>
|
||||
{weeklyBars.map((height, index) => (
|
||||
<View key={index} style={styles.barContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primaryVertical}
|
||||
style={[styles.bar, { height: height * 60 }]}
|
||||
/>
|
||||
<Text style={styles.dayLabel}>
|
||||
{["M", "T", "W", "T", "F", "S", "S"][index % 7]}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
marginBottom: 2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.gray400,
|
||||
fontWeight: '500',
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
chartContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
height: 80,
|
||||
paddingTop: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
barContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
bar: {
|
||||
width: 6,
|
||||
borderRadius: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 10,
|
||||
color: theme.colors.gray500,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: "#fff",
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
color: "#fff",
|
||||
marginBottom: 2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.gray400,
|
||||
fontWeight: "500",
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
chartContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
height: 80,
|
||||
paddingTop: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
barContainer: {
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
bar: {
|
||||
width: 6,
|
||||
borderRadius: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 10,
|
||||
color: theme.colors.gray500,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { theme } from "../styles/theme";
|
||||
|
||||
interface AttendanceRecord {
|
||||
id: string;
|
||||
checkInTime: string;
|
||||
checkOutTime?: string | null;
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
interface AttendanceCalendarProps {
|
||||
attendanceRecords: AttendanceRecord[];
|
||||
}
|
||||
|
||||
export function AttendanceCalendar({
|
||||
attendanceRecords,
|
||||
}: AttendanceCalendarProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [calendarDays, setCalendarDays] = useState<
|
||||
Array<{ date: Date | null; hasAttendance: boolean; isToday: boolean }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
generateCalendar(currentMonth);
|
||||
}, [currentMonth, attendanceRecords]);
|
||||
|
||||
const generateCalendar = (month: Date) => {
|
||||
const year = month.getFullYear();
|
||||
const monthIndex = month.getMonth();
|
||||
|
||||
// Get first day of month and number of days
|
||||
const firstDay = new Date(year, monthIndex, 1);
|
||||
const lastDay = new Date(year, monthIndex + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
// Create attendance lookup set
|
||||
const attendanceDates = new Set(
|
||||
attendanceRecords.map((record) =>
|
||||
new Date(record.checkInTime).toDateString(),
|
||||
),
|
||||
);
|
||||
|
||||
const today = new Date().toDateString();
|
||||
|
||||
// Build calendar array
|
||||
const days: Array<{
|
||||
date: Date | null;
|
||||
hasAttendance: boolean;
|
||||
isToday: boolean;
|
||||
}> = [];
|
||||
|
||||
// Add empty cells for days before month starts
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
days.push({ date: null, hasAttendance: false, isToday: false });
|
||||
}
|
||||
|
||||
// Add days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(year, monthIndex, day);
|
||||
const dateString = date.toDateString();
|
||||
days.push({
|
||||
date,
|
||||
hasAttendance: attendanceDates.has(dateString),
|
||||
isToday: dateString === today,
|
||||
});
|
||||
}
|
||||
|
||||
setCalendarDays(days);
|
||||
};
|
||||
|
||||
const goToPreviousMonth = () => {
|
||||
setCurrentMonth(
|
||||
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1),
|
||||
);
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
setCurrentMonth(
|
||||
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1),
|
||||
);
|
||||
};
|
||||
|
||||
const monthName = currentMonth.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.white, theme.colors.gray50]}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Attendance Calendar</Text>
|
||||
</View>
|
||||
|
||||
{/* Month Navigation */}
|
||||
<View style={styles.monthNav}>
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousMonth}
|
||||
style={styles.navButton}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.monthText}>{monthName}</Text>
|
||||
<TouchableOpacity onPress={goToNextMonth} style={styles.navButton}>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Day Headers */}
|
||||
<View style={styles.dayHeaders}>
|
||||
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
|
||||
<Text key={day} style={styles.dayHeader}>
|
||||
{day}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<View style={styles.calendarGrid}>
|
||||
{calendarDays.map((day, index) => (
|
||||
<View key={index} style={styles.dayCell}>
|
||||
{day.date ? (
|
||||
<View
|
||||
style={[
|
||||
styles.dayContent,
|
||||
day.isToday && styles.todayContent,
|
||||
day.hasAttendance && styles.attendanceContent,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayText,
|
||||
day.isToday && styles.todayText,
|
||||
day.hasAttendance && styles.attendanceText,
|
||||
]}
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</Text>
|
||||
{day.hasAttendance && <View style={styles.attendanceDot} />}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyCell} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Legend */}
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={styles.legendDotAttendance} />
|
||||
<Text style={styles.legendText}>Attended</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={styles.legendDotToday} />
|
||||
<Text style={styles.legendText}>Today</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize.xl,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.gray800,
|
||||
},
|
||||
monthNav: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
navButton: {
|
||||
padding: 8,
|
||||
},
|
||||
monthText: {
|
||||
fontSize: theme.typography.fontSize.lg,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
dayHeaders: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 8,
|
||||
},
|
||||
dayHeader: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
calendarGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
dayCell: {
|
||||
width: `${100 / 7}%`,
|
||||
aspectRatio: 1,
|
||||
padding: 2,
|
||||
},
|
||||
dayContent: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
position: "relative",
|
||||
},
|
||||
todayContent: {
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.primary,
|
||||
},
|
||||
attendanceContent: {
|
||||
backgroundColor: theme.colors.successLight,
|
||||
},
|
||||
emptyCell: {
|
||||
flex: 1,
|
||||
},
|
||||
dayText: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
todayText: {
|
||||
color: theme.colors.white,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
},
|
||||
attendanceText: {
|
||||
color: theme.colors.white,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
},
|
||||
attendanceDot: {
|
||||
position: "absolute",
|
||||
bottom: 2,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.white,
|
||||
},
|
||||
legend: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: 20,
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.gray200,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
legendDotAttendance: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: theme.colors.successLight,
|
||||
},
|
||||
legendDotToday: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.primary,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Dimensions } from "react-native";
|
||||
import { PieChart } from "react-native-chart-kit";
|
||||
import { theme } from "../styles/theme";
|
||||
|
||||
interface GoalTypeData {
|
||||
goalType: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface GoalTypeBreakdownChartProps {
|
||||
data: GoalTypeData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function GoalTypeBreakdownChart({
|
||||
data,
|
||||
title = "Goals by Type",
|
||||
}: GoalTypeBreakdownChartProps) {
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
// Color palette for different goal types
|
||||
const colors = [
|
||||
"#3b82f6", // Blue
|
||||
"#10b981", // Green
|
||||
"#f59e0b", // Orange
|
||||
"#8b5cf6", // Purple
|
||||
"#ec4899", // Pink
|
||||
"#06b6d4", // Cyan
|
||||
];
|
||||
|
||||
// Prepare chart data
|
||||
const chartData = data.map((item, index) => ({
|
||||
name: item.goalType,
|
||||
count: item.count,
|
||||
color: colors[index % colors.length],
|
||||
legendFontColor: theme.colors.gray600,
|
||||
legendFontSize: 12,
|
||||
}));
|
||||
|
||||
const chartConfig = {
|
||||
color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`,
|
||||
};
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>No goals yet</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.chartContainer}>
|
||||
<PieChart
|
||||
data={chartData}
|
||||
width={screenWidth - 60}
|
||||
height={200}
|
||||
chartConfig={chartConfig}
|
||||
accessor="count"
|
||||
backgroundColor="transparent"
|
||||
paddingLeft="15"
|
||||
absolute
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: theme.colors.white,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
...theme.shadows.medium,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize.lg,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.gray700,
|
||||
marginBottom: 12,
|
||||
},
|
||||
chartContainer: {
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray400,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Dimensions } from "react-native";
|
||||
import { LineChart } from "react-native-chart-kit";
|
||||
import { theme } from "../styles/theme";
|
||||
import type { WeeklyTrendData } from "../api/types";
|
||||
|
||||
interface WeeklyProgressChartProps {
|
||||
weeklyData: WeeklyTrendData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function WeeklyProgressChart({
|
||||
weeklyData,
|
||||
title = "Weekly Progress",
|
||||
}: WeeklyProgressChartProps) {
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
// Prepare chart data
|
||||
const labels = weeklyData.map((week) => week.weekLabel);
|
||||
const checkInsData = weeklyData.map((week) => week.checkIns);
|
||||
const goalsCompletedData = weeklyData.map((week) => week.goalsCompleted);
|
||||
const avgProgressData = weeklyData.map((week) => week.averageProgress);
|
||||
|
||||
const chartConfig = {
|
||||
backgroundColor: theme.colors.white,
|
||||
backgroundGradientFrom: theme.colors.white,
|
||||
backgroundGradientTo: theme.colors.white,
|
||||
decimalPlaces: 0,
|
||||
color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`,
|
||||
labelColor: (opacity = 1) => `rgba(107, 114, 128, ${opacity})`,
|
||||
style: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
propsForDots: {
|
||||
r: "4",
|
||||
strokeWidth: "2",
|
||||
stroke: theme.colors.primary,
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: checkInsData,
|
||||
color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`, // Blue for check-ins
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
data: goalsCompletedData,
|
||||
color: (opacity = 1) => `rgba(16, 185, 129, ${opacity})`, // Green for goals
|
||||
strokeWidth: 2,
|
||||
},
|
||||
],
|
||||
legend: ["Check-ins", "Goals Completed"],
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.chartContainer}>
|
||||
<LineChart
|
||||
data={data}
|
||||
width={screenWidth - 60}
|
||||
height={220}
|
||||
chartConfig={chartConfig}
|
||||
bezier
|
||||
style={styles.chart}
|
||||
withInnerLines={true}
|
||||
withOuterLines={true}
|
||||
withVerticalLabels={true}
|
||||
withHorizontalLabels={true}
|
||||
fromZero={true}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: "#3b82f6" }]} />
|
||||
<Text style={styles.legendText}>Check-ins</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: "#10b981" }]} />
|
||||
<Text style={styles.legendText}>Goals Completed</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: theme.colors.white,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
...theme.shadows.medium,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize.lg,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.gray700,
|
||||
marginBottom: 12,
|
||||
},
|
||||
chartContainer: {
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
},
|
||||
chart: {
|
||||
marginVertical: 8,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
legend: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: 20,
|
||||
paddingTop: 8,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
legendDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { theme } from "../styles/theme";
|
||||
import { useUser } from "@clerk/clerk-expo";
|
||||
import { getUserStatistics } from "../api/statistics";
|
||||
import type { WeeklyTrendData } from "../api/types";
|
||||
|
||||
export function WeeklyProgressWidget() {
|
||||
const { user } = useUser();
|
||||
const [weeklyData, setWeeklyData] = useState<WeeklyTrendData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWeeklyData = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
const stats = await getUserStatistics(user.id);
|
||||
// Get last 4 weeks for compact display
|
||||
const last4Weeks = stats.weeklyTrend.slice(-4);
|
||||
setWeeklyData(last4Weeks);
|
||||
} catch (error) {
|
||||
console.error("Failed to load weekly data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWeeklyData();
|
||||
}, [user?.id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (weeklyData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate totals from last 4 weeks
|
||||
const totalCheckIns = weeklyData.reduce(
|
||||
(sum, week) => sum + week.checkIns,
|
||||
0,
|
||||
);
|
||||
const totalGoals = weeklyData.reduce(
|
||||
(sum, week) => sum + week.goalsCompleted,
|
||||
0,
|
||||
);
|
||||
const avgProgress =
|
||||
weeklyData.reduce((sum, week) => sum + week.averageProgress, 0) /
|
||||
weeklyData.length;
|
||||
|
||||
// Get max value for scaling bars
|
||||
const maxCheckIns = Math.max(...weeklyData.map((w) => w.checkIns), 1);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.purple}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.card}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>Weekly Progress</Text>
|
||||
<Text style={styles.subtitle}>Last 4 weeks</Text>
|
||||
</View>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="trending-up" size={24} color="#fff" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{totalCheckIns}</Text>
|
||||
<Text style={styles.statLabel}>Check-ins</Text>
|
||||
</View>
|
||||
<View style={styles.statDivider} />
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{totalGoals}</Text>
|
||||
<Text style={styles.statLabel}>Goals Met</Text>
|
||||
</View>
|
||||
<View style={styles.statDivider} />
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{Math.round(avgProgress)}%</Text>
|
||||
<Text style={styles.statLabel}>Avg Progress</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.chartContainer}>
|
||||
{weeklyData.map((week, index) => {
|
||||
const barHeight = (week.checkIns / maxCheckIns) * 60;
|
||||
return (
|
||||
<View key={index} style={styles.barContainer}>
|
||||
<View style={styles.barWrapper}>
|
||||
<View
|
||||
style={[styles.bar, { height: Math.max(barHeight, 4) }]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.barLabel}>{week.weekLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: 20,
|
||||
...theme.shadows.medium,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize.xl,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: "rgba(255, 255, 255, 0.8)",
|
||||
},
|
||||
iconContainer: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
marginBottom: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: 16,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: theme.typography.fontSize["2xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
textAlign: "center",
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
chartContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
alignItems: "flex-end",
|
||||
height: 80,
|
||||
},
|
||||
barContainer: {
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
},
|
||||
barWrapper: {
|
||||
height: 60,
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: 6,
|
||||
},
|
||||
bar: {
|
||||
width: 24,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: 4,
|
||||
minHeight: 4,
|
||||
},
|
||||
barLabel: {
|
||||
fontSize: 10,
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user