Files
Reactive-Resume/docs/superpowers/specs/2026-07-05-applications-tracker-design.md
T
Amruth Pillai b404dbd42a Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot

Add an Applications module at /dashboard/applications: pipeline board
(dnd-kit), table view with bulk actions, Insights (fit tiles, funnel,
sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and
Add/Edit/Detail slide-overs. Each application links a live Reactive
Resume.

AI "Application Copilot" (applications.ai.*): job-posting autofill,
resume↔job match score (fit ring), resume tailoring, and cover-letter /
follow-up drafting — via the user's configured provider.

Board cards + table rows get context menus (edit / move / archive /
delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox.

Also includes local TanStack devtools setup and toolchain bumps.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* feat(applications): close follow-up gaps + squash migrations

Finish the deferred/open items on the applications tracker:

- Cover-letter upload re-enabled. Fix the storage blocker by deriving the
  key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE)
  instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image
  avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add
  coverLetterUrl/coverLetterName columns + Documents-section upload/remove.
- Contacts editor in the detail sheet (add/edit/remove, keyed per app).
- Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more").
- Extract new Lingui messages across locales.
- Guard coverLetterUrl to http(s)/relative at the API boundary.

Squash the five branch-only application-table migrations (create -> +tags
-> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via
drizzle-kit generate.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* chore: update dependencies

* fix(web): address React Doctor findings — compiler, purity, query, component structure

prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces,
handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to
module scope so they aren't rebuilt on every render.

react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the
non-assignment form to unblock auto-memoization.

set-state-in-effect: derive updatedAtLabel at render time instead of syncing
it through useState + useEffect.

query-destructure-result: destructure useQuery results at call site in
resume-analysis and resume-thumbnail to follow TanStack Query v5 convention.

only-export-components: extract non-component exports to sibling .ts files so
Fast Refresh can preserve component state:
  - getNextWeights → typography/get-next-weights.ts
  - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts
  - getLocaleOptions → features/locale/locale-options.tsx
  - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts
  - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts
  - computeDelta + getSparklinePoints → statistics.utils.ts

no-multi-comp: split multi-component files into focused companions:
  - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx
  - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx
  - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx
  - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts

fix(tests): add Resume type import to section-builder mocks and cast partial
mock data as unknown as Resume to satisfy stricter type checking; fix
noExplicitAny Biome errors in the same mocks.

* feat(applications): improve performance

* chore: fix knip issues

* perf(builder): halve per-keystroke render cost

Section-form fields called `form.handleSubmit()` on every keystroke, which
re-validated the whole form and toggled submit state — firing the render
cascade twice per character (~6809 renders/keystroke, FPS dropping to 9).

Persist via a form-level `listeners.onChange` instead and drop the per-field
`handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume
subscriptions to metadata slices so they no longer re-render on content edits.

Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview,
and design controls verified working; 449/449 web tests pass.

* perf(home): eliminate hero CLS from unreserved video box

The hero <section> is `flex items-center` (shrink-to-fit), so the video
wrapper's width depended on the video's intrinsic size, which only resolves
after the media loads. aspect-ratio couldn't reserve height without a definite
width, so the video grew from ~190px to ~563px after first paint and shoved the
centered hero text down ~373px (CLS ~0.095).

Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an
explicit aspect ratio + width/height on the video so its box is reserved before
load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl.

* docs: add application tracker guides

* chore(db): squash application migrations

* fix(email): import React in auth template for server-side rendering compatibility

* chore(release): v5.2.1

* Refactor resume rendering and builder workflows

* fix: address application tracker review findings
2026-07-05 23:44:04 +02:00

9.1 KiB
Raw Blame History

Applications Tracker — Roadmap & Status

Status legend: done · 🟡 in progress · not started · 🧊 deferred (intentional, revisit when needed)

A job-application tracker built inside Reactive Resume. Each application points at the live resume the user sent (resumeId), which is why it lives in-product rather than a generic tracker. Built from the claude.ai/design prototype "Applications Tracker.dc.html".

Owner dirs: packages/db/src/schema/applications.ts, packages/schema/src/applications/, packages/api/src/features/applications/, apps/web/src/features/applications/, apps/web/src/routes/dashboard/applications/.


Phase 0 — Core slice (shipped)

The working vertical slice: data model, CRUD, board, add/detail panels. Zero new deps.

  • DB: application table (user FK cascade, resumeId FK set-null, JSONB contacts/activity, follow-up + AI-reservation columns). Migration 20260705090711_third_hercules.
  • Schema: applicationStatusSchema, STAGES (value/label/color), contactSchema, activityEventSchema, aiMetadataSchema.
  • API: oRPC applications.{list,getById,create,update,addNote,delete}protectedProcedure, userId-scoped via one requireOwned; update auto-logs stage-change activity.
  • Web: sidebar nav item; /dashboard/applications route (empty state ↔ board).
  • Board: dnd-kit drag across 6 fixed stages, optimistic move + rollback toast.
  • Add slide-over: manual entry + link a live Reactive Resume; native date follow-up.
  • Detail slide-over: key facts, stage stepper, linked resume, contacts, follow-up, activity timeline + add-note, archive/delete.
  • Unit test: activity-logging path (service.test.ts). Typecheck / boundaries / biome clean.

Known small gaps — now closed ():

  • Lingui messages extracted (pnpm --filter web lingui:extract) so the new <Trans>/t strings are translatable.
  • Board caps rendered cards per column (COLUMN_PAGE_SIZE = 50) with a "Show N more" button, so a stage with hundreds of applications doesn't mount hundreds of draggable nodes. Server-side paging still unneeded (list payload is small).
  • Contacts are editable from the Detail panel (ContactsEditor): add name/role/label, remove, persisted via API update's existing contacts field.

Phase 1 — Table view + Insights (shipped)

  • Table view — paginated table (25/page), row selection + bulk actions (move stage, add tag, archive, delete). Added a wrapped Checkbox in packages/ui; hand-rolled table (no @tanstack/react-table dep). Board/Table/Insights toggle via URL view search param.
  • Insights view — stat tiles, pipeline funnel (conversion per stage), source bars, and a shareable funnel-flow SVG with PNG export (canvas, no chart lib — kept the zero-dep property). Server aggregation applications.stats (per-stage/per-source counts); funnel/tiles derived client-side via computeInsights() (unit-tested).
  • Tags: tags text[] column + filter UI + bulk "add tag" + applications.tags distinct list.
  • Archived-toggle so archived rows can be reached and unarchived (closes the archive loop).

Phase 2 — Campaigns + import + uploads (shipped)

  • Campaigns — the campaign text field is now first-class: set on create (with a native <datalist> autocomplete of existing campaigns), filter on board/table, per-campaign Insights scoping, and an applications.campaigns distinct-with-counts endpoint. (Kept it a text field, not a table — grouping/filtering/insights work without the join-table overhead.)
  • CSV importImport CSV opens a slide-over: paste rows or upload a .csv, a zero-dep parser (csv.ts, unit-tested) maps aliased headers (Company/Role/Stage/Salary/Source/Tags/…), previews recognized columns + ready/skipped counts, then bulk-creates via applications.import (max 500, each row gets a created activity event). Verified end-to-end in the browser.
  • Cover-letter uploadcoverLetterUrl/coverLetterName columns; the detail panel's Documents section attaches a PDF via the existing orpc.storage.uploadFile, shows a download link, and supports removal.

Phase 3 — AI agent integration (shipped, verified live)

All four applications.ai.* procedures are implemented (packages/api/src/features/applications/ai.ts), resolving the user's default provider via aiProvidersService.getDefaultRunnablegetModelgenerateText, with tolerant JSON parsing. Rate-limited via aiRequestRateLimit.

  • Job-posting autofill — paste a URL → server fetches + strips the page → LLM extracts company/role/location/salary/jobDescription → prefills the Add form. (jobDescription is now a user-editable field so it persists for match/tailor.)
  • Resume↔job match score — scores the linked resume vs the job description, persists matchScore + gaps in aiMetadata. Rendered as a fit ring in the redesigned copilot.
  • Tailor resume — one-shot: duplicates the linked resume, regenerates a job-tailored summary, links the copy to the application, logs a timeline event. (No agent/REDIS dependency.)
  • Draft cover letter / follow-up — generates from application + resume context; shown inline with copy/dismiss.
  • UX: the AI section was redesigned into an "Application Copilot" module — a resume-fit ring (band-colored) as the signature element + a capability menu (icon + title + description) instead of plain buttons.

Verified live against a real OpenAI provider (autofill, match score, tailor resume).

Extra polish shipped alongside (from review + user requests)

  • Edit an application — the Add sheet became ApplicationFormSheet, edit-capable; reachable from the detail-panel "Edit" button and the context menu.
  • Context menus — kebab "⋯" on board cards (hover) and table rows: Edit / Move to ▸ / Archive / Delete (application-actions-menu.tsx).
  • Review fixes: bulk stage-moves log activity; single-statement note append; "Mark rejected" + "no results" states; CSV BOM strip + 500-row cap + file-input reset.

Cover-letter upload (re-enabled)

Un-deferred. The blocker was storage/service.ts hardcoding a .jpeg key for every upload, so a PDF was read back as an image. Fixed by deriving the key extension from the content type the router already passes (buildFileKey + EXTENSION_BY_CONTENT_TYPE) — images stay .jpeg (unchanged for avatars), PDFs get .pdf and the static handler's existing .pdf force-download path serves them correctly. Re-added coverLetterUrl/coverLetterName columns (migration 20260705125353_yielding_star_brand), DTO editable fields, and a Documents-section upload/download/remove in the detail sheet (PDF-only, best-effort storage.deleteFile on remove). Link-to-Reactive-Resume remains the primary document feature; the cover letter sits alongside it.


(historical) Phase 3 plan — reservations in place, no model wired yet

Reservations already shipped: schema fields (sourceUrl, jobDescription, matchScore, aiMetadata) and stubbed applications.ai.* procedures that throw NOT_IMPLEMENTED (packages/api/src/features/applications/ai.ts). Each stub below just needs its handler implemented against @reactive-resume/ai and the existing agent/thread tables (agentThread, agentAction in packages/db/src/schema/agent.ts).

  • Job-posting autofill (applications.ai.autofill) — fetch sourceUrl (or accept pasted text) → LLM extracts company/role/location/salary/jobDescription → return a prefill object for the Add form; persist raw extraction in aiMetadata. UI: enable the disabled "Auto-fill" button in add-application-sheet.tsx.
  • Resume ↔ job match score (applications.ai.matchScore) — compare the linked resume's data against jobDescription → write matchScore (0100) + a gap list into aiMetadata. UI: surface a score badge on the card + a gaps section in the Detail panel.
  • Tailor resume for this job (applications.ai.tailorResume) — spawn an agent thread that duplicates the linked resume and edits it for the posting (reuse the agent JSON-patch pipeline); link the new resumeId back to the application and log a timeline event.
  • Cover-letter / follow-up drafting (applications.ai.draftMessage) — generate a cover letter or recruiter follow-up from application + resume + contact context. UI: the disabled "AI actions" block in application-detail-sheet.tsx.
  • Rate-limiting & provider check — reuse aiRequestRateLimit (already exists) and gate on a configured AI provider (aiProviders table) before calling any of the above.

Suggested order: autofill (highest user value, self-contained) → match score → draft message → tailor resume (most complex, depends on the agent pipeline).


Verification (each phase)

  • pnpm --filter @reactive-resume/api test + typecheck, pnpm --filter web typecheck, pnpm exec turbo boundaries, pnpm check.
  • DB changes: dotenvx run -f .env.local -- pnpm db:generate → review SQL → pnpm db:migrate.
  • Manual: dotenvx run -f .env.local -- pnpm dev (:3000) → exercise the new surface end-to-end.