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.
This commit is contained in:
@@ -25,12 +25,15 @@ export const collegeApi = {
|
||||
createSubject(data: { name: string; code: string; shortName?: string; color?: string; classType?: string; defaultRoom?: string }) { return request<{ id: number }>('/subjects', { method: 'POST', body: JSON.stringify(data) }); },
|
||||
subject(id: number) { return request<{ id: number; name: string; code: string; shortName: string; color: string; classType: string; defaultRoom: string; summary: { total: number; attended: number; absent: number; percentage: number }; sessions: Array<{ id: number; date: string; time: string; endTime: string; room: string; status: AttendanceStatus }> }>(`/subjects/${id}`); },
|
||||
updateSubject(id: number, data: { name: string; code: string; shortName: string; color?: string; classType?: string; defaultRoom?: string }) { return request<{ id: number }>(`/subjects/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); },
|
||||
deleteSubject(id: number) { return request<{ id: number; deleted: boolean }>(`/subjects/${id}`, { method: 'DELETE' }); },
|
||||
schedule(date: string) { return request<{ date: string; sessions: Session[] }>(`/schedule?date=${date}`); },
|
||||
markAttendance(id: number, status: AttendanceStatus) { return request<{ id: number; status: AttendanceStatus }>(`/schedule/${id}/attendance`, { method: 'PUT', body: JSON.stringify({ status }) }); },
|
||||
removeClass(id: number) { return request<{ id: number; deleted: boolean }>(`/schedule/${id}`, { method: 'DELETE' }); },
|
||||
addOneOffClass(data: { subjectId: number; date: string; startTime: string; endTime: string; room: string }) { return request<{ id: number }>('/schedule', { method: 'POST', body: JSON.stringify(data) }); },
|
||||
attendanceSummary() { return request<{ subjects: Array<{ id: number; name: string; code: string; shortName?: string; color: string; total: number; attended: number; absent: number; cancelled: number; percentage: number }> }>('/attendance/summary'); },
|
||||
attendanceMarkers(month: string) { return request<{ markers: Record<string, 'success' | 'warning' | 'danger' | 'neutral'> }>(`/attendance/markers?month=${month}`); },
|
||||
timetable(date: string) { return request<{ date: string; classes: Array<{ id: number; weekday: number; startTime: string; endTime: string; room: string; subjectId: number; subjectName: string; code: string; color: string; classType: string; effectiveFrom: string; effectiveTo: string | null }> }>(`/timetable/classes?date=${date}`); },
|
||||
updateTimetableClass(id: number, data: { subjectId?: number; weekday?: number; startTime?: string; endTime?: string; room?: string; effectiveFrom?: string }) { return request<{ id: number; replacesId: number; effectiveFrom: string }>(`/timetable/classes/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); },
|
||||
deleteTimetableClass(id: number) { return request<{ id: number; deleted: boolean }>(`/timetable/classes/${id}`, { method: 'DELETE' }); },
|
||||
addTimetableClass(data: { subjectId: number; weekday: number; startTime: string; endTime: string; room: string; effectiveFrom?: string }) { return request<{ id: number }>('/timetable/classes', { method: 'POST', body: JSON.stringify(data) }); },
|
||||
};
|
||||
|
||||
36
lib/date.ts
36
lib/date.ts
@@ -1,3 +1,17 @@
|
||||
// Pre-instantiated Intl formatters hoisted to module scope (per js-hoist-intl best practice)
|
||||
const dayHeadingFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
const monthDayFormatter = new Intl.DateTimeFormat(undefined, { month: 'long', day: 'numeric' });
|
||||
const sessionDateFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
const weekdayNarrowFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'narrow' });
|
||||
const weekdayLongFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'long' });
|
||||
const monthYearFormatter = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||||
const monthShortDayFormatter = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
||||
const yearFormatter = new Intl.DateTimeFormat(undefined, { year: 'numeric' });
|
||||
const timeShortFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
const headerDateFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
|
||||
|
||||
const weekdayShortFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||||
|
||||
export const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
|
||||
export const addDays = (date: Date, amount: number) => {
|
||||
@@ -10,11 +24,27 @@ export const mondayOfWeek = (date: Date) => addDays(date, -((date.getDay() + 6)
|
||||
|
||||
export const dateFromKey = (value: string) => new Date(`${value}T12:00:00`);
|
||||
|
||||
export const formatDayHeading = (date: Date) => new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(date);
|
||||
export const formatDayHeading = (date: Date) => dayHeadingFormatter.format(date);
|
||||
|
||||
export const formatMonthDay = (date: Date) => new Intl.DateTimeFormat(undefined, { month: 'long', day: 'numeric' }).format(date);
|
||||
export const formatMonthDay = (date: Date) => monthDayFormatter.format(date);
|
||||
|
||||
export const formatSessionDate = (value: string) => new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(dateFromKey(value));
|
||||
export const formatSessionDate = (value: string) => sessionDateFormatter.format(dateFromKey(value));
|
||||
|
||||
export const formatWeekdayNarrow = (date: Date) => weekdayNarrowFormatter.format(date);
|
||||
|
||||
export const formatWeekdayShort = (date: Date) => weekdayShortFormatter.format(date);
|
||||
|
||||
export const formatWeekdayLong = (date: Date) => weekdayLongFormatter.format(date);
|
||||
|
||||
export const formatMonthYear = (date: Date) => monthYearFormatter.format(date);
|
||||
|
||||
export const formatMonthShortDay = (date: Date) => monthShortDayFormatter.format(date);
|
||||
|
||||
export const formatYear = (date: Date) => yearFormatter.format(date);
|
||||
|
||||
export const formatCurrentTime = (date = new Date()) => timeShortFormatter.format(date);
|
||||
|
||||
export const formatHeaderDate = (date: Date) => headerDateFormatter.format(date);
|
||||
|
||||
export const currentMinutes = () => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -16,6 +16,15 @@ export function subjectToneFor(identity: string | number, savedColor?: string):
|
||||
return matched ?? subjectTones[hash(identity) % subjectTones.length];
|
||||
}
|
||||
|
||||
export function subjectShortLabel(name: string, code: string, shortName?: string): string {
|
||||
if (shortName && shortName.trim()) return shortName.trim().slice(0, 5);
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return parts.map((w) => w[0]).join('').toUpperCase().slice(0, 4);
|
||||
}
|
||||
return code.trim().slice(0, 4);
|
||||
}
|
||||
|
||||
export function attendanceTone(percentage: number, total: number, threshold = 75): SemanticTone {
|
||||
if (!total) return 'neutral';
|
||||
if (percentage >= threshold) return 'success';
|
||||
|
||||
Reference in New Issue
Block a user