Day 1 · 4 March 2026

My girlfriend has spent the last few weeks trying to land an entry-level legal role in the Netherlands, junior counsel, compliance internship, legal trainee, the same handful of titles in slightly different combinations. The actual search part isn't the hard bit. The hard bit is tracking forty-something postings across LinkedIn, Indeed, and Glassdoor in a spreadsheet she keeps rewriting. So this evening I sat down to build her something better.

The shape of it is one I've reached for a few times before: FastAPI on the backend talking to Mongo, a React + Vite + Tailwind frontend with shadcn primitives and React Query for state, a Kanban view backing a triage inbox. The point of writing this post isn't the stack, it's the choices that are specific to the problem she's actually solving.

The core UX

What she actually needs is a fast triage loop. Open the app, see new postings, decide quickly whether each one is worth applying to, move on. So the inbox is split-pane (30/70, posting list on the left, detail on the right) and keyboard-driven: X rejects the current job and auto-advances, S shortlists it, A marks it as applied, J/K navigate up and down without taking your hand off the keyboard. The Kanban view is drag-and-drop via @dnd-kit, DndContext with sortable cards, droppable columns, a drag overlay for visual feedback, and a status update fired on drop.

Auto-advance after action is the single change that turned the inbox from "a thing you scroll" into "a thing you triage." Without it, every X requires a click-or-arrow before the next decision. With it, you can clear forty postings in three minutes.

Stitching the deployment together

This is where the evening got long. The thing was supposed to live on Koyeb's hobby tier, which means a strict memory quota that doesn't accommodate two separate services for the frontend and the backend. So the deploy story converged on a single container: the React build runs in a oven/bun:1-slim stage, the output gets copied into /app/static, and FastAPI serves both the API and the SPA, StaticFiles mounted at /assets and a catch-all GET /{full_path:path} route returning index.html for everything else.

The papercuts I worked through in order:

React Router build errors. The layout Route in App.tsx needed an explicit path="/*" to act as the catch-all parent, and NotFound.tsx had a duplicate export. Two-line fix, twenty minutes to find.

VITE_API_BASE_URL baked at the wrong time. Vite inlines env vars at build time, not runtime, so passing --env to the running container does nothing. The fix is ARG VITE_API_BASE_URL=... in the frontend Dockerfile so the build sees the right value when it bundles. The kind of gotcha that's obvious in retrospect and absolutely not obvious the first time it bites you.

Static-dir off-by-one. The combined deploy mounts the React build at /app/static; my FastAPI code was doing Path(__file__).parent.parent.parent.parent / "static" and resolving one directory above the container root. Walked the path counts on a piece of paper, dropped one .parent, problem went away.

MongoDB Atlas with a password full of special characters. The Atlas-generated password had a + in it, which gets URL-decoded as a space the moment it lands inside a connection URI. Fix: stop building the URI as a single env-var string, pull DB_USER and DB_PASSWORD as separate components, and quote_plus them before assembling. Three commits to actually get there.

Background pool auth errors. Atlas with minPoolSize=1 was reconnecting in the background on every idle reap and spitting auth warnings into the logs even on healthy traffic. Setting minPoolSize=0 made the warnings disappear, the connections still open on demand, they just don't get held warm.

Invalid TLS flags. My initial Mongo client config had tlsAllowInvalidCertificates=True and tlsAllowInvalidHostnames=True set, Atlas refuses both outright. Removed both.

The fetcher

The interesting layer. Pulling jobs from LinkedIn, Indeed, and Glassdoor isn't hard, JobSpy handles all three behind one interface. Getting back only the postings that are actually relevant to her is the part that took thought.

The Amsterdam legal job market has three structural problems if you're filtering for entry-level English-language roles:

JobSpy bulk fetch linkedin · indeed · glassdoor ~30 raw Language filter dutch stopword density >= 7% −50% Seniority filter title regex + years-of-exp scan −30% Fuzzy dedup rapidfuzz title 85 / company 90 −1-2 Triage inbox X · S · A · J · K 4-8 surfaced
Fetcher pipeline, what survives between the upstream APIs and the morning triage queue.

Language. Roughly half of postings are in Dutch. International firms post in English; Dutch firms post in Dutch. She was targeting the former. Dutch detection runs as a stopword-density check: I built a frozenset of about eighty Dutch function words (voor, van, worden, werkzaamheden, vacature, …) plus a list of strong indicators (nederland, hbo, wo, mbo, cao, solliciteer) and trigger on either ≥7% stopword density in long text or a single strong-indicator hit anywhere.

Seniority. Search queries are tuned for junior roles ("junior legal counsel", "legal internship", "compliance trainee"), but senior postings leak through anything that mentions legal or counsel. A regex over the title catches the obvious cases (senior, sr., lead, principal, head of, director, manager, VP, chief, counsel II) with word boundaries that took more iteration than I expected because "leadership" should not match lead and "managerial" should not match manager. The description gets a separate pass for experience requirements: ([3-9]|\d{2,})\s*\+?\s*years? with Dutch and "minimum of" variants. Anything that trips either check gets dropped.

Duplicates. The same job is posted on all three platforms simultaneously. Running without dedup, "Junior Legal Counsel at Booking.com" appears three times. The dedup is fuzzy: rapidfuzz.ratio on title and company with thresholds at 85 and 90 respectively, plus a more generous location-similarity check that knows "Amsterdam, Netherlands" and "Amsterdam, NL" and "Amsterdam" are the same place. There's also a content-hashed exact-dedup ID per source so a refetch of the same platform doesn't double-insert.

What landed by midnight

Drag-drop pipeline. Split-pane keyboard inbox. Platform filter chips with counts. Job notes with dirty-state tracking so unsaved edits stay visible. Source badges (LinkedIn, Indeed, Glassdoor). Salary display when present. Age indicator ("posted 3 days ago"). Search bar. Status filter. Date-range filter (24h / 7d / 30d / 90d). Location filter. Quick stats at the top of the inbox showing new / applied / active counts. Error boundary so the whole app doesn't blank on a single bad job document. Skeleton loading screens. A pink accent throughout because she said the default green I'd picked looked like a tax website.

It deployed. She marked her first seven postings into the inbox before bed.

· · ·
Day 8 · 12 March 2026

A week of real use. She'd put roughly forty postings through the pipeline; eight had been applied to, two were already at first-stage interviews. The thing that surfaced now wasn't a bug, it was that volume changes what you want to see. With forty rows in the inbox you want to find the one good posting. With four hundred you want to understand the shape of where you're spending energy.

Three new pages

Import History. A GET /jobs/import-history endpoint that returns one row per fetch run with timestamps, the queries that ran, how many results came back per query, how many survived filtering, how many were inserted vs. skipped as duplicates. The page is a flat table because that's what the data wants to be. The point isn't visualisation, it's accountability: when she says "I haven't seen anything new from Indeed in a week" we can both look at the same screen and see why.

Statistics. Total jobs, by source, by location, by status. Application rate, success rate, an "actual apply rate" that excludes the "looking into" and "saved for later" buckets because those aren't really decisions. Time-range selector at the top.

Visual Progress. A heatmap of applications by date (calendar-style, GitHub-contributions shape), a tag cloud of skills extracted from descriptions, and a list of companies by location. The heatmap is the one I keep coming back to, activity is bursty in a job hunt, and seeing the bursts mapped to the calendar makes it easier to spot whether a flat week was a deliberate break or accidental drift.

CI/CD finally

Up until tonight I'd been deploying by running koyeb deploy from my terminal, which is fine until you forget you did it and the deployed version drifts from main. Added a GitHub Actions workflow that on every push to main runs koyeb deploy with the combined Dockerfile, ignoring .git and node_modules from the archive. The first run failed because the koyeb binary wasn't on PATH inside the GitHub runner; second run added echo "$HOME/.koyeb/bin" >> $GITHUB_PATH and the deploy went through.

While I was in there I noticed the backend was listening on port 8400 in the combined image, which the Koyeb health-checker was hitting at 8000. Two-line Dockerfile fix. Also dropped supervisord from the combined image entirely (with no Celery worker to supervise it was just one more process to crash on boot), and called uvicorn directly as the container's CMD.

· · ·
Day 11 · 15 March 2026

She said: "I haven't seen any new jobs in three days." I went to look.

The fetch endpoint was returning {"fetched": 247, "inserted": 0}. Two hundred and forty-seven postings, zero of them new, and the inserted count was zero every single time. Which is suspicious in the specific way that "zero by coincidence" never is.

The dedup loop bug

The fuzzy dedup loop looked like this, in spirit:

for job in fetched:
    similar = repo.find_similar_jobs(job)
    for existing in similar:
        if _is_similar_job(existing, job):
            logger.info("fuzzy skip")
            continue  # <- wrong control-flow target
    repo.insert(job)

