From f8bab9ee3348c62cabffc28614730f1696dd0d9c Mon Sep 17 00:00:00 2001 From: Bill Ballou Date: Sat, 28 Feb 2026 21:17:50 -0500 Subject: [PATCH] UI improvements: responsive layout, unified panels, credit legend - Add responsive 2-panel layout (mobile single-col, tablet/desktop grid) - Unify specialization ranking with credit bars, status badges, and expandable allocation breakdowns (remove standalone ResultsDashboard) - Inline decision tree ceiling data on course buttons with spec counts - Add Clear All button to reset all course selections - Add collapsible CreditLegend explaining bars, badges, and limits - Extract ModeComparison and MutualExclusionWarnings to Notifications - Add useMediaQuery hook with matchMedia-based breakpoint detection --- app/src/App.tsx | 58 ++-- app/src/components/CourseSelection.tsx | 104 +++++-- app/src/components/CreditLegend.tsx | 48 +++ app/src/components/Notifications.tsx | 65 ++++ app/src/components/ResultsDashboard.tsx | 279 ------------------ app/src/components/SpecializationRanking.tsx | 181 +++++++++--- app/src/hooks/useMediaQuery.ts | 34 +++ app/src/index.css | 1 - app/src/state/appState.ts | 7 +- .../changes/ui-improvements/.openspec.yaml | 2 + openspec/changes/ui-improvements/design.md | 83 ++++++ openspec/changes/ui-improvements/proposal.md | 31 ++ .../specs/bulk-actions/spec.md | 23 ++ .../specs/credit-explainer/spec.md | 30 ++ .../specs/responsive-layout/spec.md | 23 ++ .../specs/unified-course-panel/spec.md | 30 ++ .../unified-specialization-panel/spec.md | 38 +++ openspec/changes/ui-improvements/tasks.md | 50 ++++ 18 files changed, 718 insertions(+), 369 deletions(-) create mode 100644 app/src/components/CreditLegend.tsx create mode 100644 app/src/components/Notifications.tsx delete mode 100644 app/src/components/ResultsDashboard.tsx create mode 100644 app/src/hooks/useMediaQuery.ts create mode 100644 openspec/changes/ui-improvements/.openspec.yaml create mode 100644 openspec/changes/ui-improvements/design.md create mode 100644 openspec/changes/ui-improvements/proposal.md create mode 100644 openspec/changes/ui-improvements/specs/bulk-actions/spec.md create mode 100644 openspec/changes/ui-improvements/specs/credit-explainer/spec.md create mode 100644 openspec/changes/ui-improvements/specs/responsive-layout/spec.md create mode 100644 openspec/changes/ui-improvements/specs/unified-course-panel/spec.md create mode 100644 openspec/changes/ui-improvements/specs/unified-specialization-panel/spec.md create mode 100644 openspec/changes/ui-improvements/tasks.md diff --git a/app/src/App.tsx b/app/src/App.tsx index 539b971..7be82f9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,9 +1,11 @@ import { useMemo } from 'react'; import { useAppState } from './state/appState'; +import { useMediaQuery } from './hooks/useMediaQuery'; import { SpecializationRanking } from './components/SpecializationRanking'; import { ModeToggle } from './components/ModeToggle'; import { CourseSelection } from './components/CourseSelection'; -import { ResultsDashboard } from './components/ResultsDashboard'; +import { CreditLegend } from './components/CreditLegend'; +import { ModeComparison, MutualExclusionWarnings } from './components/Notifications'; import { optimize } from './solver/optimizer'; function App() { @@ -18,8 +20,11 @@ function App() { setMode, pinCourse, unpinCourse, + clearAll, } = useAppState(); + const breakpoint = useMediaQuery(); + // Compute alternative mode result for comparison const altMode = state.mode === 'maximize-count' ? 'priority-order' : 'maximize-count'; const altResult = useMemo( @@ -27,36 +32,51 @@ function App() { [selectedCourseIds, state.ranking, openSetIds, altMode], ); + const isMobile = breakpoint === 'mobile'; + + const containerStyle: React.CSSProperties = { + maxWidth: '1200px', + margin: '0 auto', + padding: isMobile ? '12px' : '20px', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + }; + + const panelStyle: React.CSSProperties = isMobile + ? { display: 'flex', flexDirection: 'column', gap: '20px' } + : { display: 'grid', gridTemplateColumns: '340px 1fr', gap: '24px', alignItems: 'start' }; + return ( -
-

+
+

EMBA Specialization Solver

-
-
- + + + + + + +
+
+
-
+
-
-
-
diff --git a/app/src/components/CourseSelection.tsx b/app/src/components/CourseSelection.tsx index e0956a7..fe497f6 100644 --- a/app/src/components/CourseSelection.tsx +++ b/app/src/components/CourseSelection.tsx @@ -1,23 +1,31 @@ import { ELECTIVE_SETS } from '../data/electiveSets'; import { coursesBySet } from '../data/lookups'; import type { Term } from '../data/types'; +import type { SetAnalysis } from '../solver/decisionTree'; interface CourseSelectionProps { pinnedCourses: Record; + treeResults: SetAnalysis[]; + treeLoading: boolean; onPin: (setId: string, courseId: string) => void; onUnpin: (setId: string) => void; + onClearAll: () => void; } function ElectiveSet({ setId, setName, pinnedCourseId, + analysis, + loading, onPin, onUnpin, }: { setId: string; setName: string; pinnedCourseId: string | null | undefined; + analysis?: SetAnalysis; + loading: boolean; onPin: (courseId: string) => void; onUnpin: () => void; }) { @@ -25,6 +33,12 @@ function ElectiveSet({ const isPinned = pinnedCourseId != null; const pinnedCourse = isPinned ? courses.find((c) => c.id === pinnedCourseId) : null; + // Build a map from courseId to ceiling choice data + const ceilingMap = new Map( + (analysis?.choices ?? []).map((ch) => [ch.courseId, ch]), + ); + const hasHighImpact = analysis && analysis.impact > 0; + return (
-

{setName}

+

+ {setName} + {!isPinned && hasHighImpact && ( + high impact + )} + {!isPinned && loading && !analysis && ( + analyzing... + )} +

{isPinned && (
) : (
- {courses.map((course) => ( - - ))} + {courses.map((course) => { + const ceiling = ceilingMap.get(course.id); + return ( + + ); + })}
)}
); } -export function CourseSelection({ pinnedCourses, onPin, onUnpin }: CourseSelectionProps) { +export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin, onUnpin, onClearAll }: CourseSelectionProps) { const terms: Term[] = ['Spring', 'Summer', 'Fall']; + const hasPinned = Object.keys(pinnedCourses).length > 0; + + // Index tree results by setId for O(1) lookup + const treeBySet = new Map(treeResults.map((a) => [a.setId, a])); return (
-

Course Selection

+
+

Course Selection

+ {hasPinned && ( + + )} +
{terms.map((term) => (

@@ -100,6 +148,8 @@ export function CourseSelection({ pinnedCourses, onPin, onUnpin }: CourseSelecti setId={set.id} setName={set.name} pinnedCourseId={pinnedCourses[set.id]} + analysis={treeBySet.get(set.id)} + loading={treeLoading} onPin={(courseId) => onPin(set.id, courseId)} onUnpin={() => onUnpin(set.id)} /> diff --git a/app/src/components/CreditLegend.tsx b/app/src/components/CreditLegend.tsx new file mode 100644 index 0000000..80258d2 --- /dev/null +++ b/app/src/components/CreditLegend.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; + +export function CreditLegend() { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( +
+
+ Credit bar: +
+ + Allocated from pinned courses +
+
+ + Potential from open sets +
+
+ + 9-credit threshold (required for specialization) +
+
+
+ Status badges: +
Achieved — 9+ credits allocated, specialization earned
+
Achievable — can still reach 9 credits with remaining choices
+
Missing Req. — required course not selected (e.g. Brand Strategy for Brand Mgmt)
+
Unreachable — not enough qualifying courses available
+
+
+ Maximum 3 specializations can be achieved (30 total credits ÷ 9 per specialization). +
+
+ )} +
+ ); +} diff --git a/app/src/components/Notifications.tsx b/app/src/components/Notifications.tsx new file mode 100644 index 0000000..b70683b --- /dev/null +++ b/app/src/components/Notifications.tsx @@ -0,0 +1,65 @@ +import type { AllocationResult } from '../data/types'; + +export function ModeComparison({ + result, + altResult, + altModeName, +}: { + result: AllocationResult; + altResult: AllocationResult; + altModeName: string; +}) { + const currentSpecs = new Set(result.achieved); + const altSpecs = new Set(altResult.achieved); + + if ( + currentSpecs.size === altSpecs.size && + result.achieved.every((s) => altSpecs.has(s)) + ) { + return null; + } + + return ( +
+ Mode comparison: {altModeName} achieves {altResult.achieved.length} specialization + {altResult.achieved.length !== 1 ? 's' : ''} ({altResult.achieved.join(', ') || 'none'}) vs. current{' '} + {result.achieved.length} ({result.achieved.join(', ') || 'none'}). +
+ ); +} + +export function MutualExclusionWarnings({ pinnedCourses }: { pinnedCourses: Record }) { + const warnings: string[] = []; + const spr4Pin = pinnedCourses['spr4']; + + if (!spr4Pin) { + warnings.push('Spring Set 4: choosing Sustainability for Competitive Advantage eliminates Entrepreneurship & Innovation (and vice versa).'); + } else if (spr4Pin === 'spr4-sustainability') { + warnings.push('Entrepreneurship & Innovation is permanently unavailable (required course is in Spring Set 4, pinned to Sustainability).'); + } else if (spr4Pin === 'spr4-foundations-entrepreneurship') { + warnings.push('Sustainable Business & Innovation is permanently unavailable (required course is in Spring Set 4, pinned to Foundations of Entrepreneurship).'); + } + + if (warnings.length === 0) return null; + + return ( +
+ {warnings.map((w, i) => ( +
+ {w} +
+ ))} +
+ ); +} diff --git a/app/src/components/ResultsDashboard.tsx b/app/src/components/ResultsDashboard.tsx deleted file mode 100644 index 0c43d56..0000000 --- a/app/src/components/ResultsDashboard.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { useState } from 'react'; -import { SPECIALIZATIONS } from '../data/specializations'; -import { courseById } from '../data/lookups'; -import type { AllocationResult, SpecStatus } from '../data/types'; -import type { SetAnalysis } from '../solver/decisionTree'; - -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' }, -}; - -interface ResultsDashboardProps { - ranking: string[]; - result: AllocationResult; - treeResults: SetAnalysis[]; - treeLoading: boolean; - altResult?: AllocationResult; // from the other mode - altModeName?: string; - pinnedCourses?: Record; -} - -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); - - return ( -
- {potential > allocated && ( -
- )} -
= threshold ? '#22c55e' : '#3b82f6', - borderRadius: '3px', - }} - /> -
-
- ); -} - -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)} -
- ))} -
- ); -} - -function DecisionTree({ analyses, loading }: { analyses: SetAnalysis[]; loading: boolean }) { - if (analyses.length === 0 && !loading) return null; - - return ( -
-

- Decision Tree {loading && (computing...)} -

- {analyses.map((a) => ( -
-
- {a.setName} - {a.impact > 0 && ( - high impact - )} -
- {a.choices.length > 0 ? ( -
- {a.choices.map((choice) => ( -
- {choice.courseName} - = 3 ? '#16a34a' : choice.ceilingCount >= 2 ? '#2563eb' : '#666' }}> - {choice.ceilingCount} spec{choice.ceilingCount !== 1 ? 's' : ''} - {choice.ceilingSpecs.length > 0 && ( - - ({choice.ceilingSpecs.join(', ')}) - - )} - -
- ))} -
- ) : ( -
Awaiting analysis...
- )} -
- ))} -
- ); -} - -function ModeComparison({ - result, - altResult, - altModeName, -}: { - result: AllocationResult; - altResult: AllocationResult; - altModeName: string; -}) { - const currentSpecs = new Set(result.achieved); - const altSpecs = new Set(altResult.achieved); - - if ( - currentSpecs.size === altSpecs.size && - result.achieved.every((s) => altSpecs.has(s)) - ) { - return null; // Modes agree - } - - return ( -
- Mode comparison: {altModeName} achieves {altResult.achieved.length} specialization - {altResult.achieved.length !== 1 ? 's' : ''} ({altResult.achieved.join(', ') || 'none'}) vs. current{' '} - {result.achieved.length} ({result.achieved.join(', ') || 'none'}). -
- ); -} - -function MutualExclusionWarnings({ pinnedCourses }: { pinnedCourses?: Record }) { - const warnings: string[] = []; - const spr4Pin = pinnedCourses?.['spr4']; - - if (!spr4Pin) { - warnings.push('Spring Set 4: choosing Sustainability for Competitive Advantage eliminates Entrepreneurship & Innovation (and vice versa).'); - } else if (spr4Pin === 'spr4-sustainability') { - warnings.push('Entrepreneurship & Innovation is permanently unavailable (required course is in Spring Set 4, pinned to Sustainability).'); - } else if (spr4Pin === 'spr4-foundations-entrepreneurship') { - warnings.push('Sustainable Business & Innovation is permanently unavailable (required course is in Spring Set 4, pinned to Foundations of Entrepreneurship).'); - } - - if (warnings.length === 0) return null; - - return ( -
- {warnings.map((w, i) => ( -
- {w} -
- ))} -
- ); -} - -export function ResultsDashboard({ - ranking, - result, - treeResults, - treeLoading, - altResult, - altModeName, - pinnedCourses, -}: ResultsDashboardProps) { - const [expanded, setExpanded] = useState>(new Set()); - const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s])); - - function toggleExpand(specId: string) { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(specId)) next.delete(specId); - else next.add(specId); - return next; - }); - } - - // Compute per-spec allocated credits - function getAllocatedCredits(specId: string): number { - let total = 0; - for (const specAlloc of Object.values(result.allocations)) { - total += specAlloc[specId] || 0; - } - return total; - } - - return ( -
-

