Improve analysis UX: algorithm explanations, skeleton loading, auto-expand achieved specs

- Replace terse one-line optimization mode descriptions with clearer multi-sentence
  explanations of how Maximize Count and Priority Order algorithms behave
- Add skeleton loading placeholders on course buttons while analysis is pending
- Auto-expand achieved specializations to show credit breakdown by default
- Add instructional subtitles to Course Selection and Specializations sections
- Make Clear and Clear All buttons more prominent with visible backgrounds
This commit is contained in:
2026-02-28 21:56:06 -05:00
parent 6af24d9270
commit 969d4ff5a9
10 changed files with 201 additions and 17 deletions

View File

@@ -64,19 +64,17 @@ function ElectiveSet({
{!isPinned && hasHighImpact && (
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px', fontWeight: 400 }}>high impact</span>
)}
{!isPinned && loading && !analysis && (
<span style={{ fontSize: '11px', color: '#888', marginLeft: '8px', fontWeight: 400 }}>analyzing...</span>
)}
</h4>
{isPinned && (
<button
onClick={onUnpin}
style={{
fontSize: '11px', border: 'none', background: 'none',
color: '#3b82f6', cursor: 'pointer', padding: '2px 4px',
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
borderRadius: '4px', fontWeight: 500,
}}
>
clear
Clear
</button>
)}
</div>
@@ -89,6 +87,7 @@ function ElectiveSet({
{courses.map((course) => {
const ceiling = ceilingMap.get(course.id);
const reqFor = requiredForSpec[course.id];
const showSkeleton = loading && !analysis;
return (
<button
key={course.id}
@@ -102,7 +101,19 @@ function ElectiveSet({
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<span style={{ flex: 1 }}>{course.name}</span>
{ceiling && (
{showSkeleton ? (
<span
style={{
display: 'inline-block',
width: '60px',
height: '14px',
borderRadius: '3px',
background: 'linear-gradient(90deg, #e5e7eb 25%, #f0f0f0 50%, #e5e7eb 75%)',
backgroundSize: '200% 100%',
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
}}
/>
) : ceiling ? (
<span style={{
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
@@ -114,7 +125,7 @@ function ElectiveSet({
</span>
)}
</span>
)}
) : null}
</div>
{reqFor && (
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
@@ -130,6 +141,8 @@ function ElectiveSet({
);
}
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
const hasPinned = Object.keys(pinnedCourses).length > 0;
@@ -139,14 +152,19 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin
return (
<div>
<style>{skeletonStyle}</style>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h2 style={{ fontSize: '16px', margin: 0 }}>Course Selection</h2>
<div>
<h2 style={{ fontSize: '16px', margin: 0 }}>Course Selection</h2>
<p style={{ fontSize: '12px', color: '#888', margin: '2px 0 0' }}>Select one course per elective slot. Analysis shows how each choice affects your specializations.</p>
</div>
{hasPinned && (
<button
onClick={onClearAll}
style={{
fontSize: '12px', border: 'none', background: 'none',
color: '#ef4444', cursor: 'pointer', padding: '2px 6px',
fontSize: '12px', border: '1px solid #fecaca', background: '#fef2f2',
color: '#dc2626', cursor: 'pointer', padding: '3px 10px',
borderRadius: '4px', fontWeight: 500,
}}
>
Clear All

View File

@@ -45,10 +45,10 @@ export function ModeToggle({ mode, onSetMode }: ModeToggleProps) {
Priority Order
</button>
</div>
<p style={{ fontSize: '11px', color: '#888', marginTop: '6px' }}>
<p style={{ fontSize: '11px', color: '#888', marginTop: '6px', lineHeight: '1.4' }}>
{mode === 'maximize-count'
? 'Get as many specializations as possible. Ranking breaks ties.'
: 'Guarantee your top-ranked specialization first, then add more.'}
? 'Finds the combination of specializations that achieves the highest count (up to 3). Your ranking is only used to break ties when multiple combinations achieve the same count.'
: 'Processes specializations in your ranked order, top to bottom. Locks in your highest-ranked achievable specialization first, then adds more if feasible. Your ranking directly controls which specializations are prioritized.'}
</p>
</div>
);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import {
DndContext,
closestCenter,
@@ -180,7 +180,23 @@ interface SpecializationRankingProps {
}
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
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 } }),
@@ -217,7 +233,8 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
return (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '8px' }}>Specializations</h2>
<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`