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>(
|
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
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -21,206 +24,283 @@ const clipboard = useClipboard();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-muted/60 p-5 text-sm border-b border-border">
|
<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">
|
<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
|
{#if course.instructors.length > 0}
|
||||||
</h4>
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#if course.instructors.length > 0}
|
{#each course.instructors as instructor}
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<Tooltip.Root delayDuration={200}>
|
||||||
{#each course.instructors as instructor}
|
<Tooltip.Trigger>
|
||||||
<Tooltip.Root delayDuration={200}>
|
<span
|
||||||
<Tooltip.Trigger>
|
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"
|
||||||
<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}
|
||||||
{instructor.displayName}
|
{@const rating = instructor.rmpRating}
|
||||||
{#if instructor.rmpRating != null}
|
{@const lowConfidence =
|
||||||
{@const rating = instructor.rmpRating}
|
(instructor.rmpNumRatings ?? 0) <
|
||||||
<span
|
RMP_CONFIDENCE_THRESHOLD}
|
||||||
class="text-[10px] font-semibold {ratingColor(rating)}"
|
<span
|
||||||
>{rating.toFixed(1)}★</span>
|
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
|
||||||
{/if}
|
style={ratingStyle(
|
||||||
</span>
|
rating,
|
||||||
</Tooltip.Trigger>
|
themeStore.isDark,
|
||||||
<Tooltip.Content
|
)}
|
||||||
sideOffset={6}
|
>
|
||||||
class={cn(tooltipContentClass, "px-3 py-2")}
|
{rating.toFixed(1)}
|
||||||
>
|
{#if lowConfidence}
|
||||||
<div class="space-y-1.5">
|
<Triangle
|
||||||
<div class="font-medium">{instructor.displayName}</div>
|
class="size-2 fill-current"
|
||||||
{#if instructor.isPrimary}
|
/>
|
||||||
<div class="text-muted-foreground">Primary instructor</div>
|
{:else}
|
||||||
{/if}
|
<Star
|
||||||
{#if instructor.rmpRating != null}
|
class="size-2.5 fill-current"
|
||||||
<div class="text-muted-foreground">
|
/>
|
||||||
{instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings)
|
{/if}
|
||||||
</div>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if instructor.email}
|
</span>
|
||||||
<button
|
</Tooltip.Trigger>
|
||||||
onclick={(e) => clipboard.copy(instructor.email!, e)}
|
<Tooltip.Content
|
||||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
sideOffset={6}
|
||||||
>
|
class={cn(tooltipContentClass, "px-3 py-2")}
|
||||||
{#if clipboard.copiedValue === instructor.email}
|
>
|
||||||
<Check class="size-3" />
|
<div class="space-y-1.5">
|
||||||
<span>Copied!</span>
|
<div class="font-medium">
|
||||||
{:else}
|
{instructor.displayName}
|
||||||
<Copy class="size-3" />
|
</div>
|
||||||
<span>{instructor.email}</span>
|
{#if instructor.isPrimary}
|
||||||
{/if}
|
<div class="text-muted-foreground">
|
||||||
</button>
|
Primary instructor
|
||||||
{/if}
|
</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>
|
</div>
|
||||||
</Tooltip.Content>
|
{:else}
|
||||||
</Tooltip.Root>
|
<span class="text-muted-foreground italic">Staff</span>
|
||||||
{/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
|
|
||||||
{/if}
|
{/if}
|
||||||
</Tooltip.Content>
|
</div>
|
||||||
</Tooltip.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Waitlist -->
|
<!-- Meeting Times -->
|
||||||
{#if course.waitCapacity > 0}
|
<div>
|
||||||
<div>
|
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
|
||||||
<h4 class="text-sm text-foreground mb-2">
|
{#if course.meetingTimes.length > 0}
|
||||||
Waitlist
|
<ul class="space-y-2">
|
||||||
</h4>
|
{#each course.meetingTimes as mt}
|
||||||
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span>
|
<li>
|
||||||
</div>
|
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
|
||||||
{/if}
|
<span class="italic text-muted-foreground"
|
||||||
</div>
|
>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>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -269,12 +288,12 @@ const table = createSvelteTable({
|
|||||||
transition:fly={{ duration: 150, y: -10 }}
|
transition:fly={{ duration: 150, y: -10 }}
|
||||||
>
|
>
|
||||||
{@render columnVisibilityGroup(
|
{@render columnVisibilityGroup(
|
||||||
DropdownMenu.Group,
|
DropdownMenu.Group,
|
||||||
DropdownMenu.GroupHeading,
|
DropdownMenu.GroupHeading,
|
||||||
DropdownMenu.CheckboxItem,
|
DropdownMenu.CheckboxItem,
|
||||||
DropdownMenu.Separator,
|
DropdownMenu.Separator,
|
||||||
DropdownMenu.Item,
|
DropdownMenu.Item,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -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 <
|
||||||
1,
|
RMP_CONFIDENCE_THRESHOLD}
|
||||||
)}/5 ({ratingData.count} ratings on RateMyProfessors)"
|
<Tooltip.Root
|
||||||
delay={150}
|
delayDuration={150}
|
||||||
side="bottom"
|
|
||||||
passthrough
|
|
||||||
>
|
>
|
||||||
<span
|
<Tooltip.Trigger>
|
||||||
class="ml-1 text-xs font-medium {ratingColor(
|
<span
|
||||||
ratingData.rating,
|
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
|
||||||
)}"
|
style={ratingStyle(
|
||||||
>{ratingData.rating.toFixed(
|
ratingData.rating,
|
||||||
1,
|
themeStore.isDark,
|
||||||
)}★</span
|
)}
|
||||||
|
>
|
||||||
|
{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}
|
{/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"}
|
||||||
@@ -668,12 +771,12 @@ const table = createSvelteTable({
|
|||||||
out:fade={{ duration: 100 }}
|
out:fade={{ duration: 100 }}
|
||||||
>
|
>
|
||||||
{@render columnVisibilityGroup(
|
{@render columnVisibilityGroup(
|
||||||
ContextMenu.Group,
|
ContextMenu.Group,
|
||||||
ContextMenu.GroupHeading,
|
ContextMenu.GroupHeading,
|
||||||
ContextMenu.CheckboxItem,
|
ContextMenu.CheckboxItem,
|
||||||
ContextMenu.Separator,
|
ContextMenu.Separator,
|
||||||
ContextMenu.Item,
|
ContextMenu.Item,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user