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

View File

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

View File

@@ -0,0 +1,73 @@
## Context
On mobile (max-width: 639px), the app stacks the specializations panel above the course selection panel in a single scrolling page. The user loses sight of specialization statuses when scrolling down to pick courses. There are no existing sticky/fixed elements in the codebase — all positioning is static or component-relative.
The app uses inline `React.CSSProperties` exclusively (no CSS framework), React 19, and the `useMediaQuery` hook for responsive breakpoints.
## Goals / Non-Goals
**Goals:**
- Show a compact, fixed-position banner at the top of the viewport on mobile when the specializations section scrolls out of view
- Summarize all four status categories with counts and color-coded badges matching `STATUS_STYLES`
- Allow tapping the banner to scroll back to the specializations section
- Animate the banner in/out smoothly
**Non-Goals:**
- No banner on tablet or desktop — those layouts already keep specializations visible via the two-column grid with `maxHeight: 85vh` scroll containers
- No interactive controls in the banner (no reordering, no expansion) — it's read-only summary
- No persistence of banner visibility preference
## Decisions
### 1. Visibility trigger: IntersectionObserver on a sentinel ref
**Decision**: Attach a `ref` to the specializations wrapper `<div>` in `App.tsx`. Use an `IntersectionObserver` to detect when this element leaves the viewport. When the element is not intersecting, show the banner.
**Alternatives considered**:
- *Scroll event listener with threshold*: Requires computing offsets, fires frequently, needs throttling. IntersectionObserver is more efficient and declarative.
- *CSS `position: sticky`*: Cannot transform the full specializations panel into a compact summary. Sticky would keep the full panel visible, which is too large on mobile.
### 2. Component placement: Rendered in App.tsx, outside the panel layout
**Decision**: Mount `<MobileStatusBanner>` at the top of the `App` return, before the `<h1>`. It renders as `position: fixed; top: 0` so DOM order doesn't matter for visual placement, but placing it early keeps the component tree logical.
**Rationale**: The banner needs `optimizationResult.statuses` which is already available in `App`. Placing it at the App level avoids prop drilling through the specializations component.
### 3. Data flow: Pass statuses record directly
**Decision**: Pass `result.statuses` (the `Record<string, SpecStatus>`) to the banner component. The component counts each status category internally.
**Alternatives considered**:
- *Pass pre-computed counts*: Would reduce banner's logic but adds computation to App. The count loop over 14 entries is trivial.
- *Pass the full AllocationResult*: Over-exposes data the banner doesn't need.
### 4. Animation: CSS transition on transform
**Decision**: Use `transform: translateY(-100%)` when hidden, `translateY(0)` when visible, with a `transition: transform 200ms ease-out`. The element is always mounted when on mobile; visibility is toggled via transform.
**Alternatives considered**:
- *Conditional rendering with mount/unmount*: Cannot animate exit without additional logic (e.g., `onTransitionEnd` cleanup). Always-mounted with transform is simpler.
- *opacity fade*: Banner would still occupy space or need pointer-events toggling. Transform slide is cleaner — slides up off-screen.
### 5. Scroll-to-top behavior: `scrollIntoView` on the specializations ref
**Decision**: When the banner is tapped, call `specSectionRef.current.scrollIntoView({ behavior: 'smooth' })`. This reuses the same ref used for the IntersectionObserver.
**Rationale**: Simple, built-in browser API. No offset calculations needed since we want the section at the top of the viewport.
### 6. Styling approach: Inline styles, consistent with codebase
**Decision**: Use inline `React.CSSProperties` for the banner, matching the existing project convention. Reuse `STATUS_STYLES` colors from `SpecializationRanking.tsx` by exporting them.
**Alternative**: Could duplicate the color constants in the banner file. But exporting avoids drift if status colors change.
### 7. z-index: 1000
**Decision**: Use `z-index: 1000` for the fixed banner. This is the first fixed element in the app, so any reasonable value works. 1000 leaves room for future layering needs (modals, tooltips) without collision.
## Risks / Trade-offs
- **First `position: fixed` element**: Introduces a new positioning context. If future components also use fixed positioning, z-index conflicts could arise. → Mitigation: Document the z-index value; 1000 leaves headroom.
- **IntersectionObserver on older browsers**: Supported in all modern browsers (Safari 12.1+, Chrome 58+). EMBA students use modern devices. → No polyfill needed.
- **Banner covers page content**: Fixed element at top overlaps scrollable content. → Mitigation: The banner is compact (~40px tall) and only appears when the user has already scrolled past the specializations section, so it replaces information that's already off-screen. The slide-in animation makes the appearance non-jarring.
- **STATUS_STYLES export**: Changing the export surface of `SpecializationRanking.tsx`. → Minimal risk — it's a simple constant export, not a behavioral change.

