From 5a1bd33c9820b30ba27e322ddb40b7df727e0075 Mon Sep 17 00:00:00 2001 From: Austin Brace Date: Tue, 7 Apr 2026 20:51:54 -0500 Subject: [PATCH] refactor(#51): extract mastery threshold constants to shared file --- .../[exam]/assess/[domain]/AssessmentClient.tsx | 5 +++-- src/components/dashboard/DashboardHero.tsx | 3 ++- src/components/progress/ReadinessCard.tsx | 3 ++- src/components/ui/progress-indicator.tsx | 5 +++-- src/lib/constants.ts | 14 ++++++++++++++ src/lib/content/sidebar.ts | 3 ++- src/lib/dashboard/tips.ts | 4 +++- src/lib/llm/tool-handlers.ts | 3 ++- src/lib/progress/calculator.ts | 11 ++++++----- src/lib/utils/colors.ts | 5 +++-- src/lib/utils/mastery.ts | 13 +++++++------ 11 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 src/lib/constants.ts diff --git a/src/app/[exam]/assess/[domain]/AssessmentClient.tsx b/src/app/[exam]/assess/[domain]/AssessmentClient.tsx index ce669c8..e4e6f8e 100644 --- a/src/app/[exam]/assess/[domain]/AssessmentClient.tsx +++ b/src/app/[exam]/assess/[domain]/AssessmentClient.tsx @@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'; import { QuestionCard } from '@/components/assess/QuestionCard'; import { Clock, CheckCircle, XCircle, Target } from 'lucide-react'; import type { Question } from '@/types/domain'; +import { MASTERY_THRESHOLD, APPROACHING_THRESHOLD } from '@/lib/constants'; import type { QuestionAnswer, AssessmentResult } from '@/types/assessment'; interface AssessmentClientProps { @@ -153,8 +154,8 @@ export function AssessmentClient({ examId, domainId, topicId, questions }: Asses - = 85 ? 'default' : result.score >= 60 ? 'secondary' : 'destructive'} className="text-base px-4 py-2"> - {result.score >= 85 ? 'Excellent!' : result.score >= 60 ? 'Good Progress' : 'Needs Review'} + = MASTERY_THRESHOLD ? 'default' : result.score >= APPROACHING_THRESHOLD ? 'secondary' : 'destructive'} className="text-base px-4 py-2"> + {result.score >= MASTERY_THRESHOLD ? 'Excellent!' : result.score >= APPROACHING_THRESHOLD ? 'Good Progress' : 'Needs Review'} diff --git a/src/components/dashboard/DashboardHero.tsx b/src/components/dashboard/DashboardHero.tsx index 25aa234..8d94ea1 100644 --- a/src/components/dashboard/DashboardHero.tsx +++ b/src/components/dashboard/DashboardHero.tsx @@ -1,3 +1,4 @@ +import { MASTERY_THRESHOLD } from "@/lib/constants"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -30,7 +31,7 @@ function getCTA(examId: string, overall: OverallProgress, domains: DomainProgres } // Exam ready - 85%+ mastery - if (overall.masteryScore >= 85) { + if (overall.masteryScore >= MASTERY_THRESHOLD) { return { text: "Review Your Progress", href: `/${examId}/progress`, diff --git a/src/components/progress/ReadinessCard.tsx b/src/components/progress/ReadinessCard.tsx index de85f61..951d8f9 100644 --- a/src/components/progress/ReadinessCard.tsx +++ b/src/components/progress/ReadinessCard.tsx @@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Info } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ProgressIndicator } from '@/components/ui/progress-indicator'; import type { ReadinessEstimate, DomainReadiness } from '@/lib/progress/calculator'; +import { MASTERY_THRESHOLD, APPROACHING_THRESHOLD } from '@/lib/constants'; import { clsx } from 'clsx'; interface ReadinessCardProps { @@ -116,7 +117,7 @@ export function ReadinessCard({ estimate, examId }: ReadinessCardProps) { function DomainBar({ domain }: { domain: DomainReadiness }) { const [expanded, setExpanded] = useState(false); const masteryRounded = Math.round(domain.mastery); - const colorClass = masteryRounded >= 85 ? 'text-green-600' : masteryRounded >= 60 ? 'text-amber-600' : 'text-red-600'; + const colorClass = masteryRounded >= MASTERY_THRESHOLD ? 'text-green-600' : masteryRounded >= APPROACHING_THRESHOLD ? 'text-amber-600' : 'text-red-600'; const hasWeakTopics = domain.weakTopics.length > 0; return ( diff --git a/src/components/ui/progress-indicator.tsx b/src/components/ui/progress-indicator.tsx index 857104a..4885928 100644 --- a/src/components/ui/progress-indicator.tsx +++ b/src/components/ui/progress-indicator.tsx @@ -1,5 +1,6 @@ "use client" +import { MASTERY_THRESHOLD, APPROACHING_THRESHOLD } from '@/lib/constants'; import { cn } from '@/lib/utils'; import * as ProgressPrimitive from "@radix-ui/react-progress"; @@ -16,8 +17,8 @@ interface ProgressIndicatorProps { */ export function ProgressIndicator({ value, className }: ProgressIndicatorProps) { const indicatorColorClass = - value >= 85 ? 'bg-green-600' : - value >= 60 ? 'bg-amber-500' : + value >= MASTERY_THRESHOLD ? 'bg-green-600' : + value >= APPROACHING_THRESHOLD ? 'bg-amber-500' : 'bg-red-600'; return ( diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..f951be1 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,14 @@ +/** Score at or above which a topic is considered "mastered" / "exam ready" */ +export const MASTERY_THRESHOLD = 85; + +/** Score at or above which a topic is considered "developing" */ +export const APPROACHING_THRESHOLD = 60; + +/** Score at or above which readiness level is "approaching" (vs "building") */ +export const READINESS_APPROACHING_THRESHOLD = 65; + +/** Score at or above which dashboard tips switch to exam-prep category */ +export const EXAM_PREP_THRESHOLD = 75; + +/** Decimal equivalent for SQL queries (mastery_level is 0-1 scale) */ +export const MASTERY_THRESHOLD_DECIMAL = MASTERY_THRESHOLD / 100; diff --git a/src/lib/content/sidebar.ts b/src/lib/content/sidebar.ts index 0d5b230..6c7d600 100644 --- a/src/lib/content/sidebar.ts +++ b/src/lib/content/sidebar.ts @@ -1,3 +1,4 @@ +import { MASTERY_THRESHOLD } from '@/lib/constants'; import { getAllDomains } from './loader'; import { parseTopicSections } from './parser'; import { getAllTopicWindowedMasteries } from '@/lib/progress/mastery'; @@ -111,7 +112,7 @@ export function getSidebarHierarchyWithProgress(examId: string): SidebarHierarch if (topicResult && topicResult.attempts > 0) { topicMasterySum += masteryScore; topicsWithProgress++; - if (masteryScore >= 85) topicsCompleted++; + if (masteryScore >= MASTERY_THRESHOLD) topicsCompleted++; } return { diff --git a/src/lib/dashboard/tips.ts b/src/lib/dashboard/tips.ts index 10a31ab..43dafc0 100644 --- a/src/lib/dashboard/tips.ts +++ b/src/lib/dashboard/tips.ts @@ -1,3 +1,5 @@ +import { EXAM_PREP_THRESHOLD } from '@/lib/constants'; + export interface Tip { id: string; category: "onboarding" | "assessment" | "study" | "feature" | "exam-prep"; @@ -136,7 +138,7 @@ function getTipCategory(state: UserState): Tip["category"] { return "onboarding"; } - if (state.masteryScore >= 75) { + if (state.masteryScore >= EXAM_PREP_THRESHOLD) { return "exam-prep"; } diff --git a/src/lib/llm/tool-handlers.ts b/src/lib/llm/tool-handlers.ts index 13d0fc5..29a8e96 100644 --- a/src/lib/llm/tool-handlers.ts +++ b/src/lib/llm/tool-handlers.ts @@ -1,3 +1,4 @@ +import { MASTERY_THRESHOLD } from '@/lib/constants'; import { getTutorProgressContext } from '@/lib/progress/tutor-context'; import { getAllDomains, getTopicById, getTopicQuestions } from '@/lib/content/loader'; import { getLabMeta } from '@/lib/content/experiments'; @@ -466,7 +467,7 @@ export function handleSuggestNextStudyTopic(_params: Record, ex } // Skip mastered topics - if (mastery >= 85) continue; + if (mastery >= MASTERY_THRESHOLD) continue; const finalScore = Math.round(baseScore * weightMultiplier); diff --git a/src/lib/progress/calculator.ts b/src/lib/progress/calculator.ts index 0962012..18f1799 100644 --- a/src/lib/progress/calculator.ts +++ b/src/lib/progress/calculator.ts @@ -1,6 +1,7 @@ import { cache } from "react"; import { db } from "@/lib/db/client"; import { getAllDomains, getTopicById } from "@/lib/content/loader"; +import { MASTERY_THRESHOLD, MASTERY_THRESHOLD_DECIMAL, READINESS_APPROACHING_THRESHOLD } from "@/lib/constants"; import { getAllTopicWindowedMasteries } from './mastery'; import type { TopicMasteryResult } from './mastery'; @@ -132,7 +133,7 @@ export const getDomainProgress = cache((examId: string, domainId: string): Domai const topicStats = db.prepare(` SELECT COUNT(*) as topics_with_progress, - SUM(CASE WHEN mastery_level >= 0.85 THEN 1 ELSE 0 END) as completed_topics + SUM(CASE WHEN mastery_level >= ${MASTERY_THRESHOLD_DECIMAL} THEN 1 ELSE 0 END) as completed_topics FROM topic_progress WHERE exam_id = ? AND domain_id = ? `).get(examId, domainId) as { topics_with_progress: number; completed_topics: number }; @@ -336,7 +337,7 @@ export const getReadinessEstimate = cache((examId: string): ReadinessEstimate => } // Topics below 85% are weak (including unstudied ones) - if (mastery < 85) { + if (mastery < MASTERY_THRESHOLD) { weakTopics.push({ topicId: topic.meta.id, topicName: topic.meta.shortName, @@ -378,9 +379,9 @@ export const getReadinessEstimate = cache((examId: string): ReadinessEstimate => // Determine level let level: 'ready' | 'approaching' | 'building'; - if (overallMastery >= 85) { + if (overallMastery >= MASTERY_THRESHOLD) { level = 'ready'; - } else if (overallMastery >= 65) { + } else if (overallMastery >= READINESS_APPROACHING_THRESHOLD) { level = 'approaching'; } else { level = 'building'; @@ -413,7 +414,7 @@ function getAllDomainProgressBatch(examId: string): DomainProgress[] { SELECT domain_id, COUNT(*) as topics_with_progress, - SUM(CASE WHEN mastery_level >= 0.85 THEN 1 ELSE 0 END) as completed_topics + SUM(CASE WHEN mastery_level >= ${MASTERY_THRESHOLD_DECIMAL} THEN 1 ELSE 0 END) as completed_topics FROM topic_progress WHERE exam_id = ? GROUP BY domain_id diff --git a/src/lib/utils/colors.ts b/src/lib/utils/colors.ts index 57569d2..80616dc 100644 --- a/src/lib/utils/colors.ts +++ b/src/lib/utils/colors.ts @@ -3,6 +3,7 @@ * Tailwind cannot handle dynamic class names (e.g., `border-${color}-500`), * so we map domain color strings to hex values for use with style={{ }}. */ +import { MASTERY_THRESHOLD, APPROACHING_THRESHOLD } from '@/lib/constants'; const DOMAIN_COLOR_HEX: Record = { blue: '#3b82f6', @@ -25,8 +26,8 @@ export function getDomainColorHex(color: string): string { * Uses Tailwind classes since these are static (known at build time). */ export function getMasteryDotColorClass(score: number): string { - if (score >= 85) return 'bg-green-500'; - if (score >= 60) return 'bg-amber-500'; + if (score >= MASTERY_THRESHOLD) return 'bg-green-500'; + if (score >= APPROACHING_THRESHOLD) return 'bg-amber-500'; if (score > 0) return 'bg-red-500'; return 'bg-muted-foreground/30'; } diff --git a/src/lib/utils/mastery.ts b/src/lib/utils/mastery.ts index 98f2c23..682912c 100644 --- a/src/lib/utils/mastery.ts +++ b/src/lib/utils/mastery.ts @@ -2,13 +2,14 @@ * Centralized mastery utilities for consistent display across the application. * Consolidates mastery scoring, labeling, and formatting functions. */ +import { MASTERY_THRESHOLD, APPROACHING_THRESHOLD } from '@/lib/constants'; /** * Get mastery score color class based on threshold */ export function getMasteryColorClass(score: number): string { - if (score >= 85) return "text-green-600"; - if (score >= 60) return "text-amber-600"; + if (score >= MASTERY_THRESHOLD) return "text-green-600"; + if (score >= APPROACHING_THRESHOLD) return "text-amber-600"; if (score > 0) return "text-red-600"; return "text-muted-foreground"; } @@ -17,8 +18,8 @@ export function getMasteryColorClass(score: number): string { * Get mastery label based on score */ export function getMasteryLabel(score: number): string { - if (score >= 85) return "Mastered"; - if (score >= 60) return "Developing"; + if (score >= MASTERY_THRESHOLD) return "Mastered"; + if (score >= APPROACHING_THRESHOLD) return "Developing"; if (score > 0) return "In Progress"; return "Not Started"; } @@ -30,8 +31,8 @@ export function getMasteryStatus(score: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline"; } { - if (score >= 85) return { label: "Exam Ready", variant: "default" }; - if (score >= 60) return { label: "Developing", variant: "secondary" }; + if (score >= MASTERY_THRESHOLD) return { label: "Exam Ready", variant: "default" }; + if (score >= APPROACHING_THRESHOLD) return { label: "Developing", variant: "secondary" }; if (score > 0) return { label: "Building Foundation", variant: "outline" }; return { label: "Not Started", variant: "outline" }; }