Skip to main content

Pagination & Counting Sweep — 2026-04-25

Why this matters​

The reservation list bug ("only 20 of 97 reservations visible") was caused by:

  1. The dashboard sent { page: 1, limit: 500 } in the POST body, but the reservation-service controller read pagination from the query string (PaginationDto), so the body's limit was silently ignored — backend defaulted to 20.
  2. The dashboard then computed status counts by filtering the (capped) 500-row pull client-side. With > 500 rows the counts would also be wrong.

Both have the same shape: assume all records fit in one fetch, then aggregate client-side. This pattern is everywhere.

The proper fix (already applied to reservations)​

BeforeAfter
useReservations({ limit: 500 }) then .filter(r => r.status === 'CONFIRMED').lengthuseReservationStats() → backend's GROUP BY status query
Inner list pulled 20 with no pagination controlsuseReservations({ page, limit: 20 }) + Prev/Next controls
Body limit: 500 silently capped at 20Backend controller now merges body page/limit into PaginationDto

Sweep results (dashboard)​

7 sites with hardcoded large limit (potential silent truncation)​

PathLimitRisk
housekeeping/keynova.tsx:4701000Status counts on the room board — fails for 500+ room properties
vishramgruh.ts:151500Active hospital authorizations — fails when > 500
housekeeping/inventory/page.tsx:51100Linen inventory — fails when > 100 SKUs
housekeeping/maintenance/page.tsx:119100Maintenance requests — fails when > 100 open
housekeeping/tasks/page.tsx:122100HK tasks — fails when > 100 daily tasks (likely on big properties)
rates/use-rate-calendar-data.ts:38100Rate calendar — fails for long date ranges
properties.ts:78 (getTenants)100Tenant switcher — fails when > 100 tenants (multi-property chains)

70 sites doing client-side .filter(x => x.status === ...)​

These compute status counts or sub-lists from a fetched array. If the array is paginated to N rows, every count past N is wrong.

Highest-risk files (count badges visible to staff):

  • arrivals/page.tsx — pendingCount/activeCount
  • front-desk/departures/page.tsx — dueOut/checkedOut
  • control-center/actions/page.tsx — pending/inProgress/completedToday/overdue
  • cash-management/keynova.tsx — openDrawers
  • cash-management/petty-cash/page.tsx — pendingApprovals
  • chain/reports/page.tsx — compliantCount
  • ai/keynova.tsx — active anomalies

P0 (Phase 1 launch blockers — pages staff use every shift)​

  1. Housekeeping board (housekeeping/keynova.tsx) — replace useRoomGrid({ limit: 1000 }) + client .filter() with a GET /housekeeping/rooms/stats (or similar) endpoint that returns counts by status. Same pattern as /reservations/stats.
  2. Housekeeping tasks — needs server-side pagination + GET /tasks/stats for the chip counts.
  3. Front-desk arrivals & departures — replace .filter(...).length with /reservations/stats.today.arrivals (already returns the counts).
  4. Control center actions — GET /actions/stats for the SLA badges.

P1 (back-office screens)​

  1. Maintenance, linen inventory, petty-cash, cash-management, chain/reports, AI — same pattern. Add per-domain *Stats endpoints.

P2 (admin tooling)​

  1. Tenant switcher pagination (rare to have > 100 tenants but still wrong).

Why testing didn't catch this​

LayerWhat it testsWhy it missed
Backend flow-tests (hmp-infrastructure/testing/flow-tests)API contracts on a single happy-path entityDoesn't create > N records to verify pagination
Vitest unit testsComponent rendering with mock dataMocks return ≤ 20 rows, so counts always look right
Playwright smoke-* specsPage loads, headings present, no 5xxAsserts "rows OR empty state" — doesn't count rows or assert pagination
Visual reviewLooks correct on seed data (~20 rows)Bug only manifests when real-property data exceeds the limit

Testing improvements to land alongside the proper fix​

1. Pagination contract tests (NEW)​

