feat: sync RMP professor ratings and display in course search interface

This commit is contained in:
2026-01-29 00:26:40 -06:00
parent 5fab8c216a
commit d108a41f91
17 changed files with 1173 additions and 248 deletions
+103 -34
View File
@@ -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)}&ndash;{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>