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.

Mini Job Board main page

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:

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

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)

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);
}

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.

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