From 8c891c6507c27994e12ac91087aae38819ae6cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:57:26 +0000 Subject: [PATCH] Fix stale Plan tab on plan switch; replace segmented toggle with title picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix: switching the active plan while the Plan tab stayed mounted showed stale content — the previously active plan's day/chapter breakdown kept rendering. Root cause: ChapterGroupList's over group names snapshotted each group's chapters into a plain `const` before returning JSX. Day names ("Day 1", "Day 2", ...) are identical across every plan, so (keyed by value) never re-invoked that callback on a plan switch, freezing the snapshot at whichever plan was active on first render. Fix: do the props.data[groupName] lookup inline in the JSX so it stays a reactive getter instead of a frozen snapshot. (The underlying activePlanId/perDayTagData signal chain was already correct — confirmed via instrumentation — only the rendered output was stale.) UX: replaced the segmented-control plan toggle with PlanPicker, an iOS "tap the title to switch context" menu (the pattern Mail and Reminders use for their inbox/list pickers). The segmented control didn't scale past a couple of plans; this does, and only appears when there's more than one plan to switch between. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K2wCTwwVhhkSDxQxWo7UoH --- docs/components.md | 20 +++--- web/src/components/AppRouter.tsx | 8 +-- web/src/components/ChapterGroupList.tsx | 25 ++++--- web/src/components/Layout.tsx | 11 +++- web/src/components/PlanPicker.module.css | 83 ++++++++++++++++++++++++ web/src/components/PlanPicker.tsx | 64 ++++++++++++++++++ web/src/components/PlanToggle.module.css | 42 ------------ web/src/components/PlanToggle.tsx | 33 ---------- 8 files changed, 178 insertions(+), 108 deletions(-) create mode 100644 web/src/components/PlanPicker.module.css create mode 100644 web/src/components/PlanPicker.tsx delete mode 100644 web/src/components/PlanToggle.module.css delete mode 100644 web/src/components/PlanToggle.tsx diff --git a/docs/components.md b/docs/components.md index 1d15f40..0784b3f 100644 --- a/docs/components.md +++ b/docs/components.md @@ -9,6 +9,7 @@ App └── AppRouter └── Layout (root for all routes) ├── header + │ ├── title → PlanPicker on /plan when >1 plan exists, else plain text │ ├── SearchInput │ ├── show-completed checkbox │ └── menu icon → PlanSettings (sidebar) @@ -17,8 +18,7 @@ App │ ├── / → ChapterGroupList (Books view) │ │ └── ChapterGroup (one per book) │ │ └── Chapter (one per chapter) - │ ├── /plan → PlanToggle (segmented control, hidden if only one plan) - │ │ + ChapterGroupList (Plan view) + │ ├── /plan → ChapterGroupList (Plan view) │ │ └── ChapterGroup (one per day) │ │ └── Chapter │ └── /history → HistoryList @@ -38,21 +38,19 @@ Configures routes. Computes two top-level data structures passed as props: - `bookGroups: Record` — computed once via `groupByBook` - `planGroups: Accessor>` — `createMemo` around `groupByDay`; reactive to `api.perDayTagData()` -The `/plan` route renders `PlanToggle` above `ChapterGroupList`. - -### `PlanToggle` (`PlanToggle.tsx`) - -iOS-style segmented control for switching `api.activePlanId()` — one segment per `api.plans()` entry, tapping calls `api.setActivePlanId`. Renders nothing (` 1}>`) when there's only one plan, since a toggle with a single, permanently-selected option is pointless. Scrolls away with the page content rather than staying pinned — `ChapterGroup`'s own day headers are already `position: sticky; top: 0` inside the same scroll container (`Layout`'s `
`), and that component is shared across the Books/Plan/History routes, so pinning the toggle at the same `top: 0` would fight with — and visually sit on top of — the day headers once they reach the top of the scroll area. - ### `Layout` (`Layout.tsx`) Shell shared by all routes. Contains: -- Page title (derived from current path) +- Page title (derived from current path) — on `/plan`, replaced with `PlanPicker` once there's more than one plan to switch between - Show Completed checkbox (toggles `api.showCompleted`) - Search input (writes to `api.setSearchText`) - Sidebar toggle (shows/hides `PlanSettings`) -- Tab bar navigation +- Tab bar navigation (Plan, Books, History, Settings) + +### `PlanPicker` (`PlanPicker.tsx`) + +iOS-style "tap the title to switch context" menu, the same pattern Mail uses for its inbox picker and Reminders for its list picker — chosen over a segmented control specifically because a segmented control runs out of horizontal room once there are more than a couple of plans. `Layout` only renders it (in place of the plain `

