Skip to content

Diary: replace the broken numbered pager with scroll-driven batch loading #2060

Description

@steilerDev

As a homeowner browsing my construction diary, I want older entries to load automatically as I scroll so that I can move through my history in one continuous timeline instead of operating a numbered pager that does not work.

Parent Epic: none (EPIC-13 Construction Diary is closed; filed standalone — see the diary cluster note below)
Priority: Should Have


Problem

Two user-reported defects on the diary page (/diary), both rooted in the hand-rolled pagination on that page:

  1. The pager does not work. Clicking "Next" / "Previous" has no effect. On mobile nothing visibly happens at all; on desktop the page smooth-scrolls to the top but the list stays on page 1. Editing ?page= in the address bar by hand does work, because that is a full page load.
  2. There is no way to choose how many entries are shown per page. The page size is fixed and not exposed anywhere in the UI.

The user's decision is not to repair the pager but to replace it: "could we rework this, such that we don't have static page numbers, but are dynamically loading items in as the user scrolls."

Root cause (already investigated — do not re-investigate)

All in client/src/pages/DiaryPage/DiaryPage.tsx:

  • handlePageChange (~line 222) writes page into the URL via setSearchParams, then calls window.scrollTo({ top: 0, behavior: 'smooth' }).
  • The debounced-search sync effect (~lines 70–83) lists searchParams in its dependency array. Any searchParams change re-runs it, and once past the first render (isFirstSearchSyncRef.current is false) it unconditionally executes newParams.set('page', '1'); setSearchParams(newParams).
  • Therefore every pager click is immediately reverted to page 1, while the smooth scroll still runs — exactly the reported desktop symptom. On mobile the scroll is a no-op, so "nothing happens".
  • Manually entering a ?page= URL works because it is a full remount: isFirstSearchSyncRef is re-initialised to true, so the reset effect is skipped on that first pass.
  • pageSize is hardcoded to 25 at line 43 — this is why there is no per-page control.

Expected behaviour

The numbered pager is replaced by continuous ("infinite") scroll loading:

  • The diary page loads a first batch of entries and renders them in the existing date-grouped timeline.
  • As the user scrolls toward the bottom of the list, the next batch is fetched and appended below the entries already shown; the reading position does not jump.
  • Loading a batch shows a clear loading affordance at the bottom of the list, not a full-page loading state that blanks the already-loaded entries.
  • When there are no more entries, the list says so and stops issuing requests.
  • Changing any filter (mode, types, date range, drafts toggle) or the search query resets the timeline to a fresh first batch.
  • A failed batch shows an error at the bottom of the list with a retry action; entries already loaded remain on screen.
  • An explicit, keyboard-reachable "Load more" control remains available alongside the automatic loading.

Assumptions (flagged — challenge before implementing if any is wrong)

  1. The numbered pager is removed entirely — the prev/next buttons, the "Page X of Y" indicator, and the ?page= URL parameter all go away.
  2. No page-size selector is added. Problem (2) above is considered resolved by the rework: with entries loading continuously there is nothing for the user to size. Page size becomes an internal batch size, not a user-facing setting.
  3. Filter and search state stays in the URL exactly as it is today (q, dateFrom, dateTo, filterMode, types, status). Only page leaves the URL. Changing a filter resets the list to the first batch.
  4. The total entry count in the header subtitle is retained ("N entries"), and continues to reflect the total matching the current filters — not the number currently loaded.
  5. Per CLAUDE.md's Component Reuse Policy, the scroll-loading mechanism is built as a reusable shared component and/or hook under client/src/components/ or client/src/hooks/ — not as a one-off inside DiaryPage. Other list views must be able to adopt it later. Note: no IntersectionObserver usage exists anywhere in client/src/ today, so this is new shared infrastructure.
  6. No backend change is expected. GET /api/diary-entries already returns pagination.totalPages / totalItems and accepts page + pageSize, so batches can be requested by incrementing the page internally. If the architect finds a stability problem with offset paging over a list that can gain entries while being scrolled, that is a legitimate escalation — flag it rather than silently changing the contract.
  7. The "Load more" button is an accessibility fallback and the retry affordance — not a substitute for auto-loading. Auto-loading on scroll is the primary path; both must work.

Out of scope

  • The DataTable-based list pages (Invoices, Work Items, Household Items, Vendors). They use the shared DataTable with its table-state reducer and are not affected by this defect. Adopting the new scroll mechanism there is a separate future decision.
  • Adding a page-size selector anywhere (see assumption 2).
  • Changing diary filtering, search semantics, entry grouping, or the diary entry cards themselves.
  • Backend pagination contract changes (see assumption 6).

Acceptance Criteria

Initial load

  • 1. Given diary entries exist and no filters are applied, When I open /diary, Then the first batch of entries is rendered in the existing date-grouped timeline and no numbered pager, "Page X of Y" indicator, or prev/next buttons appear anywhere on the page.
  • 2. Given the diary page has loaded, When I inspect the page header, Then the subtitle shows the total number of entries matching the current filters (not the number currently loaded).

Scroll-driven loading

  • 3. Given more entries exist than are currently loaded, When I scroll toward the bottom of the list, Then the next batch is requested automatically and appended below the entries already shown.
  • 4. Given a batch is being appended, When it renders, Then the entries already on screen stay mounted and my scroll position is not reset to the top and does not jump.
  • 5. Given a batch request is in flight, When I look at the bottom of the list, Then a loading affordance is visible there and the already-loaded entries remain visible (no full-page loading state replacing the list).
  • 6. Given a batch request is in flight, When further scroll events fire before it resolves, Then no additional request for the same batch is issued (no concurrent or duplicate requests for one batch).

