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`

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-01

View File

@@ -0,0 +1,43 @@
## Context
The app is a single-page React application for EMBA course planning. Users select courses from elective sets, rank specializations by priority, and an optimizer allocates credits to determine which specializations are achieved. The UI has three usability gaps identified in the proposal: terse algorithm descriptions, minimal loading feedback, and hidden credit breakdowns for achieved specializations.
All three changes are scoped to existing React components with no new dependencies, API changes, or data model changes.
## Goals / Non-Goals
**Goals:**
- Make the optimization mode descriptions clear enough that users understand the practical trade-off without needing external documentation.
- Provide immediate visual feedback via skeleton placeholders when analysis is in progress.
- Ensure users always see credit breakdowns for achieved specializations without requiring discovery of click-to-expand.
**Non-Goals:**
- Redesigning the ModeToggle layout or switching to a different control type (dropdown, radio, etc.).
- Adding a global progress bar or percentage indicator for the worker.
- Making non-achieved specializations expandable.
- Adding animation/transition effects to the skeleton loading.
## Decisions
### Algorithm explanations approach
**Decision**: Replace the single-line `<p>` description below the segmented control with a multi-line explanation block that describes each mode in terms of its practical behavior and when to use it.
**Rationale**: Users need to understand the trade-off (breadth vs. guarantee) to make an informed choice. A slightly longer description (2-3 sentences) directly below the active button conveys this without cluttering the UI. Alternative considered: tooltips on each button — rejected because they require hover (not mobile-friendly) and hide information by default.
### Skeleton loading approach
**Decision**: When `loading` is true and no `analysis` exists for a set, render skeleton placeholder rectangles in place of the course choice buttons. Replace the "analyzing..." text label with these visual placeholders.
**Rationale**: Skeleton screens communicate that content is loading and will appear in-place, reducing perceived wait time. They also reserve layout space, preventing content jumps. Alternative considered: spinner overlay — rejected because it blocks interaction and doesn't communicate what's loading.
**Implementation**: Render 2-3 rectangular `<div>` elements with a light pulsing background (`#e5e7eb` with CSS animation) sized to match the course choice buttons. No external library needed — a simple CSS keyframe animation suffices.
### Achieved specializations default expanded
**Decision**: Initialize the `expanded` state set to contain all achieved specialization IDs instead of an empty set. Update it whenever `result.achieved` changes.
**Rationale**: The credit breakdown is the most useful information for achieved specializations — users shouldn't need to discover a click affordance to see it. Users can still click to collapse if they want a compact view. Alternative considered: removing the expand/collapse entirely — rejected because users may want the compact view once they've reviewed the breakdown.
## Risks / Trade-offs
- **Longer mode descriptions take space** → Kept to 2-3 sentences; acceptable given users only have two modes and this is a key decision point.
- **Skeleton animation adds CSS** → Minimal; a single `@keyframes` rule via inline styles or a `<style>` tag in the component.
- **Auto-expanding achieved specs increases vertical height** → Only achieved specializations (typically 1-3 of 14) are expanded; the additional height is modest and the information is valuable.

View File

@@ -0,0 +1,24 @@
## Why
The analysis UI has three usability gaps: the two optimization modes ("Maximize Count" and "Priority Order") have minimal one-line descriptions that don't convey how they differ in practice, there is no visual loading feedback beyond a tiny per-set "analyzing..." label when the solver runs, and achieved specializations don't show their credit breakdown by default—users must discover the click-to-expand behavior on their own.
## What Changes
- Replace the terse one-line mode descriptions in `ModeToggle` with clearer, more informative explanations that help users understand the practical difference between the two algorithms.
- Add a skeleton loading placeholder to elective set cards while analysis is pending, replacing the minimal "analyzing..." text with a visual placeholder that communicates progress.
- Default achieved specializations to expanded state so users always see the per-course credit breakdown without needing to click.
## Capabilities
### New Capabilities
- `algorithm-explanations`: Improved descriptive text and presentation for the two optimization mode settings in ModeToggle.
- `analysis-skeleton-loading`: Skeleton placeholder UI shown on elective set cards while the decision tree worker is processing.
- `expanded-achieved-specializations`: Achieved specializations default to expanded, always showing the AllocationBreakdown credit list.
### Modified Capabilities
## Impact
- `app/src/components/ModeToggle.tsx` — updated descriptive text and possibly layout for mode explanations.
- `app/src/components/CourseSelection.tsx` — replace "analyzing..." text with skeleton loading placeholder.
- `app/src/components/SpecializationRanking.tsx` — change default expansion state for achieved specializations.

