Skip to content
Job Search AssistantPortfolio

Personal project · full stack

One calm place for a messy job search.

Job Search Assistant replaces a spreadsheet and a pile of loose documents with a single system: every application with its full status history, analytics that show what actually converts, and the CV and cover-letter documents that go with each one.

  • Go
  • React 19
  • TypeScript
  • PostgreSQL
  • OpenAPI
  • Tailwind + shadcn/ui
  • LLM integration

The app itself is private, so there is no login screen to show, and everything on this page is built from fictional data. I'm glad to walk you through the app and the repository live.

An illustration redrawn from the app's Applications screen. Every company and figure on this page is fictional.

What this shows

What this project says about how I work

  • I own it end to end

    Product decisions, API design, a Go backend, a React UI, CI/CD, hosting and backups. One person, one repository, running in production.

  • I write decisions down

    Every architectural choice has a dated entry with the reasoning and the trade-off I accepted. The specs are kept current, so the docs describe the app as it is.

  • I add new technology with guardrails

    The app has an optional LLM feature. Its output is treated as untrusted: checked in code, and measured against a live model on made-up data before a prompt change ships.

The problem

A spreadsheet, and documents linked by hand

My search lived in a Google Sheet of about a hundred rows. The CV, the cover letters and the case answers sat in separate documents, linked by hand and not always consistently. I couldn't tell which platform led to interviews, or how long companies take to answer.

Before

  • One spreadsheet row per application, about a hundred of them
  • Cover letters copied and edited by hand for every vacancy
  • CVs and case answers in separate documents, linked by hand
  • No answer to "which platform actually works?"

After

  • One record per application, with a dated status history
  • Cover letters built from a template, saved with the application, exported as PDF
  • A versioned CV and a library of case stories
  • Funnel, timings and conversion by source, computed from the history

Features

What it does

A job search is a long pipeline with a lot of paperwork. The app keeps the pipeline, the paperwork and the numbers in one place.

Every application is a record

Company, role, source and the full status history with dates. Notes, the pasted vacancy text and a counter of days since the last movement sit on the same card. Drafts stay apart from real applications.

  1. Interview Scheduled18 SepCall with the platform team, Thursday
  2. Applied9 SepApplied through the company site
  3. To Apply7 Sep

Analytics that answer "what converts?"

A funnel, median timings, conversion by source and a weekly trend, all computed from the status history. Every figure shows its sample size, so a small n never passes for a fact.

  • Applied120
  • Got a response48
  • Reached an interview21
See the analytics

Documents kept with the application

A versioned CV, a cover-letter template with a live preview that highlights what is still unfilled, and a PDF generated on the fly. A letter that was sent is frozen, so the record shows exactly what I submitted.

I'm applying for the Platform Engineer role at Kestrel Cloud. What draws me to the team: your own words.

Private and backed up

Sign-in with Google and a single allowed address: nothing is public. The database is backed up every day to Dropbox, with 30 days kept.

A library of case stories

Situation, Task, Action, Result: each story is written once and kept in one library, so a good example is never lost in an old document.

Built for the phone too

Bottom navigation, full-screen dialogs and a table that never needs horizontal scrolling. Even the date input quirk on iOS Safari is handled.

Analytics

One question: what converts?

The Analytics page turns the status history into a funnel, medians and conversion by source. Sample size sits next to every number.

Funnel

How far the applications got.

  • Applied120100%
  • Got a response4840%
  • Reached an interview2118%
  • Offer54%

The real page also splits rejections into before and after an interview, and shows a weekly trend.

Interview rate by source

Which platform is actually worth the time.

  • LinkedIn14%n = 58
  • Xing25%n = 24
  • Indeed6%n = 18
  • Company site30%n = 20

Share of applications that reached an interview. Bars are scaled to the best source.

  • Applied to first response

    6 days

    median, n = 48

  • Between interview rounds

    8 days

    median, n = 14

  • Rounds before an offer

    3

    median, n = 5

Illustrative numbers: the same shape as the app's Analytics page, not my real search. Applications imported from the old spreadsheet are left out of the timings, because their dates are the application date, not the real event date.

How it's built

Small on purpose: one service, one contract, one database

A personal tool that has to stay cheap and stay maintainable for years, by one person. That shaped every choice below.

At runtime

  1. Browser

    React SPA

    • Vite
    • TypeScript
    • React Query
    • shadcn/ui
    • Tailwind
  2. One Go service

    Serves the SPA and the API from the same address: no CORS, one URL

    • net/http + chi
    • Google sign-in
    • PDF on the fly
    • Sentry
  3. PostgreSQL

    Serverless Postgres, migrated with goose

    • Neon
    • sqlc
    • goose

