Back

Job Hunt Dashboard

Desktop / Productivity | 2026

Job Hunt Dashboard
https://localhost · tray app
+
Gmailsynced 2 min ago·14 new · 3 job-related
AIgemini-2.0-flash
Today · 3 of 5 done
Reach out to 3 referrals
Apply to 5 SDE roles
LeetCode: 2 mediums, 1 hard
Update LinkedIn with METY case study
Mock interview · system design
Deadline · in 14 days
Apply to 50 SDE roles
Inbox · auto-classified
Stripe
Re: SWE New Grad — Phone screen scheduled
screen
Anthropic
Application received · Software Engineer
applied
CockroachDB
Take-home: distributed systems exercise
task
Datadog
Unfortunately we won’t be moving forward
rejected
tray · auto-launch · local-onlywallpaper · ws://127.0.0.1:49152
Overview

A local-first job tracker that never leaves my machine.

My Role
Sole engineer

A tool I built for myself while job-hunting in spring 2026. Electron 29 desktop app that lives in the Windows tray, polls Gmail on a schedule with read-only OAuth, and uses a pluggable AI provider (Gemini / Claude / OpenAI) to classify whether each new email is job-related. Everything is stored on my disk; nothing leaves except the Gmail OAuth call itself and — if I opt in — the email subject + snippet sent to one chosen LLM for classification.

Stack
Electron 29 · Node 18+ · plain HTML / CSS / JS (no framework) · node-cron · googleapis · ws · JSON storage

Deliberately frameworkless: zero bundler, zero React, zero Tailwind. Storage is a JSON file in app.getPath('userData'). Gmail is the official googleapis SDK on a read-only scope. AI providers are reached over raw HTTPS without any vendor SDK so the surface is the same for all three. The wallpaper integration talks to the renderer over a local WebSocket on ws://127.0.0.1:49152.

Timeline
May 2026 · solo · for personal use

The app I open every morning during the job search. Daily-task list and long-term goals are editable from inside the app, the dashboard mirrors live to a Wallpaper Engine background so the status is on screen even when the app is collapsed to tray.

Highlights

Local-first, BYO-AI, even live on the desktop wallpaper.

Three properties are the point. It stays on disk — no server, no cloud, no telemetry. It's BYOK across three LLMs — Gemini, Anthropic, or OpenAI, switchable from Settings. It paints itself onto the wallpaper via a Wallpaper Engine plugin that listens on a localhost socket and re-renders whenever the app state changes.

3
AI providers
Gemini · Claude · OpenAI · BYOK
0
Servers
local-only · JSON in userData
ws://
Wallpaper transport
127.0.0.1:49152 · two-way
Read-only Gmail OAuth. Nothing else leaves.
The Google sign-in is the only external call required to use the app. AI classification is opt-in and goes only to the provider whose key you pasted in Settings. If you select “None”, every job-related email lands in the in-app inbox for manual review.
BYOK across three providers, behind one switch.
AI provider configs (gemini-2.0-flash, claude-haiku-4-5, gpt-4o-mini) live side-by-side in Settings. Keys are stored locally; switching providers is one dropdown change, no rebuild.
Live wallpaper as a second viewport.
A Wallpaper Engine “Web” wallpaper at wallpaper/wallpaper.html connects to the local WebSocket and renders the same daily tasks + deadline + sync status as the app. Check off a task on the wallpaper and it propagates back to the app instantly.
Context

A job search is the worst kind of long-running unindexed inbox.

Six months of applying to roles turns Gmail into a haystack — acknowledgments, recruiter outreach, scheduling threads, take-home assignments, and rejections all mixed in with everything else. Generic trackers (Huntr, Teal) want me to upload everything to their cloud. I wanted the opposite: an offline app on my own machine, plus the option to spend a few cents on an LLM call only when I explicitly choose to.

r/cscareerquestions · recurring thread
How are people tracking 100+ applications without losing their minds?
The problem this app exists to solve
Huntr / Teal pricing pages, 2026
Free tier limits at ~20 active applications; Pro tier $20-30/mo with cloud sync.
Cost cliff for a 3-6 month search
OAuth 2.0 best practice
Request the narrowest scope that still works. Read-only Gmail is gmail.readonly — don't ask for write access if you only need to read.
The scope this app uses, by design
Wallpaper Engine workshop
Web wallpapers can run arbitrary HTML + JS and connect to local services — the perfect transport for a tray-app status widget.
The integration nobody asks for, but everyone wants
1.0Demand signals.DIAGRAM
The Problem

A productivity tool I'd actually trust with my Gmail.