` title text) when `api.plans().length > 1`; the trigger button shows `api.activePlan().name` plus a chevron and, when tapped, opens an absolutely-positioned menu (closed on outside `pointerdown` or on selecting an item) listing every `api.plans()` entry with a checkmark on the active one. Selecting an entry calls `api.setActivePlanId`. ### `ChapterGroupList` (`ChapterGroupList.tsx`) @@ -62,6 +60,8 @@ Key behaviors: - Filters groups by `api.searchText()` — only groups containing a matching chapter name are shown - When `sortProgressToTop` is true (Books view), groups with any completed chapters are sorted above those with none +The `` over group names looks up each group's chapters (`props.data[groupName]`) *inline* in the `ChapterGroup` `data` prop rather than pre-computing it into a local `const`. This matters specifically on the Plan view: the group names are always `"Day 1"`, `"Day 2"`, ... regardless of which plan is active, so `` (keyed by value) never re-invokes its callback when you switch plans — a plain `const chapters = props.data[groupName]` computed once inside that callback would freeze at whichever plan was active on first render. Keeping the lookup inline lets it compile to a getter that's re-read reactively instead. + ### `ChapterGroup` (`ChapterGroup.tsx`) Accordion row representing one book (Books view) or one day (Plan view). diff --git a/web/src/components/AppRouter.tsx b/web/src/components/AppRouter.tsx index ce3ac9e..a1628f4 100644 --- a/web/src/components/AppRouter.tsx +++ b/web/src/components/AppRouter.tsx @@ -3,7 +3,6 @@ import { Layout } from './Layout' import { ChapterGroupList } from './ChapterGroupList' import { HistoryList } from './HistoryList' import { PlanSettings } from './PlanSettings' -import { PlanToggle } from './PlanToggle' import { getBookNamesMap, getChapterData } from '../utils/dataUtils' import { groupByBook, groupByDay } from '../utils/groupUtils' import { useApi } from './ApiContext' @@ -29,12 +28,7 @@ export function AppRouter() { /> ( - <> - - - - )} + component={() => } />
    - {(groupName) => { - const chapters = props.data[groupName] - return ( -
  • - -
  • - ) - }} + {(groupName) => ( +
  • + +
  • + )}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index c46c83c..db8ad72 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { useApi } from './ApiContext' import styles from './Layout.module.css' import { A, type RouteSectionProps } from '@solidjs/router' import { Icon } from './Icon' +import { PlanPicker } from './PlanPicker' const isIosSafari = /iP(hone|ad|od)/.test(navigator.userAgent) && @@ -20,9 +21,11 @@ export function Layout(props: RouteSectionProps) { ) const isSettingsRoute = () => props.location.pathname.endsWith('/settings') + const isPlanRoute = () => props.location.pathname.endsWith('/plan') + const showPlanPicker = () => isPlanRoute() && api.plans().length > 1 const title = () => - props.location.pathname.endsWith('/plan') + isPlanRoute() ? 'Plan' : props.location.pathname.endsWith('/history') ? 'History' @@ -48,7 +51,11 @@ export function Layout(props: RouteSectionProps) {
-

{title()}

+

+ + + +