-
{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)) {