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
This commit is contained in:
2026-02-28 21:17:50 -05:00
parent 9e00901179
commit f8bab9ee33
18 changed files with 718 additions and 369 deletions

View File

@@ -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 (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
<h1 style={{ fontSize: '20px', marginBottom: '20px', color: '#111' }}>
<div style={containerStyle}>
<h1 style={{ fontSize: '20px', marginBottom: '12px', color: '#111' }}>
EMBA Specialization Solver
</h1>
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr 1fr', gap: '24px', alignItems: 'start' }}>
<div>
<ModeToggle mode={state.mode} onSetMode={setMode} />
<ModeToggle mode={state.mode} onSetMode={setMode} />
<MutualExclusionWarnings pinnedCourses={state.pinnedCourses} />
<ModeComparison
result={optimizationResult}
altResult={altResult}
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
/>
<div style={panelStyle}>
<div style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<CreditLegend />
<SpecializationRanking
ranking={state.ranking}
statuses={optimizationResult.statuses}
result={optimizationResult}
onReorder={reorder}
/>
</div>
<div style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<div style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<CourseSelection
pinnedCourses={state.pinnedCourses}
onPin={pinCourse}
onUnpin={unpinCourse}
/>
</div>
<div style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<ResultsDashboard
ranking={state.ranking}
result={optimizationResult}
treeResults={treeResults}
treeLoading={treeLoading}
altResult={altResult}
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
pinnedCourses={state.pinnedCourses}
onPin={pinCourse}
onUnpin={unpinCourse}
onClearAll={clearAll}
/>
</div>
</div>

View File

@@ -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<string, string | null>;
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 (
<div
style={{
@@ -36,17 +50,21 @@ function ElectiveSet({
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>{setName}</h4>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>
{setName}
{!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: 'none', background: 'none',
color: '#3b82f6', cursor: 'pointer', padding: '2px 4px',
}}
>
clear
@@ -59,36 +77,66 @@ function ElectiveSet({
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{courses.map((course) => (
<button
key={course.id}
onClick={() => onPin(course.id)}
style={{
textAlign: 'left',
padding: '6px 10px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
background: '#fff',
cursor: 'pointer',
fontSize: '13px',
color: '#333',
}}
>
{course.name}
</button>
))}
{courses.map((course) => {
const ceiling = ceilingMap.get(course.id);
return (
<button
key={course.id}
onClick={() => onPin(course.id)}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
textAlign: 'left', padding: '6px 10px',
border: '1px solid #e5e7eb', borderRadius: '4px',
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#333',
gap: '8px',
}}
>
<span style={{ flex: 1 }}>{course.name}</span>
{ceiling && (
<span style={{
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
}}>
{ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
{ceiling.ceilingSpecs.length > 0 && (
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
({ceiling.ceilingSpecs.join(', ')})
</span>
)}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
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 (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Course Selection</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h2 style={{ fontSize: '16px', margin: 0 }}>Course Selection</h2>
{hasPinned && (
<button
onClick={onClearAll}
style={{
fontSize: '12px', border: 'none', background: 'none',
color: '#ef4444', cursor: 'pointer', padding: '2px 6px',
}}
>
Clear All
</button>
)}
</div>
{terms.map((term) => (
<div key={term} style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '13px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
@@ -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)}
/>

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
export function CreditLegend() {
const [open, setOpen] = useState(false);
return (
<div style={{ marginBottom: '10px', fontSize: '12px' }}>
<button
onClick={() => setOpen(!open)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
color: '#3b82f6', fontSize: '12px', padding: 0,
}}
>
{open ? '▾ How to read this' : '▸ How to read this'}
</button>
{open && (
<div style={{ marginTop: '6px', padding: '10px', background: '#f9fafb', borderRadius: '6px', border: '1px solid #e5e7eb', color: '#555', lineHeight: 1.6 }}>
<div style={{ marginBottom: '8px' }}>
<strong>Credit bar:</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
<span style={{ display: 'inline-block', width: '14px', height: '8px', background: '#3b82f6', borderRadius: '2px' }} />
Allocated from pinned courses
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
<span style={{ display: 'inline-block', width: '14px', height: '8px', background: '#bfdbfe', borderRadius: '2px' }} />
Potential from open sets
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
<span style={{ display: 'inline-block', width: '2px', height: '10px', background: '#666' }} />
9-credit threshold (required for specialization)
</div>
</div>
<div style={{ marginBottom: '8px' }}>
<strong>Status badges:</strong>
<div><span style={{ color: '#16a34a', fontWeight: 600 }}>Achieved</span> 9+ credits allocated, specialization earned</div>
<div><span style={{ color: '#2563eb', fontWeight: 600 }}>Achievable</span> can still reach 9 credits with remaining choices</div>
<div><span style={{ color: '#d97706', fontWeight: 600 }}>Missing Req.</span> required course not selected (e.g. Brand Strategy for Brand Mgmt)</div>
<div><span style={{ color: '#9ca3af', fontWeight: 600 }}>Unreachable</span> not enough qualifying courses available</div>
</div>
<div style={{ color: '#888' }}>
Maximum 3 specializations can be achieved (30 total credits ÷ 9 per specialization).
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '10px', marginBottom: '8px', fontSize: '12px',
}}
>
<strong>Mode comparison:</strong> {altModeName} achieves {altResult.achieved.length} specialization
{altResult.achieved.length !== 1 ? 's' : ''} ({altResult.achieved.join(', ') || 'none'}) vs. current{' '}
{result.achieved.length} ({result.achieved.join(', ') || 'none'}).
</div>
);
}
export function MutualExclusionWarnings({ pinnedCourses }: { pinnedCourses: Record<string, string | null> }) {
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 (
<div style={{ marginBottom: '8px' }}>
{warnings.map((w, i) => (
<div
key={i}
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '8px 10px', fontSize: '12px', color: '#92400e', marginBottom: '4px',
}}
>
{w}
</div>
))}
</div>
);
}

View File

@@ -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<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' },
};
interface ResultsDashboardProps {
ranking: string[];
result: AllocationResult;
treeResults: SetAnalysis[];
treeLoading: boolean;
altResult?: AllocationResult; // from the other mode
altModeName?: string;
pinnedCourses?: Record<string, string | null>;
}
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 (
<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',
}}
/>
)}
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
borderRadius: '3px',
}}
/>
<div
style={{
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
width: '2px', height: '10px', background: '#666',
}}
/>
</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: '8px', 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>
);
}
function DecisionTree({ analyses, loading }: { analyses: SetAnalysis[]; loading: boolean }) {
if (analyses.length === 0 && !loading) return null;
return (
<div style={{ marginTop: '20px' }}>
<h3 style={{ fontSize: '14px', marginBottom: '8px' }}>
Decision Tree {loading && <span style={{ fontSize: '12px', color: '#888' }}>(computing...)</span>}
</h3>
{analyses.map((a) => (
<div key={a.setId} style={{ marginBottom: '12px', border: '1px solid #e5e7eb', borderRadius: '6px', padding: '10px' }}>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '6px' }}>
{a.setName}
{a.impact > 0 && (
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px' }}>high impact</span>
)}
</div>
{a.choices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{a.choices.map((choice) => (
<div
key={choice.courseId}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '4px 8px', background: '#f9fafb', borderRadius: '4px', fontSize: '12px',
}}
>
<span>{choice.courseName}</span>
<span style={{ fontWeight: 600, color: choice.ceilingCount >= 3 ? '#16a34a' : choice.ceilingCount >= 2 ? '#2563eb' : '#666' }}>
{choice.ceilingCount} spec{choice.ceilingCount !== 1 ? 's' : ''}
{choice.ceilingSpecs.length > 0 && (
<span style={{ fontWeight: 400, color: '#888', marginLeft: '4px' }}>
({choice.ceilingSpecs.join(', ')})
</span>
)}
</span>
</div>
))}
</div>
) : (
<div style={{ fontSize: '12px', color: '#888' }}>Awaiting analysis...</div>
)}
</div>
))}
</div>
);
}
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 (
<div
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '10px', marginBottom: '12px', fontSize: '12px',
}}
>
<strong>Mode comparison:</strong> {altModeName} achieves {altResult.achieved.length} specialization
{altResult.achieved.length !== 1 ? 's' : ''} ({altResult.achieved.join(', ') || 'none'}) vs. current{' '}
{result.achieved.length} ({result.achieved.join(', ') || 'none'}).
</div>
);
}
function MutualExclusionWarnings({ pinnedCourses }: { pinnedCourses?: Record<string, string | null> }) {
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 (
<div style={{ marginBottom: '12px' }}>
{warnings.map((w, i) => (
<div
key={i}
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '8px 10px', fontSize: '12px', color: '#92400e', marginBottom: '4px',
}}
>
{w}
</div>
))}
</div>
);
}
export function ResultsDashboard({
ranking,
result,
treeResults,
treeLoading,
altResult,
altModeName,
pinnedCourses,
}: ResultsDashboardProps) {
const [expanded, setExpanded] = useState<Set<string>>(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 (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Results</h2>
{altResult && altModeName && (
<ModeComparison result={result} altResult={altResult} altModeName={altModeName} />
)}
<MutualExclusionWarnings pinnedCourses={pinnedCourses} />
<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>
{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 (
<div key={specId} style={{ marginBottom: '6px' }}>
<div
onClick={() => isAchieved && toggleExpand(specId)}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', borderRadius: '6px',
background: style.bg, cursor: isAchieved ? 'pointer' : 'default',
}}
>
<span style={{ flex: 1, fontSize: '13px', color: '#333' }}>{spec.name}</span>
<span style={{ fontSize: '11px', color: '#888' }}>
{allocated > 0 ? `${allocated.toFixed(1)}` : '0'} / 9.0
</span>
<span
style={{
fontSize: '11px', padding: '2px 8px', borderRadius: '10px',
background: style.color + '15', color: style.color, fontWeight: 600,
}}
>
{style.label}
</span>
</div>
<CreditBar allocated={allocated} potential={potential} threshold={9} />
{isAchieved && expanded.has(specId) && (
<AllocationBreakdown specId={specId} allocations={result.allocations} />
)}
</div>
);
})}
<DecisionTree analyses={treeResults} loading={treeLoading} />
</div>
);
}

