Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/engine/src/mcp/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// MCP response helpers — convert DB-encoded values to human-readable strings

const DAY_BITS: [number, string][] = [
[1, "M"],
[2, "T"],
[4, "W"],
[8, "Th"],
[16, "F"],
];

const NULL_TIME = -420;

/**
* Convert a bitmask day value to an array of day abbreviations.
* M=1, T=2, W=4, Th=8, F=16
*/
export function valueToDays(value: number): string[] {
return DAY_BITS.filter(([bit]) => (value & bit) !== 0).map(([, day]) => day);
}

/**
* Convert a minutes-since-8AM time value to a 12-hour time string.
* NULL_TIME (-420) returns "TBA".
*/
export function valueToTime(value: number): string {
if (value === NULL_TIME) return "TBA";
const totalMinutes = value + 480; // offset to minutes since midnight
const hour24 = Math.floor(totalMinutes / 60);
const minute = totalMinutes % 60;
const hour12 = hour24 % 12 || 12;
const ampm = hour24 >= 12 ? "PM" : "AM";
return `${hour12}:${minute.toString().padStart(2, "0")} ${ampm}`;
}

/**
* Convert a raw section record's encoded days/times to human-readable fields,
* preserving all other fields on the object.
*/
export function formatSection<T extends { days: number; startTime: number; endTime: number }>(
section: T
): Omit<T, "days" | "startTime" | "endTime"> & {
days: string[];
startTime: string;
endTime: string;
} {
const { days, startTime, endTime, ...rest } = section;
return {
...rest,
days: valueToDays(days),
startTime: valueToTime(startTime),
endTime: valueToTime(endTime),
};
}
5 changes: 4 additions & 1 deletion apps/engine/src/mcp/tools/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { z } from "zod";
import { eq, ilike, sql, asc, and } from "drizzle-orm";
import * as schema from "../../db/schema.js";
import { formatSection } from "../helpers.js";

