A polished, local-first job search organizer built with React and TypeScript. It keeps every application in one JSON file that you hold, and provides focused views for tracking progress, upcoming work, and pipeline statistics — without an account, a backend, or a synchronization service.
Try the demo — nineteen fictional applications, one still running at every stage and the rest ended every way there is, nothing to install. Open the tracker when you want to enter your own.
Use the hosted app. Open the tracker and it starts empty. Every change saves as you make it — there is no Save button anywhere in this app.
Press Add your first application and start. Everything is kept in the browser to begin with, and the top bar does the remembering for you: the moment a change exists only in the browser, it asks you to keep a copy, and it goes on asking — across reloads — until you have one.
In Chrome and Edge that copy is a folder. Choose a folder puts a real tracker.json on your disk that you can back up, sync, or open in any text editor, written on every change. The same button opens one you already have: pick a folder with a tracker.json in it and that tracker opens; pick an empty folder and your tracker starts saving there. If you already have applications in the browser, a folder holding a different tracker opens as a tracker of its own rather than replacing them. A page cannot write anywhere you have not pointed it, so there is no folder it could pick for you, but the picker opens in Documents. Firefox and Safari cannot write to a folder at all, so there the top bar offers Export a backup instead. Either way nothing you write is uploaded — the site is static files, with no account. It does send a few usage counts, never what you write, and you can turn them off.
The catch worth knowing up front: without a connected folder, your data lives only in that browser profile. Clearing site data takes it with it. See Where your data lives.
Run it yourself if you would rather the file sat in a project directory you control, or you want to write stage notes in your own editor — the one feature the hosted app cannot offer, because it needs a machine to start the editor on. See Running it yourself.
- Kanban board with a column per stage, a running lane in each and a lane for every way something ended there, drag-and-drop, the next upcoming invite and attachment filenames on cards, one-press moves and a stage-selector fallback, and muted styling with an
Idle 30 dayslabel for live applications that have not changed stage in 30 days - Stage prep notes per application, one set per pipeline stage, written in Markdown and read as a foldable outline, opened from a Kanban card or table row with the current stage first — and Prep notes is a view of its own, holding notes from several applications at once in panes you can split, arranged the way you left it the last time you had it open
- Captured lines per stage, recording what an interviewer tells you as you are told it, stored the moment they are entered and pinned on screen under the prep note however far it scrolls
- Optional deadline per application, recording an external closing or decision date separately from your own next action
- Preference ranking from four subjective ratings, discounted for what you have not judged yet, with the weakest dimension named so a healthy average cannot hide a dealbreaker, shown on the table row and the Kanban card, summarised across the collection on Statistics, and used to break ties within a table band
- Compensation as a measurement rather than a rating: what was advertised, what you expect, and what was offered, kept side by side and compared against your target
- Derived urgency ranking that explains itself, combining stage, scheduled invites, deadline, next-action date, and how long an application has sat in the same stage, into one sortable column
- Sortable and filterable table with invites, deadline, urgency, preference, compensation, and attachments columns, where sorting by invite sorts by what is next, and opening on urgency bands the rows under headings — what is dated, what is live but undated, what is finished with a task still on it, and what is finished
- A Done control on the Kanban card and in the table's next action cell that clears a finished task and records it as a dated completed action, kept apart from your notes
- Calendar invites imported from the
.icsa recruiter sends, filed against a stage, with a rescheduled invite replacing the one it supersedes - Where an application stands is two things, kept apart: the stage it got to, and its outcome — still running, rejected by them, withdrawn by you, or closed by them without a rejection (the role was filled, pulled or frozen, or they went silent)
- One-press moves on every card and table row: back a stage, on a stage, End (Rejected, I withdrew, Employer closed), and once ended, Reopen or Archive
- Archive to start the next job search clean: archived applications keep their outcome and history but drop out of every view until the Archive filter asks for them, and Archive all ended in More actions puts away everything that was rejected, withdrawn or closed at once
- Statistics as the questions a search asks — who has gone quiet, where you lose the most, whether you are keeping momentum, who is replying, which sources work, how long it takes, and whether the money is there — each answered in a sentence over the chart it came from, with the thresholds behind each answer editable on its card
- Global company, role, notes, stage prep note, and invite search plus stage, outcome, archive and activity filtering, where the outcome filter also takes Ended, any way
- Add, edit, delete, import, and export controls, plus confirmed demo-data reset in the demo profile
- Append-only history whenever an application actually changes stage or outcome
- Responsive layouts, keyboard-accessible forms and dialogs, and keyboard shortcuts for splitting, finding, and navigating the stage notes panel
You need Node.js and pnpm. The repository pins pnpm 10.30.3 through Corepack.
corepack enable
pnpm install --frozen-lockfile
pnpm devOpen the local address printed by Vite. The tracker reads and writes data/tracker.json in the project directory through the dev server, creating an empty one on first launch.
To work with the 19 fictional examples instead — one still running at every stage, every outcome at least once, and one archived:
pnpm dev:demoThat command uses data/demo/tracker.json and data/demo/attachments/, separate from live data, so demo records never overwrite real applications. It serves on port 5273 so it can run alongside pnpm dev on its own 5173.
For a production build with the same file-backed database:
pnpm build
pnpm startUse pnpm start:demo after a build to preview against the demo database. It serves on port 4273, leaving pnpm start on its own 4173.
To build the hosted app — the same app with browser storage in place of the dev server's filesystem routes — and serve it through its Worker locally, feedback route included:
pnpm preview:workersIt builds both sites with pnpm build:workers, the tracker at /projects/job_apps_tracker/app and the demo at …/app/demo, and runs wrangler dev on http://localhost:8787/projects/job_apps_tracker/app. Feedback goes to a local stand-in for the R2 bucket under .wrangler/. The inbox stays shut unless a .dev.vars file (gitignored) sets a token, e.g. FEEDBACK_ADMIN_TOKEN=local.
This is the only local setup where the demo's Open my tracker and the tracker's Try the demo links go anywhere. Each link works out the other build's address from its own base path, and a dev server — pnpm dev:demo, or VITE_TRACKER_BACKEND=browser pnpm dev — serves one build at /, so there each link points back at the page it is on.
Select Add application and enter a company, optional role and URL, its current stage, notes, and an optional next action. A next action can be saved without a date; its date is cleared automatically if the action text is removed. A deadline is separate: it records an external closing or decision date and can be set with no next action at all. URLs must begin with http:// or https://.
Select Done beside a next action — on a Kanban card or in the table's next action column — when you have finished it. The task moves to that application's Completed actions, a dated record kept separately from Notes, and the next action clears. The deadline is left alone, since a closing date is not something you complete, and finishing a task is not a stage change, so no history entry is added.
Ending an application as Rejected or Withdrawn clears an outstanding next action for you, and the notice for the move says so. Either ends the work, so the follow-up you had planned is work that will not happen, and left in place it sits on your plan forever because nothing asks about it again. It is dropped rather than recorded under Completed actions, since you never did it. Closed is left alone, and so is moving to Accepted, the last stage: a task on a job you took is real work, and a role the employer closed may still be worth checking back on. If a rejection does leave you something to do, such as asking for feedback, write it as a new next action afterwards. Applications with nothing left to do fall into the table's Live, nothing dated band, which is where you decide what is next.
Completed actions are records rather than lines appended to your notes. Notes is prose you write and rewrite; a completed action is one line the app writes the moment you press Done. Keeping them apart means editing your notes can never disturb the record, the date is not something to parse back out of a sentence, and a Done pressed by mistake can be removed.
Open an application to see the whole list, newest first, above Notes. Done sits on the same row as the next action and its date there, so you can finish a task while editing, and Remove deletes an entry you did not mean to record. Both are drafts until you save, like everything else in that dialog. Removing an entry does not put the task back on your plan — undoing the record is not the same as undoing the work, and the app does not guess which you meant.
Open an existing application from any view to edit or delete it. The form has a Stage and an Outcome select, which together reach every combination, and an Archived checkbox. Any change to the stage or the outcome adds a timestamped history entry.
Every tracker starts with ten stages: Headhunted, Applied, Recruiter messaged, Online assessment, Screening call, Take-home assessment, Round 1, Round 2, Offer and Accepted. Stages under More actions renames any of them and adds rounds after Round 2 for a process that runs longer. A renamed stage keeps everything filed under it, and the names and rounds are saved with the tracker, so they travel with an export. A round you added can be taken away again only while nothing is filed under it.
The card and the table row carry the moves you make most, one press each. While an application is running: ← back a stage, → on to the next (from an offer, that is Accepted — taking the job is the last stage, not a special button), End, which asks how it ended — Rejected, I withdrew, or Employer closed — and Archive, which puts it away as it stands. Once it has ended: Reopen puts it back to running at the stage it stopped, and Archive puts it away. The stage select beside them changes only the stage, keeping the outcome, so it can also correct where something ended without reopening it. You can also drag a Kanban card onto any lane — dropping it on "Round 1 — Rejected" is the same move End makes.
Archived applications keep everything, outcome included, and drop out of every view, the count and Statistics. The Archive filter shows them again — with a dashed outline and an Archived badge, so they stand apart from the current search shown beside them — and Unarchive brings one back. Archive all ended under More actions archives everything you ended — rejected, withdrawn or closed — at once, after saying how many of each, which is how a new search starts without the last one's leftovers. Anything still active is left where it is, an accepted job included; archive that one from its own Archive when you are ready.
Files written by earlier versions, which kept how an application ended inside its state (interview_1_rejected), are migrated on load. The dev server keeps the original in data/backups/ first, and a connected folder on the hosted app gets a tracker.before-v3.json beside the file.
Open an application and select Add files under Attachments to keep a job description, a take-home brief, or the CV you actually sent. Several can be picked at once. New files are listed with their size and are staged — Remove drops one, and nothing is written until you save the dialog. Files already saved get Open, which downloads a copy, and Remove, which deletes it on save. Each file is capped at 25 MiB. Filenames show on Kanban cards and fill the table's Attachments column, so you can see what an application carries without opening it.
Where the bytes land depends on how you are running the app. Running it yourself, they go under
data/attachments/{applicationId}/{attachmentId}. On the hosted app they go into browser
storage, and into your connected folder as well if you have one — which means that without a
connected folder, Export is the only way an attachment leaves that browser. Prep notes and
captures are text and survive in the exported tracker.json; attachments are files, and only
the zip export carries them.
Open an application and use Import .ics file under Interview invites to read the calendar attachment a recruiter emailed you. The time, place, and joining link come from the file; the stage it is filed under defaults to the application's current stage, and you can change it to any other. Add invite manually records one by hand instead, for an interview arranged over the phone.
Re-importing a rescheduled invite updates the one it replaces rather than adding a second, because invites are matched on the calendar UID inside the file. The stage you filed it under survives the update, and an invite older than the one you already have is ignored. Cancelled invites stay on the record, marked as cancelled, so you can see what was called off.
A Kanban card shows the next invite still ahead. The table's Invites column lists them all, filters on their text, and sorts by whichever is next. Their text is searchable from the global search box.
The tracker reads invites; it does not talk to your calendar. Nothing is sent anywhere, and nothing changes in Google Calendar or Outlook when you edit an invite here.
Select Prep notes on a Kanban card or table row to record what you need for a stage: questions to ask, stories to tell, names to remember. Each stage of the pipeline holds its own notes, and the view opens with the application's current stage first so it is the first thing on screen during the interview.
Prep notes is reached from the header, next to Add application, rather than from the view strip — the four views there are ways of looking at your applications, and this is a workspace. It is a toggle: press it and the notes come up over whatever you were reading, press it again and they go, leaving that view exactly where it was. Picking a view from the strip puts them away too. What you have open stays open: the notes, the panes you split them into, how wide those panes are and which tab you were reading are all there when you come back — including after you close the app. Leaving for the board and returning does not rearrange anything, and closing the last tab leaves you on an empty workspace rather than dropping you somewhere else. An application you delete takes its tabs with it.
The sidebar holds both ways of getting around, one above the other: Outline is the headings of the note you are reading, and All prep notes is every note you have written, grouped by the stage it prepares for — Round 2 across every company, then Offer, and so on down the pipeline. Stages you have written nothing for are not listed, and a stage holding only what you were told says so. The search at the top of that panel filters the tree as you type, over what you wrote and what you were told, and matches company and role too. A matching note lists the words around each hit underneath it, with the words you searched for marked, so you can see why it matched without opening it. Pick one and the note opens with the find bar already running on those words — the match highlighted, and the fold holding it opened, exactly as Ctrl/Cmd+F would. Each half folds away from its own heading, and the handle between them decides how they share the column. Drag the sidebar's outer edge to widen it, or past its narrowest to put it away — and drag that same edge back out to bring it back at the width you left. Pick a row and the note opens in the pane you are reading, or drag it onto the pane you want it in — or onto a pane's edge to split a new one open there, the same drag the tabs answer. A note always opens in the pane you are reading, so asking for one another pane is showing gives you a copy of your own — one long note can be read in two panes at once, scrolled to different places, and typing into either shows in both, because it is one note. Asking the pane you are reading for a note it already has just shows it. Without a pointer, pick the row and then use the tab-move shortcuts below. That is a different question from the search on the board, which finds applications, and from Ctrl/Cmd+F, which searches only what is already open. Open in the panel's title bar (Ctrl/Cmd+P) reaches any stage of any application, including ones you have written nothing for yet — the one thing the tree cannot do — and Sidebar beside it shows and hides the whole column. The other controls up there are icons; hover one to see what it is and the shortcut it shares. Prep notes in the header is filled in while the workspace is up, so you can see at a glance whether you are in it.
The view is not limited to the application you opened it from. Ctrl/Cmd+P reaches every application's notes, so you can put one company's Round 1 beside another's and prepare them together. Every tab is named for the company it belongs to, and each pane carries its own strip of tabs. The search and filter controls in the header narrow the board, not this: a filter is not a reason to take a note you are writing away.
Add notes for a stage you have not reached yet with Add notes for another stage—useful for drafting offer questions while you are still interviewing. Clearing a stage's notes removes them when you save. Prep notes are searchable from the global search box.
Notes are written in Markdown. A stage that already has notes opens as a rendered outline, ready to read; select Edit on it to change the text, and Read to go back. Empty stages open straight in the editor, which has a toolbar for bold, italic, heading, and bullet point, and Collapse all.
Every stage has a What they said log docked beneath its prep note, holding what you were told during that stage — the note above it is what you wrote before, this is what you heard. Type a line in the capture box and press Enter: it is stored immediately—there is no Save to remember mid-conversation, and nothing to lose by closing the panel. Ctrl/Cmd+K puts the caret in the box from anywhere in the panel, and in a split panel it lands in the pane you are reading.
The dock stays on screen however far the prep note above it scrolls, so what you have been told and the box you add to are both always in view. The log takes what it needs and then scrolls on its own, holding the newest line in sight. Lines are grouped under the day they were captured on, and each carries the time it was captured. The date is said once, at the head of the day, rather than on every line. Each line is Markdown, so **bold** and links work in it.
Captures are stored separately from what you prepared, which has three consequences worth knowing:
- Clearing a stage's prep notes does not unsay what you were told in it. The note stays, with a blank body and its captures intact.
- Correct opens the lines for editing, one box each, with the time each was captured beside it. Enter or leaving a box stores it, Escape puts the line back, and the bin removes it. A line's time does not move when you correct it: an edit fixes what was written down, it does not claim the line was said later.
- Captures stay on screen and writable while you are editing the prep note, or while that note is open in an external editor. There is no shared text for the two to race over.
- Saving the panel never rewrites a capture, and Cancel never rolls one back. Corrections are stored as they are made, the same way captures are, so there is nothing here for a Save to be waiting on.
Supported syntax is a practical subset: # through ###### headings, - or 1. lists that nest when you indent them, **bold**, _italic_, `code`, > block quotes, fenced code blocks, and [links](https://example.com). A URL or email address pasted in bare links itself, as does one written inside <…>; trailing sentence punctuation stays out of the link. A [link](#heading) to one of the note's own headings jumps to it, opening whatever fold it sits inside, so a note that arrives with its own contents list works as written. Links out open only for http, https, and mailto targets.
In the reading view everything with hierarchy folds, so you can collapse a long note to its shape and open only the part you need mid-interview:
- Headings fold everything beneath them, through to the next heading of the same or higher level.
- Bullets fold whatever sits under them—sub-bullets, a detail paragraph, a quote, a code block.
- Quotes and code blocks fold behind a one-line summary.
- Collapse all folds a whole note at once, and turns into Expand all.
Click anywhere on a heading, point, quote summary, or code summary to fold it—not just the chevron. Clicking a link inside a point follows the link, and selecting text to copy it does not fold anything.
A line that simply wraps a bullet stays part of that bullet. To give a point foldable detail, leave a blank line and indent the detail under it:
- Timeline
They want an answer by Friday.What you have folded is never saved—it resets each time you open the dialog.
Notes fold while you are writing them as well as while you are reading them, along the same headings, points, quotes, and code blocks. The chevrons stand in a column beside the box, since a textarea has no room in its text for anything that is not text, and Collapse all sits in the formatting toolbar. What is folded is taken out of the box, not out of the note: it is still stored, still saved, still searched, and still counted by the word count.
Two things follow from a folded note being smaller than the note itself:
- An edit that would reach through a fold—backspacing the line break at the end of a folded heading, say—opens that fold instead of making the edit. Quietly swallowing lines you cannot see is worse than a keystroke that has to be pressed twice.
- The find opens any fold holding a match while it is running, and picking a heading from the outline opens whatever it is written inside, the same as in the reading view. Both close back up afterwards, so the note keeps the shape you left it in.
Folds follow the lines they belong to as you type: writing a new section above a folded one leaves it folded, and deleting a heading takes its fold with it.
This one needs the dev server, because it starts an editor process on the machine running it. The hosted site has no such machine, so it leaves the button out and the in-app Markdown editor is the only one.
Select Editor on a stage note to open it in a real editor. The current draft is written to data/editing/{applicationId}/{stage}.md, that file is handed to your editor, and anything you save there is pulled back and stored automatically—an editor has no Save button to press, so the app does it for you. The stage's in-app textarea steps aside while the session is live; Stop ends it, and closing the dialog ends every session and deletes the scratch files.
Which editor opens is taken from VISUAL, then EDITOR, then your platform's default handler for .md:
VISUAL="code --wait" pnpm devTerminal editors such as vim will not work here. The editor is launched detached from the dev server, which owns the only terminal, so a program that needs a TTY has nowhere to draw. Use a GUI command (code, zed, subl) or leave both variables unset and let the platform opener pick.
A spawned editor always opens on the machine running the dev server. If you reach the app over an SSH tunnel or port forward, that is not the machine you are looking at, and the app cannot detect this reliably—a web page has no way to launch anything locally except through a URL scheme an installed app has registered.
Set TRACKER_EDITOR_URL and the app hands the browser a URL instead of spawning anything, so your local editor opens it. {path} is replaced with the file's absolute path on the server:
TRACKER_EDITOR_URL='cursor://vscode-remote/ssh-remote+myhost{path}' pnpm devUse vscode://vscode-remote/ssh-remote+myhost{path} for VS Code, where myhost is the SSH host alias your editor already uses. Because the URL points at the remote file, your editor opens it over its own remote connection and the app keeps reading changes back—the round-trip still works. For a local checkout, vscode://file{path} or zed://file{path} is enough.
When nothing is configured and the server looks like it is on a remote host, the banner names the hostname it opened on rather than leaving you wondering why no window appeared. It also shows the absolute path, so you can always open the file yourself.
tracker.json remains the source of truth; the files under data/editing/ are disposable scratch copies and are gitignored. The route is only served by pnpm dev and pnpm start, and it refuses cross-origin requests since it both writes a file and starts a process.
The stage notes panel binds seven shortcuts while it is open. Most are bound to the document rather than to a particular field, so they work wherever the caret is in the panel; the two that a text box has its own meaning for stand aside while you are typing. You do not have to come back here for them: the keyboard button in the panel's title bar lists them all, and each control up there names itself and the shortcut it shares when you hover it. The panel answers Ctrl and Cmd alike whatever you are on; the labels show the one your own platform writes.
Ctrl/Cmd+\opens another pane, and lands in a small menu where←,→,↑or↓says which side it goes. The pane opens empty, waiting for a note: open one into it with the picker, pick one from the tree beside it, or drag a tab across. The same menu is on the Split button in the title bar, and its last item folds every pane back into one.Ctrl/Cmd+Fopens the find bar. It deliberately takes over the browser's own find, which cannot see text inside a folded note.Entersteps to the next match andShift+Enterto the previous;Escapecloses the bar. It does not close the view — there is nothing to dismiss, and the header is how you leave.Ctrl/Cmd+Popens the note picker. It reaches every stage of the application you are working on, plus every note already written and every application's current stage, so a second company's notes are a few keystrokes away. Type to narrow it,ArrowUpandArrowDownmove through the results,Enteropens the highlighted note—reopening one you had closed, or adding a stage you have not reached—andEscapedismisses it.Ctrl/Cmd+Bshows and hides the sidebar, with the outline and every note in it.Ctrl/Cmd+Kputs the caret in the capture box, and in a split panel it lands in the pane you are reading.Ctrl/Cmd+Shift+Xcloses the note you are reading, leaving its notes where they are. NotCtrl/Cmd+W: that closes the browser's own tab, and a page in a tab cannot take the key from it, so answering it would cost you the tab and the note at once. If you do press it by habit, the workspace comes back as you left it when you reopen the page.Ctrl/Cmd+Shift+←,→,↑and↓send the tab you are reading towards that edge: into the pane already there, or into a new one split open when there is none. Moving the last tab out of a pane closes that pane and folds the split back. These stand aside while you are typing into a note, where the same keys select to the start and end of a line.Ctrl/Cmd+Shift+,and.move that tab along its own strip, wrapping at either end.
Tabs can also be dragged: within a strip to reorder them, onto another pane's strip to move them across, or onto the edge of any pane to split a new one open there. This works with a finger as well as a mouse — hold a tab for a moment to pick it up, the way any list you can reorder on a phone works, then carry it where you want it. A swipe that starts moving straight away scrolls the strip instead. Every arrangement a drag can reach has a keyboard equivalent above, so the panel does not need a pointer.
Panes are resizable. Drag the handle between two of them, or focus it and use the arrow keys — ← and → between side-by-side panes, ↑ and ↓ between stacked ones. A pane is held at a readable minimum rather than being allowed to collapse out of sight. Widths are remembered along with the rest of the arrangement, on this browser and this machine only: how you arranged the panes is your workspace, not part of a note, so it never travels with an export.
Elsewhere, Escape closes any dialog, Tab cycles within it rather than escaping to the page behind, and the compensation amount fields take arrow keys to nudge a figure by 5,000—10,000 with Shift.
The editor carries four ratings: Work (the day-to-day itself), Growth (where it leads), People (team and manager), and Company & product. Each takes 1–5, or Don't know when you have asked and genuinely cannot tell, and starts at Not rated.
Those three states are deliberately different. Not rated means you have not looked; Don't know means you looked and the answer is not available, which is information in its own right. Both cost the same in the score, because either way the decision carries a blind spot, but the Preference column tells you which it is so you know whether to go ask or just think for a moment.
The score is the average of what you have judged, discounted for what you have not, so a
fully judged 4.00 outranks a 4.00 with a gap in it. It also names the weakest dimension:
4.00 · People 1 and a plain 4.00 are the same average, and only one of them has a 1 in
it. An application you have not rated at all is not ranked last — it shows a dash and stays
put whichever way you sort.
The same text appears on the Kanban card, so the board tells you what you think of a role while you are moving it along. Statistics turns the whole collection into a picture of how you judge roles at all, which no single row can: per dimension how many are judged, how many you could not tell, how many you have never looked at, and the mean of the judgements you did make, with the rated count and overall mean in a line beneath. That last table is where you find out whether People reads low because you keep rating it low or because you have never assessed it.
Preference deliberately does not feed the urgency score or decide which band an application lands in. A rating is what you want; a deadline is when it is due, and letting one bend the other would sink a deadline due today under a nicer role due next week. Inside a band it is the last thing consulted: where the date and the score have already tied, the role you think more of goes first, and rows you have not rated stay put.
Compensation is deliberately not a rating. Given a number everyone agrees more is better, so scoring it 1–5 would throw the number away; it belongs in a field of its own.
The editor carries three compensation figures per application — Advertised (what the posting or recruiter said), Expected (what you are aiming for here), and Offered (what arrived in writing) — plus one currency for all three. All three are kept rather than one current number, because compensation moves and the progression is the point: an offer that came in under the advertised band is worth seeing.
Every figure is a band, because that is what a posting gives you (130–150k). Leave the
to box empty for a single number and it reads back the way you typed it. Amounts are
annual base pay in whole units; thousands separators are accepted, and the arrow keys nudge
a figure by 5,000 — 10,000 with Shift — so adjusting an offer does not mean retyping it. An
amount needs a currency to be read in, so saving one without a currency is refused — but a
currency with no amount behind it is simply dropped, the way a next-action date is dropped
when the action goes away.
The currency is a picker rather than a text box, since it is the field easiest to get wrong and there are only so many answers. It always includes whatever the application already holds, so a code imported from elsewhere is never quietly replaced; imports still accept any three-letter code.
Expected doubles as the target. There is no global salary target because there is nowhere honest to keep one: the saved document has a closed shape and discards any extra top-level key, and a single number could not be compared against a per-application currency anyway. What you would accept genuinely differs by role, level, and country, so it lives on the application.
The Compensation column states the currency once, then the progression, then how far off
target it lands: AUD · Advertised 180,000–210,000 · Expected 200,000 · Offered 215,000 · 8% above target. The gap is measured between the nearest ends of the two bands, so it only
ever claims what the bands guarantee — 120,000–140,000 against a 135,000 target reads
within target rather than being called short from a midpoint nobody quoted. Sorting the
column sorts by the offered figure, or the advertised one when there is no offer; an
application holding only your own expectation has no quoted figure and stays at the bottom
in either direction. That sort compares raw numbers and ignores currency, since there are no
exchange rates to keep, which is why every cell names its currency first.
The column's filter is a range rather than a text box: pick Any stage, Advertised,
Expected, or Offered, then a minimum, a maximum, or both. A bound checks overlap
against the relevant band, so a 120,000–150,000 query catches a 100,000–130,000 posting;
leaving one bound blank reads as "at least" or "at most" rather than as zero. Any stage
checks all three, so an application with only an offer can still turn up without picking
Offered by name.
- Kanban shows a column per stage. Each has a lane for what is still running there, and a lane for each way something in view ended there — Round 1 — Rejected, Offer — Withdrawn — drawn once something ended that way. Attachment filenames, a preference score for rated applications, and a prep notes button appear on cards, and you can move applications by drag-and-drop, the move buttons, or the stage selector. A live application that has not changed stage for 30 days or more is greyed out and labelled
Idle 30 days; it stays in its own lane with its history, and still moves wherever any other application can. Silence is measured from the last stage change rather than the last edit, so annotating a card or importing an invite does not reset it. Ended applications are finished rather than idle and are never labelled. - Table answers "what should I do next". It opens sorted by urgency, descending, which bands the rows under four headings: Dated, soonest first for anything with an invite, deadline, or dated action ahead or behind; Live, nothing dated for what is still running with no invite, deadline or dated action; Finished, action outstanding for a task left on an application that ended; and Finished for the rest. A band holding nothing prints no heading, and sorting any other column takes the bands away, because they would then separate rows on a rule the visible order does not follow. Inside a band the urgency score orders the rows, except in the dated band, which reads by date as its heading says, with preference breaking ties the date and the score have both left level. Every row carries the same move buttons as the card beside its stage select, with the outcome named beside the select once it has ended. It also sorts and column-filters by company, role, source, stage, activity, next action, deadline, urgency, preference, compensation, attachments, created date, or last update, and gives each row a prep notes button. Columns whose value can be missing—activity, deadline, invites, preference, compensation—keep the rows without one last in either sort direction. The Activity column carries the same
Idle N daystext the board shows and filters on it, and reads—for an application that is not idle. The urgency column shows why an application ranks where it does—Deadline in 3 days,Action overdue 2 days,No change for 21 days—and filtering it matches that text. Ended applications are not ranked and show—. Column filters only affect the table and are not saved. Global search, stage, outcome, archive, activity, company, and source filters still apply across views. - Statistics answers "how is the search going" as the questions a search actually asks, each a card that leads with a one-sentence answer and shows the chart it came from: Am I being ghosted? lists the live applications with no stage change for 30 days or more, and opens each one; Where am I losing? gives each stage's pass rate — of the applications that reached it and have since been decided, how many got further — and names the weakest stage once it has enough decided to be fair; Where do applications go? draws every recorded move from the stage left to the stage reached; Am I keeping momentum? shows applications and replies per week against the weeks before; Is anyone answering? gives the reply rate and how long replies took; Which channels are worth my time? ranks sources by how far their applications got, once each has enough to compare; How long does it take? shows the days to each offer and rejection; Is the money there? compares offers, or failing those postings, with your targets; and How do I judge roles? shows each rating dimension's scores on a 1–5 scale with the mean marked. The thirty days and the two minimums are stated on the cards they change and can be edited there; the quiet threshold is also what Idle means on the board and the table. Small numbers are written as counts rather than percentages, and a screen reader gets every number as a table.
The global search, stage, outcome, archive, activity, company, and source filters apply across views. Activity takes Idle or Active, which are complements rather than a pair of thresholds—a finished application counts as Active because it is not idle. Stage, outcome and activity are three controls because each qualifies the others without changing them, so "Round 1, rejected" and "Round 1 and idle" are both questions worth asking. The outcome filter also takes Ended, any way. The archive filter starts on Current search; Archived shows only what you put away, and Current and archived both. Copy roles puts the roles of whatever is showing on the clipboard, one per line and without repeats—so filtering to Applied at one company from LinkedIn and pressing it copies exactly those roles. Dates, calendar days, and overdue status use your browser's timezone.
-
Export downloads a zip archive with
tracker.jsonand any attachment files. This is a complete copy: it is what you import into another browser, another machine, or a fresh checkout. On the hosted app it is the download icon on each tracker's row in the switcher; running it yourself, it is under More actions. -
Import accepts zip archives or legacy JSON. On the hosted app, From a file… under New or existing tracker in the switcher opens a file as a tracker of its own, beside the ones you have. Dropping a file anywhere on the window, or pasting one you have copied, asks first: open it as a new tracker, or replace what the tracker you are in holds — and only if you replace, whether to save a copy of what would be lost. Running it yourself, Import is under More actions and replaces the tracker, like the drop and the paste.
Whichever way the file arrives it is parsed into the domain model and checked against the schema and its invariants before you are asked to replace anything, so a hand-edited export that no longer holds together names the field that broke rather than half-replacing your data. A file that is not a tracker export is refused with what it is you can drop. Dragging a Kanban card or a notes tab is untouched: only a drag carrying files from outside the page is an import.
-
Reset demo data appears only in the demo —
pnpm dev:demo,pnpm start:demo, or the hosted demo. It asks for confirmation and restores the original 19 examples.
On the hosted app, a file dropped over a tracker with applications asks: open it as a new tracker beside this one, or Replace — open it in this tab and close the tracker that was here. Replacing never writes over a tracker; closing is the same as removing it from the browser, so a tracker saved to a folder loses nothing (its folder is untouched, and From a folder… opens it again), and only one kept nowhere but the browser is offered a copy first. Running it yourself, importing does replace the one data/tracker.json, after offering to save a copy. An empty tracker is simply replaced. In Chrome and Edge, a file that already is one of your trackers is recognised first — a folder's own tracker.json, or the file a tracker was opened from — by the browser checking it is the same file on disk, not by what it contains: the tracker you are in ignores its own file, and another tracker's file offers to switch to it. Browsers never tell a page a file's full path, so the switcher shows the nearest thing: which folder a tracker is in, or which file it came from.
How often to export depends on where your data already is. With a connected folder, or running it yourself, you have a real file on disk and exports are for moving between machines. With neither — the hosted app in Firefox or Safari, or in Chrome before you have chosen a folder — the export is your only copy, and clearing site data would otherwise be the end of it.
Feedback in the top bar opens a small panel over whatever view you are on — the page stays usable behind it. Pick Bug, Idea or Other, write a line, and press Send. Everything else is optional:
- Screenshot takes a picture of this tab. The browser asks to share the tab first — that is how a web page photographs itself — and the panel says so before the first time; one frame is kept, sharing stops at once, and the panel steps out of the picture. You can also paste or drop an image onto the panel, or pick one; up to four.
- Show me shrinks the panel to a pill in the corner while you reproduce the problem. Meanwhile it notes which buttons, tabs and menus you use —
Clicked button “Add application”,Set “Stage” to “Interview 1”— but never what you type. Take more screenshots from the pill, then press Done to get back to your report, where every recorded step is listed and can be removed before sending. - Email me when it is done leaves an address to hear back on. It is remembered in this browser for next time and is never shown publicly.
Also sent with it lists the rest of the report: which view you were on, the browser and the window size. Sent lists what this browser has sent. A report that could not be sent is kept there with the reason and a Try again button, so nothing you wrote is lost to a dropped connection.
Reports go to the Worker that serves the hosted app and are stored in a private R2 bucket. Running it yourself, the dev and preview servers take them instead and write them to data/feedback/ (gitignored).
Reading them is the page at /api/feedback/inbox under the app's address. Running it yourself, that is http://localhost:5173/api/feedback/inbox, with the token local unless FEEDBACK_ADMIN_TOKEN is set. On the hosted app it asks for the token you set as a Worker secret (see Publishing it yourself). Each report shows its text, screenshots, recorded steps and context, with a status to triage it by, and an email link prefilled to tell the sender it is fixed.
There are two builds, and they differ in one thing: who writes the file.
Running it yourself (pnpm dev, pnpm start) — live data stays in data/tracker.json inside the project directory (gitignored), with attachment files in data/attachments/ (also gitignored). Demo data uses data/demo/tracker.json and data/demo/attachments/. The dev server and preview commands read and write the active profile's paths through /__db and /__attachments.
On first launch after upgrading from the browser-storage era, a valid legacy localStorage document from the same browser origin is copied into data/tracker.json once, then browser storage is cleared.
The hosted app has no server to write that file, so the browser does it. Every change goes into the browser's own storage immediately, and into your connected folder as well when there is one. Those are not alternatives: the folder is the copy that outlives the browser profile, and the browser copy is what survives a folder permission the browser decided to withdraw.
Several trackers, one per tab. The hosted app can hold more than one tracker in the same browser — a folder in one tab, an imported file in another. Each is named after its folder, or the file it was imported from, and that name is the browser tab's title and the switcher beside the logo. The pencil beside a tracker gives it a name of your own. The name is saved inside tracker.json, so it goes wherever the tracker goes — into your connected folder, into every export, and into another browser when you import there — and it sticks over the folder's or the file's name. The folder itself keeps its name on disk. Exports are named after the tracker and the day, like Autumn search 2026-10-01.zip. The switcher lists them all: click one to open it, or Cmd/Ctrl-click to open it in a new tab. New or existing tracker offers a blank tracker, one from a folder (Chrome and Edge), or one from an exported file; each opens beside the one you were in and leaves it exactly as it was. Each tracker in the list has a pencil to rename it, an arrow to export it, and a cross to remove it from the browser, so you can tidy any of them without opening it. Removing deletes it from the browser — after offering to save a copy if nothing else holds it — and never touches a connected folder's files. A folder can belong to one tracker at a time: pick one that another tracker already uses and that tracker opens instead. The same tracker open in two tabs stays in step: a change in one shows in the other straight away, and two changes made at the same moment are both kept.
A connected folder gets the same layout the dev server writes — tracker.json at the top, attachments under attachments/{applicationId}/{attachmentId} — so one folder opens in either build. Point the hosted app at your checkout's data/ directory and both are looking at the same file.
The top bar always says which of these you have:
| It says | What that means |
|---|---|
| Saved in this browser | Nothing is only in the browser yet: the tracker is empty, or your last export or import has everything in it. Click it in Chrome or Edge to save to a folder anyway. |
| Save to a folder | Chrome or Edge, with changes only in the browser. Click to pick a folder; hover it to see since when. |
Saved to <folder> |
Both copies are current. Click to switch folders. |
Saving to <folder>… |
A change is being written right now; it reads Saved again as soon as it lands. |
Reconnect <folder> |
The folder is still yours but its permission lapsed, which browsers do. Edits are still being saved to browser storage; click to grant it again and the folder catches up. |
| Export a backup | Firefox or Safari, which cannot write to a folder, with changes your last export does not have. Click to export; hover it to see since when. An import counts as a backup too, since you are holding the file you imported. |
Durability follows from that: a connected folder is a file you own, and browser storage is only as durable as the browser profile. The first time a change lands only in browser storage, the app asks the browser to keep that storage persistent, which makes it less likely to be cleared to free up space; Firefox may ask you to allow it. That makes the browser copy harder to lose, but it is still not a file you hold, so the reminder does not go away because of it. Nothing from your tracker is uploaded in either build — the hosted app is static files and has no account. Two things ever leave the browser: a feedback report you write and press Send on, described below, which carries nothing from your applications; and usage counts, fixed words about how the app is used and never what you write, which you can turn off.
The hosted app counts how it is used, so it is clear what is worth improving. The dev server and anything you run yourself send nothing.
What a count can say is fixed by a list in src/usageEvent.ts: every value is one of a few set words, so a company, a note, a name or a date you wrote cannot fit in one. Per page load it sends roughly how many applications the tracker has (in ranges like 3–10), roughly how long it has been in use, how far the furthest application got (interview, offer, accepted), whether it saves to a folder (or chose one whose permission has since lapsed), which features are in use (prep notes, messages, ratings and so on), each as yes or no, and what kind of site the visit came from (search, social, GitHub, the demo, the rest of fruitiecutiepie.com, or none) — never the site itself. As you work it counts an application added, a move to a stage and outcome, a view chosen and roughly how long each view stayed on screen (in ranges like 1–5 minutes), an export, an import and whether the file could be read, a folder connected, an older file opened, and anything that failed, by what you were doing at the time.
Each count carries a hash of the tracker's id, so counts from one tracker can be told apart from another's. That is a pseudonym rather than anonymity: the hash is computed the same way for everyone, so anyone holding your tracker's link, which holds its id, could work out which counts are yours. Nothing else about you goes with a count: it is sent without cookies, and the Worker that receives it reads no address and no browser details. It refuses anything not on the list, keeps no request logs, and stores each count with the time it arrived, as any log does, in Workers Analytics Engine, which drops it after three months. Cloudflare, which serves the site, sees the address any request comes from, as every host does; the Worker never reads it.
To read them, create a Cloudflare API token with Account Analytics: Read and run:
CLOUDFLARE_ACCOUNT_ID=… CLOUDFLARE_API_TOKEN=… pnpm usage:reportIt prints the last eight weeks: active trackers and those that recorded something, new trackers, how many added an application on their first day and three in their first week, how many came back in their second and fifth weeks (leaving out anyone who had already taken a job), applications added and moves, where visits came from, how many save to a folder, which features and views are used and for how long, how far searches get, and what failed. --weeks N changes the period, --json prints the numbers instead of a table, and --limit N raises the row limit if it warns that it hit it. Rows are kept for three months, so a tracker's age (in the snapshot) is what says how long people stay beyond that.
Twelve weeks of it also appear on the project's progress page at fruitiecutiepie.com/projects/job-apps-tracker/progress, which only the owner and invited contributors can open. Every hour the Worker works the summary out and pushes it to api.fruitiecutiepie.com. The tracker chooses the metrics and their labels, in src/usageDocument.ts, and the site decides how they look. Setting it up takes two Worker secrets, uploaded from Bitwarden by scripts/push-worker-secrets: ANALYTICS_TOKEN (Account Analytics: Read) and USAGE_PUSH_TOKEN, which is minted on the site with scripts/usage-token job-apps-tracker. The account is the ANALYTICS_ACCOUNT_ID var in wrangler.jsonc.
Share usage counts under More actions (the … button) turns them off, and the first screen of an empty tracker says the same in a line. A browser that sends Global Privacy Control or Do Not Track starts with them off.
https://fruitiecutiepie.com/projects/job_apps_tracker/app/demo is a second build of the same app under the demo profile: the 19 fictional examples, one still running at every stage and the rest ended every way there is, seeded on a first visit. Reset demo data restores them and exists only there.
It is the same origin as the tracker, so the one thing keeping them apart is that each gets its own IndexedDB database. Emptying the demo sticks — it is not reseeded on every load — and a standing banner says whose data it is, with a link back.
The hosted app is a Cloudflare Worker serving the static build, plus the feedback route. .github/workflows/deploy.yml deploys it whenever the Test workflow passes on a push to master, building exactly the commit Test checked. Merging is going live; there is no separate step. CI deploys code only, so a change to the route in wrangler.jsonc goes out by hand with pnpm wrangler triggers deploy.
What it runs on is declared in the repository rather than set up by hand. The Worker, its route and its binding to the feedback bucket are in wrangler.jsonc. The R2 bucket itself is OpenTofu in infra/cloudflare/, with its state committed and encrypted. Secrets — the Cloudflare tokens, the state passphrase and FEEDBACK_ADMIN_TOKEN — are in Bitwarden, pushed by scripts in scripts/. infra/cloudflare/README.md has the setup, the token permissions, and how to rotate each secret.
Without FEEDBACK_ADMIN_TOKEN, reports are still accepted and stored, but the inbox page refuses to list them.
A fork deploys to its own Cloudflare account by changing account_id and the route in wrangler.jsonc, the account in infra/cloudflare/variables.tf, and the base paths in package.json's build:workers to match the route.
pnpm typecheck
pnpm lint
pnpm test
pnpm test:smoke
pnpm build
pnpm build:workersThe last line is what CI deploys, and it is the only check that exercises the browser storage backend end to end rather than through jsdom.
A browser suite covers what jsdom cannot judge: where the panel's panes actually end up, whether a pane stays wide enough to read a note in, how a narrow window rearranges them, and whether a tab can be dragged with a finger. It runs in Chromium, Firefox and WebKit, and needs those browsers once:
npx playwright install chromium firefox webkit
pnpm test:browserOn macOS, Firefox runs in Playwright's Linux image through Docker (Colima: colima start),
because Playwright's Firefox does not launch on macOS 27. pnpm test:browser does this on its
own; TEST_FIREFOX=native pnpm test:browser tries Firefox natively instead.
The suite also compares screenshots of the board and the Prep panel at phone and desktop width,
in light and dark, against baselines in src/test/screenshots/. After a deliberate visual change, update them with
pnpm test:browser -u and look at the new images before committing them. A pre-commit hook,
turned on by pnpm install, does the update for you when a commit touches the UI: it stops the
commit with the new images in your working tree, and committing again after git add passes.
SKIP_SCREENSHOTS=1 git commit skips it.
Use pnpm test:watch while developing. The automated smoke test and optional browser checklist are documented in SMOKE_TEST.md.
Contributor architecture, invariants, and common traps are documented in AGENTS.md.
MIT. See LICENSE.