Files
Bill Ballou 7940050196 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).
2026-02-28 22:27:07 -05:00

3.6 KiB

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.