diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index caf135812..b895d77b5 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -24,6 +24,8 @@ export type Resume = { hasPassword?: boolean; isPublic?: boolean; showDownloadButtons?: boolean; + /** The job application this resume was made for; Check's Job match reads its posting. */ + applicationId?: string | null; }; // Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts). diff --git a/apps/web/src/features/resume/editor/check/actions.ts b/apps/web/src/features/resume/editor/check/actions.ts new file mode 100644 index 000000000..dfc1e3a7d --- /dev/null +++ b/apps/web/src/features/resume/editor/check/actions.ts @@ -0,0 +1,80 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { CheckIssue } from "./issues"; +import { t } from "@lingui/core/macro"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; +import { useResumeStore } from "@/features/resume/builder/draft"; +import { useEditorStore } from "../store"; +import { useEditorMode } from "../use-editor-mode"; +import { getScrollBehavior, revealSelectionInPanel } from "../write/reveal"; + +/** The author's Check choices on a draft, created on first use. */ +export function checkStateOf(draft: ResumeData) { + draft.metadata.check ??= { ignored: [], hiddenTerms: [] }; + return draft.metadata.check; +} + +/** One undo step, confirmed in a toast whose Undo takes it back. */ +export function editWithUndo(edit: (draft: ResumeData) => void, message: string) { + const store = useResumeStore.getState(); + store.updateResumeData(edit, { newStep: true }); + toast.add({ + description: message, + actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() }, + }); +} + +/** Scrolls the page to an issue's pin, or the panel to its card. */ +export function scrollToIssue(key: string, where: "page" | "panel") { + requestAnimationFrame(() => { + const selector = + where === "page" ? `[data-issue-pin="${CSS.escape(key)}"]` : `[data-issue-card="${CSS.escape(key)}"]`; + document.querySelector(selector)?.scrollIntoView({ block: "center", behavior: getScrollBehavior() }); + }); +} + +/** What a Check card can do: fix, open the field in Write, show its line on the page, or set it aside. */ +export function useCheckActions() { + const [, setMode] = useEditorMode(); + const breakpoint = useBreakpoint(); + + const showOnPage = (issue: CheckIssue) => { + const editor = useEditorStore.getState(); + editor.setCheckIssue(issue.key); + editor.setPageView("page"); + // The page is behind the drawer on tablets and in its own view on phones. + if (breakpoint === "tablet") editor.setDrawerOpen(false); + if (breakpoint === "mobile") editor.setMobileView("page"); + scrollToIssue(issue.key, "page"); + }; + + const fix = (issue: CheckIssue) => { + if (issue.fix.kind === "apply") { + editWithUndo(issue.fix.apply, t`${issue.fix.done}. Issue resolved.`); + useEditorStore.getState().setCheckIssue(null); + return; + } + + setMode("write"); + if (breakpoint === "mobile") useEditorStore.getState().setMobileView("write"); + revealSelectionInPanel(issue.fix.target); + }; + + const ignore = (issue: CheckIssue) => { + editWithUndo( + (draft) => { + const state = checkStateOf(draft); + if (!state.ignored.includes(issue.key)) state.ignored.push(issue.key); + }, + issue.keepLabel ? t`Kept as it is` : t`Issue ignored`, + ); + useEditorStore.getState().setCheckIssue(null); + }; + + const restoreIgnored = () => + editWithUndo((draft) => { + checkStateOf(draft).ignored = []; + }, t`Ignored issues are back`); + + return { showOnPage, fix, ignore, restoreIgnored }; +} diff --git a/apps/web/src/features/resume/editor/check/check-panel.tsx b/apps/web/src/features/resume/editor/check/check-panel.tsx new file mode 100644 index 000000000..4ce95c7e6 --- /dev/null +++ b/apps/web/src/features/resume/editor/check/check-panel.tsx @@ -0,0 +1,379 @@ +import type { AtsCategory, AtsReport } from "@reactive-resume/resume/ats"; +import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { CSSProperties } from "react"; +import type { CheckTab } from "../store"; +import type { CheckIssue } from "./issues"; +import type { CheckResult } from "./use-check"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { useState } from "react"; +import { ATS_CATEGORIES } from "@reactive-resume/resume/ats"; +import { Button } from "@reactive-resume/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@reactive-resume/ui/components/dialog"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { Spinner } from "@reactive-resume/ui/components/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { cn } from "@reactive-resume/utils/style"; +import { AtsPdfReportView } from "@/features/ats-checker/report/report-view"; +import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check"; +import { useIsResumeLocked } from "@/features/resume/builder/draft"; +import { createResumePdfBlob } from "@/features/resume/export/pdf-document"; +import { useEditorStore } from "../store"; +import { useCheckActions } from "./actions"; +import { getCategoryDescription, getCategoryName } from "./issues"; +import { JobMatchTab, useJobMatch } from "./job-match"; +import { useCheck } from "./use-check"; +import { WritingTab } from "./writing-tab"; + +/** + * Check: the score over the live checks, then Issues (numbered cards pinned to their lines), Job match (terms from + * the linked application's posting, not scored) and Writing (an opt-in AI review, not scored). + */ +export function CheckPanel() { + const check = useCheck(); + const tab = useEditorStore((state) => state.checkTab); + const setTab = useEditorStore((state) => state.setCheckTab); + const match = useJobMatch(check?.data); + + if (!check) return null; + + return ( +
+ + + setTab(value as CheckTab)} className="gap-3.5"> + + + Issues + {check.issues.length} + + + Job match + {match.result && ( + + {match.result.found.length}/{match.result.total} + + )} + + + Writing + + + + + + + + + + + + + +
+ ); +} + +const getVerdict = (score: number) => + score >= 80 ? t`Reads cleanly` : score >= 50 ? t`Mostly readable` : t`Hard for software to read`; + +/** The 84px ring (accent, or warn below 80), the verdict and what the score counts. */ +function ScoreSummary({ report }: { report: AtsReport }) { + const { score, passedRules, totalRules } = report; + const open = report.findings.length; + + return ( +
+
= 80 ? "var(--accent)" : "var(--warn)", + } as CSSProperties + } + > + + {score} + +
+ +
+ {getVerdict(score)} + + + {passedRules} of {totalRules} checks pass. + {" "} + {open > 0 ? ( + + ) : ( + Nothing to review. + )} + + + + Live · updates as you edit + +
+
+ ); +} + +function IssuesTab({ check }: { check: CheckResult }) { + const { report, issues, data } = check; + const selected = useEditorStore((state) => state.checkIssue); + const { restoreIgnored } = useCheckActions(); + const locked = useIsResumeLocked(); + + return ( +
+ {issues.length === 0 ? ( +
+ +

+ + Nothing to fix. Every check passes. + +

+
+ ) : ( +
    + {issues.map((issue) => ( + + ))} +
+ )} + + + +

+ + The score counts how reliably software reads your resume. It doesn't predict whether you'll be shortlisted. + {" "} + {report.ignored.length > 0 && ( + <> + {" "} + + + )} +

+ + +
+ ); +} + +type IssueCardProps = { issue: CheckIssue; selected: boolean; locked: boolean }; + +/** A numbered card: category, title, a plain explanation, the fix, Show on page and Ignore (or Keep). */ +function IssueCard({ issue, selected, locked }: IssueCardProps) { + const { fix, ignore, showOnPage } = useCheckActions(); + const setCheckIssue = useEditorStore((state) => state.setCheckIssue); + const titleId = `issue-${issue.number}-title`; + const applies = issue.fix.kind === "apply"; + + return ( + // Clicking a card picks it, outlining its line on the page; Show on page does the same from the keyboard. + // biome-ignore lint/a11y/useKeyWithClickEvents: the buttons inside cover the keyboard. +
  • setCheckIssue(issue.key)} + className={cn( + "grid cursor-pointer gap-2.5 rounded-xl border bg-surface p-3.5 transition-[border-color,box-shadow] duration-standard", + selected ? "border-warn shadow-e2" : "border-line", + )} + > +
    + +
    + {getCategoryName(issue.category)} +

    + + Issue {issue.number}:{" "} + + {issue.title} +

    +

    {issue.body}

    +
    +
    + + {/* Touch size on phones (B1). */} +
    + + {issue.target && ( + + )} + +
    +
  • + ); +} + +/** Contact details, Dates, Layout, Section headings, Writing: "n of m", open when something needs attention. */ +function CategoryRows({ report }: { report: AtsReport }) { + const [toggled, setToggled] = useState>>({}); + const categories = ATS_CATEGORIES.filter((category) => report.categories[category].total > 0); + + return ( +
    +

    + Checks by category +

    +
    + {categories.map((category, index) => { + const { total, passed } = report.categories[category]; + const failing = total - passed; + const open = toggled[category] ?? failing > 0; + const descriptionId = `check-category-${category}`; + + return ( +
    0 && "border-line border-t")}> + + +
    + ); + })} +
    +
    + ); +} + +/** + * Also check the exported PDF: renders it, runs the file-level engine on it in this tab and reports in a toast. + * Its full report opens from the toast. + */ +function DeepCheck({ data }: { data: ResumeData }) { + const [running, setRunning] = useState(false); + const [report, setReport] = useState(null); + + const run = async () => { + setRunning(true); + try { + const blob = await createResumePdfBlob(data); + const result = await runAtsCheck(blobToPdfFile(blob, "resume.pdf")); + const problems = result.report.findings.length; + + toast.add( + problems === 0 + ? { description: t`Exported PDF checked: it reads cleanly too.` } + : { + description: t`Exported PDF checked: ${problems} more to look at.`, + actionProps: { children: t`Show`, onClick: () => setReport(result.report) }, + }, + ); + } catch { + toast.add({ type: "error", description: t`The exported PDF couldn't be checked. Try again.` }); + } finally { + setRunning(false); + } + }; + + return ( + <> + + + !open && setReport(null)}> + + + + The exported PDF + + + + What the file-level check found in the PDF you'd send. It ran in this tab; nothing was uploaded. + + + + {report && } + + + + ); +} diff --git a/apps/web/src/features/resume/editor/check/issues.test.ts b/apps/web/src/features/resume/editor/check/issues.test.ts new file mode 100644 index 000000000..47ff4a52c --- /dev/null +++ b/apps/web/src/features/resume/editor/check/issues.test.ts @@ -0,0 +1,126 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { CheckIssue } from "./issues"; +import { beforeAll, describe, expect, it } from "vitest"; +import { i18n } from "@lingui/core"; +import { produce } from "immer"; +import { lintResumeForAts } from "@reactive-resume/resume/ats"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { buildIssues } from "./issues"; + +const NOW = new Date("2026-09-28T00:00:00Z"); + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en-US", messages: {} }); +}); + +function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData { + return produce(defaultResumeData, (data) => { + data.basics.name = "Jordan Reyes"; + data.basics.email = "jordan@reyes.design"; + data.basics.phone = "+49 151 2345 6789"; + data.basics.location = "Berlin"; + data.sections.experience.items = [ + { + id: "kettle", + hidden: false, + company: "Studio Kettle", + position: "Junior Designer", + location: "Lisbon", + period: "2016 - 2019", + website: { url: "", label: "", inlineLink: false }, + description: "

    Designed identities for small businesses.

    ", + roles: [], + } as ResumeData["sections"]["experience"]["items"][number], + ]; + data.metadata.template = "onyx"; + data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }]; + mutate(data); + }); +} + +const issuesOf = (data: ResumeData) => buildIssues(lintResumeForAts(data, { now: NOW }), data); + +const only = (data: ResumeData, code: string): CheckIssue => { + const issue = issuesOf(data).find((entry) => entry.finding.code === code); + if (!issue) throw new Error(`No ${code} issue`); + return issue; +}; + +/** Applies an issue's one-step fix to the data and returns the result. */ +function applyFix(data: ResumeData, issue: CheckIssue) { + if (issue.fix.kind !== "apply") throw new Error(`${issue.finding.code} has no one-step fix`); + const { apply } = issue.fix; + return produce(data, (draft) => { + apply(draft); + }); +} + +describe("buildIssues", () => { + it("numbers open issues in the report's order and places each on its block", () => { + const data = makeResume((resume) => { + resume.basics.email = ""; + const entry = resume.sections.experience.items[0]; + if (entry) entry.period = "a while back"; + }); + const issues = issuesOf(data); + + expect(issues.map((issue) => [issue.number, issue.finding.code])).toEqual([ + [1, "MISSING_EMAIL"], + [2, "UNPARSEABLE_PERIOD"], + ]); + expect(issues[0]?.target).toEqual({ kind: "header" }); + expect(issues[1]?.target).toEqual({ kind: "item", sectionId: "experience", itemId: "kettle" }); + expect(issues[1]?.category).toBe("dates"); + expect(issues[1]?.fix).toMatchObject({ kind: "write", target: issues[1]?.target }); + }); + + it("adds https:// to a link in one step, and sends links with another scheme to Write", () => { + const bare = makeResume((resume) => (resume.basics.website.url = "reyes.design")); + const fixed = applyFix(bare, only(bare, "MALFORMED_URL")); + expect(fixed.basics.website.url).toBe("https://reyes.design"); + expect(issuesOf(fixed).map((issue) => issue.finding.code)).not.toContain("MALFORMED_URL"); + + const odd = makeResume((resume) => (resume.basics.website.url = "ftp:/reyes.design")); + expect(only(odd, "MALFORMED_URL").fix.kind).toBe("write"); + }); + + it("switches a two-column page to one column without losing its sidebar sections", () => { + const data = makeResume((resume) => { + resume.metadata.template = "azurill"; + resume.sections.skills.items = [ + { id: "s1", hidden: false, icon: "", iconColor: "", name: "Figma", proficiency: "", level: 0, keywords: [] }, + ]; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }]; + }); + const issue = only(data, "TWO_COLUMN_LAYOUT"); + expect(issue.keepLabel).toBe(true); + expect(issue.target).toBeNull(); + + const fixed = applyFix(data, issue); + expect(fixed.metadata.layout.pages).toEqual([{ fullWidth: true, main: ["experience", "skills"], sidebar: [] }]); + expect(issuesOf(fixed)).toEqual([]); + }); + + it("moves a prose section out of the sidebar, and resets an unusual heading", () => { + const data = makeResume((resume) => { + resume.sections.experience.title = "Where I've Been"; + resume.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: ["experience"] }]; + }); + + const moved = applyFix(data, only(data, "PROSE_SECTION_IN_SIDEBAR")); + expect(moved.metadata.layout.pages[0]).toMatchObject({ main: ["experience"], sidebar: [] }); + + const renamed = applyFix(data, only(data, "NON_STANDARD_SECTION_TITLE")); + expect(renamed.sections.experience.title).toBe(""); + }); + + it("sets the minimum for type, spacing and margins", () => { + const data = makeResume((resume) => { + resume.metadata.typography.body.fontSize = 8; + resume.metadata.page.marginY = 4; + }); + + expect(applyFix(data, only(data, "SMALL_BODY_FONT")).metadata.typography.body.fontSize).toBe(9); + expect(applyFix(data, only(data, "TIGHT_PAGE_MARGINS")).metadata.page.marginY).toBe(8); + }); +}); diff --git a/apps/web/src/features/resume/editor/check/issues.ts b/apps/web/src/features/resume/editor/check/issues.ts new file mode 100644 index 000000000..f87971fda --- /dev/null +++ b/apps/web/src/features/resume/editor/check/issues.ts @@ -0,0 +1,377 @@ +import type { PageMapTarget } from "@reactive-resume/pdf/page-map"; +import type { AtsCategory, AtsFinding, AtsReport } from "@reactive-resume/resume/ats"; +import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; +import type { IconName } from "@reactive-resume/ui/components/icon"; +import { t } from "@lingui/core/macro"; +import { atsRuleCategory } from "@reactive-resume/resume/ats"; +import { getSectionTitle } from "@/libs/resume/section"; +import { describeEntry, findEntry, getSectionObject, resolveSection } from "../write/model"; + +/** How a card fixes its issue: in one step on the resume (with undo), or by opening the entry in Write. */ +type IssueFix = + | { kind: "apply"; label: string; icon: IconName; done: string; apply: (draft: ResumeData) => void } + | { kind: "write"; label: string; target: PageMapTarget }; + +export type CheckIssue = { + key: string; + /** 1-based, in the order the cards list them; the pin on the page carries the same number. */ + number: number; + finding: AtsFinding; + category: AtsCategory; + title: string; + body: string; + fix: IssueFix; + /** "Keep" instead of "Ignore": the issue follows from a design choice that is the author's to make. */ + keepLabel: boolean; + /** The block on the page the issue is about, for its pin; null for page-wide settings. */ + target: PageMapTarget | null; +}; + +export const getCategoryName = (category: AtsCategory) => + ({ + contact: t`Contact details`, + dates: t`Dates`, + layout: t`Layout`, + headings: t`Section headings`, + writing: t`Writing`, + })[category]; + +export const getCategoryDescription = (category: AtsCategory) => + ({ + contact: t`Name, email, phone and location are found, and links are full addresses.`, + dates: t`Every entry has dates in a form systems read, in order, and none in the future.`, + layout: t`Text comes out in the order a person reads it, at a size and spacing that survive.`, + headings: t`Sections use headings systems recognise, and work experience shows.`, + writing: t`Every role says what you did. Opinions on wording live in the Writing tab.`, + })[category]; + +const decodeToken = (token: string) => token.replace(/~1/g, "/").replace(/~0/g, "~"); + +const tokensOf = (pointer: string) => pointer.split("/").slice(1).map(decodeToken); + +/** The section a pointer is in: the summary, a built-in section's key or a custom section's id. */ +function sectionIdOf(tokens: readonly string[], data: ResumeData): string | undefined { + const [head, next] = tokens; + if (head === "summary") return "summary"; + if (head === "sections") return next; + if (head === "customSections") return data.customSections[Number(next)]?.id; + return undefined; +} + +function itemIdOf(tokens: readonly string[], data: ResumeData): string | undefined { + const itemsIndex = tokens.indexOf("items"); + if (itemsIndex === -1) return undefined; + + let node: unknown = data; + for (const token of tokens.slice(0, itemsIndex + 2)) node = (node as Record | undefined)?.[token]; + + const id = (node as { id?: unknown } | undefined)?.id; + return typeof id === "string" ? id : undefined; +} + +/** The block a finding is about: the header for contact details, else its section or entry. */ +function getFindingTarget(finding: AtsFinding, data: ResumeData): PageMapTarget | null { + const tokens = tokensOf(finding.pointer); + if (tokens[0] === "basics" || tokens[0] === "picture") return { kind: "header" }; + + const sectionId = sectionIdOf(tokens, data); + if (!sectionId) return null; + + const itemId = itemIdOf(tokens, data); + return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId }; +} + +/** The printed name of a section: the title the author gave it, else its type's name. */ +export function getSectionName(data: ResumeData, sectionId: string): string { + const section = resolveSection(data, sectionId); + if (!section) return sectionId; + + const title = getSectionObject(data, section)?.title.trim(); + return title || getSectionTitle(section.kind === "custom" ? section.type : (section.id as SectionType | "summary")); +} + +function getEntryName(data: ResumeData, target: PageMapTarget | null): string { + if (target?.kind !== "item") return t`This entry`; + + const section = resolveSection(data, target.sectionId); + const entry = findEntry(data, target.sectionId, target.itemId); + const title = section && entry ? describeEntry(section.type, entry).title : ""; + return title ? `“${title}”` : t`This entry`; +} + +/** Sets the value a finding's pointer names (the pointer still has array indexes, which match `draft`). */ +function setAt(draft: ResumeData, pointer: string, value: unknown) { + const tokens = tokensOf(pointer); + const last = tokens.pop(); + let node: unknown = draft; + for (const token of tokens) node = (node as Record | undefined)?.[token]; + if (node && last !== undefined) (node as Record)[last] = value; +} + +const HAS_SCHEME = /^[a-z][a-z\d+.-]*:/i; + +type Message = { title: string; body: string; fix: IssueFix; keepLabel?: boolean }; + +function describeFinding(finding: AtsFinding, data: ResumeData, target: PageMapTarget | null): Message { + const value = String(finding.params?.value ?? ""); + const section = String(finding.params?.section ?? ""); + const minimum = Number(finding.params?.minimum ?? 0); + const write = (label: string): IssueFix => ({ kind: "write", label, target: target ?? { kind: "header" } }); + const entry = getEntryName(data, target); + + switch (finding.code) { + case "MISSING_NAME": + return { + title: t`Your name is missing`, + body: t`Systems file you under the name at the top of the page.`, + fix: write(t`Add your name`), + }; + case "MISSING_EMAIL": + return { + title: t`No email address`, + body: t`Most systems file candidates by email address.`, + fix: write(t`Add an email`), + }; + case "MALFORMED_EMAIL": + return { + title: t`Email address won't be recognised`, + body: t`“${value}” isn't a plain address like name@example.com.`, + fix: write(t`Edit the email`), + }; + case "MISSING_PHONE": + return { + title: t`No phone number`, + body: t`Some application systems ask for one before you can apply.`, + fix: write(t`Add a phone number`), + }; + case "MISSING_LOCATION": + return { + title: t`No location`, + body: t`A city and country let roles be matched to where you are.`, + fix: write(t`Add a location`), + }; + case "MALFORMED_URL": { + const url = `https://${value}`; + return { + title: t`A link isn't a full address`, + body: t`Some systems only recognise links that start with https://. This one reads as plain text: “${value}”.`, + fix: HAS_SCHEME.test(value) + ? write(t`Edit the link`) + : { + kind: "apply", + label: t`Use ${url}`, + icon: "link", + done: t`Link updated`, + apply: (draft) => setAt(draft, finding.pointer, url), + }, + }; + } + case "PICTURE_PRESENT": + return { + title: t`Your resume has a photo`, + body: t`Some systems mishandle images, and photos are discouraged in some countries.`, + fix: { + kind: "apply", + label: t`Hide the photo`, + icon: "visibility_off", + done: t`Photo hidden`, + apply: (draft) => { + draft.picture.hidden = true; + }, + }, + }; + case "EMPTY_PERIOD": + return { + title: t`An entry has no dates`, + body: t`${entry} doesn't say when. Dates place it on your timeline.`, + fix: write(t`Add dates`), + }; + case "UNPARSEABLE_PERIOD": + return { + title: t`Dates won't be read`, + body: t`“${value}” isn't a form systems recognise, such as Mar 2020 – Present.`, + fix: write(t`Fix the dates`), + }; + case "UNPARSEABLE_DATE": + return { + title: t`A date won't be read`, + body: t`“${value}” isn't a form systems recognise, such as March 2022.`, + fix: write(t`Fix the date`), + }; + case "REVERSED_PERIOD": + return { + title: t`Dates run backwards`, + body: t`“${value}” ends before it starts.`, + fix: write(t`Fix the dates`), + }; + case "FUTURE_DATED_PERIOD": + return { + title: t`Dates start in the future`, + body: t`“${value}” starts after today. Check the year, or mark it as current.`, + fix: write(t`Fix the dates`), + }; + case "SECTION_MISSING_FROM_LAYOUT": { + const name = getSectionName(data, section); + return { + title: t`${name} never prints`, + body: t`It has entries but isn't placed on a page.`, + fix: { + kind: "apply", + label: t`Add it to page 1`, + icon: "add", + done: t`${name} added to page 1`, + apply: (draft) => { + const [page] = draft.metadata.layout.pages; + if (page) page.main.push(section); + else draft.metadata.layout.pages.push({ fullWidth: false, main: [section], sidebar: [] }); + }, + }, + }; + } + case "NO_VISIBLE_EXPERIENCE": + return { + title: t`No work experience shows`, + body: t`Systems look for an experience section. Projects or volunteering can show the same history.`, + fix: { kind: "write", label: t`Add experience`, target: { kind: "section", sectionId: "experience" } }, + }; + case "MISSING_EXPERIENCE_DESCRIPTION": + return { + title: t`A role has no description`, + body: t`${entry} names the role but not what you did, so it adds no keywords.`, + fix: write(t`Describe the role`), + }; + case "NON_STANDARD_SECTION_TITLE": { + const title = String(finding.params?.title ?? ""); + return { + title: t`“${title}” isn't a heading systems look for`, + body: t`They look for headings such as Experience, Education and Skills.`, + fix: { + kind: "apply", + label: t`Use the standard heading`, + icon: "title", + done: t`Heading changed`, + // An empty title prints the section type's name in the resume's language. + apply: (draft) => setAt(draft, finding.pointer, ""), + }, + }; + } + case "MULTI_COLUMN_PROSE_SECTION": { + const name = getSectionName(data, section); + const columns = Number(finding.params?.columns ?? 2); + return { + title: t`${name} is split into ${columns} columns`, + body: t`Columns can scramble the order text is read in.`, + fix: { + kind: "apply", + label: t`Use one column`, + icon: "view_list", + done: t`${name} uses one column`, + apply: (draft) => setAt(draft, finding.pointer, 1), + }, + }; + } + case "PROSE_SECTION_IN_SIDEBAR": { + const name = getSectionName(data, section); + return { + title: t`${name} sits in the sidebar`, + body: t`Sidebars are often read last, or mixed into the main column.`, + fix: { + kind: "apply", + label: t`Move it to the main column`, + icon: "arrow_forward", + done: t`${name} moved to the main column`, + apply: (draft) => { + for (const page of draft.metadata.layout.pages) { + if (!page.sidebar.includes(section)) continue; + page.sidebar = page.sidebar.filter((id) => id !== section); + page.main.push(section); + } + }, + }, + }; + } + case "TWO_COLUMN_LAYOUT": { + const names = String(finding.params?.sections ?? "") + .split(",") + .filter(Boolean) + .map((id) => getSectionName(data, id)) + .join(", "); + return { + title: t`Sidebar is read after the main column`, + body: t`${names} come last in the text. Most systems still find them; some don't.`, + keepLabel: true, + fix: { + kind: "apply", + label: t`Switch to one column`, + icon: "view_list", + done: t`Switched to one column`, + // Full-width pages print no sidebar, so its sections move into the main column first. + apply: (draft) => { + for (const page of draft.metadata.layout.pages) { + if (page.fullWidth || page.sidebar.length === 0) continue; + page.main.push(...page.sidebar); + page.sidebar = []; + page.fullWidth = true; + } + }, + }, + }; + } + case "SMALL_BODY_FONT": + return { + title: t`Body text is very small`, + body: t`Text under ${minimum} pt can be misread when a system re-renders it.`, + fix: { + kind: "apply", + label: t`Use ${minimum} pt`, + icon: "format_size", + done: t`Body text set to ${minimum} pt`, + apply: (draft) => setAt(draft, finding.pointer, minimum), + }, + }; + case "TIGHT_LINE_HEIGHT": + return { + title: t`Lines are packed tightly`, + body: t`Lines this close can run together when read. Use at least ${minimum}.`, + fix: { + kind: "apply", + label: t`Use ${minimum}`, + icon: "tune", + done: t`Line height set to ${minimum}`, + apply: (draft) => setAt(draft, finding.pointer, minimum), + }, + }; + case "TIGHT_PAGE_MARGINS": + return { + title: t`Margins are very narrow`, + body: t`Text this close to the edge can be cut off. Use at least ${minimum} pt.`, + fix: { + kind: "apply", + label: t`Use ${minimum} pt margins`, + icon: "tune", + done: t`Margins set to ${minimum} pt`, + apply: (draft) => setAt(draft, finding.pointer, minimum), + }, + }; + } +} + +/** The open findings as numbered cards, in the report's order (most severe first). */ +export function buildIssues(report: AtsReport, data: ResumeData): CheckIssue[] { + return report.findings.map((finding, index) => { + const target = getFindingTarget(finding, data); + const message = describeFinding(finding, data, target); + + return { + key: finding.key, + number: index + 1, + finding, + category: atsRuleCategory(finding.code), + title: message.title, + body: message.body, + fix: message.fix, + keepLabel: message.keepLabel ?? false, + target, + }; + }); +} diff --git a/apps/web/src/features/resume/editor/check/job-match.tsx b/apps/web/src/features/resume/editor/check/job-match.tsx new file mode 100644 index 000000000..382210fb7 --- /dev/null +++ b/apps/web/src/features/resume/editor/check/job-match.tsx @@ -0,0 +1,554 @@ +import type { JdTermMatch } from "@reactive-resume/resume/ats-pdf"; +import type { ResumeData, SkillItem } from "@reactive-resume/schema/resume/data"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useMemo, useState } from "react"; +import { buildMarkdown } from "@reactive-resume/resume/markdown"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { Input } from "@reactive-resume/ui/components/input"; +import { Label } from "@reactive-resume/ui/components/label"; +import { NativeSelect } from "@reactive-resume/ui/components/native-select"; +import { Textarea } from "@reactive-resume/ui/components/textarea"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { generateId } from "@reactive-resume/utils/string"; +import { cn } from "@reactive-resume/utils/style"; +import { applicationsListQueryKey, applicationsListQueryOptions } from "@/features/applications/queries"; +import { useCurrentBuilderResumeSelector, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft"; +import { getOrpcErrorMessage } from "@/libs/error-message"; +import { orpc } from "@/libs/orpc/client"; +import { useEditorStore } from "../store"; +import { checkStateOf, editWithUndo } from "./actions"; + +/** Matches the applications feature's cap on a saved posting. */ +const MAX_POSTING_CHARS = 20_000; + +/** A posting term, with `label` as the posting writes it ("C#" rather than the matcher's "csharp"). */ +type MatchedTerm = JdTermMatch & { label: string }; + +type JobMatchResult = { found: MatchedTerm[]; missing: MatchedTerm[]; total: number }; + +export type JobMatch = { + /** The application the resume was made for, once the list has loaded. */ + application: { id: string; company: string; role: string; jobDescription: string | null } | null; + /** The linked application's posting, or the one pasted this visit when none is linked. */ + posting: string; + result: JobMatchResult | null; + hiddenTerms: readonly string[]; +}; + +/** + * Job match reads the posting of the application the resume is linked to, or one pasted for this visit, and sorts + * its terms into found and missing. Terms hidden as "not true for me" are left out. It isn't part of the score. + */ +export function useJobMatch(data: ResumeData | undefined): JobMatch { + const applicationId = useCurrentBuilderResumeSelector((resume) => resume.applicationId ?? null); + const pasted = useEditorStore((state) => state.pastedPosting); + const { data: applications } = useQuery(applicationsListQueryOptions()); + const application = applications?.find((entry) => entry.id === applicationId) ?? null; + const posting = application ? (application.jobDescription ?? "").trim() : pasted.trim(); + + // The matcher (stemmer and skill aliases) loads only once there's a posting to match. + const { data: engine } = useQuery({ + queryKey: ["ats-pdf-engine"], + queryFn: () => import("@reactive-resume/resume/ats-pdf"), + staleTime: Number.POSITIVE_INFINITY, + enabled: posting.length > 0, + }); + + const hiddenTerms = data?.metadata.check?.hiddenTerms; + const resumeText = useMemo(() => (data ? buildMarkdown(data) : ""), [data]); + + const result = useMemo(() => { + if (!engine || !posting) return null; + const hidden = new Set(hiddenTerms ?? []); + const terms = engine + .matchJobDescription({ jobDescription: posting, resumeText }) + .terms.filter((term) => !hidden.has(term.term)) + .map((term) => ({ ...term, label: termAsWritten(engine.surfaceFormsOf(term.term), posting) ?? term.term })); + + return { + found: terms.filter((term) => term.resumeCount > 0), + missing: terms.filter((term) => term.resumeCount === 0), + total: terms.length, + }; + }, [engine, posting, resumeText, hiddenTerms]); + + return { application, posting, result, hiddenTerms: hiddenTerms ?? [] }; +} + +/** The first spelling of a term the posting uses, as it writes it ("Figma", not "figma"). */ +function termAsWritten(forms: readonly string[], posting: string): string | undefined { + for (const form of forms) { + const escaped = form.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const found = posting.match(new RegExp(escaped, "i"))?.[0]; + if (found) return found; + } +} + +type JobMatchTabProps = { match: JobMatch; data: ResumeData }; + +export function JobMatchTab({ match, data }: JobMatchTabProps) { + const { application, posting, result, hiddenTerms } = match; + const [openTerm, setOpenTerm] = useState(null); + const highlightTerm = useEditorStore((state) => state.highlightTerm); + const setHighlightTerm = useEditorStore((state) => state.setHighlightTerm); + const locked = useIsResumeLocked(); + + if (!posting) return ; + + const open = result?.missing.find((term) => term.term === openTerm); + const skills = data.sections.skills.items.find((item) => !item.hidden); + + const addToSkills = (term: MatchedTerm) => { + const written = term.label; + editWithUndo((draft) => { + const target = draft.sections.skills.items.find((item) => item.id === skills?.id); + if (target) target.keywords.push(written); + else draft.sections.skills.items.push(newSkill(written)); + }, t`“${written}” added to Skills`); + setOpenTerm(null); + setHighlightTerm(term.term); + }; + + const hideTerm = (term: MatchedTerm) => { + editWithUndo((draft) => { + const state = checkStateOf(draft); + if (!state.hiddenTerms.includes(term.term)) state.hiddenTerms.push(term.term); + }, t`“${term.label}” hidden`); + setOpenTerm(null); + }; + + return ( +
    + + + {!result ? ( +

    + Reading the posting… +

    + ) : result.total === 0 ? ( +

    + No terms stood out in this posting. +

    + ) : ( + <> +
    +
    + + + {result.found.length} of {result.total} posting terms appear + + + + not scored + +
    +
    +
    +
    +
    + + {result.missing.length > 0 && ( +
    +

    + Not in your resume · add only if true +

    +
    + {result.missing.map((term) => ( + + ))} +
    + + {open && ( +
    +

    + {" "} + If it's part of your experience, add it where it belongs. +

    +
    + + +
    +
    + )} +
    + )} + + {result.found.length > 0 && ( +
    +

    + Already covered · pick one to find it on the page +

    +
    + {result.found.map((term) => { + const on = highlightTerm === term.term; + return ( + + ); + })} +
    +
    + )} + + )} + + {hiddenTerms.length > 0 && ( +

    + Hidden: {hiddenTerms.join(", ")} ·{" "} + +

    + )} +
    + ); +} + +const newSkill = (name: string): SkillItem => ({ + id: generateId(), + hidden: false, + icon: "", + iconColor: "", + name, + proficiency: "", + level: 0, + keywords: [], +}); + +function useLinkApplication() { + const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id); + const patchResume = usePatchResume(); + const { mutateAsync, isPending } = useMutation(orpc.documents.linkApplication.mutationOptions()); + + const link = async (applicationId: string | null) => { + try { + await mutateAsync({ type: "resume", id: resumeId, applicationId }); + patchResume((resume) => { + resume.applicationId = applicationId; + }); + } catch (error) { + toast.add({ + type: "error", + description: getOrpcErrorMessage(error, { fallback: t`Couldn't link the application.` }), + }); + } + }; + + return { link, isPending }; +} + +/** Where the posting comes from, with Change (link another application or unlink) or Save as application. */ +function PostingSource({ application }: { application: JobMatch["application"] }) { + const [changing, setChanging] = useState(false); + const setPastedPosting = useEditorStore((state) => state.setPastedPosting); + const { link } = useLinkApplication(); + + if (!application) { + return ( +
    +
    + + + + + + Pasted posting + + + For this visit only + + + +
    + +
    + ); + } + + return ( +
    +
    + + {application.company.slice(0, 1).toUpperCase()} + + + + {application.role} · {application.company} + + + Posting from the linked application + + + +
    + {changing && ( +
    + { + setChanging(false); + void link(id); + }} + /> + +
    + )} +
    + ); +} + +function ApplicationPicker({ value, onChange }: { value: string | null; onChange: (id: string) => void }) { + const { data: applications } = useQuery(applicationsListQueryOptions()); + const choices = (applications ?? []).filter((application) => !application.archived); + // Linking changes the document's details, which a locked document keeps as they are. + const locked = useIsResumeLocked(); + + return ( + event.target.value && onChange(event.target.value)} + > + + {choices.map((application) => ( + + ))} + + ); +} + +/** + * C2: nothing to match against. Link an existing application first; pasting a posting is the fallback. A linked + * application without a saved posting takes one here, saved to the application. + */ +function NoPosting({ application }: { application: JobMatch["application"] }) { + const pasted = useEditorStore((state) => state.pastedPosting); + const setPastedPosting = useEditorStore((state) => state.setPastedPosting); + const [draft, setDraft] = useState(pasted); + const { link, isPending } = useLinkApplication(); + const queryClient = useQueryClient(); + const { mutate: savePosting, isPending: saving } = useMutation({ + ...orpc.applications.update.mutationOptions(), + onSuccess: () => queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }), + onError: (error) => + toast.add({ + type: "error", + description: getOrpcErrorMessage(error, { fallback: t`Couldn't save the posting.` }), + }), + }); + + if (application) { + return ( +
    + + {application.role} · {application.company} + +

    + This application has no posting saved yet. Paste it to match against it. +

    +