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,2 @@
schema: spec-driven
created: 2026-03-01

View File

@@ -0,0 +1,55 @@
## Context
The app already has a `MobileStatusBanner` component (top banner for specialization statuses) implemented with IntersectionObserver + `position: fixed; top: 0`. This change adds a symmetric bottom banner for course selection progress, following the same patterns.
On mobile, the layout is a single-column flex with specializations above, course selection below. The user needs to scroll between them. The existing top banner appears when specializations scroll out of view; this bottom banner appears when the course selection scrolls out of view (above the viewport).
## Goals / Non-Goals
**Goals:**
- Show a compact, fixed-position banner at the bottom of the viewport on mobile when the course selection section is scrolled above the viewport
- Display course selection progress: "{N} of 12 selected" with a compact visual indicator
- Tapping the banner scrolls to the course selection section
- Follow the same animation and styling patterns as the existing top banner
**Non-Goals:**
- No banner on tablet or desktop
- No course-level detail in the banner (just the count)
- No interaction beyond tap-to-scroll
## Decisions
### 1. Visibility trigger: IntersectionObserver on the course section ref
**Decision**: Attach a `ref` to the course selection wrapper `<div>` in `App.tsx`. Use an IntersectionObserver to detect when this element leaves the viewport. Show the banner when the element is not intersecting.
**Rationale**: Same approach as the specializations banner — consistent, efficient, proven pattern.
### 2. Position: fixed at bottom
**Decision**: Use `position: fixed; bottom: 0` with `z-index: 1000`, matching the top banner's z-index. The banner slides up from the bottom using `translateY(100%)``translateY(0)`.
**Rationale**: Symmetric with the top banner. Same z-index is fine since the two banners don't overlap (one is at top, one at bottom).
### 3. Summary content: Selected count out of 12
**Decision**: Display the number of pinned courses out of 12 total elective sets. Show as "{N} / 12 courses selected". Derive the count from `Object.keys(pinnedCourses).length`.
**Rationale**: Simple, glanceable summary. The 12-total is fixed (defined in `ELECTIVE_SETS`). The count gives immediate progress awareness.
### 4. Component structure: Separate `MobileCourseBanner` component
**Decision**: Create a new `MobileCourseBanner` component rather than making the existing `MobileStatusBanner` generic. Props: `selectedCount: number`, `totalSets: number`, `visible: boolean`, `onTap: () => void`.
**Rationale**: The two banners display fundamentally different data (status badges vs. course count). A shared abstraction would add complexity for little benefit. Keep them simple and independent.
### 5. Integration: Second observer in App.tsx
**Decision**: Add a `courseSectionRef` and a second `useEffect` with its own IntersectionObserver in `App.tsx`, alongside the existing `specSectionRef` observer. Use a separate `courseBannerVisible` state.
**Rationale**: Two independent observers are cleaner than combining logic. Each has its own ref, state, and cleanup.
## Risks / Trade-offs
- **Two fixed banners at once**: On very short mobile viewports, both banners could theoretically appear simultaneously (specializations off top, courses off bottom), reducing visible content. → Acceptable: each is ~40px; this scenario means the user is in the middle of the page with neither section visible, which is the exact moment summaries are most useful.
- **Observer count**: Two IntersectionObservers on mobile. → Negligible performance cost; observers are event-driven, not polling.

View File

@@ -0,0 +1,26 @@
## Why
On mobile, the course selection panel renders below the specializations panel. When the user scrolls up past the course selection section (e.g., to review or reorder specializations), they lose visibility of their current course selection progress — how many of the 12 elective sets have been picked. A floating bottom banner mirrors the existing top specializations banner, giving the user a persistent summary of course selection state and a quick way to scroll back.
## What Changes
- Add a floating banner at the bottom of the viewport on mobile when the user scrolls above the course selection section
- The banner summarizes course selection progress: number of sets selected out of 12 total
- Tapping the banner scrolls down to the course selection section
- Uses the same animation pattern as the existing `MobileStatusBanner` (slide in/out via translateY)
- Only renders on mobile breakpoint (`max-width: 639px`)
## Capabilities
### New Capabilities
- `mobile-course-banner`: A floating bottom UI component that summarizes course selection progress on mobile when the course selection section is scrolled out of view
### Modified Capabilities
<!-- No existing specs to modify -->
## Impact
- **Components**: New `MobileCourseBanner` component; changes to `App.tsx` to mount it with a second IntersectionObserver on the course selection wrapper
- **Styling**: Second `position: fixed` element in the codebase (bottom: 0), using z-index 1000 consistent with the existing top banner
- **Dependencies**: No new libraries required
- **Performance**: Minimal — one additional IntersectionObserver

View File

