Add mobile floating banners for specialization status and course selection progress

On mobile, the single-column layout makes it easy to lose context when
scrolling between the specializations and course selection panels. This adds
two floating banners that appear via IntersectionObserver:

- Top banner: summarizes specialization statuses (achieved/achievable/missing/unreachable)
- Bottom banner: shows course selection progress (N/12 selected)

Both slide in/out with CSS transitions and scroll to their respective
sections on tap. Only rendered on mobile viewports (max-width: 639px).
This commit is contained in:
2026-02-28 22:27:07 -05:00
parent 969d4ff5a9
commit 7940050196
14 changed files with 527 additions and 4 deletions
+58 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
import { useAppState } from './state/appState';
import { useMediaQuery } from './hooks/useMediaQuery';
import { SpecializationRanking } from './components/SpecializationRanking';
@@ -6,6 +6,8 @@ import { ModeToggle } from './components/ModeToggle';
import { CourseSelection } from './components/CourseSelection';
import { CreditLegend } from './components/CreditLegend';
import { ModeComparison } from './components/Notifications';
import { MobileStatusBanner } from './components/MobileStatusBanner';
import { MobileCourseBanner } from './components/MobileCourseBanner';
import { optimize } from './solver/optimizer';
function App() {
@@ -34,6 +36,44 @@ function App() {
const isMobile = breakpoint === 'mobile';
const specSectionRef = useRef<HTMLDivElement>(null);
const [bannerVisible, setBannerVisible] = useState(false);
useEffect(() => {
if (!isMobile || !specSectionRef.current) {
setBannerVisible(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => setBannerVisible(!entry.isIntersecting),
);
observer.observe(specSectionRef.current);
return () => observer.disconnect();
}, [isMobile]);
const handleBannerTap = useCallback(() => {
specSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const courseSectionRef = useRef<HTMLDivElement>(null);
const [courseBannerVisible, setCourseBannerVisible] = useState(false);
useEffect(() => {
if (!isMobile || !courseSectionRef.current) {
setCourseBannerVisible(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => setCourseBannerVisible(!entry.isIntersecting),
);
observer.observe(courseSectionRef.current);
return () => observer.disconnect();
}, [isMobile]);
const handleCourseBannerTap = useCallback(() => {
courseSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const containerStyle: React.CSSProperties = {
maxWidth: '1200px',
margin: '0 auto',
@@ -47,6 +87,21 @@ function App() {
return (
<div style={containerStyle}>
{isMobile && (
<>
<MobileStatusBanner
statuses={optimizationResult.statuses}
visible={bannerVisible}
onTap={handleBannerTap}
/>
<MobileCourseBanner
selectedCount={Object.keys(state.pinnedCourses).length}
totalSets={12}
visible={courseBannerVisible}
onTap={handleCourseBannerTap}
/>
</>
)}
<h1 style={{ fontSize: '20px', marginBottom: '12px', color: '#111' }}>
EMBA Specialization Solver
</h1>
@@ -60,7 +115,7 @@ function App() {
/>
<div style={panelStyle}>
<div style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<div ref={specSectionRef} style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<CreditLegend />
<SpecializationRanking
ranking={state.ranking}
@@ -68,7 +123,7 @@ function App() {
onReorder={reorder}
/>
</div>
<div style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<div ref={courseSectionRef} style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<CourseSelection
pinnedCourses={state.pinnedCourses}
treeResults={treeResults}
+38
View File
@@ -0,0 +1,38 @@
interface MobileCourseBannerProps {
selectedCount: number;
totalSets: number;
visible: boolean;
onTap: () => void;
}
export function MobileCourseBanner({ selectedCount, totalSets, visible, onTap }: MobileCourseBannerProps) {
const bannerStyle: React.CSSProperties = {
position: 'fixed',
bottom: 0,
left: 0,
width: '100%',
zIndex: 1000,
background: '#fff',
borderTop: '1px solid #ddd',
padding: '8px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
transform: visible ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 200ms ease-out',
cursor: 'pointer',
boxSizing: 'border-box',
};
return (
<div style={bannerStyle} onClick={onTap}>
<span style={{ fontSize: '13px', fontWeight: 600, color: '#333' }}>
{selectedCount} / {totalSets}
</span>
<span style={{ fontSize: '12px', color: '#666' }}>
courses selected
</span>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { STATUS_STYLES } from './SpecializationRanking';
import type { SpecStatus } from '../data/types';
const STATUS_ORDER: SpecStatus[] = ['achieved', 'achievable', 'missing_required', 'unreachable'];
interface MobileStatusBannerProps {
statuses: Record<string, SpecStatus>;
visible: boolean;
onTap: () => void;
}
export function MobileStatusBanner({ statuses, visible, onTap }: MobileStatusBannerProps) {
const counts: Record<SpecStatus, number> = { achieved: 0, achievable: 0, missing_required: 0, unreachable: 0 };
for (const status of Object.values(statuses)) {
counts[status]++;
}
const bannerStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
zIndex: 1000,
background: '#fff',
borderBottom: '1px solid #ddd',
padding: '8px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
flexWrap: 'wrap',
transform: visible ? 'translateY(0)' : 'translateY(-100%)',
transition: 'transform 200ms ease-out',
cursor: 'pointer',
boxSizing: 'border-box',
};
return (
<div style={bannerStyle} onClick={onTap}>
{STATUS_ORDER.map((key) => {
const style = STATUS_STYLES[key];
return (
<span
key={key}
style={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '10px',
background: style.color + '20',
color: style.color,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{counts[key]} {style.label}
</span>
);
})}
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ import { SPECIALIZATIONS } from '../data/specializations';
import { courseById } from '../data/lookups';
import type { SpecStatus, AllocationResult } from '../data/types';
const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
export 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.' },