export function registerCourseTools(server: McpServer, db: NodePgDatabase) {
server.tool(
Expand Down Expand Up @@ -128,11 +129,13 @@ export function registerCourseTools(server: McpServer, db: NodePgDatabase) {
.where(eq(schema.sections.courseId, targetId))
.orderBy(asc(schema.sections.id));

const formatted = sections.map(formatSection);

return {
content: [
{
type: "text" as const,
text: JSON.stringify({ courseId: targetId, count: sections.length, sections }, null, 2),
text: JSON.stringify({ courseId: targetId, count: formatted.length, sections: formatted }, null, 2),
},
],
};
Expand Down
3 changes: 2 additions & 1 deletion apps/engine/src/mcp/tools/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { z } from "zod";
import { eq, ilike, and, asc, sql } from "drizzle-orm";
import * as schema from "../../db/schema.js";
import { formatSection } from "../helpers.js";

interface TimeSlot {
days: number;
Expand Down Expand Up @@ -125,7 +126,7 @@ export function registerScheduleTools(server: McpServer, db: NodePgDatabase) {
{
schedule: schedule[0],
courses: courseMappings,
sections: allSections,
sections: allSections.map(formatSection),
conflicts: conflicts.length > 0 ? conflicts : "No conflicts detected",
},
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,16 @@

// Delete old calendar (if exists)
if (curCal && curCal.length > 0) {
const oldId = curCal[0].id;

await supabase.storage
.from("calendars")
.remove([`${oldId}.ics`]);

const { error: supabaseError3 } = await supabase
.from("icals")
.delete()
.eq("id", curCal[0].id);
.eq("id", oldId);

if (supabaseError3) {
console.log(supabaseError3);
Expand Down
205 changes: 112 additions & 93 deletions apps/web/src/lib/functions/refreshCals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,85 +22,104 @@ export async function handler() {

console.log("Getting calendars...");
let startTime = Date.now();
const { data, error } = await supabase

// Step 1: Fetch all ical records (lightweight query)
const { data: icals, error: icalsError } = await supabase
.from("icals")
.select(
`
*,
schedules (id, term,
.select("id, schedule_id")
.not("schedule_id", "is", null);

if (icalsError) {
console.error("Error fetching icals:", icalsError);
return { statusCode: 500, body: JSON.stringify(icalsError) };
}

if (!icals || icals.length === 0) {
console.log("No calendars found.");
return { statusCode: 200, body: "No calendars to refresh." };
}

console.log(`Found ${icals.length} ical records in ${(Date.now() - startTime) / 1000}s`);

// Step 2: Fetch schedules with course data in batches by schedule ID
const scheduleIds = [...new Set(icals.map(i => i.schedule_id).filter(Boolean))] as number[];
const scheduleMap = new Map<number, any>();
const BATCH_SIZE = 30;

for (let i = 0; i < scheduleIds.length; i += BATCH_SIZE) {
const batch = scheduleIds.slice(i, i + BATCH_SIZE);
const { data: schedules, error: schedError } = await supabase
.from("schedules")
.select(`
id, term,
course_schedule_associations ( metadata->confirms,
courses (code, title, instructors,
sections (
start_time, end_time, days, title, room
)
)
)
)
`
)
.not("schedule_id", "is", null);
`)
.in("id", batch);

if (error) {
console.error(error);
return new Response(JSON.stringify(error), { status: 500 });
}
if (schedError) {
console.error(`Error fetching schedules batch at ${i}:`, schedError);
continue;
}

if (!data) {
console.error("No data found.");
return new Response("No data found.", { status: 404 });
if (schedules) {
for (const s of schedules) {
scheduleMap.set(s.id, s);
}
}
}

console.log(
"Found " +
data.length +
" calendars in " +
(Date.now() - startTime) / 1000 +
"s"
`Fetched ${scheduleMap.size} schedules in ${(Date.now() - startTime) / 1000}s`
);
const upsertPromises: Promise<unknown>[] = [];

// Build combined data matching original shape
const data = icals
.map(ical => ({
...ical,
schedules: scheduleMap.get(ical.schedule_id!) ?? null
}))
.filter(d => d.schedules !== null);

// Step 3: Generate ICS content for each calendar (CPU-only, no I/O)
startTime = Date.now();
// Loop through each schedule
for (let i = 0; i < data.length; i++) {
const events: EventAttributes[] = [];
if (!data[i] || !data[i].schedules) {
continue;
}
const uploads: { id: string; content: string }[] = [];
let errorCount = 0;

const schedule = data[i].schedules;
if (!schedule) {
console.error("No schedule found for calendar " + data[i].id);
continue;
}
for (const cal of data) {
if (!cal?.schedules) continue;

const schedule = cal.schedules;
const term = schedule.term.toString();
const calInfo = CALENDAR_INFO[term];

if (!calInfo) {
console.warn(`No calendar info for term ${term}, skipping ${cal.id}`);
continue;
}

const events: EventAttributes[] = [];
const courses = schedule.course_schedule_associations.map(
(csa: { courses: any; confirms: any }) => {
return {
title: csa.courses.title,
code: csa.courses.code,
instructors: csa.courses.instructors
? "Instructors: " + csa.courses.instructors.join(", ")
: "",
sections: csa.courses.sections.filter(
(section: { title: string }) => {
return Object.values(csa.confirms).includes(
section.title
);
}
)
};
}
(csa: { courses: any; confirms: any }) => ({
title: csa.courses.title,
code: csa.courses.code,
instructors: csa.courses.instructors
? "Instructors: " + csa.courses.instructors.join(", ")
: "",
sections: csa.courses.sections.filter(
(section: { title: string }) =>
Object.values(csa.confirms).includes(section.title)
)
})
);

// Loop through each course
for (let j = 0; j < courses.length; j++) {
const course = courses[j];

// Loop through each section
for (let k = 0; k < course.sections.length; k++) {
const section = course.sections[k];
for (const course of courses) {
for (const section of course.sections) {
const dur = section.end_time - section.start_time;
const description =
course.instructors !== ""
Expand Down Expand Up @@ -141,57 +160,57 @@ export async function handler() {
}

if (events.length === 0) {
upsertPromises.push(
supabase.storage
.from("calendars")
.update(data[i].id + ".ics", "", {
cacheControl: "900",
upsert: true,
contentType: "text/calendar"
})
);
uploads.push({ id: cal.id, content: "" });
continue;
}

createEvents(events, async (error, value) => {
if (error) {
console.error(error);
return new Response(JSON.stringify(error), { status: 500 });
}
const { error: icsError, value } = createEvents(events);
if (icsError || !value) {
console.error(`Failed to create ics for calendar ${cal.id}:`, icsError);
errorCount++;
continue;
}

// Append time zone info to start time
value = value.replace(/DTSTART/g, "DTSTART;TZID=America/New_York");
uploads.push({
id: cal.id,
content: value.replace(/DTSTART/g, "DTSTART;TZID=America/New_York")
});
}

console.log(
`Generated ${uploads.length} calendars in ${(Date.now() - startTime) / 1000}s`
);

// Push to supabase storage
upsertPromises.push(
// Step 4: Upload in controlled batches (deferred execution)
const CONCURRENT_UPLOADS = 10;
for (let i = 0; i < uploads.length; i += CONCURRENT_UPLOADS) {
const batch = uploads.slice(i, i + CONCURRENT_UPLOADS);
const results = await Promise.all(
batch.map(({ id, content }) =>
supabase.storage
.from("calendars")
.update(data[i].id + ".ics", value, {
.update(id + ".ics", content, {
cacheControl: "900",
upsert: true,
contentType: "text/calendar"
})
);
});
}
console.log(
"Generated " +
upsertPromises.length +
" calendars in " +
(Date.now() - startTime) / 1000 +
"s"
);
)
);

const CONCURRENT_REQUESTS = 10;
for (let i = 0; i < upsertPromises.length; i += CONCURRENT_REQUESTS) {
await Promise.all(upsertPromises.slice(i, i + CONCURRENT_REQUESTS));
for (let j = 0; j < results.length; j++) {
if (results[j].error) {
console.error(`Upload failed for ${batch[j].id}:`, results[j].error);
errorCount++;
}
}
}

console.log(
"Processed " +
data.length +
" calendars in " +
(Date.now() - startTime) / 1000 +
"s"
`Processed ${data.length} calendars (${errorCount} errors) in ${(Date.now() - startTime) / 1000}s`
);

return {
statusCode: 200,
body: `Refreshed ${data.length - errorCount}/${data.length} calendars`
};
}
6 changes: 6 additions & 0 deletions apps/web/sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export default {
job: {
function: {
handler: "src/lib/functions/refreshCals.handler",
timeout: "5 minutes",
memorySize: 512,
environment: {
PUBLIC_SUPABASE_URL: process.env.PUBLIC_SUPABASE_URL ?? "",
SERVICE_KEY: process.env.SERVICE_KEY ?? "",
},
}
}
});
Expand Down
Loading
Loading