1
Local-first, no exceptions
Storage is a JSON file in app.getPath('userData'). No SaaS, no backend, no analytics, no telemetry. The OS owns the data file, the app reads and writes it.
2
Narrowest possible Gmail scope
gmail.readonly only. The app must never need write or modify access — that's both a trust boundary and a smaller blast radius if the OAuth token leaks.
3
Multi-provider AI without vendor lock
Users (me, mostly) shouldn't have to commit to one LLM provider. Same classification surface has to work over Gemini, Anthropic, or OpenAI — raw HTTPS, no SDKs.
4
No-AI manual fallback
If the user picks 'None' as the provider, every job-flagged email routes to a manual inbox tab. The app stays useful at $0 AI spend.
5
Lives in the tray on Windows
Starts hidden on boot via Windows' login items, sits in the system tray, polls Gmail on schedule without ever needing focus.
6
Live wallpaper as a viewport
The dashboard state needs to be visible without focusing the app. A Wallpaper Engine wallpaper paints the same tasks + status onto the desktop and stays in sync via WebSocket.
North-star principles
Nothing leaves unless you ask.
Gmail OAuth is the only mandatory call. Email content reaches an AI provider only if a key is pasted and a provider is set as active.
No framework where one isn't needed.
Plain HTML / CSS / JS in the renderer. Zero bundler, zero build step, zero dependencies between the markup you write and the markup that ships.
Two viewports, one source of truth.
The renderer process owns app state. The wallpaper is a read-mostly mirror over a local WebSocket. Both reflect the same JSON store underneath.
Process

Three increments that each made the next one possible.

V1

Electron shell + manual inbox.

First milestone: a tray-resident Electron 29 app with a single HTML page, a JSON storage adapter, and a manual inbox where I could paste subject lines into application records. No Gmail integration, no AI. Useful enough to start the search, ugly enough to know it needed more.

V2

Gmail sync + AI classification.

Added googleapis OAuth and a node-cron scheduler that polls Gmail every N minutes. Built a small provider abstraction so the sameclassify(subject, snippet) call dispatches to Gemini, Anthropic, or OpenAI based on the active provider in Settings. The job-related ones land tagged in the dashboard; the rest get ignored.

V3

Wallpaper Engine live integration.

Stood up a local WebSocket server on ws://127.0.0.1:49152 inside the Electron main process. The Wallpaper Engine wallpaper is just an HTML page with a WebSocket client that subscribes to state updates from the renderer and pushes task-toggle events back. The result: my daily tasks and sync status are on screen even when the app is collapsed to the tray.

The auto-launch quirk

Electron's setLoginItemSettings happily registers the app for auto-launch, but openAsHidden alone doesn't hide it on first boot after install — Windows shows the window once. Fix was checking process.argv for the --hidden flag Windows passes on login starts and calling win.hide() if present. The first launch shows the window so you know it's installed; every subsequent boot opens it straight into the tray.

Email classification
Before — V1 manual inbox
Paste subject lines by hand. Useful but slow — 50 applications a week means a lot of typing.
After — V2 multi-provider AI
node-cron pulls new threads every N minutes, dispatches subject + snippet to the active provider (gemini-2.0-flash / claude-haiku-4-5 / gpt-4o-mini), routes the job-tagged ones to the dashboard.
3.0DIAGRAM
Dashboard visibility
Before
Have to click the tray icon to see today's tasks or the sync status. Out of sight, out of mind.
After
Wallpaper Engine wallpaper renders the same daily tasks + deadline + sync status. Two-way: checking a task on the wallpaper marks it done in the app.
3.1DIAGRAM
Architecture

One process, three surfaces, one JSON file.

The Electron main process owns everything mutable — the JSON store, the cron timer, the OAuth tokens, the WebSocket server. The renderer process is the operator UI. The wallpaper is a passive listener over the local socket. All three see the same state because they all read from the same in-process source of truth.

jobhunt: ~/architecture
electron@main:/$main process boot
[1] storage load JSON · app.getPath('userData')/state.json
[2] cron schedule gmail sync · every N min · resettable
[3] ws start WebSocket server · 127.0.0.1:49152
[4] tray register tray icon + menu · start hidden if --hidden
─── on cron tick ───────────────────────────────────────
mustakim@portfolio:~$gmail.users.messages.list({ q, labelIds, maxResults })
mustakim@portfolio:~$for each new msg → ai.classify(subject, snippet) // provider = active
mustakim@portfolio:~$if job-related → store.upsert(application) · ws.broadcast(state)
─── on renderer / wallpaper task toggle ────────────────
mustakim@portfolio:~$ws.recv({ type: "task.toggle", id }) → store.update(...)
mustakim@portfolio:~$ws.broadcast(state) # both viewports refresh
6.0Main-process lifecycle.DIAGRAM
AI provider abstraction
// providers/index.js
const PROVIDERS = {
  gemini:    require('./gemini'),     // gemini-2.0-flash
  anthropic: require('./anthropic'),  // claude-haiku-4-5
  openai:    require('./openai'),     // gpt-4o-mini
}

