diff --git a/web/src/lib/__tests__/course.test.ts b/web/src/lib/__tests__/course.test.ts new file mode 100644 index 0000000..66d033a --- /dev/null +++ b/web/src/lib/__tests__/course.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { formatMeetingTimeSummary } from "$lib/course"; +import type { CourseResponse, DbMeetingTime } from "$lib/api"; + +function makeMeetingTime(overrides: Partial = {}): DbMeetingTime { + return { + begin_time: null, + end_time: null, + start_date: "2025-01-13", + end_date: "2025-05-08", + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + sunday: false, + building: null, + building_description: null, + room: null, + campus: null, + meeting_type: "CLAS", + meeting_schedule_type: "LEC", + ...overrides, + }; +} + +function makeCourse(overrides: Partial = {}): CourseResponse { + return { + crn: "12345", + subject: "CS", + courseNumber: "1234", + title: "Test Course", + termCode: "202510", + sequenceNumber: null, + instructionalMethod: null, + campus: null, + enrollment: 10, + maxEnrollment: 30, + waitCount: 0, + waitCapacity: 0, + creditHours: 3, + creditHourLow: null, + creditHourHigh: null, + crossList: null, + crossListCapacity: null, + crossListCount: null, + linkIdentifier: null, + isSectionLinked: null, + partOfTerm: null, + meetingTimes: [], + attributes: [], + instructors: [], + ...overrides, + }; +} + +describe("formatMeetingTimeSummary", () => { + it("returns 'Async' for async online courses", () => { + const course = makeCourse({ + meetingTimes: [makeMeetingTime({ building: "INT" })], + }); + expect(formatMeetingTimeSummary(course)).toBe("Async"); + }); + + it("returns 'TBA' for courses with no meeting times", () => { + const course = makeCourse({ meetingTimes: [] }); + expect(formatMeetingTimeSummary(course)).toBe("TBA"); + }); + + it("returns 'TBA' when days and times are all TBA", () => { + const course = makeCourse({ + meetingTimes: [makeMeetingTime()], + }); + expect(formatMeetingTimeSummary(course)).toBe("TBA"); + }); + + it("returns formatted days and time for normal meeting", () => { + const course = makeCourse({ + meetingTimes: [ + makeMeetingTime({ + monday: true, + wednesday: true, + friday: true, + begin_time: "0900", + end_time: "0950", + }), + ], + }); + expect(formatMeetingTimeSummary(course)).toBe("MWF 9:00–9:50 AM"); + }); + + it("returns formatted days with TBA time", () => { + const course = makeCourse({ + meetingTimes: [ + makeMeetingTime({ + tuesday: true, + thursday: true, + }), + ], + }); + // Days are set but time is TBA — not both TBA, so it enters the final branch + expect(formatMeetingTimeSummary(course)).toBe("TTh TBA"); + }); +}); diff --git a/web/src/lib/__tests__/filters.test.ts b/web/src/lib/__tests__/filters.test.ts new file mode 100644 index 0000000..8a41fbb --- /dev/null +++ b/web/src/lib/__tests__/filters.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { parseTimeInput, formatTime, toggleDay, toggleValue } from "$lib/filters"; + +describe("parseTimeInput", () => { + it("parses AM time", () => { + expect(parseTimeInput("10:30 AM")).toBe("1030"); + }); + + it("parses PM time", () => { + expect(parseTimeInput("3:00 PM")).toBe("1500"); + }); + + it("parses 12:00 PM as noon", () => { + expect(parseTimeInput("12:00 PM")).toBe("1200"); + }); + + it("parses 12:00 AM as midnight", () => { + expect(parseTimeInput("12:00 AM")).toBe("0000"); + }); + + it("parses case-insensitive AM/PM", () => { + expect(parseTimeInput("9:15 am")).toBe("0915"); + expect(parseTimeInput("2:45 Pm")).toBe("1445"); + }); + + it("parses military time", () => { + expect(parseTimeInput("14:30")).toBe("1430"); + expect(parseTimeInput("9:05")).toBe("0905"); + }); + + it("returns null for empty string", () => { + expect(parseTimeInput("")).toBeNull(); + expect(parseTimeInput(" ")).toBeNull(); + }); + + it("returns null for non-time strings", () => { + expect(parseTimeInput("abc")).toBeNull(); + expect(parseTimeInput("hello world")).toBeNull(); + }); + + it("parses out-of-range military time (no validation beyond format)", () => { + // The regex matches but doesn't validate hour/minute ranges + expect(parseTimeInput("25:00")).toBe("2500"); + }); + + it("trims whitespace", () => { + expect(parseTimeInput(" 10:00 AM ")).toBe("1000"); + }); +}); + +describe("formatTime", () => { + it("formats morning time", () => { + expect(formatTime("0930")).toBe("9:30 AM"); + }); + + it("formats afternoon time", () => { + expect(formatTime("1500")).toBe("3:00 PM"); + }); + + it("formats noon", () => { + expect(formatTime("1200")).toBe("12:00 PM"); + }); + + it("formats midnight", () => { + expect(formatTime("0000")).toBe("12:00 AM"); + }); + + it("returns empty string for null", () => { + expect(formatTime(null)).toBe(""); + }); + + it("returns empty string for invalid length", () => { + expect(formatTime("12")).toBe(""); + expect(formatTime("123456")).toBe(""); + }); +}); + +describe("toggleDay", () => { + it("adds a day not in the list", () => { + expect(toggleDay(["monday"], "wednesday")).toEqual(["monday", "wednesday"]); + }); + + it("removes a day already in the list", () => { + expect(toggleDay(["monday", "wednesday"], "monday")).toEqual(["wednesday"]); + }); + + it("adds to empty list", () => { + expect(toggleDay([], "friday")).toEqual(["friday"]); + }); + + it("removes last day", () => { + expect(toggleDay(["monday"], "monday")).toEqual([]); + }); +}); + +describe("toggleValue", () => { + it("adds a value not in the array", () => { + expect(toggleValue(["OA"], "HB")).toEqual(["OA", "HB"]); + }); + + it("removes a value already in the array", () => { + expect(toggleValue(["OA", "HB"], "OA")).toEqual(["HB"]); + }); + + it("adds to empty array", () => { + expect(toggleValue([], "OA")).toEqual(["OA"]); + }); + + it("removes last value", () => { + expect(toggleValue(["OA"], "OA")).toEqual([]); + }); +}); diff --git a/web/src/lib/__tests__/scroll-fade.test.ts b/web/src/lib/__tests__/scroll-fade.test.ts new file mode 100644 index 0000000..4546add --- /dev/null +++ b/web/src/lib/__tests__/scroll-fade.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + FADE_DISTANCE, + FADE_PERCENT, + leftOpacity, + rightOpacity, + maskGradient, + type ScrollMetrics, +} from "$lib/scroll-fade"; + +describe("leftOpacity", () => { + it("returns 0 when scrollLeft is 0", () => { + expect(leftOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(0); + }); + + it("returns 1 when scrollLeft >= FADE_DISTANCE", () => { + expect(leftOpacity({ scrollLeft: FADE_DISTANCE, scrollWidth: 1000, clientWidth: 500 })).toBe(1); + expect( + leftOpacity({ scrollLeft: FADE_DISTANCE + 50, scrollWidth: 1000, clientWidth: 500 }) + ).toBe(1); + }); + + it("returns proportional value for partial scroll", () => { + const half = FADE_DISTANCE / 2; + expect(leftOpacity({ scrollLeft: half, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5); + }); +}); + +describe("rightOpacity", () => { + it("returns 0 when content fits (no scroll needed)", () => { + expect(rightOpacity({ scrollLeft: 0, scrollWidth: 500, clientWidth: 500 })).toBe(0); + }); + + it("returns 0 when scrolled to the end", () => { + expect(rightOpacity({ scrollLeft: 500, scrollWidth: 1000, clientWidth: 500 })).toBe(0); + }); + + it("returns 1 when far from the end", () => { + expect(rightOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(1); + }); + + it("returns proportional value near the end", () => { + const maxScroll = 500; // scrollWidth(1000) - clientWidth(500) + const remaining = FADE_DISTANCE / 2; + const scrollLeft = maxScroll - remaining; + expect(rightOpacity({ scrollLeft, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5); + }); +}); + +describe("maskGradient", () => { + it("returns full transparent-to-transparent gradient when no scroll", () => { + const metrics: ScrollMetrics = { scrollLeft: 0, scrollWidth: 500, clientWidth: 500 }; + const result = maskGradient(metrics); + // leftOpacity=0, rightOpacity=0 → leftEnd=0%, rightStart=100% + expect(result).toBe( + "linear-gradient(to right, transparent 0%, black 0%, black 100%, transparent 100%)" + ); + }); + + it("includes fade zones when scrolled to the middle", () => { + const metrics: ScrollMetrics = { + scrollLeft: FADE_DISTANCE, + scrollWidth: 1000, + clientWidth: 500, + }; + const result = maskGradient(metrics); + // leftOpacity=1 → leftEnd=FADE_PERCENT%, rightOpacity=1 → rightStart=100-FADE_PERCENT% + expect(result).toContain(`black ${FADE_PERCENT}%`); + expect(result).toContain(`black ${100 - FADE_PERCENT}%`); + }); +}); diff --git a/web/src/lib/components/AttributesPopover.svelte b/web/src/lib/components/AttributesPopover.svelte index 1afc11c..c8e1149 100644 --- a/web/src/lib/components/AttributesPopover.svelte +++ b/web/src/lib/components/AttributesPopover.svelte @@ -1,5 +1,6 @@ + + + +{#if open} + + +
+ + + +{/if} diff --git a/web/src/lib/components/CourseCard.svelte b/web/src/lib/components/CourseCard.svelte new file mode 100644 index 0000000..b17666a --- /dev/null +++ b/web/src/lib/components/CourseCard.svelte @@ -0,0 +1,67 @@ + + +
+ + + {#if expanded} +
+ +
+ {/if} +
diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index 99bccfe..254292e 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -47,6 +47,7 @@ import { ContextMenu, DropdownMenu } from "bits-ui"; import { flip } from "svelte/animate"; import { cubicOut } from "svelte/easing"; import { fade, slide } from "svelte/transition"; +import CourseCard from "./CourseCard.svelte"; import CourseDetail from "./CourseDetail.svelte"; import RichTooltip from "./RichTooltip.svelte"; import SimpleTooltip from "./SimpleTooltip.svelte"; @@ -241,6 +242,12 @@ const table = createSvelteTable({ }); +{#snippet emptyState()} +
+ No courses found. Try adjusting your filters. +
+{/snippet} + {#snippet columnVisibilityGroup( Group: typeof DropdownMenu.Group, GroupHeading: typeof DropdownMenu.GroupHeading, @@ -293,10 +300,50 @@ const table = createSvelteTable({ {/if} {/snippet} - + +
+ {#if loading && courses.length === 0} + {#each Array(skeletonRowCount) as _} +
+
+
+
+
+
+
+
+
+
+
+
+
+ {/each} + {:else if courses.length === 0 && !loading} + {@render emptyState()} + {:else} + {#each courses as course (course.crn)} +
+ toggleRow(course.crn)} + /> +
+ {/each} + {/if} +
+ + + +
- +
{#each table.getHeaderGroups() as headerGroup} - No courses found. Try adjusting your filters. + {@render emptyState()} diff --git a/web/src/lib/components/FilterChip.svelte b/web/src/lib/components/FilterChip.svelte index e53ae5a..a9a0800 100644 --- a/web/src/lib/components/FilterChip.svelte +++ b/web/src/lib/components/FilterChip.svelte @@ -10,7 +10,7 @@ let { + {#if expandedSection === "status"} +
+
+ Availability + +
+ +
+ + {#if ranges.waitCount.max > 0} + (v === 0 ? "Off" : String(v))} + /> + {:else} +
+ Max waitlist + No waitlisted courses +
+ {/if} +
+ {/if} +
+ + + + {#if expandedSection === "schedule"} +
+
+ Days of week +
+ {#each DAY_OPTIONS as { label, value } (value)} + + {/each} +
+
+ +
+ +
+ Time range +
+ { + timeStart = parseTimeInput(e.currentTarget.value); + e.currentTarget.value = formatTime(timeStart); + }} + class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" + /> + to + { + timeEnd = parseTimeInput(e.currentTarget.value); + e.currentTarget.value = formatTime(timeEnd); + }} + class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" + /> +
+
+
+ {/if} +
+ + + + {#if expandedSection === "attributes"} +
+ {#each attributeSections as { label, key, dataKey }, i (key)} + {#if i > 0} +
+ {/if} +
+ {label} +
+ {#each referenceData[dataKey] as item (item.code)} + {@const selected = getAttrSelected(key)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ + + + {#if expandedSection === "more"} +
+ + +
+ +
+ + +
+ +
+ + +
+ {/if} + + diff --git a/web/src/lib/components/NavBar.svelte b/web/src/lib/components/NavBar.svelte index c597723..23784de 100644 --- a/web/src/lib/components/NavBar.svelte +++ b/web/src/lib/components/NavBar.svelte @@ -1,6 +1,7 @@ -