From e0522bc07d80cadf758d0b43b04127e89b8b1b39 Mon Sep 17 00:00:00 2001 From: condyl Date: Fri, 14 Aug 2026 23:03:23 -0400 Subject: [PATCH 1/2] chore: upgrade project to Node.js 24 --- .github/workflows/format.yml | 2 +- .github/workflows/generationTest.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/ui-tests.yml | 2 +- .nvmrc | 1 + package-lock.json | 3 +++ package.json | 3 +++ 7 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 379e80e..d24ac53 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 22 + node-version: 24 - name: Install dependencies run: npm install diff --git a/.github/workflows/generationTest.yml b/.github/workflows/generationTest.yml index b3b0797..eebe890 100644 --- a/.github/workflows/generationTest.yml +++ b/.github/workflows/generationTest.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Install dependencies run: npm ci diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3162308..191b38d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: npm - name: Install dependencies diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 2a57f51..a2c1093 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Install dependencies run: npm ci diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/package-lock.json b/package-lock.json index 6fc3ce8..d05b14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,9 @@ "tailwindcss-animate": "^1.0.7", "vite": "^7.3.0", "vitest": "^3.2.4" + }, + "engines": { + "node": "24.x" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index 15d8631..32c92e6 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "cross-env VITE_API_BASE_URL=https://brocktimetable.connorbernard.com/api vite", "dev:local": "cross-env VITE_API_BASE_URL=http://localhost:3001/api vite", From e1e2d66269ad49142072bd838ef10518120c6e8d Mon Sep 17 00:00:00 2001 From: condyl Date: Sat, 5 Sep 2026 12:00:38 -0400 Subject: [PATCH 2/2] feat: add WebMCP schedule tools --- .../utils/__tests__/calendarViewUtils.test.js | 29 + .../Calendar/utils/calendarViewUtils.js | 25 +- .../generator/CourseColorsContext.jsx | 11 +- .../__tests__/CourseColorsContext.test.jsx | 37 + .../__tests__/previewTimetablesTest.js | 31 + .../timetableGeneration.js | 26 + .../utils/combinationUtils.js | 33 +- .../timetableGeneration/utils/filterUtils.js | 21 +- .../webmcp/__tests__/scheduleOptions.test.js | 55 + .../__tests__/useScheduleWebMcp.test.jsx | 71 ++ src/lib/webmcp/scheduleOptions.js | 258 +++++ src/lib/webmcp/useScheduleWebMcp.jsx | 976 ++++++++++++++++++ src/pages/GeneratorPage.jsx | 22 + vercel.json | 11 + vite.config.js | 12 + 15 files changed, 1594 insertions(+), 24 deletions(-) create mode 100644 src/components/generator/Calendar/utils/__tests__/calendarViewUtils.test.js create mode 100644 src/lib/contexts/generator/__tests__/CourseColorsContext.test.jsx create mode 100644 src/lib/generator/timetableGeneration/__tests__/previewTimetablesTest.js create mode 100644 src/lib/webmcp/__tests__/scheduleOptions.test.js create mode 100644 src/lib/webmcp/__tests__/useScheduleWebMcp.test.jsx create mode 100644 src/lib/webmcp/scheduleOptions.js create mode 100644 src/lib/webmcp/useScheduleWebMcp.jsx create mode 100644 vercel.json diff --git a/src/components/generator/Calendar/utils/__tests__/calendarViewUtils.test.js b/src/components/generator/Calendar/utils/__tests__/calendarViewUtils.test.js new file mode 100644 index 0000000..e0b129a --- /dev/null +++ b/src/components/generator/Calendar/utils/__tests__/calendarViewUtils.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { getVisibleCandidates } from "../calendarViewUtils"; + +const timetable = (id) => ({ + courses: [ + { + courseCode: "COSC1P02", + mainComponents: [ + { id, schedule: { duration: "2", startDate: 100, endDate: 200 } }, + ], + secondaryComponents: {}, + }, + ], +}); + +describe("getVisibleCandidates", () => { + it("keeps one representative for visually identical timetable candidates", () => { + const first = { id: "first", timetable: timetable("100-1") }; + const duplicate = { id: "duplicate", timetable: timetable("100-2") }; + const distinct = { id: "distinct", timetable: timetable("101-1") }; + + expect( + getVisibleCandidates([first, duplicate, distinct], { + start: new Date(100_000), + end: new Date(201_000), + }), + ).toEqual([first, distinct]); + }); +}); diff --git a/src/components/generator/Calendar/utils/calendarViewUtils.js b/src/components/generator/Calendar/utils/calendarViewUtils.js index f959874..240f69e 100644 --- a/src/components/generator/Calendar/utils/calendarViewUtils.js +++ b/src/components/generator/Calendar/utils/calendarViewUtils.js @@ -96,29 +96,38 @@ export const getVisibleTimetableSignature = (timetable, viewRange) => { return courseSignatures.join("||"); }; -export const getVisibleTimetables = (timetables, viewRange) => { - if (!Array.isArray(timetables)) return []; - if (!viewRange) return timetables; +export const getVisibleCandidates = (candidates, viewRange) => { + if (!Array.isArray(candidates)) return []; + if (!viewRange) return candidates; const uniqueSignatures = new Set(); - const filteredTimetables = []; + const visibleCandidates = []; - timetables.forEach((timetable) => { + candidates.forEach((candidate) => { + const timetable = candidate.timetable || candidate; const signature = getVisibleTimetableSignature(timetable, viewRange); if (signature == null) { - filteredTimetables.push(timetable); + visibleCandidates.push(candidate); return; } if (!uniqueSignatures.has(signature)) { uniqueSignatures.add(signature); - filteredTimetables.push(timetable); + visibleCandidates.push(candidate); } }); - return filteredTimetables; + return visibleCandidates; }; +export const getVisibleTimetables = (timetables, viewRange) => + Array.isArray(timetables) + ? getVisibleCandidates( + timetables.map((timetable) => ({ timetable })), + viewRange, + ).map(({ timetable }) => timetable) + : []; + // Get calendar view notification message export const getCalendarViewNotificationMessage = (startDate) => { return ( diff --git a/src/lib/contexts/generator/CourseColorsContext.jsx b/src/lib/contexts/generator/CourseColorsContext.jsx index a8b8964..996920a 100644 --- a/src/lib/contexts/generator/CourseColorsContext.jsx +++ b/src/lib/contexts/generator/CourseColorsContext.jsx @@ -95,7 +95,10 @@ export const CourseColorsProvider = ({ children }) => { if (prev[courseCode]) { return prev; } - const currentUsedColors = usedColorsRef.current; + // Several courses can be added in one React commit (for example through + // WebMCP). Derive the palette from `prev`, not the asynchronously updated + // ref, so each queued initializer sees colors assigned by the earlier one. + const currentUsedColors = Object.values(prev); const availableColors = defaultColors.filter( (color) => !currentUsedColors.includes(color), ); @@ -106,12 +109,12 @@ export const CourseColorsProvider = ({ children }) => { } else { newColor = availableColors[0]; } - // Update usedColors - setUsedColors((current) => [...current, newColor]); - return { + const nextColors = { ...prev, [courseCode]: newColor, }; + setUsedColors(Object.values(nextColors)); + return nextColors; }); }, []); diff --git a/src/lib/contexts/generator/__tests__/CourseColorsContext.test.jsx b/src/lib/contexts/generator/__tests__/CourseColorsContext.test.jsx new file mode 100644 index 0000000..b338bd8 --- /dev/null +++ b/src/lib/contexts/generator/__tests__/CourseColorsContext.test.jsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { useContext, useEffect } from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + CourseColorsContext, + CourseColorsProvider, +} from "../CourseColorsContext"; +import { defaultColors } from "../courseColorPalette"; + +function BatchInitializer() { + const { courseColors, initializeCourseColor } = + useContext(CourseColorsContext); + + useEffect(() => { + initializeCourseColor("COSC1P02"); + initializeCourseColor("MATH1P01"); + }, [initializeCourseColor]); + + return {JSON.stringify(courseColors)}; +} + +describe("CourseColorsContext", () => { + it("assigns distinct palette colours to courses initialized in one commit", async () => { + render( + + + , + ); + + await screen.findByText((content) => content.includes("MATH1P01")); + expect(JSON.parse(screen.getByRole("status").textContent)).toEqual({ + COSC1P02: defaultColors[0], + MATH1P01: defaultColors[1], + }); + }); +}); diff --git a/src/lib/generator/timetableGeneration/__tests__/previewTimetablesTest.js b/src/lib/generator/timetableGeneration/__tests__/previewTimetablesTest.js new file mode 100644 index 0000000..ae99f9b --- /dev/null +++ b/src/lib/generator/timetableGeneration/__tests__/previewTimetablesTest.js @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearAllCourseData, + storeCourseData, +} from "@/lib/generator/courseData"; +import { clearAllPins } from "@/lib/generator/pinnedComponents"; +import { reinitializeTimeSlots } from "@/lib/generator/timeSlots"; +import { + generateTimetables, + getValidTimetables, + previewTimetables, +} from "../timetableGeneration"; +import coscData from "./__mocks__/COSC1P02.json"; +import biolData from "./__mocks__/BIOL2P02.json"; + +describe("previewTimetables", () => { + beforeEach(() => { + clearAllCourseData(); + clearAllPins(); + reinitializeTimeSlots(); + storeCourseData(coscData); + }); + + it("returns a what-if result without replacing the current timetable results", () => { + generateTimetables("default"); + const currentResults = getValidTimetables(); + + expect(previewTimetables([biolData])).not.toHaveLength(0); + expect(getValidTimetables()).toBe(currentResults); + }); +}); diff --git a/src/lib/generator/timetableGeneration/timetableGeneration.js b/src/lib/generator/timetableGeneration/timetableGeneration.js index e790b38..1c95849 100644 --- a/src/lib/generator/timetableGeneration/timetableGeneration.js +++ b/src/lib/generator/timetableGeneration/timetableGeneration.js @@ -20,6 +20,7 @@ import { buildConflictFallback } from "./utils/conflictUtils"; import { calculateWaitingTime, calculateClassDays } from "./utils/sortUtils"; import { getCourseData } from "../courseData"; import { getTimeSlots } from "../timeSlots"; +import { getPinnedComponents } from "../pinnedComponents"; let validTimetables = []; let previousSortOption = "default"; @@ -123,6 +124,31 @@ export const generateTimetables = (sortOption) => { export const getValidTimetables = () => validTimetables; +// Generates a read-only what-if result for WebMCP. Unlike generateTimetables, +// it neither changes the visible result list nor emits UI events. +export const previewTimetables = (additionalCourses, additionalPins = []) => { + const courses = [...Object.values(getCourseData()), ...additionalCourses]; + const performance = { totalCombinationsProcessed: 0 }; + const options = { + pinnedComponents: [...getPinnedComponents(), ...additionalPins], + emitOverride: false, + markPinned: false, + }; + const combinations = courses.map((course) => + generateSingleCourseCombinations(course, getTimeSlots(), options), + ); + if ( + combinations.some((courseCombinations) => courseCombinations.length === 0) + ) + return []; + + return generateTimetableCombinations(combinations, performance, { + onTruncate: () => {}, + }) + .filter(isTimetableValid) + .map((courses) => ({ courses })); +}; + export const getGenerationPerformance = () => { const { generationStartTime, diff --git a/src/lib/generator/timetableGeneration/utils/combinationUtils.js b/src/lib/generator/timetableGeneration/utils/combinationUtils.js index f66cf5e..bd2e015 100644 --- a/src/lib/generator/timetableGeneration/utils/combinationUtils.js +++ b/src/lib/generator/timetableGeneration/utils/combinationUtils.js @@ -40,9 +40,15 @@ export const cartesianProduct = (arrays) => { return result; }; -export const generateSingleCourseCombinations = (course, timeSlots) => { - const pinnedComponents = getPinnedComponents(); - +export const generateSingleCourseCombinations = ( + course, + timeSlots, + { + pinnedComponents = getPinnedComponents(), + emitOverride = true, + markPinned = true, + } = {}, +) => { const durationFilter = pinnedComponents.find((p) => { const [pinnedCourse, type] = p.split(" "); return pinnedCourse === course.courseCode && type === "DURATION"; @@ -57,9 +63,16 @@ export const generateSingleCourseCombinations = (course, timeSlots) => { const { availableGroups: mainAvailable } = filterComponentsAgainstTimeSlots( validMainComponents, timeSlots, + { emitOverride }, ); - validMainComponents = filterPinned(mainAvailable, course.courseCode, "MAIN"); + validMainComponents = filterPinned( + mainAvailable, + course.courseCode, + "MAIN", + pinnedComponents, + markPinned, + ); if (validMainComponents.length === 0) { return []; @@ -78,9 +91,16 @@ export const generateSingleCourseCombinations = (course, timeSlots) => { const { availableGroups } = filterComponentsAgainstTimeSlots( items, timeSlots, + { emitOverride }, ); - const filtered = filterPinned(availableGroups, course.courseCode, type); + const filtered = filterPinned( + availableGroups, + course.courseCode, + type, + pinnedComponents, + markPinned, + ); return filtered; }; @@ -189,6 +209,7 @@ export const generateSingleCourseCombinations = (course, timeSlots) => { export const generateTimetableCombinations = ( courseCombinations, performanceMetrics, + { onTruncate = emitTruncationWarning } = {}, ) => { let results = []; let count = 0; @@ -199,7 +220,7 @@ export const generateTimetableCombinations = ( count++; performanceMetrics.totalCombinationsProcessed++; if (count >= maxComboThreshold) { - emitTruncationWarning(); + onTruncate(); return false; } return true; diff --git a/src/lib/generator/timetableGeneration/utils/filterUtils.js b/src/lib/generator/timetableGeneration/utils/filterUtils.js index 85c6e33..dd6a31a 100644 --- a/src/lib/generator/timetableGeneration/utils/filterUtils.js +++ b/src/lib/generator/timetableGeneration/utils/filterUtils.js @@ -7,7 +7,11 @@ import { import { getPinnedComponents } from "@/lib/generator/pinnedComponents"; import { emitTimetableOverridden } from "./UIEventsUtils"; -export const filterComponentsAgainstTimeSlots = (components, timeSlots) => { +export const filterComponentsAgainstTimeSlots = ( + components, + timeSlots, + { emitOverride = true } = {}, +) => { const timeRegex = /[a-zA-Z]/; const groupedComponents = new Map(); const blockedComponents = []; @@ -53,7 +57,7 @@ export const filterComponentsAgainstTimeSlots = (components, timeSlots) => { } if (availableGroups.length === 0 && blockedComponents.length > 0) { - emitTimetableOverridden(); + if (emitOverride) emitTimetableOverridden(); blockedComponents.sort((a, b) => a.blockedPercentage - b.blockedPercentage); availableGroups.push(blockedComponents[0].group); return { @@ -70,9 +74,14 @@ export const filterComponentsAgainstTimeSlots = (components, timeSlots) => { }; }; -export const filterPinned = (components, courseCode, componentType) => { - components.forEach((component) => (component.pinned = false)); - const pinnedComponents = getPinnedComponents(); +export const filterPinned = ( + components, + courseCode, + componentType, + pinnedComponents = getPinnedComponents(), + markPinned = true, +) => { + if (markPinned) components.forEach((component) => (component.pinned = false)); const coursePinnedComponents = pinnedComponents.filter((p) => { const [course, type] = p.split(" "); @@ -86,7 +95,7 @@ export const filterPinned = (components, courseCode, componentType) => { const [, , id] = pinned.split(" "); const baseComponentId = getBaseComponentId(component.id); if (baseComponentId === id) { - component.pinned = true; + if (markPinned) component.pinned = true; return true; } return false; diff --git a/src/lib/webmcp/__tests__/scheduleOptions.test.js b/src/lib/webmcp/__tests__/scheduleOptions.test.js new file mode 100644 index 0000000..ed73abe --- /dev/null +++ b/src/lib/webmcp/__tests__/scheduleOptions.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeCourseLabel, + rankScheduleOptions, + toTimeSlots, +} from "../scheduleOptions"; + +const schedule = (time, days = "MW") => ({ + courses: [ + { + courseCode: "COSC1P02", + mainComponents: [{ id: "lec-1", type: "LEC", schedule: { time, days } }], + secondaryComponents: {}, + }, + ], +}); + +describe("schedule WebMCP helpers", () => { + it("normalizes an agent-supplied course label", () => { + expect(normalizeCourseLabel(" cosc1p02 d2 ")).toBe("COSC 1P02 D2"); + expect(normalizeCourseLabel("not a course")).toBeNull(); + }); + + it("removes schedules that violate hard start-time requirements", () => { + const ranked = rankScheduleOptions( + [ + { id: "early", timetable: schedule("0800-0900") }, + { id: "late", timetable: schedule("1000-1100") }, + ], + { hard: { notBefore: "09:00" }, soft: {} }, + ); + + expect(ranked.map((option) => option.id)).toEqual(["late"]); + }); + + it("ranks later classes first when avoiding early starts is a soft preference", () => { + const ranked = rankScheduleOptions( + [ + { id: "early", timetable: schedule("0800-0900") }, + { id: "late", timetable: schedule("1000-1100") }, + ], + { hard: {}, soft: { avoidBefore: "09:00" } }, + ); + + expect(ranked.map((option) => option.id)).toEqual(["late", "early"]); + }); + + it("turns unavailable time windows into the generator slot grid", () => { + expect( + toTimeSlots([{ days: ["M"], start: "09:00", end: "10:00" }]), + ).toEqual({ + M: [2, 3], + }); + }); +}); diff --git a/src/lib/webmcp/__tests__/useScheduleWebMcp.test.jsx b/src/lib/webmcp/__tests__/useScheduleWebMcp.test.jsx new file mode 100644 index 0000000..10db4b1 --- /dev/null +++ b/src/lib/webmcp/__tests__/useScheduleWebMcp.test.jsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useScheduleWebMcp } from "../useScheduleWebMcp"; + +const setters = { + setTimetables: vi.fn(), + setAddedCourses: vi.fn(), + setTimetableType: vi.fn(), + setTerm: vi.fn(), + setSelectedDuration: vi.fn(), + setDurations: vi.fn(), + setCurrentTimetableIndex: vi.fn(), + onTimeBlockChange: vi.fn(), +}; + +function Harness() { + useScheduleWebMcp({ + timetables: [], + addedCourses: [], + timetableType: "UG", + term: "FW", + sortOption: "default", + courseColors: {}, + ...setters, + }); + return null; +} + +afterEach(() => { + delete document.modelContext; + vi.clearAllMocks(); +}); + +describe("useScheduleWebMcp", () => { + it("registers the complete schedule-management tool surface when WebMCP is available", async () => { + const registerTool = vi.fn(() => Promise.resolve()); + Object.defineProperty(document, "modelContext", { + configurable: true, + value: { registerTool }, + }); + + const { unmount } = render(); + await waitFor(() => expect(registerTool).toHaveBeenCalledTimes(15)); + + expect( + registerTool.mock.calls.map(([definition]) => definition.name), + ).toEqual([ + "searchCourses", + "createSchedule", + "addCourses", + "previewCourseAddition", + "removeCourses", + "setSchedulePreferences", + "setUnavailableTimes", + "pinSections", + "unpinSections", + "searchScheduleOptions", + "getScheduleOptionDetails", + "compareScheduleOptions", + "selectScheduleOption", + "getCurrentSchedule", + "exportSchedule", + ]); + + unmount(); + expect( + registerTool.mock.calls.every(([, options]) => options.signal.aborted), + ).toBe(true); + }); +}); diff --git a/src/lib/webmcp/scheduleOptions.js b/src/lib/webmcp/scheduleOptions.js new file mode 100644 index 0000000..9978916 --- /dev/null +++ b/src/lib/webmcp/scheduleOptions.js @@ -0,0 +1,258 @@ +const DAY_NAMES = { + M: "Monday", + T: "Tuesday", + W: "Wednesday", + R: "Thursday", + F: "Friday", + S: "Saturday", + U: "Sunday", +}; + +const DAY_CODES = Object.keys(DAY_NAMES); + +export const normalizeCourseLabel = (value) => { + const label = String(value || "") + .trim() + .toUpperCase() + .replace(/\s+/g, " "); + const match = label.match(/^([A-Z]{4})\s*(\d[A-Z]\d{2})\s*(?:D)?(\d+)$/); + if (!match) return null; + return `${match[1]} ${match[2]} D${match[3]}`; +}; + +export const normalizeCourseCode = (value) => + String(value || "") + .toUpperCase() + .replace(/\s+/g, "") + .replace(/D\d+$/, ""); + +const toMinutes = (value) => { + const text = String(value || "").trim(); + if (/^\d{1,2}:\d{2}$/.test(text)) { + const [hour, minute] = text.split(":").map(Number); + return hour * 60 + minute; + } + if (/^\d{3,4}$/.test(text)) { + const padded = text.padStart(4, "0"); + return Number(padded.slice(0, 2)) * 60 + Number(padded.slice(2)); + } + return null; +}; + +const formatTime = (minutes) => { + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +}; + +const componentMeetings = (course) => { + const components = [ + ...(course.mainComponents || []), + course.secondaryComponents?.lab, + course.secondaryComponents?.tutorial, + course.secondaryComponents?.seminar, + ].filter(Boolean); + + return components.flatMap((component) => { + const [start, end] = String(component.schedule?.time || "") + .replace(/\s/g, "") + .split("-") + .map(toMinutes); + if ( + start == null || + end == null || + /[A-Z]/i.test(component.schedule?.time || "") + ) { + return []; + } + return String(component.schedule?.days || "") + .replace(/\s/g, "") + .split("") + .filter((day) => DAY_CODES.includes(day)) + .map((day) => ({ + courseCode: course.courseCode, + type: component.type, + sectionId: component.id, + day, + start, + end, + })); + }); +}; + +export const getScheduleMetrics = (timetable) => { + const meetings = (timetable?.courses || []).flatMap(componentMeetings); + const byDay = new Map(); + meetings.forEach((meeting) => { + if (!byDay.has(meeting.day)) byDay.set(meeting.day, []); + byDay.get(meeting.day).push(meeting); + }); + + let largestGapMinutes = 0; + let totalGapMinutes = 0; + byDay.forEach((dayMeetings) => { + dayMeetings.sort((a, b) => a.start - b.start); + for (let index = 1; index < dayMeetings.length; index += 1) { + const gap = Math.max( + 0, + dayMeetings[index].start - dayMeetings[index - 1].end, + ); + largestGapMinutes = Math.max(largestGapMinutes, gap); + totalGapMinutes += gap; + } + }); + + const starts = meetings.map((meeting) => meeting.start); + const ends = meetings.map((meeting) => meeting.end); + return { + meetings, + daysOnCampus: byDay.size, + earliestStart: starts.length ? Math.min(...starts) : null, + latestEnd: ends.length ? Math.max(...ends) : null, + largestGapMinutes, + totalGapMinutes, + days: [...byDay.keys()].sort( + (a, b) => DAY_CODES.indexOf(a) - DAY_CODES.indexOf(b), + ), + }; +}; + +const values = (value) => (Array.isArray(value) ? value : []); + +export const matchesHardPreferences = (metrics, preferences = {}) => { + const hard = preferences.hard || preferences; + const notBefore = toMinutes(hard.notBefore); + const notAfter = toMinutes(hard.notAfter); + const avoidDays = new Set(values(hard.avoidDays)); + + if ( + notBefore !== null && + metrics.earliestStart !== null && + metrics.earliestStart < notBefore + ) + return false; + if ( + notAfter !== null && + metrics.latestEnd !== null && + metrics.latestEnd > notAfter + ) + return false; + if (hard.maxCampusDays && metrics.daysOnCampus > hard.maxCampusDays) + return false; + if ( + hard.maxGapMinutes !== undefined && + metrics.largestGapMinutes > hard.maxGapMinutes + ) + return false; + return !metrics.days.some((day) => avoidDays.has(day)); +}; + +export const scoreSchedule = (metrics, preferences = {}) => { + const soft = preferences.soft || preferences; + let score = 100; + const avoidBefore = toMinutes(soft.avoidBefore); + const preferAfter = toMinutes(soft.preferAfter); + const avoidDays = new Set(values(soft.avoidDays)); + + if ( + avoidBefore !== null && + metrics.earliestStart !== null && + metrics.earliestStart < avoidBefore + ) { + score -= Math.ceil((avoidBefore - metrics.earliestStart) / 15) * 3; + } + if ( + preferAfter !== null && + metrics.earliestStart !== null && + metrics.earliestStart < preferAfter + ) { + score -= Math.ceil((preferAfter - metrics.earliestStart) / 30); + } + score -= metrics.days.filter((day) => avoidDays.has(day)).length * 25; + if (soft.preferCompactDays) score -= Math.round(metrics.totalGapMinutes / 15); + if (soft.preferFewerDays) score -= metrics.daysOnCampus * 8; + return score; +}; + +export const describeOption = (id, timetable, preferences, cachedMetrics) => { + const metrics = cachedMetrics || getScheduleMetrics(timetable); + const dayNames = + metrics.days.map((day) => DAY_NAMES[day]).join(", ") || "No timed meetings"; + return { + id, + score: scoreSchedule(metrics, preferences), + hasConflicts: Boolean(timetable?.hasConflicts), + courseCount: timetable?.courses?.length || 0, + daysOnCampus: metrics.daysOnCampus, + earliestStart: + metrics.earliestStart === null ? null : formatTime(metrics.earliestStart), + latestEnd: + metrics.latestEnd === null ? null : formatTime(metrics.latestEnd), + largestGapMinutes: metrics.largestGapMinutes, + totalGapMinutes: metrics.totalGapMinutes, + days: metrics.days, + summary: `${dayNames}; ${metrics.earliestStart === null ? "no timed meetings" : `${formatTime(metrics.earliestStart)}–${formatTime(metrics.latestEnd)}`}`, + }; +}; + +export const rankScheduleOptions = (candidates, preferences = {}) => + candidates + .map((candidate) => ({ + ...candidate, + // Agent searches repeatedly rank the same generated candidates. Keep the + // expensive meeting/gap calculation produced at generation time. + metrics: candidate.metrics || getScheduleMetrics(candidate.timetable), + })) + .filter((candidate) => + matchesHardPreferences(candidate.metrics, preferences.hard || {}), + ) + .sort((a, b) => { + const scoreDifference = + scoreSchedule(b.metrics, preferences) - + scoreSchedule(a.metrics, preferences); + if (scoreDifference !== 0) return scoreDifference; + return a.metrics.totalGapMinutes - b.metrics.totalGapMinutes; + }); + +export const toTimeSlots = (blocks) => { + const slots = {}; + for (const block of blocks) { + const start = toMinutes(block.start); + const end = toMinutes(block.end); + if (start === null || end === null || end <= start) continue; + for (const day of values(block.days)) { + if (!DAY_CODES.includes(day)) continue; + const startSlot = Math.max(0, Math.floor((start - 8 * 60) / 30)); + const endSlot = Math.min(28, Math.ceil((end - 8 * 60) / 30)); + if (endSlot <= startSlot) continue; + slots[day] = [ + ...(slots[day] || []), + ...Array.from({ length: endSlot - startSlot }, (_, i) => startSlot + i), + ]; + } + } + return Object.fromEntries( + Object.entries(slots).map(([day, daySlots]) => [ + day, + [...new Set(daySlots)], + ]), + ); +}; + +export const normalizeUnavailableBlocks = (blocks) => + values(blocks).flatMap((block, blockIndex) => { + const start = toMinutes(block.start); + const end = toMinutes(block.end); + if (start === null || end === null || end <= start) return []; + return values(block.days) + .filter((day) => DAY_CODES.includes(day)) + .map((day, dayIndex) => ({ + id: `agent-${Date.now()}-${blockIndex}-${dayIndex}-${day}`, + title: String(block.label || "Unavailable"), + daysOfWeek: day, + startTime: formatTime(start), + endTime: formatTime(end), + startRecur: "1970-01-01", + endRecur: "9999-12-31", + })); + }); diff --git a/src/lib/webmcp/useScheduleWebMcp.jsx b/src/lib/webmcp/useScheduleWebMcp.jsx new file mode 100644 index 0000000..4e852b6 --- /dev/null +++ b/src/lib/webmcp/useScheduleWebMcp.jsx @@ -0,0 +1,976 @@ +import { useEffect, useRef } from "react"; +import { exportCal, updateExportData } from "@/lib/generator/ExportCal"; +import { getCourse, getNameList } from "@/lib/generator/fetchData"; +import { + storeCourseData, + getCourseData, + clearAllCourseData, + removeCourseData, +} from "@/lib/generator/courseData"; +import { + addPinnedComponent, + clearAllPins, + clearCoursePins, + getPinnedComponents, + removePinnedComponent, +} from "@/lib/generator/pinnedComponents"; +import { + addTimeBlockEvent, + clearAllTimeBlockEvents, + getTimeBlockEvents, +} from "@/lib/generator/createCalendarEvents"; +import { + reinitializeTimeSlots, + setBlockedTimeSlots, +} from "@/lib/generator/timeSlots"; +import { + generateTimetables, + getValidTimetables, + previewTimetables, +} from "@/lib/generator/timetableGeneration/timetableGeneration"; +import { + buildDurationLabel, + parseCourseLabel, + syncUrlToState, +} from "@/lib/urlState/urlStateUtils"; +import { + calculateNavigationDate, + getVisibleCandidates, +} from "@/components/generator/Calendar/utils/calendarViewUtils"; +import { + describeOption, + getScheduleMetrics, + normalizeCourseCode, + normalizeCourseLabel, + normalizeUnavailableBlocks, + rankScheduleOptions, + toTimeSlots, +} from "./scheduleOptions"; + +const json = (value) => JSON.stringify(value); +const asArray = (value) => (Array.isArray(value) ? value : []); + +const tool = (name, description, inputSchema, execute, annotations = {}) => ({ + name, + description, + inputSchema, + annotations, + execute: async (input, context) => { + try { + return json({ ok: true, ...(await execute(input || {}, context || {})) }); + } catch (error) { + return json({ + ok: false, + error: + error instanceof Error + ? error.message + : "Unable to complete that request.", + }); + } + }, +}); + +const courseSchema = { + type: "array", + items: { type: "string", description: "Course label such as COSC 1P02 D2." }, + minItems: 1, +}; + +const preferenceSchema = { + type: "object", + description: + "Use hard constraints to eliminate options and soft preferences to rank them.", + properties: { + hard: { + type: "object", + properties: { + notBefore: { + type: "string", + description: "Earliest allowed class time, HH:MM.", + }, + notAfter: { + type: "string", + description: "Latest allowed class end time, HH:MM.", + }, + avoidDays: { + type: "array", + items: { type: "string", enum: ["M", "T", "W", "R", "F", "S", "U"] }, + }, + maxCampusDays: { type: "integer", minimum: 1, maximum: 7 }, + maxGapMinutes: { type: "integer", minimum: 0 }, + }, + }, + soft: { + type: "object", + properties: { + avoidBefore: { + type: "string", + description: "Prefer schedules without earlier classes, HH:MM.", + }, + preferAfter: { + type: "string", + description: "Prefer later starts, HH:MM.", + }, + avoidDays: { + type: "array", + items: { type: "string", enum: ["M", "T", "W", "R", "F", "S", "U"] }, + }, + preferCompactDays: { type: "boolean" }, + preferFewerDays: { type: "boolean" }, + }, + }, + }, +}; + +const blocksSchema = { + type: "array", + items: { + type: "object", + required: ["days", "start", "end"], + properties: { + days: { + type: "array", + items: { type: "string", enum: ["M", "T", "W", "R", "F", "S", "U"] }, + }, + start: { type: "string", description: "HH:MM" }, + end: { type: "string", description: "HH:MM" }, + label: { type: "string" }, + }, + }, +}; + +export function useScheduleWebMcp({ + timetables, + setTimetables, + addedCourses, + setAddedCourses, + timetableType, + setTimetableType, + term, + setTerm, + sortOption, + selectedDuration, + setSelectedDuration, + setDurations, + setCurrentTimetableIndex, + onTimeBlockChange, + courseColors, +}) { + const stateRef = useRef({ + timetables, + addedCourses, + timetableType, + term, + sortOption, + selectedDuration, + }); + const preferencesRef = useRef({ hard: {}, soft: {} }); + const candidatesRef = useRef([]); + const scheduleVersionRef = useRef(0); + const selectedIdRef = useRef(null); + const courseColorsRef = useRef(courseColors); + + stateRef.current = { + timetables, + addedCourses, + timetableType, + term, + sortOption, + selectedDuration, + }; + courseColorsRef.current = courseColors; + + const rebuildDurations = (labels) => { + const seen = new Set(); + const durations = labels.flatMap((label) => { + const { cleanCourseCode, duration } = parseCourseLabel(label); + const durationLabel = buildDurationLabel( + getCourseData()[cleanCourseCode], + duration, + ); + return durationLabel && !seen.has(durationLabel) + ? (seen.add(durationLabel), [durationLabel]) + : []; + }); + stateRef.current.selectedDuration = durations[durations.length - 1] || ""; + setDurations(durations); + setSelectedDuration(durations[durations.length - 1] || ""); + }; + + const commitCourses = (nextCourses) => { + stateRef.current.addedCourses = nextCourses; + setAddedCourses(nextCourses); + rebuildDurations(nextCourses); + }; + + const optionId = (index) => `${scheduleVersionRef.current}:${index}`; + + const calendarViewRange = () => { + const start = Number( + String(stateRef.current.selectedDuration || "").split("-")[0], + ); + if (!Number.isFinite(start) || start <= 0) return null; + const rangeStart = calculateNavigationDate(new Date(start * 1000)); + const rangeEnd = new Date(rangeStart); + rangeEnd.setDate(rangeEnd.getDate() + 7); + return { start: rangeStart, end: rangeEnd }; + }; + + const visibleCandidates = (candidates) => + getVisibleCandidates(candidates, calendarViewRange()); + + const rankVisibleCandidates = (candidates, preferences) => + visibleCandidates(rankScheduleOptions(candidates, preferences)); + + const syncSelectedTimetable = (timetable) => { + syncUrlToState({ + currentTimetable: timetable, + addedCourses: stateRef.current.addedCourses, + sortOption: stateRef.current.sortOption, + timetableType: stateRef.current.timetableType, + term: stateRef.current.term, + timeBlockEvents: getTimeBlockEvents(), + selectedDuration: "", + courseColors: courseColorsRef.current, + }); + }; + + const publishCandidates = () => { + const ranked = rankVisibleCandidates( + candidatesRef.current, + preferencesRef.current, + ); + const visible = ranked.map((candidate) => candidate.timetable); + stateRef.current.timetables = visible; + setTimetables(visible); + setCurrentTimetableIndex(0); + selectedIdRef.current = ranked[0]?.id || null; + if (ranked[0]) syncSelectedTimetable(ranked[0].timetable); + return ranked; + }; + + const regenerate = () => { + generateTimetables(stateRef.current.sortOption); + scheduleVersionRef.current += 1; + candidatesRef.current = getValidTimetables().map((timetable, index) => ({ + id: optionId(index), + timetable, + metrics: getScheduleMetrics(timetable), + })); + return publishCandidates(); + }; + + const candidateById = (id) => + candidatesRef.current.find((candidate) => candidate.id === id); + + const ensureCandidates = () => { + if (candidatesRef.current.length) return; + scheduleVersionRef.current += 1; + candidatesRef.current = stateRef.current.timetables.map( + (timetable, index) => ({ + id: optionId(index), + timetable, + metrics: getScheduleMetrics(timetable), + }), + ); + selectedIdRef.current = candidatesRef.current[0]?.id || null; + }; + + const addCourses = async (courseValues, override = {}, preloadedCourses) => { + const labels = courseValues.map(normalizeCourseLabel); + if (labels.some((label) => !label)) + throw new Error("Courses must use a label such as COSC 1P02 D2."); + const existing = new Set(stateRef.current.addedCourses); + const uniqueLabels = [...new Set(labels)].filter( + (label) => !existing.has(label), + ); + if (!uniqueLabels.length) + throw new Error("Those courses are already in the schedule."); + const nextType = override.timetableType || stateRef.current.timetableType; + const nextTerm = override.term || stateRef.current.term; + const loaded = + preloadedCourses || + (await Promise.all( + uniqueLabels.map(async (label) => { + const { cleanCourseCode } = parseCourseLabel(label); + return { + label, + course: await getCourse(cleanCourseCode, nextType, nextTerm), + }; + }), + )); + loaded.forEach(({ label, course }) => { + const { cleanCourseCode, duration } = parseCourseLabel(label); + storeCourseData(course); + addPinnedComponent(`${cleanCourseCode} DURATION ${duration}`); + }); + const nextCourses = [...stateRef.current.addedCourses, ...uniqueLabels]; + commitCourses(nextCourses); + const ranked = regenerate(); + return { addedCourses: uniqueLabels, schedule: scheduleResult(ranked) }; + }; + + const scheduleResult = (ranked) => ({ + scheduleVersion: scheduleVersionRef.current, + totalOptions: ranked.length, + generatedCombinations: candidatesRef.current.length, + matchingCombinations: rankScheduleOptions( + candidatesRef.current, + preferencesRef.current, + ).length, + selectedOptionId: selectedIdRef.current, + options: ranked + .slice(0, 5) + .map((candidate) => + describeOption( + candidate.id, + candidate.timetable, + preferencesRef.current, + candidate.metrics, + ), + ), + message: ranked.length + ? undefined + : "No generated timetable satisfies the current hard constraints.", + }); + + const getDetails = (candidate) => { + if (!candidate) + throw new Error( + "That schedule option is no longer available. Search again.", + ); + const metrics = + candidate.metrics || getScheduleMetrics(candidate.timetable); + return { + option: describeOption( + candidate.id, + candidate.timetable, + preferencesRef.current, + ), + meetings: metrics.meetings.map((meeting) => ({ + ...meeting, + start: `${String(Math.floor(meeting.start / 60)).padStart(2, "0")}:${String(meeting.start % 60).padStart(2, "0")}`, + end: `${String(Math.floor(meeting.end / 60)).padStart(2, "0")}:${String(meeting.end % 60).padStart(2, "0")}`, + })), + courses: (candidate.timetable.courses || []).map((course) => ({ + code: course.courseCode, + name: course.courseName, + })), + }; + }; + + const describePreviewOption = (candidate, preferences) => { + const metrics = + candidate.metrics || getScheduleMetrics(candidate.timetable); + return { + ...describeOption( + candidate.id, + candidate.timetable, + preferences, + metrics, + ), + meetings: metrics.meetings.map((meeting) => ({ + ...meeting, + start: `${String(Math.floor(meeting.start / 60)).padStart(2, "0")}:${String(meeting.start % 60).padStart(2, "0")}`, + end: `${String(Math.floor(meeting.end / 60)).padStart(2, "0")}:${String(meeting.end % 60).padStart(2, "0")}`, + })), + }; + }; + + useEffect(() => { + const modelContext = document.modelContext; + if (!modelContext?.registerTool) return undefined; + + const tools = [ + tool( + "searchCourses", + "Search the selected Brock timetable for course offerings. This does not change the schedule.", + { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + timetableType: { type: "string", enum: ["UG", "AD", "PS", "GR"] }, + term: { type: "string", enum: ["FW", "SP", "SU"] }, + limit: { type: "integer", minimum: 1, maximum: 30 }, + }, + }, + async ({ + query, + timetableType: requestedType, + term: requestedTerm, + limit = 10, + }) => { + const results = await getNameList( + requestedType || stateRef.current.timetableType, + requestedTerm || stateRef.current.term, + ); + const normalizedQuery = String(query) + .toUpperCase() + .replace(/\s+/g, ""); + return { + courses: results + .filter((course) => + `${course.label || course.value || course} ${course.courseName || ""}` + .toUpperCase() + .replace(/\s+/g, "") + .includes(normalizedQuery), + ) + .slice(0, limit) + .map((course) => + typeof course === "string" + ? { label: course } + : { + label: course.label || course.value, + courseCode: course.courseCode, + duration: course.duration, + courseName: course.courseName, + }, + ), + }; + }, + { readOnlyHint: true }, + ), + + tool( + "createSchedule", + "Replace the current schedule with the requested courses and generate the best options.", + { + type: "object", + required: ["courses", "timetableType", "term"], + properties: { + courses: courseSchema, + timetableType: { type: "string", enum: ["UG", "AD", "PS", "GR"] }, + term: { type: "string", enum: ["FW", "SP", "SU"] }, + preferences: preferenceSchema, + unavailableTimes: blocksSchema, + }, + }, + async ({ + courses, + timetableType: nextType, + term: nextTerm, + preferences, + unavailableTimes, + }) => { + const labels = courses.map(normalizeCourseLabel); + if (labels.some((label) => !label)) + throw new Error("Courses must use a label such as COSC 1P02 D2."); + const uniqueLabels = [...new Set(labels)]; + // Fetch before replacing live state: a typo or unavailable offering must + // never erase the user's current timetable. + const loaded = await Promise.all( + uniqueLabels.map(async (label) => { + const { cleanCourseCode } = parseCourseLabel(label); + return { + label, + course: await getCourse(cleanCourseCode, nextType, nextTerm), + }; + }), + ); + clearAllCourseData(); + clearAllPins(); + clearAllTimeBlockEvents(); + reinitializeTimeSlots(); + stateRef.current.timetableType = nextType; + stateRef.current.term = nextTerm; + stateRef.current.addedCourses = []; + setTimetableType(nextType); + setTerm(nextTerm); + setAddedCourses([]); + preferencesRef.current = preferences || { hard: {}, soft: {} }; + if (unavailableTimes) { + const blocks = normalizeUnavailableBlocks(unavailableTimes); + blocks.forEach(addTimeBlockEvent); + setBlockedTimeSlots(toTimeSlots(unavailableTimes)); + onTimeBlockChange?.(); + } + return addCourses( + uniqueLabels, + { timetableType: nextType, term: nextTerm }, + loaded, + ); + }, + ), + + tool( + "addCourses", + "Add courses to the current schedule and regenerate its options.", + { + type: "object", + required: ["courses"], + properties: { courses: courseSchema }, + }, + async ({ courses }) => addCourses(courses), + ), + + tool( + "previewCourseAddition", + "Read-only what-if: test adding courses to the current schedule without changing it.", + { + type: "object", + required: ["courses"], + properties: { + courses: courseSchema, + preferences: preferenceSchema, + limit: { type: "integer", minimum: 1, maximum: 20 }, + }, + }, + async ({ courses, preferences, limit = 5 }) => { + const labels = courses.map(normalizeCourseLabel); + if (labels.some((label) => !label)) + throw new Error("Courses must use a label such as COSC 1P02 D2."); + if ( + labels.some((label) => + stateRef.current.addedCourses.includes(label), + ) + ) + throw new Error( + "Preview courses must not already be in the schedule.", + ); + const loaded = await Promise.all( + [...new Set(labels)].map(async (label) => { + const { cleanCourseCode } = parseCourseLabel(label); + return getCourse( + cleanCourseCode, + stateRef.current.timetableType, + stateRef.current.term, + ); + }), + ); + const previewPreferences = preferences + ? { + hard: { + ...preferencesRef.current.hard, + ...(preferences.hard || {}), + }, + soft: { + ...preferencesRef.current.soft, + ...(preferences.soft || {}), + }, + } + : preferencesRef.current; + ensureCandidates(); + const currentOptions = rankVisibleCandidates( + candidatesRef.current, + previewPreferences, + ).length; + const durationPins = labels.map((label) => { + const { cleanCourseCode, duration } = parseCourseLabel(label); + return `${cleanCourseCode} DURATION ${duration}`; + }); + const candidates = previewTimetables(loaded, durationPins).map( + (timetable, index) => ({ + id: `preview:${index}`, + timetable, + metrics: getScheduleMetrics(timetable), + }), + ); + const ranked = rankVisibleCandidates(candidates, previewPreferences); + const matchingCandidates = rankScheduleOptions( + candidates, + previewPreferences, + ); + const bestAvailable = rankVisibleCandidates(candidates, { + ...previewPreferences, + hard: {}, + })[0]; + const conflictFreeCandidates = visibleCandidates(candidates); + return { + previewOnly: true, + previewCourses: labels, + currentOptions, + generatedCombinations: candidates.length, + matchingCombinations: matchingCandidates.length, + conflictFreeOptions: conflictFreeCandidates.length, + totalOptions: ranked.length, + optionChange: ranked.length - currentOptions, + excludedByHardPreferences: + conflictFreeCandidates.length - ranked.length, + options: ranked + .slice(0, limit) + .map((candidate) => + describePreviewOption(candidate, previewPreferences), + ), + bestAvailableOption: + ranked.length || !bestAvailable + ? undefined + : describePreviewOption(bestAvailable, previewPreferences), + message: ranked.length + ? undefined + : conflictFreeCandidates.length + ? `${conflictFreeCandidates.length} conflict-free timetable(s) exist, but none satisfy the hard preferences.` + : "No conflict-free timetable exists with these courses.", + }; + }, + { readOnlyHint: true }, + ), + + tool( + "removeCourses", + "Remove course labels or course codes from the current schedule and regenerate.", + { + type: "object", + required: ["courses"], + properties: { courses: courseSchema }, + }, + async ({ courses }) => { + const requested = new Set(courses.map(normalizeCourseCode)); + const removed = stateRef.current.addedCourses.filter((label) => + requested.has(normalizeCourseCode(label)), + ); + if (!removed.length) + throw new Error( + "None of those courses are in the current schedule.", + ); + removed.forEach((label) => { + const { cleanCourseCode } = parseCourseLabel(label); + removeCourseData(cleanCourseCode); + clearCoursePins(cleanCourseCode); + }); + commitCourses( + stateRef.current.addedCourses.filter( + (label) => !removed.includes(label), + ), + ); + return { + removedCourses: removed, + schedule: scheduleResult(regenerate()), + }; + }, + ), + + tool( + "setSchedulePreferences", + "Set hard requirements and soft timetable preferences, then regenerate and rank options.", + { + type: "object", + required: ["preferences"], + properties: { + preferences: preferenceSchema, + replace: { type: "boolean" }, + }, + }, + async ({ preferences, replace = false }) => { + preferencesRef.current = replace + ? preferences + : { + hard: { + ...preferencesRef.current.hard, + ...(preferences.hard || {}), + }, + soft: { + ...preferencesRef.current.soft, + ...(preferences.soft || {}), + }, + }; + return { + preferences: preferencesRef.current, + schedule: scheduleResult(publishCandidates()), + }; + }, + ), + + tool( + "setUnavailableTimes", + "Replace or add recurring unavailable class times, then regenerate. Days use M, T, W, R, F, S, U.", + { + type: "object", + required: ["blocks"], + properties: { blocks: blocksSchema, replace: { type: "boolean" } }, + }, + async ({ blocks, replace = true }) => { + if (replace) { + clearAllTimeBlockEvents(); + reinitializeTimeSlots(); + } + const normalizedBlocks = normalizeUnavailableBlocks(blocks); + normalizedBlocks.forEach(addTimeBlockEvent); + setBlockedTimeSlots(toTimeSlots(blocks)); + onTimeBlockChange?.(); + return { + unavailableTimes: normalizedBlocks, + schedule: scheduleResult(regenerate()), + }; + }, + ), + + tool( + "pinSections", + "Require specific course sections in generated schedules.", + { + type: "object", + required: ["sections"], + properties: { + sections: { + type: "array", + items: { + type: "object", + required: ["courseCode", "type", "id"], + properties: { + courseCode: { type: "string" }, + type: { type: "string", enum: ["MAIN", "LAB", "TUT", "SEM"] }, + id: { type: "string" }, + }, + }, + }, + }, + }, + async ({ sections }) => { + asArray(sections).forEach(({ courseCode, type, id }) => + addPinnedComponent( + `${normalizeCourseCode(courseCode)} ${type} ${id}`, + ), + ); + return { + pinnedSections: getPinnedComponents(), + schedule: scheduleResult(regenerate()), + }; + }, + ), + + tool( + "unpinSections", + "Remove requirements for specific course sections and regenerate.", + { + type: "object", + required: ["sections"], + properties: { + sections: { + type: "array", + items: { + type: "object", + required: ["courseCode", "type", "id"], + properties: { + courseCode: { type: "string" }, + type: { type: "string", enum: ["MAIN", "LAB", "TUT", "SEM"] }, + id: { type: "string" }, + }, + }, + }, + }, + }, + async ({ sections }) => { + asArray(sections).forEach(({ courseCode, type, id }) => + removePinnedComponent( + `${normalizeCourseCode(courseCode)} ${type} ${id}`, + ), + ); + return { + pinnedSections: getPinnedComponents(), + schedule: scheduleResult(regenerate()), + }; + }, + ), + + tool( + "searchScheduleOptions", + "Search and rank generated schedule options without changing the selected option.", + { + type: "object", + properties: { + preferences: preferenceSchema, + limit: { type: "integer", minimum: 1, maximum: 20 }, + cursor: { type: "integer", minimum: 0 }, + }, + }, + async ({ preferences = {}, limit = 10, cursor = 0 }) => { + ensureCandidates(); + const merged = { + hard: { + ...preferencesRef.current.hard, + ...(preferences.hard || {}), + }, + soft: { + ...preferencesRef.current.soft, + ...(preferences.soft || {}), + }, + }; + const matchingCandidates = rankScheduleOptions( + candidatesRef.current, + merged, + ); + const ranked = visibleCandidates(matchingCandidates); + return { + scheduleVersion: scheduleVersionRef.current, + totalMatches: ranked.length, + generatedCombinations: candidatesRef.current.length, + matchingCombinations: matchingCandidates.length, + nextCursor: cursor + limit < ranked.length ? cursor + limit : null, + options: ranked + .slice(cursor, cursor + limit) + .map((candidate) => + describeOption( + candidate.id, + candidate.timetable, + merged, + candidate.metrics, + ), + ), + }; + }, + { readOnlyHint: true }, + ), + + tool( + "getScheduleOptionDetails", + "Get meeting-by-meeting details for a generated schedule option.", + { + type: "object", + required: ["optionId"], + properties: { optionId: { type: "string" } }, + }, + async ({ optionId }) => getDetails(candidateById(optionId)), + { readOnlyHint: true }, + ), + + tool( + "compareScheduleOptions", + "Compare two to five generated schedule options.", + { + type: "object", + required: ["optionIds"], + properties: { + optionIds: { + type: "array", + minItems: 2, + maxItems: 5, + items: { type: "string" }, + }, + }, + }, + async ({ optionIds }) => ({ + options: asArray(optionIds) + .map(candidateById) + .map((candidate) => getDetails(candidate).option), + }), + { readOnlyHint: true }, + ), + + tool( + "selectScheduleOption", + "Make a generated timetable option the visible selected schedule.", + { + type: "object", + required: ["optionId"], + properties: { optionId: { type: "string" } }, + }, + async ({ optionId }) => { + const candidate = candidateById(optionId); + if (!candidate) + throw new Error( + "That schedule option is no longer available. Search again.", + ); + const visible = rankVisibleCandidates( + candidatesRef.current, + preferencesRef.current, + ); + const visibleIndex = visible.findIndex( + (option) => option.id === optionId, + ); + if (visibleIndex < 0) + throw new Error( + "That option is not distinct in the current calendar view. Search again.", + ); + selectedIdRef.current = optionId; + stateRef.current.timetables = visible.map( + (option) => option.timetable, + ); + setTimetables(stateRef.current.timetables); + setCurrentTimetableIndex(visibleIndex); + syncSelectedTimetable(candidate.timetable); + return { + selectedOption: describeOption( + optionId, + candidate.timetable, + preferencesRef.current, + candidate.metrics, + ), + }; + }, + ), + + tool( + "getCurrentSchedule", + "Read the current schedule, preferences, and selected option.", + { type: "object", properties: {} }, + async () => { + ensureCandidates(); + const selected = + candidateById(selectedIdRef.current) || candidatesRef.current[0]; + return { + courses: stateRef.current.addedCourses, + timetableType: stateRef.current.timetableType, + term: stateRef.current.term, + preferences: preferencesRef.current, + selected: selected + ? describeOption( + selected.id, + selected.timetable, + preferencesRef.current, + selected.metrics, + ) + : null, + scheduleVersion: scheduleVersionRef.current, + }; + }, + { readOnlyHint: true }, + ), + + tool( + "exportSchedule", + "Download the selected schedule as an iCalendar (.ics) file or return its share link.", + { + type: "object", + properties: { + optionId: { type: "string" }, + format: { type: "string", enum: ["ics", "shareLink"] }, + }, + }, + async ({ optionId, format = "ics" }) => { + ensureCandidates(); + const candidate = + candidateById(optionId || selectedIdRef.current) || + candidatesRef.current[0]; + if (!candidate) throw new Error("There is no schedule to export."); + syncSelectedTimetable(candidate.timetable); + if (format === "shareLink") { + return { + exportedOptionId: candidate.id, + shareLink: window.location.href, + }; + } + updateExportData(candidate.timetable); + exportCal({ durationCount: stateRef.current.timetables.length }); + return { + exportedOptionId: candidate.id, + filename: "BrockTimetable.ics", + }; + }, + { consequentialHint: true }, + ), + ]; + + const controller = new AbortController(); + Promise.all( + tools.map((definition) => + modelContext.registerTool(definition, { signal: controller.signal }), + ), + ).catch((error) => { + console.warn("Unable to register WebMCP schedule tools:", error); + }); + return () => controller.abort(); + // Tool callbacks intentionally read ref-backed live state; re-registering + // on every timetable change would withdraw tools during agent execution. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + onTimeBlockChange, + setAddedCourses, + setCurrentTimetableIndex, + setDurations, + setSelectedDuration, + setTerm, + setTimetableType, + setTimetables, + ]); +} diff --git a/src/pages/GeneratorPage.jsx b/src/pages/GeneratorPage.jsx index bded671..5671f98 100644 --- a/src/pages/GeneratorPage.jsx +++ b/src/pages/GeneratorPage.jsx @@ -56,6 +56,7 @@ import eventBus from "@/lib/eventBus"; import FooterComponent from "@/components/sitewide/FooterComponent"; import ShareFeatureBanner from "@/components/sitewide/ShareFeatureBanner"; import { usePageMeta } from "@/lib/usePageMeta"; +import { useScheduleWebMcp } from "@/lib/webmcp/useScheduleWebMcp"; const conflictSignature = (info) => info @@ -128,6 +129,27 @@ function GeneratorPageContent() { const currentTimetable = timetables[currentTimetableIndex] ?? null; + // WebMCP is a progressive enhancement: this hook is inert in browsers that + // do not expose document.modelContext, while WebMCP-aware agents get the + // same state mutations and generated options as the visible application. + useScheduleWebMcp({ + timetables, + setTimetables, + addedCourses, + setAddedCourses, + timetableType, + setTimetableType, + term, + setTerm, + sortOption, + selectedDuration, + setSelectedDuration, + setDurations, + setCurrentTimetableIndex, + onTimeBlockChange, + courseColors, + }); + useEffect(() => { conflictInfoRef.current = conflictInfo; }, [conflictInfo]); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..b20d50b --- /dev/null +++ b/vercel.json @@ -0,0 +1,11 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "Origin-Agent-Cluster", "value": "?1" }, + { "key": "Permissions-Policy", "value": "tools=(self)" } + ] + } + ] +} diff --git a/vite.config.js b/vite.config.js index 4e04e20..b362d5e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,6 +8,18 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + server: { + headers: { + "Origin-Agent-Cluster": "?1", + "Permissions-Policy": "tools=(self)", + }, + }, + preview: { + headers: { + "Origin-Agent-Cluster": "?1", + "Permissions-Policy": "tools=(self)", + }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"),