import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { AddClassModal } from '@/components/add-class-modal'; import { useGlobalAddClass } from '@/components/global-add-class'; import { AppHeader, AppText, AttendanceRing, BottomSheet, Button, Card, CurrentTimeIndicator, EmptyState, IconButton, InlineBanner, RecessCard, ScheduleEventCard, Screen, WeekStrip, colors, radius, size, spacing, type WeekDay, } from '@/components/ui'; import { addDays, currentMinutes, dateKey, formatCurrentTime, formatDayHeading, formatHeaderDate, formatMonthYear, mondayOfWeek, timeToMinutes, } from '@/lib/date'; import { attendanceTone, subjectToneFor } from '@/lib/design'; import { collegeApi, type AttendanceStatus, type Profile, type Session } from '@/lib/api'; type DayMarker = WeekDay['marker']; type AttendanceSummary = { total: number; attended: number; percentage: number }; const emptySummary: AttendanceSummary = { total: 0, attended: 0, percentage: 0 }; const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); const daysInMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); export default function TodayScreen() { const router = useRouter(); const { revision: globalAddRevision } = useGlobalAddClass(); const [activeDate, setActiveDate] = useState(() => new Date()); const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date())); const [classes, setClasses] = useState([]); const [profile, setProfile] = useState(null); const [summary, setSummary] = useState(emptySummary); const [weekendSchedule, setWeekendSchedule] = useState(false); const [weekMarkers, setWeekMarkers] = useState>({}); const [monthMarkers, setMonthMarkers] = useState>({}); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [refresh, setRefresh] = useState(0); const [calendarExpanded, setCalendarExpanded] = useState(false); const [addClassOpen, setAddClassOpen] = useState(false); const [selectedClass, setSelectedClass] = useState(null); const today = new Date(); const todayKey = dateKey(today); const selectedDateKey = dateKey(activeDate); const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]); const weekDates = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)), [weekStart]); useFocusEffect(useCallback(() => { let active = true; collegeApi.profile().then((result) => { if (!active) return; setProfile(result); const weekendsEnabled = Boolean(result.weekendSchedule); setWeekendSchedule(weekendsEnabled); if (!weekendsEnabled) { setActiveDate((current) => { if (current.getDay() === 6) return addDays(current, 2); if (current.getDay() === 0) return addDays(current, 1); return current; }); } }).catch(() => undefined); return () => { active = false; }; }, [])); const loadSchedule = useCallback(() => { setLoading(true); setLoadError(''); collegeApi.schedule(selectedDateKey) .then(({ sessions }) => setClasses(sessions)) .catch((error: Error) => { setClasses([]); setLoadError(error.message); }) .finally(() => setLoading(false)); }, [selectedDateKey]); useEffect(() => { loadSchedule(); }, [loadSchedule, refresh]); useEffect(() => { if (globalAddRevision > 0) setRefresh((current) => current + 1); }, [globalAddRevision]); useEffect(() => { collegeApi.attendanceSummary().then(({ subjects }) => { const total = subjects.reduce((value, subject) => value + subject.total, 0); const attended = subjects.reduce((value, subject) => value + subject.attended, 0); setSummary({ total, attended, percentage: total ? Math.round(attended / total * 100) : 0 }); }).catch(() => setSummary(emptySummary)); }, [refresh]); useEffect(() => { Promise.all(weekDates.map((date) => collegeApi.schedule(dateKey(date)).then(({ sessions }) => ({ date, sessions })).catch(() => ({ date, sessions: [] as Session[] })))) .then((results) => { const markers: Record = {}; results.forEach(({ date, sessions }) => { const key = dateKey(date); if (key > todayKey) return; const held = sessions.filter((session) => session.status !== 'cancelled'); if (!held.length) return; if (held.every((session) => session.status === 'attended')) markers[key] = 'success'; else if (held.some((session) => session.status === 'absent')) markers[key] = 'danger'; else if (held.some((session) => session.status === 'pending')) markers[key] = 'warning'; else markers[key] = 'neutral'; }); setWeekMarkers(markers); }); }, [refresh, todayKey, weekDates]); const visibleDays: WeekDay[] = weekDates .filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6)) .map((date) => ({ date, marker: weekMarkers[dateKey(date)] ?? 'none' })); const calendarMonthKey = `${calendarMonth.getFullYear()}-${String(calendarMonth.getMonth() + 1).padStart(2, '0')}`; useEffect(() => { collegeApi.attendanceMarkers(calendarMonthKey) .then(({ markers }) => setMonthMarkers(markers)) .catch(() => setMonthMarkers({})); }, [calendarMonthKey, refresh]); const updateStatus = (session: Session, status: AttendanceStatus) => { const previous = classes; setClasses((items) => items.map((item) => item.id === session.id ? { ...item, status } : item)); setSummary((current) => { const wasHeld = session.status === 'attended' || session.status === 'absent'; const isHeld = status === 'attended' || status === 'absent'; const total = current.total + (isHeld ? 1 : 0) - (wasHeld ? 1 : 0); const attended = current.attended + (status === 'attended' ? 1 : 0) - (session.status === 'attended' ? 1 : 0); return { total, attended, percentage: total ? Math.round((attended / total) * 100) : 0 }; }); collegeApi.markAttendance(session.id, status) .then(() => { if (Platform.OS !== 'web') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }) .catch((error: Error) => { setClasses(previous); setSummary((current) => { const wasHeld = session.status === 'attended' || session.status === 'absent'; const isHeld = status === 'attended' || status === 'absent'; const total = current.total + (wasHeld ? 1 : 0) - (isHeld ? 1 : 0); const attended = current.attended + (session.status === 'attended' ? 1 : 0) - (status === 'attended' ? 1 : 0); return { total, attended, percentage: total ? Math.round((attended / total) * 100) : 0 }; }); Alert.alert('Couldn’t save attendance', error.message); }); }; const removeClass = (session: Session) => { Alert.alert('Remove this class?', 'This removes only this occurrence. Your recurring timetable will not change.', [ { text: 'Keep class', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => { setSelectedClass(null); setClasses((items) => items.filter((item) => item.id !== session.id)); collegeApi.removeClass(session.id).then(() => setRefresh((value) => value + 1)).catch((error: Error) => { Alert.alert('Couldn’t remove class', error.message); setRefresh((value) => value + 1); }); } }, ]); }; const isToday = selectedDateKey === todayKey; const insightTone = attendanceTone(summary.percentage, summary.total); const now = currentMinutes(); const recessEnabled = Boolean(profile && profile.recessEnabled && timeToMinutes(profile.recessStart) >= 0 && timeToMinutes(profile.recessStart) < timeToMinutes(profile.recessEnd)); const timelineItems = [ ...classes.map((item) => ({ type: 'class' as const, start: item.time, end: item.endTime, item })), ...(recessEnabled ? [{ type: 'recess' as const, start: profile!.recessStart, end: profile!.recessEnd }] : []), ].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1)); const activeTimelineIndex = isToday ? timelineItems.findIndex((item) => timeToMinutes(item.start) <= now && now < timeToMinutes(item.end)) : -1; const insertionIndex = isToday ? (activeTimelineIndex >= 0 ? activeTimelineIndex : timelineItems.findIndex((item) => timeToMinutes(item.start) > now)) : -1; const currentTimeLabel = formatCurrentTime(); const pendingCount = classes.filter((item) => item.status === 'pending').length; const metricTone = summary.total ? (insightTone === 'neutral' ? 'brand' : insightTone) : 'brand'; const collapseCalendar = () => { if (calendarExpanded) setCalendarExpanded(false); }; return {profile?.name ? ( router.push('/account' as never)} style={({ pressed }) => [styles.avatar, pressed && styles.avatarPressed]}> {profile.initials || '?'} ) : null} {summary.total ? `${summary.percentage}% overall` : 'No attendance yet'} {summary.total ? `${summary.attended} of ${summary.total} classes attended` : 'Mark a class to begin tracking.'} Target 75% event.stopPropagation()}> {calendarExpanded ? ( { setActiveDate(date); setCalendarExpanded(false); }} /> ) : ( setActiveDate((current) => addDays(current, direction * 7))} style={styles.weekStrip} /> )} { if (!calendarExpanded) { setCalendarMonth(startOfMonth(activeDate)); } setCalendarExpanded((v) => !v); }} style={({ pressed }) => [styles.extendBar, pressed && styles.extendBarPressed]}> Schedule {classes.length ? `${classes.length} ${classes.length === 1 ? 'class' : 'classes'}${pendingCount ? ` · ${pendingCount} to mark` : ''}` : 'Your agenda for this date'} {loadError ? } /> : null} {loading ? Loading your classes… : null} {!loading && !loadError && classes.length === 0 && !recessEnabled ? setAddClassOpen(true)} leading={} />} /> : null} {!loading && !loadError && timelineItems.length > 0 ? {timelineItems.map((entry, index) => { const isNow = index === activeTimelineIndex; const key = entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`; return {index === insertionIndex ? : null} {entry.start} {index < timelineItems.length - 1 ? : null} {entry.type === 'recess' ? : setSelectedClass(entry.item)} />} footer={entry.item.status !== 'cancelled' ? updateStatus(entry.item, 'attended')} onAbsent={() => updateStatus(entry.item, 'absent')} /> : null} />} ; })} {isToday && insertionIndex === -1 && activeTimelineIndex === -1 ? : null} : null} setAddClassOpen(false)} onAdded={() => setRefresh((value) => value + 1)} /> setSelectedClass(null)}> {selectedClass ? <> {selectedClass.title} {selectedClass.code} · {selectedClass.time}–{selectedClass.endTime} · {selectedClass.room}