End of list

  • 7. Given all matching entries have been loaded, When I scroll to the bottom, Then an end-of-list message is shown and no further requests are made, however far I keep scrolling.
  • 8. Given the filters match fewer entries than one batch, When the page loads, Then all entries are shown, the end-of-list state is reached immediately, and no second request is made.
  • 9. Given no entries match the current filters, When the page loads, Then the existing empty state is shown and no end-of-list message or "Load more" control is rendered.

Filter and search reset

  • 10. Given several batches are loaded, When I change any filter (mode, types, date from/to, drafts toggle), Then the timeline resets to a fresh first batch for the new filters, previously loaded entries for the old filters are discarded, and the header total updates.
  • 11. Given several batches are loaded, When I type a new search query and the debounce elapses, Then the timeline resets to a fresh first batch for that query and the URL retains the search/filter parameters as it does today.
  • 12. Given any filter or search change, When the URL updates, Then it contains no page parameter.

Error and retry

  • 13. Given one or more batches are loaded, When a subsequent batch request fails, Then an error message with a retry action is shown at the bottom of the list and every already-loaded entry remains on screen.
  • 14. Given a batch has failed, When I activate retry, Then the same batch is re-requested and, on success, appended in the correct position with no duplicated and no skipped entries.
  • 15. Given a batch has failed, When I keep scrolling without activating retry, Then no automatic re-request loop occurs.

Accessibility

  • 16. Given the diary page is loaded and more entries exist, When I navigate with the keyboard only, Then I can reach a visible "Load more" control and activating it loads the next batch — without needing to scroll with a pointer.
  • 17. Given I use a screen reader, When a new batch is appended, Then a polite live-region announcement states that new entries were loaded and how many.
  • 18. Given I use a screen reader, When the end of the list is reached, Then that fact is announced/exposed rather than being conveyed by visual position alone.
  • 19. Given the "Load more" control and the retry control, When they receive keyboard focus, Then a visible focus indicator is rendered in both light and dark mode.

Legacy URLs

  • 20. Given an old bookmark of the form /diary?page=3 (optionally with other filter params), When I open it, Then the page loads normally from the first batch, the other filter params are still honoured, no error is shown, and the stale page parameter does not affect what is loaded.

Reuse and viewports

  • 21. Given the Component Reuse Policy, When the mechanism is implemented, Then the scroll-loading logic lives in a reusable shared component and/or hook under client/src/components/ or client/src/hooks/, with DiaryPage as its consumer — no scroll-observer logic implemented inline in DiaryPage.tsx.
  • 22. Given the desktop, tablet, and mobile viewports, When I repeat criteria 3, 4, 7, 10, and 16 on each, Then the behaviour holds on all three; in particular, on mobile the interaction that previously "did nothing" now loads further entries.
  • 23. Given the reworked page, When it renders in dark mode, Then the loading, end-of-list, error, and "Load more" states all use design tokens and are legible.

UAT Scenarios

UAT-1 — Continuous scrolling replaces the broken pager
Given I have more than one batch of diary entries
When I open the diary page and scroll down to the bottom repeatedly
Then older entries keep appearing beneath the ones I was reading, my place in the list is never lost, and I never see page numbers or next/previous buttons.

UAT-2 — Mobile, the originally reported symptom
Given I am on a phone-sized viewport with more than one batch of entries
When I scroll to the bottom of the diary list
Then more entries load (previously nothing happened at all).

UAT-3 — Reaching the end
Given I keep scrolling until the oldest entry is shown
When I continue to scroll
Then I see a clear "no more entries" indication and the app stops trying to load anything further.

UAT-4 — Filtering resets the list
Given I have scrolled through several batches
When I switch the filter mode, change the date range, or type a search term
Then the list starts again from the newest matching entry and the entry count in the header matches the new filter.

UAT-5 — Recovering from a failed load
Given I have several batches loaded and the next batch fails (e.g. the connection drops)
When I see the error at the bottom of the list and press retry after reconnecting
Then the entries I had already read are still there and the next batch loads and appends correctly, with no duplicates.

UAT-6 — Keyboard-only use
Given I navigate the diary page using only the keyboard
When I tab to the "Load more" control and press Enter
Then the next batch loads and I can continue reading — I am never forced to scroll with a pointer.

UAT-7 — Old bookmark
Given I have an old bookmark pointing at /diary?page=3
When I open it
Then the diary opens normally at the top of the list with no error.

Notes

  • Filed standalone: the natural parent (EPIC-13, Construction Diary) is closed and there is no open epic covering diary work. This is another entry in the standalone diary cluster — when a diary v2 epic is opened, link this issue to it.
  • This issue replaces a UX mechanism and closes out the underlying defect; it is labelled user-story rather than bug for that reason. It is not "done" until the reported pager symptoms are gone, which criterion 22 pins explicitly.
  • Existing pager i18n keys (pagination.previous, pagination.next, pagination.pageInfo in the diary namespace) become unused on this page — remove them if nothing else consumes them, and note that new keys for the loading, end-of-list, error/retry, and "Load more" states need translating.
  • Existing E2E selectors data-testid="prev-page-button" / data-testid="next-page-button" on the diary page disappear with the pager; any E2E test referencing them must be updated rather than deleted wholesale — the coverage they represented (paging to older entries) moves to the scroll flow.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions