Files
clg-kit/app/(tabs)/timetable.tsx

237 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, View } from 'react-native';
import { AddClassModal } from '@/components/add-class-modal';
import {
AppHeader,
AppText,
BottomSheet,
Button,
Card,
EmptyState,
IconButton,
InlineBanner,
RecessCard,
ScheduleEventCard,
Screen,
WeekStrip,
colors,
spacing,
type WeekDay,
} from '@/components/ui';
import {
addDays,
dateKey,
formatDayHeading,
formatMonthShortDay,
formatWeekdayLong,
formatYear,
mondayOfWeek,
} from '@/lib/date';
import { subjectToneFor } from '@/lib/design';
import { collegeApi } from '@/lib/api';
type TimetableClass = Awaited<ReturnType<typeof collegeApi.timetable>>['classes'][number];
export default function TimetableScreen() {
const router = useRouter();
const [activeDate, setActiveDate] = useState(() => {
const today = new Date();
return today.getDay() === 0 || today.getDay() === 6 ? mondayOfWeek(today) : today;
});
const [schedule, setSchedule] = useState<TimetableClass[]>([]);
const [weekendSchedule, setWeekendSchedule] = useState(false);
const [recess, setRecess] = useState<{ enabled: boolean; start: string; end: string }>({ enabled: false, start: '13:00', end: '14:00' });
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [addClassOpen, setAddClassOpen] = useState(false);
const [selectedTimetableClass, setSelectedTimetableClass] = useState<TimetableClass | null>(null);
const [refresh, setRefresh] = useState(0);
const selectedDateKey = dateKey(activeDate);
const todayKey = dateKey(new Date());
const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
const weekDays: WeekDay[] = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index))
.filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
.map((date) => ({ date })), [weekStart, weekendSchedule]);
useFocusEffect(useCallback(() => {
let active = true;
collegeApi.profile().then((profile) => {
if (!active) return;
setWeekendSchedule(Boolean(profile.weekendSchedule));
setRecess({ enabled: Boolean(profile.recessEnabled), start: profile.recessStart, end: profile.recessEnd });
}).catch(() => undefined);
return () => { active = false; };
}, []));
const load = useCallback(() => {
setLoading(true);
setLoadError('');
collegeApi.timetable(selectedDateKey)
.then(({ classes }) => setSchedule(classes))
.catch((error: Error) => { setSchedule([]); setLoadError(error.message); })
.finally(() => setLoading(false));
}, [selectedDateKey]);
useEffect(() => { load(); }, [load, refresh]);
const moveWeek = (amount: number) => setActiveDate(addDays(activeDate, amount * 7));
const weekday = formatWeekdayLong(activeDate);
const showRecess = recess.enabled && recess.start < recess.end;
const timelineItems = [
...schedule.map((item) => ({ type: 'class' as const, start: item.startTime, end: item.endTime, item })),
...(showRecess ? [{ type: 'recess' as const, start: recess.start, end: recess.end }] : []),
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
return <Screen contentContainerStyle={styles.content}>
<AppHeader title="Timetable" style={styles.header} />
<View style={styles.weekToolbar}>
<IconButton icon="chevron-back" label="Previous week" tone="ghost" onPress={() => moveWeek(-1)} />
<View style={styles.weekCopy}>
<AppText variant="label">Week of {formatMonthShortDay(weekStart)}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted}>{formatYear(activeDate)}</AppText>
</View>
<View style={styles.toolbarActions}>
{todayKey !== selectedDateKey ? <Button label="This week" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
<IconButton icon="chevron-forward" label="Next week" tone="ghost" onPress={() => moveWeek(1)} />
</View>
</View>
<WeekStrip
days={weekDays}
selectedDateKey={selectedDateKey}
todayDateKey={todayKey}
onSelect={setActiveDate}
onSwipeWeek={(direction) => setActiveDate((current) => addDays(current, direction * 7))}
style={styles.weekStrip}
/>
<View style={styles.sectionHeader}>
<View style={styles.sectionCopy}>
<AppText variant="heading2">{weekday}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSub}>{formatDayHeading(activeDate)} · {schedule.length} {schedule.length === 1 ? 'class' : 'classes'}</AppText>
</View>
<IconButton icon="add" label={`Add a recurring class on ${weekday}`} onPress={() => setAddClassOpen(true)} />
</View>
{loadError ? <InlineBanner title="Couldnt load the timetable" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={load} />} /> : null}
{loading ? <Card tone="skySoft" style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading timetable</AppText></Card> : null}
{!loading && !loadError && schedule.length === 0 && !showRecess ? <EmptyState icon="calendar-outline" title={`No classes on ${weekday}`} message="Add a recurring class to build this days timetable." action={<Button label="Add recurring class" variant="secondary" onPress={() => setAddClassOpen(true)} leading={<Ionicons name="add" size={18} color={colors.brand.cobalt} />} />} /> : null}
{!loading && !loadError && timelineItems.length > 0 ? <View style={styles.timeline}>
{timelineItems.map((entry, index) => <View key={entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`} style={styles.classRow}>
<View style={styles.timeRail}>
<AppText variant="label" style={styles.tabular}>{entry.start}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.tabular}>{entry.end}</AppText>
{index < timelineItems.length - 1 ? <View style={styles.railLine} /> : null}
</View>
{entry.type === 'recess' ? <RecessCard timeRange={`${entry.start}${entry.end}`} style={styles.event} /> : <ScheduleEventCard
title={entry.item.subjectName}
kind={entry.item.code}
classType={entry.item.classType}
timeRange={`${entry.start}${entry.end}`}
room={entry.item.room}
subjectTone={subjectToneFor(entry.item.subjectId, entry.item.color)}
style={styles.event}
topAction={<IconButton icon="ellipsis-horizontal" label={`Options for ${entry.item.subjectName}`} tone="ghost" onPress={() => setSelectedTimetableClass(entry.item)} />}
onPress={() => router.push(`/subjects/${entry.item.subjectId}` as never)}
/>}
</View>)}
</View> : null}
<InlineBanner
title="Recurring changes start in the future"
message="Past classes and attendance records stay unchanged. Make one-day changes from Today."
tone="neutral"
style={styles.note}
/>
<AddClassModal
visible={addClassOpen}
regular
date={selectedDateKey}
weekday={activeDate.getDay()}
onClose={() => setAddClassOpen(false)}
onAdded={() => setRefresh((value) => value + 1)}
/>
<BottomSheet
visible={Boolean(selectedTimetableClass)}
title="Recurring class options"
onClose={() => setSelectedTimetableClass(null)}>
{selectedTimetableClass ? <>
<Card tone="skySoft" style={styles.selectedClassSummary}>
<AppText variant="title">{selectedTimetableClass.subjectName}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.sectionSub}>
{selectedTimetableClass.code} · {selectedTimetableClass.startTime}{selectedTimetableClass.endTime} · {selectedTimetableClass.room}
</AppText>
</Card>
<View style={styles.sheetButtons}>
<Button
label="View subject"
variant="secondary"
onPress={() => {
const id = selectedTimetableClass.subjectId;
setSelectedTimetableClass(null);
router.push(`/subjects/${id}` as never);
}}
leading={<Ionicons name="book-outline" size={18} color={colors.brand.cobalt} />}
/>
<Button
label="Delete recurring class"
variant="ghost"
onPress={() => {
Alert.alert(
'Delete recurring class?',
'This will stop this class from repeating in future weeks. Past class records will be preserved.',
[
{ text: 'Keep', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await collegeApi.deleteTimetableClass(selectedTimetableClass.id);
setSelectedTimetableClass(null);
setRefresh((v) => v + 1);
} catch (err) {
Alert.alert('Couldnt delete class', err instanceof Error ? err.message : 'Please try again.');
}
},
},
]
);
}}
leading={<Ionicons name="trash-outline" size={18} color={colors.semantic.danger.text} />}
/>
</View>
</> : null}
</BottomSheet>
</Screen>;
}
const styles = StyleSheet.create({
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
header: { paddingTop: spacing[2], minHeight: 0 },
weekToolbar: { marginTop: spacing[3], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
weekCopy: { flex: 1, alignItems: 'center' },
toolbarActions: { flexDirection: 'row', alignItems: 'center' },
weekStrip: { marginTop: spacing[1] },
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
sectionCopy: { flex: 1 },
sectionSub: { marginTop: spacing[1] },
loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
timeline: { gap: spacing[2] },
classRow: { flexDirection: 'row', alignItems: 'stretch' },
timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
tabular: { fontVariant: ['tabular-nums'] },
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
event: { flex: 1, marginBottom: spacing[2] },
note: { marginTop: spacing[6] },
selectedClassSummary: { marginTop: spacing[2] },
sheetButtons: { gap: spacing[2], marginTop: spacing[5] },
});