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",
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 ;
+}
+
+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"),