From db0ec1e69da82bb05992afb756ad7413e1ea641f Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 15:43:21 -0600 Subject: [PATCH] feat: add rmp profile links and confidence-aware rating display --- src/data/courses.rs | 4 +- src/data/models.rs | 1 + src/web/routes.rs | 2 + web/src/lib/components/CourseDetail.svelte | 482 ++++++++++++--------- web/src/lib/components/CourseTable.svelte | 207 ++++++--- web/src/lib/course.test.ts | 3 + web/src/lib/course.ts | 48 +- 7 files changed, 487 insertions(+), 260 deletions(-) diff --git a/src/data/courses.rs b/src/data/courses.rs index d6e5a85..257a34b 100644 --- a/src/data/courses.rs +++ b/src/data/courses.rs @@ -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 diff --git a/src/data/models.rs b/src/data/models.rs index c56eeb7..0f79a32 100644 --- a/src/data/models.rs +++ b/src/data/models.rs @@ -85,6 +85,7 @@ pub struct CourseInstructorDetail { pub is_primary: bool, pub avg_rating: Option, pub num_ratings: Option, + pub rmp_legacy_id: Option, /// Present when fetched via batch query; `None` for single-course queries. pub course_id: Option, } diff --git a/src/web/routes.rs b/src/web/routes.rs index 9646ca8..8c619dd 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -439,6 +439,7 @@ pub struct InstructorResponse { is_primary: bool, rmp_rating: Option, rmp_num_ratings: Option, + rmp_legacy_id: Option, } #[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(); diff --git a/web/src/lib/components/CourseDetail.svelte b/web/src/lib/components/CourseDetail.svelte index bc498e6..afe07af 100644 --- a/web/src/lib/components/CourseDetail.svelte +++ b/web/src/lib/components/CourseDetail.svelte @@ -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(); @@ -21,206 +24,283 @@ const clipboard = useClipboard();
-
- -
-

- Instructors -

- {#if course.instructors.length > 0} -
- {#each course.instructors as instructor} - - - - {instructor.displayName} - {#if instructor.rmpRating != null} - {@const rating = instructor.rmpRating} - {rating.toFixed(1)}★ - {/if} - - - -
-
{instructor.displayName}
- {#if instructor.isPrimary} -
Primary instructor
- {/if} - {#if instructor.rmpRating != null} -
- {instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings) -
- {/if} - {#if instructor.email} - - {/if} +
+ +
+

Instructors

+ {#if course.instructors.length > 0} +
+ {#each course.instructors as instructor} + + + + {instructor.displayName} + {#if instructor.rmpRating != null} + {@const rating = instructor.rmpRating} + {@const lowConfidence = + (instructor.rmpNumRatings ?? 0) < + RMP_CONFIDENCE_THRESHOLD} + + {rating.toFixed(1)} + {#if lowConfidence} + + {:else} + + {/if} + + {/if} + + + +
+
+ {instructor.displayName} +
+ {#if instructor.isPrimary} +
+ Primary instructor +
+ {/if} + {#if instructor.rmpRating != null} +
+ {instructor.rmpRating.toFixed(1)}/5 + · {instructor.rmpNumRatings ?? 0} ratings + {#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD} + (low) + {/if} +
+ {/if} + {#if instructor.rmpLegacyId != null} + + + View on RMP + + {/if} + {#if instructor.email} + + {/if} +
+
+
+ {/each}
- - - {/each} -
- {:else} - Staff - {/if} -
- - -
-

- Meeting Times -

- {#if course.meetingTimes.length > 0} -
    - {#each course.meetingTimes as mt} -
  • - {#if isMeetingTimeTBA(mt) && isTimeTBA(mt)} - TBA - {:else} -
    - {#if !isMeetingTimeTBA(mt)} - - {formatMeetingDaysLong(mt)} - - {/if} - {#if !isTimeTBA(mt)} - - {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} - - {:else} - Time TBA - {/if} -
    - {/if} - {#if mt.building || mt.room} -
    - {mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""} -
    - {/if} -
    - {formatDate(mt.start_date)} – {formatDate(mt.end_date)} -
    -
  • - {/each} -
- {:else} - TBA - {/if} -
- - -
-

- - Delivery - - - - -

- - {course.instructionalMethod ?? "—"} - {#if course.campus} - · {course.campus} - {/if} - -
- - -
-

- Credits -

- {formatCreditHours(course)} -
- - - {#if course.attributes.length > 0} -
-

- - Attributes - - - - -

-
- {#each course.attributes as attr} - - - {attr} - - - {/each} -
-
- {/if} - - - {#if course.crossList} -
-

- - Cross-list - - - - -

- - - - - {course.crossList} - - {#if course.crossListCount != null && course.crossListCapacity != null} - - {course.crossListCount}/{course.crossListCapacity} - - {/if} - - - - Group {course.crossList} - {#if course.crossListCount != null && course.crossListCapacity != null} - — {course.crossListCount} enrolled across {course.crossListCapacity} shared seats + {:else} + Staff {/if} - - -
- {/if} +
- - {#if course.waitCapacity > 0} -
-

- Waitlist -

- {course.waitCount} / {course.waitCapacity} -
- {/if} -
+ +
+

Meeting Times

+ {#if course.meetingTimes.length > 0} +
    + {#each course.meetingTimes as mt} +
  • + {#if isMeetingTimeTBA(mt) && isTimeTBA(mt)} + TBA + {:else} +
    + {#if !isMeetingTimeTBA(mt)} + + {formatMeetingDaysLong(mt)} + + {/if} + {#if !isTimeTBA(mt)} + + {formatTime( + mt.begin_time, + )}–{formatTime(mt.end_time)} + + {:else} + Time TBA + {/if} +
    + {/if} + {#if mt.building || mt.room} +
    + {mt.building_description ?? + mt.building}{mt.room + ? ` ${mt.room}` + : ""} +
    + {/if} +
    + {formatDate(mt.start_date)} – {formatDate( + mt.end_date, + )} +
    +
  • + {/each} +
+ {:else} + TBA + {/if} +
+ + +
+

+ + Delivery + + + + +

+ + {course.instructionalMethod ?? "—"} + {#if course.campus} + + · {course.campus} + + {/if} + +
+ + +
+

Credits

+ {formatCreditHours(course)} +
+ + + {#if course.attributes.length > 0} +
+

+ + Attributes + + + + +

+
+ {#each course.attributes as attr} + + + {attr} + + + {/each} +
+
+ {/if} + + + {#if course.crossList} +
+

+ + Cross-list + + + + +

+ + + + + {course.crossList} + + {#if course.crossListCount != null && course.crossListCapacity != null} + + {course.crossListCount}/{course.crossListCapacity} + + {/if} + + + + Group {course.crossList} + {#if course.crossListCount != null && course.crossListCapacity != null} + — {course.crossListCount} enrolled across {course.crossListCapacity} + shared seats + {/if} + + +
+ {/if} + + + {#if course.waitCapacity > 0} +
+

Waitlist

+ {course.waitCount} / {course.waitCapacity} +
+ {/if} +
diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index 3de5875..0c2c9df 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -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({ {#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} {@render columnVisibilityGroup( - DropdownMenu.Group, - DropdownMenu.GroupHeading, - DropdownMenu.CheckboxItem, - DropdownMenu.Separator, - DropdownMenu.Item, - )} + DropdownMenu.Group, + DropdownMenu.GroupHeading, + DropdownMenu.CheckboxItem, + DropdownMenu.Separator, + DropdownMenu.Item, + )}
{/if} @@ -384,7 +403,10 @@ const table = createSvelteTable({ {@const course = row.original} {#if display === "Staff"} {#if commaIdx !== -1} - {display.slice(0, commaIdx)}, - {display.slice(commaIdx + 1)} + {display.slice( + 0, + commaIdx, + )}, + {display.slice( + commaIdx + + 1, + )} {:else} {display} {/if} {/if} {#if ratingData} - - {ratingData.rating.toFixed( - 1, - )}★ + + {ratingData.rating.toFixed( + 1, + )} + {#if lowConfidence} + + {:else} + + {/if} + + + - + + {ratingData.rating.toFixed( + 1, + )}/5 · {ratingData.count} + ratings + {#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD} + (low) + {/if} + {#if ratingData.legacyId != null} + · + + RMP + + + {/if} + + + {/if} {:else if colId === "time"} {#if timeIsTBA(course)} @@ -566,10 +657,14 @@ const table = createSvelteTable({ {: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)} {#if locTooltip} {locDisplay ?? "—"} {:else if locDisplay} - + {locDisplay} {:else} - + {/if} {:else if colId === "seats"} @@ -668,12 +771,12 @@ const table = createSvelteTable({ out:fade={{ duration: 100 }} > {@render columnVisibilityGroup( - ContextMenu.Group, - ContextMenu.GroupHeading, - ContextMenu.CheckboxItem, - ContextMenu.Separator, - ContextMenu.Item, - )} + ContextMenu.Group, + ContextMenu.GroupHeading, + ContextMenu.CheckboxItem, + ContextMenu.Separator, + ContextMenu.Item, + )} {/if} diff --git a/web/src/lib/course.test.ts b/web/src/lib/course.test.ts index 5b5d2be..ca8a43f 100644 --- a/web/src/lib/course.test.ts +++ b/web/src/lib/course.test.ts @@ -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"); diff --git a/web/src/lib/course.ts b/web/src/lib/course.ts index f004c8b..ca2fc79 100644 --- a/web/src/lib/course.ts +++ b/web/src/lib/course.ts @@ -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 */