-
Notifications
You must be signed in to change notification settings - Fork 2
Feature/session wide algorithm #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 30 commits
28714c6
d2588ee
974f949
356ae5a
3e6ab9b
7b4b6ea
d2ca704
8872031
04b2007
705b8d7
da2baeb
fa4ff29
573adef
8862067
16fb835
ff504c9
54b156d
8e4c621
cd05c4a
45888ce
efe2242
3ddbf98
445ab62
6a642b3
e451a8c
628c4f4
54b7d67
ba22470
c20df47
e0c47ee
5fb1b9f
32560d5
1028ff4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth"; | |||||||||||||||||||||||||||||||||||
| import { signInWithGooglePopup } from "@/auth/authN"; | ||||||||||||||||||||||||||||||||||||
| import Image from "next/image"; | ||||||||||||||||||||||||||||||||||||
| import { signInWithMicrosoftPopup } from "@/auth/authN"; | ||||||||||||||||||||||||||||||||||||
| import { generateSession } from "@/features/scheduling/test/generateEmulatorData"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export default function LoginPage() { | ||||||||||||||||||||||||||||||||||||
| const [error, setError] = useState<string>(""); | ||||||||||||||||||||||||||||||||||||
|
|
@@ -33,6 +34,16 @@ export default function LoginPage() { | |||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const generateTestData = async () => { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| const data = await generateSession(); | ||||||||||||||||||||||||||||||||||||
| console.log(data); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| catch { | ||||||||||||||||||||||||||||||||||||
| setError("An error occurred while trying to sign in. Please try again."); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||
| <div className="relative min-h-full w-full flex items-center justify-center bg-primary-300 overflow-hidden"> | ||||||||||||||||||||||||||||||||||||
| <div className="absolute inset-0 h-full"> | ||||||||||||||||||||||||||||||||||||
|
|
@@ -67,6 +78,14 @@ export default function LoginPage() { | |||||||||||||||||||||||||||||||||||
| Sign in with Microsoft | ||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||||
| onClick={generateTestData} | ||||||||||||||||||||||||||||||||||||
| className="flex flex-row justify-around items-center w-5/6 max-w-[344px] bg-white | ||||||||||||||||||||||||||||||||||||
| mt-5 py-4 px-12 rounded-full shadow-[0_4px_4px_-1px_rgba(0,0,0,0.2)] font-lato text-xl text-gray-600" | ||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||
| <Image src={MicrosoftIcon.src} alt="Microsoft" width={32} height={32} /> | ||||||||||||||||||||||||||||||||||||
| Generate Test | ||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+81
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test button should not be on the production login page. This "Generate Test" button appears to be debug/development tooling. Including it on the login page has several issues:
Consider:
Proposed fix (conditional rendering)- <button
- onClick={generateTestData}
- className="flex flex-row justify-around items-center w-5/6 max-w-[344px] bg-white
- mt-5 py-4 px-12 rounded-full shadow-[0_4px_4px_-1px_rgba(0,0,0,0.2)] font-lato text-xl text-gray-600"
- >
- <Image src={MicrosoftIcon.src} alt="Microsoft" width={32} height={32} />
- Generate Test
- </button>
+ {process.env.NODE_ENV === 'development' && (
+ <button
+ onClick={generateTestData}
+ className="flex flex-row justify-around items-center w-5/6 max-w-[344px] bg-white
+ mt-5 py-4 px-12 rounded-full shadow-[0_4px_4px_-1px_rgba(0,0,0,0.2)] font-lato text-xl text-gray-600"
+ >
+ Generate Test Data
+ </button>
+ )}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| {/* Error Message */} | ||||||||||||||||||||||||||||||||||||
| {errorDisplay && ( | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
|
|
||
| import { StaffAttendeeID, AdminAttendeeID, NightShiftID, SessionID, SectionID } from "@/types/sessionTypes"; | ||
| import { StaffAttendeeID, AdminAttendeeID, NightShiftID, SessionID, SectionID, NightShift } from "@/types/sessionTypes"; | ||
| import { da } from "@faker-js/faker/."; | ||
| import { w } from "@faker-js/faker/dist/airline-CLphikKp"; | ||
| import { em } from "@mantine/core"; | ||
| import { max } from "moment"; | ||
| import { Moment } from "moment"; | ||
| import moment from "moment"; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| export class SessionScheduler { | ||
| session: SessionID | undefined; | ||
|
|
@@ -17,9 +22,218 @@ export class SessionScheduler { | |
|
|
||
| withNightShifts(nightShifts: NightShiftID[]): SessionScheduler { this.nightShifts = nightShifts; return this; } | ||
|
|
||
| assignDaysOff(): SessionScheduler { return this; } | ||
| private shuffleArray<T>(arr: T[]): T[] { | ||
| return arr .map(value => ({ value, sort: Math.random() })) | ||
| .sort((a, b) => a.sort - b.sort) | ||
| .map(({ value }) => value); | ||
| } | ||
| private toISO(date: Moment): string { | ||
| return date.format("YYYY-MM-DD"); | ||
| } | ||
|
|
||
| private getDateStringsInRange(start: Moment, end: Moment): string[] { | ||
| const dates: string[] = []; | ||
| const curr = start.clone(); | ||
| while (curr.isSameOrBefore(end)) { | ||
| dates.push(this.toISO(curr)); | ||
| curr.add(1, "day"); | ||
| } | ||
| return dates; | ||
| } | ||
|
|
||
| private isJamboreeISO(dateISO: string, sections: SectionID[]): boolean { | ||
| const date = moment(dateISO); | ||
| return sections.some(section => { | ||
| if (!('type' in section)) return false; | ||
| const s = moment(section.startDate); | ||
| const e = moment(section.endDate).subtract(1, "day"); | ||
| return ( | ||
| (section.type === "BUNK-JAMBO" || section.type === "NON-BUNK-JAMBO") && | ||
| date.isSameOrAfter(s) && | ||
| date.isSameOrBefore(e) | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| private assignOneDayOff( | ||
| employee: StaffAttendeeID | AdminAttendeeID, | ||
| candidateDates: string[], | ||
| dayCounts: Map<string, number>, | ||
| allEmployees: (StaffAttendeeID | AdminAttendeeID)[], | ||
| maxStaffOffPerDay: number | ||
| ): boolean { | ||
|
|
||
| for (const date of candidateDates) { | ||
| if ((dayCounts.get(date) ?? 0) >= maxStaffOffPerDay){ | ||
| console.log("Max staff off per day reached"); | ||
| continue; | ||
| }; | ||
| // bunk constraint (HARD) | ||
| if ('bunk' in employee && maxStaffOffPerDay !== Infinity) { | ||
| const bunkConflict = allEmployees.some(e => | ||
| 'bunk' in e && | ||
| e.bunk === employee.bunk && | ||
| e.daysOff.includes(date) | ||
| ); | ||
|
|
||
| if (bunkConflict) continue; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // assign | ||
| employee.daysOff.push(date); | ||
| dayCounts.set(date, (dayCounts.get(date) ?? 0) + 1); | ||
| return true; | ||
| } | ||
|
|
||
|
|
||
|
|
||
| return false; | ||
| } | ||
|
|
||
|
|
||
|
|
||
| assignDaysOff(session: SessionID, employees: (StaffAttendeeID | AdminAttendeeID)[]): SessionScheduler { | ||
| try { | ||
| const start = moment(session.startDate); | ||
|
|
||
| // Define windows (days 2–6 and 7–12) (TEMP Set for default session. Need to change to handle multiple sessions) | ||
| const window1 = this.getDateStringsInRange(start.clone().add(1, "day"), start.clone().add(5, "day")); | ||
| const window2 = this.getDateStringsInRange(start.clone().add(6, "day"), start.clone().add(11, "day")); | ||
|
|
||
| const allDates = [...window1, ...window2]; | ||
| let maxStaffOffPerDay = Math.ceil(employees.length / allDates.length); | ||
|
|
||
| const dayCounts = new Map<string, number>(); | ||
| allDates.forEach(d => dayCounts.set(d, 0)); | ||
|
|
||
| // Separate program counselors | ||
| const nonWFProgramCounselors = employees.filter( | ||
| e => 'programCounselor' in e && e.programCounselor && e.programCounselor.id !== "WF" | ||
| ); | ||
| const WFProgramCounselors = employees.filter( | ||
| e => 'programCounselor' in e && e.programCounselor && e.programCounselor.id === "WF" | ||
| ); | ||
| // ---- Choose exact number of WF counselors for Jamboree ---- | ||
| const numWFWindow1 = Math.ceil(WFProgramCounselors.length/window1.length); // example: 3 WF on Jamboree in window1 | ||
| const numWFWindow2 = Math.ceil(WFProgramCounselors.length/window2.length); // example: 2 WF on Jamboree in window2 | ||
|
|
||
| const shuffledWF = this.shuffleArray(WFProgramCounselors); | ||
| const selectedWFWindow1 = shuffledWF.slice(0, numWFWindow1); | ||
| const selectedWFWindow2 = shuffledWF.slice(numWFWindow1, numWFWindow1 + numWFWindow2); | ||
|
|
||
| // ---- Build Jamboree sets ---- | ||
| const jamboreeWindow1 = new Set([...nonWFProgramCounselors, ...selectedWFWindow1]); | ||
| const jamboreeWindow2 = new Set([...nonWFProgramCounselors, ...selectedWFWindow2]); | ||
|
|
||
| // ---- PASS 1: window1 ---- | ||
| for (const employee of employees) { | ||
| maxStaffOffPerDay = Math.ceil(employees.length / window1.length); | ||
| const dates = | ||
| 'programCounselor' in employee && jamboreeWindow1.has(employee) | ||
| ? window1.filter(d => this.isJamboreeISO(d, this.sections)) | ||
| : window1; | ||
|
|
||
| if (dates.length === 1){ | ||
| // set maxStaffOffPerDay to Infinity | ||
| maxStaffOffPerDay = Infinity; | ||
| }; | ||
|
|
||
| if (!this.assignOneDayOff(employee, dates, dayCounts, employees, maxStaffOffPerDay)) { | ||
| console.warn(`Failed to assign window-1 day off for ${employee.id}`); | ||
| } | ||
| } | ||
|
eshavignesh123 marked this conversation as resolved.
|
||
|
|
||
| // ---- PASS 2: window2 ---- | ||
| for (const employee of employees) { | ||
| maxStaffOffPerDay = Math.ceil(employees.length / window2.length); | ||
|
|
||
| const dates = | ||
| 'programCounselor' in employee && jamboreeWindow2.has(employee) | ||
| ? window2.filter(d => this.isJamboreeISO(d, this.sections)) | ||
| : window2; | ||
|
|
||
| if (dates.length === 1){ | ||
|
|
||
| maxStaffOffPerDay = Infinity; | ||
| }; | ||
|
|
||
| if (!this.assignOneDayOff(employee, dates, dayCounts, employees, maxStaffOffPerDay)) { | ||
| console.warn(`Failed to assign window-2 day off for ${employee.id}`); | ||
| } | ||
| } | ||
|
Comment on lines
+95
to
+159
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clamp day‑off windows to session dates and guard empty windows. The fixed windows (days 2–6, 7–12) can extend beyond the session end. For shorter sessions this creates out‑of‑range day‑offs and can also lead to ✅ Proposed fix (clip windows + skip empty pass)- // Define windows (days 2–6 and 7–12) (TEMP Set for default session. Need to change to handle multiple sessions)
- const window1 = this.getDateStringsInRange(start.clone().add(1, "day"), start.clone().add(5, "day"));
- const window2 = this.getDateStringsInRange(start.clone().add(6, "day"), start.clone().add(11, "day"));
+ // Define windows (days 2–6 and 7–12), clipped to session end
+ const sessionEnd = moment(session.endDate).subtract(1, "day");
+ const window1Start = start.clone().add(1, "day");
+ const window1End = moment.min(start.clone().add(5, "day"), sessionEnd);
+ const window1 = window1Start.isSameOrBefore(window1End)
+ ? this.getDateStringsInRange(window1Start, window1End)
+ : [];
+
+ const window2Start = start.clone().add(6, "day");
+ const window2End = moment.min(start.clone().add(11, "day"), sessionEnd);
+ const window2 = window2Start.isSameOrBefore(window2End)
+ ? this.getDateStringsInRange(window2Start, window2End)
+ : [];
@@
- for (const employee of employees) {
+ if (window1.length > 0) {
+ for (const employee of employees) {
maxStaffOffPerDay = Math.ceil(employees.length / window1.length);
@@
- if (!this.assignOneDayOff(employee, dates, dayCounts, employees, maxStaffOffPerDay)) {
+ if (!this.assignOneDayOff(employee, dates, dayCounts, employees, maxStaffOffPerDay)) {
console.warn(`Failed to assign window-1 day off for ${employee.id}`);
- }
+ }
+ }
}
@@
- for (const employee of employees) {
+ if (window2.length > 0) {
+ for (const employee of employees) {
maxStaffOffPerDay = Math.ceil(employees.length / window2.length);
@@
- if (!this.assignOneDayOff(employee, dates, dayCounts, employees, maxStaffOffPerDay)) {
+ if (!this.assignOneDayOff(employee, dates, dayCounts, employees, maxStaffOffPerDay)) {
console.warn(`Failed to assign window-2 day off for ${employee.id}`);
- }
+ }
+ }
}🤖 Prompt for AI Agents |
||
|
|
||
| } catch (e) { | ||
| console.error(e); | ||
| } | ||
|
|
||
| return this; | ||
| } | ||
|
|
||
|
|
||
|
|
||
| assignNightShifts(session: SessionID, employees: StaffAttendeeID[]): SessionScheduler { | ||
| const bunks = new Set<number>(); | ||
| const endDate = moment(session.endDate).subtract(1, 'day'); | ||
| const startDate = moment(session.startDate); | ||
|
|
||
| // Collect unique bunks | ||
| for (let i = 0; i < employees.length; i++) { | ||
| bunks.add(employees[i].bunk); | ||
| } | ||
|
|
||
|
|
||
|
|
||
| let currDate = startDate; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Create a night bunk object for each night | ||
| while (currDate.isSameOrBefore(endDate)) { | ||
| const shift: NightShiftID = { | ||
| sessionId: session.id, | ||
| id: `night-${this.toISO(currDate)}` | ||
| }; | ||
| // looping through all unique bunks that the employees have | ||
| for (const bunkNumber of bunks) { | ||
| // Create NightShift object for this bunk | ||
| shift[bunkNumber] = { | ||
| counselorsOnDuty: [], | ||
| nightBunkDuty: [], | ||
| }; | ||
|
|
||
| // choosing 4 employees | ||
| const employeesInBunk = employees.filter((e) => e.bunk === bunkNumber); | ||
| if (employeesInBunk.length === 0) continue; | ||
| const shuffled = this.shuffleArray(employeesInBunk); | ||
| const selected = shuffled.slice(0, 4); | ||
|
|
||
| for (const employee of selected) { | ||
| const prevDate = currDate.clone().subtract(1, 'day'); | ||
| const nextDate = currDate.clone().add(1, 'day'); | ||
|
|
||
| const hasAdjacentDayOff = | ||
| employee.daysOff.includes(this.toISO(prevDate)) || | ||
| employee.daysOff.includes(this.toISO(nextDate)); | ||
|
|
||
| // If no adjacent day off, eligible for COD | ||
| // Otherwise, assign to NBD | ||
| if (!hasAdjacentDayOff) { | ||
| shift[bunkNumber].counselorsOnDuty.push(employee.id); | ||
| } else { | ||
| shift[bunkNumber].nightBunkDuty.push(employee.id); | ||
| } | ||
|
|
||
| } | ||
| } | ||
| // Save the shift object | ||
| this.nightShifts.push(shift); | ||
| currDate.add(1, "day"); | ||
| } | ||
| return this; | ||
|
|
||
|
|
||
|
|
||
|
|
||
| assignNightShifts(): SessionScheduler { return this; } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| getSchedule(): { nightShifts: NightShiftID[] } { return { nightShifts: this.nightShifts }; } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix the error message - it references sign-in instead of test data generation.
The catch block reuses the sign-in error message, which would be confusing to users/developers debugging test data generation failures.
Proposed fix
const generateTestData = async () => { try { const data = await generateSession(); console.log(data); } catch { - setError("An error occurred while trying to sign in. Please try again."); + setError("An error occurred while generating test data. Please try again."); } }📝 Committable suggestion
🤖 Prompt for AI Agents