Riddim main EPAC

EPAC-1940 Calendar scrolls to Jan 2001 on Parliament tab re-tap; add prominent Today action and de-clutter toolbar

Context / background

On the Parliament tab's sitting calendar (SittingCalendarView), tapping the Parliament tab item while it is already the active tab scrolls the calendar all the way up to January 2001 - the start of the configured visible date range. There is no parliamentary data before 2016 (the year picker's lower bound is hard-coded to 2016 at SittingCalendarView.swift:204), so the user lands in an empty pre-history with no obvious way back.

There is a hidden "tap to scroll to today" affordance on the legend dot at SittingCalendarView.swift:127-133, but it reads as a legend swatch, not as a control. The accessibility label "Today - tap to scroll to current month" confirms it is meant as a button, but visually it is indistinguishable from the "Upcoming" legend swatch next to it.

Adjacent concern flagged by user: the Parliament toolbar and the Members toolbar are getting crowded - this issue should also consolidate.

Root cause analysis

Confirmed root cause: standard iOS UIScrollView.scrollsToTop behavior, applied to a HorizonCalendar CalendarScrollView whose content range begins at 2001-01-01.

Evidence:

  1. HorizonCalendar's scroll view is a UIScrollView with default settings. CalendarScrollView: UIScrollView at ios/.build/SourcePackages/checkouts/HorizonCalendar/Sources/Internal/CalendarScrollView.swift:24. It does not override scrollsToTop, so it inherits the default true.
  2. iOS tab re-tap triggers scrollsToTop. When the user taps a TabView's currently-active tab item, UIKit walks the active tab's view hierarchy and asks the first eligible scroll view with scrollsToTop == true to scroll its contentOffset to .zero. This is the same mechanism as the legacy status-bar tap.
  3. contentOffset.zero for this calendar is January 2001. visibleDates is configured from 2001-01-01 through 2026-12-31 at SittingCalendarView.swift:30 with monthsLayout: .vertical. So "top" of the scroll view is January 2001 - the earliest month in the range.
  4. Year picker bound differs from calendar bound. The chevron year picker is clamped to 2016+ (SittingCalendarView.swift:194-204), so even when the user lands in 2001 the picker cannot take them back through chevrons. The only existing escape is the unobvious "Today" legend dot.
  5. Tab re-tap intent is correct; the calendar's response is wrong. Double-tapping a tab bar item to scroll to top is standard iOS behavior we should not fight system-wide - but for this scroll view it produces a non-actionable state. We do not gain anything from preserving "scroll to start of 25-year range," and we lose discoverability.

Rejected candidates (with reasons):

  • NavigationStack popping to root - the calendar is the root of parliamentStack (ContentView.swift:464-481); there is no parent to pop to. The popping happens, and is correct, on subsequent taps, but it is not what scrolls the calendar to 2001.
  • task(id: viewModel.currentYear) re-firing - the initial-load task at SittingCalendarView.swift:153-160 does scroll to today, but only if viewModel.dates.isEmpty. On subsequent tab visits the dates are cached, so the task body is a no-op. Not the cause.
  • HorizonCalendar's own scroll restoration - CalendarViewProxy does not register an automatic scroll-to-top hook with the tab bar. The behavior comes from UIKit's traversal, not HorizonCalendar.

Confidence: high. The mechanism is well-documented UIKit behavior, the source file confirms CalendarScrollView does not opt out, and the date-range arithmetic explains why "top" is January 2001 specifically.

