diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index a7e954f..c295471 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -2,15 +2,17 @@ import type { CourseResponse } from "$lib/api"; import { abbreviateInstructor, - formatTime, formatMeetingDays, + formatTimeRange, formatLocation, getPrimaryInstructor, isMeetingTimeTBA, isTimeTBA, + formatMeetingTimesTooltip, } from "$lib/course"; import CourseDetail from "./CourseDetail.svelte"; import { fade, fly, slide } from "svelte/transition"; +import { flip } from "svelte/animate"; import { onMount } from "svelte"; import { OverlayScrollbars } from "overlayscrollbars"; import { themeStore } from "$lib/stores/theme.svelte"; @@ -48,6 +50,13 @@ let tableWrapper: HTMLDivElement = undefined!; let copiedCrn: string | null = $state(null); let copyTimeoutId: number | undefined; +// Collapse expanded row when the dataset changes to avoid stale detail rows +// and FLIP position calculation glitches from lingering expanded content +$effect(() => { + courses; // track dependency + expandedCrn = null; +}); + onMount(() => { const osInstance = OverlayScrollbars(tableWrapper, { overflow: { x: "scroll", y: "hidden" }, @@ -192,7 +201,7 @@ const columns: ColumnDef[] = [ accessorFn: (row) => { if (row.meetingTimes.length === 0) return ""; const mt = row.meetingTimes[0]; - return `${formatMeetingDays(mt)} ${formatTime(mt.begin_time)}`; + return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`; }, header: "Time", enableSorting: true, @@ -227,6 +236,7 @@ const table = createSvelteTable({ get data() { return courses; }, + getRowId: (row) => String(row.crn), columns, state: { get sorting() { @@ -438,8 +448,8 @@ const table = createSvelteTable({ {/each} - - {#if loading && courses.length === 0} + {#if loading && courses.length === 0} + {#each Array(5) as _} {#each table.getVisibleLeafColumns() as col} @@ -458,7 +468,9 @@ const table = createSvelteTable({ {/each} {/each} - {:else if courses.length === 0} + + {:else if courses.length === 0} + - {:else} - {#each table.getRowModel().rows as row (row.id)} - {@const course = row.original} + + {:else} + {#each table.getRowModel().rows as row, i (row.id)} + {@const course = row.original} + - {#if timeIsTBA(course)} - TBA - {:else} - {@const mt = - course.meetingTimes[0]} - {#if !isMeetingTimeTBA(mt)} - {formatMeetingDays( - mt, - )} - {" "} - {/if} - {#if !isTimeTBA(mt)} - {formatTime( - mt.begin_time, - )}–{formatTime( - mt.end_time, - )} - {:else} + + {#if timeIsTBA(course)} TBA + {:else} + {@const mt = + course.meetingTimes[0]} + + {#if !isMeetingTimeTBA(mt)} + {formatMeetingDays( + mt, + )} + {" "} + {/if} + {#if !isTimeTBA(mt)} + {formatTimeRange( + mt.begin_time, + mt.end_time, + )} + {:else} + TBA + {/if} + {#if course.meetingTimes.length > 1} + +{course + .meetingTimes + .length - + 1} + {/if} + {/if} - {/if} + {:else if colId === "location"} @@ -706,9 +739,9 @@ const table = createSvelteTable({ {/if} - {/each} - {/if} - + + {/each} + {/if} diff --git a/web/src/lib/components/SimpleTooltip.svelte b/web/src/lib/components/SimpleTooltip.svelte index 1359438..7b62631 100644 --- a/web/src/lib/components/SimpleTooltip.svelte +++ b/web/src/lib/components/SimpleTooltip.svelte @@ -24,7 +24,7 @@ let { {text} diff --git a/web/src/lib/course.test.ts b/web/src/lib/course.test.ts index 6aaac34..c3a2aca 100644 --- a/web/src/lib/course.test.ts +++ b/web/src/lib/course.test.ts @@ -1,14 +1,19 @@ import { describe, it, expect } from "vitest"; import { formatTime, + formatTimeRange, formatMeetingDays, + formatMeetingDaysVerbose, formatMeetingTime, + formatMeetingTimeTooltip, + formatMeetingTimesTooltip, abbreviateInstructor, formatCreditHours, getPrimaryInstructor, isMeetingTimeTBA, isTimeTBA, formatDate, + formatDateShort, formatMeetingDaysLong, } from "$lib/course"; import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api"; @@ -53,13 +58,13 @@ describe("formatMeetingDays", () => { formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true })) ).toBe("MWF"); }); - it("returns TR for tue/thu", () => { - expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR"); + it("returns TTh for tue/thu", () => { + expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TTh"); }); - it("returns empty string when no days", () => { - expect(formatMeetingDays(makeMeetingTime())).toBe(""); + it("returns MW for mon/wed", () => { + expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true }))).toBe("MW"); }); - it("returns all days", () => { + it("returns MTWThF for all weekdays", () => { expect( formatMeetingDays( makeMeetingTime({ @@ -68,16 +73,56 @@ describe("formatMeetingDays", () => { wednesday: true, thursday: true, friday: true, - saturday: true, - sunday: true, }) ) - ).toBe("MTWRFSU"); + ).toBe("MTWThF"); + }); + it("returns partial abbreviation for single day", () => { + expect(formatMeetingDays(makeMeetingTime({ monday: true }))).toBe("Mon"); + expect(formatMeetingDays(makeMeetingTime({ thursday: true }))).toBe("Thu"); + expect(formatMeetingDays(makeMeetingTime({ saturday: true }))).toBe("Sat"); + }); + it("concatenates codes for other multi-day combos", () => { + expect(formatMeetingDays(makeMeetingTime({ monday: true, friday: true }))).toBe("MF"); + expect(formatMeetingDays(makeMeetingTime({ tuesday: true, saturday: true }))).toBe("TSa"); + expect( + formatMeetingDays(makeMeetingTime({ wednesday: true, friday: true, sunday: true })) + ).toBe("WFSu"); + expect( + formatMeetingDays( + makeMeetingTime({ monday: true, tuesday: true, wednesday: true, thursday: true }) + ) + ).toBe("MTWTh"); + }); + it("returns empty string when no days", () => { + expect(formatMeetingDays(makeMeetingTime())).toBe(""); + }); +}); + +describe("formatTimeRange", () => { + it("elides AM when both times are AM", () => { + expect(formatTimeRange("0900", "0950")).toBe("9:00–9:50 AM"); + }); + it("elides PM when both times are PM", () => { + expect(formatTimeRange("1315", "1430")).toBe("1:15–2:30 PM"); + }); + it("keeps both markers when crossing noon", () => { + expect(formatTimeRange("1130", "1220")).toBe("11:30 AM–12:20 PM"); + }); + it("returns TBA for null begin", () => { + expect(formatTimeRange(null, "0950")).toBe("TBA"); + }); + it("returns TBA for null end", () => { + expect(formatTimeRange("0900", null)).toBe("TBA"); + }); + it("handles midnight and noon", () => { + expect(formatTimeRange("0000", "0050")).toBe("12:00–12:50 AM"); + expect(formatTimeRange("1200", "1250")).toBe("12:00–12:50 PM"); }); }); describe("formatMeetingTime", () => { - it("formats a standard meeting time", () => { + it("formats a standard meeting time with elided AM/PM", () => { expect( formatMeetingTime( makeMeetingTime({ @@ -88,7 +133,19 @@ describe("formatMeetingTime", () => { end_time: "0950", }) ) - ).toBe("MWF 9:00 AM–9:50 AM"); + ).toBe("MWF 9:00–9:50 AM"); + }); + it("keeps both markers when crossing noon", () => { + expect( + formatMeetingTime( + makeMeetingTime({ + tuesday: true, + thursday: true, + begin_time: "1130", + end_time: "1220", + }) + ) + ).toBe("TTh 11:30 AM–12:20 PM"); }); it("returns TBA when no days", () => { expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe( @@ -96,7 +153,7 @@ describe("formatMeetingTime", () => { ); }); it("returns days + TBA when no times", () => { - expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA"); + expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("Mon TBA"); }); }); @@ -244,3 +301,86 @@ describe("formatMeetingDaysLong", () => { expect(formatMeetingDaysLong(makeMeetingTime())).toBe(""); }); }); + +describe("formatDateShort", () => { + it("formats YYYY-MM-DD to short", () => { + expect(formatDateShort("2024-08-26")).toBe("Aug 26, 2024"); + }); + it("formats MM/DD/YYYY to short", () => { + expect(formatDateShort("12/12/2024")).toBe("Dec 12, 2024"); + }); + it("returns original for invalid", () => { + expect(formatDateShort("bad")).toBe("bad"); + }); +}); + +describe("formatMeetingDaysVerbose", () => { + it("returns plural for single day", () => { + expect(formatMeetingDaysVerbose(makeMeetingTime({ thursday: true }))).toBe("Thursdays"); + }); + it("joins two days with ampersand", () => { + expect(formatMeetingDaysVerbose(makeMeetingTime({ tuesday: true, thursday: true }))).toBe( + "Tuesdays & Thursdays" + ); + }); + it("uses Oxford-style ampersand for 3+ days", () => { + expect( + formatMeetingDaysVerbose(makeMeetingTime({ monday: true, wednesday: true, friday: true })) + ).toBe("Mondays, Wednesdays & Fridays"); + }); + it("returns empty string when no days", () => { + expect(formatMeetingDaysVerbose(makeMeetingTime())).toBe(""); + }); +}); + +describe("formatMeetingTimeTooltip", () => { + it("formats full tooltip with location and dates", () => { + const mt = makeMeetingTime({ + tuesday: true, + thursday: true, + begin_time: "1615", + end_time: "1730", + building_description: "Main Hall", + room: "2.206", + }); + expect(formatMeetingTimeTooltip(mt)).toBe( + "Tuesdays & Thursdays, 4:15–5:30 PM\nMain Hall 2.206, Aug 26, 2024 – Dec 12, 2024" + ); + }); + it("handles TBA days and times", () => { + expect(formatMeetingTimeTooltip(makeMeetingTime())).toBe("TBA\nAug 26, 2024 – Dec 12, 2024"); + }); + it("handles days with TBA times", () => { + expect(formatMeetingTimeTooltip(makeMeetingTime({ monday: true }))).toBe( + "Mondays, TBA\nAug 26, 2024 – Dec 12, 2024" + ); + }); +}); + +describe("formatMeetingTimesTooltip", () => { + it("returns TBA for empty array", () => { + expect(formatMeetingTimesTooltip([])).toBe("TBA"); + }); + it("joins multiple meetings with blank line", () => { + const mts = [ + makeMeetingTime({ + monday: true, + wednesday: true, + friday: true, + begin_time: "0900", + end_time: "0950", + }), + makeMeetingTime({ + thursday: true, + begin_time: "1300", + end_time: "1400", + building_description: "Lab", + room: "101", + }), + ]; + const result = formatMeetingTimesTooltip(mts); + expect(result).toContain("Mondays, Wednesdays & Fridays, 9:00–9:50 AM"); + expect(result).toContain("Thursdays, 1:00–2:00 PM\nLab 101"); + expect(result).toContain("\n\n"); + }); +}); diff --git a/web/src/lib/course.ts b/web/src/lib/course.ts index a402e3c..ae9511d 100644 --- a/web/src/lib/course.ts +++ b/web/src/lib/course.ts @@ -10,21 +10,29 @@ export function formatTime(time: string | null): string { return `${display}:${minutes} ${period}`; } -/** Get day abbreviation string like "MWF" from a meeting time */ +/** + * Compact day abbreviation for table cells. + * + * Single day → 3-letter: "Mon", "Thu" + * Multi-day → concatenated codes: "MWF", "TTh", "MTWTh", "TSa" + * + * Codes use single letters where unambiguous (M/T/W/F) and + * two letters where needed (Th/Sa/Su). + */ export function formatMeetingDays(mt: DbMeetingTime): string { - const days: [boolean, string][] = [ - [mt.monday, "M"], - [mt.tuesday, "T"], - [mt.wednesday, "W"], - [mt.thursday, "R"], - [mt.friday, "F"], - [mt.saturday, "S"], - [mt.sunday, "U"], + const dayDefs: [boolean, string, string][] = [ + [mt.monday, "M", "Mon"], + [mt.tuesday, "T", "Tue"], + [mt.wednesday, "W", "Wed"], + [mt.thursday, "Th", "Thu"], + [mt.friday, "F", "Fri"], + [mt.saturday, "Sa", "Sat"], + [mt.sunday, "Su", "Sun"], ]; - return days - .filter(([active]) => active) - .map(([, abbr]) => abbr) - .join(""); + const active = dayDefs.filter(([a]) => a); + if (active.length === 0) return ""; + if (active.length === 1) return active[0][2]; + return active.map(([, code]) => code).join(""); } /** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */ @@ -44,14 +52,38 @@ export function formatMeetingDaysLong(mt: DbMeetingTime): string { return active.map(([, short]) => short).join(", "); } -/** Condensed meeting time: "MWF 9:00 AM–9:50 AM" */ +/** + * Format a time range with smart AM/PM elision. + * + * Same period: "9:00–9:50 AM" + * Cross-period: "11:30 AM–12:20 PM" + * Missing: "TBA" + */ +export function formatTimeRange(begin: string | null, end: string | null): string { + if (!begin || begin.length !== 4 || !end || end.length !== 4) return "TBA"; + + const bHours = parseInt(begin.slice(0, 2), 10); + const eHours = parseInt(end.slice(0, 2), 10); + const bPeriod = bHours >= 12 ? "PM" : "AM"; + const ePeriod = eHours >= 12 ? "PM" : "AM"; + + const bDisplay = bHours > 12 ? bHours - 12 : bHours === 0 ? 12 : bHours; + const eDisplay = eHours > 12 ? eHours - 12 : eHours === 0 ? 12 : eHours; + + const endStr = `${eDisplay}:${end.slice(2)} ${ePeriod}`; + if (bPeriod === ePeriod) { + return `${bDisplay}:${begin.slice(2)}–${endStr}`; + } + return `${bDisplay}:${begin.slice(2)} ${bPeriod}–${endStr}`; +} + +/** Condensed meeting time: "MWF 9:00–9:50 AM" */ export function formatMeetingTime(mt: DbMeetingTime): string { const days = formatMeetingDays(mt); if (!days) return "TBA"; - const begin = formatTime(mt.begin_time); - const end = formatTime(mt.end_time); - if (begin === "TBA") return `${days} TBA`; - return `${days} ${begin}–${end}`; + const range = formatTimeRange(mt.begin_time, mt.end_time); + if (range === "TBA") return `${days} TBA`; + return `${days} ${range}`; } /** @@ -151,6 +183,84 @@ export function formatLocationLong(mt: DbMeetingTime): string | null { return mt.room ? `${name} ${mt.room}` : name; } +/** Format a date as "Aug 26, 2024". Accepts YYYY-MM-DD or MM/DD/YYYY. */ +export function formatDateShort(dateStr: string): string { + let year: number, month: number, day: number; + if (dateStr.includes("-")) { + [year, month, day] = dateStr.split("-").map(Number); + } else if (dateStr.includes("/")) { + [month, day, year] = dateStr.split("/").map(Number); + } else { + return dateStr; + } + if (!year || !month || !day) return dateStr; + const date = new Date(year, month - 1, day); + return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); +} + +/** + * Verbose day names for tooltips: "Tuesdays & Thursdays", "Mondays, Wednesdays & Fridays". + * Single day → plural: "Thursdays". + */ +export function formatMeetingDaysVerbose(mt: DbMeetingTime): string { + const dayDefs: [boolean, string][] = [ + [mt.monday, "Mondays"], + [mt.tuesday, "Tuesdays"], + [mt.wednesday, "Wednesdays"], + [mt.thursday, "Thursdays"], + [mt.friday, "Fridays"], + [mt.saturday, "Saturdays"], + [mt.sunday, "Sundays"], + ]; + const active = dayDefs.filter(([a]) => a).map(([, name]) => name); + if (active.length === 0) return ""; + if (active.length === 1) return active[0]; + return active.slice(0, -1).join(", ") + " & " + active[active.length - 1]; +} + +/** + * Full verbose tooltip for a single meeting time: + * "Tuesdays & Thursdays, 4:15–5:30 PM\nMain Hall 2.206 · Aug 26 – Dec 12, 2024" + */ +export function formatMeetingTimeTooltip(mt: DbMeetingTime): string { + const days = formatMeetingDaysVerbose(mt); + const range = formatTimeRange(mt.begin_time, mt.end_time); + let line1: string; + if (!days && range === "TBA") { + line1 = "TBA"; + } else if (!days) { + line1 = range; + } else if (range === "TBA") { + line1 = `${days}, TBA`; + } else { + line1 = `${days}, ${range}`; + } + + const parts = [line1]; + + const loc = formatLocationLong(mt); + const dateRange = + mt.start_date && mt.end_date + ? `${formatDateShort(mt.start_date)} – ${formatDateShort(mt.end_date)}` + : null; + + if (loc && dateRange) { + parts.push(`${loc}, ${dateRange}`); + } else if (loc) { + parts.push(loc); + } else if (dateRange) { + parts.push(dateRange); + } + + return parts.join("\n"); +} + +/** Full verbose tooltip for all meeting times on a course, newline-separated. */ +export function formatMeetingTimesTooltip(meetingTimes: DbMeetingTime[]): string { + if (meetingTimes.length === 0) return "TBA"; + return meetingTimes.map(formatMeetingTimeTooltip).join("\n\n"); +} + /** Format credit hours display */ export function formatCreditHours(course: CourseResponse): string { if (course.creditHours != null) return String(course.creditHours);