// one shape, three implementations
async function classify(subject, snippet, cfg) {
  if (cfg.provider === 'none') {
    return { jobRelated: null, manual: true }
  }
  const p = PROVIDERS[cfg.provider]
  return await p.classify({
    subject, snippet,
    apiKey: cfg.keys[cfg.provider],
  })
}

// all three speak raw HTTPS — no SDKs, no auth helpers,
// no shared dependency surface between them.
Wallpaper transport
// main process
const wss = new WebSocketServer({
  host: '127.0.0.1',
  port: 49152,
})

wss.on('connection', (ws) => {
  ws.send(JSON.stringify({
    type: 'state', payload: store.snapshot()
  }))
  ws.on('message', (raw) => {
    const m = JSON.parse(raw)
    if (m.type === 'task.toggle') {
      store.toggleTask(m.id)
      broadcast({ type: 'state', payload: store.snapshot() })
    }
  })
})

// wallpaper/wallpaper.html
const ws = new WebSocket('ws://127.0.0.1:49152')
ws.onmessage = (e) => renderState(JSON.parse(e.data))
checkbox.onchange = () => ws.send(...)
6.1Provider abstraction + wallpaper transport.DIAGRAM
Final Designs

What it looks like every morning at 8am.

The desktop app is intentionally dense — stat cards across the top, today's objectives on the left, this-week progress + long-term goals on the right, all editable in-app. The Wallpaper Engine view is a slimmer read-mostly mirror of the same state, painted onto the desktop and kept in sync over the local WebSocket.

Job Hunt desktop app — Dashboard tab with stat cards, daily objectives, weekly progress, long-term goals
7.0Dashboard tab — 224 applications tracked, daily objectives + weekly progress + long-term goals.IMAGE
Job Hunt wallpaper — live dashboard painted onto the Windows desktop via Wallpaper Engine
7.1Wallpaper Engine live view.IMAGE

The same state, painted onto the desktop. A Wallpaper Engine “Web” wallpaper connects to the local WebSocket on 127.0.0.1:49152 and renders the stats, today's objectives, this-week targets, long-term goals, and the deadline countdown — all without focusing the app.

It's two-way: checking a task on the wallpaper marks it done in the app, and vice versa. Both surfaces read from the same in-process JSON store, so they never drift.

Settings — Gmail connection, AI provider keys, general sync settings
7.2Settings — Gmail + AI provider + sync config.IMAGE
Settings — editable daily tasks (count/binary) and long-term goals
7.3Settings — editable daily tasks + long-term goals.IMAGE

Local-only · no public URLJob Hunt is a desktop app, not a hosted site — it lives in the Windows tray and reads your Gmail locally. The screenshots above are from the running Electron build on developer hardware; daily tasks and goals are fully editable in Settings (count tasks track a number, binary tasks are done / not-done).

Retrospective

What I'd keep, what I'd ship next.

Worked

No-framework renderer.
Plain HTML / CSS / JS made the renderer faster to iterate on than a React + bundler setup would have. Zero build step, instant reload, no dependency drift. Justified by how small the UI is.
Provider abstraction over raw HTTPS.
Skipping vendor SDKs kept the dependency surface tiny and made switching providers a one-file change. Same classify(subject, snippet) call across all three.
WebSocket-as-state-bus.
A local-only ws server is enough to keep two viewports (renderer + wallpaper) in sync. No Redux, no Zustand, no IPC ceremony — just JSON messages over a socket.

Didn't

Windows-only.
Tray behavior, auto-launch, Wallpaper Engine integration — all Windows-coded. A cross-platform version means rebuilding the auto-launch + wallpaper pieces from scratch for macOS / Linux.
No inbox search.
Once 300 applications are in the JSON store, finding a specific company is slower than it should be. Need an in-app filter / search.
No calendar integration.
Interview times still live in Google Calendar separately. A read-only calendar feed in the app would let the dashboard surface 'next 3 interviews' alongside the daily tasks.

Next

macOS port.
Re-implement tray, auto-launch, and a mac wallpaper transport (probably Lively or a custom Swift launcher). The Electron renderer is portable already; only the OS-touching layers need rewriting.
Local LLM fallback.
Add an Ollama-backed provider so classification can run fully offline. Pair nicely with the existing 'None' option as a no-cost middle ground.
Search + filter inside the app.
Even a SQLite index on company + status + date would make the inbox usable at >100 applications. JSON-array scans don't scale forever.