Acceptance criteria

  • [ ] Given the user is on the Parliament tab with the calendar visible at any month, when they tap the Parliament tab bar item (re-selecting the active tab), then the calendar does not scroll to January 2001.
  • [ ] Given the user is anywhere in the Parliament calendar's date range, when they tap a prominent "Today" affordance, then the calendar scrolls/animates to the month containing today and centers today's date if possible.
  • [ ] The "Today" affordance must be visible from the calendar's default state without requiring the user to scroll, open a menu, or recognize the legend dot as a button. Pick one placement and remove the legend-dot tap target so there is only one canonical Today action: prefer a toolbar item (label "Today", system image calendar.circle or similar), OR a sticky bottom-bar pill above the safe-area inset. Author decides; both meet AC.
  • [ ] The "Today" control is hidden or disabled when today's date is already inside the currently visible month range (avoid noise when there is nothing to do).
  • [ ] Parliament toolbar is de-cluttered. Today's top-bar contains: year-picker (principal), refresh, calendar-export menu, Order Paper link, Committees link, Ontario Debates link - 6 distinct items plus a 3-item principal cluster. Consolidate by either (a) grouping Order Paper / Committees / Ontario Debates into a single Menu (ellipsis or line.3.horizontal) with one toolbar slot, or (b) moving Committees + Ontario Debates into the Accountability / Members hubs they thematically belong to (see ADR-001 in CLAUDE.md). Author decides; document the choice in the PR description.
  • [ ] Members toolbar (MembersView.swift:45-118) is also de-cluttered. Five sequential filter menus + a clear button in one ToolbarItemGroup is too dense. Consolidate by either (a) combining Party, Province, Status, Cabinet into a single "Filters" menu that expands into pickers, with the active-filter count rendered as a badge on the filter icon, or (b) moving filters into a sheet/bottom-bar surfaced by one toolbar button. Author decides.
  • [ ] The fix does not change scroll-to-top behavior on tabs whose primary content is conventionally oriented (Home, Members list, Accountability, Search). Specifically: do not blanket-disable scrollsToTop across the app; scope the change to the calendar scroll view only.
  • [ ] On tab re-tap of Parliament, if the calendar is not currently showing today, the system can optionally scroll to today (a reasonable user expectation given "tab re-tap = go to a sensible home"). If implemented, document the choice in the PR description; if not, do nothing and rely on the explicit Today button. Either is acceptable.
  • [ ] Existing snapshot / unit tests in SittingCalendarViewModelTests continue to pass; new test added: SittingCalendarView does not opt the underlying scroll view into scrollsToTop.
  • [ ] Manual verification steps below pass on iPhone simulator (iOS 17 baseline, iOS 18+ acceptable) and on iPad.

Implementation approach (non-prescriptive)

Two viable mechanisms - pick the one with fewer side effects after spike-checking both:

Option A - opt the calendar scroll view out of scrollsToTop. HorizonCalendar does not currently expose a public modifier to set scrollsToTop. Either:

  • Submit a small upstream PR to airbnb/HorizonCalendar adding .scrollsToTop(_:) on CalendarViewRepresentable (preferred long-term), or
  • Walk the view hierarchy from a UIViewRepresentable shim inserted alongside the calendar to find the CalendarScrollView and set scrollsToTop = false. This is fragile but localized - keep it in one helper file with a comment pointing at this issue.

Option B - intercept the tab re-tap. Add a UITabBarControllerDelegate (via UITabBarController.appearance() is not enough; need a real introspection layer) that, when the user re-selects the Parliament tab, runs calendarViewProxy.scrollToMonth(containing: .now, ...) instead of letting the system scroll to .zero. iOS 18 ships a tabViewSelection API that makes this cleaner.

Recommended: Option A, scoped to the calendar view only. It is the smaller change and does not reach across the app. If Option A is infeasible without an upstream change, Option B is the fallback.

External validation gates

  • [ ] None - this is a pure iOS UI fix verifiable from repo changes, simulator, and a real device. No App Store Connect, legal, or product sign-off needed.

Out of scope

  • Changing the calendar's date range lower bound (the data backstop is 2016, but HorizonCalendar's range stays 2001+ as a buffer). Not touching this here.
  • Removing the legend "Sitting days / Today / Upcoming" row entirely. The colors-legend stays; only its dual-purpose tap behavior is removed once the explicit Today control replaces it.
  • Reworking the iPad sidebar / parliamentStack navigation flow.
  • Backend or data pipeline work.
  • Any change to HomeFeedView, AccountabilityHubView, or SearchView.

Inputs / dependencies

  • iOS source: ios/epac/Views/Calendar/SittingCalendarView.swift, ios/epac/Views/ContentView.swift, ios/epac/Views/Members/MembersView.swift.
  • HorizonCalendar SDK source (read-only): ios/.build/SourcePackages/checkouts/HorizonCalendar/Sources/. Pinned version comes from ios/epac.xcodeproj SPM resolution.
  • Existing CalendarViewProxy API: scrollToMonth(containing:scrollPosition:animated:) already used at SittingCalendarView.swift:131 and :157.
  • Tab structure: ContentView.phoneLayout and iPadLayout in ios/epac/Views/ContentView.swift.

