Case study
Mini Job Board — testable filters, shareable URL state
A vanilla-JavaScript job board where the engineering story isn't the UI—it's the architecture under it: a filter/sort module that has no idea the DOM exists, URL query params used as the single source of truth for filters, and a no-framework stack chosen on purpose and backed by tests.
← Back to portfolio



Job listing
1. Problem
Most junior frontend demos show a static list and stop there. I wanted interaction-heavy product behavior—real-time search, combined filters, sorting, saved jobs, and multi-page flows—without reaching for React or a build step. The risk with vanilla JS is that everything collapses into one DOM-coupled file that you can't test.
So the real problem was architectural: keep the filtering logic pure and unit-testable, make filtered views shareable and deep-linkable, and treat the absence of a framework as a deliberate engineering choice rather than a missing dependency.
2. Architecture
Logic lives in shared modules; pages stay thin. State derives from data, not the other way around:
- scripts/filter-logic.mjs — pure filter/sort helpers, unit-tested in Node, importable without a browser.
- app.js — wires inputs to the module, reads/writes the URL, and renders derived slices of state.
- Shared scripts — site-header.js, jobs-fetch-error.js, and validate-jobs.mjs reused across pages and CI.
3. The filter module
Everything that decides which jobs show up lives in scripts/filter-logic.mjs as pure functions. They take data and a state object and return a new array—no DOM reads, no globals—so they can be unit-tested in Node with node --test.
- applyJobFilters(jobs, state) chains type, seniority, salary, and free-text search, then hands off to sortJobs.
- Search spans title, company, and location; each filter narrows an immutable copy, never the source array.
- sortJobs supports newest (by id), company A–Z, and remote-first (remote → hybrid → on-site, then company).
4. URL as the single source of truth
Filter state lives in the URL, not just in memory. That makes any filtered view shareable and deep-linkable—paste /?type=remote&seniority=senior&q=Developer and the board loads already filtered. Two small functions keep the URL and the controls in sync.
- readFiltersFromUrl() parses URLSearchParams on load and accepts both q and search for the query.
- normalizeUrlFilter lowercases, trims, dash-joins, and validates each value against an allow-list Set—junk params are ignored, not rendered.
- Only non-default values are written, so the URL stays clean (no ?type=all&sort=newest noise).
- history.replaceState (not pushState) avoids polluting the back stack on every keystroke; it only writes when the URL actually changes.
- A popstate listener re-reads the URL, re-syncs the dropdown labels, and re-applies filters—so browser back/forward restores the exact view.
5. Vanilla depth, on purpose
No framework and no build step is the feature here, not a shortcut. It forces—and demonstrates—command of the platform: DOM, events, history, storage, and module boundaries without a library smoothing over them.
- State without a store — an in-memory jobs array is the source of truth; the UI renders derived slices (filteredJobs, paginated batches).
- Persistence without a backend — theme, grid/list view, saved IDs, applied IDs, and the collapsed-filters preference all live in localStorage.
- Multi-page flows — index listing, job-details.html?id=, and saved-jobs.html share header and fetch-error modules instead of duplicating logic.
- Resilience — a fetch-failure banner and empty states with one-click reset, so the board degrades gracefully on a slow or broken network.
The deliberate trade-off: device-local persistence and client-side filtering, with a clear upgrade path to a real API and server-side pagination. Vanilla multi-page depth is the story—so a React rewrite is explicitly out of scope.
6. Outcomes
- Filter logic is unit-tested in isolation (tests/filter-logic.test.mjs via node --test)—no DOM, no browser needed.
- 11 Playwright E2E scenarios cover search/save, the saved-jobs page, fetch-error banners, URL-state load/update, pagination, and apply tracking.
- CI runs JS syntax checks, JSON schema validation of data/jobs.json, unit tests, and E2E (npm run ci:full).
- Live on GitHub Pages—zero build, instant deploy: