mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
feat: add rmp profile links and confidence-aware rating display
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
<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="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}
|
||||
<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 instructor.rmpRating != null}
|
||||
{@const rating = instructor.rmpRating}
|
||||
<span
|
||||
class="text-[10px] font-semibold {ratingColor(rating)}"
|
||||
>{rating.toFixed(1)}★</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
class={cn(tooltipContentClass, "px-3 py-2")}
|
||||
>
|
||||
<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 instructor.rmpRating != null}
|
||||
<div class="text-muted-foreground">
|
||||
{instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings)
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.email}
|
||||
<button
|
||||
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}
|
||||
<Check class="size-3" />
|
||||
<span>Copied!</span>
|
||||
{:else}
|
||||
<Copy class="size-3" />
|
||||
<span>{instructor.email}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<!-- Instructors -->
|
||||
<div>
|
||||
<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}
|
||||
<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 instructor.rmpRating != null}
|
||||
{@const rating = instructor.rmpRating}
|
||||
{@const lowConfidence =
|
||||
(instructor.rmpNumRatings ?? 0) <
|
||||
RMP_CONFIDENCE_THRESHOLD}
|
||||
<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>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
class={cn(tooltipContentClass, "px-3 py-2")}
|
||||
>
|
||||
<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 instructor.rmpRating != null}
|
||||
<div class="text-muted-foreground">
|
||||
{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,
|
||||
)}
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{#if clipboard.copiedValue === 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}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">Staff</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Meeting Times -->
|
||||
<div>
|
||||
<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>
|
||||
{: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}
|
||||
{#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="italic text-muted-foreground">TBA</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delivery -->
|
||||
<div>
|
||||
<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>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<span class="text-foreground">
|
||||
{course.instructionalMethod ?? "—"}
|
||||
{#if course.campus}
|
||||
<span class="text-muted-foreground"> · {course.campus}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
<div>
|
||||
<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="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>
|
||||
<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>
|
||||
<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>
|
||||
</SimpleTooltip>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cross-list -->
|
||||
{#if course.crossList}
|
||||
<div>
|
||||
<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>
|
||||
<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">
|
||||
{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
|
||||
sideOffset={6}
|
||||
class={tooltipContentClass}
|
||||
>
|
||||
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
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">Staff</span>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Meeting Times -->
|
||||
<div>
|
||||
<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
|
||||
>
|
||||
{: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}
|
||||
{#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="italic text-muted-foreground">TBA</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delivery -->
|
||||
<div>
|
||||
<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
|
||||
>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<span class="text-foreground">
|
||||
{course.instructionalMethod ?? "—"}
|
||||
{#if course.campus}
|
||||
<span class="text-muted-foreground">
|
||||
· {course.campus}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
<div>
|
||||
<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="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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
</SimpleTooltip>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cross-list -->
|
||||
{#if course.crossList}
|
||||
<div>
|
||||
<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
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{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 sideOffset={6} class={tooltipContentClass}>
|
||||
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="text-sm text-foreground mb-2">Waitlist</h4>
|
||||
<span class="text-2foreground"
|
||||
>{course.waitCount} / {course.waitCapacity}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
@@ -269,12 +288,12 @@ const table = createSvelteTable({
|
||||
transition:fly={{ duration: 150, y: -10 }}
|
||||
>
|
||||
{@render columnVisibilityGroup(
|
||||
DropdownMenu.Group,
|
||||
DropdownMenu.GroupHeading,
|
||||
DropdownMenu.CheckboxItem,
|
||||
DropdownMenu.Separator,
|
||||
DropdownMenu.Item,
|
||||
)}
|
||||
DropdownMenu.Group,
|
||||
DropdownMenu.GroupHeading,
|
||||
DropdownMenu.CheckboxItem,
|
||||
DropdownMenu.Separator,
|
||||
DropdownMenu.Item,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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(
|
||||
1,
|
||||
)}/5 ({ratingData.count} ratings on RateMyProfessors)"
|
||||
delay={150}
|
||||
side="bottom"
|
||||
passthrough
|
||||
{@const lowConfidence =
|
||||
ratingData.count <
|
||||
RMP_CONFIDENCE_THRESHOLD}
|
||||
<Tooltip.Root
|
||||
delayDuration={150}
|
||||
>
|
||||
<span
|
||||
class="ml-1 text-xs font-medium {ratingColor(
|
||||
ratingData.rating,
|
||||
)}"
|
||||
>{ratingData.rating.toFixed(
|
||||
1,
|
||||
)}★</span
|
||||
<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,
|
||||
)}
|
||||
{#if lowConfidence}
|
||||
<Triangle
|
||||
class="size-2 fill-current"
|
||||
/>
|
||||
{:else}
|
||||
<Star
|
||||
class="size-2.5 fill-current"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
class={cn(
|
||||
tooltipContentClass,
|
||||
"px-2.5 py-1.5",
|
||||
)}
|
||||
>
|
||||
</SimpleTooltip>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
{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"}
|
||||
@@ -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,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user