mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
feat(web): check mode with pinned issues, job match, a writing review and proposals
- The score ring (live checks only, easing to each new score), the verdict, and Issues · Job match · Writing tabs. - Issues: numbered cards with category, explanation and fix, pinned to their lines on the page with warn pins and wavy underlines. One-step fixes apply with undo; the rest open the field in Write. Show on page, and Ignore (Keep for the two-column issue), stored with the resume. Category rows below, open when they need attention. "Also check the exported PDF" reports in a toast. - Job match reads the linked application's posting, or a pasted one that can be saved as an application. Missing terms ask where they belong (Add to Skills) or can be hidden; covered terms light up their entries on the page. - Writing: an opt-in AI review that says what it sends. Rewrites of bullets and paragraphs arrive as proposals: struck-through old text and highlighted new text on the page, numbered markers, Accept, Reject, Accept all, A/R and arrow keys, one undo step. Out-of-date proposals can't be applied. - "What a person sees / What a parser reads": the parser view extracts the text of the PDF on the page, in reading order, flagging issue lines. - Phones step through issues on the page; tablet pins open the drawer. - The builder's ATS section and its deep-check UI are gone.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid content-start gap-3.5 p-4 pb-12">
|
||||
<ScoreSummary report={check.report} />
|
||||
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as CheckTab)} className="gap-3.5">
|
||||
<TabsList aria-label={t`Check views`} className="h-[38px] w-full">
|
||||
<TabsTrigger value="issues" className="gap-1.5">
|
||||
<Trans>Issues</Trans>
|
||||
<span className="font-mono text-ink-3 text-xs">{check.issues.length}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="match" className="gap-1.5">
|
||||
<Trans>Job match</Trans>
|
||||
{match.result && (
|
||||
<span className="font-mono text-ink-3 text-xs">
|
||||
{match.result.found.length}/{match.result.total}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="writing">
|
||||
<Trans>Writing</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="issues">
|
||||
<IssuesTab check={check} />
|
||||
</TabsContent>
|
||||
<TabsContent value="match">
|
||||
<JobMatchTab match={match} data={check.data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="writing">
|
||||
<WritingTab data={check.data} issues={check.issues} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-line p-4">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t`Readability score: ${score} out of 100`}
|
||||
className="score-ring grid size-[84px] shrink-0 place-items-center rounded-full"
|
||||
style={
|
||||
{
|
||||
"--ring-value": `${score}%`,
|
||||
"--ring-color": score >= 80 ? "var(--accent)" : "var(--warn)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="grid size-[70px] place-items-center rounded-full bg-surface font-display font-medium text-[26px]">
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1">
|
||||
<strong className="font-semibold text-[15px]">{getVerdict(score)}</strong>
|
||||
<span className="text-[13px] text-ink-2 leading-[19px]">
|
||||
<Trans>
|
||||
{passedRules} of {totalRules} checks pass.
|
||||
</Trans>{" "}
|
||||
{open > 0 ? (
|
||||
<Plural value={open} one="# thing to review." other="# things to review." />
|
||||
) : (
|
||||
<Trans>Nothing to review.</Trans>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-ink-3 text-xs">
|
||||
<Icon name="bolt" size={14} />
|
||||
<Trans>Live · updates as you edit</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssuesTab({ check }: { check: CheckResult }) {
|
||||
const { report, issues, data } = check;
|
||||
const selected = useEditorStore((state) => state.checkIssue);
|
||||
const { restoreIgnored } = useCheckActions();
|
||||
const locked = useIsResumeLocked();
|
||||
|
||||
return (
|
||||
<div className="grid gap-2.5">
|
||||
{issues.length === 0 ? (
|
||||
<div className="flex gap-2.5 rounded-xl bg-accent-soft p-3 text-[13px] text-accent-text leading-[19px]">
|
||||
<Icon name="check_circle" />
|
||||
<p>
|
||||
<Trans>
|
||||
<strong>Nothing to fix.</strong> Every check passes.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol aria-label={t`Issues`} className="grid gap-2.5">
|
||||
{issues.map((issue) => (
|
||||
<IssueCard key={issue.key} issue={issue} selected={selected === issue.key} locked={locked} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<CategoryRows report={report} />
|
||||
|
||||
<p className="px-1 text-ink-3 text-xs leading-[17px]">
|
||||
<Trans>
|
||||
The score counts how reliably software reads your resume. It doesn't predict whether you'll be shortlisted.
|
||||
</Trans>{" "}
|
||||
{report.ignored.length > 0 && (
|
||||
<>
|
||||
<Plural value={report.ignored.length} one="# issue is ignored." other="# issues are ignored." />{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onClick={restoreIgnored}
|
||||
className="font-medium text-ink-2 underline underline-offset-2"
|
||||
>
|
||||
<Trans>Show them again</Trans>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<DeepCheck data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
<li
|
||||
data-issue-card={issue.key}
|
||||
aria-labelledby={titleId}
|
||||
onClick={() => 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",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-[22px] shrink-0 place-items-center rounded-full bg-warn font-bold text-[12px] text-[oklch(0.22_0.03_80)]"
|
||||
>
|
||||
{issue.number}
|
||||
</span>
|
||||
<div className="grid min-w-0 gap-[3px]">
|
||||
<span className="font-mono text-[11px] text-ink-3 uppercase">{getCategoryName(issue.category)}</span>
|
||||
<h3 id={titleId} className="font-semibold text-sm leading-5">
|
||||
<span className="sr-only">
|
||||
<Trans>Issue {issue.number}:</Trans>{" "}
|
||||
</span>
|
||||
{issue.title}
|
||||
</h3>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">{issue.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Touch size on phones (B1). */}
|
||||
<div className="ms-8 flex flex-wrap gap-1.5 [&_button]:max-sm:h-10 [&_button]:max-sm:px-3.5 [&_button]:max-sm:text-sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={applies ? "primary" : "secondary"}
|
||||
disabled={locked && applies}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
fix(issue);
|
||||
}}
|
||||
>
|
||||
{issue.fix.kind === "apply" ? <Icon name={issue.fix.icon} size={16} /> : <Icon name="edit" size={16} />}
|
||||
{issue.fix.label}
|
||||
</Button>
|
||||
{issue.target && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
showOnPage(issue);
|
||||
}}
|
||||
>
|
||||
<Trans>Show on page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={locked}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
ignore(issue);
|
||||
}}
|
||||
>
|
||||
{issue.keepLabel ? <Trans>Keep</Trans> : <Trans>Ignore</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Contact details, Dates, Layout, Section headings, Writing: "n of m", open when something needs attention. */
|
||||
function CategoryRows({ report }: { report: AtsReport }) {
|
||||
const [toggled, setToggled] = useState<Partial<Record<AtsCategory, boolean>>>({});
|
||||
const categories = ATS_CATEGORIES.filter((category) => report.categories[category].total > 0);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="check-categories" className="grid gap-2 pt-2">
|
||||
<h3 id="check-categories" className="px-1 font-semibold text-ink-3 text-xs">
|
||||
<Trans>Checks by category</Trans>
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-line">
|
||||
{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 (
|
||||
<div key={category} className={cn(index > 0 && "border-line border-t")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={descriptionId}
|
||||
onClick={() => setToggled((current) => ({ ...current, [category]: !open }))}
|
||||
className="flex h-12 w-full items-center gap-2.5 px-3 text-start font-medium text-sm transition-colors duration-quick hover:bg-hover"
|
||||
>
|
||||
<Icon
|
||||
name={failing > 0 ? "error" : "check_circle"}
|
||||
className={failing > 0 ? "text-warn-text" : "text-accent-text"}
|
||||
/>
|
||||
{getCategoryName(category)}
|
||||
<span className={cn("font-normal text-xs", failing > 0 ? "text-warn-text" : "text-ink-3")}>
|
||||
{failing > 0 ? (
|
||||
<Plural value={failing} one="# to review" other="# to review" />
|
||||
) : (
|
||||
<Trans>
|
||||
{passed} of {total}
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
className={cn("ms-auto text-ink-3 transition-transform duration-standard", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<p
|
||||
id={descriptionId}
|
||||
hidden={!open}
|
||||
className="px-3 ps-[42px] pb-3 text-[13px] text-ink-2 leading-[19px]"
|
||||
>
|
||||
{getCategoryDescription(category)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PdfAtsReport | null>(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 (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="w-fit text-ink-2" disabled={running} onClick={() => void run()}>
|
||||
{running ? <Spinner /> : <Icon name="picture_as_pdf" size={18} />}
|
||||
{running ? <Trans>Checking the exported PDF…</Trans> : <Trans>Also check the exported PDF</Trans>}
|
||||
</Button>
|
||||
|
||||
<Dialog open={report !== null} onOpenChange={(open) => !open && setReport(null)}>
|
||||
<DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>The exported PDF</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
What the file-level check found in the PDF you'd send. It ran in this tab; nothing was uploaded.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{report && <AtsPdfReportView report={report} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: "<p>Designed identities for small businesses.</p>",
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown> | undefined)?.[token];
|
||||
if (node && last !== undefined) (node as Record<string, unknown>)[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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const highlightTerm = useEditorStore((state) => state.highlightTerm);
|
||||
const setHighlightTerm = useEditorStore((state) => state.setHighlightTerm);
|
||||
const locked = useIsResumeLocked();
|
||||
|
||||
if (!posting) return <NoPosting application={application} />;
|
||||
|
||||
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 (
|
||||
<div className="grid gap-3.5">
|
||||
<PostingSource application={application} />
|
||||
|
||||
{!result ? (
|
||||
<p className="flex items-center gap-2 text-ink-2 text-sm">
|
||||
<Trans>Reading the posting…</Trans>
|
||||
</p>
|
||||
) : result.total === 0 ? (
|
||||
<p className="text-ink-2 text-sm">
|
||||
<Trans>No terms stood out in this posting.</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<strong className="font-semibold text-sm">
|
||||
<Trans>
|
||||
{result.found.length} of {result.total} posting terms appear
|
||||
</Trans>
|
||||
</strong>
|
||||
<span className="text-ink-3 text-xs">
|
||||
<Trans>not scored</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t`Posting terms in your resume`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={result.total}
|
||||
aria-valuenow={result.found.length}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-sunken"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-emphasized ease-enter"
|
||||
style={{ width: `${(result.found.length / result.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.missing.length > 0 && (
|
||||
<section aria-labelledby="match-missing" className="grid gap-2">
|
||||
<h3 id="match-missing" className="font-semibold text-ink-3 text-xs uppercase">
|
||||
<Trans>Not in your resume · add only if true</Trans>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{result.missing.map((term) => (
|
||||
<button
|
||||
key={term.term}
|
||||
type="button"
|
||||
aria-expanded={openTerm === term.term}
|
||||
onClick={() => setOpenTerm(openTerm === term.term ? null : term.term)}
|
||||
className={cn(
|
||||
"flex h-[30px] items-center gap-1 rounded-[7px] border border-dashed px-2.5 font-medium text-[13px] transition-colors duration-quick",
|
||||
openTerm === term.term ? "border-accent bg-accent-soft" : "border-line-2 hover:bg-hover",
|
||||
)}
|
||||
>
|
||||
<Icon name="add" size={15} className="text-ink-3" />
|
||||
{term.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<section
|
||||
aria-label={t`Add “${open.label}”`}
|
||||
className="grid gap-2 rounded-xl border border-line bg-raised p-3 shadow-e2"
|
||||
>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">
|
||||
<Plural
|
||||
value={open.jdCount}
|
||||
one={`“${open.label}” appears once in the posting.`}
|
||||
other={`“${open.label}” appears #× in the posting.`}
|
||||
/>{" "}
|
||||
<Trans>If it's part of your experience, add it where it belongs.</Trans>
|
||||
</p>
|
||||
<div className="grid justify-items-start gap-1">
|
||||
<Button size="sm" disabled={locked} onClick={() => addToSkills(open)}>
|
||||
<Icon name="add" size={16} />
|
||||
{skills?.name ? <Trans>Add to Skills · {skills.name}</Trans> : <Trans>Add to Skills</Trans>}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={locked} onClick={() => hideTerm(open)}>
|
||||
<Trans>Not true for me, hide it</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{result.found.length > 0 && (
|
||||
<section aria-labelledby="match-found" className="grid gap-2">
|
||||
<h3 id="match-found" className="font-semibold text-ink-3 text-xs uppercase">
|
||||
<Trans>Already covered · pick one to find it on the page</Trans>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{result.found.map((term) => {
|
||||
const on = highlightTerm === term.term;
|
||||
return (
|
||||
<button
|
||||
key={term.term}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => setHighlightTerm(on ? null : term.term)}
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1 rounded-md px-2 font-medium text-xs transition-colors duration-quick",
|
||||
on ? "bg-[#F2DE8C] text-[oklch(0.3_0.05_80)]" : "bg-accent-soft text-accent-text",
|
||||
)}
|
||||
>
|
||||
<Icon name="check" size={14} />
|
||||
{term.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hiddenTerms.length > 0 && (
|
||||
<p className="text-ink-3 text-xs">
|
||||
<Trans>Hidden: {hiddenTerms.join(", ")}</Trans> ·{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked}
|
||||
className="font-medium text-ink-2 underline underline-offset-2"
|
||||
onClick={() =>
|
||||
editWithUndo((draft) => {
|
||||
checkStateOf(draft).hiddenTerms = [];
|
||||
}, t`Hidden terms are back`)
|
||||
}
|
||||
>
|
||||
<Trans>Show again</Trans>
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-2 rounded-[10px] bg-bg p-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="grid size-7 place-items-center rounded-[7px] bg-sunken text-ink-2">
|
||||
<Icon name="content_copy" size={16} />
|
||||
</span>
|
||||
<span className="grid min-w-0 flex-1">
|
||||
<b className="font-semibold text-[13px]">
|
||||
<Trans>Pasted posting</Trans>
|
||||
</b>
|
||||
<span className="text-ink-3 text-xs">
|
||||
<Trans>For this visit only</Trans>
|
||||
</span>
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => setPastedPosting("")}>
|
||||
<Trans>Clear</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<SaveAsApplication />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-[10px] bg-bg p-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="grid size-7 place-items-center rounded-[7px] bg-sunken font-semibold text-[13px] text-ink-2">
|
||||
{application.company.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="grid min-w-0 flex-1">
|
||||
<b className="truncate font-semibold text-[13px]">
|
||||
{application.role} · {application.company}
|
||||
</b>
|
||||
<span className="text-ink-3 text-xs">
|
||||
<Trans>Posting from the linked application</Trans>
|
||||
</span>
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" aria-expanded={changing} onClick={() => setChanging(!changing)}>
|
||||
<Trans>Change</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
{changing && (
|
||||
<div className="grid gap-2">
|
||||
<ApplicationPicker
|
||||
value={application.id}
|
||||
onChange={(id) => {
|
||||
setChanging(false);
|
||||
void link(id);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
setChanging(false);
|
||||
void link(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Unlink this application</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<NativeSelect
|
||||
aria-label={t`Link an application`}
|
||||
disabled={locked}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => event.target.value && onChange(event.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{choices.length > 0 ? t`Link an application…` : t`No applications yet`}
|
||||
</option>
|
||||
{choices.map((application) => (
|
||||
<option key={application.id} value={application.id}>
|
||||
{application.role} · {application.company}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="grid gap-2.5 rounded-xl border border-line p-4">
|
||||
<strong className="font-semibold text-sm">
|
||||
{application.role} · {application.company}
|
||||
</strong>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">
|
||||
<Trans>This application has no posting saved yet. Paste it to match against it.</Trans>
|
||||
</p>
|
||||
<Textarea
|
||||
aria-label={t`Job posting`}
|
||||
rows={5}
|
||||
maxLength={MAX_POSTING_CHARS}
|
||||
value={draft}
|
||||
placeholder={t`Paste a job posting…`}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={!draft.trim() || saving}
|
||||
onClick={() => savePosting({ id: application.id, jobDescription: draft.trim() })}
|
||||
>
|
||||
<Trans>Save to the application</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2.5 rounded-xl border border-line p-4">
|
||||
<strong className="font-semibold text-sm">
|
||||
<Trans>Match against a job</Trans>
|
||||
</strong>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">
|
||||
<Trans>This resume isn't linked to an application yet.</Trans>
|
||||
</p>
|
||||
<ApplicationPicker value={null} onChange={(id) => void link(id)} />
|
||||
{isPending && (
|
||||
<span className="text-ink-3 text-xs">
|
||||
<Trans>Linking…</Trans>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-center text-ink-3 text-xs">
|
||||
<Trans>or</Trans>
|
||||
</span>
|
||||
<Textarea
|
||||
aria-label={t`Job posting`}
|
||||
rows={4}
|
||||
maxLength={MAX_POSTING_CHARS}
|
||||
value={draft}
|
||||
placeholder={t`Paste a job posting…`}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<Button size="sm" className="w-fit" disabled={!draft.trim()} onClick={() => setPastedPosting(draft)}>
|
||||
<Trans>Match this posting</Trans>
|
||||
</Button>
|
||||
<span className="text-ink-3 text-xs leading-[17px]">
|
||||
<Trans>A pasted posting can be saved as an application afterwards.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Saves the pasted posting as a new application (company and role are required) and links the resume to it. */
|
||||
function SaveAsApplication() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [company, setCompany] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const posting = useEditorStore((state) => state.pastedPosting);
|
||||
const setPastedPosting = useEditorStore((state) => state.setPastedPosting);
|
||||
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
|
||||
const queryClient = useQueryClient();
|
||||
const { link } = useLinkApplication();
|
||||
const { mutateAsync: create, isPending } = useMutation(orpc.applications.create.mutationOptions());
|
||||
const companyId = useId();
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button size="sm" variant="ghost" className="w-fit" onClick={() => setOpen(true)}>
|
||||
<Icon name="work" size={16} />
|
||||
<Trans>Save as application…</Trans>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const id = await create({
|
||||
company: company.trim(),
|
||||
role: role.trim(),
|
||||
jobDescription: posting.trim().slice(0, MAX_POSTING_CHARS),
|
||||
resumeId,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
await link(id);
|
||||
setPastedPosting("");
|
||||
toast.add({ description: t`Saved as an application and linked` });
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Couldn't save the application.` }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={companyId}>
|
||||
<Trans>Company</Trans>
|
||||
</Label>
|
||||
<Input id={companyId} required value={company} onChange={(event) => setCompany(event.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${companyId}-role`}>
|
||||
<Trans>Role</Trans>
|
||||
</Label>
|
||||
<Input id={`${companyId}-role`} required value={role} onChange={(event) => setRole(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" type="submit" disabled={!company.trim() || !role.trim() || isPending}>
|
||||
<Trans>Save and link</Trans>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { PageMap, PageMapNode, PageMapTarget } from "@reactive-resume/pdf/page-map";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CheckIssue } from "./issues";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { getProposalState } from "../proposals/proposals";
|
||||
import { isSameSelection, useEditorStore } from "../store";
|
||||
import { findEntry } from "../write/model";
|
||||
import { scrollToIssue, useCheckActions } from "./actions";
|
||||
import { useCheck } from "./use-check";
|
||||
|
||||
const toTarget = (node: PageMapNode): PageMapTarget => {
|
||||
if (node.kind === "item") return { kind: "item", sectionId: node.sectionId, itemId: node.itemId };
|
||||
if (node.kind === "section") return { kind: "section", sectionId: node.sectionId };
|
||||
return { kind: "header" };
|
||||
};
|
||||
|
||||
/** The first block drawn for a target, on whichever page it starts. */
|
||||
const findNode = (pageMap: PageMap, target: PageMapTarget) =>
|
||||
pageMap.nodes.find((node) => isSameSelection(toTarget(node), target));
|
||||
|
||||
/**
|
||||
* A marker in the page margin, level with the top of a block: 30px left of it, but never off the page, whose
|
||||
* margins can be narrower than the marker. `stack` moves further markers on the same block down.
|
||||
*/
|
||||
const marginStyle = (node: PageMapNode, page: { width: number; height: number }, stack = 0): CSSProperties => ({
|
||||
left: `max(4px, calc(${(node.x / page.width) * 100}% - 30px))`,
|
||||
top: `calc(${(node.y / page.height) * 100}% - 3px + ${stack * 26}px)`,
|
||||
});
|
||||
|
||||
/** Page-relative placement of a block, as percentages of its page. */
|
||||
function boxStyle(node: PageMapNode, page: { width: number; height: number }): CSSProperties {
|
||||
return {
|
||||
left: `${(node.x / page.width) * 100}%`,
|
||||
top: `${(node.y / page.height) * 100}%`,
|
||||
width: `${(node.width / page.width) * 100}%`,
|
||||
height: `${(node.height / page.height) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
const text = (value: unknown): string =>
|
||||
typeof value === "string"
|
||||
? value
|
||||
: typeof value === "object" && value
|
||||
? Object.values(value).map(text).join(" ")
|
||||
: "";
|
||||
|
||||
/** The blocks whose text mentions a term: the header, the summary or entries. */
|
||||
function targetsMentioning(data: ResumeData, term: string): PageMapTarget[] {
|
||||
const needle = term.toLowerCase();
|
||||
const mentions = (value: unknown) => text(value).toLowerCase().includes(needle);
|
||||
const targets: PageMapTarget[] = [];
|
||||
|
||||
if (mentions(data.basics)) targets.push({ kind: "header" });
|
||||
if (mentions(data.summary.content)) targets.push({ kind: "section", sectionId: "summary" });
|
||||
|
||||
const sections = [
|
||||
...Object.entries(data.sections).map(([id, section]) => ({ id, items: section.items as { id: string }[] })),
|
||||
...data.customSections.map((section) => ({ id: section.id, items: section.items as { id: string }[] })),
|
||||
];
|
||||
for (const section of sections) {
|
||||
for (const item of section.items) {
|
||||
if (mentions(findEntry(data, section.id, item.id))) {
|
||||
targets.push({ kind: "item", sectionId: section.id, itemId: item.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
type CheckPageLayerProps = { pageIndex: number; pageMap: PageMap | undefined };
|
||||
|
||||
/**
|
||||
* Check's marks on one page: numbered warn pins with a wavy underline for open issues, numbered accent markers for
|
||||
* proposed edits (while Writing is open), and a highlight on the entries that mention the picked job-match term.
|
||||
*/
|
||||
export function CheckPageLayer({ pageIndex, pageMap }: CheckPageLayerProps) {
|
||||
const check = useCheck();
|
||||
const tab = useEditorStore((state) => state.checkTab);
|
||||
const selected = useEditorStore((state) => state.checkIssue);
|
||||
const highlightTerm = useEditorStore((state) => state.highlightTerm);
|
||||
const proposals = useEditorStore((state) => state.proposals);
|
||||
const breakpoint = useBreakpoint();
|
||||
const page = pageMap?.pages[pageIndex];
|
||||
|
||||
const highlighted = useMemo(
|
||||
() => (check && highlightTerm && tab === "match" ? targetsMentioning(check.data, highlightTerm) : []),
|
||||
[check, highlightTerm, tab],
|
||||
);
|
||||
|
||||
if (!check || !pageMap || !page || page.width <= 0 || page.height <= 0) return null;
|
||||
|
||||
const pickIssue = (issue: CheckIssue) => {
|
||||
const editor = useEditorStore.getState();
|
||||
editor.setCheckIssue(issue.key);
|
||||
editor.setCheckTab("issues");
|
||||
// Tablets: the drawer opens on the issue. Phones stay on the page, where the issue bar steps through them.
|
||||
if (breakpoint === "tablet") editor.setDrawerOpen(true);
|
||||
if (breakpoint !== "mobile") scrollToIssue(issue.key, "panel");
|
||||
};
|
||||
|
||||
const perBlock = new Map<string, number>();
|
||||
const pins =
|
||||
tab === "writing"
|
||||
? []
|
||||
: check.issues.flatMap((issue) => {
|
||||
const node = issue.target ? findNode(pageMap, issue.target) : undefined;
|
||||
if (!node || node.page !== pageIndex) return [];
|
||||
const stack = perBlock.get(node.key) ?? 0;
|
||||
perBlock.set(node.key, stack + 1);
|
||||
return [{ issue, node, stack }];
|
||||
});
|
||||
|
||||
const markers =
|
||||
tab === "writing"
|
||||
? proposals.flatMap((proposal, index) => {
|
||||
if (getProposalState(check.data, proposal) !== "pending") return [];
|
||||
const target: PageMapTarget = proposal.target.itemId
|
||||
? { kind: "item", sectionId: proposal.target.sectionId, itemId: proposal.target.itemId }
|
||||
: { kind: "section", sectionId: proposal.target.sectionId };
|
||||
const node = findNode(pageMap, target);
|
||||
return node && node.page === pageIndex ? [{ number: index + 1, node }] : [];
|
||||
})
|
||||
: [];
|
||||
|
||||
const tinted = highlighted.flatMap((target) =>
|
||||
pageMap.nodes.filter((node) => node.page === pageIndex && isSameSelection(toTarget(node), target)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-slot="check-page-layer" className="pointer-events-none absolute inset-0">
|
||||
{tinted.map((node) => (
|
||||
<div
|
||||
key={`tint:${node.key}:${node.y}`}
|
||||
aria-hidden="true"
|
||||
className="absolute -m-[3px] rounded-[3px] bg-[#F2DE8C]/45 p-[3px] mix-blend-multiply"
|
||||
style={boxStyle(node, page)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{pins.map(({ issue, node, stack }) => {
|
||||
const isSelected = selected === issue.key;
|
||||
return (
|
||||
<div key={issue.key}>
|
||||
<div aria-hidden="true" className="absolute" style={boxStyle(node, page)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -inset-x-1.5 -inset-y-[3px] rounded-[3px] outline-[1.5px] outline-solid transition-[outline-color] duration-quick",
|
||||
isSelected ? "outline-warn" : "outline-transparent",
|
||||
)}
|
||||
/>
|
||||
{stack === 0 && <div className="wavy-underline absolute inset-x-0 -bottom-1 h-1" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-issue-pin={issue.key}
|
||||
aria-label={t`Issue ${issue.number}: ${issue.title}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => pickIssue(issue)}
|
||||
style={marginStyle(node, page, stack)}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute grid size-[22px] place-items-center rounded-full border-2 border-white bg-warn font-bold text-[11px] text-[oklch(0.22_0.03_80)] shadow-[0_1px_3px_oklch(0_0_0/0.25)]",
|
||||
isSelected && "outline-2 outline-warn outline-offset-1",
|
||||
)}
|
||||
>
|
||||
{issue.number}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{markers.map(({ number, node }) => (
|
||||
<span
|
||||
key={`marker:${number}`}
|
||||
aria-hidden="true"
|
||||
className="absolute grid size-5 place-items-center rounded-full bg-accent font-bold text-[11px] text-on-accent"
|
||||
style={marginStyle(node, page)}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "What a person sees / What a parser reads", floating over the canvas in Check. Switching is instant. */
|
||||
export function PageViewToggle() {
|
||||
const view = useEditorStore((state) => state.pageView);
|
||||
const setView = useEditorStore((state) => state.setPageView);
|
||||
const options = [
|
||||
{ value: "page", label: t`What a person sees`, icon: "description" },
|
||||
{ value: "parser", label: t`What a parser reads`, icon: "data_object" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<fieldset className="absolute top-3.5 left-1/2 z-10 flex -translate-x-1/2 gap-0.5 rounded-[10px] border border-line bg-raised p-[3px] shadow-e1">
|
||||
<legend className="sr-only">
|
||||
<Trans>Page view</Trans>
|
||||
</legend>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={view === option.value}
|
||||
onClick={() => setView(option.value)}
|
||||
className={cn(
|
||||
"flex h-[30px] items-center gap-1.5 whitespace-nowrap rounded-[7px] px-3 font-medium text-[13px] max-sm:px-2",
|
||||
view === option.value ? "bg-ink text-bg" : "text-ink-2 hover:bg-hover",
|
||||
)}
|
||||
>
|
||||
<Icon name={option.icon} size={18} />
|
||||
<span className="max-sm:sr-only">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phones (B2): with an issue picked, the page shows "Issue n of m" with ‹ › to step through them, and the card's
|
||||
* fix below, so fixing never needs the panel.
|
||||
*/
|
||||
export function IssueStepper() {
|
||||
const check = useCheck();
|
||||
const selected = useEditorStore((state) => state.checkIssue);
|
||||
const setCheckIssue = useEditorStore((state) => state.setCheckIssue);
|
||||
const { fix } = useCheckActions();
|
||||
|
||||
const issues = check?.issues ?? [];
|
||||
const index = issues.findIndex((issue) => issue.key === selected);
|
||||
const issue = issues[index];
|
||||
if (!issue) return null;
|
||||
|
||||
const go = (step: number) => {
|
||||
const next = issues[(index + step + issues.length) % issues.length];
|
||||
if (!next) return;
|
||||
setCheckIssue(next.key);
|
||||
scrollToIssue(next.key, "page");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-x-3 top-3 z-10 flex h-11 items-center gap-1 rounded-xl bg-ink px-1 text-bg shadow-e3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Close`}
|
||||
onClick={() => setCheckIssue(null)}
|
||||
className="grid size-9 place-items-center rounded-lg"
|
||||
>
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
<span className="flex-1 text-center font-semibold text-sm">
|
||||
<Trans>
|
||||
Issue {issue.number} of {issues.length}
|
||||
</Trans>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Previous issue`}
|
||||
onClick={() => go(-1)}
|
||||
className="grid size-9 place-items-center rounded-lg"
|
||||
>
|
||||
<Icon name="chevron_left" size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Next issue`}
|
||||
onClick={() => go(1)}
|
||||
className="grid size-9 place-items-center rounded-lg"
|
||||
>
|
||||
<Icon name="chevron_right" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-label={t`Issue ${issue.number}`}
|
||||
className="absolute inset-x-3 bottom-3 z-10 grid gap-2 rounded-xl border border-line bg-surface p-3.5 shadow-e3"
|
||||
>
|
||||
<strong className="font-semibold text-[15px]">{issue.title}</strong>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">{issue.body}</p>
|
||||
<Button size="sm" className="h-10 w-fit px-3.5 text-sm" onClick={() => fix(issue)}>
|
||||
{issue.fix.kind === "apply" ? <Icon name={issue.fix.icon} size={18} /> : <Icon name="edit" size={18} />}
|
||||
{issue.fix.label}
|
||||
</Button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { AtsRuleCode } from "@reactive-resume/resume/ats";
|
||||
import type { ExtractedDocument, ResumeSemantics } from "@reactive-resume/resume/ats-pdf";
|
||||
import type { CheckIssue } from "./issues";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { extractPdf } from "@/features/ats-checker/extract-client";
|
||||
import { blobToPdfFile } from "@/features/ats-checker/run-ats-check";
|
||||
import { useEditorStore } from "../store";
|
||||
import { useCheck } from "./use-check";
|
||||
|
||||
type Parsed = { doc: ExtractedDocument; semantics: ResumeSemantics };
|
||||
|
||||
/** Reads the PDF on the page the way a parser does: its text layer in reading order, and what it recognises. */
|
||||
async function parseRenderedPdf(): Promise<Parsed> {
|
||||
const file = useEditorStore.getState().rendered.file;
|
||||
if (!file) throw new Error("The page hasn't rendered yet.");
|
||||
|
||||
// The operator pass (hidden text, images of text) feeds the full check, not this view, so it's skipped.
|
||||
const raw = await extractPdf(blobToPdfFile(file, "resume.pdf"), { operatorBudgetMs: 0 });
|
||||
const { buildExtractedDocument, buildResumeSemantics } = await import("@reactive-resume/resume/ats-pdf");
|
||||
const doc = buildExtractedDocument(raw);
|
||||
|
||||
return { doc, semantics: buildResumeSemantics(raw, doc, { now: new Date() }) };
|
||||
}
|
||||
|
||||
/** Which live checks each field of the parser view reflects. */
|
||||
const FIELD_CODES: Record<string, readonly AtsRuleCode[]> = {
|
||||
name: ["MISSING_NAME"],
|
||||
email: ["MISSING_EMAIL", "MALFORMED_EMAIL"],
|
||||
phone: ["MISSING_PHONE"],
|
||||
location: ["MISSING_LOCATION"],
|
||||
links: ["MALFORMED_URL"],
|
||||
sections: ["NON_STANDARD_SECTION_TITLE", "NO_VISIBLE_EXPERIENCE", "SECTION_MISSING_FROM_LAYOUT"],
|
||||
dates: ["EMPTY_PERIOD", "UNPARSEABLE_PERIOD", "UNPARSEABLE_DATE", "REVERSED_PERIOD", "FUTURE_DATED_PERIOD"],
|
||||
};
|
||||
|
||||
const issueFor = (issues: readonly CheckIssue[], codes: readonly AtsRuleCode[]) =>
|
||||
issues.find((issue) => codes.includes(issue.finding.code));
|
||||
|
||||
/** The issue whose text shows on a line, such as a link written without https:// or an unreadable date. */
|
||||
const issueOnLine = (issues: readonly CheckIssue[], line: string) =>
|
||||
issues.find((issue) => {
|
||||
const value = issue.finding.params?.value;
|
||||
return typeof value === "string" && value.length > 2 && line.includes(value);
|
||||
});
|
||||
|
||||
function IssueChip({ issue }: { issue: CheckIssue }) {
|
||||
return (
|
||||
<span className="h-[18px] shrink-0 whitespace-nowrap rounded-full bg-warn-soft px-1.5 font-sans font-semibold text-[10px] text-warn-text leading-[18px]">
|
||||
<Trans>issue {issue.number}</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "What a parser reads": the text extracted from the PDF on the page, in reading order, with the fields a parser
|
||||
* recognises and the lines behind open issues highlighted.
|
||||
*/
|
||||
export function ParserView() {
|
||||
const version = useEditorStore((state) => state.rendered.version);
|
||||
const issues = useCheck()?.issues ?? [];
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["check-parser-view", version],
|
||||
queryFn: parseRenderedPdf,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid min-h-[792px] w-[612px] max-w-full content-start gap-[18px] rounded-lg border border-line bg-surface px-8 py-7 font-mono text-[12px] text-ink leading-[19px] max-sm:px-4">
|
||||
{isError ? (
|
||||
<p className="font-sans text-danger-text text-sm">
|
||||
<Trans>The page's PDF couldn't be read. Edit anything to render it again.</Trans>
|
||||
</p>
|
||||
) : !data ? (
|
||||
<p className="flex items-center gap-2 font-sans text-ink-2 text-sm">
|
||||
<Spinner />
|
||||
<Trans>Reading the PDF…</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<ParsedText parsed={data} issues={issues} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParsedText({ parsed, issues }: { parsed: Parsed; issues: readonly CheckIssue[] }) {
|
||||
const { doc, semantics } = parsed;
|
||||
const columns = doc.pages.some((page) => page.gutter) ? 2 : 1;
|
||||
const notFound = t`not found`;
|
||||
|
||||
if (doc.charCount === 0) {
|
||||
return (
|
||||
<p className="font-sans text-sm text-warn-text">
|
||||
<Trans>This PDF has no text layer, so a parser reads nothing from it.</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { contact } = semantics;
|
||||
const links = [...new Set([...contact.textUrls, ...contact.annotationUrls])];
|
||||
const unreadable = semantics.dates.filter((date) => !date.parsed).length;
|
||||
const fields = [
|
||||
{ id: "name", label: t`Name`, value: contact.nameLine },
|
||||
{ id: "email", label: t`Email`, value: contact.emails.join(", ") },
|
||||
{ id: "phone", label: t`Phone`, value: contact.phones.join(", ") },
|
||||
{ id: "location", label: t`Location`, value: contact.locationLine },
|
||||
{ id: "links", label: t`Links`, value: links.join(", ") },
|
||||
{ id: "sections", label: t`Sections`, value: semantics.headings.map((heading) => heading.text).join(", ") },
|
||||
{
|
||||
id: "dates",
|
||||
label: t`Dates`,
|
||||
value:
|
||||
semantics.dates.length === 0
|
||||
? ""
|
||||
: unreadable > 0
|
||||
? t`${semantics.dates.length} found, ${unreadable} unreadable`
|
||||
: t`${semantics.dates.length} found`,
|
||||
},
|
||||
];
|
||||
|
||||
// Lines grouped under the headings a parser recognised, in the order it reads them.
|
||||
const headingAt = new Map(semantics.headings.map((heading) => [heading.lineIndex, heading.text]));
|
||||
const groups: { heading: string | null; lines: string[] }[] = [{ heading: null, lines: [] }];
|
||||
doc.lines.forEach((line, index) => {
|
||||
const heading = headingAt.get(index);
|
||||
if (heading !== undefined) groups.push({ heading, lines: [] });
|
||||
else groups.at(-1)?.lines.push(line.text);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-between gap-2 font-medium text-[11px] text-ink-3 uppercase">
|
||||
<span>
|
||||
<Trans>Extracted from the PDF on the page · reading order</Trans>
|
||||
</span>
|
||||
<span>{columns === 1 ? <Trans>text layer ✓ · 1 column</Trans> : <Trans>text layer ✓ · 2 columns</Trans>}</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-[110px_minmax(0,1fr)] gap-x-4 gap-y-1.5 max-sm:grid-cols-[84px_minmax(0,1fr)]">
|
||||
{fields.map((field) => {
|
||||
const issue = issueFor(issues, FIELD_CODES[field.id] ?? []);
|
||||
return (
|
||||
<div key={field.id} className="contents">
|
||||
<dt className="text-ink-3 uppercase">{field.label}</dt>
|
||||
<dd className={cn("flex min-w-0 items-center gap-2", (issue || !field.value) && "text-warn-text")}>
|
||||
<span className="min-w-0 break-words">{field.value || notFound}</span>
|
||||
{issue && <IssueChip issue={issue} />}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
|
||||
<hr className="border-line" />
|
||||
|
||||
{groups.map((group, index) =>
|
||||
group.heading === null && group.lines.length === 0 ? null : (
|
||||
<section key={`${index}:${group.heading ?? ""}`} className="grid gap-1">
|
||||
{group.heading !== null && <h3 className="text-accent-text">▸ {group.heading.toUpperCase()}</h3>}
|
||||
{group.lines.map((line, lineIndex) => {
|
||||
const issue = issueOnLine(issues, line);
|
||||
return (
|
||||
<p
|
||||
key={`${lineIndex}:${line}`}
|
||||
className={cn("flex items-start gap-2 ps-3.5", issue ? "text-warn-text" : "text-ink-2")}
|
||||
>
|
||||
<span className="min-w-0 break-words">{line}</span>
|
||||
{issue && <IssueChip issue={issue} />}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Passage } from "../proposals/passages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapWritingReview } from "./review";
|
||||
|
||||
const passage: Passage = {
|
||||
id: "p1",
|
||||
target: { sectionId: "experience", itemId: "kettle", field: "description" },
|
||||
location: "Experience · Studio Kettle · bullet 1",
|
||||
html: "<p>Responsible for various design tasks</p>",
|
||||
text: "Responsible for various design tasks",
|
||||
};
|
||||
|
||||
const suggestion = (patch: Partial<Parameters<typeof mapWritingReview>[0][number]>) => ({
|
||||
section: "Experience",
|
||||
passageId: "p1",
|
||||
issue: "Names a duty, not an outcome.",
|
||||
rewrite: "Produced packaging and print work for local retail clients",
|
||||
impact: "high" as const,
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe("mapWritingReview", () => {
|
||||
it("turns a rewrite of a passage it was sent into a proposal for that passage", () => {
|
||||
const { proposals, notes } = mapWritingReview([suggestion({})], [passage]);
|
||||
|
||||
expect(notes).toEqual([]);
|
||||
expect(proposals).toEqual([
|
||||
{
|
||||
id: "w1",
|
||||
target: passage.target,
|
||||
location: passage.location,
|
||||
before: "<p>Responsible for various design tasks</p>",
|
||||
after: "<p>Produced packaging and print work for local retail clients</p>",
|
||||
why: "Names a duty, not an outcome.",
|
||||
status: "pending",
|
||||
source: "check",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps advice, unchanged rewrites and unknown passages as notes", () => {
|
||||
const { proposals, notes } = mapWritingReview(
|
||||
[
|
||||
suggestion({ rewrite: null }),
|
||||
suggestion({ rewrite: " Responsible for various design tasks " }),
|
||||
suggestion({ passageId: "p9", section: "Summary", impact: "low" }),
|
||||
],
|
||||
[passage],
|
||||
);
|
||||
|
||||
expect(proposals).toEqual([]);
|
||||
expect(notes.map((note) => [note.location, note.quote, note.target])).toEqual([
|
||||
[passage.location, passage.text, { kind: "item", sectionId: "experience", itemId: "kettle" }],
|
||||
[passage.location, passage.text, { kind: "item", sectionId: "experience", itemId: "kettle" }],
|
||||
["Summary", "", null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PageMapTarget } from "@reactive-resume/pdf/page-map";
|
||||
import type { Passage } from "../proposals/passages";
|
||||
import type { Proposal } from "../proposals/proposals";
|
||||
import type { WritingNote } from "../store";
|
||||
import { replaceBlockText } from "../proposals/proposals";
|
||||
|
||||
type ReviewSuggestion = {
|
||||
section: string | null;
|
||||
passageId: string | null;
|
||||
issue: string;
|
||||
rewrite: string | null;
|
||||
impact: WritingNote["impact"];
|
||||
};
|
||||
|
||||
const toPageTarget = (target: Proposal["target"]): PageMapTarget =>
|
||||
target.itemId
|
||||
? { kind: "item", sectionId: target.sectionId, itemId: target.itemId }
|
||||
: { kind: "section", sectionId: target.sectionId };
|
||||
|
||||
/**
|
||||
* Sorts a writing review's suggestions: a rewrite of a passage that was sent becomes a proposal to accept or
|
||||
* reject; anything else (advice, or a rewrite of text it wasn't given) stays a note.
|
||||
*/
|
||||
export function mapWritingReview(suggestions: readonly ReviewSuggestion[], passages: readonly Passage[]) {
|
||||
const byId = new Map(passages.map((passage) => [passage.id, passage]));
|
||||
const proposals: Proposal[] = [];
|
||||
const notes: WritingNote[] = [];
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
const passage = suggestion.passageId ? byId.get(suggestion.passageId) : undefined;
|
||||
const rewrite = suggestion.rewrite?.trim();
|
||||
|
||||
if (passage && rewrite && rewrite !== passage.text) {
|
||||
proposals.push({
|
||||
id: `w${proposals.length + 1}`,
|
||||
target: passage.target,
|
||||
location: passage.location,
|
||||
before: passage.html,
|
||||
after: replaceBlockText(passage.html, rewrite),
|
||||
why: suggestion.issue,
|
||||
status: "pending",
|
||||
source: "check",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
notes.push({
|
||||
location: passage?.location ?? suggestion.section ?? "",
|
||||
impact: suggestion.impact,
|
||||
quote: passage?.text ?? "",
|
||||
note: suggestion.issue,
|
||||
target: passage ? toPageTarget(passage.target) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return { proposals, notes };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AtsReport } from "@reactive-resume/resume/ats";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { CheckIssue } from "./issues";
|
||||
import { useDeferredValue, useMemo } from "react";
|
||||
import { lintResumeForAts } from "@reactive-resume/resume/ats";
|
||||
import { useResumeData } from "@/features/resume/builder/draft";
|
||||
import { buildIssues } from "./issues";
|
||||
|
||||
export type CheckResult = { data: ResumeData; report: AtsReport; issues: CheckIssue[] };
|
||||
|
||||
/** The live checks on the resume as it is now (deferred, so typing stays smooth), with numbered issues. */
|
||||
export function useCheck(): CheckResult | null {
|
||||
const data = useDeferredValue(useResumeData());
|
||||
|
||||
return useMemo(() => {
|
||||
if (!data) return null;
|
||||
const report = lintResumeForAts(data);
|
||||
return { data, report, issues: buildIssues(report, data) };
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
/** Open issues, for the badge on the Check tab. Ignored ones don't count. */
|
||||
export function useOpenIssueCount(): number {
|
||||
const data = useDeferredValue(useResumeData());
|
||||
return useMemo(() => (data ? lintResumeForAts(data).findings.length : 0), [data]);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { WritingNote } from "../store";
|
||||
import type { CheckIssue } from "./issues";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
||||
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { AiProviderPicker } from "@/features/settings/integrations/components/ai-provider-picker";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { collectPassages } from "../proposals/passages";
|
||||
import { ProposalList } from "../proposals/proposal-list";
|
||||
import { useEditorStore } from "../store";
|
||||
import { getSectionName } from "./issues";
|
||||
import { mapWritingReview } from "./review";
|
||||
|
||||
/** The endpoint's caps; the text and lists are trimmed here so a long resume is never refused. */
|
||||
const MAX_TEXT_CHARS = 50_000;
|
||||
const MAX_FINDINGS = 120;
|
||||
const MAX_PASSAGES = 120;
|
||||
const MAX_PASSAGE_CHARS = 1_000;
|
||||
|
||||
type WritingTabProps = { data: ResumeData; issues: readonly CheckIssue[] };
|
||||
|
||||
/**
|
||||
* Writing: an opt-in AI review of the wording. It says what it sends before it runs, returns rewrites of bullets
|
||||
* and paragraphs as proposals to accept or reject, and is never part of the score. Its errors stay in this tab.
|
||||
*/
|
||||
export function WritingTab({ data, issues }: WritingTabProps) {
|
||||
const { usableProviders, hasUsableProvider, isLoading } = useHasUsableAiProvider();
|
||||
const [providerOverride, setProviderOverride] = useState<string | null>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const review = useEditorStore((state) => state.writingReview);
|
||||
const proposals = useEditorStore((state) => state.proposals);
|
||||
const setWritingReview = useEditorStore((state) => state.setWritingReview);
|
||||
const setProposals = useEditorStore((state) => state.setProposals);
|
||||
|
||||
const provider = usableProviders.find((entry) => entry.id === providerOverride) ?? usableProviders[0];
|
||||
const passages = useMemo(
|
||||
() =>
|
||||
collectPassages(data, {
|
||||
summary: getSectionName(data, "summary"),
|
||||
sectionTitle: (sectionId) => getSectionName(data, sectionId),
|
||||
bullet: (n) => t`bullet ${n}`,
|
||||
paragraph: (n) => t`paragraph ${n}`,
|
||||
}).slice(0, MAX_PASSAGES),
|
||||
[data],
|
||||
);
|
||||
|
||||
const { mutate, isPending, error, reset } = useMutation({
|
||||
...orpc.ai.atsReview.mutationOptions(),
|
||||
onSuccess: (result) => {
|
||||
const { proposals: next, notes } = mapWritingReview(result.suggestions, passages);
|
||||
setProposals(next);
|
||||
setWritingReview({ summary: result.summary, strengths: result.strengths, notes });
|
||||
},
|
||||
});
|
||||
|
||||
const run = () => {
|
||||
reset();
|
||||
mutate({
|
||||
...(provider ? { aiProviderId: provider.id } : {}),
|
||||
extractedText: buildMarkdown(data).slice(0, MAX_TEXT_CHARS),
|
||||
findings: issues.slice(0, MAX_FINDINGS).map((issue) => ({
|
||||
code: issue.finding.code,
|
||||
severity: issue.finding.severity,
|
||||
message: issue.title.slice(0, 300),
|
||||
})),
|
||||
passages: passages.map((passage) => ({
|
||||
id: passage.id,
|
||||
where: passage.location.slice(0, 200),
|
||||
text: passage.text.slice(0, MAX_PASSAGE_CHARS),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!hasUsableProvider) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-xl border border-line p-4">
|
||||
<strong className="font-semibold text-sm">
|
||||
<Trans>A second opinion on your wording</Trans>
|
||||
</strong>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">
|
||||
<Trans>
|
||||
Connect your own AI provider to have a language model read your bullets and suggest rewrites. Issues and Job
|
||||
match need no provider.
|
||||
</Trans>
|
||||
</p>
|
||||
<Link
|
||||
to="/dashboard/settings/integrations"
|
||||
className={buttonVariants({ size: "sm", variant: "secondary", className: "w-fit" })}
|
||||
>
|
||||
<Trans>Open AI settings</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div aria-busy="true" className="flex items-center gap-2.5 rounded-xl border border-line p-4 text-ink-2 text-sm">
|
||||
<Spinner />
|
||||
<Plural
|
||||
value={passages.length}
|
||||
one="Reading # passage of your resume…"
|
||||
other="Reading # bullets and paragraphs of your resume…"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-2.5 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text leading-[19px]"
|
||||
>
|
||||
<Icon name="error" />
|
||||
<span>
|
||||
{getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_GATEWAY: t`Couldn't reach your AI provider. Its key may have expired.`,
|
||||
BAD_REQUEST: t`The provider returned a review that couldn't be read. Try again.`,
|
||||
PRECONDITION_FAILED: t`AI providers are unavailable until ENCRYPTION_SECRET is configured.`,
|
||||
},
|
||||
fallback: t`The review couldn't finish. Try again.`,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={run}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
<Link to="/dashboard/settings/integrations" className={buttonVariants({ size: "sm", variant: "secondary" })}>
|
||||
<Trans>Open AI settings</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-ink-3 text-xs">
|
||||
<Trans>Issues and Job match still work without AI.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-xl border border-line p-4">
|
||||
<strong className="font-semibold text-sm">
|
||||
<Trans>A second opinion on your wording</Trans>
|
||||
</strong>
|
||||
<p className="text-[13px] text-ink-2 leading-[19px]">
|
||||
<Trans>
|
||||
The checks are mechanical. This asks a language model how a reader might react to your bullets and suggests
|
||||
rewrites. It isn't part of the score.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-ink-3 text-xs">
|
||||
<Icon name="lock" size={16} />
|
||||
<span>
|
||||
<Trans>
|
||||
Sends your resume's text only to {provider?.label} · {provider?.model}, with your key.
|
||||
</Trans>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={choosing}
|
||||
className="font-medium text-ink-2 underline underline-offset-2"
|
||||
onClick={() => setChoosing(!choosing)}
|
||||
>
|
||||
<Trans>Change</Trans>
|
||||
</button>
|
||||
</div>
|
||||
{choosing && (
|
||||
<AiProviderPicker
|
||||
value={provider?.id ?? null}
|
||||
providers={usableProviders}
|
||||
onValueChange={(value) => {
|
||||
setProviderOverride(value);
|
||||
setChoosing(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" className="w-fit" disabled={passages.length === 0} onClick={run}>
|
||||
<Icon name="auto_awesome" size={16} />
|
||||
<Trans>Review writing</Trans>
|
||||
</Button>
|
||||
{passages.length === 0 && (
|
||||
<p className="text-ink-3 text-xs">
|
||||
<Trans>Add a summary or describe a role first; there's nothing to review yet.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{review.summary && <p className="text-[13px] text-ink-2 leading-[19px]">{review.summary}</p>}
|
||||
|
||||
{proposals.length > 0 && <ProposalList proposals={proposals} data={data} onSuggestAgain={run} />}
|
||||
|
||||
{review.notes.map((note) => (
|
||||
<WritingNoteCard key={`${note.location}:${note.note}`} note={note} />
|
||||
))}
|
||||
|
||||
{review.strengths.length > 0 && (
|
||||
<section aria-labelledby="writing-strengths" className="grid gap-1.5">
|
||||
<h3 id="writing-strengths" className="pt-1.5 font-semibold text-ink-3 text-xs uppercase">
|
||||
<Trans>What's working</Trans>
|
||||
</h3>
|
||||
<ul className="grid list-disc gap-1 ps-[18px] text-[13px] text-ink-2 leading-[19px]">
|
||||
{review.strengths.map((strength) => (
|
||||
<li key={strength}>{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-ink-3 text-xs leading-[17px]">
|
||||
<Trans>A model's opinion, not a verdict. It can be wrong.</Trans>{" "}
|
||||
<button type="button" className="font-medium text-ink-2 underline underline-offset-2" onClick={run}>
|
||||
<Trans>Run again</Trans>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const impactLabel = (impact: WritingNote["impact"]) => ({ high: t`High`, medium: t`Medium`, low: t`Low` })[impact];
|
||||
|
||||
function WritingNoteCard({ note }: { note: WritingNote }) {
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
const showOnPage = () => {
|
||||
if (!note.target) return;
|
||||
const editor = useEditorStore.getState();
|
||||
editor.select(note.target);
|
||||
editor.setPageView("page");
|
||||
if (breakpoint === "tablet") editor.setDrawerOpen(false);
|
||||
if (breakpoint === "mobile") editor.setMobileView("page");
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="grid gap-1.5 rounded-[10px] border border-line p-3">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="font-mono text-[11px] text-ink-3 uppercase">{note.location}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-[18px] rounded px-1.5 font-semibold text-[11px] leading-[18px]",
|
||||
note.impact === "high" ? "bg-warn-soft text-warn-text" : "bg-sunken text-ink-2",
|
||||
)}
|
||||
>
|
||||
{impactLabel(note.impact)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] leading-[19px]">
|
||||
{note.quote && <i className="text-ink-2">“{note.quote}”</i>} {note.note}
|
||||
</p>
|
||||
{note.target && (
|
||||
<Button size="sm" variant="secondary" className="w-fit" onClick={showOnPage}>
|
||||
<Trans>Show on page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ProposalTarget } from "./proposals";
|
||||
import { stripHtml } from "@reactive-resume/utils/string";
|
||||
import { describeEntry, getEntries, resolveSection } from "../write/model";
|
||||
|
||||
/** A bullet or paragraph a reviewer can suggest rewriting, with where it sits. */
|
||||
export type Passage = {
|
||||
id: string;
|
||||
target: ProposalTarget;
|
||||
/** "Experience · Studio Kettle · bullet 1". */
|
||||
location: string;
|
||||
/** The whole block (`<p>…</p>` or `<li>…</li>`) exactly as stored, so a proposal can find it again. */
|
||||
html: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
// A paragraph or list item with no paragraph or list item inside it: `<li><p>…</p></li>` yields the `<p>`.
|
||||
const LEAF_BLOCK = /<(p|li)(?:\s[^>]*)?>((?:(?!<\/?(?:p|li)[\s>])[\s\S])*?)<\/\1>/gi;
|
||||
|
||||
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
||||
|
||||
const decodeEntities = (text: string) =>
|
||||
text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, name: string) => {
|
||||
if (name.startsWith("#x") || name.startsWith("#X")) return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
|
||||
if (name.startsWith("#")) return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
|
||||
return ENTITIES[name.toLowerCase()] ?? entity;
|
||||
});
|
||||
|
||||
const toText = (html: string) => decodeEntities(stripHtml(html)).replace(/\s+/g, " ").trim();
|
||||
|
||||
type Block = { html: string; text: string; bullet: boolean };
|
||||
|
||||
/** The leaf paragraphs and list items of rich text, in order, skipping empty ones. */
|
||||
export function splitBlocks(html: string): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
for (const match of html.matchAll(LEAF_BLOCK)) {
|
||||
const text = toText(match[2] ?? "");
|
||||
if (!text) continue;
|
||||
|
||||
// Inside a list when more <li> have opened than closed before this block.
|
||||
const preceding = html.slice(0, match.index);
|
||||
const opened = preceding.match(/<li[\s>]/gi)?.length ?? 0;
|
||||
const closed = preceding.match(/<\/li>/gi)?.length ?? 0;
|
||||
blocks.push({ html: match[0], text, bullet: match[1]?.toLowerCase() === "li" || opened > closed });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
summary: string;
|
||||
sectionTitle: (sectionId: string) => string;
|
||||
bullet: (n: number) => string;
|
||||
paragraph: (n: number) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every passage a writing review can target, in page order: the summary's paragraphs, then each visible
|
||||
* entry's description, section by section. Hidden sections and entries don't print, so they're left out.
|
||||
*/
|
||||
export function collectPassages(data: ResumeData, labels: Labels): Passage[] {
|
||||
const passages: Passage[] = [];
|
||||
|
||||
const add = (target: ProposalTarget, where: string[], html: string) => {
|
||||
let bullets = 0;
|
||||
let paragraphs = 0;
|
||||
|
||||
for (const block of splitBlocks(html)) {
|
||||
const place = block.bullet ? labels.bullet(++bullets) : labels.paragraph(++paragraphs);
|
||||
passages.push({
|
||||
id: `p${passages.length + 1}`,
|
||||
target,
|
||||
location: [...where, place].join(" · "),
|
||||
html: block.html,
|
||||
text: block.text,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!data.summary.hidden) add({ sectionId: "summary", field: "content" }, [labels.summary], data.summary.content);
|
||||
|
||||
const sectionIds = [...Object.keys(data.sections), ...data.customSections.map((section) => section.id)];
|
||||
|
||||
for (const sectionId of sectionIds) {
|
||||
const section = resolveSection(data, sectionId);
|
||||
if (!section || section.type === "cover-letter") continue;
|
||||
|
||||
const hidden =
|
||||
section.kind === "custom"
|
||||
? data.customSections.find((custom) => custom.id === sectionId)?.hidden
|
||||
: data.sections[sectionId as keyof ResumeData["sections"]].hidden;
|
||||
if (hidden) continue;
|
||||
|
||||
for (const entry of getEntries(data, section)) {
|
||||
const { hidden: entryHidden, description } = entry as { hidden?: boolean; description?: unknown };
|
||||
if (entryHidden || typeof description !== "string") continue;
|
||||
|
||||
const title = describeEntry(section.type, entry).title;
|
||||
add(
|
||||
{ sectionId, itemId: entry.id, field: "description" },
|
||||
[labels.sectionTitle(sectionId), ...(title ? [title] : [])],
|
||||
description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return passages;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import type { Proposal } from "./proposals";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useEditorStore } from "../store";
|
||||
import { ProposalList } from "./proposal-list";
|
||||
import { readTarget } from "./proposals";
|
||||
|
||||
const routerParams = vi.hoisted(() => ({ resumeId: "proposals" }));
|
||||
const toastState = vi.hoisted(() => ({ add: vi.fn() }));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams }));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {},
|
||||
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
|
||||
}));
|
||||
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: toastState.add, close: vi.fn() } }));
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useEditorStore.getState().reset();
|
||||
toastState.add.mockClear();
|
||||
});
|
||||
|
||||
const TARGET = { sectionId: "experience", itemId: "kettle", field: "description" };
|
||||
|
||||
const proposals: Proposal[] = [
|
||||
{
|
||||
id: "w1",
|
||||
target: TARGET,
|
||||
location: "Experience · Studio Kettle · bullet 1",
|
||||
before: "<p>Responsible for design tasks</p>",
|
||||
after: "<p>Produced packaging for retail clients</p>",
|
||||
why: "Names an outcome.",
|
||||
status: "pending",
|
||||
source: "check",
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
target: TARGET,
|
||||
location: "Experience · Studio Kettle · bullet 2",
|
||||
before: "<p>Worked on websites</p>",
|
||||
after: "<p>Designed websites for 20+ businesses</p>",
|
||||
why: "Adds scale.",
|
||||
status: "pending",
|
||||
source: "check",
|
||||
},
|
||||
];
|
||||
|
||||
function setup() {
|
||||
const data = structuredClone(defaultResumeData) as ResumeData;
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
id: "kettle",
|
||||
hidden: false,
|
||||
company: "Studio Kettle",
|
||||
position: "Junior Designer",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "<ul><li><p>Responsible for design tasks</p></li><li><p>Worked on websites</p></li></ul>",
|
||||
roles: [],
|
||||
} as ResumeData["sections"]["experience"]["items"][number],
|
||||
];
|
||||
const resume: Resume = {
|
||||
id: routerParams.resumeId,
|
||||
name: "Proposals",
|
||||
slug: "proposals",
|
||||
tags: [],
|
||||
data,
|
||||
isLocked: false,
|
||||
updatedAt: new Date("2026-09-28T00:00:00.000Z"),
|
||||
};
|
||||
useResumeStore.getState().initialize(resume);
|
||||
useEditorStore.getState().setProposals(proposals);
|
||||
|
||||
const Harness = () => {
|
||||
const current = useResumeStore((state) => state.resume?.data);
|
||||
const list = useEditorStore((state) => state.proposals);
|
||||
return current ? <ProposalList proposals={list} data={current} onSuggestAgain={vi.fn()} /> : null;
|
||||
};
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Harness />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const description = () => readTarget(useResumeStore.getState().resume?.data as ResumeData, TARGET);
|
||||
|
||||
describe("ProposalList", () => {
|
||||
it("accepts with A and rejects with R on the focused edit, moving with the arrow keys", () => {
|
||||
setup();
|
||||
const [first] = screen.getAllByRole("listitem");
|
||||
first?.focus();
|
||||
|
||||
fireEvent.keyDown(first as HTMLElement, { key: "a" });
|
||||
expect(description()).toContain("<p>Produced packaging for retail clients</p>");
|
||||
expect(screen.getByText("Applied")).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(first as HTMLElement, { key: "ArrowDown" });
|
||||
const second = screen.getAllByRole("listitem")[1] as HTMLElement;
|
||||
expect(document.activeElement).toBe(second);
|
||||
fireEvent.keyDown(second, { key: "r" });
|
||||
expect(screen.getByText("Rejected")).toBeTruthy();
|
||||
expect(description()).toContain("<p>Worked on websites</p>");
|
||||
});
|
||||
|
||||
it("accepts all pending edits as one undo step, and undoing shows them as pending again", () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Accept all" }));
|
||||
|
||||
expect(description()).toBe(
|
||||
"<ul><li><p>Produced packaging for retail clients</p></li><li><p>Designed websites for 20+ businesses</p></li></ul>",
|
||||
);
|
||||
expect(screen.getAllByText("Applied")).toHaveLength(2);
|
||||
|
||||
act(() => useResumeStore.getState().undo());
|
||||
expect(description()).toContain("<p>Responsible for design tasks</p>");
|
||||
expect(screen.getAllByRole("button", { name: "Accept" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("marks an edit out of date once its text changes", () => {
|
||||
setup();
|
||||
act(() =>
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
const entry = draft.sections.experience.items[0];
|
||||
if (entry) entry.description = "<p>Rewritten by hand</p>";
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/Out of date/)).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: "Accept all" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { Proposal, ProposalState } from "./proposals";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useIsResumeLocked, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useEditorStore } from "../store";
|
||||
import { applyProposal, getProposalState } from "./proposals";
|
||||
|
||||
/** The visible text of a passage's HTML, for the card. */
|
||||
const passageText = (html: string) =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
.trim();
|
||||
|
||||
/** Applies proposals as one undo step; the toast's Undo takes them back, and they show as pending again. */
|
||||
function acceptProposals(proposals: readonly Proposal[]) {
|
||||
useResumeStore.getState().updateResumeData(
|
||||
(draft) => {
|
||||
for (const proposal of proposals) applyProposal(draft, proposal);
|
||||
},
|
||||
{ newStep: true },
|
||||
);
|
||||
useEditorStore.getState().setProposalStatus(
|
||||
proposals.map((proposal) => proposal.id),
|
||||
"accepted",
|
||||
);
|
||||
toast.add({
|
||||
description: proposals.length === 1 ? t`Edit applied` : t`${proposals.length} edits applied`,
|
||||
actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
|
||||
});
|
||||
}
|
||||
|
||||
type ProposalListProps = {
|
||||
proposals: readonly Proposal[];
|
||||
data: ResumeData;
|
||||
/** "Suggest again" on an out-of-date proposal. */
|
||||
onSuggestAgain: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A change set: "n proposed edits" with Accept all, then each edit numbered like its marker on the page, with
|
||||
* where it lands, the old text struck through, the new text and why. A accepts and R rejects the focused edit;
|
||||
* ↑ and ↓ move between edits.
|
||||
*/
|
||||
export function ProposalList({ proposals, data, onSuggestAgain }: ProposalListProps) {
|
||||
const setProposalStatus = useEditorStore((state) => state.setProposalStatus);
|
||||
const locked = useIsResumeLocked();
|
||||
const [focused, setFocused] = useState(0);
|
||||
const states = proposals.map((proposal) => getProposalState(data, proposal));
|
||||
const pending = proposals.filter((_, index) => states[index] === "pending");
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const item = (event.target as HTMLElement).closest<HTMLElement>("[data-proposal-index]");
|
||||
const index = Number(item?.dataset.proposalIndex ?? focused);
|
||||
const proposal = proposals[index];
|
||||
|
||||
const move = (next: number) => {
|
||||
event.preventDefault();
|
||||
const target = Math.min(proposals.length - 1, Math.max(0, next));
|
||||
setFocused(target);
|
||||
event.currentTarget.querySelector<HTMLElement>(`[data-proposal-index="${target}"]`)?.focus();
|
||||
};
|
||||
|
||||
if (event.key === "ArrowDown") return move(index + 1);
|
||||
if (event.key === "ArrowUp") return move(index - 1);
|
||||
if (!proposal || states[index] !== "pending" || locked) return;
|
||||
if (event.key === "a" || event.key === "A") {
|
||||
event.preventDefault();
|
||||
acceptProposals([proposal]);
|
||||
}
|
||||
if (event.key === "r" || event.key === "R") {
|
||||
event.preventDefault();
|
||||
setProposalStatus([proposal.id], "rejected");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="proposals-heading" className="overflow-hidden rounded-xl border border-line">
|
||||
<header className="flex min-h-11 items-center justify-between gap-2 border-line border-b bg-bg px-3 py-2">
|
||||
<h3 id="proposals-heading" className="font-semibold text-sm">
|
||||
<Plural value={proposals.length} one="# proposed edit" other="# proposed edits" />
|
||||
</h3>
|
||||
{pending.length > 1 && (
|
||||
<Button size="sm" disabled={locked} onClick={() => acceptProposals(pending)}>
|
||||
<Trans>Accept all</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<ol aria-label={t`Proposed edits`} onKeyDown={onKeyDown} className="divide-y divide-line">
|
||||
{proposals.map((proposal, index) => (
|
||||
<ProposalItem
|
||||
key={proposal.id}
|
||||
proposal={proposal}
|
||||
number={index + 1}
|
||||
state={states[index] ?? "pending"}
|
||||
index={index}
|
||||
focusable={index === Math.min(focused, proposals.length - 1)}
|
||||
locked={locked}
|
||||
onFocus={() => setFocused(index)}
|
||||
onAccept={() => acceptProposals([proposal])}
|
||||
onReject={() => setProposalStatus([proposal.id], "rejected")}
|
||||
onSuggestAgain={onSuggestAgain}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="border-line border-t px-3 py-2 text-ink-3 text-xs">
|
||||
<Trans>A accepts and R rejects the focused edit. ↑ and ↓ move between edits.</Trans>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ProposalItemProps = {
|
||||
proposal: Proposal;
|
||||
number: number;
|
||||
state: ProposalState;
|
||||
index: number;
|
||||
focusable: boolean;
|
||||
locked: boolean;
|
||||
onFocus: () => void;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
onSuggestAgain: () => void;
|
||||
};
|
||||
|
||||
function ProposalItem(props: ProposalItemProps) {
|
||||
const { proposal, number, state, index, focusable, locked } = props;
|
||||
const labelId = `proposal-${proposal.id}-location`;
|
||||
|
||||
return (
|
||||
<li
|
||||
data-proposal-index={index}
|
||||
// Roving focus: one edit in the tab order, ↑ and ↓ move between them.
|
||||
tabIndex={focusable ? 0 : -1}
|
||||
aria-labelledby={labelId}
|
||||
onFocus={props.onFocus}
|
||||
className="grid gap-2 p-3 outline-none focus-visible:bg-hover"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-5 shrink-0 place-items-center rounded-full bg-accent font-bold text-[11px] text-on-accent"
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span id={labelId} className="truncate font-mono text-[11px] text-ink-3 uppercase">
|
||||
<span className="sr-only">
|
||||
<Trans>Edit {number}:</Trans>{" "}
|
||||
</span>
|
||||
{proposal.location}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5 text-[13px] leading-[19px]">
|
||||
<del className="text-ink-3">{passageText(proposal.before)}</del>
|
||||
<ins className="rounded-[3px] bg-accent-soft px-1 py-0.5 no-underline">{passageText(proposal.after)}</ins>
|
||||
{proposal.why && <span className="text-ink-2 text-xs">{proposal.why}</span>}
|
||||
</div>
|
||||
|
||||
{state === "pending" ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" disabled={locked} onClick={props.onAccept}>
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={props.onReject}>
|
||||
<Trans>Reject</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"flex items-center gap-2 font-medium text-xs",
|
||||
state === "accepted" && "text-accent-text",
|
||||
state === "rejected" && "text-ink-3",
|
||||
state === "stale" && "text-warn-text",
|
||||
)}
|
||||
>
|
||||
{state === "accepted" && <Trans>Applied</Trans>}
|
||||
{state === "rejected" && <Trans>Rejected</Trans>}
|
||||
{state === "stale" && (
|
||||
<>
|
||||
<Trans>Out of date: the text has changed since.</Trans>
|
||||
<button type="button" className="text-ink-2 underline underline-offset-2" onClick={props.onSuggestAgain}>
|
||||
<Trans>Suggest again</Trans>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Proposal } from "./proposals";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { produce } from "immer";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { collectPassages, splitBlocks } from "./passages";
|
||||
import { applyProposal, canApply, getProposalState, markProposals, readTarget, replaceBlockText } from "./proposals";
|
||||
|
||||
const OLD_BULLET = "<li><p>Responsible for various design tasks</p></li>";
|
||||
|
||||
function makeData(): ResumeData {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.summary.content = "<p>Product designer with 8 years in health tools.</p>";
|
||||
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: `<ul>${OLD_BULLET}<li><p>Designed websites & identities for 20+ businesses</p></li></ul>`,
|
||||
roles: [],
|
||||
} as ResumeData["sections"]["experience"]["items"][number],
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
const proposal = (patch: Partial<Proposal> = {}): Proposal => ({
|
||||
id: "1",
|
||||
target: { sectionId: "experience", itemId: "kettle", field: "description" },
|
||||
location: "Experience · Studio Kettle · bullet 1",
|
||||
before: "<p>Responsible for various design tasks</p>",
|
||||
after: "<p>Produced packaging and print work for local retail clients</p>",
|
||||
why: "Names an outcome.",
|
||||
status: "pending",
|
||||
source: "check",
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("reads leaf paragraphs and list items, marking bullets and decoding entities", () => {
|
||||
expect(splitBlocks(`<p>Intro</p><ul>${OLD_BULLET}<li>Plain & bare</li></ul><p> </p>`)).toEqual([
|
||||
{ html: "<p>Intro</p>", text: "Intro", bullet: false },
|
||||
{
|
||||
html: "<p>Responsible for various design tasks</p>",
|
||||
text: "Responsible for various design tasks",
|
||||
bullet: true,
|
||||
},
|
||||
{ html: "<li>Plain & bare</li>", text: "Plain & bare", bullet: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPassages", () => {
|
||||
const labels = {
|
||||
summary: "Summary",
|
||||
sectionTitle: () => "Experience",
|
||||
bullet: (n: number) => `bullet ${n}`,
|
||||
paragraph: (n: number) => `paragraph ${n}`,
|
||||
};
|
||||
|
||||
it("lists the summary and each visible entry's bullets with where they sit", () => {
|
||||
const passages = collectPassages(makeData(), labels);
|
||||
expect(passages.map((passage) => [passage.id, passage.location, passage.text])).toEqual([
|
||||
["p1", "Summary · paragraph 1", "Product designer with 8 years in health tools."],
|
||||
["p2", "Experience · Junior Designer · bullet 1", "Responsible for various design tasks"],
|
||||
["p3", "Experience · Junior Designer · bullet 2", "Designed websites & identities for 20+ businesses"],
|
||||
]);
|
||||
expect(passages[1]?.target).toEqual({ sectionId: "experience", itemId: "kettle", field: "description" });
|
||||
});
|
||||
|
||||
it("leaves hidden entries and sections out", () => {
|
||||
const data = produce(makeData(), (draft) => {
|
||||
draft.summary.hidden = true;
|
||||
const entry = draft.sections.experience.items[0];
|
||||
if (entry) entry.hidden = true;
|
||||
});
|
||||
expect(collectPassages(data, labels)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proposals", () => {
|
||||
it("applies while the passage is still in the field, and only then", () => {
|
||||
const data = makeData();
|
||||
expect(canApply(data, proposal())).toBe(true);
|
||||
|
||||
const next = produce(data, (draft) => {
|
||||
expect(applyProposal(draft, proposal())).toBe(true);
|
||||
});
|
||||
expect(readTarget(next, proposal().target)).toContain("<li><p>Produced packaging and print work");
|
||||
expect(getProposalState(next, proposal())).toBe("stale");
|
||||
|
||||
produce(next, (draft) => {
|
||||
expect(applyProposal(draft, proposal())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an accepted proposal as pending again once its edit is undone", () => {
|
||||
const data = makeData();
|
||||
const accepted = proposal({ status: "accepted" });
|
||||
expect(getProposalState(data, accepted)).toBe("pending");
|
||||
|
||||
const applied = produce(data, (draft) => {
|
||||
applyProposal(draft, accepted);
|
||||
});
|
||||
expect(getProposalState(applied, accepted)).toBe("accepted");
|
||||
expect(getProposalState(applied, proposal({ status: "rejected" }))).toBe("rejected");
|
||||
});
|
||||
|
||||
it("keeps dollar signs in the new text literal", () => {
|
||||
const next = produce(makeData(), (draft) => {
|
||||
applyProposal(draft, proposal({ after: "<p>Cut costs by $& and $1</p>" }));
|
||||
});
|
||||
expect(readTarget(next, proposal().target)).toContain("<p>Cut costs by $& and $1</p>");
|
||||
});
|
||||
|
||||
it("targets the summary", () => {
|
||||
const summary = proposal({
|
||||
target: { sectionId: "summary", field: "content" },
|
||||
before: "<p>Product designer with 8 years in health tools.</p>",
|
||||
after: "<p>Product designer for clinicians.</p>",
|
||||
});
|
||||
const next = produce(makeData(), (draft) => {
|
||||
applyProposal(draft, summary);
|
||||
});
|
||||
expect(next.summary.content).toBe("<p>Product designer for clinicians.</p>");
|
||||
});
|
||||
|
||||
it("marks pending proposals on a copy: old text struck through, new text highlighted", () => {
|
||||
const data = makeData();
|
||||
const marked = markProposals(data, [proposal(), proposal({ id: "2", status: "rejected" })]);
|
||||
|
||||
expect(readTarget(marked, proposal().target)).toContain(
|
||||
'<p><s style="color: #7d7b73">Responsible for various design tasks</s> <mark data-color="#d4efd9">Produced packaging and print work for local retail clients</mark></p>',
|
||||
);
|
||||
expect(readTarget(data, proposal().target)).not.toContain("<s");
|
||||
expect(markProposals(data, [proposal({ status: "rejected" })])).toBe(data);
|
||||
});
|
||||
|
||||
it("replaces a block's text and escapes it, keeping the tag", () => {
|
||||
expect(replaceBlockText('<p class="x">Old</p>', " New <b>& bold ")).toBe(
|
||||
'<p class="x">New <b>& bold</p>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { produce } from "immer";
|
||||
import { findEntry } from "../write/model";
|
||||
|
||||
/** A field that holds rich text: the summary's content, or an entry's description. */
|
||||
export type ProposalTarget = { sectionId: string; itemId?: string; field: string };
|
||||
|
||||
type ProposalSource = "check" | "assistant" | "improve";
|
||||
|
||||
/**
|
||||
* A suggested edit: replace one passage of a field (a bullet or a paragraph) with another. It is never applied
|
||||
* until accepted. Out of date is not stored: it's whenever the passage is no longer in the field.
|
||||
*/
|
||||
export type Proposal = {
|
||||
id: string;
|
||||
target: ProposalTarget;
|
||||
/** Where it lands, for people: "Experience · Studio Kettle · bullet 1". */
|
||||
location: string;
|
||||
/** The passage as it is now: a whole `<p>` or `<li>` block, exactly as stored in the field. */
|
||||
before: string;
|
||||
/** The block to put in its place. */
|
||||
after: string;
|
||||
/** One line on why. */
|
||||
why: string;
|
||||
status: "pending" | "accepted" | "rejected";
|
||||
source: ProposalSource;
|
||||
};
|
||||
|
||||
export type ProposalState = Proposal["status"] | "stale";
|
||||
|
||||
export function readTarget(data: ResumeData, target: ProposalTarget): string | undefined {
|
||||
if (!target.itemId)
|
||||
return target.sectionId === "summary" && target.field === "content" ? data.summary.content : undefined;
|
||||
|
||||
const value = (findEntry(data, target.sectionId, target.itemId) as Record<string, unknown> | undefined)?.[
|
||||
target.field
|
||||
];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function writeTarget(draft: ResumeData, target: ProposalTarget, value: string) {
|
||||
if (!target.itemId) {
|
||||
draft.summary.content = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = findEntry(draft, target.sectionId, target.itemId) as Record<string, unknown> | undefined;
|
||||
if (entry) entry[target.field] = value;
|
||||
}
|
||||
|
||||
/** A pending proposal can be applied only while its passage is still in the field, word for word. */
|
||||
export const canApply = (data: ResumeData, proposal: Proposal) =>
|
||||
readTarget(data, proposal.target)?.includes(proposal.before) ?? false;
|
||||
|
||||
/**
|
||||
* What a proposal shows as. Pending ones whose passage has changed are out of date; accepted ones whose edit was
|
||||
* undone (the passage is back, the new text gone) are pending again.
|
||||
*/
|
||||
export function getProposalState(data: ResumeData, proposal: Proposal): ProposalState {
|
||||
if (proposal.status === "rejected") return "rejected";
|
||||
|
||||
const value = readTarget(data, proposal.target) ?? "";
|
||||
if (proposal.status === "accepted")
|
||||
return value.includes(proposal.before) && !value.includes(proposal.after) ? "pending" : "accepted";
|
||||
return value.includes(proposal.before) ? "pending" : "stale";
|
||||
}
|
||||
|
||||
/** Puts `after` in place of `before`. Returns false (changing nothing) when the proposal is out of date. */
|
||||
export function applyProposal(draft: ResumeData, proposal: Proposal): boolean {
|
||||
const value = readTarget(draft, proposal.target);
|
||||
if (value === undefined || !value.includes(proposal.before)) return false;
|
||||
|
||||
// A replacer function, so "$&" or "$1" in the new text stay literal.
|
||||
writeTarget(
|
||||
draft,
|
||||
proposal.target,
|
||||
value.replace(proposal.before, () => proposal.after),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Colours of the page marks (README §5.9), as the PDF needs them: old text struck through in grey, new text on
|
||||
// a pale accent highlight.
|
||||
const OLD_TEXT_COLOR = "#7d7b73";
|
||||
const NEW_TEXT_BACKGROUND = "#d4efd9";
|
||||
|
||||
const BLOCK = /^(<(p|li)(?:\s[^>]*)?>)([\s\S]*)(<\/\2>)$/i;
|
||||
|
||||
/** A block's opening tag, contents and closing tag; `undefined` for anything else. */
|
||||
function splitBlock(html: string) {
|
||||
const match = BLOCK.exec(html);
|
||||
return match ? { open: match[1] ?? "", inner: match[3] ?? "", close: match[4] ?? "" } : undefined;
|
||||
}
|
||||
|
||||
/** The old text struck through, then the new text highlighted, inside the passage's own block. */
|
||||
function markChange(before: string, after: string) {
|
||||
const old = splitBlock(before);
|
||||
const next = splitBlock(after);
|
||||
const struck = (html: string) => `<s style="color: ${OLD_TEXT_COLOR}">${html}</s>`;
|
||||
const highlighted = (html: string) => `<mark data-color="${NEW_TEXT_BACKGROUND}">${html}</mark>`;
|
||||
|
||||
if (!old || !next) return `${struck(before)} ${highlighted(after)}`;
|
||||
return `${old.open}${struck(old.inner)} ${highlighted(next.inner)}${old.close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page as it would read with the proposals: each passage struck through, followed by its replacement,
|
||||
* highlighted. For the preview only; it's never saved.
|
||||
*/
|
||||
export function markProposals(data: ResumeData, proposals: readonly Proposal[]): ResumeData {
|
||||
const pending = proposals.filter((proposal) => getProposalState(data, proposal) === "pending");
|
||||
if (pending.length === 0) return data;
|
||||
|
||||
return produce(data, (draft) => {
|
||||
for (const proposal of pending) {
|
||||
const value = readTarget(draft, proposal.target);
|
||||
if (value === undefined || !value.includes(proposal.before)) continue;
|
||||
|
||||
const marked = markChange(proposal.before, proposal.after);
|
||||
writeTarget(
|
||||
draft,
|
||||
proposal.target,
|
||||
value.replace(proposal.before, () => marked),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string) =>
|
||||
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
/** The block with its text replaced by plain text (a model's rewrite), keeping the tag and its attributes. */
|
||||
export function replaceBlockText(block: string, text: string): string {
|
||||
const html = escapeHtml(text.trim().replace(/\s+/g, " "));
|
||||
const parts = splitBlock(block);
|
||||
return parts ? `${parts.open}${html}${parts.close}` : html;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PageMap, PageMapTarget } from "@reactive-resume/pdf/page-map";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { Proposal } from "./proposals/proposals";
|
||||
import { create } from "zustand/react";
|
||||
|
||||
export const EDITOR_MODES = ["write", "design", "check"] as const;
|
||||
@@ -8,6 +9,23 @@ export type EditorMode = (typeof EDITOR_MODES)[number];
|
||||
/** The Share & export sheet's tabs; each entry point opens its own. */
|
||||
export type ShareTab = "link" | "download" | "history";
|
||||
|
||||
export type CheckTab = "issues" | "match" | "writing";
|
||||
|
||||
/** Phones show one view at a time: the page, or one mode's panel. */
|
||||
export type MobileView = "page" | EditorMode;
|
||||
|
||||
/** A writing note that isn't a rewrite: where it is, how much it matters, the words and what to consider. */
|
||||
export type WritingNote = {
|
||||
location: string;
|
||||
impact: "high" | "medium" | "low";
|
||||
quote: string;
|
||||
note: string;
|
||||
target: PageMapTarget | null;
|
||||
};
|
||||
|
||||
/** Check → Writing: the model's review. Its rewrites are the proposals. */
|
||||
type WritingReview = { summary: string; strengths: readonly string[]; notes: readonly WritingNote[] };
|
||||
|
||||
/** What's selected in the editor: shared by the panel and the page, so each can outline the other. */
|
||||
export type EditorSelection = PageMapTarget;
|
||||
|
||||
@@ -40,8 +58,25 @@ type EditorStore = {
|
||||
basicsOpen: boolean;
|
||||
/** Design: a template shown on the page while its thumbnail is hovered or focused, not yet applied. */
|
||||
previewTemplate: Template | null;
|
||||
/** The render on screen: physical pages and the page map. `version` counts renders, so Fit can wait for one. */
|
||||
rendered: { pageCount: number; pageMap: PageMap | undefined; version: number };
|
||||
/**
|
||||
* The render on screen: physical pages, the page map and the PDF (which Check's parser view reads). `version`
|
||||
* counts renders, so Fit can wait for one.
|
||||
*/
|
||||
rendered: { pageCount: number; pageMap: PageMap | undefined; file: Blob | undefined; version: number };
|
||||
/** Phones: the view on screen; null until one is picked, which shows the current mode. */
|
||||
mobileView: MobileView | null;
|
||||
checkTab: CheckTab;
|
||||
/** Check: the issue picked on its card or pin (its key). Both are outlined. */
|
||||
checkIssue: string | null;
|
||||
/** Check: the page as a person sees it, or the text a parser reads from it. */
|
||||
pageView: "page" | "parser";
|
||||
/** Check → Job match: a term whose entries are tinted on the page. */
|
||||
highlightTerm: string | null;
|
||||
/** Check → Job match: a posting pasted for this visit, for a resume with no application linked. */
|
||||
pastedPosting: string;
|
||||
/** Edits suggested for the page (Check → Writing), shown on it until accepted or rejected. */
|
||||
proposals: readonly Proposal[];
|
||||
writingReview: WritingReview | null;
|
||||
select: (selection: EditorSelection | null) => void;
|
||||
setZoom: (zoom: number | "fit") => void;
|
||||
setDrawerOpen: (open: boolean) => void;
|
||||
@@ -55,7 +90,16 @@ type EditorStore = {
|
||||
setFocusEntry: (entryId: string | null) => void;
|
||||
setBasicsOpen: (open: boolean) => void;
|
||||
setPreviewTemplate: (template: Template | null) => void;
|
||||
setRendered: (render: { pageCount: number; pageMap: PageMap | undefined }) => void;
|
||||
setRendered: (render: { pageCount: number; pageMap: PageMap | undefined; file: Blob }) => void;
|
||||
setMobileView: (view: MobileView) => void;
|
||||
setCheckTab: (tab: CheckTab) => void;
|
||||
setCheckIssue: (key: string | null) => void;
|
||||
setPageView: (view: "page" | "parser") => void;
|
||||
setHighlightTerm: (term: string | null) => void;
|
||||
setPastedPosting: (posting: string) => void;
|
||||
setProposals: (proposals: readonly Proposal[]) => void;
|
||||
setProposalStatus: (ids: readonly string[], status: Proposal["status"]) => void;
|
||||
setWritingReview: (review: WritingReview | null) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
@@ -84,7 +128,15 @@ const initialState = {
|
||||
focusEntryId: null,
|
||||
basicsOpen: true,
|
||||
previewTemplate: null,
|
||||
rendered: { pageCount: 0, pageMap: undefined, version: 0 },
|
||||
rendered: { pageCount: 0, pageMap: undefined, file: undefined, version: 0 },
|
||||
mobileView: null,
|
||||
checkTab: "issues",
|
||||
checkIssue: null,
|
||||
pageView: "page",
|
||||
highlightTerm: null,
|
||||
pastedPosting: "",
|
||||
proposals: [],
|
||||
writingReview: null,
|
||||
} as const;
|
||||
|
||||
export const useEditorStore = create<EditorStore>()((set) => ({
|
||||
@@ -112,5 +164,17 @@ export const useEditorStore = create<EditorStore>()((set) => ({
|
||||
setBasicsOpen: (basicsOpen) => set({ basicsOpen }),
|
||||
setPreviewTemplate: (previewTemplate) => set({ previewTemplate }),
|
||||
setRendered: (render) => set((state) => ({ rendered: { ...render, version: state.rendered.version + 1 } })),
|
||||
setMobileView: (mobileView) => set({ mobileView }),
|
||||
setCheckTab: (checkTab) => set({ checkTab }),
|
||||
setCheckIssue: (checkIssue) => set({ checkIssue }),
|
||||
setPageView: (pageView) => set({ pageView }),
|
||||
setHighlightTerm: (highlightTerm) => set({ highlightTerm }),
|
||||
setPastedPosting: (pastedPosting) => set({ pastedPosting }),
|
||||
setProposals: (proposals) => set({ proposals }),
|
||||
setProposalStatus: (ids, status) =>
|
||||
set((state) => ({
|
||||
proposals: state.proposals.map((proposal) => (ids.includes(proposal.id) ? { ...proposal, status } : proposal)),
|
||||
})),
|
||||
setWritingReview: (writingReview) => set({ writingReview }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useDeferredValue, useMemo } from "react";
|
||||
import { lintResumeForAts } from "@reactive-resume/resume/ats";
|
||||
import { useResumeData } from "@/features/resume/builder/draft";
|
||||
|
||||
/** Open Check issues (errors and warnings from the live checks) for the badge on the Check tab. */
|
||||
export function useOpenIssueCount(): number {
|
||||
const data = useDeferredValue(useResumeData());
|
||||
|
||||
return useMemo(() => {
|
||||
if (!data) return 0;
|
||||
const { counts } = lintResumeForAts(data);
|
||||
return counts.error + counts.warning;
|
||||
}, [data]);
|
||||
}
|
||||
@@ -27,12 +27,12 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { atsFindingItemElementId } from "@/libs/resume/ats";
|
||||
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
|
||||
import { useEditorStore } from "../store";
|
||||
import { EntryFields } from "./entries";
|
||||
import { useEntry, useEntryWriter } from "./fields";
|
||||
import { createEntry, describeEntry, getEntries, getPrimaryField, isDraftEntry } from "./model";
|
||||
import { entryElementId } from "./reveal";
|
||||
import { useSectionTitle } from "./section-row";
|
||||
|
||||
const DRAFT_HINTS: Record<string, MessageDescriptor> = {
|
||||
@@ -99,7 +99,7 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
|
||||
return (
|
||||
<div
|
||||
ref={sortable.setNodeRef}
|
||||
id={atsFindingItemElementId(entryId)}
|
||||
id={entryElementId(entryId)}
|
||||
data-entry-id={entryId}
|
||||
style={{ transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition }}
|
||||
className={cn(
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { EditorSelection } from "../store";
|
||||
import { atsFindingItemElementId } from "@/libs/resume/ats";
|
||||
import { useEditorStore } from "../store";
|
||||
|
||||
/** The id of an entry's card in the Write panel, which the page and Check scroll to. */
|
||||
export const entryElementId = (entryId: string) => `resume-item-${entryId}`;
|
||||
|
||||
/** Smooth scrolling unless the user asked for reduced motion. */
|
||||
export const getScrollBehavior = (): ScrollBehavior =>
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
|
||||
|
||||
const SECTION_ANCHOR = "sidebar-";
|
||||
const ITEM_ANCHOR = atsFindingItemElementId("");
|
||||
const ITEM_ANCHOR = entryElementId("");
|
||||
|
||||
/**
|
||||
* The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing
|
||||
@@ -39,7 +41,7 @@ export function revealSelectionInPanel(selection: EditorSelection) {
|
||||
const section = document.getElementById(
|
||||
selection.kind === "header" ? "sidebar-basics" : `sidebar-${selection.sectionId}`,
|
||||
);
|
||||
const entry = selection.kind === "item" ? document.getElementById(atsFindingItemElementId(selection.itemId)) : null;
|
||||
const entry = selection.kind === "item" ? document.getElementById(entryElementId(selection.itemId)) : null;
|
||||
(entry ?? section)?.scrollIntoView({ block: "start", behavior: getScrollBehavior() });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,9 +182,11 @@ export function ResumePreviewClient({
|
||||
const activeLayer = getActivePreviewLayer(previewLayers);
|
||||
const activePageCount = activeLayer?.numPages ?? 0;
|
||||
const activePageMap = activeLayer?.pageMap;
|
||||
const activeFile = activeLayer?.file;
|
||||
useEffect(() => {
|
||||
if (activePageCount > 0) onRender?.({ pageCount: activePageCount, pageMap: activePageMap });
|
||||
}, [activePageCount, activePageMap, onRender]);
|
||||
if (activePageCount > 0 && activeFile)
|
||||
onRender?.({ pageCount: activePageCount, pageMap: activePageMap, file: activeFile });
|
||||
}, [activePageCount, activePageMap, activeFile, onRender]);
|
||||
|
||||
if (!resumeData) return null;
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ export type ResumePreviewProps = {
|
||||
/** Drawn over each page, in page-relative coordinates; receives the page map of the render on screen. */
|
||||
renderPageOverlay?: (page: { pageIndex: number; pageMap: PageMap | undefined }) => ReactNode;
|
||||
/** Called whenever the render on screen changes, with its physical page count and page map. */
|
||||
onRender?: (render: { pageCount: number; pageMap: PageMap | undefined }) => void;
|
||||
/** Each render on screen: its page count, page map and the PDF itself. */
|
||||
onRender?: (render: { pageCount: number; pageMap: PageMap | undefined; file: Blob }) => void;
|
||||
};
|
||||
|
||||
export type ResolvedResumePreviewProps = ResumePreviewProps & {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { downloadWithAnchor } from "@reactive-resume/utils/file";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
|
||||
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
|
||||
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
|
||||
|
||||
type Format = { id: ExportFormat; label: string; extension: string; icon: IconName; description: string };
|
||||
|
||||
@@ -66,7 +66,10 @@ export function HistoryTab() {
|
||||
await savePendingChanges(resume.id);
|
||||
const restored = await restoreVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
|
||||
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), restored);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), {
|
||||
...restored,
|
||||
applicationId: resume.applicationId ?? null,
|
||||
});
|
||||
setVersion(null);
|
||||
void queryClient.invalidateQueries({ queryKey: listKey });
|
||||
toast.add({
|
||||
|
||||
@@ -11,3 +11,23 @@
|
||||
transform: translateX(-33.333%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Check's score ring: the arc eases to a new score, so a fix visibly counts. */
|
||||
@property --ring-value {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
@utility score-ring {
|
||||
background: conic-gradient(var(--ring-color) 0 var(--ring-value), var(--sunken) 0);
|
||||
transition: --ring-value var(--d3) var(--ease);
|
||||
}
|
||||
|
||||
/* A wavy warn underline under a line of the page that has a Check issue. */
|
||||
@utility wavy-underline {
|
||||
background-color: var(--warn);
|
||||
mask:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4'%3E%3Cpath d='M0 3 Q1.5 0 3 3 T6 3' fill='none' stroke='black' stroke-width='1.2'/%3E%3C/svg%3E")
|
||||
repeat-x left center / 6px 4px;
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { ATS_RULE_CODES } from "@reactive-resume/resume/ats";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { atsFindingItemElementId, getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "./ats";
|
||||
|
||||
const experienceItem = (id: string) => ({
|
||||
id,
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Engineer",
|
||||
location: "London",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
|
||||
function makeResume(): ResumeData {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.sections.experience.items = [experienceItem("exp-a"), experienceItem("exp-b")] as never;
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Consulting",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [experienceItem("custom-item-a")],
|
||||
},
|
||||
] as never;
|
||||
return data;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("getAtsFindingMessage", () => {
|
||||
it("covers every rule in the catalog", () => {
|
||||
for (const code of ATS_RULE_CODES) {
|
||||
const message = getAtsFindingMessage(code);
|
||||
expect(message.title.length, code).toBeGreaterThan(0);
|
||||
expect(message.action.length, code).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives each rule a distinct title", () => {
|
||||
const titles = ATS_RULE_CODES.map((code) => getAtsFindingMessage(code).title);
|
||||
expect(new Set(titles).size).toBe(titles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAtsFindingTarget", () => {
|
||||
it.each([
|
||||
["/basics/email", { side: "left", section: "basics" }],
|
||||
["/basics/customFields/0/link", { side: "left", section: "basics" }],
|
||||
["/picture", { side: "left", section: "picture" }],
|
||||
["/summary/content", { side: "left", section: "summary" }],
|
||||
["/sections/experience/items/0/period", { side: "left", section: "experience" }],
|
||||
["/customSections/2/items/1/period", { side: "left", section: "custom" }],
|
||||
["/metadata/typography/body/fontSize", { side: "right", section: "typography" }],
|
||||
["/metadata/page/marginX", { side: "right", section: "page" }],
|
||||
["/metadata/layout/pages", { side: "right", section: "layout" }],
|
||||
])("resolves %s", (pointer, expected) => {
|
||||
expect(getAtsFindingTarget(pointer)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for a pointer with no sidebar home", () => {
|
||||
expect(getAtsFindingTarget("/metadata/styleRules/0")).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes escaped pointer tokens", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAtsFindingTarget item resolution", () => {
|
||||
it("resolves the item a finding belongs to", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/0/period", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
itemId: "exp-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends two findings in the same section to different items", () => {
|
||||
const data = makeResume();
|
||||
const first = getAtsFindingTarget("/sections/experience/items/0/period", data);
|
||||
const second = getAtsFindingTarget("/sections/experience/items/1/period", data);
|
||||
|
||||
expect(first?.itemId).toBe("exp-a");
|
||||
expect(second?.itemId).toBe("exp-b");
|
||||
expect(first?.section).toBe(second?.section);
|
||||
});
|
||||
|
||||
it("resolves items inside custom sections", () => {
|
||||
expect(getAtsFindingTarget("/customSections/0/items/0/period", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "custom",
|
||||
itemId: "custom-item-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the item when no resume data is supplied", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the item for a section-level pointer", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the item when the index is out of range", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/9/period", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a stable element id", () => {
|
||||
expect(atsFindingItemElementId("exp-a")).toBe("resume-item-exp-a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAtsFindingLocation", () => {
|
||||
it("names the section for a section-level pointer", () => {
|
||||
expect(getAtsFindingLocation("/basics/email")).toBe("Basics");
|
||||
});
|
||||
|
||||
it("adds a one-based item position for an item-level pointer", () => {
|
||||
expect(getAtsFindingLocation("/sections/experience/items/0/period")).toBe("Experience · Item 1");
|
||||
});
|
||||
|
||||
it("returns null when the pointer has no sidebar home", () => {
|
||||
expect(getAtsFindingLocation("/metadata/styleRules/0")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { AtsRuleCode } from "@reactive-resume/resume/ats";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { LeftSidebarSection, SidebarSection } from "./section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { match } from "ts-pattern";
|
||||
import { getSectionTitle, leftSidebarSections } from "./section";
|
||||
|
||||
export type AtsFindingMessage = {
|
||||
title: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
export type AtsFindingTarget = {
|
||||
side: "left" | "right";
|
||||
section: SidebarSection;
|
||||
itemId?: string;
|
||||
};
|
||||
|
||||
export const atsFindingItemElementId = (itemId: string) => `resume-item-${itemId}`;
|
||||
|
||||
export function getAtsFindingMessage(code: AtsRuleCode): AtsFindingMessage {
|
||||
return match(code)
|
||||
.with("MISSING_NAME", () => ({
|
||||
title: t`Your resume has no name.`,
|
||||
action: t`Add your full name under Basics.`,
|
||||
}))
|
||||
.with("MISSING_EMAIL", () => ({
|
||||
title: t`Your resume has no email address.`,
|
||||
action: t`Most systems file candidates by email. Add one under Basics.`,
|
||||
}))
|
||||
.with("MALFORMED_EMAIL", () => ({
|
||||
title: t`This email address will not be recognized.`,
|
||||
action: t`Use a plain address such as name@example.com, with no surrounding text.`,
|
||||
}))
|
||||
.with("MISSING_PHONE", () => ({
|
||||
title: t`Your resume has no phone number.`,
|
||||
action: t`Some application systems require one before you can submit.`,
|
||||
}))
|
||||
.with("MISSING_LOCATION", () => ({
|
||||
title: t`Your resume has no location.`,
|
||||
action: t`Add at least a city and country so roles can be matched to your region.`,
|
||||
}))
|
||||
.with("MALFORMED_URL", () => ({
|
||||
title: t`This link is missing its protocol.`,
|
||||
action: t`Write the full address, including https://.`,
|
||||
}))
|
||||
.with("PICTURE_PRESENT", () => ({
|
||||
title: t`Your resume includes a photo.`,
|
||||
action: t`Some parsers mishandle images, and photos are discouraged in some regions.`,
|
||||
}))
|
||||
.with("EMPTY_PERIOD", () => ({
|
||||
title: t`This entry has no dates.`,
|
||||
action: t`Add a period such as "Jan 2020 - Present" so it lands on your timeline.`,
|
||||
}))
|
||||
.with("UNPARSEABLE_PERIOD", () => ({
|
||||
title: t`These dates will not be read correctly.`,
|
||||
action: t`Use a recognized form such as "Jan 2020 - Mar 2022" or "2020 - 2022".`,
|
||||
}))
|
||||
.with("UNPARSEABLE_DATE", () => ({
|
||||
title: t`This date will not be read correctly.`,
|
||||
action: t`Use a recognized form such as "March 2022" or "2022".`,
|
||||
}))
|
||||
.with("REVERSED_PERIOD", () => ({
|
||||
title: t`This period ends before it starts.`,
|
||||
action: t`Swap the start and end dates.`,
|
||||
}))
|
||||
.with("FUTURE_DATED_PERIOD", () => ({
|
||||
title: t`This period starts in the future.`,
|
||||
action: t`Correct the year, or use "Present" for ongoing work.`,
|
||||
}))
|
||||
.with("SECTION_MISSING_FROM_LAYOUT", () => ({
|
||||
title: t`This section has content but never appears.`,
|
||||
action: t`Place it on a page from the Layout panel, or hide it if you meant to park it.`,
|
||||
}))
|
||||
.with("NO_VISIBLE_EXPERIENCE", () => ({
|
||||
title: t`Your resume shows no work experience.`,
|
||||
action: t`Add an entry, or use projects and volunteer work to show equivalent history.`,
|
||||
}))
|
||||
.with("MISSING_EXPERIENCE_DESCRIPTION", () => ({
|
||||
title: t`This role has no description.`,
|
||||
action: t`Describe what you did so the entry contributes keywords for matching.`,
|
||||
}))
|
||||
.with("NON_STANDARD_SECTION_TITLE", () => ({
|
||||
title: t`This heading is not one parsers look for.`,
|
||||
action: t`Prefer a conventional heading such as "Work Experience" or "Education".`,
|
||||
}))
|
||||
.with("MULTI_COLUMN_PROSE_SECTION", () => ({
|
||||
title: t`This section is split across columns.`,
|
||||
action: t`Columns commonly scramble the order text is read in. Set it to a single column.`,
|
||||
}))
|
||||
.with("PROSE_SECTION_IN_SIDEBAR", () => ({
|
||||
title: t`This section sits in the sidebar.`,
|
||||
action: t`Move it into the main column and keep the sidebar for short lists.`,
|
||||
}))
|
||||
.with("SMALL_BODY_FONT", () => ({
|
||||
title: t`Your body text is very small.`,
|
||||
action: t`Use a size of at least 9pt so re-rendered copies stay accurate.`,
|
||||
}))
|
||||
.with("TIGHT_LINE_HEIGHT", () => ({
|
||||
title: t`Your lines are packed very tightly.`,
|
||||
action: t`Use a line height of at least 1.15 so lines are not merged together.`,
|
||||
}))
|
||||
.with("TIGHT_PAGE_MARGINS", () => ({
|
||||
title: t`Your page margins are very narrow.`,
|
||||
action: t`Increase them so content stays inside the reliably read area.`,
|
||||
}))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function decodePointerToken(token: string): string {
|
||||
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
}
|
||||
|
||||
function pointerTokens(pointer: string): string[] {
|
||||
return pointer.split("/").slice(1).map(decodePointerToken);
|
||||
}
|
||||
|
||||
const isLeftSidebarSection = (value: string): value is LeftSidebarSection =>
|
||||
(leftSidebarSections as string[]).includes(value);
|
||||
|
||||
function resolveItemId(tokens: readonly string[], data?: ResumeData): string | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
const itemsIndex = tokens.indexOf("items");
|
||||
if (itemsIndex === -1) return undefined;
|
||||
|
||||
const position = Number(tokens[itemsIndex + 1]);
|
||||
if (!Number.isInteger(position)) return undefined;
|
||||
|
||||
const [head, next] = tokens;
|
||||
let items: readonly unknown[] | undefined;
|
||||
|
||||
if (head === "sections" && next) {
|
||||
items = (data.sections as Record<string, { items?: readonly unknown[] }>)[next]?.items;
|
||||
} else if (head === "customSections") {
|
||||
items = data.customSections[Number(next)]?.items;
|
||||
}
|
||||
|
||||
const item = items?.[position] as { id?: unknown } | undefined;
|
||||
return typeof item?.id === "string" ? item.id : undefined;
|
||||
}
|
||||
|
||||
export function getAtsFindingTarget(pointer: string, data?: ResumeData): AtsFindingTarget | null {
|
||||
const tokens = pointerTokens(pointer);
|
||||
const [head, next] = tokens;
|
||||
const itemId = resolveItemId(tokens, data);
|
||||
const withItem = (target: AtsFindingTarget): AtsFindingTarget => (itemId ? { ...target, itemId } : target);
|
||||
|
||||
if (head === "basics") return { side: "left", section: "basics" };
|
||||
if (head === "picture") return { side: "left", section: "picture" };
|
||||
if (head === "summary") return { side: "left", section: "summary" };
|
||||
if (head === "customSections") return withItem({ side: "left", section: "custom" });
|
||||
|
||||
if (head === "sections" && next && isLeftSidebarSection(next)) {
|
||||
return withItem({ side: "left", section: next });
|
||||
}
|
||||
|
||||
if (head === "metadata") {
|
||||
if (next === "typography") return { side: "right", section: "typography" };
|
||||
if (next === "page") return { side: "right", section: "page" };
|
||||
if (next === "layout") return { side: "right", section: "layout" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAtsFindingLocation(pointer: string): string | null {
|
||||
const target = getAtsFindingTarget(pointer);
|
||||
if (!target) return null;
|
||||
|
||||
const tokens = pointerTokens(pointer);
|
||||
const itemsIndex = tokens.indexOf("items");
|
||||
const sectionTitle = getSectionTitle(target.section);
|
||||
|
||||
if (itemsIndex === -1) return sectionTitle;
|
||||
|
||||
const position = Number(tokens[itemsIndex + 1]);
|
||||
if (!Number.isInteger(position)) return sectionTitle;
|
||||
|
||||
const itemNumber = position + 1;
|
||||
return t`${sectionTitle} · Item ${itemNumber}`;
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
PaletteIcon,
|
||||
PhoneIcon,
|
||||
ReadCvLogoIcon,
|
||||
SealCheckIcon,
|
||||
ShareFatIcon,
|
||||
StarIcon,
|
||||
TextTIcon,
|
||||
@@ -50,7 +49,6 @@ export type RightSidebarSection =
|
||||
| "notes"
|
||||
| "sharing"
|
||||
| "statistics"
|
||||
| "ats"
|
||||
| "export"
|
||||
| "information";
|
||||
|
||||
@@ -85,7 +83,6 @@ export const rightSidebarSections: RightSidebarSection[] = [
|
||||
"styles",
|
||||
"page",
|
||||
"notes",
|
||||
"ats",
|
||||
"export",
|
||||
"information",
|
||||
] as const;
|
||||
@@ -124,7 +121,6 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
|
||||
.with("notes", () => t`Notes`)
|
||||
.with("sharing", () => t`Sharing`)
|
||||
.with("statistics", () => t`Statistics`)
|
||||
.with("ats", () => t`ATS Check`)
|
||||
.with("export", () => t`Export`)
|
||||
.with("information", () => t`Information`)
|
||||
|
||||
@@ -168,7 +164,6 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
|
||||
.with("notes", () => <NotepadIcon {...iconProps} />)
|
||||
.with("sharing", () => <ShareFatIcon {...iconProps} />)
|
||||
.with("statistics", () => <ChartLineIcon {...iconProps} />)
|
||||
.with("ats", () => <SealCheckIcon {...iconProps} />)
|
||||
.with("export", () => <DownloadIcon {...iconProps} />)
|
||||
.with("information", () => <InfoIcon {...iconProps} />)
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import { TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { BuilderAiAssistant } from "./ai-assistant";
|
||||
import { DocumentMenu } from "./document-menu";
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { IconName } from "@reactive-resume/ui/components/icon";
|
||||
import type { EditorMode } from "@/features/resume/editor/store";
|
||||
import type { EditorMode, MobileView } from "@/features/resume/editor/store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
|
||||
import { IssueStepper } from "@/features/resume/editor/check/page-layer";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
|
||||
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
|
||||
import { DesignSheet } from "./design-panel";
|
||||
import { EditorBar } from "./editor-bar";
|
||||
import { ModePanel } from "./mode-panels";
|
||||
import { ShareSheet } from "./share-sheet";
|
||||
import { useEditorHotkeys } from "./use-editor-hotkeys";
|
||||
import { useEditorMode } from "./use-editor-mode";
|
||||
|
||||
const LANDSCAPE_QUERY = "(orientation: landscape)";
|
||||
|
||||
@@ -123,8 +124,6 @@ function TabletBody({ mode }: { mode: EditorMode }) {
|
||||
);
|
||||
}
|
||||
|
||||
type MobileView = "page" | EditorMode;
|
||||
|
||||
type MobileBodyProps = {
|
||||
mode: EditorMode;
|
||||
onModeChange: (mode: EditorMode) => void;
|
||||
@@ -138,7 +137,8 @@ const MOBILE_TABS: { view: MobileView; icon: IconName }[] = [
|
||||
];
|
||||
|
||||
function MobileBody({ mode, onModeChange }: MobileBodyProps) {
|
||||
const [view, setView] = useState<MobileView>(mode);
|
||||
const view = useEditorStore((state) => state.mobileView) ?? mode;
|
||||
const setView = useEditorStore((state) => state.setMobileView);
|
||||
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
|
||||
const labels: Record<MobileView, string> = { write: t`Write`, page: t`Page`, design: t`Design`, check: t`Check` };
|
||||
|
||||
@@ -156,7 +156,8 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
|
||||
<main id="main-content" className={cn("h-full", !pageVisible && "invisible")}>
|
||||
<Outlet />
|
||||
</main>
|
||||
{view === "page" && (
|
||||
{view === "page" && mode === "check" && <IssueStepper />}
|
||||
{view === "page" && mode !== "check" && (
|
||||
<SelectionBar
|
||||
onEdit={() => {
|
||||
setView("write");
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import type { EditorMode } from "@/features/resume/editor/store";
|
||||
import { CheckPanel } from "@/features/resume/editor/check/check-panel";
|
||||
import { OfflineBanner } from "@/features/resume/editor/save-status";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { selectionFromPanelElement } from "@/features/resume/editor/write/reveal";
|
||||
import { WritePanel } from "@/features/resume/editor/write/write-panel";
|
||||
import { AtsCheckSectionBuilder } from "../-sidebar/right/sections/ats-check";
|
||||
import { DesignPanel } from "./design-panel";
|
||||
|
||||
// Until Check mode is rebuilt (M7), it hosts the existing ATS check.
|
||||
function CheckPanel() {
|
||||
return (
|
||||
<div className="@container p-4">
|
||||
<AtsCheckSectionBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WriteMode() {
|
||||
const select = useEditorStore((state) => state.select);
|
||||
|
||||
|
||||
@@ -10,14 +10,17 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useCurrentBuilderResumeSelector, useResumeData } from "@/features/resume/builder/draft";
|
||||
import { CheckPageLayer, PageViewToggle } from "@/features/resume/editor/check/page-layer";
|
||||
import { ParserView } from "@/features/resume/editor/check/parser-view";
|
||||
import { measureOverflow, runFit } from "@/features/resume/editor/design/fit";
|
||||
import { PageOverlay } from "@/features/resume/editor/page-overlay";
|
||||
import { markProposals } from "@/features/resume/editor/proposals/proposals";
|
||||
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store";
|
||||
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
|
||||
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
|
||||
import { ResumePreview } from "@/features/resume/preview/preview";
|
||||
import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useEditorMode } from "./use-editor-mode";
|
||||
|
||||
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
|
||||
const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const;
|
||||
@@ -51,6 +54,9 @@ export function PageCanvas() {
|
||||
const setDrawerOpen = useEditorStore((state) => state.setDrawerOpen);
|
||||
const previewTemplate = useEditorStore((state) => state.previewTemplate);
|
||||
const historyVersionId = useEditorStore((state) => state.historyVersionId);
|
||||
const pageView = useEditorStore((state) => state.pageView);
|
||||
const checkTab = useEditorStore((state) => state.checkTab);
|
||||
const proposals = useEditorStore((state) => state.proposals);
|
||||
const sheetOpen = useEditorStore((state) => state.shareTab !== null);
|
||||
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
|
||||
const { i18n } = useLingui();
|
||||
@@ -73,6 +79,10 @@ export function PageCanvas() {
|
||||
});
|
||||
const viewing = historyVersionId !== null && version?.id === historyVersionId ? version : null;
|
||||
|
||||
// Check → Writing: proposed edits show on the page, the old text struck through and the new highlighted.
|
||||
const markEdits = mode === "check" && checkTab === "writing" && pageView === "page" && proposals.length > 0;
|
||||
const parser = mode === "check" && pageView === "parser" && !viewing;
|
||||
|
||||
// Design: a hovered or focused template is drawn on the page until it's applied or the pointer leaves.
|
||||
const previewData = useMemo(
|
||||
() =>
|
||||
@@ -80,8 +90,10 @@ export function PageCanvas() {
|
||||
? viewing.data
|
||||
: data && previewTemplate
|
||||
? { ...data, metadata: { ...data.metadata, template: previewTemplate } }
|
||||
: undefined,
|
||||
[data, previewTemplate, viewing],
|
||||
: data && markEdits
|
||||
? markProposals(data, proposals)
|
||||
: undefined,
|
||||
[data, previewTemplate, viewing, markEdits, proposals],
|
||||
);
|
||||
const overflow = data && !previewTemplate && !viewing ? measureOverflow(data, rendered) : null;
|
||||
// Desktop: the page moves 120px aside so it stays visible beside the Share & export sheet.
|
||||
@@ -106,7 +118,12 @@ export function PageCanvas() {
|
||||
if (breakpoint === "tablet") setDrawerOpen(false);
|
||||
if (isPhone) select(null);
|
||||
}}
|
||||
className={cn("absolute inset-0 overflow-auto pt-7 pb-24", isPhone ? "px-4" : "px-10")}
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-auto pb-24",
|
||||
// Check keeps room above the page for the page-view toggle.
|
||||
mode === "check" && !viewing ? "pt-[68px]" : "pt-7",
|
||||
isPhone ? "px-4" : "px-10",
|
||||
)}
|
||||
>
|
||||
<ResumePreview
|
||||
data={previewData}
|
||||
@@ -116,6 +133,7 @@ export function PageCanvas() {
|
||||
className={cn(
|
||||
"mx-auto w-fit transition-transform duration-emphasized ease-enter",
|
||||
shifted && "-translate-x-[120px] rtl:translate-x-[120px]",
|
||||
parser && "hidden",
|
||||
)}
|
||||
pageClassName={cn("rounded-none shadow-page", viewing && "outline-2 outline-ink outline-offset-4")}
|
||||
onRender={setRendered}
|
||||
@@ -143,7 +161,11 @@ export function PageCanvas() {
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
|
||||
{mode === "check" ? (
|
||||
<Trans>Page 1 · {formatLabel}</Trans>
|
||||
) : (
|
||||
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
|
||||
)}
|
||||
{overflow && <OverflowChip {...overflow} />}
|
||||
</>
|
||||
)}
|
||||
@@ -163,12 +185,18 @@ export function PageCanvas() {
|
||||
}
|
||||
renderPageOverlay={({ pageIndex, pageMap }) =>
|
||||
// A version from History is read-only: its lines don't open entries.
|
||||
viewing ? null : <PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
|
||||
viewing ? null : mode === "check" ? (
|
||||
<CheckPageLayer pageIndex={pageIndex} pageMap={pageMap} />
|
||||
) : (
|
||||
<PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{parser && <ParserView />}
|
||||
</div>
|
||||
|
||||
<ZoomBar fitScale={fitScale} pageCount={Math.max(1, rendered.pageCount)} />
|
||||
{mode === "check" && !viewing && <PageViewToggle />}
|
||||
{!parser && <ZoomBar fitScale={fitScale} pageCount={Math.max(1, rendered.pageCount)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/co
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
|
||||
import { DownloadTab } from "@/features/resume/share/download-tab";
|
||||
import { HistoryTab } from "@/features/resume/share/history-tab";
|
||||
import { LinkTab } from "@/features/resume/share/link-tab";
|
||||
import { useEditorMode } from "./use-editor-mode";
|
||||
|
||||
/**
|
||||
* Share & export: one sheet with Link, Download and History. Share opens Link, the ▾ beside Download PDF
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefined }));
|
||||
const editorModeState = vi.hoisted(() => ({ setMode: vi.fn() }));
|
||||
|
||||
const deepCheckMocks = vi.hoisted(() => ({
|
||||
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
runAtsCheck: vi.fn(async () => ({
|
||||
report: {
|
||||
version: 1 as const,
|
||||
score: 91,
|
||||
cappedBy: [],
|
||||
categories: [],
|
||||
checks: [],
|
||||
findings: [],
|
||||
tips: [],
|
||||
counts: { blocker: 0, warning: 0, tip: 0 },
|
||||
applicableChecks: 40,
|
||||
passedChecks: 40,
|
||||
skippedChecks: 2,
|
||||
jd: null,
|
||||
file: { name: "resume.pdf", sizeBytes: 4, magicBytesOk: true },
|
||||
document: { pageCount: 1, truncated: false, wordCount: 320, operatorsAvailable: true },
|
||||
},
|
||||
fullText: "Ada Lovelace",
|
||||
})),
|
||||
}));
|
||||
|
||||
type SectionBaseProps = { children: React.ReactNode };
|
||||
|
||||
// The renderer and the engine are exercised by their own tests; here they only have to be callable.
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||
createResumePdfBlob: deepCheckMocks.createResumePdfBlob,
|
||||
}));
|
||||
vi.mock("@/features/ats-checker/run-ats-check", () => ({
|
||||
runAtsCheck: deepCheckMocks.runAtsCheck,
|
||||
blobToPdfFile: (blob: Blob, name: string) => new File([blob], name, { type: "application/pdf" }),
|
||||
}));
|
||||
vi.mock("@/features/ats-checker/report/report-view", () => ({
|
||||
AtsPdfReportView: ({ report }: { report: { score: number } }) => <div>Deep report: {report.score}</div>,
|
||||
}));
|
||||
vi.mock("@/features/ats-checker/ai-review/ai-review-card", () => ({
|
||||
AiReviewCard: () => <div>AI review card</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResumeData: () => resumeState.data,
|
||||
}));
|
||||
vi.mock("../../../-components/use-editor-mode", () => ({
|
||||
useEditorMode: () => ["check", editorModeState.setMode],
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const { AtsCheckSectionBuilder } = await import("./ats-check");
|
||||
const { useEditorStore } = await import("@/features/resume/editor/store");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resumeState.data = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const experienceItem = (overrides: Partial<ExperienceItem> = {}): ExperienceItem => ({
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Engineer",
|
||||
location: "London",
|
||||
period: "Jan 2020 - Present",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "<p>Designed and shipped the difference engine.</p>",
|
||||
roles: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.basics.email = "ada@example.com";
|
||||
data.basics.phone = "+44 20 7946 0100";
|
||||
data.basics.location = "London, UK";
|
||||
data.sections.experience.items = [experienceItem()];
|
||||
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }];
|
||||
|
||||
mutate(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const renderPanel = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AtsCheckSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("AtsCheckSectionBuilder", () => {
|
||||
it("shows no checks at all before the resume is ready", () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.queryByText(/checks passed/)).toBeNull();
|
||||
expect(screen.queryByText(/Deep check/)).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a clean resume as fully passing", () => {
|
||||
resumeState.data = makeResume();
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("21 of 21 checks passed")).toBeTruthy();
|
||||
expect(screen.getByText(/Every check passed/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists a finding with its title and remedy", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.basics.email = "ada at example dot com";
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("This email address will not be recognized.")).toBeTruthy();
|
||||
expect(screen.getByText(/Use a plain address/)).toBeTruthy();
|
||||
expect(screen.getByText("20 of 21 checks passed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("counts findings by severity", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.basics.email = "";
|
||||
data.picture.url = "/uploads/ada.png";
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("1 error")).toBeTruthy();
|
||||
expect(screen.getByText("1 note")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens what a finding is about in Write when its location is clicked", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.basics.email = "";
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Basics/ }));
|
||||
|
||||
expect(editorModeState.setMode).toHaveBeenCalledWith("write");
|
||||
expect(useEditorStore.getState()).toMatchObject({ selection: { kind: "header" }, basicsOpen: true });
|
||||
});
|
||||
|
||||
it("points typography findings at the Type group in Design", () => {
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
const group = document.createElement("section");
|
||||
group.id = "design-type";
|
||||
group.scrollIntoView = vi.fn();
|
||||
document.body.append(group);
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.metadata.typography.body.fontSize = 8;
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Typography/ }));
|
||||
|
||||
expect(editorModeState.setMode).toHaveBeenCalledWith("design");
|
||||
expect(group.scrollIntoView).toHaveBeenCalled();
|
||||
group.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the deep check tier", () => {
|
||||
beforeEach(() => {
|
||||
resumeState.data = makeResume();
|
||||
});
|
||||
|
||||
it("offers a deep check alongside the live lint", () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("Deep check")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Run deep check/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the current resume to PDF and reports on those bytes", async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Deep report: 91/)).toBeTruthy());
|
||||
expect(deepCheckMocks.createResumePdfBlob).toHaveBeenCalledWith(resumeState.data);
|
||||
expect(deepCheckMocks.runAtsCheck).toHaveBeenCalledWith(expect.any(File), { jobDescription: "" });
|
||||
});
|
||||
|
||||
it("offers the AI review only once a deep check has produced a report", async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.queryByText("AI review card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("AI review card")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("passes a pasted job description through to the check", async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Job description/), { target: { value: "Kubernetes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deepCheckMocks.runAtsCheck).toHaveBeenCalledWith(expect.any(File), { jobDescription: "Kubernetes" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("says so plainly when the check cannot finish", async () => {
|
||||
deepCheckMocks.createResumePdfBlob.mockRejectedValueOnce(new Error("render failed"));
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/could not finish/)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigating to a finding", () => {
|
||||
// The jump switches editor mode first and scrolls on the next frame, once the panel has rendered.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const stubElement = (id: string) => {
|
||||
const element = document.createElement("div");
|
||||
element.id = id;
|
||||
element.scrollIntoView = vi.fn();
|
||||
document.body.append(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
it("scrolls to the item a finding belongs to, not the section header", () => {
|
||||
const section = stubElement("sidebar-experience");
|
||||
const item = stubElement("resume-item-exp-1");
|
||||
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
|
||||
|
||||
expect(item.scrollIntoView).toHaveBeenCalled();
|
||||
expect(section.scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends two findings in the same section to different items", () => {
|
||||
const first = stubElement("resume-item-exp-1");
|
||||
const second = stubElement("resume-item-exp-2");
|
||||
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.sections.experience.items = [
|
||||
experienceItem({ id: "exp-1", period: "nonsense" }),
|
||||
experienceItem({ id: "exp-2", period: "gibberish" }),
|
||||
];
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
const buttons = screen.getAllByRole("button", { name: /Experience · Item/ });
|
||||
fireEvent.click(buttons[0] as HTMLElement);
|
||||
fireEvent.click(buttons[1] as HTMLElement);
|
||||
|
||||
expect(first.scrollIntoView).toHaveBeenCalledOnce();
|
||||
expect(second.scrollIntoView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to the section header when the item is not mounted", () => {
|
||||
const section = stubElement("sidebar-experience");
|
||||
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
|
||||
|
||||
expect(section.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,289 +0,0 @@
|
||||
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { AtsCheckResult } from "@/features/ats-checker/run-ats-check";
|
||||
import type { SidebarSection } from "@/libs/resume/section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, CheckCircleIcon, MagnifyingGlassIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { lintResumeForAts } from "@reactive-resume/resume/ats";
|
||||
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
|
||||
import { AtsPdfReportView } from "@/features/ats-checker/report/report-view";
|
||||
import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check";
|
||||
import { useResumeData } from "@/features/resume/builder/draft";
|
||||
import { getScrollBehavior, revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "@/libs/resume/ats";
|
||||
import { useEditorMode } from "../../../-components/use-editor-mode";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
|
||||
const SEVERITIES = ["error", "warning", "info"] as const;
|
||||
|
||||
// Design findings scroll to the Design group that fixes them.
|
||||
const DESIGN_GROUPS: Partial<Record<SidebarSection, string>> = { typography: "type", page: "page", layout: "template" };
|
||||
|
||||
function severityDotClass(severity: AtsSeverity) {
|
||||
return match(severity)
|
||||
.with("error", () => "bg-danger")
|
||||
.with("warning", () => "bg-warn")
|
||||
.with("info", () => "bg-info-text")
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function severityLabel(severity: AtsSeverity) {
|
||||
return match(severity)
|
||||
.with("error", () => t`Error`)
|
||||
.with("warning", () => t`Warning`)
|
||||
.with("info", () => t`Note`)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
type SeverityCountProps = {
|
||||
severity: AtsSeverity;
|
||||
count: number;
|
||||
};
|
||||
|
||||
function SeverityCount({ severity, count }: SeverityCountProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span className={cn("size-2 shrink-0 rounded-full", severityDotClass(severity))} aria-hidden />
|
||||
{match(severity)
|
||||
.with("error", () => <Plural value={count} one="# error" other="# errors" />)
|
||||
.with("warning", () => <Plural value={count} one="# warning" other="# warnings" />)
|
||||
.with("info", () => <Plural value={count} one="# note" other="# notes" />)
|
||||
.exhaustive()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type AtsFindingRowProps = {
|
||||
finding: AtsFinding;
|
||||
onJump: (pointer: string) => void;
|
||||
};
|
||||
|
||||
function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
|
||||
const message = getAtsFindingMessage(finding.code);
|
||||
const location = getAtsFindingLocation(finding.pointer);
|
||||
|
||||
return (
|
||||
<li className="space-y-2 rounded-md border bg-card p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", severityDotClass(finding.severity))} aria-hidden />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-sm leading-snug">{message.title}</p>
|
||||
<p className="text-muted-foreground text-xs leading-normal">{message.action}</p>
|
||||
</div>
|
||||
|
||||
<Badge variant="neutral" className="shrink-0">
|
||||
{severityLabel(finding.severity)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{location && (
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1.5 px-2 text-xs" onClick={() => onJump(finding.pointer)}>
|
||||
{location}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveLintTier() {
|
||||
const data = useResumeData();
|
||||
const [, setMode] = useEditorMode();
|
||||
|
||||
const report = useMemo(() => (data ? lintResumeForAts(data) : null), [data]);
|
||||
|
||||
const onJump = useCallback(
|
||||
(pointer: string) => {
|
||||
const target = getAtsFindingTarget(pointer, data);
|
||||
if (!target) return;
|
||||
|
||||
// Content findings open the entry in Write; design findings open their section in Design.
|
||||
if (target.side === "left") {
|
||||
const customIndex = target.section === "custom" ? Number(pointer.split("/")[2]) : -1;
|
||||
const sectionId = customIndex >= 0 ? data?.customSections[customIndex]?.id : target.section;
|
||||
if (!sectionId) return;
|
||||
setMode("write");
|
||||
revealSelectionInPanel(
|
||||
sectionId === "basics" || sectionId === "picture"
|
||||
? { kind: "header" }
|
||||
: target.itemId
|
||||
? { kind: "item", sectionId, itemId: target.itemId }
|
||||
: { kind: "section", sectionId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMode("design");
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.getElementById(`design-${DESIGN_GROUPS[target.section] ?? "template"}`)
|
||||
?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
|
||||
});
|
||||
},
|
||||
[data, setMode],
|
||||
);
|
||||
|
||||
if (!report) return null;
|
||||
|
||||
const { counts, findings, passedRules, totalRules } = report;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border bg-card p-3">
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
These checks run as you type and never leave your browser. They test whether software can read your resume,
|
||||
not how well it is written.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
<Trans>
|
||||
{passedRules} of {totalRules} checks passed
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full translate-x-(--fill) rounded-full bg-primary transition-[translate] duration-200 ease-out-strong rtl:-translate-x-(--fill)"
|
||||
style={{ "--fill": `${(passedRules / totalRules) * 100 - 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{findings.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{SEVERITIES.filter((severity) => counts[severity] > 0).map((severity) => (
|
||||
<SeverityCount key={severity} severity={severity} count={counts[severity]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{findings.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-md border border-dashed p-3">
|
||||
<CheckCircleIcon className="size-5 shrink-0 text-accent-text" />
|
||||
<p className="text-muted-foreground text-sm leading-normal">
|
||||
<Trans>Every check passed. Nothing here should stop a parser from reading your resume.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{findings.map((finding) => (
|
||||
<AtsFindingRow key={`${finding.code}:${finding.pointer}`} finding={finding} onJump={onJump} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier two and three: render the current resume to a PDF and run the same file-level check the
|
||||
* standalone page runs, then offer the optional AI review on top of the result.
|
||||
*
|
||||
* The render happens in this tab and the report is derived from those bytes, so the deep check
|
||||
* measures the file a recruiter would actually receive rather than the draft that produced it.
|
||||
*/
|
||||
function DeepCheckTier() {
|
||||
const data = useResumeData();
|
||||
|
||||
const [jobDescription, setJobDescription] = useState("");
|
||||
const [result, setResult] = useState<AtsCheckResult | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onRun = async () => {
|
||||
if (!data || isRunning) return;
|
||||
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(data);
|
||||
setResult(await runAtsCheck(blobToPdfFile(blob, "resume.pdf"), { jobDescription }));
|
||||
} catch {
|
||||
setError(t`The deep check could not finish. Please try again.`);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
<Trans>Deep check</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Renders your current resume to PDF and runs the full file check in your browser. Nothing is uploaded.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="builder-ats-job-description" className="text-xs">
|
||||
<Trans>Job description (optional)</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="builder-ats-job-description"
|
||||
rows={3}
|
||||
maxLength={MAX_JOB_DESCRIPTION_CHARS}
|
||||
value={jobDescription}
|
||||
placeholder={t`Paste a posting to see which of its terms already appear in your resume.`}
|
||||
onChange={(event) => setJobDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button size="sm" disabled={isRunning} onClick={() => void onRun()}>
|
||||
{isRunning ? <Spinner /> : <MagnifyingGlassIcon />}
|
||||
{isRunning ? <Trans>Checking…</Trans> : result ? <Trans>Run again</Trans> : <Trans>Run deep check</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
<WarningCircleIcon />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<AtsPdfReportView report={result.report} />
|
||||
<AiReviewCard report={result.report} fullText={result.fullText} jobDescription={jobDescription} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AtsCheckSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="ats" className="space-y-4">
|
||||
<LiveLintTier />
|
||||
<Separator />
|
||||
<DeepCheckTier />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user