feat: add rmp profile links and confidence-aware rating display

This commit is contained in:
2026-01-29 15:43:21 -06:00
parent 2947face06
commit db0ec1e69d
7 changed files with 487 additions and 260 deletions
+2 -2
View File
@@ -148,7 +148,7 @@ pub async fn get_course_instructors(
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings,
rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
@@ -177,7 +177,7 @@ pub async fn get_instructors_for_courses(
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings,
rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
+1
View File
@@ -85,6 +85,7 @@ pub struct CourseInstructorDetail {
pub is_primary: bool,
pub avg_rating: Option<f32>,
pub num_ratings: Option<i32>,
pub rmp_legacy_id: Option<i32>,
/// Present when fetched via batch query; `None` for single-course queries.
pub course_id: Option<i32>,
}
+2
View File
@@ -439,6 +439,7 @@ pub struct InstructorResponse {
is_primary: bool,
rmp_rating: Option<f32>,
rmp_num_ratings: Option<i32>,
rmp_legacy_id: Option<i32>,
}
#[derive(Serialize, TS)]
@@ -473,6 +474,7 @@ fn build_course_response(
is_primary: i.is_primary,
rmp_rating: i.avg_rating,
rmp_num_ratings: i.num_ratings,
rmp_legacy_id: i.rmp_legacy_id,
})
.collect();
+121 -41
View File
@@ -7,13 +7,16 @@ import {
formatMeetingDaysLong,
isMeetingTimeTBA,
isTimeTBA,
ratingColor,
ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte";
import { Info, Copy, Check } from "@lucide/svelte";
import { Info, Copy, Check, Star, Triangle, ExternalLink } from "@lucide/svelte";
let { course }: { course: CourseResponse } = $props();
@@ -24,9 +27,7 @@ const clipboard = useClipboard();
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Instructors -->
<div>
<h4 class="text-sm text-foreground mb-2">
Instructors
</h4>
<h4 class="text-sm text-foreground mb-2">Instructors</h4>
{#if course.instructors.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each course.instructors as instructor}
@@ -38,9 +39,27 @@ const clipboard = useClipboard();
{instructor.displayName}
{#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating}
{@const lowConfidence =
(instructor.rmpNumRatings ?? 0) <
RMP_CONFIDENCE_THRESHOLD}
<span
class="text-[10px] font-semibold {ratingColor(rating)}"
>{rating.toFixed(1)}</span>
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
style={ratingStyle(
rating,
themeStore.isDark,
)}
>
{rating.toFixed(1)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
{/if}
</span>
</Tooltip.Trigger>
@@ -49,18 +68,43 @@ const clipboard = useClipboard();
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
<div class="font-medium">{instructor.displayName}</div>
<div class="font-medium">
{instructor.displayName}
</div>
{#if instructor.isPrimary}
<div class="text-muted-foreground">Primary instructor</div>
<div class="text-muted-foreground">
Primary instructor
</div>
{/if}
{#if instructor.rmpRating != null}
<div class="text-muted-foreground">
{instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings)
{instructor.rmpRating.toFixed(1)}/5
· {instructor.rmpNumRatings ?? 0} ratings
{#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
</div>
{/if}
{#if instructor.rmpLegacyId != null}
<a
href={rmpUrl(
instructor.rmpLegacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink class="size-3" />
<span>View on RMP</span>
</a>
{/if}
{#if instructor.email}
<button
onclick={(e) => clipboard.copy(instructor.email!, e)}
onclick={(e) =>
clipboard.copy(
instructor.email!,
e,
)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{#if clipboard.copiedValue === instructor.email}
@@ -84,38 +128,54 @@ const clipboard = useClipboard();
<!-- Meeting Times -->
<div>
<h4 class="text-sm text-foreground mb-2">
Meeting Times
</h4>
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-2">
{#each course.meetingTimes as mt}
<li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
<span class="italic text-muted-foreground">TBA</span>
<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">
<span
class="font-medium text-foreground"
>
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}
{formatTime(
mt.begin_time,
)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span class="italic text-muted-foreground">Time TBA</span>
<span
class="italic text-muted-foreground"
>Time TBA</span
>
{/if}
</div>
{/if}
{#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
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)} &ndash; {formatDate(mt.end_date)}
<div
class="text-xs text-muted-foreground/70 mt-0.5"
>
{formatDate(mt.start_date)} &ndash; {formatDate(
mt.end_date,
)}
</div>
</li>
{/each}
@@ -130,7 +190,11 @@ const clipboard = useClipboard();
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Delivery
<SimpleTooltip text="How the course is taught: in-person, online, hybrid, etc." delay={150} passthrough>
<SimpleTooltip
text="How the course is taught: in-person, online, hybrid, etc."
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
@@ -138,16 +202,16 @@ const clipboard = useClipboard();
<span class="text-foreground">
{course.instructionalMethod ?? "—"}
{#if course.campus}
<span class="text-muted-foreground"> · {course.campus}</span>
<span class="text-muted-foreground">
· {course.campus}
</span>
{/if}
</span>
</div>
<!-- Credits -->
<div>
<h4 class="text-sm text-foreground mb-2">
Credits
</h4>
<h4 class="text-sm text-foreground mb-2">Credits</h4>
<span class="text-foreground">{formatCreditHours(course)}</span>
</div>
@@ -157,14 +221,22 @@ const clipboard = useClipboard();
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Attributes
<SimpleTooltip text="Course flags for degree requirements, core curriculum, or special designations" delay={150} passthrough>
<SimpleTooltip
text="Course flags for degree requirements, core curriculum, or special designations"
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr}
<SimpleTooltip text="Course attribute code" delay={150} passthrough>
<SimpleTooltip
text="Course attribute code"
delay={150}
passthrough
>
<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"
>
@@ -182,15 +254,23 @@ const clipboard = useClipboard();
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Cross-list
<SimpleTooltip text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class." delay={150} passthrough>
<SimpleTooltip
text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class."
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<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">
<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}
@@ -200,13 +280,13 @@ const clipboard = useClipboard();
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={tooltipContentClass}
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
Group <span class="font-mono font-medium"
>{course.crossList}</span
>
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
{course.crossListCount} enrolled across {course.crossListCapacity}
shared seats
{/if}
</Tooltip.Content>
</Tooltip.Root>
@@ -216,10 +296,10 @@ const clipboard = useClipboard();
<!-- Waitlist -->
{#if course.waitCapacity > 0}
<div>
<h4 class="text-sm text-foreground mb-2">
Waitlist
</h4>
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span>
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
<span class="text-2foreground"
>{course.waitCount} / {course.waitCapacity}</span
>
</div>
{/if}
</div>
+140 -37
View File
@@ -15,8 +15,11 @@ import {
openSeats,
seatsColor,
seatsDotColor,
ratingColor,
ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte";
@@ -31,8 +34,19 @@ import {
type VisibilityState,
type Updater,
} from "@tanstack/table-core";
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
import { DropdownMenu, ContextMenu } from "bits-ui";
import {
ArrowUp,
ArrowDown,
ArrowUpDown,
Columns3,
Check,
RotateCcw,
Star,
Triangle,
ExternalLink,
} from "@lucide/svelte";
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
import { cn, tooltipContentClass } from "$lib/utils";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
@@ -91,10 +105,16 @@ function primaryInstructorDisplay(course: CourseResponse): string {
return abbreviateInstructor(primary.displayName);
}
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
function primaryRating(
course: CourseResponse
): { rating: number; count: number; legacyId: number | null } | null {
const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null;
return { rating: primary.rmpRating, count: primary.rmpNumRatings ?? 0 };
return {
rating: primary.rmpRating,
count: primary.rmpNumRatings ?? 0,
legacyId: primary.rmpLegacyId ?? null,
};
}
function timeIsTBA(course: CourseResponse): boolean {
@@ -207,8 +227,7 @@ const table = createSvelteTable({
</GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label =
typeof col.header === "string" ? col.header : id}
{@const label = typeof col.header === "string" ? col.header : id}
<CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
@@ -384,7 +403,10 @@ const table = createSvelteTable({
{@const course = row.original}
<tbody
animate:flip={{ duration: 300 }}
in:fade={{ duration: 200, delay: Math.min(i * 20, 400) }}
in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
}}
>
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
@@ -405,9 +427,15 @@ const table = createSvelteTable({
e,
)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
clipboard.copy(course.crn, e);
clipboard.copy(
course.crn,
e,
);
}
}}
aria-label="Copy CRN {course.crn} to clipboard"
@@ -468,9 +496,12 @@ const table = createSvelteTable({
{@const primary = getPrimaryInstructor(
course.instructors,
)}
{@const display = primaryInstructorDisplay(course)}
{@const commaIdx = display.indexOf(", ")}
{@const ratingData = primaryRating(course)}
{@const display =
primaryInstructorDisplay(course)}
{@const commaIdx =
display.indexOf(", ")}
{@const ratingData =
primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"}
<span
@@ -486,38 +517,98 @@ const table = createSvelteTable({
passthrough
>
{#if commaIdx !== -1}
<span>{display.slice(0, commaIdx)},
<span class="text-muted-foreground">{display.slice(commaIdx + 1)}</span
></span>
<span
>{display.slice(
0,
commaIdx,
)},
<span
class="text-muted-foreground"
>{display.slice(
commaIdx +
1,
)}</span
></span
>
{:else}
<span>{display}</span>
{/if}
</SimpleTooltip>
{/if}
{#if ratingData}
<SimpleTooltip
text="{ratingData.rating.toFixed(
{@const lowConfidence =
ratingData.count <
RMP_CONFIDENCE_THRESHOLD}
<Tooltip.Root
delayDuration={150}
>
<Tooltip.Trigger>
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
style={ratingStyle(
ratingData.rating,
themeStore.isDark,
)}
>
{ratingData.rating.toFixed(
1,
)}/5 ({ratingData.count} ratings on RateMyProfessors)"
delay={150}
)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
passthrough
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
>
<span
class="ml-1 text-xs font-medium {ratingColor(
ratingData.rating,
)}"
>{ratingData.rating.toFixed(
1,
)}★</span
class="inline-flex items-center gap-1.5 text-xs"
>
</SimpleTooltip>
{ratingData.rating.toFixed(
1,
)}/5 · {ratingData.count}
ratings
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
{#if ratingData.legacyId != null}
·
<a
href={rmpUrl(
ratingData.legacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
RMP
<ExternalLink
class="size-3"
/>
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={formatMeetingTimesTooltip(course.meetingTimes)}
text={formatMeetingTimesTooltip(
course.meetingTimes,
)}
passthrough
>
{#if timeIsTBA(course)}
@@ -566,10 +657,14 @@ const table = createSvelteTable({
</SimpleTooltip>
</td>
{:else if colId === "location"}
{@const concern = getDeliveryConcern(course)}
{@const accentColor = concernAccentColor(concern)}
{@const locTooltip = formatLocationTooltip(course)}
{@const locDisplay = formatLocationDisplay(course)}
{@const concern =
getDeliveryConcern(course)}
{@const accentColor =
concernAccentColor(concern)}
{@const locTooltip =
formatLocationTooltip(course)}
{@const locDisplay =
formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip}
<SimpleTooltip
@@ -579,18 +674,26 @@ const table = createSvelteTable({
>
<span
class="text-muted-foreground"
class:pl-2={accentColor !== null}
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined}
class:pl-2={accentColor !==
null}
style:border-left={accentColor
? `2px solid ${accentColor}`
: undefined}
>
{locDisplay ?? "—"}
</span>
</SimpleTooltip>
{:else if locDisplay}
<span class="text-muted-foreground">
<span
class="text-muted-foreground"
>
{locDisplay}
</span>
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
<span
class="text-xs text-muted-foreground/50"
>—</span
>
{/if}
</td>
{:else if colId === "seats"}
+3
View File
@@ -193,6 +193,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
{
bannerId: "2",
@@ -201,6 +202,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: true,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
@@ -214,6 +216,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
+43 -5
View File
@@ -362,11 +362,49 @@ export function seatsDotColor(course: CourseResponse): string {
return "bg-green-500";
}
/** Text color class for a RateMyProfessors rating */
export 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";
/** Minimum number of ratings needed to consider RMP data reliable */
export const RMP_CONFIDENCE_THRESHOLD = 7;
/** RMP professor page URL from legacy ID */
export function rmpUrl(legacyId: number): string {
return `https://www.ratemyprofessors.com/professor/${legacyId}`;
}
/**
* Smooth OKLCH color + text-shadow for a RateMyProfessors rating.
*
* Three-stop gradient interpolated in OKLCH:
* 1.0 → red, 3.0 → amber, 5.0 → green
* with separate light/dark mode tuning.
*/
export function ratingStyle(rating: number, isDark: boolean): string {
const clamped = Math.max(1, Math.min(5, rating));
// OKLCH stops: [lightness, chroma, hue]
const stops: { light: [number, number, number]; dark: [number, number, number] }[] = [
{ light: [0.63, 0.2, 25], dark: [0.7, 0.19, 25] }, // 1.0 red
{ light: [0.7, 0.16, 85], dark: [0.78, 0.15, 85] }, // 3.0 amber
{ light: [0.65, 0.2, 145], dark: [0.72, 0.19, 145] }, // 5.0 green
];
let t: number;
let fromIdx: number;
if (clamped <= 3) {
t = (clamped - 1) / 2;
fromIdx = 0;
} else {
t = (clamped - 3) / 2;
fromIdx = 1;
}
const from = isDark ? stops[fromIdx].dark : stops[fromIdx].light;
const to = isDark ? stops[fromIdx + 1].dark : stops[fromIdx + 1].light;
const l = from[0] + (to[0] - from[0]) * t;
const c = from[1] + (to[1] - from[1]) * t;
const h = from[2] + (to[2] - from[2]) * t;
return `color: oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)}); text-shadow: 0 0 4px oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)} / 0.3);`;
}
/** Format credit hours display */