A personal job-application tracker. Record what you applied for, where and when; find it again by meaning rather than exact wording; and — the point of the whole thing — see before you enter a new application whether you have already been down that road with the same company.
Single user, runs on your machine, data in a local SQLite file.
npm install
npm run seed # 40 sample applications across 2024-2026 (skip for an empty database)
npm run dev # API on :3002, web on :5173Open http://localhost:5173.
npm run dev binds the API to :3002, not the API's usual :3001 default (see
"Configuration" below) — that keeps it from colliding with a tray build already running
on this machine, so both can be up at the same time.
The first search after startup is keyword-only for about a minute while the embedding model downloads (~25 MB, once per machine). The UI says so while it happens, and everything else works normally meanwhile.
Requires Node 24+ (node:sqlite is stable there; repolayer needs ≥22.5). Developed on
Node 26.7 / npm 11.12.
Duplicate detection. The feature everything else is arranged around. As you type a company and job title into the New Application form, a debounced check runs and reports one of four verdicts:
| verdict | meaning |
|---|---|
exact |
same company, same normalized title — saving requires confirmation |
similar |
same company, and the titles match on wording (Dice ≥ 0.8) or on meaning (cosine ≥ 0.75) |
company |
you have applied here before, for a different role |
none |
genuinely new |
Company names are normalized before comparison — lowercased, de-accented, legal suffixes
stripped — so Spotify AB, spotify and Spotify, Inc. are one employer, not three. In
the seed data that is why 40 applications produce 32 companies.
The same check is available without starting an application, from the dashboard's Check before you apply box.
Duplicates already in the database. The live check only helps for records you are about to create; Applications → Duplicates runs the same rules over everything already stored — an import run twice, a posting clipped from two tabs — and groups the repeats by employer. Each group nominates the record to keep (the one furthest along the pipeline, then the one carrying the most notes, tags and filled-in fields), you can pick a different one, and the rest are deleted on confirmation.
The bulk Remove button covers only the exact repeats. Similar-title groups are a judgment call — Software Engineer in 2025 and Senior Software Engineer in 2026 at one employer are two real applications — so those are removed a group at a time, or dismissed as "not a duplicate" for the session.
Hybrid search. Two retrievers over the same documents, fused by Reciprocal Rank Fusion:
- Lexical (MiniSearch): typo-tolerant BM25. Finds
pyhton devloper. - Semantic (all-MiniLM-L6-v2 via transformers.js, local, no API key): finds Backend
Engineer when you searched
server-side developer— no shared words at all.
Ranks are fused rather than scores, because BM25 is unbounded and cosine sits in [-1, 1]; normalizing between them would mean inventing a conversion that is wrong in ways nobody can see. The semantic half has a similarity floor, because vector search has no concept of "no match" and will otherwise return the whole table ranked by accident.
Job openings. A lighter-weight record for a role you found but are not ready to apply to yet — no status, no tags, just enough to find it again. "Convert" turns one into a real, tracked application on demand, using the same company-resolution and creation path the New Application form uses; the opening itself is kept (marked archived), not deleted, so there is still a record of what it became.
Capture from the web. Three ways to get a posting in without retyping it: paste a link
and the API reads the site's own schema.org/JobPosting data, paste the text and it is
parsed locally, or click a browser extension on the page you are already looking at. The
last one is what makes LinkedIn and Indeed work — they block servers, not the browser you
are reading them in. All three land as a job opening, run the same duplicate check, and are
written up in docs/capture.md.
The people behind it. A People page for your network: who works where, how you know
them, every conversation you have logged, and a reconnect date that lands on the dashboard
when it comes due. Link someone to an application as the person who referred you or
interviewed you. The duplicate check names who you know at a company before you apply, even a
company you have never applied to, and you can import your whole network from LinkedIn's own
data export. See docs/networking.md.
Ranked by fit, and kept tidy for you. Fill in a profile (a CV summary, the titles and
places you want, a salary floor, words to look for or avoid) and saved openings are scored 0
to 100 with the reasons shown. Two opt-in rules take chores off your hands: a default
follow-up date for new applications, and marking long-silent applications as ghosted, with a
preview of exactly what would change. The Windows tray app notifies you when a follow-up or a
reconnect comes due. See docs/automation.md.
Everything else. Applications divided by year and month, full status pipeline with a dated history, companies as first-class records, a free-form tag vocabulary attachable to both companies and applications, notes that link to either, CSV/Excel export and import, and a Settings page for switching between pre-configured databases and for full-fidelity backup/restore (see "Switching databases" and "Backup & restore" below).
A dashboard that reads the history, not just the current state. The pipeline funnel counts what each application ever reached, so one that interviewed and was then turned down still counts at the interview stage — which is the only way the conversion rate between stages means anything. Alongside it: applications per month over two years, and a "gone quiet" list of live applications with no follow-up date that nothing has moved in three weeks. Charts are inline SVG against the app's palette; no charting dependency.
apps/web/ React 19 + Vite + Ant Design 6 :5173
apps/api/ Fastify + repolayer + search :3001
apps/mcp/ MCP server (stdio) over the same repos, publishable as `@jobtrack/mcp`
apps/tray/ background process + Windows tray icon, publishable as `jobtrack`
apps/extension/ browser extension that clips a posting into an opening (signed for Firefox)
packages/shared/ domain types, zod schemas, pure logic
data/jobtrack.db SQLite (gitignored)
docs/ longer reference docs (web capture, networking, automation, npm publishing, ...)
npm workspaces — one npm install at the root covers everything.
packages/shared is browser-safe and must not import repolayer. The database row types
live in apps/api/src/db/schema.ts; a compile-time test
(apps/api/test/schema-contract.test.ts) asserts the two never drift apart.
Data access goes through repolayer, so the engine is one config value:
npm install pg
DB_DRIVER=postgres DATABASE_URL=postgres://... npm run devNothing outside apps/api/src/db/repos.ts names a driver, and every query stays inside
repolayer's portable operator set — there is no dialect-specific SQL anywhere in the app.
That portability costs something, and the design works around it deliberately:
| repolayer has no… | so instead |
|---|---|
| joins or relations | apps/api/src/db/hydrate.ts stitches rows with batched in queries — three queries per page regardless of page size, with a test that counts them |
aggregation beyond count() |
year/month totals are tallied in one pass in JS |
| SQL date functions | periodYear / periodMonth are denormalized columns, written from appliedOn in exactly one place |
| full-text search | search is built above the repo (see above) |
| identifier quoting | no column is named after a reserved word — the job title column is job_title, never position |
| cascading deletes | deletes clean up their own tag links, notes, status events and vectors, in a transaction |
Every variable below can be set in the environment or in a .env file in the app data
directory — the repo root for a clone, %APPDATA%\jobtrack (or ~/.local/share/jobtrack)
for an installed jobtrack, which is the file the tray's Open App Settings opens. It is
read at startup by the API, the tray and the MCP server alike. A variable set in the real
environment wins over the file, so PORT=3002 npm run dev still overrides it.
| variable | default | meaning |
|---|---|---|
DB_DRIVER |
sqlite |
sqlite, postgres or mysql — the implicit "default" target |
DB_FILE |
data/jobtrack.db |
SQLite only; a bare filename is a second database in data/, a relative path resolves from the app data directory |
DATABASE_URL |
— | required for postgres/mysql |
DB_TARGETS |
— | JSON array of additional named targets, see below |
PORT / HOST |
3001 / 127.0.0.1 |
API bind address |
SEMANTIC_SEARCH |
true |
false skips the model entirely; search stays lexical |
EMBEDDING_MODEL |
Xenova/all-MiniLM-L6-v2 |
any transformers.js feature-extraction model |
MODEL_CACHE_DIR |
.models |
where the ONNX model is cached; relative to the app data directory |
CORS_ORIGINS |
— | extra browser origins allowed without a token, comma-separated |
API_TOKEN |
generated | what a browser extension presents; kept in data/api-token |
The API binds to 127.0.0.1, but that still leaves it reachable from every page open in the
browser on the same machine — so it does not reflect arbitrary origins. A wrong token is
refused outright; a request with no Origin header (curl, the MCP server, the tray itself)
is allowed; a request from this app's own UI is allowed; anything else needs the token,
which is how the browser extension gets in. GET /api/meta stays open as a health probe,
and GET /api/auth/check is the reverse — only a valid token opens it, so "is this token
right?" has an answer that depends on nothing else. Details and the reasoning are in
docs/capture.md.
DB_DRIVER / DB_FILE / DATABASE_URL always describe one implicit target named
"default". DB_TARGETS can name more:
DB_TARGETS='[{"name":"cloud","driver":"postgres","url":"postgres://user:pass@host/db"}]'When more than one target is configured, the app's Settings page shows a switcher.
Connection settings themselves are never shown or editable there — only .env holds them.
Switching writes which target is active to data/active-db.json (a name only, never a
credential) and restarts the server to connect to it — it is not a live hot-swap. That
means something has to bring the process back up:
npm run dev'stsx watchdoes not restart on a self-exit (only on a file change), so a dev server needs restarting by hand after a switch.- A production deployment needs a process supervisor with a restart policy — pm2, systemd
Restart=always, Dockerrestart: unless-stopped, or similar — for the switch to be seamless.
| command | does |
|---|---|
npm run dev |
API + web + shared in watch mode |
npm test |
every workspace (497 tests) |
npm run lint |
oxlint over every workspace (npm run lint:fix applies what it can fix) |
npm run typecheck |
tsc --build across the project references |
npm run build |
production web bundle |
npm run seed |
sample data (-- --force to add to a non-empty database) |
npm run mcp |
start the MCP server (stdio) — see MCP server |
npm run tray |
run the API + web UI as one process, with a Windows tray icon — see Tray app |
npm run clean |
remove build output and caches |
npm run icons --workspace=@jobtrack/web |
regenerate favicon.ico / apple-touch-icon from the SVG sources |
npm run build --workspace=@jobtrack/extension |
build the browser clipper into apps/extension/dist (load unpacked) |
npm run package --workspace=@jobtrack/extension |
build and package the clipper for release into apps/extension/release |
Vitest across four projects: shared, API, MCP and web.
The MCP suite (apps/mcp/test) connects a real MCP client to the server over an in-memory
transport, so it checks what a client actually sees: that every tool's schema can be
advertised, the prompts render, and results survive serialization.
Service tests run against repolayer's MemoryRepo — no database, no fixtures, no cleanup —
which is trustworthy only because it passes the same conformance suite as the SQLite
adapter. The embedder is injected, so tests use a deterministic fake and never download a
model.
One suite (sqlite-integration.test.ts) runs against a real temporary SQLite file, because
a fake cannot prove that ensureTable() emits DDL a real engine accepts or that a Date
survives storage unchanged.
One caveat worth knowing. repolayer's
timestamps: trueshorthand is honored by the SQLite adapter but leaves both fieldsnullonMemoryRepo. Both this app and its test support use the explicit{ createdAt, updatedAt }form, which behaves identically on both. Worth a conformance case upstream.
CSV and .xlsx, both driven by the same filter object as the list view — so what you export
is exactly what you were looking at, including an active search.
Six columns: Position, Company, Location, Date, Status, Notes. A list, not a report; there is no summary or statistics sheet. The workbook keeps one worksheet per year, matching how the app organizes applications everywhere else.
CSV is written by hand against RFC 4180 with a UTF-8 BOM, so titles containing commas do not
shift columns and Malmö survives a double-click into Excel.
Why the buffered xlsx writer. exceljs's streaming
WorkbookWriterproduced archives whose zip central directory recordedcrc = 0anduncompressed size = 0for several entries. The compressed bytes were intact, so lenient readers — including exceljs's own — opened them happily, while spec-correct readers saw those entries as empty and rejected the workbook.writeBuffer()uses a different packing path and is correct. The tests now assert on the central directory directly (test/support/zip.ts) rather than round-tripping through the library that wrote the file, since that tolerance is what let the bug through.
CSV and .xlsx, in the same shape Export produces — Position, Company, Location, Date,
Status, Notes, matched by header name rather than position. Location is optional, so a file
exported before that column existed still imports. The obvious source is the app's own
Export output, so a filtered export round-trips back in; a hand-built spreadsheet in the
same shape works too.
Two steps, both hitting POST /api/import?format=csv|xlsx&mode=preview|commit, and neither
holds server-side upload state — the browser just posts the same file twice:
- Preview parses and validates every row against the exact zod schema the New
Application form uses, then runs each one through the same duplicate check the form runs
while you type. A row whose verdict is
exact— the same ruleshouldBlockSaveuses everywhere else — is marked a duplicate and will be skipped; everything else isnew. Two identical rows within the same file are caught too, even though neither is in the database yet. - Commit re-runs that classification and creates every
newrow through the samecreateApplicationcall the API and the web form use — company resolution, the opening status event, the linked note, all included. Duplicates are skipped, not overwritten; a row that fails to parse is reported and does not stop the rest of the batch.
The one thing that does not round-trip exactly: Export merges every note on an application
into one Notes cell, so Import creates that cell back as a single new note rather than
reconstructing the original set.
Paste a link, paste the text, or click a browser extension on the posting you are reading. All three produce a job opening through the same path the form uses, with the same duplicate check attached.
The browser extension is released for Firefox: open the add-on file in Firefox, allow the install, and press Connect to JobTrack in its settings. It updates itself. Chrome and Edge can load a local build unpacked.
Which route works where is not a detail — LinkedIn, Indeed and Glassdoor block servers from
reading postings, so the link route cannot work there and the extension is the answer, since
it reads the page already rendered in your own browser. The full write-up, the extension's
install and how connecting hands it the API token are in docs/capture.md.
apps/mcp is a standalone Model Context Protocol server
(stdio transport) so an MCP client — Claude Desktop, Claude Code — can read and write
JobTrack data directly: "log that I applied to Spotify today", "what's still awaiting a
reply", "save this posting for later".
It talks to the database directly, not through the REST API: it calls
createRepos(config) and the exact same services/*.service.ts functions the routes call
(exposed to it via a small exports map in apps/api/package.json), so it works whether or
not npm run dev is running. The same SQLite connection settings apply, including the
busyTimeoutMs that makes two processes touching the file at once safe.
Tools cover create/update/status-change for applications, companies, notes, tags and job openings, plus every read (list/get/search/dashboard) — deliberately no delete tools, so an MCP client cannot destroy data, only add to or edit it. On top of the plain records:
| tool | what it is for |
|---|---|
capture_posting |
read a posting from a link or pasted text into a draft opening, with the duplicate verdict and its fit; save: true also saves it (refusing a posting that is already saved or applied to, unless allowDuplicate: true) |
create_opening |
save an opening by hand; refuses a posting already saved or applied to (same link, or same company and title when a link is missing) unless allowDuplicate: true |
get_statistics |
the Statistics page's numbers: counts for today, this week, this month and any range, compared with the stretch before, broken down by location, source, work mode, status and company (also GET /api/statistics) |
get_agenda |
what is waiting today: due follow-ups, applications gone quiet, openings left sitting for two weeks (also GET /api/agenda) |
bulk_change_status |
one status change across several applications ("mark these ghosted"), each with its own dated event |
find_duplicate_groups |
the Duplicates page's sweep, read-only |
list_openings |
now filterable by q, location and source |
get_profile, update_profile, rank_openings, score_postings |
what you are looking for, your openings scored against it best first with reasons, and postings scored before they are saved (see docs/automation.md) |
list_contacts, get_contact, create_contact, update_contact, log_interaction, link_contact, list_linked_contacts |
the network: who works where, conversations, reconnect dates, and who played a part in which application (see docs/networking.md) |
Prompts turn the routine chores into one click in the client's prompt menu. Each is a script telling the model which tools to call in which order, and none changes anything without asking first:
| prompt | does |
|---|---|
weekly_review |
walks the agenda: follow-up drafts, which quiet applications to mark ghosted, what to do with idle openings, a short summary |
triage_openings |
recommends apply / research / archive for each saved opening, using your history with each company |
log_email_update |
takes a pasted employer email, finds the application and records the status change, date and notes |
prepare_application |
gathers an opening's posting and company history and drafts a cover letter, saved as a note |
interview_prep |
builds a prep sheet from an application, its history and your notes |
draft_outreach |
finds who you know at a company, suggests who can help, drafts a referral or intro request, and logs it once sent |
npm run mcp # runs it directly, for manual testing (e.g. with @modelcontextprotocol/inspector)apps/mcp is also published standalone as @jobtrack/mcp
— see docs/publishing.md. On Windows, the installer bundles it and
connects Claude Desktop for you, updating it with every install (see
windows/README.md). Elsewhere, a global install is the easiest
way to point an MCP client at it, and it is updated only by installing it again:
npm install -g @jobtrack/mcp{
"mcpServers": {
"jobtrack": {
"command": "jobtrack-mcp"
}
}
}bin/jobtrack-mcp.js defaults JOBTRACK_HOME to the same per-user directory
(%APPDATA%\jobtrack on Windows) that the globally-installed jobtrack tray app defaults
to — install both and they share one database with no extra configuration.
Running from a repo clone instead (npx tsx apps/mcp/src/index.ts, as below) skips that
default — it falls back to this repo's own data/jobtrack.db, same as npm run dev, unless
you set JOBTRACK_HOME yourself:
{
"mcpServers": {
"jobtrack": {
"command": "npx",
"args": ["tsx", "apps/mcp/src/index.ts"],
"cwd": "/path/to/JobTrack",
"env": { "JOBTRACK_HOME": "<per-user path, to share data with an installed jobtrack>" }
}
}
}One caveat worth knowing. If the API dev server is also running, each process keeps its own in-memory search index. A write made through MCP calls that process's own
search.markStale()— the web app's search results only pick it up once its own index is separately invalidated (a later write there, or a restart). Every other read — lists, detail pages, the dashboard — hits SQLite directly and is unaffected.
apps/tray runs the API and the built web UI as one background process, with a Windows
tray icon for the rest — no terminal window, no separate npm run dev. It composes the same
buildApp the API server itself uses (apps/api/src/app.ts) with @fastify/static serving
apps/web/dist, so one process answers both /api/* and the SPA.
npm run build # apps/web/dist must exist — the tray serves it, it doesn't build it
npm run trayThe tray menu has four items:
- Open JobTrack — opens the UI in your default browser.
- Autostart with Windows — toggles a per-user Registry Run key
(
HKCU\Software\Microsoft\Windows\CurrentVersion\Run), no admin rights needed. - Open App Settings — opens
.envin Notepad, seeding it from.env.examplefirst if it doesn't exist yet. - Quit — stops the server and the tray icon together.
Windows only for now — elsewhere, npm run tray still runs the combined server, just
without a tray icon (Ctrl+C to stop). It also expects Node.js already installed on the
machine.
Two flags exist for the Windows installer, and are no-ops otherwise: --no-tray skips the tray
icon (the installer's host draws its own), and --home <path> sets the data directory. When
--no-tray is given, the process also accepts quit on stdin, which runs the same orderly
shutdown the tray's Quit item does. On startup it prints a JOBTRACK_READY {...} line carrying
the URL, port, version and driver, so a supervisor need not guess at a port that may only be set
in .env.
For anyone who is not going to install Node.js, windows/ builds JobTrack into an ordinary
Windows application: a per-user installer (no admin rights, no UAC), a native tray icon, a typed
settings dialog, and a supervised server that starts silently at sign-in instead of opening a
console window. It bundles its own Node runtime and installs the published jobtrack package,
so it is a repackaging of the npm release rather than a second build of it — and it uses the same
%APPDATA%\jobtrack directory, so the two channels share one database.
Downloads are on the Releases page; see
windows/README.md for how it is built and released.
apps/tray (package name jobtrack), apps/mcp (@jobtrack/mcp), @jobtrack/api, and
@jobtrack/shared are all set up to be published standalone, not just run from a clone of
this repo — see docs/publishing.md for how the packaging works and
the exact npm publish steps.
The Settings page can export a full-fidelity snapshot of every table — every field, every
id, every relation — and restore it later. This is a different thing from the CSV/Excel
export elsewhere in the app, which is a deliberately lossy report meant for a person to read
(apps/api/src/export/columns.ts). A backup is meant to be read back by JobTrack itself and
reconstruct the database exactly, so it's useful for:
- Resetting a database seeded with test data, on any driver — restore an empty/earlier snapshot, or just don't restore at all and use the database with confidence it's really clean.
- Disaster recovery — export regularly, restore onto a fresh install if something goes wrong.
- Migrating drivers — export from SQLite, switch the active target to Postgres (see "Switching databases" above), import the same file there.
A restore replaces everything in the active database — every backed-up table is wiped and recreated from the file, inside one transaction, not merged with what's already there.
The file (.jtbak) is gzip-compressed and then obfuscated with a fixed XOR keystream, so it
isn't plain, readable JSON if it's opened in a text editor. This is not encryption — there
is no passphrase, the "key" is a constant in apps/api/src/backup/codec.ts, and it does not
protect the personal data inside (salaries, notes, company names) from anyone who actually
wants it. Treat a .jtbak file the same way you'd treat a database dump.
One known gap: repolayer stamps createdAt/updatedAt to the moment of restore — it has no
way to pass a specific timestamp through create(). Every other field, including all of the
app's own date fields (appliedOn, savedOn, occurredOn, …), round-trips exactly.
searchVectors (embeddings) is deliberately excluded — it's fully derived from the other
tables' text and rebuilds itself automatically after a restore.
The same Settings page has a Clear database button (every backed-up table wiped, nothing
recreated — type CLEAR to confirm) and, only while the active database is empty, a Seed
with demo data button that writes the same realistic multi-year job search
npm run seed does (apps/api/src/backup/seed.ts, shared by both). The API refuses to seed a
database that already has data in it — clear it first — so this is a safe pair of buttons to
leave on a page nobody but you can reach.
npm audit is clean. Three transitive packages are pinned forward via overrides in the
root package.json:
- sharp
^0.35.4— versions below 0.35.0 inherit four libvips CVEs, and below 0.35.4 two libheif ones (GHSA-rgj7-g3m4-5g8c). Arrives via transformers for image pipelines this app never uses. - adm-zip
^0.6.1— via onnxruntime-node; 0.6.0 and earlier follow destination symlinks on extraction (GHSA-vwc7-r8mq-g2x9). - uuid
^11.1.1— exceljs pins uuid@7 but only callsrequire('uuid').v4, which v11 still exports from its CJS build.
Changing overrides requires a clean install (rm -rf node_modules package-lock.json && npm install) — npm's incremental install silently ignores them otherwise.