mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 08:23:35 -06:00
feat: refactor admin instructor UI with component extraction and optimistic updates
This commit is contained in:
+156
-18
@@ -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.
|
/// Expand common subject abbreviations used at UTSA and check for overlap.
|
||||||
fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
|
fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
|
||||||
const MAPPINGS: &[(&str, &[&str])] = &[
|
const MAPPINGS: &[(&str, &[&str])] = &[
|
||||||
|
// Core subjects (original mappings, corrected)
|
||||||
("cs", &["computer science"]),
|
("cs", &["computer science"]),
|
||||||
("ece", &["electrical", "computer engineering"]),
|
("ece", &["early childhood education", "early childhood"]),
|
||||||
("ee", &["electrical engineering", "electrical"]),
|
("ee", &["electrical engineering", "electrical"]),
|
||||||
("me", &["mechanical engineering", "mechanical"]),
|
("me", &["mechanical engineering", "mechanical"]),
|
||||||
("ce", &["civil engineering", "civil"]),
|
("ce", &["civil engineering", "civil"]),
|
||||||
@@ -105,6 +106,85 @@ fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
|
|||||||
("ms", &["management science"]),
|
("ms", &["management science"]),
|
||||||
("kin", &["kinesiology"]),
|
("kin", &["kinesiology"]),
|
||||||
("com", &["communication"]),
|
("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 {
|
for &(abbr, expansions) in MAPPINGS {
|
||||||
@@ -164,6 +244,7 @@ pub fn compute_match_score(
|
|||||||
pub struct MatchingStats {
|
pub struct MatchingStats {
|
||||||
pub total_unmatched: usize,
|
pub total_unmatched: usize,
|
||||||
pub candidates_created: usize,
|
pub candidates_created: usize,
|
||||||
|
pub candidates_rescored: usize,
|
||||||
pub auto_matched: usize,
|
pub auto_matched: usize,
|
||||||
pub skipped_unparseable: usize,
|
pub skipped_unparseable: usize,
|
||||||
pub skipped_no_candidates: usize,
|
pub skipped_no_candidates: usize,
|
||||||
@@ -200,6 +281,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
|||||||
return Ok(MatchingStats {
|
return Ok(MatchingStats {
|
||||||
total_unmatched: 0,
|
total_unmatched: 0,
|
||||||
candidates_created: 0,
|
candidates_created: 0,
|
||||||
|
candidates_rescored: 0,
|
||||||
auto_matched: 0,
|
auto_matched: 0,
|
||||||
skipped_unparseable: 0,
|
skipped_unparseable: 0,
|
||||||
skipped_no_candidates: 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)> =
|
let candidate_rows: Vec<(i32, i32, String)> =
|
||||||
sqlx::query_as("SELECT instructor_id, rmp_legacy_id, status FROM rmp_match_candidates")
|
sqlx::query_as("SELECT instructor_id, rmp_legacy_id, status FROM rmp_match_candidates")
|
||||||
.fetch_all(db_pool)
|
.fetch_all(db_pool)
|
||||||
.await?;
|
.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();
|
let mut rejected_pairs: HashSet<(i32, i32)> = HashSet::new();
|
||||||
for (iid, lid, status) in candidate_rows {
|
for (iid, lid, status) in candidate_rows {
|
||||||
existing_pairs.insert((iid, lid));
|
match status.as_str() {
|
||||||
if status == "rejected" {
|
"accepted" | "rejected" => {
|
||||||
rejected_pairs.insert((iid, lid));
|
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 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 auto_accept: Vec<(i32, i32)> = Vec::new(); // (instructor_id, legacy_id)
|
||||||
let mut skipped_unparseable = 0usize;
|
let mut skipped_unparseable = 0usize;
|
||||||
let mut skipped_no_candidates = 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 {
|
for prof in rmp_candidates {
|
||||||
let pair = (*instructor_id, prof.legacy_id);
|
let pair = (*instructor_id, prof.legacy_id);
|
||||||
if existing_pairs.contains(&pair) {
|
if resolved_pairs.contains(&pair) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +400,16 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
|||||||
let breakdown_json =
|
let breakdown_json =
|
||||||
serde_json::to_value(&ms.breakdown).unwrap_or_else(|_| serde_json::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 {
|
match best {
|
||||||
Some((s, _)) if ms.score > s => best = Some((ms.score, prof.legacy_id)),
|
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> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6–7. Write candidates and auto-accept within a single transaction
|
// 6–7. Write candidates, rescore, and auto-accept within a single transaction
|
||||||
let candidates_created = candidates.len();
|
let candidates_created = new_candidates.len();
|
||||||
|
let candidates_rescored = rescored_candidates.len();
|
||||||
let auto_matched = auto_accept.len();
|
let auto_matched = auto_accept.len();
|
||||||
|
|
||||||
let mut tx = db_pool.begin().await?;
|
let mut tx = db_pool.begin().await?;
|
||||||
|
|
||||||
// 6. Batch-insert candidates
|
// 6a. Batch-insert new candidates
|
||||||
if !candidates.is_empty() {
|
if !new_candidates.is_empty() {
|
||||||
let c_instructor_ids: Vec<i32> = candidates.iter().map(|(iid, _, _, _)| *iid).collect();
|
let c_instructor_ids: Vec<i32> = new_candidates.iter().map(|(iid, _, _, _)| *iid).collect();
|
||||||
let c_legacy_ids: Vec<i32> = candidates.iter().map(|(_, lid, _, _)| *lid).collect();
|
let c_legacy_ids: Vec<i32> = new_candidates.iter().map(|(_, lid, _, _)| *lid).collect();
|
||||||
let c_scores: Vec<f32> = candidates.iter().map(|(_, _, s, _)| *s).collect();
|
let c_scores: Vec<f32> = new_candidates.iter().map(|(_, _, s, _)| *s).collect();
|
||||||
let c_breakdowns: Vec<serde_json::Value> =
|
let c_breakdowns: Vec<serde_json::Value> =
|
||||||
candidates.into_iter().map(|(_, _, _, b)| b).collect();
|
new_candidates.into_iter().map(|(_, _, _, b)| b).collect();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -358,6 +460,40 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
|||||||
.await?;
|
.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
|
// 7. Auto-accept top candidates
|
||||||
if !auto_accept.is_empty() {
|
if !auto_accept.is_empty() {
|
||||||
let aa_instructor_ids: Vec<i32> = auto_accept.iter().map(|(iid, _)| *iid).collect();
|
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 {
|
let stats = MatchingStats {
|
||||||
total_unmatched,
|
total_unmatched,
|
||||||
candidates_created,
|
candidates_created,
|
||||||
|
candidates_rescored,
|
||||||
auto_matched,
|
auto_matched,
|
||||||
skipped_unparseable,
|
skipped_unparseable,
|
||||||
skipped_no_candidates,
|
skipped_no_candidates,
|
||||||
@@ -419,6 +556,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
|||||||
info!(
|
info!(
|
||||||
total_unmatched = stats.total_unmatched,
|
total_unmatched = stats.total_unmatched,
|
||||||
candidates_created = stats.candidates_created,
|
candidates_created = stats.candidates_created,
|
||||||
|
candidates_rescored = stats.candidates_rescored,
|
||||||
auto_matched = stats.auto_matched,
|
auto_matched = stats.auto_matched,
|
||||||
skipped_unparseable = stats.skipped_unparseable,
|
skipped_unparseable = stats.skipped_unparseable,
|
||||||
skipped_no_candidates = stats.skipped_no_candidates,
|
skipped_no_candidates = stats.skipped_no_candidates,
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ impl Scheduler {
|
|||||||
total,
|
total,
|
||||||
stats.total_unmatched,
|
stats.total_unmatched,
|
||||||
stats.candidates_created,
|
stats.candidates_created,
|
||||||
|
stats.candidates_rescored,
|
||||||
stats.auto_matched,
|
stats.auto_matched,
|
||||||
stats.skipped_unparseable,
|
stats.skipped_unparseable,
|
||||||
stats.skipped_no_candidates,
|
stats.skipped_no_candidates,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ pub struct InstructorDetailResponse {
|
|||||||
pub struct RescoreResponse {
|
pub struct RescoreResponse {
|
||||||
pub total_unmatched: usize,
|
pub total_unmatched: usize,
|
||||||
pub candidates_created: usize,
|
pub candidates_created: usize,
|
||||||
|
pub candidates_rescored: usize,
|
||||||
pub auto_matched: usize,
|
pub auto_matched: usize,
|
||||||
pub skipped_unparseable: usize,
|
pub skipped_unparseable: usize,
|
||||||
pub skipped_no_candidates: usize,
|
pub skipped_no_candidates: usize,
|
||||||
@@ -858,6 +859,7 @@ pub async fn rescore(
|
|||||||
Ok(Json(RescoreResponse {
|
Ok(Json(RescoreResponse {
|
||||||
total_unmatched: stats.total_unmatched,
|
total_unmatched: stats.total_unmatched,
|
||||||
candidates_created: stats.candidates_created,
|
candidates_created: stats.candidates_created,
|
||||||
|
candidates_rescored: stats.candidates_rescored,
|
||||||
auto_matched: stats.auto_matched,
|
auto_matched: stats.auto_matched,
|
||||||
skipped_unparseable: stats.skipped_unparseable,
|
skipped_unparseable: stats.skipped_unparseable,
|
||||||
skipped_no_candidates: stats.skipped_no_candidates,
|
skipped_no_candidates: stats.skipped_no_candidates,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
Reference in New Issue
Block a user