# ADR-084: List Pagination and Infinite Scroll — When to Use Each, PostgREST Range Header Pattern

## Status

Accepted, 2026-06-27.

## Status History

```yaml
status_history:
  - date: 2026-06-27
    status: Proposed
    changed_by: hkl
    reason: Establishing standard for pagination across data-heavy PWAs (Finance, Counters, Sales, HR, Activity, Recce)
    changed_via: adr-kit (360lm)
  - date: 2026-06-27
    status: Accepted
    changed_by: hkl
    reason: Pattern applies to unbounded datasets; integrates with ADR-003 (mobile scroll), ADR-014 (PostgREST), ADR-061 (bounded search)
    changed_via: adr-kit (360lm)
```

## Context

360lm includes multiple data-heavy PWAs with large datasets:
- Finance ledgers: thousands of transactions per employee
- Counters: 200+ stores across brands
- Sales catalog: hundreds of SKUs
- HR employee list: 50–100 employees
- Activity log: cumulative user actions across the platform
- Recce submissions: growing archive of field inspections

Early implementations used either no pagination (loading all rows on open) or ad-hoc pagination logic without a consistent pattern. Issues:
- **No pagination:** Performance degrades with datasets > 500 rows; loads unnecessary data on screen open.
- **Ad-hoc pagination:** Inconsistent between PWAs; no standard for when to use infinite scroll vs numbered pages; unclear how to integrate PostgREST's range header pattern.

**Current state:**
- PostgREST supports range-based pagination via `Range: items=0-49` request header.
- Response header `Content-Range: 0-49/1234` provides total count and window bounds.
- ADR-003 (mobile scroll root document) constrains how lists must scroll.
- ADR-061 (dual-mode search) covers bounded datasets; this ADR covers unbounded/large datasets.

**No current standard for:**
- Page size defaults
- When to use infinite scroll vs numbered pagination
- How to parse and display PostgREST range headers
- Loading state UI patterns
- Empty list feedback

## Decision

### Decision Table: Dataset Size → Approach

| Dataset Size | Strategy | Example PWAs | Pagination Type |
|---|---|---|---|
| ≤ 500 rows, fully enumerable | Client-side filter/sort only (ADR-061) | Employees, Activities | None — all loaded on screen open |
| 500–5000 rows, read-heavy | Infinite scroll with PostgREST range | Activity log, Recce submissions | Range-based, 50 rows per page |
| 5000+ rows, query-intensive | Infinite scroll + server-side filter | Finance ledgers (per employee) | Range-based + filter params, 50 rows per page |
| Any dataset, grid layout (position matters) | Numbered pagination | Payroll runs, reports | Range-based with page buttons |

### Default Page Size

**Page size = 50 rows** (default for all PostgREST range-paginated endpoints).

Rationale:
- ~50 rows ≈ 10–15 KB JSON (typical row), acceptable for mobile 4G (< 1s fetch).
- Scrolling 50 rows at 60 FPS on mobile is smooth with sticky headers.
- Balances latency (small requests) vs request count (few round-trips).
- // ponytail: ceiling=50 rows per page, upgrade trigger=mobile device JS profiler shows > 16ms frame time during scroll

### Infinite Scroll Pattern (Preferred for Mobile Feed-Style Lists)

