mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 10:23:39 -06:00
feat: sync RMP professor ratings and display in course search interface
This commit is contained in:
@@ -1,87 +1,201 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
formatTime,
|
||||
formatMeetingDays,
|
||||
formatCreditHours,
|
||||
} from "$lib/course";
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
formatTime,
|
||||
formatCreditHours,
|
||||
formatDate,
|
||||
formatMeetingDaysLong,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
} from "$lib/course";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import { Info, Copy, Check } from "@lucide/svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let copiedEmail: string | null = $state(null);
|
||||
|
||||
async function copyEmail(email: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
await navigator.clipboard.writeText(email);
|
||||
copiedEmail = email;
|
||||
setTimeout(() => {
|
||||
copiedEmail = null;
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-muted p-4 text-sm border-b border-border">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-muted/60 p-5 text-sm border-b border-border">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<!-- Instructors -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Instructors</h4>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
Instructors
|
||||
</h4>
|
||||
{#if course.instructors.length > 0}
|
||||
<ul class="space-y-0.5">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each course.instructors as instructor}
|
||||
<li class="text-muted-foreground">
|
||||
{instructor.displayName}
|
||||
{#if instructor.isPrimary}
|
||||
<span class="text-xs bg-card border border-border rounded px-1 py-0.5 ml-1">primary</span>
|
||||
{/if}
|
||||
{#if instructor.email}
|
||||
<span class="text-xs"> — {instructor.email}</span>
|
||||
{/if}
|
||||
</li>
|
||||
<Tooltip.Root delayDuration={200}>
|
||||
<Tooltip.Trigger>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
|
||||
>
|
||||
{instructor.displayName}
|
||||
{#if 'rmpRating' in instructor && instructor.rmpRating}
|
||||
{@const rating = instructor.rmpRating as number}
|
||||
<span
|
||||
class="text-[10px] font-semibold {rating >= 4.0 ? 'text-status-green' : rating >= 3.0 ? 'text-yellow-500' : 'text-status-red'}"
|
||||
>{rating.toFixed(1)}★</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<div class="font-medium">{instructor.displayName}</div>
|
||||
{#if instructor.isPrimary}
|
||||
<div class="text-muted-foreground">Primary instructor</div>
|
||||
{/if}
|
||||
{#if 'rmpRating' in instructor && instructor.rmpRating}
|
||||
<div class="text-muted-foreground">
|
||||
{(instructor.rmpRating as number).toFixed(1)}/5 ({(instructor as any).rmpNumRatings ?? 0} ratings)
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.email}
|
||||
<button
|
||||
onclick={(e) => copyEmail(instructor.email!, e)}
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{#if copiedEmail === instructor.email}
|
||||
<Check class="size-3" />
|
||||
<span>Copied!</span>
|
||||
{:else}
|
||||
<Copy class="size-3" />
|
||||
<span>{instructor.email}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Staff</span>
|
||||
<span class="text-muted-foreground italic">Staff</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Meeting Times -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Meeting Times</h4>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
Meeting Times
|
||||
</h4>
|
||||
{#if course.meetingTimes.length > 0}
|
||||
<ul class="space-y-1">
|
||||
<ul class="space-y-2">
|
||||
{#each course.meetingTimes as mt}
|
||||
<li class="text-muted-foreground">
|
||||
<span class="font-mono">{formatMeetingDays(mt) || "TBA"}</span>
|
||||
{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}
|
||||
{#if mt.building || mt.room}
|
||||
<span class="text-xs">
|
||||
({mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""})
|
||||
</span>
|
||||
<li>
|
||||
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
|
||||
<span class="italic text-muted-foreground">TBA</span>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
<span class="font-medium text-foreground">
|
||||
{formatMeetingDaysLong(mt)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
<span class="text-muted-foreground">
|
||||
{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="italic text-muted-foreground">Time TBA</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs opacity-70">{mt.start_date} – {mt.end_date}</div>
|
||||
{#if mt.building || mt.room}
|
||||
<div class="text-xs text-muted-foreground mt-0.5">
|
||||
{mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs text-muted-foreground/70 mt-0.5">
|
||||
{formatDate(mt.start_date)} – {formatDate(mt.end_date)}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">TBA</span>
|
||||
<span class="italic text-muted-foreground">TBA</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delivery -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Delivery</h4>
|
||||
<span class="text-muted-foreground">
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Delivery
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
How the course is taught: in-person, online, hybrid, etc.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</span>
|
||||
</h4>
|
||||
<span class="text-foreground">
|
||||
{course.instructionalMethod ?? "—"}
|
||||
{#if course.campus}
|
||||
· {course.campus}
|
||||
<span class="text-muted-foreground"> · {course.campus}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Credits</h4>
|
||||
<span class="text-muted-foreground">{formatCreditHours(course)}</span>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
Credits
|
||||
</h4>
|
||||
<span class="text-foreground">{formatCreditHours(course)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Attributes -->
|
||||
{#if course.attributes.length > 0}
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Attributes</h4>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Attributes
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
Course flags for degree requirements, core curriculum, or special designations
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</span>
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each course.attributes as attr}
|
||||
<span class="text-xs bg-card border border-border rounded px-1.5 py-0.5 text-muted-foreground">
|
||||
{attr}
|
||||
</span>
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<span
|
||||
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
{attr}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-64"
|
||||
>
|
||||
Course attribute code
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,21 +204,53 @@
|
||||
<!-- Cross-list -->
|
||||
{#if course.crossList}
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Cross-list</h4>
|
||||
<span class="text-muted-foreground">
|
||||
{course.crossList}
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
({course.crossListCount}/{course.crossListCapacity})
|
||||
{/if}
|
||||
</span>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Cross-list
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</span>
|
||||
</h4>
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1.5 text-foreground font-mono">
|
||||
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
{course.crossList}
|
||||
</span>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{course.crossListCount}/{course.crossListCapacity}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
Group <span class="font-mono font-medium">{course.crossList}</span>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
— {course.crossListCount} enrolled across {course.crossListCapacity} shared seats
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist -->
|
||||
{#if course.waitCapacity > 0}
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">Waitlist</h4>
|
||||
<span class="text-muted-foreground">{course.waitCount} / {course.waitCapacity}</span>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
Waitlist
|
||||
</h4>
|
||||
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { abbreviateInstructor, formatMeetingTime, getPrimaryInstructor } from "$lib/course";
|
||||
import CourseDetail from "./CourseDetail.svelte";
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
abbreviateInstructor,
|
||||
formatTime,
|
||||
formatMeetingDays,
|
||||
formatLocation,
|
||||
getPrimaryInstructor,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
} from "$lib/course";
|
||||
import CourseDetail from "./CourseDetail.svelte";
|
||||
|
||||
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
|
||||
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
|
||||
|
||||
let expandedCrn: string | null = $state(null);
|
||||
let expandedCrn: string | null = $state(null);
|
||||
|
||||
function toggleRow(crn: string) {
|
||||
expandedCrn = expandedCrn === crn ? null : crn;
|
||||
}
|
||||
function toggleRow(crn: string) {
|
||||
expandedCrn = expandedCrn === crn ? null : crn;
|
||||
}
|
||||
|
||||
function seatsColor(course: CourseResponse): string {
|
||||
return course.enrollment < course.maxEnrollment ? "text-status-green" : "text-status-red";
|
||||
}
|
||||
function openSeats(course: CourseResponse): number {
|
||||
return Math.max(0, course.maxEnrollment - course.enrollment);
|
||||
}
|
||||
|
||||
function primaryInstructorDisplay(course: CourseResponse): string {
|
||||
const primary = getPrimaryInstructor(course.instructors);
|
||||
if (!primary) return "Staff";
|
||||
return abbreviateInstructor(primary.displayName);
|
||||
}
|
||||
function seatsColor(course: CourseResponse): string {
|
||||
const open = openSeats(course);
|
||||
if (open === 0) return "text-status-red";
|
||||
if (open <= 5) return "text-yellow-500";
|
||||
return "text-status-green";
|
||||
}
|
||||
|
||||
function timeDisplay(course: CourseResponse): string {
|
||||
if (course.meetingTimes.length === 0) return "TBA";
|
||||
return formatMeetingTime(course.meetingTimes[0]);
|
||||
}
|
||||
function seatsDotColor(course: CourseResponse): string {
|
||||
const open = openSeats(course);
|
||||
if (open === 0) return "bg-red-500";
|
||||
if (open <= 5) return "bg-yellow-500";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
function primaryInstructorDisplay(course: CourseResponse): string {
|
||||
const primary = getPrimaryInstructor(course.instructors);
|
||||
if (!primary) return "Staff";
|
||||
return abbreviateInstructor(primary.displayName);
|
||||
}
|
||||
|
||||
function ratingColor(rating: number): string {
|
||||
if (rating >= 4.0) return "text-status-green";
|
||||
if (rating >= 3.0) return "text-yellow-500";
|
||||
return "text-status-red";
|
||||
}
|
||||
|
||||
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
|
||||
const primary = getPrimaryInstructor(course.instructors);
|
||||
if (!primary?.rmpRating) return null;
|
||||
return { rating: primary.rmpRating, count: primary.rmpNumRatings ?? 0 };
|
||||
}
|
||||
|
||||
function timeIsTBA(course: CourseResponse): boolean {
|
||||
if (course.meetingTimes.length === 0) return true;
|
||||
const mt = course.meetingTimes[0];
|
||||
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
@@ -36,6 +71,7 @@
|
||||
<th class="py-2 px-2 font-medium">Title</th>
|
||||
<th class="py-2 px-2 font-medium">Instructor</th>
|
||||
<th class="py-2 px-2 font-medium">Time</th>
|
||||
<th class="py-2 px-2 font-medium">Location</th>
|
||||
<th class="py-2 px-2 font-medium text-right">Seats</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -43,43 +79,76 @@
|
||||
{#if loading && courses.length === 0}
|
||||
{#each Array(5) as _}
|
||||
<tr class="border-b border-border">
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-10 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse ml-auto"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-16 bg-muted rounded animate-pulse"></div></td>
|
||||
<td class="py-2.5 px-2"><div class="h-4 w-14 bg-muted rounded animate-pulse ml-auto"></div></td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if courses.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="py-12 text-center text-muted-foreground">
|
||||
<td colspan="7" class="py-12 text-center text-muted-foreground">
|
||||
No courses found. Try adjusting your filters.
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each courses as course (course.crn)}
|
||||
<tr
|
||||
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
||||
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
||||
onclick={() => toggleRow(course.crn)}
|
||||
>
|
||||
<td class="py-2 px-2 font-mono">{course.crn}</td>
|
||||
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{course.subject} {course.courseNumber}-{course.sequenceNumber ?? ""}
|
||||
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
|
||||
</td>
|
||||
<td class="py-2 px-2">{course.title}</td>
|
||||
<td class="py-2 px-2 whitespace-nowrap">{primaryInstructorDisplay(course)}</td>
|
||||
<td class="py-2 px-2 whitespace-nowrap">{timeDisplay(course)}</td>
|
||||
<td class="py-2 px-2 text-right whitespace-nowrap {seatsColor(course)}">
|
||||
{course.enrollment}/{course.maxEnrollment}
|
||||
{#if course.waitCount > 0}
|
||||
<div class="text-xs text-muted-foreground">WL: {course.waitCount}/{course.waitCapacity}</div>
|
||||
<td class="py-2 px-2 font-medium">{course.title}</td>
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{primaryInstructorDisplay(course)}
|
||||
{#if primaryRating(course)}
|
||||
{@const r = primaryRating(course)!}
|
||||
<span
|
||||
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
|
||||
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
|
||||
>{r.rating.toFixed(1)}★</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{#if timeIsTBA(course)}
|
||||
<span class="text-xs text-muted-foreground/60">TBA</span>
|
||||
{:else}
|
||||
{@const mt = course.meetingTimes[0]}
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
||||
{" "}
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
<span class="text-muted-foreground">{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground/60">TBA</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{#if formatLocation(course)}
|
||||
<span class="text-muted-foreground">{formatLocation(course)}</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground/50">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
||||
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
|
||||
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{#if expandedCrn === course.crn}
|
||||
<tr>
|
||||
<td colspan="6" class="p-0">
|
||||
<td colspan="7" class="p-0">
|
||||
<CourseDetail {course} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { totalCount, offset, limit, onPageChange }: {
|
||||
totalCount: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onPageChange: (newOffset: number) => void;
|
||||
} = $props();
|
||||
let {
|
||||
totalCount,
|
||||
offset,
|
||||
limit,
|
||||
onPageChange,
|
||||
}: {
|
||||
totalCount: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onPageChange: (newOffset: number) => void;
|
||||
} = $props();
|
||||
|
||||
const start = $derived(offset + 1);
|
||||
const end = $derived(Math.min(offset + limit, totalCount));
|
||||
const hasPrev = $derived(offset > 0);
|
||||
const hasNext = $derived(offset + limit < totalCount);
|
||||
const start = $derived(offset + 1);
|
||||
const end = $derived(Math.min(offset + limit, totalCount));
|
||||
const hasPrev = $derived(offset > 0);
|
||||
const hasNext = $derived(offset + limit < totalCount);
|
||||
</script>
|
||||
|
||||
{#if totalCount > 0}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Term, Subject } from "$lib/api";
|
||||
import type { Term, Subject } from "$lib/api";
|
||||
|
||||
let {
|
||||
terms,
|
||||
subjects,
|
||||
selectedTerm = $bindable(),
|
||||
selectedSubject = $bindable(),
|
||||
query = $bindable(),
|
||||
openOnly = $bindable(),
|
||||
}: {
|
||||
terms: Term[];
|
||||
subjects: Subject[];
|
||||
selectedTerm: string;
|
||||
selectedSubject: string;
|
||||
query: string;
|
||||
openOnly: boolean;
|
||||
} = $props();
|
||||
let {
|
||||
terms,
|
||||
subjects,
|
||||
selectedTerm = $bindable(),
|
||||
selectedSubject = $bindable(),
|
||||
query = $bindable(),
|
||||
openOnly = $bindable(),
|
||||
}: {
|
||||
terms: Term[];
|
||||
subjects: Subject[];
|
||||
selectedTerm: string;
|
||||
selectedSubject: string;
|
||||
query: string;
|
||||
openOnly: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
|
||||
+104
-9
@@ -6,6 +6,10 @@ import {
|
||||
abbreviateInstructor,
|
||||
formatCreditHours,
|
||||
getPrimaryInstructor,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
formatDate,
|
||||
formatMeetingDaysLong,
|
||||
} from "$lib/course";
|
||||
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
|
||||
|
||||
@@ -45,9 +49,9 @@ describe("formatTime", () => {
|
||||
|
||||
describe("formatMeetingDays", () => {
|
||||
it("returns MWF for mon/wed/fri", () => {
|
||||
expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))).toBe(
|
||||
"MWF"
|
||||
);
|
||||
expect(
|
||||
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");
|
||||
@@ -76,12 +80,20 @@ describe("formatMeetingTime", () => {
|
||||
it("formats a standard meeting time", () => {
|
||||
expect(
|
||||
formatMeetingTime(
|
||||
makeMeetingTime({ monday: true, wednesday: true, friday: true, begin_time: "0900", end_time: "0950" })
|
||||
makeMeetingTime({
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
begin_time: "0900",
|
||||
end_time: "0950",
|
||||
})
|
||||
)
|
||||
).toBe("MWF 9:00 AM–9:50 AM");
|
||||
});
|
||||
it("returns TBA when no days", () => {
|
||||
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe("TBA");
|
||||
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe(
|
||||
"TBA"
|
||||
);
|
||||
});
|
||||
it("returns days + TBA when no times", () => {
|
||||
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA");
|
||||
@@ -89,7 +101,8 @@ describe("formatMeetingTime", () => {
|
||||
});
|
||||
|
||||
describe("abbreviateInstructor", () => {
|
||||
it("abbreviates standard name", () => expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J."));
|
||||
it("abbreviates standard name", () =>
|
||||
expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J."));
|
||||
it("handles no comma", () => expect(abbreviateInstructor("Staff")).toBe("Staff"));
|
||||
it("handles multiple first names", () =>
|
||||
expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M."));
|
||||
@@ -117,17 +130,99 @@ describe("getPrimaryInstructor", () => {
|
||||
describe("formatCreditHours", () => {
|
||||
it("returns creditHours when set", () => {
|
||||
expect(
|
||||
formatCreditHours({ creditHours: 3, creditHourLow: null, creditHourHigh: null } as CourseResponse)
|
||||
formatCreditHours({
|
||||
creditHours: 3,
|
||||
creditHourLow: null,
|
||||
creditHourHigh: null,
|
||||
} as CourseResponse)
|
||||
).toBe("3");
|
||||
});
|
||||
it("returns range when variable", () => {
|
||||
expect(
|
||||
formatCreditHours({ creditHours: null, creditHourLow: 1, creditHourHigh: 3 } as CourseResponse)
|
||||
formatCreditHours({
|
||||
creditHours: null,
|
||||
creditHourLow: 1,
|
||||
creditHourHigh: 3,
|
||||
} as CourseResponse)
|
||||
).toBe("1–3");
|
||||
});
|
||||
it("returns dash when no credit info", () => {
|
||||
expect(
|
||||
formatCreditHours({ creditHours: null, creditHourLow: null, creditHourHigh: null } as CourseResponse)
|
||||
formatCreditHours({
|
||||
creditHours: null,
|
||||
creditHourLow: null,
|
||||
creditHourHigh: null,
|
||||
} as CourseResponse)
|
||||
).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMeetingTimeTBA", () => {
|
||||
it("returns true when no days set", () => {
|
||||
expect(isMeetingTimeTBA(makeMeetingTime())).toBe(true);
|
||||
});
|
||||
it("returns false when any day is set", () => {
|
||||
expect(isMeetingTimeTBA(makeMeetingTime({ monday: true }))).toBe(false);
|
||||
});
|
||||
it("returns false when multiple days set", () => {
|
||||
expect(isMeetingTimeTBA(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTimeTBA", () => {
|
||||
it("returns true when begin_time is null", () => {
|
||||
expect(isTimeTBA(makeMeetingTime())).toBe(true);
|
||||
});
|
||||
it("returns true when begin_time is empty", () => {
|
||||
expect(isTimeTBA(makeMeetingTime({ begin_time: "" }))).toBe(true);
|
||||
});
|
||||
it("returns true when begin_time is short", () => {
|
||||
expect(isTimeTBA(makeMeetingTime({ begin_time: "09" }))).toBe(true);
|
||||
});
|
||||
it("returns false when begin_time is valid", () => {
|
||||
expect(isTimeTBA(makeMeetingTime({ begin_time: "0900" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats standard date", () => {
|
||||
expect(formatDate("2024-08-26")).toBe("August 26, 2024");
|
||||
});
|
||||
it("formats December date", () => {
|
||||
expect(formatDate("2024-12-12")).toBe("December 12, 2024");
|
||||
});
|
||||
it("formats January 1st", () => {
|
||||
expect(formatDate("2026-01-01")).toBe("January 1, 2026");
|
||||
});
|
||||
it("formats MM/DD/YYYY date", () => {
|
||||
expect(formatDate("01/20/2026")).toBe("January 20, 2026");
|
||||
});
|
||||
it("formats MM/DD/YYYY with May", () => {
|
||||
expect(formatDate("05/13/2026")).toBe("May 13, 2026");
|
||||
});
|
||||
it("returns original string for invalid input", () => {
|
||||
expect(formatDate("bad-date")).toBe("bad-date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMeetingDaysLong", () => {
|
||||
it("returns full plural for single day", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ thursday: true }))).toBe("Thursdays");
|
||||
});
|
||||
it("returns full plural for Monday only", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ monday: true }))).toBe("Mondays");
|
||||
});
|
||||
it("returns semi-abbreviated for multiple days", () => {
|
||||
expect(
|
||||
formatMeetingDaysLong(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
|
||||
).toBe("Mon, Wed, Fri");
|
||||
});
|
||||
it("returns semi-abbreviated for TR", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(
|
||||
"Tue, Thur"
|
||||
);
|
||||
});
|
||||
it("returns empty string when no days", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
+69
-1
@@ -27,6 +27,23 @@ export function formatMeetingDays(mt: DbMeetingTime): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */
|
||||
export function formatMeetingDaysLong(mt: DbMeetingTime): string {
|
||||
const days: [boolean, string, string][] = [
|
||||
[mt.monday, "Mon", "Mondays"],
|
||||
[mt.tuesday, "Tue", "Tuesdays"],
|
||||
[mt.wednesday, "Wed", "Wednesdays"],
|
||||
[mt.thursday, "Thur", "Thursdays"],
|
||||
[mt.friday, "Fri", "Fridays"],
|
||||
[mt.saturday, "Sat", "Saturdays"],
|
||||
[mt.sunday, "Sun", "Sundays"],
|
||||
];
|
||||
const active = days.filter(([a]) => a);
|
||||
if (active.length === 0) return "";
|
||||
if (active.length === 1) return active[0][2];
|
||||
return active.map(([, short]) => short).join(", ");
|
||||
}
|
||||
|
||||
/** Condensed meeting time: "MWF 9:00 AM–9:50 AM" */
|
||||
export function formatMeetingTime(mt: DbMeetingTime): string {
|
||||
const days = formatMeetingDays(mt);
|
||||
@@ -47,10 +64,61 @@ export function abbreviateInstructor(name: string): string {
|
||||
}
|
||||
|
||||
/** Get primary instructor from a course, or first instructor */
|
||||
export function getPrimaryInstructor(instructors: InstructorResponse[]): InstructorResponse | undefined {
|
||||
export function getPrimaryInstructor(
|
||||
instructors: InstructorResponse[]
|
||||
): InstructorResponse | undefined {
|
||||
return instructors.find((i) => i.isPrimary) ?? instructors[0];
|
||||
}
|
||||
|
||||
/** Check if a meeting time has no scheduled days */
|
||||
export function isMeetingTimeTBA(mt: DbMeetingTime): boolean {
|
||||
return (
|
||||
!mt.monday &&
|
||||
!mt.tuesday &&
|
||||
!mt.wednesday &&
|
||||
!mt.thursday &&
|
||||
!mt.friday &&
|
||||
!mt.saturday &&
|
||||
!mt.sunday
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a meeting time has no begin/end times */
|
||||
export function isTimeTBA(mt: DbMeetingTime): boolean {
|
||||
return !mt.begin_time || mt.begin_time.length !== 4;
|
||||
}
|
||||
|
||||
/** Format a date string to "January 20, 2026". Accepts YYYY-MM-DD or MM/DD/YYYY. */
|
||||
export function formatDate(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: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
/** Short location string from first meeting time: "MH 2.206" or campus fallback */
|
||||
export function formatLocation(course: CourseResponse): string | null {
|
||||
for (const mt of course.meetingTimes) {
|
||||
if (mt.building && mt.room) return `${mt.building} ${mt.room}`;
|
||||
if (mt.building) return mt.building;
|
||||
}
|
||||
return course.campus ?? null;
|
||||
}
|
||||
|
||||
/** Longer location string using building description: "Main Hall 2.206" */
|
||||
export function formatLocationLong(mt: DbMeetingTime): string | null {
|
||||
const name = mt.building_description ?? mt.building;
|
||||
if (!name) return null;
|
||||
return mt.room ? `${name} ${mt.room}` : name;
|
||||
}
|
||||
|
||||
/** Format credit hours display */
|
||||
export function formatCreditHours(course: CourseResponse): string {
|
||||
if (course.creditHours != null) return String(course.creditHours);
|
||||
|
||||
+90
-96
@@ -1,108 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { type Subject, type SearchResponse, client } from "$lib/api";
|
||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { type Subject, type SearchResponse, client } from "$lib/api";
|
||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
// Read initial state from URL params (intentionally captured once)
|
||||
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||
// Read initial state from URL params (intentionally captured once)
|
||||
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||
|
||||
// Filter state
|
||||
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
||||
let query = $state(initialParams.get("q") ?? "");
|
||||
let openOnly = $state(initialParams.get("open") === "true");
|
||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||
const limit = 25;
|
||||
// Filter state
|
||||
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
||||
let query = $state(initialParams.get("q") ?? "");
|
||||
let openOnly = $state(initialParams.get("open") === "true");
|
||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||
const limit = 25;
|
||||
|
||||
// Data state
|
||||
let subjects: Subject[] = $state([]);
|
||||
let searchResult: SearchResponse | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
// Data state
|
||||
let subjects: Subject[] = $state([]);
|
||||
let searchResult: SearchResponse | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Fetch subjects when term changes
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
if (!term) return;
|
||||
client.getSubjects(term).then((s) => {
|
||||
subjects = s;
|
||||
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
||||
selectedSubject = "";
|
||||
}
|
||||
// Fetch subjects when term changes
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
if (!term) return;
|
||||
client.getSubjects(term).then((s) => {
|
||||
subjects = s;
|
||||
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
||||
selectedSubject = "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
const subject = selectedSubject;
|
||||
const q = query;
|
||||
const open = openOnly;
|
||||
const off = offset;
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(term, subject, q, open, off);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
// Reset offset when filters change (not offset itself)
|
||||
let prevFilters = $state("");
|
||||
$effect(() => {
|
||||
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
||||
if (prevFilters && key !== prevFilters) {
|
||||
offset = 0;
|
||||
}
|
||||
prevFilters = key;
|
||||
});
|
||||
|
||||
async function performSearch(term: string, subject: string, q: string, open: boolean, off: number) {
|
||||
if (!term) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Sync URL
|
||||
const params = new URLSearchParams();
|
||||
params.set("term", term);
|
||||
if (subject) params.set("subject", subject);
|
||||
if (q) params.set("q", q);
|
||||
if (open) params.set("open", "true");
|
||||
if (off > 0) params.set("offset", String(off));
|
||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
|
||||
try {
|
||||
searchResult = await client.searchCourses({
|
||||
term,
|
||||
subject: subject || undefined,
|
||||
q: q || undefined,
|
||||
open_only: open || undefined,
|
||||
limit,
|
||||
offset: off,
|
||||
});
|
||||
});
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
const subject = selectedSubject;
|
||||
const q = query;
|
||||
const open = openOnly;
|
||||
const off = offset;
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(term, subject, q, open, off);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
// Reset offset when filters change (not offset itself)
|
||||
let prevFilters = $state("");
|
||||
$effect(() => {
|
||||
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
||||
if (prevFilters && key !== prevFilters) {
|
||||
offset = 0;
|
||||
}
|
||||
prevFilters = key;
|
||||
});
|
||||
|
||||
async function performSearch(
|
||||
term: string,
|
||||
subject: string,
|
||||
q: string,
|
||||
open: boolean,
|
||||
off: number,
|
||||
) {
|
||||
if (!term) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Sync URL
|
||||
const params = new URLSearchParams();
|
||||
params.set("term", term);
|
||||
if (subject) params.set("subject", subject);
|
||||
if (q) params.set("q", q);
|
||||
if (open) params.set("open", "true");
|
||||
if (off > 0) params.set("offset", String(off));
|
||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
|
||||
try {
|
||||
searchResult = await client.searchCourses({
|
||||
term,
|
||||
subject: subject || undefined,
|
||||
q: q || undefined,
|
||||
open_only: open || undefined,
|
||||
limit,
|
||||
offset: off,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Search failed";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Search failed";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(newOffset: number) {
|
||||
offset = newOffset;
|
||||
}
|
||||
function handlePageChange(newOffset: number) {
|
||||
offset = newOffset;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col items-center p-5">
|
||||
|
||||
@@ -45,7 +45,10 @@ type StatusState =
|
||||
| { mode: "error"; lastFetch: Date }
|
||||
| { mode: "timeout"; lastFetch: Date };
|
||||
|
||||
const STATUS_ICONS: Record<ServiceStatus | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
|
||||
const STATUS_ICONS: Record<
|
||||
ServiceStatus | "Unreachable",
|
||||
{ icon: typeof CheckCircle; color: string }
|
||||
> = {
|
||||
active: { icon: CheckCircle, color: "var(--status-green)" },
|
||||
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
||||
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
||||
|
||||
Reference in New Issue
Block a user