A personal referral-tracking tool for job hunting.
When you're job hunting, applying cold rarely works as well as getting a referral. But referrals are tedious to manage: you ask a recruiter, a LinkedIn connection, or a friend; you send a connection request and wait for it to be accepted before you can even ask; someone says "ping me next week"; another goes quiet and you lose track of whether you ever followed up. Multiply that across a few dozen open roles and it's easy to lose the thread entirely.
This app is a small, personal audit trail for that process: who you've asked for a referral, on which job, what stage that ask is at, and when you need to nudge someone or just apply directly because the referral window has passed.
It was built purely for my own job search — it is not a polished product, just a hobby project scratching a personal itch. Feel free to fork it and adapt it for your own use.
Want to use the hosted version instead of running your own? Comment on this repo with your email and I'll add you to the allow-list.
The app is built around a small vocabulary. If something in the UI is unclear, this section is the reference.
| Type | Meaning |
|---|---|
| Talent Partner | A recruiter or Talent Acquisition contact (internal or agency) who can submit you for a role. Shown in the API/database as TA. |
| Connection | An existing LinkedIn connection, or someone you intend to connect with, who could refer you internally. |
| Friend | A personal contact you'd reach out to organically — a call, a WhatsApp message, etc. — rather than through LinkedIn. |
A Job has two separate status concepts that are easy to conflate:
- Job Status — manually set by you:
Saved,Applied,Rejected,Closed. - Referral Status — derived automatically from that job's Referrals, not
something you set directly:
Not Started,In Progress,Referred,Not Required.
A job can be Applied while its Referral Status is independently In Progress (you applied without waiting on the referral) — the two tracks
don't have to move together.
The one manual override is a "Referral not required" checkbox on a job,
which forces Referral Status to Not Required. It clears itself
automatically the moment you add a Referral to that job, since adding one
means you're actively pursuing it again.
Each individual Referral has its own status, and which ones are valid depends on the referral type (e.g. a Friend referral has no "Connection Requested" step, since there's no LinkedIn connection to request):
Planned → Connection Requested (Talent Partner / Connection only) →
Referral Requested → Referred, or Declined at any point.
The Tasks page is a computed checklist, not something you fill in by hand. Every task falls into one of four categories:
| Category | Fires when... |
|---|---|
| Pending Referral Request | A referral request has been sent to a Talent Partner/Connection and is awaiting a response. |
| Pending Connection Request | A LinkedIn connection request is out and awaiting acceptance, before you can ask for a referral. |
| Pending Ask Friend | A friend has been identified as a referral source but hasn't been asked yet (or asked and awaiting response). |
| Apply Today | The job's referral waiting window (4 days by default) has passed with no referral, and you haven't applied yet — time to just apply directly. |
Tasks disappear once the job is marked Applied, or once the referral
waiting window has passed (at which point only Apply Today shows for that
job).
- Jobs Waiting for Referral — count of jobs with at least one live Pending Referral Request, Pending Connection Request, or Pending Ask Friend task. Clicking it opens the Tasks page pre-filtered to those three categories.
- Apply Today — count of jobs with a live Apply Today task. Clicking it opens the Tasks page filtered to just that category.
The only filter on the Tasks page is by Task Category (checkboxes for the four categories above). There's no filter by job, status, or date.
- Dismissal is permanent. Checking off a task (with a 5-second undo window) removes it for good — there's no snooze or "remind me tomorrow." If someone asks you to follow up later, you currently have to track that yourself (e.g. in the job's Notes field) rather than relying on the app to resurface it.
- Referral-status-per-type rules are hand-synced. The set of valid Referral statuses per referral type is duplicated between the frontend and backend (no shared schema/codegen), so a future change to one must be mirrored in the other by hand.
- No password/session management beyond Google sign-in — see the security note below.
- Backend: Spring Boot 4.1 (Java 25), PostgreSQL, Flyway migrations, Spring Security with an app-issued JWT (Google ID token verified server-side, not Spring Security's OAuth2 login flow).
- Frontend: React 19 + TypeScript + Vite, React Router v7, TanStack Query v5, Tailwind CSS v4 + shadcn/ui.
See docs/PRD.md for the full product spec and docs/TECH_DESIGN.md for the
data model, API surface, and task-computation rules in detail.
docker compose up -d # starts Postgres
cd backend
./mvnw spring-boot:run # http://localhost:8080Configure via environment variables (all have local-dev defaults — see
backend/src/main/resources/application.yml):
DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORDJWT_SECRET,JWT_EXPIRATION_MINUTES(default1440)GOOGLE_CLIENT_ID— your Google OAuth Client IDCORS_ALLOWED_ORIGINS(defaulthttp://localhost:5173)SERVER_PORT(default8080)
cd frontend
npm install
cp .env.example .env # then fill in VITE_GOOGLE_CLIENT_ID
npm run dev # http://localhost:5173Without VITE_GOOGLE_CLIENT_ID set, the login page shows a fallback message
instead of the Google Sign-In button — useful for local testing with
hand-crafted JWTs in localStorage instead of real Google auth.
JWT_SECREThas a dev-only default fallback (application.yml). It's a publicly-committed placeholder — anyone deploying this app must override it with a real, private secret, or an attacker who knows the default can forge a valid login token for any user. Never run this in any non-local/shared environment without settingJWT_SECRETexplicitly.- The app JWT is stored in
localStorageon the frontend (not anhttpOnlycookie). This is a deliberate simplification for a personal-use SPA with no CSRF surface, but it does mean an XSS vulnerability (e.g. from a compromised dependency) could exfiltrate the token. Acceptable for personal use; would need revisiting for anything handling other people's data at scale.
MIT — see LICENSE.