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:
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
48
app/src/components/CreditLegend.tsx
Normal file
48
app/src/components/CreditLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
app/src/components/Notifications.tsx
Normal file
65
app/src/components/Notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
34
app/src/hooks/useMediaQuery.ts
Normal file
34
app/src/hooks/useMediaQuery.ts
Normal 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;
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 960px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user