Files
clg-kit/app/(tabs)/timetable.tsx
root 693cc1c0b6 feat: redesign day picker with expandable calendar, status markers & Playwright E2E testing
- Replace modal calendar with inline expandable calendar widget integrated into the day picker.
- Add status indicator dots (attended/absent/pending) to month calendar grid matching day picker.
- Disable and gray out weekend days in calendar when weekend schedule is turned off.
- Redesign extend bar with integrated handle that aligns with the palette.
- Remove start-to-end timing on Today screen's class time rail, showing only start time.
- Implement Playwright E2E and visual testing suite with multi-screen capture scripts.
- Add cascade DELETE endpoints for subjects and versioned recurring timetable entries.
2026-09-05 08:58:51 +05:30

230 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} 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] },
});