View File

@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Mode description explains practical behavior
The ModeToggle component SHALL display a multi-sentence description for each optimization mode that explains the algorithm's practical behavior and when a user would choose it.
For "Maximize Count", the description SHALL convey that:
- The algorithm finds the combination of specializations that achieves the highest count (up to 3).
- The user's ranking is used only to break ties when multiple combinations achieve the same count.
For "Priority Order", the description SHALL convey that:
- The algorithm processes specializations in the user's ranked order, top to bottom.
- It locks in the highest-ranked achievable specialization first, then adds more if feasible.
- The user's ranking directly controls which specializations are prioritized.
#### Scenario: User selects Maximize Count mode
- **WHEN** the optimization mode is set to "maximize-count"
- **THEN** a description is displayed that explains the algorithm finds the largest possible set of specializations and uses ranking only as a tiebreaker
#### Scenario: User selects Priority Order mode
- **WHEN** the optimization mode is set to "priority-order"
- **THEN** a description is displayed that explains the algorithm guarantees the top-ranked specialization first and greedily adds more in rank order
#### Scenario: Description updates on mode switch
- **WHEN** the user switches from one mode to the other
- **THEN** the description text SHALL update immediately to reflect the newly selected mode

View File

@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Skeleton placeholder during analysis
When an elective set is not pinned and analysis is pending, the CourseSelection component SHALL display skeleton placeholder elements in place of the course choice buttons.
The skeleton placeholders SHALL:
- Visually approximate the size and layout of the course choice buttons they replace.
- Display a pulsing/animated background to indicate loading.
- Replace the current "analyzing..." text label.
#### Scenario: Analysis pending for an unpinned set
- **WHEN** an elective set is not pinned AND `loading` is true AND no analysis result exists for that set
- **THEN** skeleton placeholder rectangles SHALL be displayed instead of course choice buttons
#### Scenario: Analysis completes for a set
- **WHEN** an analysis result arrives for a previously loading set
- **THEN** the skeleton placeholders SHALL be replaced with the actual course choice buttons showing analysis data
#### Scenario: Set is already pinned
- **WHEN** an elective set has a pinned course
- **THEN** no skeleton placeholders SHALL be shown regardless of loading state

View File

@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Achieved specializations default to expanded
The SpecializationRanking component SHALL initialize with all achieved specializations in the expanded state, showing their AllocationBreakdown credit list by default.
#### Scenario: Initial render with achieved specializations
- **WHEN** the SpecializationRanking component renders with one or more achieved specializations
- **THEN** the AllocationBreakdown (per-course credit list) SHALL be visible for each achieved specialization without user interaction
#### Scenario: Achieved set changes after course selection
- **WHEN** the set of achieved specializations changes due to a course pin/unpin or ranking reorder
- **THEN** all newly achieved specializations SHALL be expanded by default
#### Scenario: User manually collapses an achieved specialization
- **WHEN** the user clicks on an achieved specialization that is currently expanded
- **THEN** the AllocationBreakdown for that specialization SHALL collapse
#### Scenario: Non-achieved specializations remain non-expandable
- **WHEN** a specialization has a status other than "achieved"
- **THEN** it SHALL NOT display an AllocationBreakdown and SHALL NOT be expandable

View File

@@ -0,0 +1,14 @@
## 1. Algorithm Explanations
- [x] 1.1 Update ModeToggle description text for "maximize-count" mode with multi-sentence explanation covering: finds largest set of achievable specializations, ranking used only as tiebreaker
- [x] 1.2 Update ModeToggle description text for "priority-order" mode with multi-sentence explanation covering: processes specializations in ranked order top-to-bottom, locks in highest-ranked first, then adds more
## 2. Skeleton Loading
- [x] 2.1 Add skeleton placeholder component to CourseSelection.tsx — render 2-3 pulsing rectangular divs matching course button dimensions when `loading && !analysis && !isPinned`
- [x] 2.2 Remove the existing "analyzing..." text label from ElectiveSet
## 3. Expanded Achieved Specializations
- [x] 3.1 Change SpecializationRanking `expanded` state initialization to include all achieved specialization IDs from `result.achieved`
- [x] 3.2 Add effect or derived state to update `expanded` set when `result.achieved` changes, ensuring newly achieved specializations are auto-expanded