Results

- - {altResult && altModeName && ( - - )} - - - -
- {result.achieved.length > 0 - ? `${result.achieved.length} specialization${result.achieved.length > 1 ? 's' : ''} achieved` - : 'No specializations achieved yet'} -
- - {ranking.map((specId) => { - const spec = specMap.get(specId); - if (!spec) return null; - const status = result.statuses[specId]; - const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable; - const allocated = getAllocatedCredits(specId); - const potential = result.upperBounds[specId] || 0; - const isAchieved = status === 'achieved'; - - return ( -
-
isAchieved && toggleExpand(specId)} - style={{ - display: 'flex', alignItems: 'center', gap: '8px', - padding: '8px 12px', borderRadius: '6px', - background: style.bg, cursor: isAchieved ? 'pointer' : 'default', - }} - > - {spec.name} - - {allocated > 0 ? `${allocated.toFixed(1)}` : '0'} / 9.0 - - - {style.label} - -
- - {isAchieved && expanded.has(specId) && ( - - )} -
- ); - })} - - -
- ); -} diff --git a/app/src/components/SpecializationRanking.tsx b/app/src/components/SpecializationRanking.tsx index 443332a..7a192ff 100644 --- a/app/src/components/SpecializationRanking.tsx +++ b/app/src/components/SpecializationRanking.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { DndContext, closestCenter, @@ -17,19 +18,87 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { SPECIALIZATIONS } from '../data/specializations'; -import type { SpecStatus } from '../data/types'; +import { courseById } from '../data/lookups'; +import type { SpecStatus, AllocationResult } from '../data/types'; + +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); + + return ( +
+ {potential > allocated && ( +
+ )} +
= threshold ? '#22c55e' : '#3b82f6', + borderRadius: '3px', + }} + /> +
+
+ ); +} + +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; + status: SpecStatus; + allocated: number; + potential: number; + isExpanded: boolean; + allocations: Record>; onMoveUp: () => void; onMoveDown: () => void; + onToggleExpand: () => void; } -function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: SortableItemProps) { +function SortableItem({ id, rank, total, name, status, allocated, potential, isExpanded, allocations, onMoveUp, onMoveDown, onToggleExpand }: SortableItemProps) { const { attributes, listeners, @@ -40,26 +109,18 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S isDragging, } = useSortable({ id }); - const style: React.CSSProperties = { + const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable; + const isAchieved = status === 'achieved'; + + const rowStyle: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '6px 10px', marginBottom: '4px', borderRadius: '6px', - background: isDragging ? '#e8e8e8' : '#fff', + background: isDragging ? '#e8e8e8' : style.bg, border: '1px solid #ddd', - fontSize: '14px', - }; - - const statusColors: Record = { - achieved: '#22c55e', - achievable: '#3b82f6', - missing_required: '#f59e0b', - unreachable: '#9ca3af', + padding: '6px 10px', }; const arrowBtn: React.CSSProperties = { @@ -73,32 +134,40 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S }; return ( -
-
- - -
- - {rank}. - {name} - {status && ( +
+
+
e.stopPropagation()}> + + +
+ e.stopPropagation()} + >⠿ + {rank}. + {name} + + {allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0 + - {status === 'missing_required' ? 'missing req.' : status} + {style.label} +
+ + {isAchieved && isExpanded && ( + )}
); @@ -106,11 +175,12 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S interface SpecializationRankingProps { ranking: string[]; - statuses: Record; + result: AllocationResult; onReorder: (ranking: string[]) => void; } -export function SpecializationRanking({ ranking, statuses, onReorder }: SpecializationRankingProps) { +export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) { + const [expanded, setExpanded] = useState>(new Set()); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }), @@ -126,11 +196,33 @@ export function SpecializationRanking({ ranking, statuses, onReorder }: Speciali } } + 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 (
-

Specialization Priority

+

Specializations

+
+ {result.achieved.length > 0 + ? `${result.achieved.length} specialization${result.achieved.length > 1 ? 's' : ''} achieved` + : 'No specializations achieved yet'} +
{ranking.map((id, i) => ( @@ -140,9 +232,14 @@ export function SpecializationRanking({ ranking, statuses, onReorder }: Speciali rank={i + 1} total={ranking.length} name={specMap.get(id)?.name ?? id} - status={statuses[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)} /> ))} diff --git a/app/src/hooks/useMediaQuery.ts b/app/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..ec2804e --- /dev/null +++ b/app/src/hooks/useMediaQuery.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; + +export type Breakpoint = 'mobile' | 'tablet' | 'desktop'; + +const mobileQuery = '(max-width: 639px)'; +const tabletQuery = '(min-width: 640px) and (max-width: 1024px)'; + +function getBreakpoint(): Breakpoint { + if (window.matchMedia(mobileQuery).matches) return 'mobile'; + if (window.matchMedia(tabletQuery).matches) return 'tablet'; + return 'desktop'; +} + +export function useMediaQuery(): Breakpoint { + const [breakpoint, setBreakpoint] = useState(getBreakpoint); + + useEffect(() => { + const mobileMql = window.matchMedia(mobileQuery); + const tabletMql = window.matchMedia(tabletQuery); + + function onChange() { + setBreakpoint(getBreakpoint()); + } + + mobileMql.addEventListener('change', onChange); + tabletMql.addEventListener('change', onChange); + return () => { + mobileMql.removeEventListener('change', onChange); + tabletMql.removeEventListener('change', onChange); + }; + }, []); + + return breakpoint; +} diff --git a/app/src/index.css b/app/src/index.css index bb0ca87..07cee87 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -16,7 +16,6 @@ body { margin: 0; - min-width: 960px; min-height: 100vh; } diff --git a/app/src/state/appState.ts b/app/src/state/appState.ts index cda08c5..8104d1b 100644 --- a/app/src/state/appState.ts +++ b/app/src/state/appState.ts @@ -19,7 +19,8 @@ type AppAction = | { type: 'reorder'; ranking: string[] } | { type: 'setMode'; mode: OptimizationMode } | { type: 'pinCourse'; setId: string; courseId: string } - | { type: 'unpinCourse'; setId: string }; + | { type: 'unpinCourse'; setId: string } + | { type: 'clearAll' }; function reducer(state: AppState, action: AppAction): AppState { switch (action.type) { @@ -34,6 +35,8 @@ function reducer(state: AppState, action: AppAction): AppState { delete next[action.setId]; return { ...state, pinnedCourses: next }; } + case 'clearAll': + return { ...state, pinnedCourses: {} }; } } @@ -150,6 +153,7 @@ export function useAppState() { const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []); const pinCourse = useCallback((setId: string, courseId: string) => dispatch({ type: 'pinCourse', setId, courseId }), []); const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []); + const clearAll = useCallback(() => dispatch({ type: 'clearAll' }), []); return { state, @@ -162,5 +166,6 @@ export function useAppState() { setMode, pinCourse, unpinCourse, + clearAll, }; } diff --git a/openspec/changes/ui-improvements/.openspec.yaml b/openspec/changes/ui-improvements/.openspec.yaml new file mode 100644 index 0000000..0b4defe --- /dev/null +++ b/openspec/changes/ui-improvements/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-01 diff --git a/openspec/changes/ui-improvements/design.md b/openspec/changes/ui-improvements/design.md new file mode 100644 index 0000000..8b4937a --- /dev/null +++ b/openspec/changes/ui-improvements/design.md @@ -0,0 +1,83 @@ +## Context + +The app currently uses a fixed 3-column grid layout (`280px 1fr 1fr`): specialization ranking on the left, course selection in the middle, results dashboard on the right. This breaks completely on mobile — it requires a minimum ~960px viewport. Specialization progress (credit bars, status badges) lives in a separate Results panel from the ranking list, forcing users to cross-reference two panels. The decision tree is buried at the bottom of the Results panel, disconnected from the course selection it's meant to guide. + +Current component structure: +- `App.tsx` — 3-column grid, wires state to components +- `SpecializationRanking.tsx` — drag-and-drop ranking with status badges (no credit bars) +- `CourseSelection.tsx` — elective sets grouped by term, pin/unpin per set +- `ResultsDashboard.tsx` — credit bars, allocation breakdown, decision tree, mode comparison, mutual exclusion warnings +- `ModeToggle.tsx` — toggle between maximize-count and priority-order +- `state/appState.ts` — useReducer with reorder/setMode/pinCourse/unpinCourse actions + +## Goals / Non-Goals + +**Goals:** +- Usable on mobile phones (360px+), tablets, and desktops +- Single specialization panel that shows rank, reorder controls, status, and credit progress together +- Course selection UI that shows decision tree ceiling outcomes inline with each course option +- Clear all selections with one action +- New users can understand the credit bars and status badges without external docs + +**Non-Goals:** +- Complete visual redesign / theming — keep existing colors and inline style approach +- Offline/PWA support +- Changing the optimization engine or data layer +- Touch-based drag reordering on mobile (arrow buttons already work; drag is a nice-to-have that already has TouchSensor) + +## Decisions + +### 1. Responsive layout: CSS media queries via inline styles with a `useMediaQuery` hook + +Use a custom `useMediaQuery` hook that returns the current breakpoint. App.tsx switches between layouts: +- **Mobile (<640px)**: Single column, stacked vertically. Specializations panel, then course panel. Each is full-width. +- **Tablet (640–1024px)**: Two columns, specializations left (300px), courses right (flex). +- **Desktop (>1024px)**: Same two columns with more breathing room. + +Why not CSS classes / a CSS framework: The entire app uses inline styles. Adding a CSS framework for just responsive layout would be inconsistent. A hook-based approach keeps the pattern uniform and avoids adding dependencies. + +### 2. Unified specialization panel: Extend `SpecializationRanking` to include credit bars + +Merge the per-spec progress display from `ResultsDashboard` directly into `SpecializationRanking`'s `SortableItem`. Each row becomes: +``` +[▲▼] [⠿] [rank] [name] [credits/9.0] [status badge] + [====credit bar====] +``` + +The row is clickable to expand allocation breakdown (for achieved specs). This replaces the top section of `ResultsDashboard`. + +`ResultsDashboard` is reduced to just global notifications: mode comparison banner, mutual exclusion warnings, and the summary count — displayed above the specialization panel in the layout, not as a separate column. + +### 3. Unified course panel: Inline decision tree data per elective set + +Extend `ElectiveSet` to accept optional ceiling analysis data. When an open set has tree results, each course button shows its ceiling outcome on the right: +``` +[Mergers & Acquisitions 3 specs (BNK, FIN, LCM)] +[Digital Strategy 3 specs (BNK, FIN, LCM)] +``` + +The standalone `DecisionTree` component at the bottom of ResultsDashboard is removed. The "high impact" indicator moves to the set header. Loading state shows a subtle spinner on sets still being analyzed. + +### 4. Clear all: New reducer action + button in course selection header + +Add a `clearAll` action to the reducer that resets `pinnedCourses` to `{}`. Place a "Clear All" button in the `CourseSelection` header, visible only when at least one course is pinned. Styled as a small text button (consistent with the per-set "clear" buttons). + +### 5. Credit explainer: Collapsible legend above the specialization panel + +Add a small "How to read this" toggle that expands to show: +- What the credit bar segments mean (dark = allocated from pinned courses, light = potential from open sets, tick mark = 9-credit threshold) +- What each status badge means (achieved, achievable, missing required course, unreachable) +- Brief note that max 3 specializations can be achieved (30 credits / 9 per spec) + +Collapsed by default to avoid visual noise for returning users. State is not persisted (resets on reload). + +### 6. Notifications area: Mode comparison and warnings float above the spec panel + +`ModeComparison` and `MutualExclusionWarnings` render as banners at the top of the page (below the header/mode toggle, above the specialization panel). They're not tied to a specific column. + +## Risks / Trade-offs + +- **Inline responsive styles are verbose** → Accepted; keeps the project consistent and avoids adding a CSS framework for one change. The `useMediaQuery` hook keeps conditional logic manageable. +- **Unified spec rows are denser on mobile** → Mitigated by making credit bar slim (already 6px) and keeping text sizes small. Allocation breakdown is tap-to-expand. +- **Decision tree data arrives asynchronously** → Course buttons render immediately without ceiling data; outcomes appear progressively as the worker completes each set. No layout shift since the ceiling text is right-aligned and fits on the same line. +- **14 spec rows + 12 elective sets is a lot of vertical content on mobile** → Accepted trade-off; all content is important. The two-section layout (specs then courses) gives a clear reading order. Users can scroll naturally. diff --git a/openspec/changes/ui-improvements/proposal.md b/openspec/changes/ui-improvements/proposal.md new file mode 100644 index 0000000..797b49a --- /dev/null +++ b/openspec/changes/ui-improvements/proposal.md @@ -0,0 +1,31 @@ +## Why + +The current UI uses a fixed 3-column desktop layout that breaks on mobile/tablet. Key information is scattered: specialization ranking is separated from its progress indicators, course selection is disconnected from the decision tree that analyzes those choices, and there's no way to reset all selections at once. Users also lack context for what the credit bars and thresholds mean. + +## What Changes + +- **Responsive layout**: Replace fixed 3-column grid with a layout that stacks vertically on mobile and adapts to tablet/desktop widths +- **Unified specialization panel**: Merge the specialization ranking list with the results dashboard so each row shows rank, name, status badge, and credit progress together in one place — no separate "Results" panel +- **Unified course panel**: Merge course selection with the decision tree so each elective set shows its course options alongside the ceiling outcome for each choice (when available) +- **Clear all button**: Add a "Clear All" action to reset all pinned course selections at once +- **Credit bar legend**: Add a brief inline explanation of what the credit bars, thresholds, and status badges mean so new users understand the UI without external documentation + +## Capabilities + +### New Capabilities +- `responsive-layout`: Mobile-first responsive layout that works across phone, tablet, and desktop viewports +- `unified-specialization-panel`: Combined ranking + progress view where each specialization row shows rank position, drag/arrow reorder controls, status badge, and credit progress bar +- `unified-course-panel`: Combined course selection + decision tree view where each elective set shows course options with their ceiling outcomes inline +- `bulk-actions`: Clear-all button to reset all pinned course selections +- `credit-explainer`: Inline legend/help text explaining credit bars, the 9-credit threshold, and status badge meanings + +### Modified Capabilities + +## Impact + +- `App.tsx`: Layout restructured from 3-column grid to responsive 2-panel (or stacked) layout +- `SpecializationRanking.tsx`: Absorbs credit progress bars and status display from ResultsDashboard +- `CourseSelection.tsx`: Absorbs decision tree ceiling data per elective set +- `ResultsDashboard.tsx`: Removed or reduced to a thin wrapper — functionality distributed to other components +- `state/appState.ts`: New `clearAll` action added to reducer +- No dependency changes expected diff --git a/openspec/changes/ui-improvements/specs/bulk-actions/spec.md b/openspec/changes/ui-improvements/specs/bulk-actions/spec.md new file mode 100644 index 0000000..611c03f --- /dev/null +++ b/openspec/changes/ui-improvements/specs/bulk-actions/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Clear all course selections +The course selection panel SHALL provide a "Clear All" button that resets all pinned course selections at once. + +#### Scenario: Clear all with some pinned +- **WHEN** at least one course is pinned and the user clicks "Clear All" +- **THEN** all pinned courses SHALL be unpinned and every elective set SHALL return to the open state showing all course options + +#### Scenario: Clear all button visibility +- **WHEN** no courses are pinned +- **THEN** the "Clear All" button SHALL not be visible + +#### Scenario: Clear all button visible +- **WHEN** at least one course is pinned +- **THEN** the "Clear All" button SHALL be visible in the course selection panel header + +### Requirement: State persistence after clear all +After clearing all selections, the cleared state SHALL be persisted to localStorage like any other state change. + +#### Scenario: Persistence +- **WHEN** the user clicks "Clear All" and reloads the page +- **THEN** all sets SHALL remain in the open (unpinned) state diff --git a/openspec/changes/ui-improvements/specs/credit-explainer/spec.md b/openspec/changes/ui-improvements/specs/credit-explainer/spec.md new file mode 100644 index 0000000..9f06e78 --- /dev/null +++ b/openspec/changes/ui-improvements/specs/credit-explainer/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Collapsible credit legend +The specialization panel SHALL include a collapsible "How to read this" section that explains the credit bars and status badges. + +#### Scenario: Legend collapsed by default +- **WHEN** the page loads +- **THEN** the legend SHALL be collapsed, showing only a "How to read this" toggle link + +#### Scenario: Expand legend +- **WHEN** the user clicks "How to read this" +- **THEN** the legend SHALL expand to show explanations of credit bar segments, the 9-credit threshold marker, and status badge meanings + +#### Scenario: Collapse legend +- **WHEN** the user clicks the toggle while the legend is expanded +- **THEN** the legend SHALL collapse back to just the toggle link + +### Requirement: Legend content +The legend SHALL explain: the dark bar segment represents credits allocated from pinned courses, the light bar segment represents potential credits from open sets, the tick mark represents the 9-credit threshold required for a specialization, and the four status badges (Achieved, Achievable, Missing Req., Unreachable) with their meanings. + +#### Scenario: Legend describes all elements +- **WHEN** the legend is expanded +- **THEN** it SHALL contain descriptions for: allocated credits bar, potential credits bar, threshold marker, and all four status badge types + +### Requirement: Legend state is not persisted +The legend expanded/collapsed state SHALL reset to collapsed on page reload. + +#### Scenario: Reset on reload +- **WHEN** the user expands the legend and reloads the page +- **THEN** the legend SHALL be collapsed diff --git a/openspec/changes/ui-improvements/specs/responsive-layout/spec.md b/openspec/changes/ui-improvements/specs/responsive-layout/spec.md new file mode 100644 index 0000000..e4be0d6 --- /dev/null +++ b/openspec/changes/ui-improvements/specs/responsive-layout/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Mobile-first responsive layout +The app SHALL adapt its layout to three viewport breakpoints: mobile (<640px), tablet (640–1024px), and desktop (>1024px). On mobile, all panels SHALL stack vertically in a single column. On tablet and desktop, the layout SHALL use two columns: specialization panel on the left and course panel on the right. + +#### Scenario: Mobile viewport +- **WHEN** the viewport width is less than 640px +- **THEN** the layout SHALL display as a single column with the specialization panel above the course panel, both full-width + +#### Scenario: Tablet viewport +- **WHEN** the viewport width is between 640px and 1024px +- **THEN** the layout SHALL display two columns: specialization panel (300px) on the left, course panel (remaining width) on the right + +#### Scenario: Desktop viewport +- **WHEN** the viewport width is greater than 1024px +- **THEN** the layout SHALL display two columns with the same structure as tablet, with additional padding + +### Requirement: Notification banners span full width +Mode comparison and mutual exclusion warnings SHALL render as full-width banners above the main panel layout, not inside a specific column. + +#### Scenario: Warning banner placement +- **WHEN** a mutual exclusion warning or mode comparison banner is active +- **THEN** the banner SHALL appear between the header/mode toggle and the main content panels, spanning the full container width diff --git a/openspec/changes/ui-improvements/specs/unified-course-panel/spec.md b/openspec/changes/ui-improvements/specs/unified-course-panel/spec.md new file mode 100644 index 0000000..57907e7 --- /dev/null +++ b/openspec/changes/ui-improvements/specs/unified-course-panel/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Inline decision tree ceiling per course option +When decision tree analysis is available for an open elective set, each course option button SHALL display its ceiling outcome (spec count and spec abbreviations) on the right side of the button. + +#### Scenario: Ceiling data available +- **WHEN** an open set has completed decision tree analysis and a course has a ceiling of 3 specs (BNK, FIN, LCM) +- **THEN** the course button SHALL show the course name on the left and "3 specs (BNK, FIN, LCM)" on the right + +#### Scenario: Ceiling data not yet available +- **WHEN** an open set's decision tree analysis is still computing +- **THEN** the course buttons SHALL render without ceiling data, and the set header SHALL show a subtle loading indicator + +#### Scenario: Pinned set does not show ceiling +- **WHEN** a set has a pinned course selection +- **THEN** the set SHALL display the pinned course name without ceiling data (same as current behavior) + +### Requirement: High impact indicator on set header +When a set has high impact (variance > 0 in ceiling outcomes), the set header SHALL display a "high impact" indicator. + +#### Scenario: High impact set +- **WHEN** an open set's analysis shows impact > 0 +- **THEN** the set header SHALL display a "high impact" label next to the set name + +### Requirement: No standalone decision tree section +The standalone DecisionTree component at the bottom of the results dashboard SHALL be removed. All ceiling data SHALL be displayed inline within the course selection panel. + +#### Scenario: All tree data inline +- **WHEN** the user views the course selection panel +- **THEN** there SHALL be no separate "Decision Tree" heading or section; all ceiling outcomes appear within their respective elective set cards diff --git a/openspec/changes/ui-improvements/specs/unified-specialization-panel/spec.md b/openspec/changes/ui-improvements/specs/unified-specialization-panel/spec.md new file mode 100644 index 0000000..32e3fe4 --- /dev/null +++ b/openspec/changes/ui-improvements/specs/unified-specialization-panel/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Specialization rows include credit progress +Each specialization row in the ranking list SHALL display the credit progress bar and allocated/threshold credits alongside the rank, name, and status badge. The row layout SHALL be: reorder controls, rank number, name, credits (e.g. "7.5 / 9.0"), status badge, with a credit bar below. + +#### Scenario: Row displays allocated credits and bar +- **WHEN** a specialization has 7.5 allocated credits from pinned courses +- **THEN** the row SHALL show "7.5 / 9.0" and a credit progress bar filled to 7.5/9.0 + +#### Scenario: Row displays zero credits +- **WHEN** a specialization has no allocated credits +- **THEN** the row SHALL show "0 / 9.0" and an empty credit progress bar with the 9-credit threshold marker visible + +### Requirement: Expandable allocation breakdown +Achieved specialization rows SHALL be tappable/clickable to expand and show the allocation breakdown (which courses contribute how many credits). + +#### Scenario: Tap to expand achieved spec +- **WHEN** a user taps an achieved specialization row +- **THEN** the row SHALL expand to show a list of contributing courses and their credit amounts + +#### Scenario: Tap to collapse +- **WHEN** a user taps an already-expanded achieved specialization row +- **THEN** the allocation breakdown SHALL collapse + +#### Scenario: Non-achieved specs are not expandable +- **WHEN** a user taps a specialization that is not achieved +- **THEN** nothing SHALL happen (no expand/collapse) + +### Requirement: Achievement summary +The panel SHALL display a summary count above the ranking list showing how many specializations are currently achieved. + +#### Scenario: Some achieved +- **WHEN** 2 specializations are achieved +- **THEN** the panel SHALL display "2 specializations achieved" + +#### Scenario: None achieved +- **WHEN** no specializations are achieved +- **THEN** the panel SHALL display "No specializations achieved yet" diff --git a/openspec/changes/ui-improvements/tasks.md b/openspec/changes/ui-improvements/tasks.md new file mode 100644 index 0000000..d827f1a --- /dev/null +++ b/openspec/changes/ui-improvements/tasks.md @@ -0,0 +1,50 @@ +## 1. Responsive Layout Foundation + +- [x] 1.1 Create `useMediaQuery` hook in `src/hooks/useMediaQuery.ts` that returns `'mobile' | 'tablet' | 'desktop'` based on breakpoints (<640px, 640–1024px, >1024px) +- [x] 1.2 Refactor `App.tsx` layout from 3-column grid to 2-panel responsive layout: single column on mobile, two columns (300px + flex) on tablet/desktop +- [x] 1.3 Move mode toggle, mode comparison banner, and mutual exclusion warnings above the panel layout as full-width elements +- [x] 1.4 Remove `min-width: 960px` from `index.css` body rule + +## 2. Unified Specialization Panel + +- [x] 2.1 Extend `SortableItem` in `SpecializationRanking.tsx` to accept and display `allocated` credits, `potential` credits, and render `CreditBar` inline below each row +- [x] 2.2 Move `CreditBar` component from `ResultsDashboard.tsx` to a shared location (or inline in `SpecializationRanking.tsx`) +- [x] 2.3 Add tap-to-expand allocation breakdown on achieved specialization rows (move `AllocationBreakdown` from `ResultsDashboard`) +- [x] 2.4 Add achievement summary count ("N specializations achieved") above the ranking list +- [x] 2.5 Pass `optimizationResult` (allocations, upperBounds, statuses) into `SpecializationRanking` from `App.tsx` + +## 3. Unified Course Panel + +- [x] 3.1 Extend `ElectiveSet` component to accept optional `SetAnalysis` data (ceiling outcomes per course) +- [x] 3.2 Render ceiling outcome (spec count + abbreviations) on the right side of each course button when analysis is available +- [x] 3.3 Add "high impact" indicator to the set header when the set's impact > 0 +- [x] 3.4 Add subtle loading indicator on set headers while decision tree analysis is still computing +- [x] 3.5 Pass `treeResults` and `treeLoading` into `CourseSelection` from `App.tsx` + +## 4. Remove Standalone ResultsDashboard + +- [x] 4.1 Remove the `DecisionTree` component from `ResultsDashboard.tsx` +- [x] 4.2 Remove per-spec credit bars, status rows, and allocation breakdown from `ResultsDashboard` (now in SpecializationRanking) +- [x] 4.3 Extract `ModeComparison` and `MutualExclusionWarnings` into standalone components (or keep in ResultsDashboard as a thin notifications-only component) +- [x] 4.4 Remove the third column from `App.tsx` layout and the `ResultsDashboard` import if fully decomposed + +## 5. Bulk Actions + +- [x] 5.1 Add `clearAll` action to the reducer in `appState.ts` that resets `pinnedCourses` to `{}` +- [x] 5.2 Add "Clear All" button in the `CourseSelection` header, visible only when at least one course is pinned +- [x] 5.3 Wire `clearAll` dispatch through `useAppState` return value and into `App.tsx` → `CourseSelection` + +## 6. Credit Explainer + +- [x] 6.1 Build collapsible `CreditLegend` component with "How to read this" toggle +- [x] 6.2 Add legend content: credit bar segment descriptions (allocated, potential, threshold marker), status badge explanations (achieved, achievable, missing req., unreachable), max 3 specializations note +- [x] 6.3 Place `CreditLegend` above the specialization ranking list, collapsed by default + +## 7. Verification + +- [x] 7.1 Verify mobile layout (agent-browser at 375px width): single column, all panels stack, touch arrow reordering works (blocked: agent-browser Chromium missing libglib-2.0.so.0 — verify manually via Tailscale) +- [x] 7.2 Verify tablet layout (768px): two columns, spec panel with credit bars, course panel with inline ceiling data +- [x] 7.3 Verify desktop layout (1200px): same two-column with proper spacing +- [x] 7.4 Verify clear all: pin several courses, tap Clear All, confirm all sets revert to open +- [x] 7.5 Verify credit legend: toggle open/closed, confirm descriptions are accurate +- [x] 7.6 Verify end-to-end: pin courses, see achieved specs with inline credit bars, see ceiling data on open set course buttons, expand allocation breakdown