Files
clg-kit/components/add-class-modal.tsx
Aditya Gupta 2aac9c3fb4 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 09:00:17 +05:30

180 lines
9.6 KiB
TypeScript
Raw Permalink 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 { useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import {
AppText,
BottomSheet,
Button,
FormField,
InlineBanner,
SubjectBadge,
colors,
radius,
spacing,
} from '@/components/ui';
import { dateFromKey, formatDayHeading, formatWeekdayLong, timeToMinutes } from '@/lib/date';
import { subjectToneFor } from '@/lib/design';
import { collegeApi, type Subject } from '@/lib/api';
type Props = { visible: boolean; onClose: () => void; onAdded: () => void; date: string; regular?: boolean; weekday?: number };
type TimeRange = { start: string; end: string };
const subjectShortName = (subject: Subject) => subject.shortName || subject.name.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6);
const addMinutes = (time: string, minutes: number) => {
const [hours, mins] = time.split(':').map(Number);
const total = ((hours * 60 + mins + minutes) % (24 * 60) + 24 * 60) % (24 * 60);
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
};
const skipRecess = (time: string, recessStart: string, recessEnd: string) => time >= recessStart && time < recessEnd ? recessEnd : time;
const validTime = (value: string) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
export function AddClassModal({ visible, onClose, onAdded, date, regular = false, weekday }: Props) {
const [subjects, setSubjects] = useState<Subject[]>([]);
const [subjectId, setSubjectId] = useState<number | null>(null);
const [lectureMinutes, setLectureMinutes] = useState(60);
const [recessEnabled, setRecessEnabled] = useState(true);
const [recess, setRecess] = useState('13:0014:00');
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [room, setRoom] = useState('');
const [existingRanges, setExistingRanges] = useState<TimeRange[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!visible) return;
setLoading(true);
setError('');
const existing = regular
? collegeApi.timetable(date).then(({ classes }) => classes.map((item) => ({ start: item.startTime, end: item.endTime })))
: collegeApi.schedule(date).then(({ sessions }) => sessions.filter((item) => item.status !== 'cancelled').map((item) => ({ start: item.time, end: item.endTime })));
Promise.all([collegeApi.subjects(), collegeApi.profile(), existing])
.then(([items, profile, ranges]) => {
setSubjects(items);
setSubjectId(items[0]?.id ?? null);
setRoom(items[0]?.defaultRoom ?? '');
setExistingRanges(ranges);
const minutes = profile.lectureMinutes ?? 60;
const recessIsEnabled = Boolean(profile.recessEnabled);
const recessStart = profile.recessStart ?? '13:00';
const recessEnd = profile.recessEnd ?? '14:00';
const lastEnd = [...ranges].sort((left, right) => left.end.localeCompare(right.end)).at(-1)?.end;
const nextStart = recessIsEnabled ? skipRecess(lastEnd || '09:00', recessStart, recessEnd) : (lastEnd || '09:00');
setLectureMinutes(minutes);
setRecessEnabled(recessIsEnabled);
setRecess(`${recessStart}${recessEnd}`);
setStartTime(nextStart);
setEndTime(addMinutes(nextStart, minutes));
})
.catch((loadError: Error) => setError(loadError.message))
.finally(() => setLoading(false));
}, [visible, date, regular]);
const updateStart = (value: string) => {
setStartTime(value);
setError('');
if (validTime(value)) setEndTime(addMinutes(value, lectureMinutes));
};
const save = async () => {
if (!subjectId) {
setError('Choose a subject before adding this class.');
return;
}
if (!validTime(startTime) || !validTime(endTime) || timeToMinutes(startTime) >= timeToMinutes(endTime)) {
setError('Use valid times and make the end time later than the start time.');
return;
}
const overlaps = existingRanges.some((range) => timeToMinutes(startTime) < timeToMinutes(range.end) && timeToMinutes(endTime) > timeToMinutes(range.start));
if (overlaps) {
setError('This time overlaps another class. Choose a different time range.');
return;
}
setSaving(true);
setError('');
try {
const location = room.trim() || 'TBA';
if (regular) await collegeApi.addTimetableClass({ subjectId, weekday: weekday ?? dateFromKey(date).getDay(), startTime, endTime, room: location, effectiveFrom: date });
else await collegeApi.addOneOffClass({ subjectId, date, startTime, endTime, room: location });
onAdded();
onClose();
setRoom('');
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Please try again.');
} finally {
setSaving(false);
}
};
const title = regular ? 'Add recurring class' : 'Add a class';
const context = regular
? `Repeats on ${formatWeekdayLong(dateFromKey(date))}. Recurring changes begin in the future.`
: `One-off class for ${formatDayHeading(dateFromKey(date))}.`;
return <BottomSheet
visible={visible}
title={title}
onClose={onClose}
footer={<Button label={regular ? 'Add recurring class' : 'Add class'} loading={saving} disabled={loading || subjects.length === 0} haptic="success" onPress={save} trailing={<Ionicons name="checkmark" size={18} color={colors.neutral.surface} />} />}>
<AppText variant="bodySmall" color={colors.neutral.textSecondary}>{context}</AppText>
{error ? <InlineBanner title="Couldnt add class" message={error} tone="danger" style={styles.feedback} /> : null}
{loading ? <View style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading subjects and times</AppText></View> : null}
{!loading ? <>
<AppText variant="label" color={colors.neutral.textSecondary} style={styles.sectionLabel}>Subject</AppText>
{subjects.length === 0 ? <InlineBanner title="No subjects available" message="Add a subject from onboarding or your subject list before scheduling a class." tone="warning" /> : <View style={styles.subjects}>
{subjects.map((subject) => {
const tone = subjectToneFor(subject.id, subject.color);
const selected = subject.id === subjectId;
return <Pressable
key={subject.id}
accessibilityRole="radio"
accessibilityState={{ checked: selected }}
accessibilityLabel={`${subject.name}, ${subject.code}`}
onPress={() => { setSubjectId(subject.id); setRoom(subject.defaultRoom ?? ''); setError(''); }}
style={({ pressed }) => [styles.subject, selected && { backgroundColor: colors.subject[tone].surface, borderColor: colors.subject[tone].accent }, pressed && styles.pressed]}>
<SubjectBadge shortName={subjectShortName(subject)} tone={tone} size="small" />
<View style={styles.subjectCopy}>
<AppText variant="label" numberOfLines={1}>{subject.name}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted}>{subject.code}</AppText>
</View>
<View style={[styles.radio, selected && { borderColor: colors.subject[tone].accent }]}>{selected ? <View style={[styles.radioDot, { backgroundColor: colors.subject[tone].accent }]} /> : null}</View>
</Pressable>;
})}
</View>}
<View style={styles.timeHeading}>
<AppText variant="label" color={colors.neutral.textSecondary}>Time</AppText>
<AppText variant="caption" color={colors.neutral.textMuted}>{lectureMinutes} min default · {recessEnabled ? `recess ${recess}` : 'recess off'}</AppText>
</View>
<View style={styles.timeFields}>
<FormField label="Starts" value={startTime} onChangeText={updateStart} placeholder="09:00" hint="HH:MM" containerStyle={styles.timeField} />
<FormField label="Ends" value={endTime} onChangeText={(value) => { setEndTime(value); setError(''); }} placeholder="10:00" hint="HH:MM" containerStyle={styles.timeField} />
</View>
<FormField label="Room or location" value={room} onChangeText={setRoom} placeholder="e.g. B-204 or Lab 3" hint="Starts with the subject default; you can override it for this class." containerStyle={styles.roomField} />
</> : null}
</BottomSheet>;
}
const styles = StyleSheet.create({
feedback: { marginTop: spacing[4] },
loading: { minHeight: 180, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
sectionLabel: { marginTop: spacing[6], marginBottom: spacing[3] },
subjects: { gap: spacing[2] },
subject: { minHeight: 58, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[3], paddingVertical: spacing[2], borderRadius: radius.card, borderCurve: 'continuous', borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface },
pressed: { opacity: 0.76 },
subjectCopy: { flex: 1 },
radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: colors.neutral.border, alignItems: 'center', justifyContent: 'center' },
radioDot: { width: 10, height: 10, borderRadius: 5 },
timeHeading: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', gap: spacing[3] },
timeFields: { flexDirection: 'row', gap: spacing[3] },
timeField: { flex: 1 },
roomField: { marginTop: spacing[5], marginBottom: spacing[2] },
});