diff --git a/app/dev-grid.html b/app/dev-grid.html new file mode 100644 index 0000000..f24ff68 --- /dev/null +++ b/app/dev-grid.html @@ -0,0 +1,9 @@ + + + grid test + + +
+ + + diff --git a/app/src/client/components/ToolCards.tsx b/app/src/client/components/ToolCards.tsx index 6ba2ae9..e738d97 100644 --- a/app/src/client/components/ToolCards.tsx +++ b/app/src/client/components/ToolCards.tsx @@ -73,8 +73,23 @@ export function ToolRender({ view }: { view: ToolView }) {
{shown.map((c, i) => (
- {c.code} - {c.title} + {c.pcUrl ? ( + + {c.code} + {c.title} + + ) : ( + <> + {c.code} + {c.title} + + )} {c.meta && {c.meta}} {c.rating != null && ( {c.rating.toFixed(2)} diff --git a/app/src/client/components/WeekGrid.tsx b/app/src/client/components/WeekGrid.tsx index e6c4227..f0e9f5c 100644 --- a/app/src/client/components/WeekGrid.tsx +++ b/app/src/client/components/WeekGrid.tsx @@ -1,5 +1,5 @@ import type { Meeting, ScheduleView } from "../lib/tools"; -import { tjColor } from "../lib/tools"; +import { fmtRange, tjColor } from "../lib/tools"; const DAY_COLUMNS = [ ["Monday", "M"], @@ -9,7 +9,8 @@ const DAY_COLUMNS = [ ["Friday", "F"], ] as const; -const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri"]; +const DAY_LABELS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; +const DAY_LABELS_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri"]; const START = 8 * 60; // 8:00 AM @@ -81,7 +82,12 @@ export function WeekGrid({
 
@@ -100,7 +106,7 @@ export function WeekGrid({
{DAY_COLUMNS.map(([full, short], di) => (
-
{DAY_LABELS[di]}
+
{compact ? DAY_LABELS_SHORT[di] : DAY_LABELS[di]}
{layoutDay( schedule.meetings.filter((m) => onDay(m, full, short)) @@ -135,17 +141,16 @@ export function WeekGrid({ }} title={`${m.courseCode} ${m.label} · ${m.startLabel}–${m.endLabel}${m.room ? ` · ${m.room}` : ""}${m.confirmed ? "" : " · option — pick in TigerJunction"}`} > - {!compact && duration >= 60 && cols <= 2 && ( + {!compact && duration >= 45 && cols <= 2 && (
- {m.startLabel.replace(" ", "")}– - {m.endLabel.replace(" ", "")} + {fmtRange(m.startMin, m.endMin)}
)}
{m.courseCode} {!compact && cols <= 3 ? ` ${m.label}` : ""}
- {!compact && duration >= 80 && cols === 1 && m.room && ( + {!compact && duration >= 75 && cols === 1 && m.room && (
{m.room}
)}
diff --git a/app/src/client/dev-grid.tsx b/app/src/client/dev-grid.tsx new file mode 100644 index 0000000..88f664c --- /dev/null +++ b/app/src/client/dev-grid.tsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import { WeekGrid } from "./components/WeekGrid"; +import { extractSchedule } from "./lib/tools"; +import fixture from "./real-fixture.json"; +import "./styles.css"; + +const view = extractSchedule(fixture)!; +createRoot(document.getElementById("root")!).render( +
+); diff --git a/app/src/client/lib/tools.ts b/app/src/client/lib/tools.ts index 6ed5c89..da27ced 100644 --- a/app/src/client/lib/tools.ts +++ b/app/src/client/lib/tools.ts @@ -156,16 +156,36 @@ export function tjColor(index: number, darken = 0): string { export function parseTime(label: unknown): number | null { if (typeof label !== "string") return null; - const m = label.trim().match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i); + // Fractional minutes appear when the engine mis-decodes TigerJunction's + // 10-minute time units (see decodeCompressedTimes below). + const m = label.trim().match(/^(\d{1,2}):(\d{2}(?:\.\d+)?)\s*(AM|PM)$/i); if (!m) return null; let hour = parseInt(m[1], 10); - const minute = parseInt(m[2], 10); + const minute = parseFloat(m[2]); const ampm = m[3].toUpperCase(); if (ampm === "PM" && hour !== 12) hour += 12; if (ampm === "AM" && hour === 12) hour = 0; return hour * 60 + minute; } +/** Minutes-since-midnight → "1:05 PM". */ +export function fmtTime(min: number): string { + const h24 = Math.floor(min / 60); + const m = Math.round(min % 60); + const h12 = h24 % 12 || 12; + return `${h12}:${String(m).padStart(2, "0")} ${h24 >= 12 ? "PM" : "AM"}`; +} + +/** Minutes → TJ-style in-block range label, "12:15-1:05". */ +export function fmtRange(start: number, end: number): string { + const short = (min: number) => { + const h24 = Math.floor(min / 60); + const m = Math.round(min % 60); + return `${h24 % 12 || 12}:${String(m).padStart(2, "0")}`; + }; + return `${short(start)}-${short(end)}`; +} + /** * Recognizes get_schedule_details / verify_schedule–shaped payloads. * @@ -218,6 +238,33 @@ export function extractSchedule(data: unknown): ScheduleView | null { } if (raw.length === 0) return null; + // The deployed engine renders TigerJunction's Supabase times as if the + // stored value were minutes-past-8am, but junction actually stores + // 10-minute units (web convert.ts: hour = value/6 + 8). The tell: every + // "time" lands before 10 AM, often with fractional minutes. Undo it: + // real = (mislabeled - 480) * 10 + 480. Harmless once the engine is + // fixed, because genuine schedules never trip the detector. + const timed = raw.filter((r) => r.startMin != null); + const compressed = + timed.length > 0 && + timed.every( + (r) => r.startMin! < 600 && (r.endMin == null || r.endMin < 600) + ); + if (compressed) { + for (const r of timed) { + r.startMin = Math.round((r.startMin! - 480) * 10 + 480); + if (r.endMin != null) r.endMin = Math.round((r.endMin - 480) * 10 + 480); + r.startLabel = fmtTime(r.startMin); + if (r.endMin != null) r.endLabel = fmtTime(r.endMin); + } + } else { + // Normalize any fractional minutes either way. + for (const r of timed) { + r.startMin = Math.round(r.startMin!); + if (r.endMin != null) r.endMin = Math.round(r.endMin); + } + } + // One palette color per course, in order of first appearance. const colorOf = new Map(); for (const r of raw) { @@ -342,8 +389,31 @@ export type CourseRowData = { status?: string; rating?: number | null; meta?: string; + /** Deep link to this offering on PrincetonCourses, when derivable. */ + pcUrl?: string; }; +const PC_HOME = PI_APPS.find((a) => a.key === "princetoncourses")!.home; + +/** + * PrincetonCourses keys courses by registrar guid = term + courseID + * (e.g. 1264002051). The engine hands us "002051-1264" ids or + * listingId + term pairs. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function pcCourseUrl(c: any): string | undefined { + if (typeof c.id === "string") { + const m = c.id.match(/^(\d{6})-(\d{4})$/); + if (m) return `${PC_HOME}/course/${m[2]}${m[1]}`; + } + const listing = c.listingId ?? c.listing_id; + const term = typeof c.term === "number" ? c.term : undefined; + if (typeof listing === "string" && /^\d{6}$/.test(listing) && term) { + return `${PC_HOME}/course/${term}${listing}`; + } + return undefined; +} + export function extractCourses(data: unknown): CourseRowData[] | null { if (data == null || typeof data !== "object") return null; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -379,6 +449,7 @@ export function extractCourses(data: unknown): CourseRowData[] | null { status: c.status ? String(c.status) : undefined, rating, meta: bits.join(" · ") || undefined, + pcUrl: pcCourseUrl(c), }); } return rows.length > 0 ? rows : null; diff --git a/app/src/client/pages/AppsPage.tsx b/app/src/client/pages/AppsPage.tsx index 6770daa..620a563 100644 --- a/app/src/client/pages/AppsPage.tsx +++ b/app/src/client/pages/AppsPage.tsx @@ -46,6 +46,9 @@ export function AppsPage({ if (err) return { text: `couldn't connect — ${err}`, cls: "status err" }; const server = desk.mcp?.servers?.[key]; if (!settings.apps.includes(key)) return { text: "off", cls: "status" }; + if (key === "princetoncourses" && settings.apps.includes("junction")) { + return { text: "covered by TigerJunction", cls: "status on" }; + } if (server?.state === "ready" || server?.state === "connected") { return { text: "connected", cls: "status on" }; } diff --git a/app/src/client/real-fixture.json b/app/src/client/real-fixture.json new file mode 100644 index 0000000..71d0117 --- /dev/null +++ b/app/src/client/real-fixture.json @@ -0,0 +1,318 @@ +{ + "schedule": { + "id": 68717, + "title": "My Schedule", + "term": 1272, + "termName": "Fall 2026" + }, + "courses": [ + { + "id": 24503, + "code": "COS226", + "title": "Algorithms and Data Structures", + "status": "open" + }, + { + "id": 24508, + "code": "COS326", + "title": "Functional Programming and Formal Methods", + "status": "open" + }, + { + "id": 24509, + "code": "COS330", + "title": "Great Ideas in Theoretical Computer Science", + "status": "open" + }, + { + "id": 24515, + "code": "COS424", + "title": "Reasoning with Data", + "status": "open" + }, + { + "id": 25354, + "code": "VIS218", + "title": "Graphic Design: Image", + "status": "open" + } + ], + "sections": [ + { + "courseCode": "COS424", + "sectionTitle": "P04", + "days": [ + "F" + ], + "startTime": "8:25.5 AM", + "endTime": "8:30.5 AM", + "room": "Friend Center 112", + "status": "closed" + }, + { + "courseCode": "COS424", + "sectionTitle": "P03", + "days": [ + "F" + ], + "startTime": "8:25.5 AM", + "endTime": "8:30.5 AM", + "room": "Friend Center 111", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "L01", + "days": [ + "T", + "Th" + ], + "startTime": "8:32 AM", + "endTime": "8:40 AM", + "room": "Friend Center 101", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P01", + "days": [ + "Th" + ], + "startTime": "8:41.5 AM", + "endTime": "8:49.5 AM", + "room": "Andlinger Center 017", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P02", + "days": [ + "F" + ], + "startTime": "8:16 AM", + "endTime": "8:24 AM", + "room": "Andlinger Center 017", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P04", + "days": [ + "F" + ], + "startTime": "8:41.5 AM", + "endTime": "8:49.5 AM", + "room": "Friend Center 112", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P01A", + "days": [ + "Th" + ], + "startTime": "8:41.5 AM", + "endTime": "8:49.5 AM", + "room": "Friend Center 111", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P02A", + "days": [ + "F" + ], + "startTime": "8:16 AM", + "endTime": "8:24 AM", + "room": "Friend Center 006", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P02B", + "days": [ + "F" + ], + "startTime": "8:16 AM", + "endTime": "8:24 AM", + "room": null, + "status": "canceled" + }, + { + "courseCode": "COS226", + "sectionTitle": "P03", + "days": [ + "F" + ], + "startTime": "8:32 AM", + "endTime": "8:40 AM", + "room": "Friend Center 108", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P03A", + "days": [ + "F" + ], + "startTime": "8:32 AM", + "endTime": "8:40 AM", + "room": "Friend Center 111", + "status": "open" + }, + { + "courseCode": "COS226", + "sectionTitle": "P05", + "days": [ + "F" + ], + "startTime": "8:16 AM", + "endTime": "8:24 AM", + "room": "Friend Center 009", + "status": "open" + }, + { + "courseCode": "COS330", + "sectionTitle": "L01", + "days": [ + "M", + "W" + ], + "startTime": "8:16 AM", + "endTime": "8:24 AM", + "room": "McCosh Hall 46", + "status": "open" + }, + { + "courseCode": "COS326", + "sectionTitle": "L01", + "days": [ + "T", + "Th" + ], + "startTime": "8:16 AM", + "endTime": "8:21 AM", + "room": "Engineering Quad D-Wing D221", + "status": "open" + }, + { + "courseCode": "COS326", + "sectionTitle": "P01", + "days": [ + "Th" + ], + "startTime": "8:25.5 AM", + "endTime": "8:30.5 AM", + "room": "Friend Center 108", + "status": "open" + }, + { + "courseCode": "COS326", + "sectionTitle": "P02", + "days": [ + "F" + ], + "startTime": "8:25.5 AM", + "endTime": "8:30.5 AM", + "room": "Sherrerd Hall 001", + "status": "open" + }, + { + "courseCode": "COS330", + "sectionTitle": "P01", + "days": [ + "Th" + ], + "startTime": "8:32 AM", + "endTime": "8:37 AM", + "room": "Friend Center 108", + "status": "open" + }, + { + "courseCode": "COS330", + "sectionTitle": "P02", + "days": [ + "F" + ], + "startTime": "8:32 AM", + "endTime": "8:37 AM", + "room": "Friend Center 109", + "status": "open" + }, + { + "courseCode": "COS330", + "sectionTitle": "P03", + "days": [ + "F" + ], + "startTime": "8:32 AM", + "endTime": "8:37 AM", + "room": "Friend Center 009", + "status": "open" + }, + { + "courseCode": "COS424", + "sectionTitle": "L01", + "days": [ + "T", + "Th" + ], + "startTime": "8:41.5 AM", + "endTime": "8:49.5 AM", + "room": "Engineering Quad E-Wing E225", + "status": "open" + }, + { + "courseCode": "COS424", + "sectionTitle": "P01", + "days": [ + "F" + ], + "startTime": "8:16 AM", + "endTime": "8:21 AM", + "room": "Friend Center 111", + "status": "open" + }, + { + "courseCode": "COS424", + "sectionTitle": "P02", + "days": [ + "F" + ], + "startTime": "8:16 AM", + "endTime": "8:21 AM", + "room": "Friend Center 110", + "status": "closed" + }, + { + "courseCode": "VIS218", + "sectionTitle": "U01", + "days": [ + "M" + ], + "startTime": "8:33 AM", + "endTime": "8:50 AM", + "room": "Nassau Street, 185 205", + "status": "open" + } + ], + "conflicts": [ + "COS424 (P04) overlaps with COS326 (P02)", + "COS424 (P03) overlaps with COS326 (P02)", + "COS226 (L01) overlaps with COS330 (P01)", + "COS226 (P01) overlaps with COS424 (L01)", + "COS226 (P02) overlaps with COS424 (P01)", + "COS226 (P02) overlaps with COS424 (P02)", + "COS226 (P01A) overlaps with COS424 (L01)", + "COS226 (P02A) overlaps with COS424 (P01)", + "COS226 (P02A) overlaps with COS424 (P02)", + "COS226 (P02B) overlaps with COS424 (P01)", + "COS226 (P02B) overlaps with COS424 (P02)", + "COS226 (P03) overlaps with COS330 (P02)", + "COS226 (P03) overlaps with COS330 (P03)", + "COS226 (P03A) overlaps with COS330 (P02)", + "COS226 (P03A) overlaps with COS330 (P03)", + "COS226 (P05) overlaps with COS424 (P01)", + "COS226 (P05) overlaps with COS424 (P02)" + ] +} diff --git a/app/src/client/styles.css b/app/src/client/styles.css index c0e5dd5..ef5b148 100644 --- a/app/src/client/styles.css +++ b/app/src/client/styles.css @@ -625,6 +625,15 @@ textarea { .course-row:last-child { border-bottom: none; } +.course-row .course-link { + display: contents; + color: inherit; + text-decoration: none; +} +.course-row .course-link:hover .ctitle { + text-decoration: underline; + color: var(--pi-deep); +} .course-row .code { font-family: var(--mono); font-size: 12.5px; @@ -684,10 +693,7 @@ textarea { --rows: 13; display: grid; grid-template-columns: 46px repeat(5, 1fr); - gap: 0 6px; -} -.week.compact { - --week-h: 320px; + gap: 0; } .week .axis-col { position: relative; @@ -707,14 +713,21 @@ textarea { .week .day-name { text-align: center; font-family: var(--hand); - font-size: 16px; + font-size: 18px; + font-weight: 600; color: var(--ink-soft); - padding-bottom: 6px; + padding-bottom: 8px; + white-space: nowrap; + overflow: hidden; +} +.week.compact .day-name { + font-size: 15px; } .week .day-col { position: relative; height: var(--week-h); border-top: 1px solid var(--rule); + border-left: 1px solid var(--rule-soft); background-image: repeating-linear-gradient( to bottom, transparent 0, @@ -723,18 +736,26 @@ textarea { var(--rule-soft) calc(var(--week-h) / var(--rows)) ); } +.week .day:last-child .day-col { + border-right: 1px solid var(--rule-soft); +} -/* TigerJunction-style calendar block: solid color, 3px ink left border */ +/* TigerJunction-style calendar block: solid color, 4px ink left border */ .block { position: absolute; - border-radius: 4px; - border-left: 3px solid; - padding: 2px 6px; + border-radius: 3px; + border-left: 4px solid; + padding: 3px 7px; overflow: hidden; - font-size: 11px; - line-height: 1.25; + font-size: 12px; + line-height: 1.3; animation: block-in 0.25s ease both; - box-shadow: 0 1px 2px rgba(28, 36, 64, 0.1); + box-shadow: 0 1px 2px rgba(28, 36, 64, 0.08); +} +.week.compact .block { + padding: 1px 5px; + border-left-width: 3px; + border-radius: 2px; } @keyframes block-in { from { @@ -747,17 +768,20 @@ textarea { } } .block .btime { - font-size: 9.5px; - font-weight: 300; + font-size: 10.5px; + font-weight: 400; opacity: 0.85; white-space: nowrap; } .block .bcode { font-family: var(--mono); - font-size: 10.5px; + font-size: 11.5px; font-weight: 700; white-space: nowrap; } +.week.compact .block .bcode { + font-size: 10px; +} .block .bwhere { opacity: 0.75; font-size: 10px; diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts index 9790e07..9d2e8dd 100644 --- a/app/src/server/pi.ts +++ b/app/src/server/pi.ts @@ -96,6 +96,9 @@ export class Pi extends Think { const appErrors: Partial> = {}; const enabled = new Set(settings.apps); + // The princetoncourses MCP scope is a strict subset of junction's, so + // connecting both would register every shared tool twice. Junction wins. + if (enabled.has("junction")) enabled.delete("princetoncourses"); const base = this.engineBase(); for (const [id, server] of Object.entries(this.getMcpServers().servers)) {