Compare commits
8
Commits
screen2
...
ad3eba48b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3eba48b0 | ||
|
|
0ccf59344e | ||
|
|
42122ac341 | ||
|
|
4dd2ed5839 | ||
|
|
f9a588fcd6 | ||
|
|
9330f4fd05 | ||
|
|
d6683e6e5e | ||
|
|
bac7df33e8 |
No files matched your search
Binary file not shown.
@@ -4,6 +4,101 @@ import { getDatabase } from "@/lib/database";
|
|||||||
import log from "@/lib/logger";
|
import log from "@/lib/logger";
|
||||||
import { ensureUserSynced } from "@/lib/sync-user";
|
import { ensureUserSynced } from "@/lib/sync-user";
|
||||||
|
|
||||||
|
const AI_LINK_PREFIX = "[AI_LINKED]";
|
||||||
|
|
||||||
|
type GoalType =
|
||||||
|
| "weight_target"
|
||||||
|
| "strength_milestone"
|
||||||
|
| "endurance_target"
|
||||||
|
| "flexibility_goal"
|
||||||
|
| "habit_building"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
interface ParsedPlanItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
goalType: GoalType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferGoalType(text: string): GoalType {
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("strength") ||
|
||||||
|
normalized.includes("bench") ||
|
||||||
|
normalized.includes("squat") ||
|
||||||
|
normalized.includes("deadlift") ||
|
||||||
|
normalized.includes("weights")
|
||||||
|
) {
|
||||||
|
return "strength_milestone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("run") ||
|
||||||
|
normalized.includes("cardio") ||
|
||||||
|
normalized.includes("endurance") ||
|
||||||
|
normalized.includes("cycle")
|
||||||
|
) {
|
||||||
|
return "endurance_target";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("stretch") ||
|
||||||
|
normalized.includes("mobility") ||
|
||||||
|
normalized.includes("yoga") ||
|
||||||
|
normalized.includes("flexibility")
|
||||||
|
) {
|
||||||
|
return "flexibility_goal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("daily") ||
|
||||||
|
normalized.includes("routine") ||
|
||||||
|
normalized.includes("habit")
|
||||||
|
) {
|
||||||
|
return "habit_building";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] {
|
||||||
|
const lines = activityPlan
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split(/\n+/)
|
||||||
|
.flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z0-9])/g))
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim())
|
||||||
|
.filter((line) => line.length > 10)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const uniqueLines = Array.from(new Set(lines));
|
||||||
|
|
||||||
|
return uniqueLines.map((line) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
|
||||||
|
description: line,
|
||||||
|
goalType: inferGoalType(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPlanItems(): ParsedPlanItem[] {
|
||||||
|
const defaults = [
|
||||||
|
"Complete 3 strength sessions this week with progressive overload.",
|
||||||
|
"Add 2 cardio sessions of 25-30 minutes for endurance.",
|
||||||
|
"Do a 10-minute mobility routine daily after training.",
|
||||||
|
];
|
||||||
|
|
||||||
|
return defaults.map((line) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
|
||||||
|
description: line,
|
||||||
|
goalType: inferGoalType(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { userId: clerkUserId } = await auth();
|
const { userId: clerkUserId } = await auth();
|
||||||
@@ -94,8 +189,103 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If approved, create a notification for the user
|
let pausedGoalsCount = 0;
|
||||||
|
let createdGoalsCount = 0;
|
||||||
|
|
||||||
|
// If approved, regenerate linked AI goals and create a notification for the user
|
||||||
if (status === "approved") {
|
if (status === "approved") {
|
||||||
|
try {
|
||||||
|
const existingActiveGoals = await db.getFitnessGoalsByUserId(
|
||||||
|
updatedRecommendation.userId,
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedGoals = existingActiveGoals.filter((goal) =>
|
||||||
|
goal.notes?.startsWith(AI_LINK_PREFIX),
|
||||||
|
);
|
||||||
|
|
||||||
|
pausedGoalsCount = linkedGoals.length;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
linkedGoals.map((goal) =>
|
||||||
|
db.updateFitnessGoal(goal.id, {
|
||||||
|
status: "paused",
|
||||||
|
notes: `${goal.notes || ""}\nPaused due to recommendation approval on ${new Date().toISOString()}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let planItems = parseActivityPlanToItems(
|
||||||
|
updatedRecommendation.activityPlan || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
planItems.length === 0 &&
|
||||||
|
updatedRecommendation.recommendationText
|
||||||
|
) {
|
||||||
|
planItems = parseActivityPlanToItems(
|
||||||
|
updatedRecommendation.recommendationText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planItems.length === 0) {
|
||||||
|
planItems = getDefaultPlanItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitnessProfileId =
|
||||||
|
updatedRecommendation.fitnessProfileId ||
|
||||||
|
(await db.getFitnessProfileByUserId(updatedRecommendation.userId))
|
||||||
|
?.id;
|
||||||
|
|
||||||
|
if (!fitnessProfileId) {
|
||||||
|
log.warn("No fitness profile available for AI goal creation", {
|
||||||
|
recommendationId,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const createdGoals = await Promise.all(
|
||||||
|
planItems.map((item) =>
|
||||||
|
db.createFitnessGoal({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
fitnessProfileId,
|
||||||
|
goalType: item.goalType,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
targetValue: undefined,
|
||||||
|
currentValue: 0,
|
||||||
|
unit: undefined,
|
||||||
|
startDate: new Date(),
|
||||||
|
targetDate: undefined,
|
||||||
|
completedDate: undefined,
|
||||||
|
status: "active",
|
||||||
|
progress: 0,
|
||||||
|
priority: "medium",
|
||||||
|
notes: `${AI_LINK_PREFIX} recommendationId=${updatedRecommendation.id}; itemId=${item.id}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
createdGoalsCount = createdGoals.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Regenerated linked AI goals from approved recommendation", {
|
||||||
|
recommendationId: updatedRecommendation.id,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
pausedGoals: pausedGoalsCount,
|
||||||
|
createdGoals: createdGoalsCount,
|
||||||
|
});
|
||||||
|
} catch (goalConversionError) {
|
||||||
|
log.error(
|
||||||
|
"Failed to regenerate linked goals for approved recommendation",
|
||||||
|
goalConversionError,
|
||||||
|
{
|
||||||
|
recommendationId,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.createNotification({
|
await db.createNotification({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -122,6 +312,8 @@ export async function POST(req: Request) {
|
|||||||
data: updatedRecommendation,
|
data: updatedRecommendation,
|
||||||
meta: {
|
meta: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
pausedGoals: pausedGoalsCount,
|
||||||
|
createdGoals: createdGoalsCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -83,12 +83,6 @@ export default function TabLayout() {
|
|||||||
title: "Plans",
|
title: "Plans",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
|
||||||
name="attendance"
|
|
||||||
options={{
|
|
||||||
title: "Attendance",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
Alert,
|
|
||||||
} from "react-native";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useAuth } from "@clerk/clerk-expo";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useTheme } from "../../contexts/ThemeContext";
|
|
||||||
import { MinimalCard } from "../../components/MinimalCard";
|
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
|
||||||
import { MinimalButton } from "../../components/MinimalButton";
|
|
||||||
import { Badge } from "../../components/Badge";
|
|
||||||
import { IconContainer } from "../../components/IconContainer";
|
|
||||||
import { attendanceApi, Attendance } from "../../api/attendance";
|
|
||||||
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
|
||||||
import { useStatistics } from "../../contexts/StatisticsContext";
|
|
||||||
import { getErrorMessage } from "../../utils/error-helpers";
|
|
||||||
import log from "../../utils/logger";
|
|
||||||
|
|
||||||
export default function AttendanceScreen() {
|
|
||||||
const { getToken, userId } = useAuth();
|
|
||||||
const { colors, typography } = useTheme();
|
|
||||||
const { clearCache: clearStatisticsCache } = useStatistics();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
|
||||||
const [history, setHistory] = useState<Attendance[]>([]);
|
|
||||||
|
|
||||||
const fetchAttendance = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
log.debug("Fetching attendance history");
|
|
||||||
const data = await attendanceApi.getHistory(token);
|
|
||||||
setHistory(data);
|
|
||||||
|
|
||||||
// Check if there's an active check-in (latest one has no checkOutTime)
|
|
||||||
if (data.length > 0 && !data[0].checkOutTime) {
|
|
||||||
setActiveCheckIn(data[0]);
|
|
||||||
} else {
|
|
||||||
setActiveCheckIn(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to fetch attendance", error);
|
|
||||||
Alert.alert("Error", "Failed to load attendance data");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAttendance();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCheckIn = async () => {
|
|
||||||
try {
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
await attendanceApi.checkIn("gym", token);
|
|
||||||
clearStatisticsCache();
|
|
||||||
fetchAttendance();
|
|
||||||
Alert.alert("Success", "Checked in successfully!");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
log.error("Failed to check in", error);
|
|
||||||
Alert.alert("Error", getErrorMessage(error, "Failed to check in"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckOut = async () => {
|
|
||||||
try {
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
await attendanceApi.checkOut(token);
|
|
||||||
clearStatisticsCache();
|
|
||||||
fetchAttendance();
|
|
||||||
Alert.alert("Success", "Checked out successfully!");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
log.error("Failed to check out", error);
|
|
||||||
Alert.alert("Error", getErrorMessage(error, "Failed to check out"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && !history.length) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.centered, { backgroundColor: colors.background }]}>
|
|
||||||
<ActivityIndicator size="large" color={colors.primary} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={[styles.container, { backgroundColor: colors.background }]}
|
|
||||||
contentContainerStyle={styles.content}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text
|
|
||||||
style={[typography.h1, { color: colors.textPrimary, fontSize: 32 }]}
|
|
||||||
>
|
|
||||||
Attendance
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.body,
|
|
||||||
{ color: colors.textSecondary, marginTop: 8 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{activeCheckIn
|
|
||||||
? "You're crushing it today!"
|
|
||||||
: history.length === 0
|
|
||||||
? "Ready to start your fitness journey?"
|
|
||||||
: "Track your gym visits and build streaks!"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Check In/Out Section */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
{activeCheckIn ? (
|
|
||||||
<MinimalCard variant="bordered" style={styles.activeCard}>
|
|
||||||
<View style={styles.activeHeader}>
|
|
||||||
<View style={styles.activeHeaderLeft}>
|
|
||||||
<IconContainer
|
|
||||||
variant="colored"
|
|
||||||
backgroundColor={`${colors.success}20`}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="checkmark-circle"
|
|
||||||
size={28}
|
|
||||||
color={colors.success}
|
|
||||||
/>
|
|
||||||
</IconContainer>
|
|
||||||
<View style={{ marginLeft: 12 }}>
|
|
||||||
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
|
||||||
✅ Currently Checked In
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.caption,
|
|
||||||
{ color: colors.textTertiary, marginTop: 2 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Since{" "}
|
|
||||||
{new Date(activeCheckIn.checkInTime).toLocaleTimeString(
|
|
||||||
[],
|
|
||||||
{
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<MinimalButton
|
|
||||||
title="Check Out"
|
|
||||||
onPress={handleCheckOut}
|
|
||||||
variant="danger"
|
|
||||||
size="lg"
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
/>
|
|
||||||
</MinimalCard>
|
|
||||||
) : (
|
|
||||||
<MinimalButton
|
|
||||||
title="💪 Check In"
|
|
||||||
onPress={handleCheckIn}
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Attendance Calendar */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader title="📅 Calendar" />
|
|
||||||
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
|
||||||
<AttendanceCalendar attendanceRecords={history} />
|
|
||||||
</MinimalCard>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Recent History */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader title="📊 Recent History" />
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={{ fontSize: 64 }}>📍</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.bodyEmphasis,
|
|
||||||
{ color: colors.textPrimary, marginTop: 16 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
No attendance history yet
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.body,
|
|
||||||
{
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginTop: 8,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Check in to start building your streak! 🔥
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</MinimalCard>
|
|
||||||
) : (
|
|
||||||
<View style={styles.historyList}>
|
|
||||||
{history.slice(0, 10).map((record, index) => {
|
|
||||||
const checkIn = new Date(record.checkInTime);
|
|
||||||
const checkOut = record.checkOutTime
|
|
||||||
? new Date(record.checkOutTime)
|
|
||||||
: null;
|
|
||||||
const duration = checkOut
|
|
||||||
? Math.round((checkOut.getTime() - checkIn.getTime()) / 60000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MinimalCard
|
|
||||||
key={index}
|
|
||||||
variant="bordered"
|
|
||||||
style={{ borderRadius: 16 }}
|
|
||||||
>
|
|
||||||
<View style={styles.historyItem}>
|
|
||||||
<View style={styles.historyLeft}>
|
|
||||||
<IconContainer
|
|
||||||
variant="colored"
|
|
||||||
backgroundColor={
|
|
||||||
checkOut ? `${colors.success}20` : `${colors.info}20`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={checkOut ? "checkmark-done" : "time"}
|
|
||||||
size={20}
|
|
||||||
color={checkOut ? colors.success : colors.info}
|
|
||||||
/>
|
|
||||||
</IconContainer>
|
|
||||||
<View style={{ marginLeft: 12, flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={[typography.h3, { color: colors.textPrimary }]}
|
|
||||||
>
|
|
||||||
{checkIn.toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.caption,
|
|
||||||
{ color: colors.textTertiary, marginTop: 2 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{checkIn.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
{checkOut &&
|
|
||||||
` - ${checkOut.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{duration && (
|
|
||||||
<Badge
|
|
||||||
label={`${duration}m`}
|
|
||||||
variant="neutral"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</MinimalCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom Spacer */}
|
|
||||||
<View style={{ height: 100 }} />
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
centered: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
paddingBottom: 20,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingTop: 60,
|
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
activeCard: {
|
|
||||||
padding: 20,
|
|
||||||
borderRadius: 20,
|
|
||||||
},
|
|
||||||
activeHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
activeHeaderLeft: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: 40,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
historyList: {
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
historyItem: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
historyLeft: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -49,8 +49,6 @@ export function CustomTabBar({
|
|||||||
return focused ? "home" : "home-outline";
|
return focused ? "home" : "home-outline";
|
||||||
case "goals":
|
case "goals":
|
||||||
return focused ? "trophy" : "trophy-outline";
|
return focused ? "trophy" : "trophy-outline";
|
||||||
case "attendance":
|
|
||||||
return focused ? "calendar" : "calendar-outline";
|
|
||||||
case "recommendations":
|
case "recommendations":
|
||||||
return focused ? "sparkles" : "sparkles-outline";
|
return focused ? "sparkles" : "sparkles-outline";
|
||||||
case "profile":
|
case "profile":
|
||||||
@@ -66,8 +64,6 @@ export function CustomTabBar({
|
|||||||
return "Home";
|
return "Home";
|
||||||
case "goals":
|
case "goals":
|
||||||
return "Goals";
|
return "Goals";
|
||||||
case "attendance":
|
|
||||||
return "Attendance";
|
|
||||||
case "recommendations":
|
case "recommendations":
|
||||||
return "Plans";
|
return "Plans";
|
||||||
case "profile":
|
case "profile":
|
||||||
|
|||||||
Reference in new issue
Block a user