View File

@@ -0,0 +1,28 @@
## Why
On mobile, the specializations panel stacks above the course selection panel and scrolls naturally with the page. Once a user scrolls down to interact with course selections, they lose visibility of the specialization statuses (achieved, achievable, missing required, unreachable). This forces constant scrolling back up to check how course picks affect specialization progress. A floating summary banner solves this by keeping the key status information visible while the user works through their course selections.
## What Changes
- Add a floating banner that appears at the top of the viewport on mobile after the user scrolls past the specializations section
- The banner summarizes specialization statuses: count of achieved, achievable, missing required, and unreachable specializations
- Uses the same color-coded status badges already defined in `STATUS_STYLES`
- The banner animates in/out based on scroll position using an `IntersectionObserver` on the specializations section
- Tapping the banner scrolls back to the full specializations panel
- Only renders on mobile breakpoint (`max-width: 639px`)
## Capabilities
### New Capabilities
- `mobile-status-banner`: A floating UI component that summarizes specialization statuses on mobile when the specializations section is scrolled out of view
### Modified Capabilities
<!-- No existing specs to modify -->
## Impact
- **Components**: New `MobileStatusBanner` component; minor changes to `App.tsx` to mount it and pass specialization data
- **Hooks**: May add a small scroll-detection hook or use `IntersectionObserver` inline
- **Styling**: Introduces the first `position: fixed` element in the codebase; needs appropriate `z-index` to layer above content
- **Dependencies**: No new libraries required — `IntersectionObserver` is a browser API
- **Performance**: Minimal — observer fires only on intersection changes, not on every scroll event

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Banner appears when specializations scroll out of view
The system SHALL display a floating summary banner fixed to the top of the viewport on mobile when the specializations section is no longer visible in the viewport.
#### Scenario: User scrolls past specializations on mobile
- **WHEN** the user is on a mobile viewport (max-width: 639px) and scrolls down until the specializations section leaves the viewport
- **THEN** a fixed banner SHALL slide in from the top of the screen displaying specialization status counts
#### Scenario: User scrolls back up to specializations on mobile
- **WHEN** the banner is visible and the user scrolls up until the specializations section re-enters the viewport
- **THEN** the banner SHALL slide out of view (upward)
#### 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 status counts with color-coded badges
The banner SHALL show the count of specializations in each status category using the same color scheme as the specialization list: achieved (green), achievable (blue), missing required (amber), and unreachable (gray).
#### Scenario: Multiple statuses present
- **WHEN** the banner is visible and statuses are: 2 achieved, 5 achievable, 1 missing required, 6 unreachable
- **THEN** the banner SHALL display four badges: "2 Achieved" in green, "5 Achievable" in blue, "1 Missing Req." in amber, "6 Unreachable" in gray
#### Scenario: Zero count for a status category
- **WHEN** a status category has zero specializations (e.g., 0 missing required)
- **THEN** that category's badge SHALL still be displayed with a count of 0
#### Scenario: Statuses update after course selection change
- **WHEN** the user selects or deselects a course while the banner is visible
- **THEN** the banner status counts SHALL update to reflect the new optimization result
### Requirement: Banner animates in and out
The banner SHALL animate its appearance and disappearance using a vertical slide transition.
#### Scenario: Banner slides in
- **WHEN** the specializations 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 specializations 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 specializations
The banner SHALL be tappable. Tapping it SHALL smoothly scroll the page so the specializations section is visible at the top of the viewport.
#### 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 specializations section to the top of the viewport
#### Scenario: Banner hides after scroll completes
- **WHEN** the user taps the banner and the page scrolls to the specializations section
- **THEN** the banner SHALL slide out as the specializations section becomes visible (via the standard intersection trigger)
### Requirement: Banner renders above all other content
The banner SHALL use `position: fixed` with `z-index: 1000` to ensure it layers above all other page content.
#### Scenario: Banner overlaps page content
- **WHEN** the banner is visible
- **THEN** it SHALL be rendered at `position: fixed; top: 0` spanning the full viewport width, above all other elements
### Requirement: Banner uses consistent styling
The banner SHALL use inline `React.CSSProperties` consistent with the existing codebase convention, and SHALL reuse the exported `STATUS_STYLES` color definitions from the specialization ranking component.
#### Scenario: Status colors match specialization list
- **WHEN** the banner displays status badges
- **THEN** the badge colors SHALL exactly match the `STATUS_STYLES` used in the specialization ranking rows (achieved: #16a34a, achievable: #2563eb, missing_required: #d97706, unreachable: #9ca3af)

View File

@@ -0,0 +1,29 @@
## 1. Export STATUS_STYLES
- [x] 1.1 Export `STATUS_STYLES` from `SpecializationRanking.tsx` so it can be imported by the banner component
## 2. Create MobileStatusBanner component
- [x] 2.1 Create `app/src/components/MobileStatusBanner.tsx` with props: `statuses: Record<string, SpecStatus>`, `visible: boolean`, `onTap: () => void`
- [x] 2.2 Implement status count logic — loop over the statuses record to count each category (achieved, achievable, missing_required, unreachable)
- [x] 2.3 Render four color-coded badges using `STATUS_STYLES` colors, each showing `"{count} {label}"` (display all categories including zero counts)
- [x] 2.4 Style the banner: `position: fixed`, `top: 0`, `left: 0`, `width: 100%`, `z-index: 1000`, white background with bottom border, compact height (~40px)
- [x] 2.5 Implement slide animation: always mounted, use `transform: translateY(-100%)` when hidden vs `translateY(0)` when visible, `transition: transform 200ms ease-out`
- [x] 2.6 Attach `onClick` handler to the banner root element that calls `onTap`
## 3. Integrate into App.tsx
- [x] 3.1 Add a `ref` to the specializations wrapper `<div>` in `App.tsx` (the div containing `CreditLegend` and `SpecializationRanking`)
- [x] 3.2 Add IntersectionObserver logic: observe the specializations ref, set a `bannerVisible` state to `true` when the section is not intersecting, `false` when it is
- [x] 3.3 Gate the observer and banner on `isMobile` — only create the observer and render the banner when on mobile breakpoint
- [x] 3.4 Mount `<MobileStatusBanner>` at the top of the App return (before `<h1>`), passing `statuses={optimizationResult.statuses}`, `visible={bannerVisible && isMobile}`, and `onTap` that calls `specSectionRef.current.scrollIntoView({ behavior: 'smooth' })`
- [x] 3.5 Clean up the IntersectionObserver in the useEffect return (disconnect on unmount or when isMobile changes)
## 4. Test
- [x] 4.1 Verify banner appears on mobile viewport when scrolling past specializations
- [x] 4.2 Verify banner hides when scrolling back up to specializations
- [x] 4.3 Verify banner does not render on tablet/desktop viewports
- [x] 4.4 Verify tapping the banner scrolls back to the specializations section
- [x] 4.5 Verify status counts update reactively when course selections change while banner is visible
- [x] 4.6 Verify badge colors match the specialization list status badges