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>( let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#" r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary, 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 ci.course_id
FROM course_instructors ci FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id 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>( let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#" r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary, 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 ci.course_id
FROM course_instructors ci FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id 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 is_primary: bool,
pub avg_rating: Option<f32>, pub avg_rating: Option<f32>,
pub num_ratings: Option<i32>, pub num_ratings: Option<i32>,
pub rmp_legacy_id: Option<i32>,
/// Present when fetched via batch query; `None` for single-course queries. /// Present when fetched via batch query; `None` for single-course queries.
pub course_id: Option<i32>, pub course_id: Option<i32>,
} }
+2
View File
@@ -439,6 +439,7 @@ pub struct InstructorResponse {
is_primary: bool, is_primary: bool,
rmp_rating: Option<f32>, rmp_rating: Option<f32>,
rmp_num_ratings: Option<i32>, rmp_num_ratings: Option<i32>,
rmp_legacy_id: Option<i32>,
} }
#[derive(Serialize, TS)] #[derive(Serialize, TS)]
@@ -473,6 +474,7 @@ fn build_course_response(
is_primary: i.is_primary, is_primary: i.is_primary,
rmp_rating: i.avg_rating, rmp_rating: i.avg_rating,
rmp_num_ratings: i.num_ratings, rmp_num_ratings: i.num_ratings,
rmp_legacy_id: i.rmp_legacy_id,
}) })
.collect(); .collect();
+121 -41
View File
@@ -7,13 +7,16 @@ import {
formatMeetingDaysLong, formatMeetingDaysLong,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
ratingColor, ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course"; } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte"; import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils"; import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte"; 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(); let { course }: { course: CourseResponse } = $props();
@@ -24,9 +27,7 @@ const clipboard = useClipboard();
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Instructors --> <!-- Instructors -->
<div> <div>
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">Instructors</h4>
Instructors
</h4>
{#if course.instructors.length > 0} {#if course.instructors.length > 0}
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each course.instructors as instructor} {#each course.instructors as instructor}
@@ -38,9 +39,27 @@ const clipboard = useClipboard();
{instructor.displayName} {instructor.displayName}
{#if instructor.rmpRating != null} {#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating} {@const rating = instructor.rmpRating}
{@const lowConfidence =
(instructor.rmpNumRatings ?? 0) <
RMP_CONFIDENCE_THRESHOLD}
<span <span
class="text-[10px] font-semibold {ratingColor(rating)}" class="text-[10px] font-semibold inline-flex items-center gap-0.5"
>{rating.toFixed(1)}</span> 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} {/if}
</span> </span>
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -49,18 +68,43 @@ const clipboard = useClipboard();
class={cn(tooltipContentClass, "px-3 py-2")} class={cn(tooltipContentClass, "px-3 py-2")}
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="font-medium">{instructor.displayName}</div> <div class="font-medium">
{instructor.displayName}
</div>
{#if instructor.isPrimary} {#if instructor.isPrimary}
<div class="text-muted-foreground">Primary instructor</div> <div class="text-muted-foreground">
Primary instructor
</div>
{/if} {/if}
{#if instructor.rmpRating != null} {#if instructor.rmpRating != null}
<div class="text-muted-foreground"> <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> </div>
{/if} {/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} {#if instructor.email}
<button <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" class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
> >
{#if clipboard.copiedValue === instructor.email} {#if clipboard.copiedValue === instructor.email}
@@ -84,38 +128,54 @@ const clipboard = useClipboard();
<!-- Meeting Times --> <!-- Meeting Times -->
<div> <div>
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
Meeting Times
</h4>
{#if course.meetingTimes.length > 0} {#if course.meetingTimes.length > 0}
<ul class="space-y-2"> <ul class="space-y-2">
{#each course.meetingTimes as mt} {#each course.meetingTimes as mt}
<li> <li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)} {#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
<span class="italic text-muted-foreground">TBA</span> <span class="italic text-muted-foreground"
>TBA</span
>
{:else} {:else}
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
{#if !isMeetingTimeTBA(mt)} {#if !isMeetingTimeTBA(mt)}
<span class="font-medium text-foreground"> <span
class="font-medium text-foreground"
>
{formatMeetingDaysLong(mt)} {formatMeetingDaysLong(mt)}
</span> </span>
{/if} {/if}
{#if !isTimeTBA(mt)} {#if !isTimeTBA(mt)}
<span class="text-muted-foreground"> <span class="text-muted-foreground">
{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)} {formatTime(
mt.begin_time,
)}&ndash;{formatTime(mt.end_time)}
</span> </span>
{:else} {:else}
<span class="italic text-muted-foreground">Time TBA</span> <span
class="italic text-muted-foreground"
>Time TBA</span
>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if mt.building || mt.room} {#if mt.building || mt.room}
<div class="text-xs text-muted-foreground mt-0.5"> <div
{mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""} class="text-xs text-muted-foreground mt-0.5"
>
{mt.building_description ??
mt.building}{mt.room
? ` ${mt.room}`
: ""}
</div> </div>
{/if} {/if}
<div class="text-xs text-muted-foreground/70 mt-0.5"> <div
{formatDate(mt.start_date)} &ndash; {formatDate(mt.end_date)} class="text-xs text-muted-foreground/70 mt-0.5"
>
{formatDate(mt.start_date)} &ndash; {formatDate(
mt.end_date,
)}
</div> </div>
</li> </li>
{/each} {/each}
@@ -130,7 +190,11 @@ const clipboard = useClipboard();
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
Delivery 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" /> <Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip> </SimpleTooltip>
</span> </span>
@@ -138,16 +202,16 @@ const clipboard = useClipboard();
<span class="text-foreground"> <span class="text-foreground">
{course.instructionalMethod ?? "—"} {course.instructionalMethod ?? "—"}
{#if course.campus} {#if course.campus}
<span class="text-muted-foreground"> · {course.campus}</span> <span class="text-muted-foreground">
· {course.campus}
</span>
{/if} {/if}
</span> </span>
</div> </div>
<!-- Credits --> <!-- Credits -->
<div> <div>
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">Credits</h4>
Credits
</h4>
<span class="text-foreground">{formatCreditHours(course)}</span> <span class="text-foreground">{formatCreditHours(course)}</span>
</div> </div>
@@ -157,14 +221,22 @@ const clipboard = useClipboard();
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
Attributes 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" /> <Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip> </SimpleTooltip>
</span> </span>
</h4> </h4>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr} {#each course.attributes as attr}
<SimpleTooltip text="Course attribute code" delay={150} passthrough> <SimpleTooltip
text="Course attribute code"
delay={150}
passthrough
>
<span <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" 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"> <h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
Cross-list 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" /> <Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip> </SimpleTooltip>
</span> </span>
</h4> </h4>
<Tooltip.Root delayDuration={150} disableHoverableContent> <Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger> <Tooltip.Trigger>
<span class="inline-flex items-center gap-1.5 text-foreground font-mono"> <span
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium"> 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} {course.crossList}
</span> </span>
{#if course.crossListCount != null && course.crossListCapacity != null} {#if course.crossListCount != null && course.crossListCapacity != null}
@@ -200,13 +280,13 @@ const clipboard = useClipboard();
{/if} {/if}
</span> </span>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content <Tooltip.Content sideOffset={6} class={tooltipContentClass}>
sideOffset={6} Group <span class="font-mono font-medium"
class={tooltipContentClass} >{course.crossList}</span
> >
Group <span class="font-mono font-medium">{course.crossList}</span>
{#if course.crossListCount != null && course.crossListCapacity != null} {#if course.crossListCount != null && course.crossListCapacity != null}
{course.crossListCount} enrolled across {course.crossListCapacity} shared seats {course.crossListCount} enrolled across {course.crossListCapacity}
shared seats
{/if} {/if}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
@@ -216,10 +296,10 @@ const clipboard = useClipboard();
<!-- Waitlist --> <!-- Waitlist -->
{#if course.waitCapacity > 0} {#if course.waitCapacity > 0}
<div> <div>
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">Waitlist</h4>
Waitlist <span class="text-2foreground"
</h4> >{course.waitCount} / {course.waitCapacity}</span
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span> >
</div> </div>
{/if} {/if}
</div> </div>
+140 -37
View File
@@ -15,8 +15,11 @@ import {
openSeats, openSeats,
seatsColor, seatsColor,
seatsDotColor, seatsDotColor,
ratingColor, ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course"; } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte"; import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte"; import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte"; import CourseDetail from "./CourseDetail.svelte";
@@ -31,8 +34,19 @@ import {
type VisibilityState, type VisibilityState,
type Updater, type Updater,
} from "@tanstack/table-core"; } from "@tanstack/table-core";
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte"; import {
import { DropdownMenu, ContextMenu } from "bits-ui"; 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"; import SimpleTooltip from "./SimpleTooltip.svelte";
let { let {
@@ -91,10 +105,16 @@ function primaryInstructorDisplay(course: CourseResponse): string {
return abbreviateInstructor(primary.displayName); 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); const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null; 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 { function timeIsTBA(course: CourseResponse): boolean {
@@ -207,8 +227,7 @@ const table = createSvelteTable({
</GroupHeading> </GroupHeading>
{#each columns as col} {#each columns as col}
{@const id = col.id!} {@const id = col.id!}
{@const label = {@const label = typeof col.header === "string" ? col.header : id}
typeof col.header === "string" ? col.header : id}
<CheckboxItem <CheckboxItem
checked={columnVisibility[id] !== false} checked={columnVisibility[id] !== false}
closeOnSelect={false} closeOnSelect={false}
@@ -384,7 +403,10 @@ const table = createSvelteTable({
{@const course = row.original} {@const course = row.original}
<tbody <tbody
animate:flip={{ duration: 300 }} 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 <tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
@@ -405,9 +427,15 @@ const table = createSvelteTable({
e, e,
)} )}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault(); e.preventDefault();
clipboard.copy(course.crn, e); clipboard.copy(
course.crn,
e,
);
} }
}} }}
aria-label="Copy CRN {course.crn} to clipboard" aria-label="Copy CRN {course.crn} to clipboard"
@@ -468,9 +496,12 @@ const table = createSvelteTable({
{@const primary = getPrimaryInstructor( {@const primary = getPrimaryInstructor(
course.instructors, course.instructors,
)} )}
{@const display = primaryInstructorDisplay(course)} {@const display =
{@const commaIdx = display.indexOf(", ")} primaryInstructorDisplay(course)}
{@const ratingData = primaryRating(course)} {@const commaIdx =
display.indexOf(", ")}
{@const ratingData =
primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"} {#if display === "Staff"}
<span <span
@@ -486,38 +517,98 @@ const table = createSvelteTable({
passthrough passthrough
> >
{#if commaIdx !== -1} {#if commaIdx !== -1}
<span>{display.slice(0, commaIdx)}, <span
<span class="text-muted-foreground">{display.slice(commaIdx + 1)}</span >{display.slice(
></span> 0,
commaIdx,
)},
<span
class="text-muted-foreground"
>{display.slice(
commaIdx +
1,
)}</span
></span
>
{:else} {:else}
<span>{display}</span> <span>{display}</span>
{/if} {/if}
</SimpleTooltip> </SimpleTooltip>
{/if} {/if}
{#if ratingData} {#if ratingData}
<SimpleTooltip {@const lowConfidence =
text="{ratingData.rating.toFixed( 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, 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" side="bottom"
passthrough sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
> >
<span <span
class="ml-1 text-xs font-medium {ratingColor( class="inline-flex items-center gap-1.5 text-xs"
ratingData.rating,
)}"
>{ratingData.rating.toFixed(
1,
)}★</span
> >
</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} {/if}
</td> </td>
{:else if colId === "time"} {:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip <SimpleTooltip
text={formatMeetingTimesTooltip(course.meetingTimes)} text={formatMeetingTimesTooltip(
course.meetingTimes,
)}
passthrough passthrough
> >
{#if timeIsTBA(course)} {#if timeIsTBA(course)}
@@ -566,10 +657,14 @@ const table = createSvelteTable({
</SimpleTooltip> </SimpleTooltip>
</td> </td>
{:else if colId === "location"} {:else if colId === "location"}
{@const concern = getDeliveryConcern(course)} {@const concern =
{@const accentColor = concernAccentColor(concern)} getDeliveryConcern(course)}
{@const locTooltip = formatLocationTooltip(course)} {@const accentColor =
{@const locDisplay = formatLocationDisplay(course)} concernAccentColor(concern)}
{@const locTooltip =
formatLocationTooltip(course)}
{@const locDisplay =
formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip} {#if locTooltip}
<SimpleTooltip <SimpleTooltip
@@ -579,18 +674,26 @@ const table = createSvelteTable({
> >
<span <span
class="text-muted-foreground" class="text-muted-foreground"
class:pl-2={accentColor !== null} class:pl-2={accentColor !==
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined} null}
style:border-left={accentColor
? `2px solid ${accentColor}`
: undefined}
> >
{locDisplay ?? "—"} {locDisplay ?? "—"}
</span> </span>
</SimpleTooltip> </SimpleTooltip>
{:else if locDisplay} {:else if locDisplay}
<span class="text-muted-foreground"> <span
class="text-muted-foreground"
>
{locDisplay} {locDisplay}
</span> </span>
{:else} {:else}
<span class="text-xs text-muted-foreground/50">—</span> <span
class="text-xs text-muted-foreground/50"
>—</span
>
{/if} {/if}
</td> </td>
{:else if colId === "seats"} {:else if colId === "seats"}
+3
View File
@@ -193,6 +193,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false, isPrimary: false,
rmpRating: null, rmpRating: null,
rmpNumRatings: null, rmpNumRatings: null,
rmpLegacyId: null,
}, },
{ {
bannerId: "2", bannerId: "2",
@@ -201,6 +202,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: true, isPrimary: true,
rmpRating: null, rmpRating: null,
rmpNumRatings: null, rmpNumRatings: null,
rmpLegacyId: null,
}, },
]; ];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B"); expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
@@ -214,6 +216,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false, isPrimary: false,
rmpRating: null, rmpRating: null,
rmpNumRatings: null, rmpNumRatings: null,
rmpLegacyId: null,
}, },
]; ];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A"); expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
+43 -5
View File
@@ -362,11 +362,49 @@ export function seatsDotColor(course: CourseResponse): string {
return "bg-green-500"; return "bg-green-500";
} }
/** Text color class for a RateMyProfessors rating */ /** Minimum number of ratings needed to consider RMP data reliable */
export function ratingColor(rating: number): string { export const RMP_CONFIDENCE_THRESHOLD = 7;
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500"; /** RMP professor page URL from legacy ID */
return "text-status-red"; 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 */ /** Format credit hours display */