diff --git a/app/src/components/CourseSelection.tsx b/app/src/components/CourseSelection.tsx index 454f084..6d32a5f 100644 --- a/app/src/components/CourseSelection.tsx +++ b/app/src/components/CourseSelection.tsx @@ -64,19 +64,17 @@ function ElectiveSet({ {!isPinned && hasHighImpact && ( high impact )} - {!isPinned && loading && !analysis && ( - analyzing... - )} {isPinned && ( )} @@ -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 ( -

+

{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.'}

); diff --git a/app/src/components/SpecializationRanking.tsx b/app/src/components/SpecializationRanking.tsx index 7a192ff..bddc736 100644 --- a/app/src/components/SpecializationRanking.tsx +++ b/app/src/components/SpecializationRanking.tsx @@ -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>(new Set()); + 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 } }), @@ -217,7 +233,8 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa return (
-

Specializations

+

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` diff --git a/openspec/changes/analysis-ux-improvements/.openspec.yaml b/openspec/changes/analysis-ux-improvements/.openspec.yaml new file mode 100644 index 0000000..0b4defe --- /dev/null +++ b/openspec/changes/analysis-ux-improvements/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-01 diff --git a/openspec/changes/analysis-ux-improvements/design.md b/openspec/changes/analysis-ux-improvements/design.md new file mode 100644 index 0000000..918fdae --- /dev/null +++ b/openspec/changes/analysis-ux-improvements/design.md @@ -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 `

` 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 `

` 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 `