@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Banner appears when course selection scrolls out of view
The system SHALL display a floating summary banner fixed to the bottom of the viewport on mobile when the course selection section is no longer visible in the viewport.
#### Scenario: User scrolls above course selection on mobile
- **WHEN** the user is on a mobile viewport (max-width: 639px) and scrolls up until the course selection section leaves the viewport
- **THEN** a fixed banner SHALL slide in from the bottom of the screen displaying course selection progress
#### Scenario: User scrolls back down to course selection on mobile
- **WHEN** the banner is visible and the user scrolls down until the course selection section re-enters the viewport
- **THEN** the banner SHALL slide out of view (downward)
#### Scenario: User is on tablet or desktop
- **WHEN** the viewport width is greater than 639px
- **THEN** the banner SHALL NOT render at all
### Requirement: Banner displays course selection progress
The banner SHALL show the number of elective sets that have a selected course out of the total 12 sets.
#### Scenario: Some courses selected
- **WHEN** the banner is visible and the user has selected courses in 5 of 12 elective sets
- **THEN** the banner SHALL display "5 / 12 courses selected"
#### Scenario: No courses selected
- **WHEN** the banner is visible and no courses have been selected
- **THEN** the banner SHALL display "0 / 12 courses selected"
#### Scenario: All courses selected
- **WHEN** the banner is visible and all 12 elective sets have selections
- **THEN** the banner SHALL display "12 / 12 courses selected"
#### Scenario: Selection count updates reactively
- **WHEN** the user selects or deselects a course while the banner is visible
- **THEN** the banner count SHALL update to reflect the new selection count
### Requirement: Banner animates in and out
The banner SHALL animate its appearance and disappearance using a vertical slide transition from the bottom.
#### Scenario: Banner slides in
- **WHEN** the course selection section scrolls out of the viewport on mobile
- **THEN** the banner SHALL transition from `translateY(100%)` to `translateY(0)` over 200ms with ease-out timing
#### Scenario: Banner slides out
- **WHEN** the course selection section scrolls back into the viewport on mobile
- **THEN** the banner SHALL transition from `translateY(0)` to `translateY(100%)` over 200ms with ease-out timing
### Requirement: Tapping banner scrolls to course selection
The banner SHALL be tappable. Tapping it SHALL smoothly scroll the page so the course selection section is visible.
#### Scenario: User taps the banner
- **WHEN** the banner is visible and the user taps anywhere on it
- **THEN** the page SHALL smooth-scroll to bring the course selection section into view
#### Scenario: Banner hides after scroll completes
- **WHEN** the user taps the banner and the page scrolls to the course selection section
- **THEN** the banner SHALL slide out as the course selection section becomes visible (via the standard intersection trigger)
### Requirement: Banner renders above all other content at bottom
The banner SHALL use `position: fixed` with `bottom: 0` and `z-index: 1000` to ensure it layers above all other page content at the bottom of the viewport.
#### Scenario: Banner overlaps page content at bottom
- **WHEN** the banner is visible
- **THEN** it SHALL be rendered at `position: fixed; bottom: 0` spanning the full viewport width, above all other elements

View File

@@ -0,0 +1,23 @@
## 1. Create MobileCourseBanner component
- [x] 1.1 Create `app/src/components/MobileCourseBanner.tsx` with props: `selectedCount: number`, `totalSets: number`, `visible: boolean`, `onTap: () => void`
- [x] 1.2 Render "{selectedCount} / {totalSets} courses selected" text
- [x] 1.3 Style the banner: `position: fixed`, `bottom: 0`, `left: 0`, `width: 100%`, `z-index: 1000`, white background with top border, compact height (~40px)
- [x] 1.4 Implement slide animation: always mounted, use `transform: translateY(100%)` when hidden vs `translateY(0)` when visible, `transition: transform 200ms ease-out`
- [x] 1.5 Attach `onClick` handler to the banner root element that calls `onTap`
## 2. Integrate into App.tsx
- [x] 2.1 Add a `courseSectionRef` to the course selection wrapper `<div>` in `App.tsx`
- [x] 2.2 Add a `courseBannerVisible` state and IntersectionObserver useEffect for the course section ref, gated on `isMobile`
- [x] 2.3 Mount `<MobileCourseBanner>` in the App return (after the existing `MobileStatusBanner`), passing `selectedCount={Object.keys(state.pinnedCourses).length}`, `totalSets={12}`, `visible={courseBannerVisible && isMobile}`, and `onTap` that calls `courseSectionRef.current.scrollIntoView({ behavior: 'smooth' })`
- [x] 2.4 Clean up the IntersectionObserver on unmount or when isMobile changes
## 3. Test
- [x] 3.1 Verify banner appears at bottom on mobile when scrolling above course selection
- [x] 3.2 Verify banner hides when scrolling back down to course selection
- [x] 3.3 Verify banner does not render on tablet/desktop
- [x] 3.4 Verify tapping the banner scrolls to the course selection section
- [x] 3.5 Verify selected count updates reactively when courses are pinned/unpinned
- [x] 3.6 Verify both top and bottom banners can coexist without visual conflict

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}

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