- Mark "Managing Growing Companies" as cancelled with visual indicator and solver exclusion - Prevent selecting duplicate courses across elective sets (e.g., same course in Spring and Summer) - Add 2.5-credit interval tick marks to specialization progress bars - Bump version to 1.1.0 with date display in UI header
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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<SpecStatus, { bg: string; color: string; label: string }> = {
|
|
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 (
|
|
<div style={{ position: 'relative', height: '6px', background: '#e5e7eb', borderRadius: '3px', marginTop: '4px' }}>
|
|
{potential > allocated && (
|
|
<div
|
|
style={{
|
|
position: 'absolute', left: 0, top: 0, height: '100%',
|
|
width: `${potentialPct}%`, background: '#bfdbfe', borderRadius: '3px',
|
|
transition: 'width 300ms ease-out',
|
|
}}
|
|
/>
|
|
)}
|
|
<div
|
|
style={{
|
|
position: 'absolute', left: 0, top: 0, height: '100%',
|
|
width: `${allocPct}%`, background: 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) => (
|
|
<div
|
|
key={t}
|
|
style={{
|
|
position: 'absolute', left: `${(t / maxWidth) * 100}%`, top: 0,
|
|
width: '1px', height: '6px', background: 'rgba(0,0,0,0.2)',
|
|
zIndex: 1, transition: 'left 300ms ease-out',
|
|
}}
|
|
/>
|
|
))}
|
|
<div
|
|
style={{
|
|
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
|
|
width: '2px', height: '10px', background: '#666',
|
|
zIndex: 2, transition: 'left 300ms ease-out',
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AllocationBreakdown({ specId, allocations }: { specId: string; allocations: Record<string, Record<string, number>> }) {
|
|
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 (
|
|
<div style={{ marginTop: '6px', paddingLeft: '28px', fontSize: '12px', color: '#555' }}>
|
|
{contributions.map((c, i) => (
|
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
<span>{c.courseName}</span>
|
|
<span style={{ fontWeight: 600 }}>{c.credits.toFixed(1)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SortableItemProps {
|
|
id: string;
|
|
rank: number;
|
|
total: number;
|
|
name: string;
|
|
status: SpecStatus;
|
|
allocated: number;
|
|
potential: number;
|
|
isExpanded: boolean;
|
|
allocations: Record<string, Record<string, number>>;
|
|
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 (
|
|
<div ref={setNodeRef} style={rowStyle} {...attributes}>
|
|
<div
|
|
style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: isAchieved ? 'pointer' : 'default' }}
|
|
onClick={isAchieved ? onToggleExpand : undefined}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
|
|
<button style={{ ...arrowBtn, visibility: rank === 1 ? 'hidden' : 'visible' }} onClick={onMoveUp} aria-label="Move up">▲</button>
|
|
<button style={{ ...arrowBtn, visibility: rank === total ? 'hidden' : 'visible' }} onClick={onMoveDown} aria-label="Move down">▼</button>
|
|
</div>
|
|
<span
|
|
ref={setActivatorNodeRef}
|
|
{...listeners}
|
|
style={{ cursor: 'grab', color: '#ccc', fontSize: '14px', touchAction: 'none' }}
|
|
aria-label="Drag to reorder"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>⠿</span>
|
|
<span style={{ color: '#999', fontSize: '12px', minWidth: '20px' }}>{rank}.</span>
|
|
<span style={{ flex: 1, fontSize: '13px', color: '#333' }}>{name}</span>
|
|
<span style={{ fontSize: '11px', color: '#888', whiteSpace: 'nowrap' }}>
|
|
{allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: '11px', padding: '2px 6px', borderRadius: '10px',
|
|
background: style.color + '20', color: style.color, fontWeight: 600,
|
|
whiteSpace: 'nowrap', transition: 'background 200ms, color 200ms',
|
|
}}
|
|
>
|
|
{style.label}
|
|
</span>
|
|
</div>
|
|
<CreditBar allocated={allocated} potential={potential} threshold={9} />
|
|
<div style={{
|
|
maxHeight: isAchieved && isExpanded ? '200px' : '0',
|
|
overflow: 'hidden',
|
|
transition: 'max-height 200ms ease-out',
|
|
}}>
|
|
<AllocationBreakdown specId={id} allocations={allocations} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SpecializationRankingProps {
|
|
ranking: string[];
|
|
result: AllocationResult;
|
|
onReorder: (ranking: string[]) => void;
|
|
}
|
|
|
|
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => 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 (
|
|
<div>
|
|
<h2 style={{ fontSize: '16px', marginBottom: '4px' }}>Specializations</h2>
|
|
<p style={{ fontSize: '12px', color: '#888', margin: '0 0 8px' }}>Drag or use arrows to rank your preferences. The optimizer uses this order to allocate credits.</p>
|
|
<div style={{ marginBottom: '8px', fontSize: '13px', color: '#666' }}>
|
|
{result.achieved.length > 0
|
|
? `${result.achieved.length} specialization${result.achieved.length > 1 ? 's' : ''} achieved`
|
|
: 'No specializations achieved yet'}
|
|
</div>
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={ranking} strategy={verticalListSortingStrategy}>
|
|
{ranking.map((id, i) => (
|
|
<SortableItem
|
|
key={id}
|
|
id={id}
|
|
rank={i + 1}
|
|
total={ranking.length}
|
|
name={specMap.get(id)?.name ?? id}
|
|
status={result.statuses[id]}
|
|
allocated={getAllocatedCredits(id)}
|
|
potential={result.upperBounds[id] || 0}
|
|
isExpanded={expanded.has(id)}
|
|
allocations={result.allocations}
|
|
onMoveUp={() => { if (i > 0) onReorder(arrayMove([...ranking], i, i - 1)); }}
|
|
onMoveDown={() => { if (i < ranking.length - 1) onReorder(arrayMove([...ranking], i, i + 1)); }}
|
|
onToggleExpand={() => toggleExpand(id)}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
);
|
|
}
|