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.
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:
┌──────────────┐ fetch ┌──────────────────┐
│ data/jobs.json│──────────▶│ in-memory jobs │ source of truth
└──────────────┘ └────────┬─────────┘
│
URL (?type&seniority │ applyJobFilters(jobs, state)
&salary&q&sort) ───────────▶│ ── pure, no DOM ──
│
┌────────▼─────────┐
│ filteredJobs │──▶ render (grid / list)
└──────────────────┘ + load-more batching
localStorage: theme · view mode · saved IDs · applied IDs · filters collapsed
Pages: index.html · job-details.html?id= · saved-jobs.html
- 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.
Composable filters
- 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).
The salary-band edge case
Salaries arrive as messy strings ("€3k–5k / year", "5k-8k"). parseSalaryMonthlyUsd normalizes them to a monthly USD range—dividing yearly figures by 12 and converting EUR—then a band matches if the ranges overlap, not if one contains the other:
// Normalize "€48k-60k / year" → monthly USD range, then overlap-test
const isYearly = /\/\s*year|per\s+year|yearly/.test(s);
const isEur = /€|eur/.test(s);
if (isYearly) { min /= 12; max /= 12; }
if (isEur) { min *= EUR_TO_USD; max *= EUR_TO_USD; }
const rangesOverlap = (a, b) => a.min < b.max && a.max > b.min;
Overlap (not containment) is the right rule: a "3k–5k" job should still surface under the "5k–8k" band because its range crosses the boundary. That kind of decision is trivial to verify when the logic is a pure function.
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.
Reading state in (defensively)
- 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.
Writing state out (without history spam)
function syncUrlToFilters() {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (typeFilter.value !== "all") params.set("type", typeFilter.value);
// ...seniority, salary, sort — only non-default values
const nextUrl = query ? `${path}?${query}` : path;
if (currentUrl !== nextUrl) history.replaceState(null, "", nextUrl);
}
- 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: alejosworkstuff.github.io/mini-job-board