Pagination & Counting Sweep — 2026-04-25
Why this matters
The reservation list bug ("only 20 of 97 reservations visible") was caused by:
- 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'slimitwas silently ignored — backend defaulted to 20. - 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)
| Before | After |
|---|---|
useReservations({ limit: 500 }) then .filter(r => r.status === 'CONFIRMED').length | useReservationStats() → backend's GROUP BY status query |
| Inner list pulled 20 with no pagination controls | useReservations({ page, limit: 20 }) + Prev/Next controls |
Body limit: 500 silently capped at 20 | Backend controller now merges body page/limit into PaginationDto |
Sweep results (dashboard)
7 sites with hardcoded large limit (potential silent truncation)
| Path | Limit | Risk |
|---|---|---|
housekeeping/keynova.tsx:470 | 1000 | Status counts on the room board — fails for 500+ room properties |
vishramgruh.ts:151 | 500 | Active hospital authorizations — fails when > 500 |
housekeeping/inventory/page.tsx:51 | 100 | Linen inventory — fails when > 100 SKUs |
housekeeping/maintenance/page.tsx:119 | 100 | Maintenance requests — fails when > 100 open |
housekeeping/tasks/page.tsx:122 | 100 | HK tasks — fails when > 100 daily tasks (likely on big properties) |
rates/use-rate-calendar-data.ts:38 | 100 | Rate calendar — fails for long date ranges |
properties.ts:78 (getTenants) | 100 | Tenant 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/activeCountfront-desk/departures/page.tsx— dueOut/checkedOutcontrol-center/actions/page.tsx— pending/inProgress/completedToday/overduecash-management/keynova.tsx— openDrawerscash-management/petty-cash/page.tsx— pendingApprovalschain/reports/page.tsx— compliantCountai/keynova.tsx— active anomalies
Recommended fixes (priority order)
P0 (Phase 1 launch blockers — pages staff use every shift)
- Housekeeping board (
housekeeping/keynova.tsx) — replaceuseRoomGrid({ limit: 1000 })+ client.filter()with aGET /housekeeping/rooms/stats(or similar) endpoint that returns counts by status. Same pattern as/reservations/stats. - Housekeeping tasks — needs server-side pagination +
GET /tasks/statsfor the chip counts. - Front-desk arrivals & departures — replace
.filter(...).lengthwith/reservations/stats.today.arrivals(already returns the counts). - Control center actions —
GET /actions/statsfor the SLA badges.
P1 (back-office screens)
- Maintenance, linen inventory, petty-cash, cash-management, chain/reports, AI — same pattern. Add per-domain
*Statsendpoints.
P2 (admin tooling)
- Tenant switcher pagination (rare to have > 100 tenants but still wrong).
Why testing didn't catch this
| Layer | What it tests | Why it missed |
|---|---|---|
Backend flow-tests (hmp-infrastructure/testing/flow-tests) | API contracts on a single happy-path entity | Doesn't create > N records to verify pagination |
| Vitest unit tests | Component rendering with mock data | Mocks return ≤ 20 rows, so counts always look right |
Playwright smoke-* specs | Page loads, headings present, no 5xx | Asserts "rows OR empty state" — doesn't count rows or assert pagination |
| Visual review | Looks 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=20and assertsdata.length === 20 && meta.total === 25 && meta.totalPages === 2. - Calls
GET /endpoint?page=2&limit=20and asserts the second page returns the remaining 5. - Calls the matching
*/statsendpoint 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 dashboarduseQuery/API helper calls without an inline// eslint-disable-next-line pagination/no-fixed-large-limit -- reason: …comment.\.filter\(.*=>.*\.status\s*===after auseQueryfor 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
?pageand?limitquery params (and body fields, when POST). - Cap
limitserver-side (default 20, max 100). - Provide a sibling
*/statsendpoint 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().lengthfor badges. - Use
*/statsfor 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— addedstats(),pagefilter.hmp-web-dashboard/src/lib/hooks/use-reservations.ts— addeduseReservationStats().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-
limitsites + 70 client-filter sites are tracked as a separate sweep PR. Same pattern, different domains. */statsendpoints for housekeeping/tasks/maintenance/inventory/actions need to be added on the backend (each is agroupByquery).- Pagination contract tests + Playwright "create N records" test added to the test plan.