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).
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.