The cover-letter and MCP OAuth flow suites were gated on environment variables
CI never set, so they never ran. Point them at the job's PostgreSQL. The
cover-letter suite works in its own schema; the OAuth suite gets a database of
its own because it writes signing keys under its own secret, which the e2e
server can't decrypt.
Nothing reads the unit step's coverage report, so test:ci no longer collects it.
* feat(import): add LinkedIn data export as a resume import source
LinkedIn's "Get a copy of your data" export ships a ZIP of per-topic
CSVs (Profile, Positions, Education, Skills, Languages,
Certifications). Reading these directly gives structured data without
needing a connected AI provider, unlike the existing PDF/DOCX import
path.
Also fixes a latent bug found while building this: parseJSONResume
(and the new LinkedIn parser) built their result via a shallow spread
of the shared `defaultResumeData` singleton, so assigning into
`result.sections.x` mutated that singleton in place and leaked section
data into the next unrelated import call in the same process. Both now
start from a structuredClone.
* fix(import): escape LinkedIn text, harden zip parsing and date handling
Move escapeHtml and toHtml from the plain-text importer into html.ts and
use them for LinkedIn summary, position descriptions and education notes,
so CSV text is HTML-escaped and line breaks become paragraphs or bullet
lists instead of collapsing into one run-on paragraph.
Only an empty end date now marks an entry as ongoing. Date cells that are
not "Mon YYYY" are kept verbatim, so a finished role with an unexpected
date format no longer reads as "Present".
Unzip only the six CSVs the importer reads, matched by exact file name,
and reject any of them larger than 5 MB. This avoids inflating the rest
of a complete LinkedIn export in the browser and stops Learning_Profile.csv
being read as Profile.csv.
Map LinkedIn's five language proficiency options onto levels 5 to 1,
falling back to parseLevel for anything else. Drop the literal BOM strip,
which TextDecoder already handles.
The import dialog no longer mentions an AI provider in the loading toast
for LinkedIn imports, which are parsed entirely in the browser.
Add a regression test for the JSON Resume importer leaking section data
through the shared defaultResumeData object, plus LinkedIn tests for HTML
escaping, unrecognised end dates, BOM headers, exact file name matching,
language levels and oversized entries.
---------
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
* feat(import): parse a PDF resume without an AI provider
Importing a PDF required a connected AI provider, so anyone without a
paid API key could only import the three JSON formats. Almost nobody
arrives with one of those files; they arrive with a PDF. The first thing
a new user tries to do was blocked behind bringing their own key.
Adds a deterministic parser that reads the text out of the PDF in the
browser and prefills the builder. It pulls the contact block, segments
the body on conventional headings, and maps entries to real items,
reusing the ATS period parser for dates so a date range is not mistaken
for a phone number.
Nothing is thrown away: header parts that do not map to a field go into
the description, and unrecognized headings become custom sections. The
imported sections are placed on the page so the result renders straight
away. Output is validated against the resume schema before it is
returned.
Text extraction groups items by baseline rather than trusting hasEOL,
and turns wide column gaps into a double space, which is what lets a
row split into company, position and location.
The AI path still runs when a provider is connected. Word import is
unchanged and still requires one.
Closes#3334
* fix(import): keep every section and entry the PDF actually contains
Review found three ways the parser lost or mangled content, all of them
reproducible.
A document whose first heading was not one of the known aliases never
started a section, because unknown-heading detection was gated on a
section already being open. Everything after it was swallowed as contact
header text. The header block is now bounded by where the contact
details stop, so a heading is recognized wherever it appears.
An entry spreading company, position and dates over three lines was
imported as two malformed items. A line that introduces an entry now
merges into the open entry instead of starting a second one.
An uppercase company such as ACME CORPORATION was read as a section
heading and fragmented the entry. A heading candidate followed by a date
line is now treated as an entry header, which is what it is.
Also escape single quotes, and construct the PDF worker inside the try
so the nested worker is terminated even if construction throws.
Title-case headings are deliberately still not treated as headings:
company and school names are title case too, and splitting on them would
fragment real entries. Such a section stays in the preceding one with its
text intact rather than risking loss.
* fix(import): look past a multi-line preamble before calling a line a heading
The previous guard only inspected the next line, so an uppercase company
followed by a separate role line and then the dates was still read as a
section heading. The experience or education entry was moved into a
custom section and lost.
Heading detection now scans a two-line window for the date that marks an
entry, and stops early at a bullet so a genuine heading whose section
opens with bullet points is still recognized.
The window can suppress a real heading whose first entry puts a bare date
two lines below it. That is the deliberate direction to fail in: a missed
heading leaves the text in the preceding section, while a misread entry
fragments structured content.
* fix(import): collect an entry preamble until its dates appear
An entry that spread company, role, location and dates over four lines
was imported as two broken items: the company with no dates, and the
location carrying the period.
The cause was in entry grouping rather than heading detection. Lines
before a date were only folded into the entry header when the date sat
on the very next line; anything earlier fell through to the description.
Preamble lines are now collected into the entry header until the dates
turn up, bounded by the same lookahead and stopping at a bullet, so an
undated section cannot swallow itself.
The heading lookahead widens to four lines to match, which is the
realistic maximum for company, role, location and dates.
* fix(import): harden local PDF resume parsing
* chore(import): document audited HTML construction
---------
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
* 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
* fix(pdf): align textkit line-box and font metrics to browser behaviour
CJK characters in resumes with a tightened typography line-height
(< ~1.4) had their descenders clipped by the next line. Latin glyphs
in the same resume rendered fine. Fixes the visual regression vs the
v5.0.x Puppeteer-based renderer reported in issue #2986 and follow-ups.
The clipping is caused by two independent gaps in @react-pdf/textkit
relative to standard CSS line-box rules:
1. `height(run)` short-circuits to the user-supplied lineHeight and
ignores the run's intrinsic ascent + descent. CSS line-boxes are
spec'd as `max(line-height, content-area)` — when CJK glyphs are
present the content-area is taller than a tightened lineHeight, so
the box must grow. textkit didn't, so the baseline (computed from
the real, larger CJK ascent) sat below the box and the descender
bled into the next line.
2. `ascent / descent / lineGap` are read directly from fontkit's hhea
defaults. For Source Han Sans/Serif (the CJK fallbacks registered
in #3013) hhea is intentionally inflated for legacy Windows GDI
compatibility (1.45 em vs 1.0 em), so even a fixed line-box would
have been excessively tall. Browsers (and the v5.0.x Puppeteer
renderer) read OS/2 sTypoAscender/Descender/LineGap instead, which
are the values the type designers intend for modern shaping.
Both are upstream behaviours of `@react-pdf/textkit`, but waiting for
an upstream release would leave existing users with broken CJK output.
The fix is shipped as a pnpm patch (~30 LOC):
- `resolveTypoMetrics(font)`: prefer OS/2 typo metrics, fall back to
hhea when an OS/2 table is absent (e.g. the StandardFont stand-ins
for Helvetica/Courier/Times). Used by ascent/descent/lineGap so all
height-related calculations stay consistent.
- `height(run)`: `Math.max(lineHeight || 0, intrinsic)` instead of
the original short-circuit, matching CSS line-box rules.
The patch is self-contained: existing Latin-only resumes are
unaffected (IBM Plex Serif's typo metrics equal hhea; Roboto's typo
is slightly smaller, but only changes the rendered line-box for users
who set lineHeight below ~1.17, which already used to clip ascenders
under v5.1.x and now lays out as it would in a browser).
Tooling notes:
- `Dockerfile.dev` copies `patches/` before `pnpm install` so the
dev image build no longer fails on `--frozen-lockfile`. The
production `Dockerfile` already gets it for free via
`turbo prune --docker` (the patch reference in package.json marks
the directory as part of the pruned slice).
- The patch will become a no-op once an equivalent fix lands upstream
in @react-pdf/textkit; the entry can then be removed from
`pnpm.patchedDependencies` and the file deleted.
* fix(deps): regenerate lockfile and move patchedDependencies for pnpm 11
The previous commit's lockfile was authored by pnpm 8 (lockfileVersion 6.0)
and kept patchedDependencies under package.json#pnpm. The repository now
declares packageManager: pnpm@11.1.2, which:
- writes lockfileVersion 9.0 and rejects v6 with ERR_PNPM_LOCKFILE_BREAKING_CHANGE
on --frozen-lockfile (CI failure observed in autofix.ci);
- reads pnpm settings from pnpm-workspace.yaml, silently ignoring the
package.json#pnpm field — so the textkit patch was no longer applied.
Regenerate pnpm-lock.yaml with pnpm 11.1.2 and move patchedDependencies
to pnpm-workspace.yaml so the patch is applied and CI passes.
* chore: update dependencies
---------
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>