View File

@@ -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<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);
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',
}}
/>
)}
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
borderRadius: '3px',
}}
/>
<div
style={{
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
width: '2px', height: '10px', background: '#666',
}}
/>
</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;
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, 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<SpecStatus, string> = {
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 (
<div ref={setNodeRef} style={style} {...attributes}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0px' }}>
<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"
></span>
<span style={{ color: '#999', fontSize: '12px', width: '20px' }}>{rank}.</span>
<span style={{ flex: 1 }}>{name}</span>
{status && (
<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: statusColors[status] + '20',
color: statusColors[status],
fontWeight: 600,
fontSize: '11px', padding: '2px 6px', borderRadius: '10px',
background: style.color + '20', color: style.color, fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{status === 'missing_required' ? 'missing req.' : status}
{style.label}
</span>
</div>
<CreditBar allocated={allocated} potential={potential} threshold={9} />
{isAchieved && isExpanded && (
<AllocationBreakdown specId={id} allocations={allocations} />
)}
</div>
);
@@ -106,11 +175,12 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S
interface SpecializationRankingProps {
ranking: string[];
statuses: Record<string, SpecStatus>;
result: AllocationResult;
onReorder: (ranking: string[]) => void;
}
export function SpecializationRanking({ ranking, statuses, onReorder }: SpecializationRankingProps) {
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
const [expanded, setExpanded] = useState<Set<string>>(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 (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Specialization Priority</h2>
<h2 style={{ fontSize: '16px', marginBottom: '8px' }}>Specializations</h2>
<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) => (
@@ -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)}
/>
))}
</SortableContext>

View File

@@ -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<Breakpoint>(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;
}

View File

@@ -16,7 +16,6 @@
body {
margin: 0;
min-width: 960px;
min-height: 100vh;
}

View File

@@ -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,
};
}

View File

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

View File

@@ -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 (6401024px)**: 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.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Mobile-first responsive layout
The app SHALL adapt its layout to three viewport breakpoints: mobile (<640px), tablet (6401024px), 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

View File

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

View File

@@ -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"

View File

@@ -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, 6401024px, >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