Beside the service: Google for sign-in, and an OpenAI-compatible LLM provider for one optional feature (described below), swapped by changing environment variables.

From spec to production

  1. 1

    openapi/spec.yaml

    The single source of truth for the API.

  2. 2

    Code generation

    oapi-codegen writes the Go server interfaces, orval writes the typed React Query hooks, sqlc turns SQL files into typed Go. Nothing is written twice.

  3. 3

    Pull request, then main

    One issue, one branch, one PR. Docs are updated in the same flow.

  4. 4

    GitHub Actions

    Migrations run on their own when a migration file changes. The deploy is a deliberate button. A daily pg_dump goes to Dropbox, 30 days kept.

LLM integration

One optional LLM feature, treated as an untrusted dependency

The app has one feature that calls an LLM: it can suggest wording for a few fields of a text template, using only my own CV and cases and the vacancy text saved on the card. The app works fully without it, and everything the model returns is checked before I see it.

  1. 01

    Inputs

    The CV, my case stories and the vacancy text saved on the card. The form states what leaves the server before I press the button.

  2. 02

    Rules

    Facts about me only from my CV and cases, about the employer only from the vacancy. No source for a fact means no text.

  3. 03

    Model

    Any OpenAI-compatible API, chosen by environment variables, with a fallback model. Switched off, the rest of the app works as before.

  4. 04

    Checks in code

    A malformed answer is rejected. Whatever the sources do not back is flagged or dropped, whatever the model claims.

  5. 05

    Review

    It fills only empty fields, never overwrites what I wrote, and saves nothing by itself. I read every field.

Measured, not assumed

A prompt change is not judged by one good-looking answer. One command runs the real model several times on made-up data and reports, for each of 11 expectations, the share of runs that pass. It also shows where the model is weak, so a limit is known rather than a surprise.

$ make ai-check

  • · every proposed point uses the source's own words
  • · no value over (or under) its word limit
  • · no facts outside the sources
  • · nothing carried over from another company's story
  • · no generic term padding a technology list
  • · no fact repeated in two places
  • · … 11 checks in all

Decisions

Six decisions, each with its price

The repository keeps a dated log of decisions. These are the ones I would defend in a review, with what each of them costs.
  • One OpenAPI spec is the source of truth

    Go server interfaces and typed React Query hooks are generated from it, so the API and the UI cannot drift apart.

    Trade-off Generated code lives in the repository and has to be regenerated whenever the spec changes.

  • SQL stays SQL: sqlc, no ORM

    Queries are plain files that become type-safe Go. Every query is explicit and readable, which matters when one person maintains it for years.

    Trade-off More SQL to write by hand, and a code generator in the toolchain.

  • One deployable, $0 a month

    Go serves the built React app and the API from one address, with Postgres on Neon. One service to deploy, monitor and pay for (nothing).

    Trade-off Free tiers go to sleep, so the first request after a pause is slow. Fine for a personal tool; the upgrade path is a paid instance, no redesign.

  • The LLM provider is a config value

    Any OpenAI-compatible API, chosen by environment variables, with a fallback model. Switching provider touches no code.

    Trade-off Only the lowest common denominator of the API is used: no provider-specific features.

  • Deploys are deliberate, migrations are automatic

    Migrations run on their own when a migration file changes, so the schema can never lag behind the code. Rolling out is a button, not a side effect of merging.

    Trade-off One extra click per release. An automatic "down" migration is deliberately not possible.

  • Backups before real data

    Backups are set up before real data goes in, not "someday": a daily pg_dump to Dropbox, with 30 days kept.

    Trade-off A restore is a documented manual procedure, not a one-click feature.

Honest edges

Limits, and what comes next

A project page that lists only strengths is a brochure. These are the edges I know about.

Known limits

  • Free tiers sleep, so the first request after a pause is slow. Accepted for a tool I open a few times a day.
  • A small free model is not always right, and its free tier has rate limits. That is why the LLM feature only proposes, has a fallback model, and can be switched off without touching the rest of the app.
  • Single user by design. The tables do not know an owner, so giving someone else access would need a schema change and a filter in every query.

Next

  • Tests against a real Postgres. The Go tests and the frontend lint run on every pull request, but each test checks a plain function; queries, migrations and handlers are not covered yet.
  • Edit and delete applications, status events and notes. The first version only creates them.
  • A bank of interview questions, kept for preparation.
  • A separate "no answer" bucket in the analytics, so silence is not counted as a rejection.

See it running

The app is private, so I'll walk you through it live.

There is no recording, and I would rather show the real thing. A short screen-share covers the app in use (the applications table, a status history, the analytics) and then the repository: how it is organised, the API contract, the decision log and the code behind them.