For each list endpoint, add a flow test that:

  • Seeds N+5 records where N = the dashboard's page size (e.g. 25 for a 20-per-page list).
  • Calls GET /endpoint?page=1&limit=20 and asserts data.length === 20 && meta.total === 25 && meta.totalPages === 2.
  • Calls GET /endpoint?page=2&limit=20 and asserts the second page returns the remaining 5.
  • Calls the matching */stats endpoint and asserts the counts equal the totals.

Template for flow-tests/src/flows/pagination.spec.ts:

describe('Pagination contract — /reservations', () => {
it('honors body limit + page', async () => {
// Seed 25 reservations
for (let i = 0; i < 25; i++) await createReservation();
const p1 = await api.post('/reservations/search', { page: 1, limit: 20 });
expect(p1.data.length).toBe(20);
expect(p1.meta.total).toBe(25);
const p2 = await api.post('/reservations/search', { page: 2, limit: 20 });
expect(p2.data.length).toBe(5);
});
it('stats endpoint matches paginated total', async () => {
const stats = await api.get('/reservations/stats');
const sum = Object.values(stats.byStatus).reduce((a, b) => a + b, 0);
const p1 = await api.post('/reservations/search', { page: 1, limit: 100 });
expect(sum).toBe(p1.meta.total);
});
});

Apply this to: /reservations, /tasks, /maintenance, /inventory/linen, /actions, /orders, /folios, /payments, /audit-logs.

2. UI-level "create N records" Playwright test (NEW)​

test('reservations list paginates beyond default page size', async ({ page, request }) => {
// Use the API to seed 25 reservations
const token = await login(request);
for (let i = 0; i < 25; i++) await request.post('/api/reservations', { ...token, ... });
await page.goto('/reservations');
await expect(page.getByText(/page 1 of 2/i)).toBeVisible();
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByText(/page 2 of 2/i)).toBeVisible();
});

3. Lint rule (cheap, future-proof)​

Add a custom ESLint rule (or a CI grep guard) that flags:

  • limit:\s*(100|500|1000|5000) in dashboard useQuery/API helper calls without an inline // eslint-disable-next-line pagination/no-fixed-large-limit -- reason: … comment.
  • \.filter\(.*=>.*\.status\s*=== after a useQuery for a list endpoint — should be a backend stats endpoint instead.

CI step in the dashboard's GitHub workflow:

- name: Pagination anti-pattern guard
run: |
if grep -rnE 'limit:\s*(100|500|1000|5000)' src/ --include='*.ts*' \
| grep -v 'pagination/no-fixed-large-limit'; then
echo "::error::Hardcoded large limits detected — use server-side stats or pagination"
exit 1
fi

4. Documentation: a "list endpoints" convention page​

Add hmp-docs/docs/conventions/list-endpoints.md requiring every paginated endpoint to:

  • Honor ?page and ?limit query params (and body fields, when POST).
  • Cap limit server-side (default 20, max 100).
  • Provide a sibling */stats endpoint for status/category counts.
  • Return meta: { page, limit, total, totalPages, hasNextPage, hasPreviousPage }.

The dashboard side requires every list page to:

  • Use server-side pagination, not client-side .filter().length for badges.
  • Use */stats for counts.
  • Render Prev/Next or a page selector when totalPages > 1.

Files changed in this fix (so far)​

  • hmp-reservation-service/src/reservations/reservations.controller.ts — body page/limit → PaginationDto.
  • hmp-web-dashboard/src/lib/api/reservations.ts — added stats(), page filter.
  • hmp-web-dashboard/src/lib/hooks/use-reservations.ts — added useReservationStats().
  • hmp-web-dashboard/src/app/(dashboard)/reservations/keynova.tsx — chips use stats endpoint.
  • hmp-web-dashboard/src/components/modules/reservations/reservation-list.tsx — server-side pagination + Prev/Next.

Next-iteration scope (not in this PR)​

  • The 7 hardcoded-limit sites + 70 client-filter sites are tracked as a separate sweep PR. Same pattern, different domains.
  • */stats endpoints for housekeeping/tasks/maintenance/inventory/actions need to be added on the backend (each is a groupBy query).
  • Pagination contract tests + Playwright "create N records" test added to the test plan.