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
+156 -18
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));
if status == "rejected" {
rejected_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,
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>