From 8715dcbcf6fb76257d0d884783b907ec0a24880d Mon Sep 17 00:00:00 2001 From: Upayan Date: Mon, 27 Oct 2025 19:09:22 +0530 Subject: [PATCH 1/3] Add smart filtering functionality to timetable view --- src/components/cards/ViewTimeTable.tsx | 400 +++++++++++++++++++------ 1 file changed, 311 insertions(+), 89 deletions(-) diff --git a/src/components/cards/ViewTimeTable.tsx b/src/components/cards/ViewTimeTable.tsx index 580742c..0161dc7 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 @@ -75,6 +79,155 @@ export default function ViewTimeTable() { const selectedData = allTimetables[selectedIndex] || []; const visibleIndexes = getVisibleIndexes(timetableNumber, timetableCount); + // --- 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); + } + + function containsMorningAndEvening(slots: string[]) { + const has1 = slots.some(s => /1\b/.test(s)); + const has2 = slots.some(s => /2\b/.test(s)); + return has1 && has2; + } + + 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)); + + // Helper inline: letters present A-G + const letters = atomic.map(s => (s.match(/[A-G]/) || [null])[0]).filter(Boolean) as string[]; + + const centers = getSlotCenters(atomic); + + const sameBuilding = (() => { + if (letters.length > 0) { + const set = new Set(letters); + 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 (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') { + if (!containsMorningAndEvening(atomic)) 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 letters = atomic.map(s => (s.match(/[A-G]/) || [null])[0]).filter(Boolean) as string[]; + const centers = getSlotCenters(atomic); + + const sameBuilding = (() => { + if (letters.length > 0) { + const set = new Set(letters); + 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 (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); + if (!containsMorningAndEvening(atomic)) 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]); + + // Hotkeys: 1 = same building, 2 = close, 3 = no mix, 0 = clear. ArrowLeft/ArrowRight navigate filtered list + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === '1') + setSmartFilter(prev => (prev === 'sameBuilding' ? 'none' : 'sameBuilding')); + if (e.key === '2') setSmartFilter(prev => (prev === 'close' ? 'none' : 'close')); + if (e.key === '3') setSmartFilter(prev => (prev === 'noMix' ? 'none' : 'noMix')); + if (e.key === '0') setSmartFilter('none'); + + if (e.key === 'ArrowRight') { + // move to next matching index in filteredBySmart + const arr = filteredBySmart.length ? filteredBySmart : allTimetables.map((_, i) => i); + const pos = arr.indexOf(selectedIndex); + const next = pos === -1 || pos === arr.length - 1 ? arr[0] : arr[pos + 1]; + setSelectedIndex(next); + } + if (e.key === 'ArrowLeft') { + const arr = filteredBySmart.length ? filteredBySmart : allTimetables.map((_, i) => i); + const pos = arr.indexOf(selectedIndex); + const prev = pos <= 0 ? arr[arr.length - 1] : arr[pos - 1]; + setSelectedIndex(prev); + } + } + + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [filteredBySmart, selectedIndex, allTimetables]); + const convertedData = selectedData.map( (item: { courseCode?: string; slotName?: string; facultyName?: string }) => ({ code: item.courseCode || '00000000', @@ -395,6 +548,40 @@ export default function ViewTimeTable() { : `(${timetableCount} timetables were generated)`} +
+ {(() => { + const arr = filteredBySmart.length ? filteredBySmart : allTimetables.map((_, i) => i); + const showPrevNext = arr.length > 1 || (arr.length === 1 && arr[0] !== selectedIndex); + if (!showPrevNext) return null; + return ( + <> + + + + + ); + })()} +
-
+
+
+
+ Smart filters (hotkeys: 1 / 2 / 3, 0 = clear) +
+ {(smartMatches.same.length > 0 || smartFilter === 'sameBuilding') && ( + + )} + + {(smartMatches.close.length > 0 || smartFilter === 'close') && ( + + )} + + {(smartMatches.noMix.length > 0 || smartFilter === 'noMix') && ( + + )} + + +
+
- + {timetableNumber !== 1 && ( + + )}
- {visibleIndexes.map(index => ( - - ))} + {visibleIndexes.map(index => { + if (timetableNumber === index) { + return ( +
+ {index} +
+ ); + } + return ( + + ); + })}
- + {timetableNumber !== timetableCount && ( + + )}
- {actionButtons.map((btn, idx) => ( -
- -
- ))} + {selectedData && selectedData.length > 0 + ? actionButtons.map((btn, idx) => ( +
+ +
+ )) + : null}
From 17e0c4923029106aea2661592b32b74a0ddc1bfd Mon Sep 17 00:00:00 2001 From: Upayan Date: Mon, 27 Oct 2025 21:04:25 +0530 Subject: [PATCH 2/3] Enhance timetable functionality by adding venue information to faculty and slots --- src/components/cards/FacultySelector.tsx | 34 +++- src/components/cards/ViewTimeTable.tsx | 215 +++++++++++++---------- src/components/ui/CompoundTable.tsx | 21 ++- src/lib/type.ts | 2 + src/lib/utils.ts | 6 + 5 files changed, 171 insertions(+), 107 deletions(-) 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 0161dc7..4904d7d 100644 --- a/src/components/cards/ViewTimeTable.tsx +++ b/src/components/cards/ViewTimeTable.tsx @@ -74,11 +74,6 @@ export default function ViewTimeTable() { allTimetables = originalTimetableData; } - const timetableNumber = selectedIndex + 1; - const timetableCount = allTimetables.length; - const selectedData = allTimetables[selectedIndex] || []; - const visibleIndexes = getVisibleIndexes(timetableNumber, timetableCount); - // --- Helpers to analyse timetable geometry and slots --- function extractAtomicSlots(slotName?: string) { if (!slotName) return [] as string[]; @@ -89,10 +84,34 @@ export default function ViewTimeTable() { .filter(Boolean); } - function containsMorningAndEvening(slots: string[]) { - const has1 = slots.some(s => /1\b/.test(s)); - const has2 = slots.some(s => /2\b/.test(s)); - return has1 && has2; + // 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[]) { @@ -116,16 +135,22 @@ export default function ViewTimeTable() { const res: number[] = []; allTimetables.forEach((tt, idx) => { const atomic = tt.flatMap(item => extractAtomicSlots(item.slotName)); - - // Helper inline: letters present A-G - const letters = atomic.map(s => (s.match(/[A-G]/) || [null])[0]).filter(Boolean) as string[]; - 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 (letters.length > 0) { - const set = new Set(letters); - if (set.size === 1) return true; + 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); @@ -134,6 +159,21 @@ export default function ViewTimeTable() { })(); 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); @@ -145,7 +185,9 @@ export default function ViewTimeTable() { } else if (smartFilter === 'close') { if (closeEnough) res.push(idx); } else if (smartFilter === 'noMix') { - if (!containsMorningAndEvening(atomic)) res.push(idx); + const hasMorning = atomic.some(s => slotIsMorning(s)); + const hasEvening = atomic.some(s => slotIsEvening(s)); + if (!(hasMorning && hasEvening)) res.push(idx); } }); return res; @@ -161,13 +203,20 @@ export default function ViewTimeTable() { allTimetables.forEach((tt, idx) => { const atomic = tt.flatMap(item => extractAtomicSlots(item.slotName)); - const letters = atomic.map(s => (s.match(/[A-G]/) || [null])[0]).filter(Boolean) as string[]; const centers = getSlotCenters(atomic); + const venues = tt + .map(item => (item as timetableDisplayData).venue) + .filter(Boolean) as string[]; const sameBuilding = (() => { - if (letters.length > 0) { - const set = new Set(letters); - if (set.size === 1) return true; + 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); @@ -176,6 +225,19 @@ export default function ViewTimeTable() { })(); 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); @@ -184,7 +246,9 @@ export default function ViewTimeTable() { if (sameBuilding) same.push(idx); if (closeEnough) close.push(idx); - if (!containsMorningAndEvening(atomic)) noMix.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 }; @@ -200,39 +264,28 @@ export default function ViewTimeTable() { } }, [smartFilter, filteredBySmart]); - // Hotkeys: 1 = same building, 2 = close, 3 = no mix, 0 = clear. ArrowLeft/ArrowRight navigate filtered list - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === '1') - setSmartFilter(prev => (prev === 'sameBuilding' ? 'none' : 'sameBuilding')); - if (e.key === '2') setSmartFilter(prev => (prev === 'close' ? 'none' : 'close')); - if (e.key === '3') setSmartFilter(prev => (prev === 'noMix' ? 'none' : 'noMix')); - if (e.key === '0') setSmartFilter('none'); - - if (e.key === 'ArrowRight') { - // move to next matching index in filteredBySmart - const arr = filteredBySmart.length ? filteredBySmart : allTimetables.map((_, i) => i); - const pos = arr.indexOf(selectedIndex); - const next = pos === -1 || pos === arr.length - 1 ? arr[0] : arr[pos + 1]; - setSelectedIndex(next); - } - if (e.key === 'ArrowLeft') { - const arr = filteredBySmart.length ? filteredBySmart : allTimetables.map((_, i) => i); - const pos = arr.indexOf(selectedIndex); - const prev = pos <= 0 ? arr[arr.length - 1] : arr[pos - 1]; - setSelectedIndex(prev); - } - } + // 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; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [filteredBySmart, selectedIndex, allTimetables]); + 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 || '', }) ); @@ -548,40 +601,7 @@ export default function ViewTimeTable() { : `(${timetableCount} timetables were generated)`} -
- {(() => { - const arr = filteredBySmart.length ? filteredBySmart : allTimetables.map((_, i) => i); - const showPrevNext = arr.length > 1 || (arr.length === 1 && arr[0] !== selectedIndex); - if (!showPrevNext) return null; - return ( - <> - - - - - ); - })()} -
+
-
- Smart filters (hotkeys: 1 / 2 / 3, 0 = clear) -
- {(smartMatches.same.length > 0 || smartFilter === 'sameBuilding') && ( +
Smart filters
+ {((smartMatches.same.length > 0 && smartMatches.same.length < timetableCount) || + smartFilter === 'sameBuilding') && ( )} - {(smartMatches.close.length > 0 || smartFilter === 'close') && ( + {((smartMatches.close.length > 0 && smartMatches.close.length < timetableCount) || + smartFilter === 'close') && ( )} - {(smartMatches.noMix.length > 0 || smartFilter === 'noMix') && ( + {((smartMatches.noMix.length > 0 && smartMatches.noMix.length < timetableCount) || + smartFilter === 'noMix') && ( - )} +
+
{visibleIndexes.map(index => { - // index here is 1-based position within the display list const globalIndex = displayList[index - 1]; if (timetableNumber === index) { return (
{index}
@@ -706,9 +709,7 @@ export default function ViewTimeTable() { @@ -716,26 +717,33 @@ export default function ViewTimeTable() { })}
- {timetableNumber !== timetableCount && ( - - )} +