import { useState, useEffect, useRef } from 'react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, arrayMove, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { SPECIALIZATIONS } from '../data/specializations'; import { courseById } from '../data/lookups'; import type { SpecStatus, AllocationResult } from '../data/types'; export const STATUS_STYLES: Record = { achieved: { bg: '#dcfce7', color: '#16a34a', label: 'Achieved' }, achievable: { bg: '#dbeafe', color: '#2563eb', label: 'Achievable' }, missing_required: { bg: '#fef3c7', color: '#d97706', label: 'Missing Req.' }, unreachable: { bg: '#f3f4f6', color: '#9ca3af', label: 'Unreachable' }, }; function CreditBar({ allocated, potential, threshold }: { allocated: number; potential: number; threshold: number }) { const maxWidth = Math.max(potential, threshold); const allocPct = Math.min((allocated / maxWidth) * 100, 100); const potentialPct = Math.min((potential / maxWidth) * 100, 100); // Generate tick marks at 2.5 credit intervals const ticks: number[] = []; for (let t = 2.5; t < maxWidth; t += 2.5) { ticks.push(t); } return (
{potential > allocated && (
)}
= threshold ? '#22c55e' : '#3b82f6', borderRadius: '3px', transition: 'width 300ms ease-out', }} /> {/* Tick marks at 2.5 credit intervals — rendered above bar fills */} {ticks.map((t) => (
))}
); } function AllocationBreakdown({ specId, allocations }: { specId: string; allocations: Record> }) { const contributions: { courseName: string; credits: number }[] = []; for (const [courseId, specAlloc] of Object.entries(allocations)) { const credits = specAlloc[specId]; if (credits && credits > 0) { const course = courseById[courseId]; contributions.push({ courseName: course?.name ?? courseId, credits }); } } if (contributions.length === 0) return null; return (
{contributions.map((c, i) => (
{c.courseName} {c.credits.toFixed(1)}
))}
); } interface SortableItemProps { id: string; rank: number; total: number; name: string; status: SpecStatus; allocated: number; potential: number; isExpanded: boolean; allocations: Record>; onMoveUp: () => void; onMoveDown: () => void; onToggleExpand: () => void; } function SortableItem({ id, rank, total, name, status, allocated, potential, isExpanded, allocations, onMoveUp, onMoveDown, onToggleExpand }: SortableItemProps) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition: dndTransition, isDragging, } = useSortable({ id }); const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable; const isAchieved = status === 'achieved'; // Track changes and flash on status/credit updates const prevStatusRef = useRef(status); const prevAllocatedRef = useRef(allocated); const [flash, setFlash] = useState(false); useEffect(() => { const statusChanged = prevStatusRef.current !== status; const creditsChanged = prevAllocatedRef.current !== allocated; prevStatusRef.current = status; prevAllocatedRef.current = allocated; if (statusChanged || creditsChanged) { setFlash(true); const timer = setTimeout(() => setFlash(false), 400); return () => clearTimeout(timer); } }, [status, allocated]); const rowStyle: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition: [dndTransition, 'background 400ms ease-out, box-shadow 400ms ease-out'].filter(Boolean).join(', '), opacity: isDragging ? 0.5 : 1, marginBottom: '4px', borderRadius: '6px', background: isDragging ? '#e8e8e8' : flash ? '#fef9c3' : style.bg, border: '1px solid #ddd', padding: '6px 10px', boxShadow: flash ? '0 0 0 2px #facc15' : 'none', }; const arrowBtn: React.CSSProperties = { border: 'none', background: 'none', cursor: 'pointer', padding: '0 2px', fontSize: '14px', color: '#999', lineHeight: 1, }; return (
e.stopPropagation()}>
e.stopPropagation()} >⠿ {rank}. {name} {allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0 {style.label}
); } interface SpecializationRankingProps { ranking: string[]; result: AllocationResult; onReorder: (ranking: string[]) => void; } export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) { const [expanded, setExpanded] = useState>(() => new Set(result.achieved)); const prevAchievedRef = useRef(result.achieved); useEffect(() => { const prev = prevAchievedRef.current; if (prev !== result.achieved) { prevAchievedRef.current = result.achieved; const newlyAchieved = result.achieved.filter((id) => !prev.includes(id)); if (newlyAchieved.length > 0) { setExpanded((s) => { const next = new Set(s); for (const id of newlyAchieved) next.add(id); return next; }); } } }, [result.achieved]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = ranking.indexOf(active.id as string); const newIndex = ranking.indexOf(over.id as string); onReorder(arrayMove(ranking, oldIndex, newIndex)); } } function toggleExpand(specId: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(specId)) next.delete(specId); else next.add(specId); return next; }); } function getAllocatedCredits(specId: string): number { let total = 0; for (const specAlloc of Object.values(result.allocations)) { total += specAlloc[specId] || 0; } return total; } const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s])); return (

Specializations

Drag or use arrows to rank your preferences. The optimizer uses this order to allocate credits.

{result.achieved.length > 0 ? `${result.achieved.length} specialization${result.achieved.length > 1 ? 's' : ''} achieved` : 'No specializations achieved yet'}
{ranking.map((id, i) => ( { if (i > 0) onReorder(arrayMove([...ranking], i, i - 1)); }} onMoveDown={() => { if (i < ranking.length - 1) onReorder(arrayMove([...ranking], i, i + 1)); }} onToggleExpand={() => toggleExpand(id)} /> ))}
); }