mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -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.
|
||||
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> {
|
||||
}
|
||||
}
|
||||
|
||||
// 6–7. Write candidates and auto-accept within a single transaction
|
||||
let candidates_created = candidates.len();
|
||||
// 6–7. 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user