Risks / notes for implementer

  • Do not blanket-disable scrollsToTop app-wide. Status-bar / tab re-tap scroll-to-top is correct behavior on every other scroll view in the app. Scope strictly to the calendar.
  • CalendarScrollView is internal to HorizonCalendar. If walking the view hierarchy via UIViewRepresentable introspection, do it in a single helper file and add a // swiftlint:disable:next ... - reason comment if needed. Add a test that fails loudly if HorizonCalendar's internal class name changes on a future package update.
  • iPad sidebar selection is not tab re-tap. iPad uses NavigationSplitView with router.selectedTab bindings (ContentView.swift:195-228). The scroll-to-top bug only manifests on phone (compact). Verify iPad is not accidentally regressed.
  • Mac Catalyst. The MacCommandCenter already calls viewModel.onSelectedDateChanged on refresh (ContentView.swift:256-265). Nothing to change here, but verify on Catalyst.
  • Year picker chevrons independently scroll to the target year's January via calendarViewProxy.scrollToMonth(containing: jan, ...) (SittingCalendarView.swift:196-198). Do not break this when restructuring the toolbar.

Definition of Done

  • Re-tapping the Parliament tab item no longer scrolls the calendar to January 2001 on iPhone and iPad simulators.
  • A single, visually obvious "Today" affordance returns the calendar to today's month from any point in the range.
  • Parliament and Members toolbars are visibly less crowded (count of top-bar items reduced; document the count delta in the PR description).
  • New scroll-behavior test added; existing tests pass; SwiftLint --strict passes; make build and make simulator succeed.
  • PR includes before/after screenshots of:
    • Parliament tab re-tap behavior (recorded as a short GIF or two screenshots).
    • Parliament toolbar before and after.
    • Members toolbar before and after.
  • Release-Note: line added (e.g. Release-Note: Added a Today button to the Parliament calendar and stopped tab re-tap from scrolling to 2001).

Architecture Impact

  • Affected layer: presentation only (Views/Calendar, Views/Members, possibly a tiny UIKit-bridge helper).
  • Dependency direction unchanged: View -> CalendarViewProxy (already in place).
  • No new ports, no schema/migration impact, no backend change.
  • Test strategy: ViewModel layer unchanged (no new ViewModel tests). Add one View-layer integration check that asserts the calendar's scroll view has scrollsToTop == false after first appearance.

Mergeability / change ownership

  • Single reason to change: fix calendar tab-re-tap scroll + reduce Parliament/Members toolbar density.
  • Primary code owner: ios/epac/Views/Calendar/SittingCalendarView.swift (and ViewModels owner per CODEOWNERS).
  • Hot files: SittingCalendarView.swift, MembersView.swift, possibly a new Util/CalendarScrollBehavior.swift helper.
  • Sibling issues likely to touch the same area: none currently. Confirm at pickup by scanning open EPAC PRs touching Views/Calendar/ or Views/Members/.
  • Conflict risk: low - narrow surface, no model or migration changes.
  • Sequencing lane: parallel.

Clean Architecture Shape

N/A - UI affordance + scroll-view configuration change. No new use case, no port/adapter boundary, no domain entity touched. Catalog update: not required.

Manual reproduction steps

  1. cd ios && SIM_UUID=<booted-sim-uuid> make simulator (or run from Xcode against an iPhone simulator).
  2. App opens at Home.
  3. Tap Parliament tab. Calendar should show current month.
  4. Tap the Parliament tab item again.
  5. Observe: calendar instantly jumps to January 2001 with no green sitting cells, no obvious affordance to return. (Year picker chevron-left is disabled at 2016; chevron-right works but takes many taps.)
  6. The only escape before this fix is the tiny red "Today" legend dot near the bottom - tap it to return.

Expected after fix: re-tapping Parliament does not move the calendar (or, if implementing the optional scroll-to-today on re-tap, scrolls to today's month). A prominent toolbar or bottom-bar "Today" action returns the user to today from anywhere.

Resolves the user-reported bug filed against the Parliament calendar screen on 2026-05-18.