Skip to content

Add numbered pagination + sort select to deals grid (replace full-render of 186 cards) #45

Description

@hasitpbhatt

Context

The deals grid currently renders all 186 cards on a single page load (js/search.js:158 loops over the entire filtered set, no pagination or batching). Combined with:

  • A 20ms-per-card staggered entrance animation that runs up to ~3.7s on the last card
  • A 186-DOM-node page that buries the footer and gives no sense of "how much is left"
  • No sorting UI anywhere — deals render in JSONL-file order, which is arbitrary to the user
  • No back-button semantics for filter state (filters use history.replaceState)
  • No prefers-reduced-motion support (a11y bug, not polish)

This is a UX and a11y improvement task — the codebase has infinite scroll nowhere; this issue replaces the unbounded full-render with explicit numbered pagination + a sort <select>.

Goal

  1. Render 24 deals per page in a numbered pagination control below #deals-grid.
  2. Add a sort <select> with 4 sort modes (no recent — JSONL has no added field).
  3. Preserve existing URL-state sync, ARIA wiring, filter chips, and skeleton first-paint.
  4. Restore back-button semantics for page navigation while keeping replaceState for filter toggles (filters don't deserve history entries; pages do).
  5. Respect prefers-reduced-motion.
  6. Add debug logging behind a window.__DEVcheap_DEBUG flag for QA troubleshooting.

Design contracts

  • URL state: ?category=AI&recommended=1&sort=expiring&page=3 round-trips
    • page via history.pushState (back button works)
    • sort via history.replaceState
    • category / filter params via existing replaceState (unchanged)
  • Changing sort or any filter resets currentPage = 1 (a paginated slice of a different filter set isn't the same page).
  • #deals-count updates to Showing 1–24 of 186 deals (already has aria-live="polite", announces to AT).
  • Pagination hidden when only one page of results (filtered set ≤ PAGE_SIZE).
  • Windowed page numbers: 1 … 4 5 [6] 7 8 … 12 (Google-style, max ~7 visible).
  • aria-current="page" on current page button (WCAG 2.1 pattern).
  • Disabled / buttons also set aria-disabled="true".
  • Reduced motion: smooth scroll on page change becomes instant when matchMedia('(prefers-reduced-motion: reduce)') matches.
  • No framework added — vanilla JS, ES module, matches existing search.js style.

Field names (verified from data/deals.jsonl)

Sort key Source field Notes
default (JSONL order) Current behavior, preserved
expiring deal.expires (ISO date string or null) null/no-expiry sort to Infinity (end of list). Date parsing on new Date(deal.expires).
alphabetical deal.name localeCompare lowercase
recommended deal.tags.toLowerCase().includes('recommended') Mirrors existing logic at search.js:181

Note: recent sort was originally considered but dropped — data/deals.jsonl does not contain an added / created timestamp field. If added later, this sort key can be reintroduced.

Decisions on open questions (principal-engineer calls)

  1. pushState for pagination, replaceState for filters (asymmetric, intentional). Justification: back-button should walk pages but not unwind every filter click. Documented in code comments.
  2. Filter clear → reset to page 1 and clear ?page= from URL. Done in the existing "Clear all filters" handler at search.js:660.
  3. No skeleton re-render on page change — 24 cards render faster than the network round-trip a skeleton implies. Skeletons stay only on the initial boot fetch (current behavior).
  4. Print stylesheet deferred — nice-to-have, out of scope for this issue. Will file a follow-up if needed.
  5. No <noscript> fallback added here — separately tracked; this issue is pagination+sort only.

File-by-file changes

index.html

  • Wrap #deals-count in a .deals-toolbar flex container with a sort <select> (#sort-select) and <label for="sort-select">.
  • Insert <nav id="pagination" hidden aria-label="Deal pages"> between #deals-grid and p.results-hint.

js/search.js

  • Add module-level state: PAGE_SIZE = 24, currentPage = 1, currentSort = 'default'.
  • Add sortDeals(deals, sortKey) helper (pure, returns new array).
  • Add computePageWindow(current, total)['1', '...', '4', '5', '6', ..., '12'].
  • Add renderPagination(totalCount, page) to render windowed nav, returns early if totalPages <= 1.
  • Modify renderDeals() to:
    1. Apply sortDeals(filtered, currentSort) after existing filter chain.
    2. Slice to currentPage window.
    3. Update #deals-count to "Showing X–Y of Z deals".
    4. Cap animationDelay at in-page index (max ~480ms per page instead of scaling with total).
    5. Call renderPagination(sorted.length, currentPage).
  • Category tab click (search.js:470) and filter chip click (search.js:509) and active-filter-badge removal (search.js:710): set currentPage = 1 and call updatePageURL(1) before renderDeals().
  • New pagination click handler (delegated on #pagination): pushState({ page }, '', url-with-page-param), set currentPage, call renderDeals(), scroll to #deals (smooth or auto per reduced-motion).
  • New sort <select> change handler: set currentSort, reset currentPage = 1, updateSortURL(sort), renderDeals().
  • Extend boot URL-param parsing (search.js:596–638): read page and sort, apply to currentPage / currentSort, sync <select> value.
  • Add window.addEventListener('popstate', ...) to restore page/sort from URL on back/forward.
  • Expose renderDeals, sortDeals, computePageWindow, renderPagination on window for test access (matches existing window.boot, window.activeCategories pattern).
  • Add debug logging under if (window.__DEVcheap_DEBUG):
    • On page change: [pagination] page ${prev} → ${next}, total ${totalCount}
    • On sort change: [sort] ${prev} → ${next}
    • On boot URL restore: URL params restored → page=${page}, sort=${sort}
    • On filter reset: [filters] cleared, reset to page 1
  • Add keyboard handler for .deal-card-overflow (Enter/Space) — this is a pre-existing WCAG gap surfaced during code review, fixed now while we're touching search.js.

css/style.css

  • .deals-toolbar — flex, justify-content: space-between, align-items: baseline, flex-wrap: wrap, gap: 12px.
  • .sort-wrap, .sort-label, .sort-select — styled to match existing input tokens (--bg-surface, --border, --radius-md).
  • .pagination — flex, centered, gap: 6px, margin-top: 28px, flex-wrap: wrap.
  • .page-btnmin-width: 36px, height: 36px, border-radius: var(--radius-md), matches .cat-btn styling.
  • .page-btn[aria-current="page"] — primary accent background, white text.
  • .page-btn[disabled]opacity: 0.4, cursor: not-allowed.
  • .page-ellipsispadding: 0 4px, color: var(--text-tertiary), aria-hidden="true".
  • @media (max-width: 640px).deals-toolbar stacks vertically, .pagination gap shrinks to 4px.
  • @media (prefers-reduced-motion: reduce) — disables cardIn, pulse, copyPop, badge-glow animations (also fixes the standalone a11y gap surfaced during code review).

tests/ui-interactions.test.js

New describe blocks:

  • Pagination controls:
    • renders pagination when filtered total > PAGE_SIZE
    • hides pagination when ≤ PAGE_SIZE results
    • clicking a page button updates currentPage and re-renders
    • clicking "Next" advances page; "Prev" retreats; disabled at ends
    • clicking past the last page is no-op
    • changing sort resets to page 1
    • changing category resets to page 1
    • changing filter chip resets to page 1
    • URLSearchParams reflects current page after page click (pushState)
    • popstate event restores currentPage from URL
    • aria-current="page" appears on exactly one button when paginated
    • prev/next disabled buttons also have aria-disabled="true"
  • Sort select:
    • changing sort re-orders the visible window
    • "alphabetical" sorts A→Z
    • "expiring" puts deals with expires nearest-soonest first, no-expiry at end
    • "recommended" surfaces recommended-tagged deals first
    • "default" preserves JSONL order
    • sort + pagination compose (page boundary respected after sort change)
    • URLSearchParams reflects sort param

tests/html-structure.test.js

  • Assert <select id="sort-select"> with 4 expected <option>s (default/expiring/alphabetical/recommended).
  • Assert <label for="sort-select"> exists.
  • Assert <nav id="pagination" aria-label="Deal pages"> exists with hidden attribute set in initial static HTML.

tests/accessibility.test.js

  • Assert aria-current="page" on current page button (when paginated).
  • Assert disabled pagination buttons have aria-disabled="true".
  • Assert <nav aria-label="Deal pages"> exists.
  • Assert sort <select> has accessible name (label-for OR aria-label).
  • (Bundled fix) assert .deal-card-overflow responds to Enter/Space keyboard — covers the a11y fix bundled in this issue.

Validation

npm run validate:jsonl
npm test
npm run lint:html
npm run lint:css

Checklist for PR

  • All 12 ui-interactions pagination tests green
  • All 7 sort select tests green
  • html-structure + accessibility new assertions green
  • prefers-reduced-motion block disables cardIn/pulse/copyPop
  • ?page=N&sort=expiring deep link boot-restores state
  • Back button walks page history, not filter history
  • Filter clear button resets to page 1 and strips ?page=
  • window.__DEVcheap_DEBUG = true enables verbose console logs
  • No console errors in clean boot (run in browser devtools)
  • npm run lint:css and npm run lint:html pass
  • README updated with ?page= / ?sort= URL examples (no deal-count sync needed)

Out of scope (follow-ups)

  • <noscript> fallback content for the grid
  • Print stylesheet (render all filtered deals when printing)
  • "Load More" alternative pattern
  • Adding added/created field to JSONL → re-evaluate recent sort

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions