feat: refactor admin instructor UI with component extraction and optimistic updates

This commit is contained in:
2026-01-30 19:31:31 -06:00
parent 474d519b9d
commit a103f0643a
6 changed files with 984 additions and 398 deletions
+154 -16
View File
@@ -78,8 +78,9 @@ fn department_similarity(subjects: &[String], rmp_department: Option<&str>) -> f
/// Expand common subject abbreviations used at UTSA and check for overlap.
fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
const MAPPINGS: &[(&str, &[&str])] = &[
// Core subjects (original mappings, corrected)
("cs", &["computer science"]),
("ece", &["electrical", "computer engineering"]),
("ece", &["early childhood education", "early childhood"]),
("ee", &["electrical engineering", "electrical"]),
("me", &["mechanical engineering", "mechanical"]),
("ce", &["civil engineering", "civil"]),
@@ -105,6 +106,85 @@ fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
("ms", &["management science"]),
("kin", &["kinesiology"]),
("com", &["communication"]),
// Architecture & Design
("arc", &["architecture"]),
("ide", &["interior design", "design"]),
// Anthropology & Ethnic Studies
("ant", &["anthropology"]),
("aas", &["african american studies", "ethnic studies"]),
("mas", &["mexican american studies", "ethnic studies"]),
("regs", &["ethnic studies", "gender"]),
// Languages
("lng", &["linguistics", "applied linguistics"]),
("spn", &["spanish"]),
("frn", &["french"]),
("ger", &["german"]),
("chn", &["chinese"]),
("jpn", &["japanese"]),
("kor", &["korean"]),
("itl", &["italian"]),
("rus", &["russian"]),
("lat", &["latin"]),
("grk", &["greek"]),
("asl", &["american sign language", "sign language"]),
(
"fl",
&["foreign languages", "languages", "modern languages"],
),
// Education
("edu", &["education"]),
("ci", &["curriculum", "education"]),
("edl", &["educational leadership", "education"]),
("edp", &["educational psychology", "education"]),
("bbl", &["bilingual education"]),
("spe", &["special education", "education"]),
// Business
("ent", &["entrepreneurship"]),
("gba", &["general business", "business"]),
("blw", &["business law", "law"]),
("rfd", &["real estate"]),
("mot", &["management of technology", "management"]),
// Engineering
("egr", &["engineering"]),
("bme", &["biomedical engineering", "engineering"]),
("cme", &["chemical engineering", "engineering"]),
("cpe", &["computer engineering", "engineering"]),
("ise", &["industrial", "systems engineering", "engineering"]),
("mate", &["materials engineering", "engineering"]),
// Sciences
("che", &["chemistry"]),
("bch", &["biochemistry", "chemistry"]),
("geo", &["geology"]),
("phy", &["physics"]),
("ast", &["astronomy"]),
("es", &["environmental science"]),
// Social Sciences
("crj", &["criminal justice"]),
("swk", &["social work"]),
("pad", &["public administration"]),
("grg", &["geography"]),
("ges", &["geography"]),
// Humanities
("cla", &["classics"]),
("hum", &["humanities"]),
("wgss", &["women's studies"]),
// Health
("hth", &["health"]),
("hcp", &["health science", "health"]),
("ntr", &["nutrition"]),
// Military
("msc", &["military science"]),
("asc", &["aerospace"]),
// Arts
("dan", &["dance"]),
("thr", &["theater"]),
("ahc", &["art history"]),
// Other
("cou", &["counseling"]),
("hon", &["honors"]),
("csm", &["construction"]),
("wrc", &["writing"]),
("set", &["tourism management", "tourism"]),
];
for &(abbr, expansions) in MAPPINGS {
@@ -164,6 +244,7 @@ pub fn compute_match_score(
pub struct MatchingStats {
pub total_unmatched: usize,
pub candidates_created: usize,
pub candidates_rescored: usize,
pub auto_matched: usize,
pub skipped_unparseable: usize,
pub skipped_no_candidates: usize,
@@ -200,6 +281,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
return Ok(MatchingStats {
total_unmatched: 0,
candidates_created: 0,
candidates_rescored: 0,
auto_matched: 0,
skipped_unparseable: 0,
skipped_no_candidates: 0,
@@ -245,24 +327,34 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
});
}
// 4. Load existing candidate pairs (and rejected subset) in a single query
// 4. Load existing candidate pairs — only skip resolved (accepted/rejected) pairs.
// Pending candidates are rescored so updated mappings take effect.
let candidate_rows: Vec<(i32, i32, String)> =
sqlx::query_as("SELECT instructor_id, rmp_legacy_id, status FROM rmp_match_candidates")
.fetch_all(db_pool)
.await?;
let mut existing_pairs: HashSet<(i32, i32)> = HashSet::with_capacity(candidate_rows.len());
let mut resolved_pairs: HashSet<(i32, i32)> = HashSet::new();
let mut pending_pairs: HashSet<(i32, i32)> = HashSet::new();
let mut rejected_pairs: HashSet<(i32, i32)> = HashSet::new();
for (iid, lid, status) in candidate_rows {
existing_pairs.insert((iid, lid));
match status.as_str() {
"accepted" | "rejected" => {
resolved_pairs.insert((iid, lid));
if status == "rejected" {
rejected_pairs.insert((iid, lid));
}
}
_ => {
pending_pairs.insert((iid, lid));
}
}
}
// 5. Score and collect candidates
// 5. Score and collect candidates (new + rescored pending)
let empty_subjects: Vec<String> = Vec::new();
let mut candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
let mut new_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
let mut rescored_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
let mut auto_accept: Vec<(i32, i32)> = Vec::new(); // (instructor_id, legacy_id)
let mut skipped_unparseable = 0usize;
let mut skipped_no_candidates = 0usize;
@@ -290,7 +382,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
for prof in rmp_candidates {
let pair = (*instructor_id, prof.legacy_id);
if existing_pairs.contains(&pair) {
if resolved_pairs.contains(&pair) {
continue;
}
@@ -308,7 +400,16 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
let breakdown_json =
serde_json::to_value(&ms.breakdown).unwrap_or_else(|_| serde_json::json!({}));
candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json));
if pending_pairs.contains(&pair) {
rescored_candidates.push((
*instructor_id,
prof.legacy_id,
ms.score,
breakdown_json,
));
} else {
new_candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json));
}
match best {
Some((s, _)) if ms.score > s => best = Some((ms.score, prof.legacy_id)),
@@ -327,19 +428,20 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
}
}
// 67. Write candidates and auto-accept within a single transaction
let candidates_created = candidates.len();
// 67. Write candidates, rescore, and auto-accept within a single transaction
let candidates_created = new_candidates.len();
let candidates_rescored = rescored_candidates.len();
let auto_matched = auto_accept.len();
let mut tx = db_pool.begin().await?;
// 6. Batch-insert candidates
if !candidates.is_empty() {
let c_instructor_ids: Vec<i32> = candidates.iter().map(|(iid, _, _, _)| *iid).collect();
let c_legacy_ids: Vec<i32> = candidates.iter().map(|(_, lid, _, _)| *lid).collect();
let c_scores: Vec<f32> = candidates.iter().map(|(_, _, s, _)| *s).collect();
// 6a. Batch-insert new candidates
if !new_candidates.is_empty() {
let c_instructor_ids: Vec<i32> = new_candidates.iter().map(|(iid, _, _, _)| *iid).collect();
let c_legacy_ids: Vec<i32> = new_candidates.iter().map(|(_, lid, _, _)| *lid).collect();
let c_scores: Vec<f32> = new_candidates.iter().map(|(_, _, s, _)| *s).collect();
let c_breakdowns: Vec<serde_json::Value> =
candidates.into_iter().map(|(_, _, _, b)| b).collect();
new_candidates.into_iter().map(|(_, _, _, b)| b).collect();
sqlx::query(
r#"
@@ -358,6 +460,40 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
.await?;
}
// 6b. Batch-update rescored pending candidates
if !rescored_candidates.is_empty() {
let r_instructor_ids: Vec<i32> = rescored_candidates
.iter()
.map(|(iid, _, _, _)| *iid)
.collect();
let r_legacy_ids: Vec<i32> = rescored_candidates
.iter()
.map(|(_, lid, _, _)| *lid)
.collect();
let r_scores: Vec<f32> = rescored_candidates.iter().map(|(_, _, s, _)| *s).collect();
let r_breakdowns: Vec<serde_json::Value> = rescored_candidates
.into_iter()
.map(|(_, _, _, b)| b)
.collect();
sqlx::query(
r#"
UPDATE rmp_match_candidates mc
SET score = v.score, score_breakdown = v.score_breakdown
FROM UNNEST($1::int4[], $2::int4[], $3::real[], $4::jsonb[])
AS v(instructor_id, rmp_legacy_id, score, score_breakdown)
WHERE mc.instructor_id = v.instructor_id
AND mc.rmp_legacy_id = v.rmp_legacy_id
"#,
)
.bind(&r_instructor_ids)
.bind(&r_legacy_ids)
.bind(&r_scores)
.bind(&r_breakdowns)
.execute(&mut *tx)
.await?;
}
// 7. Auto-accept top candidates
if !auto_accept.is_empty() {
let aa_instructor_ids: Vec<i32> = auto_accept.iter().map(|(iid, _)| *iid).collect();
@@ -411,6 +547,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
let stats = MatchingStats {
total_unmatched,
candidates_created,
candidates_rescored,
auto_matched,
skipped_unparseable,
skipped_no_candidates,
@@ -419,6 +556,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
info!(
total_unmatched = stats.total_unmatched,
candidates_created = stats.candidates_created,
candidates_rescored = stats.candidates_rescored,
auto_matched = stats.auto_matched,
skipped_unparseable = stats.skipped_unparseable,
skipped_no_candidates = stats.skipped_no_candidates,
+1
View File
@@ -310,6 +310,7 @@ impl Scheduler {
total,
stats.total_unmatched,
stats.candidates_created,
stats.candidates_rescored,
stats.auto_matched,
stats.skipped_unparseable,
stats.skipped_no_candidates,
+2
View File
@@ -180,6 +180,7 @@ pub struct InstructorDetailResponse {
pub struct RescoreResponse {
pub total_unmatched: usize,
pub candidates_created: usize,
pub candidates_rescored: usize,
pub auto_matched: usize,
pub skipped_unparseable: usize,
pub skipped_no_candidates: usize,
@@ -858,6 +859,7 @@ pub async fn rescore(
Ok(Json(RescoreResponse {
total_unmatched: stats.total_unmatched,
candidates_created: stats.candidates_created,
candidates_rescored: stats.candidates_rescored,
auto_matched: stats.auto_matched,
skipped_unparseable: stats.skipped_unparseable,
skipped_no_candidates: stats.skipped_no_candidates,
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { slide, fade } from "svelte/transition";
import {
client,
type InstructorListItem,
@@ -7,19 +8,22 @@ import {
type InstructorDetailResponse,
type CandidateResponse,
} from "$lib/api";
import { formatInstructorName, isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
import { formatInstructorName, isRatingValid, ratingStyle } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import {
Check,
ChevronLeft,
ChevronRight,
ExternalLink,
LoaderCircle,
RefreshCw,
Search,
X,
XCircle,
} from "@lucide/svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import CandidateCard from "./CandidateCard.svelte";
// --- State ---
let subjectMap = $state(new Map<string, string>());
let instructors = $state<InstructorListItem[]>([]);
let stats = $state<InstructorStats>({
total: 0,
@@ -47,34 +51,84 @@ let detailError = $state<string | null>(null);
// Action states
let actionLoading = $state<string | null>(null);
let rescoreLoading = $state(false);
let rescoreResult = $state<string | null>(null);
let rescoreResult = $state<{ message: string; isError: boolean } | null>(null);
// Stale row tracking: rows that changed status but haven't been refetched yet
let recentlyChanged = $state(new Set<number>());
let highlightTimeouts = new Map<number, ReturnType<typeof setTimeout>>();
// Reject-all inline confirmation
let showRejectConfirm = $state<number | null>(null);
// Search debounce
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const filterOptions = [
{ label: "All", value: undefined as string | undefined },
{ label: "Needs Review", value: "unmatched" },
{ label: "Auto-matched", value: "auto" },
{ label: "Confirmed", value: "confirmed" },
{ label: "Rejected", value: "rejected" },
// --- Constants ---
const filterCards: Array<{
label: string;
value: string | undefined;
stat: keyof InstructorStats;
textColor: string;
ringColor: string;
}> = [
{
label: "Total",
value: undefined,
stat: "total",
textColor: "text-muted-foreground",
ringColor: "ring-primary",
},
{
label: "Unmatched",
value: "unmatched",
stat: "unmatched",
textColor: "text-amber-600 dark:text-amber-400",
ringColor: "ring-amber-500",
},
{
label: "Auto",
value: "auto",
stat: "auto",
textColor: "text-blue-600 dark:text-blue-400",
ringColor: "ring-blue-500",
},
{
label: "Confirmed",
value: "confirmed",
stat: "confirmed",
textColor: "text-green-600 dark:text-green-400",
ringColor: "ring-green-500",
},
{
label: "Rejected",
value: "rejected",
stat: "rejected",
textColor: "text-red-600 dark:text-red-400",
ringColor: "ring-red-500",
},
];
const progressSegments = [
{ stat: "auto" as keyof InstructorStats, color: "bg-blue-500", label: "Auto" },
{ stat: "confirmed" as keyof InstructorStats, color: "bg-green-500", label: "Confirmed" },
{ stat: "unmatched" as keyof InstructorStats, color: "bg-amber-500", label: "Unmatched" },
{ stat: "rejected" as keyof InstructorStats, color: "bg-red-500", label: "Rejected" },
];
// --- Derived ---
let matchedLegacyIds = $derived(
new Set(detail?.currentMatches.map((m: { legacyId: number }) => m.legacyId) ?? [])
);
let progressDenom = $derived(stats.total || 1);
let autoPct = $derived((stats.auto / progressDenom) * 100);
let confirmedPct = $derived((stats.confirmed / progressDenom) * 100);
let unmatchedPct = $derived((stats.unmatched / progressDenom) * 100);
let totalPages = $derived(Math.max(1, Math.ceil(totalCount / perPage)));
// --- Data fetching ---
async function fetchInstructors() {
loading = true;
error = null;
recentlyChanged = new Set();
clearHighlightTimeouts();
try {
const res = await client.getAdminInstructors({
status: activeFilter,
@@ -107,14 +161,31 @@ async function fetchDetail(id: number) {
onMount(() => {
fetchInstructors();
client
.getReference("subject")
.then((entries) => {
const map = new Map<string, string>();
for (const entry of entries) {
map.set(entry.code, entry.description);
}
subjectMap = map;
})
.catch(() => {
// Subject lookup is best-effort
});
});
onDestroy(() => clearTimeout(searchTimeout));
onDestroy(() => {
clearTimeout(searchTimeout);
clearHighlightTimeouts();
});
// --- Navigation & filters ---
function setFilter(value: string | undefined) {
activeFilter = value;
currentPage = 1;
expandedId = null;
showRejectConfirm = null;
fetchInstructors();
}
@@ -128,10 +199,28 @@ function handleSearch() {
}, 300);
}
function clearSearch() {
searchInput = "";
searchQuery = "";
currentPage = 1;
expandedId = null;
fetchInstructors();
}
function clearAllFilters() {
searchInput = "";
searchQuery = "";
activeFilter = undefined;
currentPage = 1;
expandedId = null;
fetchInstructors();
}
function goToPage(page: number) {
if (page < 1 || page > totalPages) return;
currentPage = page;
expandedId = null;
showRejectConfirm = null;
fetchInstructors();
}
@@ -139,18 +228,64 @@ async function toggleExpand(id: number) {
if (expandedId === id) {
expandedId = null;
detail = null;
showRejectConfirm = null;
return;
}
expandedId = id;
showRejectConfirm = null;
await fetchDetail(id);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && expandedId !== null) {
expandedId = null;
detail = null;
showRejectConfirm = null;
}
}
// --- Local state updates (no refetch) ---
function updateLocalStatus(instructorId: number, newStatus: string) {
instructors = instructors.map((i) =>
i.id === instructorId ? { ...i, rmpMatchStatus: newStatus } : i
);
markChanged(instructorId);
}
function markChanged(id: number) {
// Clear any existing timeout for this ID
const existing = highlightTimeouts.get(id);
if (existing) clearTimeout(existing);
recentlyChanged = new Set([...recentlyChanged, id]);
const timeout = setTimeout(() => {
const next = new Set(recentlyChanged);
next.delete(id);
recentlyChanged = next;
highlightTimeouts.delete(id);
}, 2000);
highlightTimeouts.set(id, timeout);
}
function clearHighlightTimeouts() {
for (const timeout of highlightTimeouts.values()) {
clearTimeout(timeout);
}
highlightTimeouts.clear();
}
function matchesFilter(status: string): boolean {
if (!activeFilter) return true;
return status === activeFilter;
}
// --- Actions ---
async function handleMatch(instructorId: number, rmpLegacyId: number) {
actionLoading = `match-${rmpLegacyId}`;
try {
detail = await client.matchInstructor(instructorId, rmpLegacyId);
await fetchInstructors();
updateLocalStatus(instructorId, "confirmed");
} catch (e) {
detailError = e instanceof Error ? e.message : "Match failed";
} finally {
@@ -162,7 +297,7 @@ async function handleReject(instructorId: number, rmpLegacyId: number) {
actionLoading = `reject-${rmpLegacyId}`;
try {
await client.rejectCandidate(instructorId, rmpLegacyId);
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
await fetchDetail(instructorId);
} catch (e) {
detailError = e instanceof Error ? e.message : "Reject failed";
} finally {
@@ -170,11 +305,17 @@ async function handleReject(instructorId: number, rmpLegacyId: number) {
}
}
async function handleRejectAll(instructorId: number) {
function requestRejectAll(instructorId: number) {
showRejectConfirm = instructorId;
}
async function confirmRejectAll(instructorId: number) {
showRejectConfirm = null;
actionLoading = "reject-all";
try {
await client.rejectAllCandidates(instructorId);
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
await fetchDetail(instructorId);
updateLocalStatus(instructorId, "rejected");
} catch (e) {
detailError = e instanceof Error ? e.message : "Reject all failed";
} finally {
@@ -182,11 +323,16 @@ async function handleRejectAll(instructorId: number) {
}
}
function cancelRejectAll() {
showRejectConfirm = null;
}
async function handleUnmatch(instructorId: number, rmpLegacyId: number) {
actionLoading = `unmatch-${rmpLegacyId}`;
try {
await client.unmatchInstructor(instructorId, rmpLegacyId);
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
await fetchDetail(instructorId);
updateLocalStatus(instructorId, "unmatched");
} catch (e) {
detailError = e instanceof Error ? e.message : "Unmatch failed";
} finally {
@@ -199,10 +345,16 @@ async function handleRescore() {
rescoreResult = null;
try {
const res = await client.rescoreInstructors();
rescoreResult = `Rescored: ${res.totalUnmatched} unmatched, ${res.candidatesCreated} candidates created, ${res.autoMatched} auto-matched`;
rescoreResult = {
message: `Rescored: ${res.totalUnmatched} unmatched, ${res.candidatesCreated} candidates created, ${res.autoMatched} auto-matched`,
isError: false,
};
await fetchInstructors();
} catch (e) {
rescoreResult = e instanceof Error ? e.message : "Rescore failed";
rescoreResult = {
message: e instanceof Error ? e.message : "Rescore failed",
isError: true,
};
} finally {
rescoreLoading = false;
}
@@ -239,52 +391,85 @@ function statusBadge(status: string): { label: string; classes: string } {
function formatScore(score: number): string {
return (score * 100).toFixed(0);
}
const scoreWeights: Record<string, number> = {
name: 0.5,
department: 0.25,
uniqueness: 0.15,
volume: 0.1,
};
const scoreColors: Record<string, string> = {
name: "bg-blue-500",
department: "bg-purple-500",
uniqueness: "bg-amber-500",
volume: "bg-emerald-500",
};
const scoreLabels: Record<string, string> = {
name: "Name",
department: "Dept",
uniqueness: "Unique",
volume: "Volume",
};
</script>
<div class="flex items-center justify-between mb-4">
<svelte:window onkeydown={handleKeydown} />
<!-- Header -->
<div class="flex items-center gap-3 mb-4">
<h1 class="text-lg font-semibold text-foreground">Instructors</h1>
<div class="flex-1"></div>
<!-- Search -->
<div class="relative">
<Search
size={14}
class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
type="text"
placeholder="Search name or email..."
bind:value={searchInput}
oninput={handleSearch}
class="bg-card border-border rounded-md border pl-8 pr-8 py-1.5 text-sm text-foreground
placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring w-64 transition-shadow"
/>
{#if searchInput}
<button
onclick={clearSearch}
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
aria-label="Clear search"
>
<X size={14} />
</button>
{/if}
</div>
<!-- Rescore -->
<button
onclick={handleRescore}
disabled={rescoreLoading}
class="inline-flex items-center gap-1.5 rounded-md bg-muted px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
class="inline-flex items-center gap-1.5 rounded-md bg-muted px-3 py-1.5 text-sm font-medium
text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
>
<RefreshCw size={14} class={rescoreLoading ? "animate-spin" : ""} />
Rescore
</button>
</div>
<!-- Rescore result (dismissable) -->
{#if rescoreResult}
<div class="mb-4 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
{rescoreResult}
<div
class="mb-4 rounded-md px-3 py-2 text-sm flex items-center justify-between gap-2
{rescoreResult.isError
? 'bg-destructive/10 text-destructive'
: 'bg-muted text-muted-foreground'}"
transition:fade={{ duration: 150 }}
>
<span>{rescoreResult.message}</span>
<button
onclick={() => (rescoreResult = null)}
class="text-muted-foreground hover:text-foreground transition-colors cursor-pointer shrink-0"
aria-label="Dismiss"
>
<X size={14} />
</button>
</div>
{/if}
<!-- Error -->
{#if error}
<p class="text-destructive mb-4">{error}</p>
<div
class="mb-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
transition:fade={{ duration: 150 }}
>
{error}
</div>
{/if}
{#if loading && instructors.length === 0}
<!-- Skeleton stats cards -->
<div class="mb-4 grid grid-cols-5 gap-3">
<div class="mb-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each Array(5) as _}
<div class="bg-card border-border rounded-lg border p-3">
<div class="h-3 w-16 animate-pulse rounded bg-muted mb-2"></div>
@@ -311,68 +496,61 @@ const scoreLabels: Record<string, string> = {
</div>
</div>
{:else}
<!-- Stats Bar -->
<div class="mb-4 grid grid-cols-5 gap-3">
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-muted-foreground text-xs">Total</div>
<div class="text-lg font-semibold">{stats.total}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-amber-600 dark:text-amber-400">Unmatched</div>
<div class="text-lg font-semibold">{stats.unmatched}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-blue-600 dark:text-blue-400">Auto</div>
<div class="text-lg font-semibold">{stats.auto}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-green-600 dark:text-green-400">Confirmed</div>
<div class="text-lg font-semibold">{stats.confirmed}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-red-600 dark:text-red-400">Rejected</div>
<div class="text-lg font-semibold">{stats.rejected}</div>
</div>
</div>
<!-- Progress Bar -->
<div class="bg-muted mb-6 h-2 rounded-full overflow-hidden flex">
<div class="bg-blue-500 h-full transition-all duration-300"
style="width: {autoPct}%" title="Auto: {stats.auto}"></div>
<div class="bg-green-500 h-full transition-all duration-300"
style="width: {confirmedPct}%" title="Confirmed: {stats.confirmed}"></div>
<div class="bg-amber-500 h-full transition-all duration-300"
style="width: {unmatchedPct}%" title="Unmatched: {stats.unmatched}"></div>
</div>
<!-- Filters -->
<div class="mb-4 flex items-center gap-4">
<div class="flex gap-2">
{#each filterOptions as opt}
<button
onclick={() => setFilter(opt.value)}
class="rounded-md px-3 py-1.5 text-sm transition-colors cursor-pointer
{activeFilter === opt.value
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent'}"
<div class="relative">
<!-- Loading overlay for refetching -->
{#if loading}
<div
class="absolute inset-0 z-10 flex items-center justify-center bg-background/60 rounded-lg"
in:fade={{ duration: 100, delay: 150 }}
out:fade={{ duration: 100 }}
>
{opt.label}
<LoaderCircle size={24} class="animate-spin text-muted-foreground" />
</div>
{/if}
<!-- Stats / Filter Cards -->
<div class="mb-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each filterCards as card}
<button
onclick={() => setFilter(card.value)}
class="bg-card border-border rounded-lg border p-3 text-left transition-all duration-200
cursor-pointer hover:shadow-sm hover:border-border/80
{activeFilter === card.value ? `ring-2 ${card.ringColor} shadow-sm` : ''}"
>
<div class="text-xs {card.textColor}">{card.label}</div>
<div class="text-lg font-semibold tabular-nums">{stats[card.stat]}</div>
</button>
{/each}
</div>
<input
type="text"
placeholder="Search by name or email..."
bind:value={searchInput}
oninput={handleSearch}
class="bg-card border-border rounded-md border px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring w-64"
/>
<!-- Progress Bar -->
<div class="mb-6">
<div class="bg-muted h-2 rounded-full overflow-hidden flex">
{#each progressSegments as seg}
{@const pct = (stats[seg.stat] / progressDenom) * 100}
<div
class="{seg.color} h-full transition-all duration-500"
style="width: {pct}%"
title="{seg.label}: {stats[seg.stat]}"
></div>
{/each}
</div>
</div>
{#if instructors.length === 0}
<p class="text-muted-foreground py-8 text-center text-sm">No instructors found.</p>
<div class="py-12 text-center">
{#if searchQuery || activeFilter}
<p class="text-muted-foreground text-sm">No instructors match your filters.</p>
<button
onclick={clearAllFilters}
class="mt-2 text-sm text-primary hover:underline cursor-pointer"
>
Clear all filters
</button>
{:else}
<p class="text-muted-foreground text-sm">No instructors found.</p>
{/if}
</div>
{:else}
<div class="bg-card border-border overflow-hidden rounded-lg border">
<table class="w-full text-sm">
@@ -389,16 +567,25 @@ const scoreLabels: Record<string, string> = {
{#each instructors as instructor (instructor.id)}
{@const badge = statusBadge(instructor.rmpMatchStatus)}
{@const isExpanded = expandedId === instructor.id}
{@const isStale = !matchesFilter(instructor.rmpMatchStatus)}
{@const isHighlighted = recentlyChanged.has(instructor.id)}
<tr
class="border-border border-b cursor-pointer hover:bg-muted/50 transition-colors {isExpanded ? 'bg-muted/30' : ''}"
class="border-border border-b cursor-pointer transition-colors duration-700
{isExpanded ? 'bg-muted/30' : 'hover:bg-muted/50'}
{isHighlighted ? 'bg-primary/10' : ''}
{isStale && !isHighlighted ? 'opacity-60' : ''}"
onclick={() => toggleExpand(instructor.id)}
>
<td class="px-4 py-2.5">
<div class="font-medium text-foreground">{formatInstructorName(instructor.displayName)}</div>
<div class="font-medium text-foreground">
{formatInstructorName(instructor.displayName)}
</div>
<div class="text-xs text-muted-foreground">{instructor.email}</div>
</td>
<td class="px-4 py-2.5">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.classes}">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-300 {badge.classes}"
>
{badge.label}
</span>
</td>
@@ -408,7 +595,10 @@ const scoreLabels: Record<string, string> = {
<div class="flex items-center gap-2">
<span class="text-foreground">{tc.firstName} {tc.lastName}</span>
{#if isRatingValid(tc.avgRating, tc.numRatings ?? 0)}
<span class="font-semibold tabular-nums" style={ratingStyle(tc.avgRating!, themeStore.isDark)}>
<span
class="font-semibold tabular-nums"
style={ratingStyle(tc.avgRating!, themeStore.isDark)}
>
{tc.avgRating!.toFixed(1)}
</span>
{:else}
@@ -429,20 +619,35 @@ const scoreLabels: Record<string, string> = {
<div class="inline-flex items-center gap-1">
{#if instructor.topCandidate && instructor.rmpMatchStatus === "unmatched"}
<button
onclick={(e) => { e.stopPropagation(); handleMatch(instructor.id, instructor.topCandidate!.rmpLegacyId); }}
onclick={(e) => {
e.stopPropagation();
handleMatch(instructor.id, instructor.topCandidate!.rmpLegacyId);
}}
disabled={actionLoading !== null}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30
transition-colors disabled:opacity-50 cursor-pointer"
title="Accept top candidate"
>
{#if actionLoading === `match-${instructor.topCandidate.rmpLegacyId}`}
<LoaderCircle size={16} class="animate-spin" />
{:else}
<Check size={16} />
{/if}
</button>
{/if}
<button
onclick={(e) => { e.stopPropagation(); toggleExpand(instructor.id); }}
onclick={(e) => {
e.stopPropagation();
toggleExpand(instructor.id);
}}
class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
title={isExpanded ? "Collapse" : "Expand"}
title={isExpanded ? "Collapse" : "Expand details"}
aria-expanded={isExpanded}
>
<ChevronRight size={16} class="transition-transform duration-200 {isExpanded ? 'rotate-90' : ''}" />
<ChevronRight
size={16}
class="transition-transform duration-200 {isExpanded ? 'rotate-90' : ''}"
/>
</button>
</div>
</td>
@@ -451,162 +656,161 @@ const scoreLabels: Record<string, string> = {
<!-- Expanded detail panel -->
{#if isExpanded}
<tr class="border-border border-b bg-muted/20">
<td colspan="5" class="p-0">
<div class="p-4">
<td colspan="5" class="p-0 overflow-hidden">
<div transition:slide={{ duration: 200 }} class="p-4">
{#if detailLoading}
<p class="text-muted-foreground text-sm py-4 text-center">Loading details...</p>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="space-y-3 animate-pulse">
<div class="h-4 w-20 rounded bg-muted"></div>
<div class="space-y-2">
<div class="h-3 w-36 rounded bg-muted"></div>
<div class="h-3 w-44 rounded bg-muted"></div>
<div class="h-3 w-28 rounded bg-muted"></div>
</div>
</div>
<div class="lg:col-span-2 space-y-3 animate-pulse">
<div class="h-4 w-32 rounded bg-muted"></div>
<div class="space-y-2">
<div class="h-20 rounded bg-muted"></div>
<div class="h-20 rounded bg-muted"></div>
</div>
</div>
</div>
{:else if detailError}
<p class="text-destructive text-sm py-2">{detailError}</p>
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{detailError}
</div>
{:else if detail}
<div class="grid grid-cols-3 gap-6">
<!-- Left: Instructor info -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Instructor info -->
<div class="space-y-3">
<h3 class="font-medium text-foreground">Instructor</h3>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
<h3 class="font-medium text-foreground text-sm">Instructor</h3>
<dl
class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm"
>
<dt class="text-muted-foreground">Name</dt>
<dd class="text-foreground">{formatInstructorName(detail.instructor.displayName)}</dd>
<dd class="text-foreground">
{formatInstructorName(detail.instructor.displayName)}
</dd>
<dt class="text-muted-foreground">Email</dt>
<dd class="text-foreground">{detail.instructor.email}</dd>
<dd class="text-foreground break-all">{detail.instructor.email}</dd>
<dt class="text-muted-foreground">Courses</dt>
<dd class="text-foreground tabular-nums">{detail.instructor.courseCount}</dd>
<dd class="text-foreground tabular-nums">
{detail.instructor.courseCount}
</dd>
{#if detail.instructor.subjectsTaught.length > 0}
<dt class="text-muted-foreground">Subjects</dt>
<dd class="flex flex-wrap gap-1">
{#each detail.instructor.subjectsTaught as subj}
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{subj}</span>
{#if subjectMap.has(subj)}
<SimpleTooltip text={subjectMap.get(subj)!} delay={75}>
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium"
>{subj}</span
>
</SimpleTooltip>
{:else}
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium"
>{subj}</span
>
{/if}
{/each}
</dd>
{/if}
</dl>
</div>
<!-- Right: Candidates (spans 2 cols) -->
<div class="col-span-2 space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-foreground">
Candidates ({detail.candidates.length})
<!-- Candidates -->
<div class="lg:col-span-2 space-y-3">
<div class="flex items-center justify-between gap-2">
<h3 class="font-medium text-foreground text-sm">
Candidates
<span class="text-muted-foreground font-normal"
>({detail.candidates.length})</span
>
</h3>
{#if detail.candidates.some((c: CandidateResponse) => c.status !== "rejected" && !matchedLegacyIds.has(c.rmpLegacyId))}
{#if showRejectConfirm === detail.instructor.id}
<div
class="inline-flex items-center gap-2 text-xs"
in:fade={{ duration: 100 }}
>
<span class="text-muted-foreground"
>Reject all candidates?</span
>
<button
onclick={(e) => { e.stopPropagation(); handleRejectAll(detail!.instructor.id); }}
onclick={(e) => {
e.stopPropagation();
confirmRejectAll(detail!.instructor.id);
}}
disabled={actionLoading !== null}
class="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50 transition-colors disabled:opacity-50 cursor-pointer"
class="font-medium text-red-600 hover:text-red-700
dark:text-red-400 dark:hover:text-red-300
cursor-pointer disabled:opacity-50"
>
Confirm
</button>
<button
onclick={(e) => {
e.stopPropagation();
cancelRejectAll();
}}
class="text-muted-foreground hover:text-foreground cursor-pointer"
>
Cancel
</button>
</div>
{:else}
<button
onclick={(e) => {
e.stopPropagation();
requestRejectAll(detail!.instructor.id);
}}
disabled={actionLoading !== null}
class="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-1
text-xs font-medium text-red-700 hover:bg-red-200
dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50
transition-colors disabled:opacity-50 cursor-pointer"
>
<X size={12} /> Reject All
</button>
{/if}
{/if}
</div>
{#if detail.candidates.length === 0}
<p class="text-muted-foreground text-sm">No candidates available.</p>
<p class="text-muted-foreground text-sm py-2">
No candidates available.
</p>
{:else}
<div class="max-h-80 overflow-y-auto space-y-2 pr-1">
{#each detail.candidates as candidate (candidate.id)}
{@const isMatched = candidate.status === "matched" || matchedLegacyIds.has(candidate.rmpLegacyId)}
{@const isRejected = candidate.status === "rejected"}
{@const isPending = !isMatched && !isRejected}
<div class="rounded-md border p-3 {isMatched ? 'border-l-4 border-l-green-500 bg-green-500/5 border-border' : isRejected ? 'border-border bg-card opacity-50' : 'border-border bg-card'}">
<div class="flex items-start justify-between gap-2">
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-foreground text-sm">
{candidate.firstName} {candidate.lastName}
</span>
{#if isMatched}
<span class="text-[10px] rounded px-1 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Matched
</span>
{:else if isRejected}
<span class="text-[10px] rounded px-1 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
Rejected
</span>
{/if}
</div>
{#if candidate.department}
<div class="text-xs text-muted-foreground">{candidate.department}</div>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
{#if isMatched}
<button
onclick={(e) => { e.stopPropagation(); handleUnmatch(detail!.instructor.id, candidate.rmpLegacyId); }}
<CandidateCard
{candidate}
isMatched={candidate.status === "matched" ||
matchedLegacyIds.has(candidate.rmpLegacyId)}
isRejected={candidate.status === "rejected"}
disabled={actionLoading !== null}
class="inline-flex items-center gap-1 rounded p-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Unmatch"
>
<XCircle size={14} /> Unmatch
</button>
{:else if isPending}
<button
onclick={(e) => { e.stopPropagation(); handleMatch(detail!.instructor.id, candidate.rmpLegacyId); }}
disabled={actionLoading !== null}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Accept"
>
<Check size={14} />
</button>
<button
onclick={(e) => { e.stopPropagation(); handleReject(detail!.instructor.id, candidate.rmpLegacyId); }}
disabled={actionLoading !== null}
class="rounded p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Reject"
>
<X size={14} />
</button>
{/if}
<a
href={rmpUrl(candidate.rmpLegacyId)}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
title="View on RMP"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<!-- Stats row -->
<div class="mt-2 flex items-center gap-3 text-xs">
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
<span class="font-semibold tabular-nums" style={ratingStyle(candidate.avgRating!, themeStore.isDark)}>
{candidate.avgRating!.toFixed(1)}
</span>
{:else}
<span class="text-xs text-muted-foreground">N/A</span>
{/if}
{#if candidate.avgDifficulty !== null}
<span class="text-muted-foreground tabular-nums">{candidate.avgDifficulty.toFixed(1)} diff</span>
{/if}
<span class="text-muted-foreground">{candidate.numRatings} ratings</span>
{#if candidate.wouldTakeAgainPct !== null}
<span class="text-muted-foreground">{candidate.wouldTakeAgainPct.toFixed(0)}% again</span>
{/if}
</div>
<!-- Score bar (weighted segments) -->
<div class="mt-2">
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground shrink-0">Score:</span>
<div class="bg-muted h-2 flex-1 rounded-full overflow-hidden flex">
{#each Object.entries(candidate.scoreBreakdown ?? {}) as [key, value]}
{@const w = scoreWeights[key] ?? 0}
{@const widthPct = (value as number) * w * 100}
<div
class="{scoreColors[key] ?? 'bg-primary'} h-full transition-all"
style="width: {widthPct}%"
title="{scoreLabels[key] ?? key}: {((value as number) * 100).toFixed(0)}% (×{w})"
></div>
{/each}
</div>
<span class="tabular-nums font-medium text-foreground shrink-0">{formatScore(candidate.score ?? 0)}%</span>
</div>
</div>
</div>
{actionLoading}
isDark={themeStore.isDark}
onmatch={() =>
handleMatch(
detail!.instructor.id,
candidate.rmpLegacyId
)}
onreject={() =>
handleReject(
detail!.instructor.id,
candidate.rmpLegacyId
)}
onunmatch={() =>
handleUnmatch(
detail!.instructor.id,
candidate.rmpLegacyId
)}
/>
{/each}
</div>
{/if}
@@ -625,27 +829,35 @@ const scoreLabels: Record<string, string> = {
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between text-sm">
<span class="text-muted-foreground">
Showing {(currentPage - 1) * perPage + 1}{Math.min(currentPage * perPage, totalCount)} of {totalCount}
Showing {(currentPage - 1) * perPage + 1}&ndash;{Math.min(
currentPage * perPage,
totalCount
)} of {totalCount}
</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<button
onclick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
class="inline-flex items-center justify-center size-8 rounded-md bg-muted text-foreground
hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer disabled:cursor-default"
aria-label="Previous page"
>
<ChevronLeft size={14} /> Prev
<ChevronLeft size={16} />
</button>
<span class="text-muted-foreground tabular-nums">
<span class="text-muted-foreground tabular-nums px-2">
{currentPage} / {totalPages}
</span>
<button
onclick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
class="inline-flex items-center justify-center size-8 rounded-md bg-muted text-foreground
hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer disabled:cursor-default"
aria-label="Next page"
>
Next <ChevronRight size={14} />
<ChevronRight size={16} />
</button>
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,160 @@
<script lang="ts">
import type { CandidateResponse } from "$lib/api";
import { isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
import { Check, ExternalLink, LoaderCircle, X, XCircle } from "@lucide/svelte";
import ScoreBreakdown from "./ScoreBreakdown.svelte";
let {
candidate,
isMatched = false,
isRejected = false,
disabled = false,
actionLoading = null,
isDark = false,
onmatch,
onreject,
onunmatch,
}: {
candidate: CandidateResponse;
isMatched?: boolean;
isRejected?: boolean;
disabled?: boolean;
actionLoading?: string | null;
isDark?: boolean;
onmatch?: () => void;
onreject?: () => void;
onunmatch?: () => void;
} = $props();
const isPending = $derived(!isMatched && !isRejected);
const isMatchLoading = $derived(actionLoading === `match-${candidate.rmpLegacyId}`);
const isRejectLoading = $derived(actionLoading === `reject-${candidate.rmpLegacyId}`);
const isUnmatchLoading = $derived(actionLoading === `unmatch-${candidate.rmpLegacyId}`);
</script>
<div
class="rounded-md border p-3 transition-all duration-200
{isMatched
? 'border-l-4 border-l-green-500 bg-green-500/5 border-border'
: isRejected
? 'border-border bg-card opacity-50'
: 'border-border bg-card hover:shadow-sm'}"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-foreground text-sm">
{candidate.firstName} {candidate.lastName}
</span>
{#if isMatched}
<span
class="text-[10px] rounded px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium"
>
Matched
</span>
{:else if isRejected}
<span
class="text-[10px] rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 font-medium"
>
Rejected
</span>
{/if}
</div>
{#if candidate.department}
<div class="text-xs text-muted-foreground mt-0.5">{candidate.department}</div>
{/if}
</div>
<div class="flex items-center gap-0.5 shrink-0">
{#if isMatched}
<button
onclick={(e) => {
e.stopPropagation();
onunmatch?.();
}}
{disabled}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Remove match"
>
{#if isUnmatchLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<XCircle size={14} />
{/if}
Unmatch
</button>
{:else if isPending}
<button
onclick={(e) => {
e.stopPropagation();
onmatch?.();
}}
{disabled}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Accept match"
>
{#if isMatchLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<Check size={14} />
{/if}
</button>
<button
onclick={(e) => {
e.stopPropagation();
onreject?.();
}}
{disabled}
class="rounded p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Reject candidate"
>
{#if isRejectLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<X size={14} />
{/if}
</button>
{/if}
<a
href={rmpUrl(candidate.rmpLegacyId)}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title="View on RateMyProfessors"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<!-- Rating stats -->
<div class="mt-2 flex items-center gap-3 text-xs flex-wrap">
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
<span
class="font-semibold tabular-nums"
style={ratingStyle(candidate.avgRating!, isDark)}
>
{candidate.avgRating!.toFixed(1)}
</span>
{:else}
<span class="text-muted-foreground">No rating</span>
{/if}
{#if candidate.avgDifficulty !== null}
<span class="text-muted-foreground tabular-nums"
>{candidate.avgDifficulty.toFixed(1)} diff</span
>
{/if}
<span class="text-muted-foreground tabular-nums">{candidate.numRatings} ratings</span>
{#if candidate.wouldTakeAgainPct !== null}
<span class="text-muted-foreground tabular-nums"
>{candidate.wouldTakeAgainPct.toFixed(0)}% again</span
>
{/if}
</div>
<!-- Score breakdown -->
<div class="mt-2">
<ScoreBreakdown breakdown={candidate.scoreBreakdown} score={candidate.score ?? 0} />
</div>
</div>
@@ -0,0 +1,73 @@
<script lang="ts">
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
let {
breakdown = null,
score = 0,
}: {
breakdown?: { [key in string]?: number } | null;
score?: number;
} = $props();
const weights: Record<string, number> = {
name: 0.5,
department: 0.25,
uniqueness: 0.15,
volume: 0.1,
};
const colors: Record<string, string> = {
name: "bg-blue-500",
department: "bg-purple-500",
uniqueness: "bg-amber-500",
volume: "bg-emerald-500",
};
const labels: Record<string, string> = {
name: "Name",
department: "Dept",
uniqueness: "Unique",
volume: "Volume",
};
function fmt(v: number): string {
return (v * 100).toFixed(0);
}
const segments = $derived(
Object.entries(breakdown ?? {})
.filter(([_, value]) => value != null)
.map(([key, value]) => ({
key,
label: labels[key] ?? key,
color: colors[key] ?? "bg-primary",
weight: weights[key] ?? 0,
raw: value!,
pct: value! * (weights[key] ?? 0) * 100,
}))
);
const tooltipText = $derived(
segments.map((s) => `${s.label}: ${fmt(s.raw)}% \u00d7 ${fmt(s.weight)}%`).join("\n") +
`\nTotal: ${fmt(score)}%`
);
</script>
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground shrink-0">Score:</span>
<div class="bg-muted h-2 flex-1 rounded-full overflow-hidden flex">
{#each segments as seg (seg.key)}
<div
class="{seg.color} h-full transition-all duration-300"
style="width: {seg.pct}%"
></div>
{/each}
</div>
<SimpleTooltip text={tooltipText} side="top">
<span
class="tabular-nums font-medium text-foreground cursor-help border-b border-dotted border-muted-foreground/40"
>
{fmt(score)}%
</span>
</SimpleTooltip>
</div>