diff --git a/src/app/globals.css b/src/app/globals.css index 3dd01f0..a8916a6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -154,3 +154,7 @@ html { *::-webkit-scrollbar-corner { background: transparent; } + +button { + cursor: pointer; +} diff --git a/src/components/cards/FacultySelector.tsx b/src/components/cards/FacultySelector.tsx index 233ea56..f162ca1 100644 --- a/src/components/cards/FacultySelector.tsx +++ b/src/components/cards/FacultySelector.tsx @@ -90,6 +90,7 @@ function SelectField({ label, value, options, onChange, renderOption }: SelectFi type SubjectEntry = { slot: string; faculty: string; + venue?: string; }; function generateCourseSlotsSingle({ @@ -107,9 +108,16 @@ function generateCourseSlotsSingle({ return [ { slotName: selectedSlot, - slotFaculties: selectedFaculties.map(facultyName => ({ - facultyName, - })), + slotFaculties: selectedFaculties.map(facultyName => { + // try to get venue for this faculty and slot from subjectData + const entry = subjectData.find( + (e: SubjectEntry) => e.faculty === facultyName && e.slot === selectedSlot + ); + return { + facultyName, + ...(entry && entry.venue ? { venue: entry.venue } : {}), + }; + }), }, ]; } @@ -131,6 +139,7 @@ function generateCourseSlotsSingle({ ) .map((entry: SubjectEntry) => ({ facultyName: entry.faculty, + ...(entry.venue ? { venue: entry.venue } : {}), })), })); } @@ -181,6 +190,7 @@ function generateCourseSlotsLabOnly({ .filter(entry => entry.slot === slotName && selectedFaculties.includes(entry.faculty)) .map(entry => ({ facultyName: entry.faculty, + ...(entry.venue ? { venue: entry.venue } : {}), })), })); } @@ -236,9 +246,27 @@ function generateCourseSlotsBoth({ ) .map(entry => entry.slot); + // try to find a venue for this faculty: prefer labData entry, otherwise try theory entry + let venue: string | undefined; + const labEntryForVenue = labData.find( + entry => + entry.faculty === facultyName && entry.slot.startsWith('L') && isValidLabSlot(entry.slot) + ); + if (labEntryForVenue && labEntryForVenue.venue) { + venue = labEntryForVenue.venue; + } else { + // try to find theory entry + const theoryEntries = data[selectedSchool][selectedDomain][selectedSubject]; + const thEntry = theoryEntries.find( + (e: SubjectEntry) => e.faculty === facultyName && e.slot === selectedSlot + ); + if (thEntry && thEntry.venue) venue = thEntry.venue; + } + return { facultyName, ...(labSlots.length > 0 && { facultyLabSlot: labSlots.join(', ') }), + ...(venue ? { venue } : {}), }; }); diff --git a/src/components/cards/ViewTimeTable.tsx b/src/components/cards/ViewTimeTable.tsx index 580742c..5c1da3d 100644 --- a/src/components/cards/ViewTimeTable.tsx +++ b/src/components/cards/ViewTimeTable.tsx @@ -12,6 +12,7 @@ import Popup from '@/components/ui/Popup'; import AlertModal from '@/components/ui/AlertModal'; import LoadingPopup from '@/components/ui/LoadingPopup'; import { getCurrentDateTime } from '@/lib/utils'; +import { getSlot } from '@/lib/slots'; import { generateShareId } from '@/lib/shareIDgenerate'; import { exportToPDF } from '@/lib/exportToPDF'; import ComboBox from '../ui/ComboBox'; @@ -49,6 +50,9 @@ export default function ViewTimeTable() { const owner = session?.user?.email || null; const [filterFaculty, setFilterFaculty] = useState(''); + const [smartFilter, setSmartFilter] = useState<'none' | 'sameBuilding' | 'close' | 'noMix'>( + 'none' + ); const facultyList = Array.from( new Set( originalTimetableData @@ -70,16 +74,218 @@ export default function ViewTimeTable() { allTimetables = originalTimetableData; } - const timetableNumber = selectedIndex + 1; - const timetableCount = allTimetables.length; + // --- Helpers to analyse timetable geometry and slots --- + function extractAtomicSlots(slotName?: string) { + if (!slotName) return [] as string[]; + // break combined names: __ (th__lab), +, comma + return slotName + .split(/__|\+|,\s*/) + .map(s => s.trim()) + .filter(Boolean); + } + + // note: use slotIsMorning/slotIsEvening directly where needed + + function slotIsMorning(slot: string) { + if (!slot) return false; + // theory slots like A1 are morning if they end with 1 + if (/\d$/.test(slot)) { + return /1$/.test(slot); + } + // lab slots like L1-L30 are morning + const m = slot.match(/L(\d+)/i); + if (m) { + const n = parseInt(m[1], 10); + return !isNaN(n) && n <= 30; + } + return false; + } + + function slotIsEvening(slot: string) { + if (!slot) return false; + if (/\d$/.test(slot)) { + return /2$/.test(slot); + } + const m = slot.match(/L(\d+)/i); + if (m) { + const n = parseInt(m[1], 10); + return !isNaN(n) && n >= 31; + } + return false; + } + + function getSlotCenters(slots: string[]) { + const centers: number[] = []; + for (const s of slots) { + try { + const slotObjs = getSlot(s, true); + slotObjs.forEach(o => centers.push((o.colStart + o.colEnd) / 2)); + } catch { + // ignore + } + } + return centers; + } + + // Build list of indexes that match the selected smart filter + const filteredBySmart = React.useMemo(() => { + if (!allTimetables || allTimetables.length === 0) return [] as number[]; + if (smartFilter === 'none') return allTimetables.map((_, i) => i); + + const res: number[] = []; + allTimetables.forEach((tt, idx) => { + const atomic = tt.flatMap(item => extractAtomicSlots(item.slotName)); + const centers = getSlotCenters(atomic); + + // try to use venue information if available + const venues = tt + .map(item => (item as timetableDisplayData).venue) + .filter(Boolean) as string[]; + + const sameBuilding = (() => { + if (venues.length > 0) { + const buildings = venues + .map(v => (v || '').toString().match(/^[A-Za-z]+/)?.[0] || '') + .filter(Boolean); + if (buildings.length > 0) { + const set = new Set(buildings.map(b => b.toUpperCase())); + if (set.size === 1) return true; + } + } + if (centers.length === 0) return false; + const left = centers.every(c => c < 36); + const right = centers.every(c => c >= 36); + return left || right; + })(); + + const closeEnough = (() => { + // prefer venue room-number proximity when possible + if (venues.length > 0) { + const nums = venues + .map(v => { + const m = v.match(/(\d+)/); + return m ? parseInt(m[0], 10) : NaN; + }) + .filter(n => !isNaN(n)); + if (nums.length > 0) { + const min = Math.min(...nums); + const max = Math.max(...nums); + // consider close if rooms within 30 numbers + return max - min <= 30; + } + } + if (centers.length === 0) return false; + const min = Math.min(...centers); + const max = Math.max(...centers); + return max - min <= 20; + })(); + + if (smartFilter === 'sameBuilding') { + if (sameBuilding) res.push(idx); + } else if (smartFilter === 'close') { + if (closeEnough) res.push(idx); + } else if (smartFilter === 'noMix') { + const hasMorning = atomic.some(s => slotIsMorning(s)); + const hasEvening = atomic.some(s => slotIsEvening(s)); + if (!(hasMorning && hasEvening)) res.push(idx); + } + }); + return res; + }, [allTimetables, smartFilter]); + + // Precompute matches for each smart filter so we can decide whether buttons will have effect + const smartMatches = React.useMemo(() => { + const same: number[] = []; + const close: number[] = []; + const noMix: number[] = []; + + if (!allTimetables || allTimetables.length === 0) return { same, close, noMix }; + + allTimetables.forEach((tt, idx) => { + const atomic = tt.flatMap(item => extractAtomicSlots(item.slotName)); + const centers = getSlotCenters(atomic); + const venues = tt + .map(item => (item as timetableDisplayData).venue) + .filter(Boolean) as string[]; + + const sameBuilding = (() => { + if (venues.length > 0) { + const buildings = venues + .map(v => (v || '').toString().match(/^[A-Za-z]+/)?.[0] || '') + .filter(Boolean); + if (buildings.length > 0) { + const set = new Set(buildings.map(b => b.toUpperCase())); + if (set.size === 1) return true; + } + } + if (centers.length === 0) return false; + const left = centers.every(c => c < 36); + const right = centers.every(c => c >= 36); + return left || right; + })(); + + const closeEnough = (() => { + if (venues.length > 0) { + const nums = venues + .map(v => { + const m = v.match(/(\d+)/); + return m ? parseInt(m[0], 10) : NaN; + }) + .filter(n => !isNaN(n)); + if (nums.length > 0) { + const min = Math.min(...nums); + const max = Math.max(...nums); + return max - min <= 30; + } + } + if (centers.length === 0) return false; + const min = Math.min(...centers); + const max = Math.max(...centers); + return max - min <= 20; + })(); + + if (sameBuilding) same.push(idx); + if (closeEnough) close.push(idx); + const hasMorning = atomic.some(s => slotIsMorning(s)); + const hasEvening = atomic.some(s => slotIsEvening(s)); + if (!(hasMorning && hasEvening)) noMix.push(idx); + }); + + return { same, close, noMix }; + }, [allTimetables]); + + // When a smart filter is activated, navigate to the first matching timetable + useEffect(() => { + if (smartFilter === 'none') return; + if (filteredBySmart.length > 0) { + setSelectedIndex(filteredBySmart[0]); + } else { + setSelectedIndex(0); + } + }, [smartFilter, filteredBySmart]); + + // When a smart filter is active, the pagination should show only matching timetables. + const displayList = + smartFilter === 'none' || filteredBySmart.length === 0 + ? allTimetables.map((_, i) => i) + : filteredBySmart; + + const displayCount = displayList.length; + // position (1-based) of the currently selected timetable within the display list + const displayPosition = + displayList.indexOf(selectedIndex) === -1 ? 1 : displayList.indexOf(selectedIndex) + 1; + + const timetableNumber = displayPosition; + const timetableCount = displayCount; const selectedData = allTimetables[selectedIndex] || []; const visibleIndexes = getVisibleIndexes(timetableNumber, timetableCount); const convertedData = selectedData.map( - (item: { courseCode?: string; slotName?: string; facultyName?: string }) => ({ + (item: { courseCode?: string; slotName?: string; facultyName?: string; venue?: string }) => ({ code: item.courseCode || '00000000', slot: item.slotName || 'NIL', name: item.facultyName || 'Unknown', + venue: item.venue || '', }) ); @@ -395,6 +601,7 @@ export default function ViewTimeTable() { : `(${timetableCount} timetables were generated)`} +