recent activity feed
updated, now showing real data
This commit is contained in:
parent
981208ab7b
commit
96db3ea3b7
Binary file not shown.
@ -9,12 +9,13 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useUser } from "@clerk/clerk-expo";
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useTheme } from "../../contexts/ThemeContext";
|
import { useTheme } from "../../contexts/ThemeContext";
|
||||||
import { useStatistics } from "../../contexts/StatisticsContext";
|
import { useStatistics } from "../../contexts/StatisticsContext";
|
||||||
|
import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
|
||||||
import { MinimalCard } from "../../components/MinimalCard";
|
import { MinimalCard } from "../../components/MinimalCard";
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { IconContainer } from "../../components/IconContainer";
|
import { IconContainer } from "../../components/IconContainer";
|
||||||
@ -22,6 +23,18 @@ import { ProgressBar } from "../../components/ProgressBar";
|
|||||||
import { TrackMealModal } from "../../components/TrackMealModal";
|
import { TrackMealModal } from "../../components/TrackMealModal";
|
||||||
import { AddWaterModal } from "../../components/AddWaterModal";
|
import { AddWaterModal } from "../../components/AddWaterModal";
|
||||||
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||||
|
import {
|
||||||
|
checkInsToActivities,
|
||||||
|
completedGoalsToActivities,
|
||||||
|
newGoalsToActivities,
|
||||||
|
combineActivities,
|
||||||
|
getRecentActivities,
|
||||||
|
formatActivityTime,
|
||||||
|
formatDuration,
|
||||||
|
getActivityIcon,
|
||||||
|
getActivityEmoji,
|
||||||
|
ActivityType,
|
||||||
|
} from "../../utils/activityFeed";
|
||||||
|
|
||||||
const CALORIE_GOAL = 2000; // kcal
|
const CALORIE_GOAL = 2000; // kcal
|
||||||
const WATER_GOAL = 2000; // ml
|
const WATER_GOAL = 2000; // ml
|
||||||
@ -31,6 +44,7 @@ export default function HomeScreen() {
|
|||||||
const { colors, typography } = useTheme();
|
const { colors, typography } = useTheme();
|
||||||
const { refetchStatistics, forceRefresh, statistics, loading } =
|
const { refetchStatistics, forceRefresh, statistics, loading } =
|
||||||
useStatistics();
|
useStatistics();
|
||||||
|
const { goals } = useFitnessGoals();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
||||||
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
||||||
@ -205,6 +219,29 @@ export default function HomeScreen() {
|
|||||||
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
|
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
|
||||||
const currentStreak = statistics?.attendance.currentStreak || 0;
|
const currentStreak = statistics?.attendance.currentStreak || 0;
|
||||||
|
|
||||||
|
// Combine activities from multiple sources
|
||||||
|
const recentActivities = useMemo(() => {
|
||||||
|
if (!statistics || !goals) return [];
|
||||||
|
|
||||||
|
const checkInActivities = checkInsToActivities(statistics.attendance).slice(
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
); // Only last 2 check-ins
|
||||||
|
const completedGoalActivities = completedGoalsToActivities(goals).slice(
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
); // Only last 2 completed goals
|
||||||
|
const newGoalActivities = newGoalsToActivities(goals, 7); // Last 7 days
|
||||||
|
|
||||||
|
const allActivities = combineActivities(
|
||||||
|
checkInActivities,
|
||||||
|
completedGoalActivities,
|
||||||
|
newGoalActivities,
|
||||||
|
);
|
||||||
|
|
||||||
|
return getRecentActivities(allActivities, 5); // Show top 5
|
||||||
|
}, [statistics, goals]);
|
||||||
|
|
||||||
// Check for midnight reset
|
// Check for midnight reset
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAndResetIfNeeded = async () => {
|
const checkAndResetIfNeeded = async () => {
|
||||||
@ -657,72 +694,104 @@ export default function HomeScreen() {
|
|||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Recent Activity"
|
title="📊 Recent Activity"
|
||||||
actionLabel="See All"
|
subtitle={
|
||||||
onActionPress={() => console.log("See all tapped")}
|
recentActivities.length === 0
|
||||||
|
? "Your activity will appear here"
|
||||||
|
: `${recentActivities.length} recent activities`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.activityList}>
|
{recentActivities.length === 0 ? (
|
||||||
<MinimalCard variant="default" style={styles.activityItem}>
|
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
||||||
<IconContainer
|
<View style={styles.emptyActivity}>
|
||||||
variant="colored"
|
<Text style={{ fontSize: 64 }}>🏃</Text>
|
||||||
backgroundColor={`${colors.primary}20`}
|
<Text
|
||||||
>
|
style={[
|
||||||
<Ionicons name="barbell" size={20} color={colors.primary} />
|
typography.bodyEmphasis,
|
||||||
</IconContainer>
|
{ color: colors.textPrimary, marginTop: 16 },
|
||||||
<View style={styles.activityInfo}>
|
]}
|
||||||
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
>
|
||||||
Upper Body Power
|
No Recent Activity
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
typography.caption,
|
typography.body,
|
||||||
{ color: colors.textTertiary, marginTop: 2 },
|
{
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Today, 10:00 AM
|
Check in at the gym or complete a goal to get started! 💪
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.bodyEmphasis,
|
|
||||||
{ color: colors.textSecondary },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
45m
|
|
||||||
</Text>
|
|
||||||
</MinimalCard>
|
</MinimalCard>
|
||||||
|
) : (
|
||||||
|
<View style={styles.activityList}>
|
||||||
|
{recentActivities.map((activity) => {
|
||||||
|
const iconName = getActivityIcon(activity.type);
|
||||||
|
const emoji = getActivityEmoji(activity.type);
|
||||||
|
const timeStr = formatActivityTime(activity.timestamp);
|
||||||
|
const durationStr = formatDuration(activity.duration);
|
||||||
|
|
||||||
<MinimalCard variant="default" style={styles.activityItem}>
|
// Determine color based on activity type
|
||||||
<IconContainer
|
let iconColor = colors.primary;
|
||||||
variant="colored"
|
if (activity.type === ActivityType.GOAL_COMPLETED) {
|
||||||
backgroundColor={`${colors.success}20`}
|
iconColor = colors.success;
|
||||||
>
|
} else if (activity.type === ActivityType.GOAL_CREATED) {
|
||||||
<Ionicons name="bicycle" size={20} color={colors.success} />
|
iconColor = colors.accent;
|
||||||
</IconContainer>
|
} else if (activity.type === ActivityType.GYM_CHECKIN) {
|
||||||
<View style={styles.activityInfo}>
|
iconColor = colors.primary;
|
||||||
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
}
|
||||||
Morning Cardio
|
|
||||||
</Text>
|
return (
|
||||||
<Text
|
<MinimalCard
|
||||||
style={[
|
key={activity.id}
|
||||||
typography.caption,
|
variant="bordered"
|
||||||
{ color: colors.textTertiary, marginTop: 2 },
|
style={styles.activityItem}
|
||||||
]}
|
>
|
||||||
>
|
<IconContainer
|
||||||
Yesterday, 7:30 AM
|
variant="colored"
|
||||||
</Text>
|
backgroundColor={`${iconColor}20`}
|
||||||
</View>
|
>
|
||||||
<Text
|
<Ionicons
|
||||||
style={[
|
name={iconName as any}
|
||||||
typography.bodyEmphasis,
|
size={20}
|
||||||
{ color: colors.textSecondary },
|
color={iconColor}
|
||||||
]}
|
/>
|
||||||
>
|
</IconContainer>
|
||||||
30m
|
<View style={styles.activityInfo}>
|
||||||
</Text>
|
<Text
|
||||||
</MinimalCard>
|
style={[typography.h3, { color: colors.textPrimary }]}
|
||||||
</View>
|
>
|
||||||
|
{emoji} {activity.title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
typography.caption,
|
||||||
|
{ color: colors.textTertiary, marginTop: 2 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{timeStr}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{durationStr && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
typography.bodyEmphasis,
|
||||||
|
{ color: colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{durationStr}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</MinimalCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TrackMealModal
|
<TrackMealModal
|
||||||
@ -850,8 +919,14 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
borderRadius: 16,
|
||||||
},
|
},
|
||||||
activityInfo: {
|
activityInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
emptyActivity: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 40,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
220
apps/mobile/src/utils/activityFeed.ts
Normal file
220
apps/mobile/src/utils/activityFeed.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Activity Feed Utilities
|
||||||
|
*
|
||||||
|
* Combines multiple data sources (check-ins, goals, nutrition) into a unified activity feed
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AttendanceStatistics } from "../api/types";
|
||||||
|
import type { FitnessGoal } from "../services/fitnessGoals";
|
||||||
|
|
||||||
|
export enum ActivityType {
|
||||||
|
GYM_CHECKIN = "gym_checkin",
|
||||||
|
GOAL_COMPLETED = "goal_completed",
|
||||||
|
GOAL_CREATED = "goal_created",
|
||||||
|
NUTRITION_MILESTONE = "nutrition_milestone",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: ActivityType;
|
||||||
|
title: string;
|
||||||
|
timestamp: Date;
|
||||||
|
duration?: number; // in minutes
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert attendance check-ins to activity items
|
||||||
|
*/
|
||||||
|
export function checkInsToActivities(
|
||||||
|
attendance: AttendanceStatistics,
|
||||||
|
): ActivityItem[] {
|
||||||
|
return attendance.recentCheckIns.map((checkIn) => {
|
||||||
|
const checkInDate = new Date(checkIn.checkInTime);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `checkin-${checkIn.id}`,
|
||||||
|
type: ActivityType.GYM_CHECKIN,
|
||||||
|
title: "Gym Check-in",
|
||||||
|
timestamp: checkInDate,
|
||||||
|
duration: checkIn.duration || undefined,
|
||||||
|
metadata: {
|
||||||
|
checkOutTime: checkIn.checkOutTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert completed goals to activity items
|
||||||
|
*/
|
||||||
|
export function completedGoalsToActivities(
|
||||||
|
goals: FitnessGoal[],
|
||||||
|
): ActivityItem[] {
|
||||||
|
return goals
|
||||||
|
.filter((goal) => goal.status === "completed" && goal.completedDate)
|
||||||
|
.map((goal) => {
|
||||||
|
const completedDate = new Date(goal.completedDate!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `goal-completed-${goal.id}`,
|
||||||
|
type: ActivityType.GOAL_COMPLETED,
|
||||||
|
title: goal.title,
|
||||||
|
timestamp: completedDate,
|
||||||
|
metadata: {
|
||||||
|
goalType: goal.goalType,
|
||||||
|
priority: goal.priority,
|
||||||
|
targetValue: goal.targetValue,
|
||||||
|
unit: goal.unit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert recently created goals to activity items
|
||||||
|
*/
|
||||||
|
export function newGoalsToActivities(
|
||||||
|
goals: FitnessGoal[],
|
||||||
|
daysBack: number = 7,
|
||||||
|
): ActivityItem[] {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysBack);
|
||||||
|
|
||||||
|
return goals
|
||||||
|
.filter((goal) => {
|
||||||
|
const createdDate = new Date(goal.createdAt);
|
||||||
|
return goal.status === "active" && createdDate > cutoffDate;
|
||||||
|
})
|
||||||
|
.map((goal) => {
|
||||||
|
const createdDate = new Date(goal.createdAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `goal-created-${goal.id}`,
|
||||||
|
type: ActivityType.GOAL_CREATED,
|
||||||
|
title: goal.title,
|
||||||
|
timestamp: createdDate,
|
||||||
|
metadata: {
|
||||||
|
goalType: goal.goalType,
|
||||||
|
priority: goal.priority,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine and sort all activities by timestamp (most recent first)
|
||||||
|
*/
|
||||||
|
export function combineActivities(
|
||||||
|
...activityGroups: ActivityItem[][]
|
||||||
|
): ActivityItem[] {
|
||||||
|
const allActivities = activityGroups.flat();
|
||||||
|
|
||||||
|
return allActivities.sort(
|
||||||
|
(a, b) => b.timestamp.getTime() - a.timestamp.getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent activities limited to a specific count
|
||||||
|
*/
|
||||||
|
export function getRecentActivities(
|
||||||
|
activities: ActivityItem[],
|
||||||
|
limit: number = 5,
|
||||||
|
): ActivityItem[] {
|
||||||
|
return activities.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format activity timestamp for display
|
||||||
|
*/
|
||||||
|
export function formatActivityTime(timestamp: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - timestamp.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
// Today
|
||||||
|
if (diffDays === 0) {
|
||||||
|
if (diffMins < 60) {
|
||||||
|
return diffMins <= 1 ? "Just now" : `${diffMins}m ago`;
|
||||||
|
}
|
||||||
|
return `Today, ${timestamp.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday
|
||||||
|
if (diffDays === 1) {
|
||||||
|
return `Yesterday, ${timestamp.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This week (within 7 days)
|
||||||
|
if (diffDays < 7) {
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older
|
||||||
|
return timestamp.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in minutes to human-readable string
|
||||||
|
*/
|
||||||
|
export function formatDuration(minutes?: number): string | undefined {
|
||||||
|
if (!minutes) return undefined;
|
||||||
|
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (mins === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon name for activity type
|
||||||
|
*/
|
||||||
|
export function getActivityIcon(type: ActivityType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ActivityType.GYM_CHECKIN:
|
||||||
|
return "barbell";
|
||||||
|
case ActivityType.GOAL_COMPLETED:
|
||||||
|
return "trophy";
|
||||||
|
case ActivityType.GOAL_CREATED:
|
||||||
|
return "flag";
|
||||||
|
case ActivityType.NUTRITION_MILESTONE:
|
||||||
|
return "restaurant";
|
||||||
|
default:
|
||||||
|
return "checkmark-circle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activity title prefix/emoji
|
||||||
|
*/
|
||||||
|
export function getActivityEmoji(type: ActivityType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ActivityType.GYM_CHECKIN:
|
||||||
|
return "💪";
|
||||||
|
case ActivityType.GOAL_COMPLETED:
|
||||||
|
return "🏆";
|
||||||
|
case ActivityType.GOAL_CREATED:
|
||||||
|
return "🎯";
|
||||||
|
case ActivityType.NUTRITION_MILESTONE:
|
||||||
|
return "🍽️";
|
||||||
|
default:
|
||||||
|
return "✅";
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user