The continue resumes the inner loop, not the outer one. So when a similar job is found, the inner loop keeps walking other candidates, eventually exits naturally, and then repo.insert(job) runs anyway. That part was fine. The bug was elsewhere: find_similar_jobs was over-fetching candidates and the similarity key was so aggressively normalised that every job looked similar to every other job. Specifically, the slug function was stripping the words junior, intern, internship, trainee, graduate as "noise words that vary across postings", which meant "Junior Legal Counsel at Booking" and "Legal Counsel Intern at Booking" collapsed to the same key. And once any candidate matched the title-similarity threshold, the existing dedup decision was wrong and the new job got dropped.

The fix: keep the domain words in the similarity key. "junior" is not noise, it's the entire point of the search. The slug now only strips punctuation and casing. The fuzz thresholds stay where they were.

That bug had been silently rejecting almost every new posting for three days. She'd noticed it as "the inbox is quiet." I'd noticed it as nothing, because I wasn't checking.

The Dutch-filter false positives

While I was in there, a second class of bug. The Dutch stopwords list had English words on it. je, we, wij, werk, stage. The most damaging was stage, it's a Dutch word for "internship", but it's also the English word for, well, stage. Every English posting that mentioned "early-stage company" or "stage three of the interview process" was being counted as Dutch and dropped.

The fix: remove ambiguous tokens from the bulk stopwords frozenset and move them to a tighter context-aware list of Dutch-specific job-title indicators (juridisch, stagiair, functionaris, advocaat, notaris, medewerker, afdelingshoofd, teamleider). The density check still runs over the broad stopword set, but the broad set is now strictly Dutch words with no English homographs.

Same evening: the experience-requirement regex was scanning only the first 1500 characters of the description (presumably written that way to keep the regex fast), and missing "minimum 5 years of experience required" clauses that sat in the requirements section near the bottom. Removed the slice, ran on the full description, no measurable performance impact.

Entry-level scoring

A boolean "is this entry-level or not" filter is too blunt, the postings that survive still vary wildly in how junior-friendly they actually are. Added an _entry_level_score function that walks the title and description and returns a 0-100 score: +15 for junior/jr in the title, +20 for intern/internship/stage, +15 for trainee/graduate/entry-level, −15 per "N+ years experience" mention, −20 for senior/lead/principal/staff terms. Clamped to [0, 100]. Stored on the job document so the inbox can sort by it.

This is the kind of feature that wouldn't have been worth building on day one and is obviously the right thing to have by week two. Heuristics calibrated against real postings beat pure pass/fail filters once you have a corpus.

The smaller fixes

Existence checks via find_one, not count_documents. The job-already-exists check was using count_documents({"job_id": x}), which scans more than it needs to even with an index. Switched to find_one(..., projection={"_id": 1}), which short-circuits at the first hit.

Per-site retry fallback. The bulk JobSpy call sometimes 500s on one platform but not the others. The original code threw away the whole batch on a single failure. New behaviour: if the bulk call fails, retry each site individually, take what comes back, log the ones that didn't.

Configurable queries. Hardcoded queries went into a FETCH_QUERIES env var as a comma-separated string. The DEFAULT list (junior legal counsel, legal intern, legal trainee, compliance internship, …) ships as the fallback, but she can now tune the search without a redeploy.

HTTP 429 for the rate-limiter. The fetch endpoint was returning 500 when the upstream rate limiter rejected a call. Switched to 429 Too Many Requests with a Retry-After header so the frontend can show a sensible error instead of "something went wrong."

Email reminder service. A background scheduler that scans applied jobs older than fourteen days with no status update and sends a daily digest reminder. The toast notification on the Applied Jobs page surfaces the same thing in-app: "you applied to X 16 days ago, nothing's moved, maybe follow up." It's the one feature I added that's purely behavioural rather than technical, the bottleneck on her hunt isn't finding jobs, it's remembering to chase the ones already in flight.

Where it stands

Eleven days in. Two interviews scheduled, one of them already at second-round stage. The pipeline currently holds 73 postings in various states. The fetcher pulls roughly thirty new candidates per run, the filter keeps somewhere between four and eight, dedup drops one or two of those, and on a typical morning she has three or four new postings to triage with the inbox keyboard shortcuts. The whole loop, fetch to triage to decision, is under five minutes a day.

The repo is at github.com/catancs/job-application-dashboard. The README has a full local-setup walk-through, docker compose up brings up Mongo, Redis, the backend, and the frontend on a single command if you want to try it. The deployed version is keyed to her search; the open-source version is generic and the entry-level / Dutch-filter logic is plain enough Python that retargeting it to a different country or seniority band is a config change rather than a rewrite.

Next post on this one will be when something more interesting goes wrong, or, ideally, when she has an offer to consider.