**When to use:** Activity log, transaction history, Recce submission timeline, Asset audit log — any ordered list where position is ephemeral (user browses, doesn't return to "page 2").

**Request:**
```http
Range: items=0-49
Prefer: count=exact
```

**Response:**
```http
Content-Range: 0-49/1234
[row, row, ..., row]
```

**Client-side logic:**
1. On screen open: fetch `Range: items=0-49`.
2. Render rows into a `<ul>` or `<div class="list">`.
3. Insert a **loading sentinel** `<div class="loading-sentinel" style="display:none">Loading…</div>` at list bottom.
4. Attach an `IntersectionObserver` to the sentinel:
   - When sentinel enters viewport (user scrolls near bottom): fetch next page `Range: items=50-99`.
   - Parse `Content-Range` response header; extract total count and current window end.
   - Append new rows to the list; move sentinel to new bottom.
   - Stop when `window_end >= total_count` (all rows loaded).
5. **Scroll root document** (ADR-003) — do NOT use `overflow: auto` on the list container.

**Code skeleton:**
```javascript
const PAGE_SIZE = 50;
let offset = 0;
const list = document.querySelector('[role="list"]');
const sentinel = document.querySelector('.loading-sentinel');

const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    fetchNextPage();
  }
});
observer.observe(sentinel);

async function fetchNextPage() {
  sentinel.style.display = 'block';
  const end = offset + PAGE_SIZE - 1;
  const res = await fetch('/db/ledger_entries', {
    headers: {
      'Range': `items=${offset}-${end}`,
      'Prefer': 'count=exact'
    }
  });
  
  const contentRange = res.headers.get('Content-Range');
  // contentRange = "50-99/1234"
  const [window, total] = contentRange.split('/');
  const [start, end] = window.split('-').map(Number);
  
  const rows = await res.json();
  rows.forEach(row => list.appendChild(renderRow(row)));
  
  offset += rows.length;
  sentinel.style.display = 'block';
  
  if (offset >= parseInt(total)) {
    observer.unobserve(sentinel);
    sentinel.remove();
  }
}
```

### Numbered Pagination (For Data Grids, Reports)

**When to use:** Ledger grid view, payroll run summary, vendor rate card comparison, sales invoice list — any grid where the user navigates by explicit page number, or needs to print/export "page 2 of 5."

**UI pattern:**
- Display "Page N of M" (e.g., "Page 1 of 25").
- Prev/Next buttons (disabled on first/last page).
- Optional: direct page input field.
- Alternative: omit total count, show only "Prev / Next" with next button disabled when no more rows exist.

**Request:**
```http
Range: items=50-99
Prefer: count=exact
```

**Client-side logic:**
1. On page load: calculate page number from URL param (e.g., `?page=2`).
2. Fetch `Range: items=(page*50)-((page+1)*50-1)`.
3. Parse `Content-Range: 50-99/1234` to extract `total`.
4. Display "Page 2 of 25" (ceil(1234 / 50) = 25 pages).
5. Disable "Next" button if current window end >= total.
6. Each page button click updates URL and re-fetches.

### Loading State

**Always show feedback while fetching:**

```html
<!-- Shown while fetching -->
<div class="loading-sentinel">
  <span class="spinner"></span>
  Loading…
</div>
```

**CSS:**
```css
.loading-sentinel {
  padding: 1rem;
  text-align: center;
  color: #666;
}

.spinner {
  display: inline-block;
  width: 1rem;
  height: 1rem;
  border: 2px solid #ccc;
  border-top-color: #333;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
```

### Empty State

**Always show a message when the list is empty:**

```html
<div class="empty-state">
  <p>No records found.</p>
  <p><small>Try adjusting your filters.</small></p>
</div>
```

**Never leave an empty list with no feedback.** If a filter narrows results to zero, the user must understand why.

### Error Handling

**If a page fetch fails:**
1. Show an error message near the sentinel: `"Failed to load more. Tap to retry."`
2. Keep the sentinel visible and focussed.
3. Retry fetch on click.

```javascript
async function fetchNextPage() {
  try {
    // ... fetch and render ...
  } catch (err) {
    sentinel.innerHTML = `Failed to load. <button onclick="fetchNextPage()">Retry</button>`;
  }
}
```

## Implementation Notes

### PostgREST Range Header Integration

**Request headers:**
- `Range: items=<start>-<end>` — zero-indexed, inclusive both ends.
  - Example: `Range: items=0-49` fetches rows 0 through 49 (50 rows total).
- `Prefer: count=exact` — ask PostgREST to compute the total row count and include it in `Content-Range` response header.

**Response headers:**
- `Content-Range: <start>-<end>/<total>` — e.g., `Content-Range: 0-49/1234`.
- Parse in JavaScript: `const [window, total] = res.headers.get('Content-Range').split('/');`

**Caveats:**
- If `start >= total`, PostgREST returns HTTP 416 (Range Not Satisfiable). Handle gracefully: treat as "end of list."
- `Prefer: count=exact` is expensive on large tables (full table scan). Acceptable for counts ≤ 100k; if a PWA exceeds that, consider `Prefer: count=planned` (estimate) or omit count display.
- Range filtering respects PostgREST filters (`?column=eq.value`). Example: `Range: items=0-49&employee_id=eq.123` returns first 50 transactions for employee 123.

### No Cursor-Based Pagination

**Decision:** Do NOT implement cursor-based pagination (encode/decode opaque cursors).

**Rationale:**
- PostgREST range headers are simpler than cursor hand-off.
- Current dataset scales do not justify cursor complexity.
- If a PWA experiences dataset churn during pagination (new rows inserted in sort order), range-based pagination may skip or duplicate a row — acceptable trade-off at current scale; add a "Results may have changed" banner if needed.
- Cursor complexity redeems itself only at 100k+ row datasets with high insertion churn. // ponytail: ceiling=100k rows, upgrade trigger=user-visible pagination anomalies

### Interaction with ADR-003 (Mobile Scroll Root Document)

**Constraint:** Lists MUST scroll the root document (`window.scrollY`), not a nested `overflow: auto` container.

**Implementation:** Place the list as a direct child of `<body>` (or a full-height `<main>`), and set `position: sticky` headers relative to document root:

```html
<body>
  <header style="position: sticky; top: 0;">...</header>
  <ul id="list" role="list">
    <!-- rows append here -->
  </ul>
  <div class="loading-sentinel">Loading…</div>
  <footer style="position: sticky; bottom: 0;">...</footer>
</body>
```

Do NOT wrap the list in a `<div style="overflow: auto; height: 100vh;">` container.

### Interaction with ADR-061 (Dual-Mode Search)

**ADR-061 covers bounded datasets (≤ 500 rows).** This ADR covers unbounded datasets.

**Clarification:**
- If a PWA's "Search" tab loads results via a `/search` endpoint with a filter, use infinite scroll (this ADR) if results can exceed 500 rows.
- If a PWA's "Dashboard" tab loads the full enumerable dataset on open (≤ 500 rows), client-side `Array.filter()` suffices; no pagination needed (ADR-061).

**Example:** Tour Planner counter search (ADR-061 compliant) loads ≤ 100 counters in dashboard mode; if a counter site in the future contains 10,000 transaction line items, that ledger view uses infinite scroll (this ADR).

## Alternatives Considered

### Cursor-Based Pagination

Encode/decode opaque cursors (e.g., `cursor=eyJpZCI6IDEyM30=`) and hand off to client; next page requests use the cursor instead of numeric offset.

**Rejected:**
- More complex client-side logic (decode base64, manage cursor state).
- PostgREST range headers are simpler and sufficient at current scale (< 100k rows).
- Cursor complexity redeems itself only with high-churn datasets where new rows inserted in sort order would cause skip/duplicate anomalies with range-based pagination. Not a concern at current scale.

### Virtual Scrolling (Windowed List)

Render only visible rows; as user scrolls, unmount/remount rows; maintain a "height filler" div above and below the visible window.

**Rejected:**
- Over-engineered for page sizes ≤ 50 rows.
- Requires a library (Intersection Observer-based list windower) or custom implementation.
- ADR-013 (single HTML file, no framework) constrains external dependencies.
- Native DOM reflow at 50 rows is fast enough on modern mobile devices (< 16ms per frame).
- Upgrade trigger: if Playwright or real-device profiling shows > 16ms jank during scroll with 200+ visible rows, revisit.

### Server-Side Filter-Before-Pagination

Require the client to specify filter parameters in the range request, so the server returns pre-filtered pages.

**Not rejected, but paired with client-side filter:**
- PostgREST + PostgreSQL natively support filter params: `Range: items=0-49&status=eq.pending&created_at=gt.2026-01-01`.
- Use when filter reduces result set significantly (e.g., "transactions for employee 123" from 100k global to 500 per employee).
- Pair with client-side chip/dropdown filters (ADR-061 pattern) so user can narrow interactively without re-fetching.

### Show All (Single-Page Load)

Load all rows on screen open; paginate only if > 500 rows exist.

**Rejected:**
- Wastes data on common cases: user may only need first 10 results (search by name, find one ledger).
- Defeats the purpose of pagination (reduce initial payload).
- IntersectionObserver + infinite scroll is superior UX: user never sees a "load more" button, just scrolls naturally.

## Consequences

### Positive

- **Consistency:** All PWAs with large datasets use the same pattern (range headers, infinite scroll, numbered pagination trade-off).
- **Performance:** Only fetch data the user is likely to see (first 50 rows); subsequent pages lazy-load.
- **Mobile-friendly:** Infinite scroll feels native on mobile; paired with root-document scroll (ADR-003) is smooth.
- **PostgREST integration:** Range headers are a standard HTTP feature PostgREST exposes; no custom endpoint logic needed.
- **Responsive layout:** Loading sentinel auto-shows/hides; empty state is always visible.

### Negative / Trade-Offs

- **Complexity:** Client must manage offset state, parse Content-Range headers, and handle 416 responses. Mitigated by providing a reusable code skeleton in this ADR.
- **Stale Data:** Rows inserted into the result set during pagination may be skipped (if new row sorts before current offset) or duplicated (if inserted after offset, then next fetch overlaps). Acceptable at current scale; add "Results may have changed" banner if user browses for > 5 minutes.
- **No direct access to "page 2":** Numbered pagination supports bookmarking (e.g., `?page=2`); infinite scroll does not. Mitigation: use numbered pagination for any list where position must be linkable (e.g., payroll runs, sales invoices).
- **Content-Range header computation cost:** `Prefer: count=exact` triggers a full table scan on large tables. For tables > 100k rows, consider `Prefer: count=planned` (PostgreSQL estimate, 0 cost) or omit count display and show "Prev / Next" buttons only.

### Risks and Mitigations

| Risk | Mitigation |
|---|---|
| **Pagination race condition:** New rows inserted in sort order between page fetches cause skip/duplicate | Show a "Results may have changed during your browse" banner if user spends > 5 minutes on the list. Acceptable at < 5k/hour insertion rate. |
| **User confusion on empty list** | Always show an explicit "No records found" message with optional filter hints. Never show an empty `<ul>`. |
| **Loading sentinel not visible on slow networks** | Ensure `.loading-sentinel` is always at least 4rem tall (visible on a mobile viewport) and styled with high contrast. Test on real 3G/4G connection. |
| **Infinite scroll exhaustion (user can't find record)** | Pair with a "Search" mode (ADR-061) that requires a filter. Infinite scroll best for "browse recent" workflows; targeted search for "find by criteria." |
| **Browser back button loses scroll position** | Use `History.replaceState()` with offset state: `history.replaceState({ offset: 0 }, '', '?offset=0')`. On navigation back, restore scroll. Non-critical; acceptable if user re-scrolls. |

## Related Decisions

- **ADR-003** (Mobile Scroll Root Document) — pagination list MUST scroll the root document, not a nested overflow container.
- **ADR-014** (PostgREST as API Layer) — range headers are a PostgREST feature; this ADR standardizes how PWAs invoke them.
- **ADR-061** (Dual-Mode Search) — covers bounded datasets (≤ 500 rows, client-side filter); this ADR covers unbounded datasets requiring pagination.
- **ADR-020** (Offline-First IndexedDB Primary) — if a PWA caches paginated results offline, consider storing pages as separate IndexedDB object store entries keyed by offset; sync new pages on next online.

## References

- PostgREST Range Header Docs: https://postgrest.org/en/stable/api/reading.html#pagination
- HTTP Range Requests (RFC 7233): https://tools.ietf.org/html/rfc7233
- IntersectionObserver API: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- **Current implementations to audit:** Finance ledger views, Activity log, Recce submission timeline — verify they follow this pattern post-acceptance.
