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

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>
);
}

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>
);
}

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