Tailor all ai fields (#106)

* initial commit

* accordionising

* merge extra cards

* visual highlight

* everybody becomes an accordion

* bugbashing

* text area

* split up tailoring editor

* feat: enhance `parseTailoredSkills` to support legacy string arrays and add comprehensive tests for parsing logic.
This commit is contained in:
Shaheer Sarfaraz 2026-02-08 01:30:36 +00:00 committed by GitHub
parent cf7032ce5e
commit 9b80c2e05d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1537 additions and 740 deletions

View File

@ -18,7 +18,7 @@ There are two main ways a job becomes Ready:
1) **Manual flow (most common)**
- A job starts in `discovered`.
- You open it in the Discovered panel, decide to Tailor.
- In Tailor mode you edit the job description (optional), summary, and project picks.
- In Tailor mode you can edit job description (optional), tailored summary, tailored headline, tailored skills, and project picks.
- You click **Finalize & Move to Ready**.
- This runs summarization (if needed), generates the PDF, and sets status to `ready`.
@ -40,7 +40,7 @@ The PDF is generated from:
- The base resume selected from your v4.rxresu.me account (via Onboarding or Settings).
- The job description (used for AI tailoring and project selection).
- Your tailored summary/headline/skills and selected projects.
- Your tailored summary, tailored headline, tailored skills, and selected projects.
Paths:
@ -71,6 +71,8 @@ PATCH /api/jobs/:id
{
"jobDescription": "<new JD>",
"tailoredSummary": "<optional>",
"tailoredHeadline": "<optional>",
"tailoredSkills": "[{\"name\":\"Backend\",\"keywords\":[\"TypeScript\",\"Node.js\"]}]",
"selectedProjectIds": "p1,p2"
}
```

View File

@ -22,11 +22,22 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
({
id: "job-1",
tailoredSummary: "Saved summary",
tailoredHeadline: "Saved headline",
tailoredSkills: JSON.stringify([
{ name: "Core", keywords: ["React", "TypeScript"] },
]),
jobDescription: "Saved description",
selectedProjectIds: "p1",
...overrides,
}) as Job;
const ensureAccordionOpen = (name: string) => {
const trigger = screen.getByRole("button", { name });
if (trigger.getAttribute("aria-expanded") !== "true") {
fireEvent.click(trigger);
}
};
describe("TailoringEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -39,6 +50,7 @@ describe("TailoringEditor", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
@ -50,6 +62,7 @@ describe("TailoringEditor", () => {
onUpdate={vi.fn()}
/>,
);
ensureAccordionOpen("Summary");
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Local draft",
@ -63,6 +76,7 @@ describe("TailoringEditor", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
@ -73,16 +87,28 @@ describe("TailoringEditor", () => {
job={createJob({
id: "job-2",
tailoredSummary: "New job summary",
tailoredHeadline: "New job headline",
tailoredSkills: JSON.stringify([
{ name: "Backend", keywords: ["Node.js", "Postgres"] },
]),
jobDescription: "New job description",
selectedProjectIds: "",
})}
onUpdate={vi.fn()}
/>,
);
ensureAccordionOpen("Summary");
ensureAccordionOpen("Headline");
ensureAccordionOpen("Tailored Skills");
ensureAccordionOpen("Backend");
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"New job summary",
);
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"New job headline",
);
expect(screen.getByDisplayValue("Node.js, Postgres")).toBeInTheDocument();
});
it("emits dirty state changes", async () => {
@ -97,6 +123,7 @@ describe("TailoringEditor", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
@ -112,6 +139,7 @@ describe("TailoringEditor", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
const summary = screen.getByLabelText("Tailored Summary");
fireEvent.focus(summary);
@ -122,9 +150,92 @@ describe("TailoringEditor", () => {
onUpdate={vi.fn()}
/>,
);
ensureAccordionOpen("Summary");
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Saved summary",
);
});
it("does not clobber local headline edits from same-job prop updates", async () => {
const { rerender } = render(
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Headline");
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
target: { value: "Local headline draft" },
});
rerender(
<TailoringEditor
job={createJob({ tailoredHeadline: "Incoming headline from poll" })}
onUpdate={vi.fn()}
/>,
);
ensureAccordionOpen("Headline");
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"Local headline draft",
);
});
it("saves headline and skills in update payload", async () => {
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Headline");
ensureAccordionOpen("Tailored Skills");
ensureAccordionOpen("Core");
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
target: { value: "Updated headline" },
});
fireEvent.change(screen.getByLabelText("Keywords (comma-separated)"), {
target: { value: "Node.js, TypeScript" },
});
fireEvent.click(screen.getByRole("button", { name: "Save Selection" }));
await waitFor(() =>
expect(api.updateJob).toHaveBeenCalledWith(
"job-1",
expect.objectContaining({
tailoredHeadline: "Updated headline",
tailoredSkills:
'[{"name":"Core","keywords":["Node.js","TypeScript"]}]',
}),
),
);
});
it("hydrates headline and skills after AI summarize", async () => {
vi.mocked(api.summarizeJob).mockResolvedValueOnce({
...createJob(),
tailoredSummary: "AI summary",
tailoredHeadline: "AI headline",
tailoredSkills: JSON.stringify([
{ name: "Backend", keywords: ["Node.js", "Kafka"] },
]),
} as Job);
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
fireEvent.click(screen.getByRole("button", { name: "AI Summarize" }));
await waitFor(() => ensureAccordionOpen("Headline"));
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"AI headline",
);
ensureAccordionOpen("Tailored Skills");
ensureAccordionOpen("Backend");
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
});
});

View File

@ -1,18 +1,6 @@
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
import {
AlertTriangle,
Check,
FileText,
Loader2,
Sparkles,
} from "lucide-react";
import type { Job } from "@shared/types.js";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import * as api from "../api";
import { TailoringWorkspace } from "./tailoring/TailoringWorkspace";
interface TailoringEditorProps {
job: Job;
@ -22,17 +10,6 @@ interface TailoringEditorProps {
onBeforeGenerate?: () => boolean | Promise<boolean>;
}
const parseSelectedIds = (value: string | null | undefined) =>
new Set(value?.split(",").filter(Boolean) ?? []);
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
if (current.size !== saved.size) return true;
for (const id of current) {
if (!saved.has(id)) return true;
}
return false;
};
export const TailoringEditor: React.FC<TailoringEditorProps> = ({
job,
onUpdate,
@ -40,327 +17,14 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
onRegisterSave,
onBeforeGenerate,
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedDescription, setSavedDescription] = useState(
job.jobDescription || "",
);
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [activeField, setActiveField] = useState<
"summary" | "description" | null
>(null);
const lastJobIdRef = useRef(job.id);
const isDirty = useMemo(() => {
if (summary !== savedSummary) return true;
if (jobDescription !== savedDescription) return true;
return hasSelectionDiff(selectedIds, savedSelectedIds);
}, [
summary,
savedSummary,
jobDescription,
savedDescription,
selectedIds,
savedSelectedIds,
]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
return () => onDirtyChange?.(false);
}, [onDirtyChange]);
useEffect(() => {
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
}, []);
useEffect(() => {
const incomingSummary = job.tailoredSummary || "";
const incomingDescription = job.jobDescription || "";
const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds);
if (job.id !== lastJobIdRef.current) {
lastJobIdRef.current = job.id;
setSummary(incomingSummary);
setJobDescription(incomingDescription);
setSelectedIds(incomingSelectedIds);
setSavedSummary(incomingSummary);
setSavedDescription(incomingDescription);
setSavedSelectedIds(incomingSelectedIds);
return;
}
if (isDirty || activeField !== null) return;
setSummary(incomingSummary);
setJobDescription(incomingDescription);
setSelectedIds(incomingSelectedIds);
setSavedSummary(incomingSummary);
setSavedDescription(incomingDescription);
setSavedSelectedIds(incomingSelectedIds);
}, [
job.id,
job.tailoredSummary,
job.jobDescription,
job.selectedProjectIds,
isDirty,
activeField,
]);
const syncSavedSnapshot = useCallback(
(
nextSummary: string,
nextDescription: string,
nextSelectedIds: Set<string>,
) => {
setSavedSummary(nextSummary);
setSavedDescription(nextDescription);
setSavedSelectedIds(new Set(nextSelectedIds));
},
[],
);
const selectedIdsCsv = useMemo(
() => Array.from(selectedIds).join(","),
[selectedIds],
);
const saveChanges = useCallback(
async ({ showToast = true }: { showToast?: boolean } = {}) => {
try {
setIsSaving(true);
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription,
selectedProjectIds: selectedIdsCsv,
});
syncSavedSnapshot(summary, jobDescription, selectedIds);
if (showToast) toast.success("Changes saved");
await onUpdate();
} catch (error) {
if (showToast) toast.error("Failed to save changes");
throw error;
} finally {
setIsSaving(false);
}
},
[
job.id,
onUpdate,
selectedIdsCsv,
selectedIds,
summary,
jobDescription,
syncSavedSnapshot,
],
);
useEffect(() => {
onRegisterSave?.(() => saveChanges({ showToast: false }));
}, [onRegisterSave, saveChanges]);
const handleToggleProject = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
const handleSave = async () => {
try {
await saveChanges();
} catch {
// Toast handled in saveChanges
}
};
const handleSummarize = async () => {
try {
setIsSummarizing(true);
if (isDirty) {
await saveChanges({ showToast: false });
}
const updatedJob = await api.summarizeJob(job.id, { force: true });
const nextSummary = updatedJob.tailoredSummary || "";
const nextDescription = updatedJob.jobDescription || "";
const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds);
setSummary(nextSummary);
setJobDescription(nextDescription);
setSelectedIds(nextSelectedIds);
syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds);
toast.success("AI Summary & Projects generated");
await onUpdate();
} catch (error) {
const message =
error instanceof Error ? error.message : "AI summarization failed";
toast.error(message);
} finally {
setIsSummarizing(false);
}
};
const handleGeneratePdf = async () => {
try {
const shouldProceed = onBeforeGenerate ? await onBeforeGenerate() : true;
if (shouldProceed === false) return;
setIsGeneratingPdf(true);
await saveChanges({ showToast: false });
await api.generateJobPdf(job.id);
toast.success("Resume PDF generated");
await onUpdate();
} catch (_error) {
toast.error("PDF generation failed");
} finally {
setIsGeneratingPdf(false);
}
};
const maxProjects = 3;
const tooManyProjects = selectedIds.size > maxProjects;
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Editor</h3>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
size="sm"
variant="outline"
onClick={handleSummarize}
disabled={isSummarizing || isGeneratingPdf || isSaving}
className="w-full sm:w-auto"
>
{isSummarizing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
AI Summarize
</Button>
<Button
size="sm"
onClick={handleGeneratePdf}
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
className="w-full sm:w-auto"
>
{isGeneratingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Generate PDF
</Button>
</div>
</div>
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<label htmlFor="tailor-jd" className="text-sm font-medium">
Job Description (Edit to help AI tailoring)
</label>
<textarea
id="tailor-jd"
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
onFocus={() => setActiveField("description")}
onBlur={() =>
setActiveField((prev) => (prev === "description" ? null : prev))
}
placeholder="The raw job description..."
/>
</div>
<Separator />
<div className="space-y-2">
<label htmlFor="tailor-summary" className="text-sm font-medium">
Tailored Summary
</label>
<textarea
id="tailor-summary"
className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(e) => setSummary(e.target.value)}
onFocus={() => setActiveField("summary")}
onBlur={() =>
setActiveField((prev) => (prev === "summary" ? null : prev))
}
placeholder="AI will generate this, or you can write your own..."
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<span className="text-sm font-medium">Selected Projects</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<AlertTriangle className="h-3 w-3" />
Warning: More than {maxProjects} projects might make the resume
too long.
</span>
)}
</div>
<div className="grid gap-2 max-h-[300px] overflow-auto pr-2">
{catalog.map((project) => (
<div
key={project.id}
className="flex items-start gap-3 rounded-lg border p-3 text-sm transition-colors hover:bg-muted/50"
>
<Checkbox
id={`project-${project.id}`}
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleProject(project.id)}
className="mt-1"
/>
<label
htmlFor={`project-${project.id}`}
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<span className="font-semibold">{project.name}</span>
<span className="text-xs text-muted-foreground line-clamp-2">
{project.description}
</span>
</label>
</div>
))}
</div>
</div>
<div className="flex justify-end border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Selection
</Button>
</div>
</div>
</div>
<TailoringWorkspace
mode="editor"
job={job}
onUpdate={onUpdate}
onDirtyChange={onDirtyChange}
onRegisterSave={onRegisterSave}
onBeforeGenerate={onBeforeGenerate}
/>
);
};

View File

@ -21,11 +21,22 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
({
id: "job-1",
tailoredSummary: "Saved summary",
tailoredHeadline: "Saved headline",
tailoredSkills: JSON.stringify([
{ name: "Core", keywords: ["React", "TypeScript"] },
]),
jobDescription: "Saved description",
selectedProjectIds: "p1",
...overrides,
}) as Job;
const ensureAccordionOpen = (name: string) => {
const trigger = screen.getByRole("button", { name });
if (trigger.getAttribute("aria-expanded") !== "true") {
fireEvent.click(trigger);
}
};
describe("TailorMode", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -43,6 +54,7 @@ describe("TailorMode", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
@ -56,6 +68,7 @@ describe("TailorMode", () => {
isFinalizing={false}
/>,
);
ensureAccordionOpen("Summary");
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Local draft",
@ -74,6 +87,7 @@ describe("TailorMode", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
@ -84,6 +98,10 @@ describe("TailorMode", () => {
job={createJob({
id: "job-2",
tailoredSummary: "New job summary",
tailoredHeadline: "New job headline",
tailoredSkills: JSON.stringify([
{ name: "Backend", keywords: ["Node.js", "Postgres"] },
]),
jobDescription: "New job description",
selectedProjectIds: "",
})}
@ -92,10 +110,18 @@ describe("TailorMode", () => {
isFinalizing={false}
/>,
);
ensureAccordionOpen("Summary");
ensureAccordionOpen("Headline");
ensureAccordionOpen("Tailored Skills");
ensureAccordionOpen("Backend");
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"New job summary",
);
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"New job headline",
);
expect(screen.getByDisplayValue("Node.js, Postgres")).toBeInTheDocument();
});
it("does not sync same-job props while summary field is focused", async () => {
@ -110,6 +136,7 @@ describe("TailorMode", () => {
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
const summary = screen.getByLabelText("Tailored Summary");
fireEvent.focus(summary);
@ -122,9 +149,116 @@ describe("TailorMode", () => {
isFinalizing={false}
/>,
);
ensureAccordionOpen("Summary");
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Saved summary",
);
});
it("does not clobber local headline edits from same-job prop updates", async () => {
const { rerender } = render(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Headline");
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
target: { value: "Local headline draft" },
});
rerender(
<TailorMode
job={createJob({ tailoredHeadline: "Incoming headline from poll" })}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
ensureAccordionOpen("Headline");
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"Local headline draft",
);
});
it("autosaves headline and skills in update payload", async () => {
vi.mocked(api.updateJob).mockResolvedValue({} as Job);
render(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Headline");
ensureAccordionOpen("Tailored Skills");
ensureAccordionOpen("Core");
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
target: { value: "Updated headline" },
});
fireEvent.change(screen.getByLabelText("Keywords (comma-separated)"), {
target: { value: "Node.js, TypeScript" },
});
await waitFor(
() =>
expect(api.updateJob).toHaveBeenCalledWith(
"job-1",
expect.objectContaining({
tailoredHeadline: "Updated headline",
tailoredSkills:
'[{"name":"Core","keywords":["Node.js","TypeScript"]}]',
}),
),
{ timeout: 3500 },
);
});
it("hydrates headline and skills after AI draft generation", async () => {
vi.mocked(api.summarizeJob).mockResolvedValueOnce({
...createJob(),
tailoredSummary: "AI summary",
tailoredHeadline: "AI headline",
tailoredSkills: JSON.stringify([
{ name: "Backend", keywords: ["Node.js", "Kafka"] },
]),
} as Job);
render(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
fireEvent.click(screen.getByRole("button", { name: "Generate draft" }));
await waitFor(() => ensureAccordionOpen("Headline"));
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"AI headline",
);
ensureAccordionOpen("Tailored Skills");
ensureAccordionOpen("Backend");
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
});
});

View File

@ -1,13 +1,6 @@
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
import type { Job } from "@shared/types.js";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import * as api from "../../api";
import { CollapsibleSection } from "./CollapsibleSection";
import { ProjectSelector } from "./ProjectSelector";
import { TailoringWorkspace } from "../tailoring/TailoringWorkspace";
interface TailorModeProps {
job: Job;
@ -15,21 +8,9 @@ interface TailorModeProps {
onFinalize: () => void;
isFinalizing: boolean;
onDirtyChange?: (isDirty: boolean) => void;
/** Variant controls the finalize button text. Default is 'discovered'. */
variant?: "discovered" | "ready";
}
const parseSelectedIds = (value: string | null | undefined) =>
new Set(value?.split(",").filter(Boolean) ?? []);
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
if (current.size !== saved.size) return true;
for (const id of current) {
if (!saved.has(id)) return true;
}
return false;
};
export const TailorMode: React.FC<TailorModeProps> = ({
job,
onBack,
@ -38,377 +19,15 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onDirtyChange,
variant = "discovered",
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedDescription, setSavedDescription] = useState(
job.jobDescription || "",
);
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [isGenerating, setIsGenerating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [draftStatus, setDraftStatus] = useState<
"unsaved" | "saving" | "saved"
>("saved");
const [showDescription, setShowDescription] = useState(false);
const [activeField, setActiveField] = useState<
"summary" | "description" | null
>(null);
const lastJobIdRef = useRef(job.id);
useEffect(() => {
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
}, []);
const isDirty = useMemo(() => {
if (summary !== savedSummary) return true;
if (jobDescription !== savedDescription) return true;
return hasSelectionDiff(selectedIds, savedSelectedIds);
}, [
summary,
savedSummary,
jobDescription,
savedDescription,
selectedIds,
savedSelectedIds,
]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
return () => onDirtyChange?.(false);
}, [onDirtyChange]);
useEffect(() => {
const incomingSummary = job.tailoredSummary || "";
const incomingDescription = job.jobDescription || "";
const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds);
if (job.id !== lastJobIdRef.current) {
lastJobIdRef.current = job.id;
setSummary(incomingSummary);
setJobDescription(incomingDescription);
setSelectedIds(incomingSelectedIds);
setSavedSummary(incomingSummary);
setSavedDescription(incomingDescription);
setSavedSelectedIds(incomingSelectedIds);
setDraftStatus("saved");
return;
}
if (isDirty || activeField !== null) return;
setSummary(incomingSummary);
setJobDescription(incomingDescription);
setSelectedIds(incomingSelectedIds);
setSavedSummary(incomingSummary);
setSavedDescription(incomingDescription);
setSavedSelectedIds(incomingSelectedIds);
setDraftStatus("saved");
}, [
job.id,
job.tailoredSummary,
job.jobDescription,
job.selectedProjectIds,
isDirty,
activeField,
]);
useEffect(() => {
if (isDirty && draftStatus === "saved") {
setDraftStatus("unsaved");
}
if (!isDirty && draftStatus === "unsaved") {
setDraftStatus("saved");
}
}, [isDirty, draftStatus]);
const selectedIdsCsv = useMemo(
() => Array.from(selectedIds).join(","),
[selectedIds],
);
const syncSavedSnapshot = useCallback(
(
nextSummary: string,
nextDescription: string,
nextSelectedIds: Set<string>,
) => {
setSavedSummary(nextSummary);
setSavedDescription(nextDescription);
setSavedSelectedIds(new Set(nextSelectedIds));
setDraftStatus("saved");
},
[],
);
const persistCurrent = useCallback(async () => {
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription,
selectedProjectIds: selectedIdsCsv,
});
syncSavedSnapshot(summary, jobDescription, selectedIds);
}, [
job.id,
summary,
jobDescription,
selectedIdsCsv,
selectedIds,
syncSavedSnapshot,
]);
useEffect(() => {
if (!isDirty || draftStatus !== "unsaved") return;
const timeout = setTimeout(async () => {
try {
setDraftStatus("saving");
await persistCurrent();
} catch {
setDraftStatus("unsaved");
}
}, 1500);
return () => clearTimeout(timeout);
}, [isDirty, draftStatus, persistCurrent]);
const handleToggleProject = useCallback(
(id: string) => {
if (isGenerating || isFinalizing) return;
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[isGenerating, isFinalizing],
);
const handleGenerateWithAI = async () => {
try {
setIsGenerating(true);
if (isDirty) {
await persistCurrent();
}
const updatedJob = await api.summarizeJob(job.id, { force: true });
const nextSummary = updatedJob.tailoredSummary || "";
const nextDescription = updatedJob.jobDescription || "";
const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds);
setSummary(nextSummary);
setJobDescription(nextDescription);
setSelectedIds(nextSelectedIds);
syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds);
toast.success("Draft generated with AI", {
description: "Review and edit before finalizing.",
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to generate AI draft";
toast.error(message);
} finally {
setIsGenerating(false);
}
};
const handleFinalize = async () => {
if (isDirty) {
try {
setIsSaving(true);
await persistCurrent();
} catch {
toast.error("Failed to save draft before finalizing");
setIsSaving(false);
return;
} finally {
setIsSaving(false);
}
}
onFinalize();
};
const maxProjects = 3;
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
const disableInputs = isGenerating || isFinalizing || isSaving;
return (
<div className="flex flex-col h-full">
<div className="flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to overview
</button>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{draftStatus === "saving" && (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Saving...
</>
)}
{draftStatus === "saved" && !isDirty && (
<>
<Check className="h-3 w-3 text-emerald-400" />
Saved
</>
)}
{draftStatus === "unsaved" && (
<span className="text-amber-400">Unsaved changes</span>
)}
</div>
</div>
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
<span className="text-xs font-medium text-amber-300">
Draft tailoring for this role
</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1 ml-4">
Edit below, then finalize to generate your PDF and move to Ready.
</p>
</div>
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
<div className="flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-xs font-medium">
Need help getting started?
</div>
<div className="text-[10px] text-muted-foreground">
AI can draft a summary and select projects for you
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={handleGenerateWithAI}
disabled={isGenerating || isFinalizing}
className="h-8 w-full text-xs sm:w-auto"
>
{isGenerating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
Generate draft
</Button>
</div>
<CollapsibleSection
isOpen={showDescription}
onToggle={() => setShowDescription((prev) => !prev)}
label={`${showDescription ? "Hide" : "Edit"} job description`}
>
<div className="space-y-1">
<label
htmlFor="tailor-jd-edit"
className="text-[10px] font-medium text-muted-foreground/70"
>
Edit to help AI tailoring
</label>
<textarea
id="tailor-jd-edit"
className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription}
onChange={(event) => setJobDescription(event.target.value)}
onFocus={() => setActiveField("description")}
onBlur={() =>
setActiveField((prev) => (prev === "description" ? null : prev))
}
placeholder="The raw job description..."
disabled={disableInputs}
/>
</div>
</CollapsibleSection>
<div className="space-y-2">
<label
htmlFor="tailor-summary-edit"
className="text-xs font-medium text-muted-foreground"
>
Tailored Summary
</label>
<textarea
id="tailor-summary-edit"
className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(event) => setSummary(event.target.value)}
onFocus={() => setActiveField("summary")}
onBlur={() =>
setActiveField((prev) => (prev === "summary" ? null : prev))
}
placeholder="Write a tailored summary for this role, or generate with AI..."
disabled={disableInputs}
/>
</div>
<ProjectSelector
catalog={catalog}
selectedIds={selectedIds}
onToggle={handleToggleProject}
maxProjects={maxProjects}
disabled={disableInputs}
/>
</div>
<Separator className="opacity-50 my-4" />
<div className="space-y-2">
{!canFinalize && (
<p className="text-[10px] text-center text-muted-foreground">
Add a summary and select at least one project to{" "}
{variant === "ready" ? "regenerate" : "finalize"}.
</p>
)}
<Button
onClick={handleFinalize}
disabled={isFinalizing || !canFinalize || isGenerating}
className="w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white"
>
{isFinalizing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{variant === "ready"
? "Regenerating PDF..."
: "Finalizing & generating PDF..."}
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
{variant === "ready"
? "Regenerate PDF"
: "Finalize & Move to Ready"}
</>
)}
</Button>
<p className="text-[10px] text-center text-muted-foreground/70">
{variant === "ready"
? "This will save your changes and regenerate the tailored PDF."
: "This will generate your tailored PDF and move the job to Ready."}
</p>
</div>
</div>
<TailoringWorkspace
mode="tailor"
job={job}
onBack={onBack}
onFinalize={onFinalize}
isFinalizing={isFinalizing}
onDirtyChange={onDirtyChange}
variant={variant}
/>
);
};

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { parseTailoredSkills } from "./tailoring-utils";
describe("parseTailoredSkills", () => {
it("parses object-based tailored skills payload", () => {
const parsed = parseTailoredSkills(
JSON.stringify([
{ name: "Backend", keywords: ["Node.js", " TypeScript "] },
]),
);
expect(parsed).toEqual([
{ name: "Backend", keywords: ["Node.js", "TypeScript"] },
]);
});
it("maps legacy string arrays into a default skills group", () => {
const parsed = parseTailoredSkills(
JSON.stringify(["React", " TypeScript ", "", "Vitest"]),
);
expect(parsed).toEqual([
{ name: "Skills", keywords: ["React", "TypeScript", "Vitest"] },
]);
});
it("keeps object groups and legacy string values in mixed arrays", () => {
const parsed = parseTailoredSkills(
JSON.stringify([
{ name: "Platform", keywords: ["APIs"] },
"Observability",
]),
);
expect(parsed).toEqual([
{ name: "Platform", keywords: ["APIs"] },
{ name: "Skills", keywords: ["Observability"] },
]);
});
it("returns an empty list for invalid or non-array JSON", () => {
expect(parseTailoredSkills("{")).toEqual([]);
expect(parseTailoredSkills(JSON.stringify({ name: "Backend" }))).toEqual(
[],
);
});
});

View File

@ -0,0 +1,95 @@
export interface TailoredSkillGroup {
name: string;
keywords: string[];
}
export interface EditableSkillGroup {
id: string;
name: string;
keywordsText: string;
}
let skillDraftCounter = 0;
export function createTailoredSkillDraftId(): string {
skillDraftCounter += 1;
return `skill-group-${skillDraftCounter}`;
}
export function parseTailoredSkills(
raw: string | null | undefined,
): TailoredSkillGroup[] {
if (!raw || raw.trim().length === 0) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
const groups: TailoredSkillGroup[] = [];
const legacyKeywords: string[] = [];
for (const item of parsed) {
if (typeof item === "string") {
const keyword = item.trim();
if (keyword.length > 0) legacyKeywords.push(keyword);
continue;
}
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name.trim() : "";
const keywordsRaw = Array.isArray(record.keywords)
? record.keywords
: typeof record.keywords === "string"
? record.keywords.split(",")
: [];
const keywords = keywordsRaw
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean);
if (!name && keywords.length === 0) continue;
groups.push({ name, keywords });
}
if (legacyKeywords.length > 0) {
groups.push({ name: "Skills", keywords: legacyKeywords });
}
return groups;
} catch {
return [];
}
}
export function serializeTailoredSkills(groups: TailoredSkillGroup[]): string {
if (groups.length === 0) return "";
return JSON.stringify(groups);
}
export function toEditableSkillGroups(
groups: TailoredSkillGroup[],
): EditableSkillGroup[] {
return groups.map((group) => ({
id: createTailoredSkillDraftId(),
name: group.name,
keywordsText: group.keywords.join(", "),
}));
}
export function fromEditableSkillGroups(
groups: EditableSkillGroup[],
): TailoredSkillGroup[] {
const normalized: TailoredSkillGroup[] = [];
for (const group of groups) {
const name = group.name.trim();
const keywords = group.keywordsText
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (!name && keywords.length === 0) continue;
normalized.push({ name, keywords });
}
return normalized;
}

View File

@ -0,0 +1,261 @@
import type { ResumeProjectCatalogItem } from "@shared/types.js";
import { Plus, Trash2 } from "lucide-react";
import type React from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
import type { EditableSkillGroup } from "../tailoring-utils";
import type { TailoringActiveField } from "./useTailoringDraft";
type FocusableField = Exclude<TailoringActiveField, null>;
interface TailoringSectionsProps {
catalog: ResumeProjectCatalogItem[];
summary: string;
headline: string;
jobDescription: string;
skillsDraft: EditableSkillGroup[];
selectedIds: Set<string>;
openSkillGroupId: string;
disableInputs: boolean;
onSummaryChange: (value: string) => void;
onHeadlineChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onSkillGroupOpenChange: (value: string) => void;
onAddSkillGroup: () => void;
onUpdateSkillGroup: (
id: string,
key: "name" | "keywordsText",
value: string,
) => void;
onRemoveSkillGroup: (id: string) => void;
onToggleProject: (id: string) => void;
onFieldFocus: (field: FocusableField) => void;
onFieldBlur: (field: FocusableField) => void;
}
const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0";
const triggerClass =
"px-3 py-2 text-xs font-medium text-muted-foreground hover:no-underline";
const inputClass =
"w-full rounded-md border border-border/60 bg-background/60 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
export const TailoringSections: React.FC<TailoringSectionsProps> = ({
catalog,
summary,
headline,
jobDescription,
skillsDraft,
selectedIds,
openSkillGroupId,
disableInputs,
onSummaryChange,
onHeadlineChange,
onDescriptionChange,
onSkillGroupOpenChange,
onAddSkillGroup,
onUpdateSkillGroup,
onRemoveSkillGroup,
onToggleProject,
onFieldFocus,
onFieldBlur,
}) => {
return (
<Accordion type="multiple" className="space-y-3">
<AccordionItem value="job-description" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Job Description
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<label htmlFor="tailor-jd-edit" className="sr-only">
Job Description
</label>
<textarea
id="tailor-jd-edit"
className={`${inputClass} min-h-[120px] max-h-[250px]`}
value={jobDescription}
onChange={(event) => onDescriptionChange(event.target.value)}
onFocus={() => onFieldFocus("description")}
onBlur={() => onFieldBlur("description")}
placeholder="The raw job description..."
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="summary" className={sectionClass}>
<AccordionTrigger className={triggerClass}>Summary</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<label htmlFor="tailor-summary-edit" className="sr-only">
Tailored Summary
</label>
<textarea
id="tailor-summary-edit"
className={`${inputClass} min-h-[120px]`}
value={summary}
onChange={(event) => onSummaryChange(event.target.value)}
onFocus={() => onFieldFocus("summary")}
onBlur={() => onFieldBlur("summary")}
placeholder="Write a tailored summary for this role, or generate with AI..."
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="headline" className={sectionClass}>
<AccordionTrigger className={triggerClass}>Headline</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<label htmlFor="tailor-headline-edit" className="sr-only">
Tailored Headline
</label>
<input
id="tailor-headline-edit"
type="text"
className={inputClass}
value={headline}
onChange={(event) => onHeadlineChange(event.target.value)}
onFocus={() => onFieldFocus("headline")}
onBlur={() => onFieldBlur("headline")}
placeholder="Write a concise headline tailored to this role..."
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="skills" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Tailored Skills
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<div className="flex flex-wrap items-center justify-end gap-2 pb-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px]"
onClick={onAddSkillGroup}
disabled={disableInputs}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Skill Group
</Button>
</div>
{skillsDraft.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 px-3 py-4 text-center text-[11px] text-muted-foreground">
No skill groups yet. Add one to tailor keywords for this role.
</div>
) : (
<Accordion
type="single"
collapsible
value={openSkillGroupId}
onValueChange={onSkillGroupOpenChange}
className="space-y-2"
>
{skillsDraft.map((group, index) => (
<AccordionItem
key={group.id}
value={group.id}
className="rounded-lg border border-border/60 bg-background/40 px-0"
>
<AccordionTrigger className="px-3 py-2 text-[11px] font-medium hover:no-underline">
{group.name.trim() || `Skill Group ${index + 1}`}
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<div className="space-y-2">
<div className="space-y-1">
<label
htmlFor={`tailor-skill-group-name-${group.id}`}
className="text-[11px] font-medium text-muted-foreground"
>
Category
</label>
<input
id={`tailor-skill-group-name-${group.id}`}
type="text"
className={inputClass}
value={group.name}
onChange={(event) =>
onUpdateSkillGroup(
group.id,
"name",
event.target.value,
)
}
onFocus={() => onFieldFocus("skills")}
onBlur={() => onFieldBlur("skills")}
placeholder="Backend, Frontend, Infrastructure..."
disabled={disableInputs}
/>
</div>
<div className="space-y-1">
<label
htmlFor={`tailor-skill-group-keywords-${group.id}`}
className="text-[11px] font-medium text-muted-foreground"
>
Keywords (comma-separated)
</label>
<textarea
id={`tailor-skill-group-keywords-${group.id}`}
className={`${inputClass} min-h-[88px]`}
value={group.keywordsText}
onChange={(event) =>
onUpdateSkillGroup(
group.id,
"keywordsText",
event.target.value,
)
}
onFocus={() => onFieldFocus("skills")}
onBlur={() => onFieldBlur("skills")}
placeholder="TypeScript, Node.js, REST APIs..."
disabled={disableInputs}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => onRemoveSkillGroup(group.id)}
disabled={disableInputs}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Remove
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="projects" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Selected Projects
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<ProjectSelector
catalog={catalog}
selectedIds={selectedIds}
onToggle={onToggleProject}
maxProjects={3}
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};

View File

@ -0,0 +1,455 @@
import type { Job } from "@shared/types.js";
import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import * as api from "../../api";
import { TailoringSections } from "./TailoringSections";
import { useTailoringDraft } from "./useTailoringDraft";
interface TailoringWorkspaceBaseProps {
job: Job;
onDirtyChange?: (isDirty: boolean) => void;
}
interface TailoringWorkspaceEditorProps extends TailoringWorkspaceBaseProps {
mode: "editor";
onUpdate: () => void | Promise<void>;
onRegisterSave?: (save: () => Promise<void>) => void;
onBeforeGenerate?: () => boolean | Promise<boolean>;
}
interface TailoringWorkspaceTailorProps extends TailoringWorkspaceBaseProps {
mode: "tailor";
onBack: () => void;
onFinalize: () => void;
isFinalizing: boolean;
variant?: "discovered" | "ready";
}
type TailoringWorkspaceProps =
| TailoringWorkspaceEditorProps
| TailoringWorkspaceTailorProps;
export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
props,
) => {
const editorProps = props.mode === "editor" ? props : null;
const tailorProps = props.mode === "tailor" ? props : null;
const {
catalog,
summary,
setSummary,
headline,
setHeadline,
jobDescription,
setJobDescription,
selectedIds,
selectedIdsCsv,
skillsDraft,
openSkillGroupId,
setOpenSkillGroupId,
skillsJson,
isDirty,
setActiveField,
markCurrentAsSaved,
applyIncomingDraft,
handleToggleProject,
handleAddSkillGroup,
handleUpdateSkillGroup,
handleRemoveSkillGroup,
} = useTailoringDraft({
job: props.job,
onDirtyChange: props.onDirtyChange,
});
const [isSaving, setIsSaving] = useState(false);
const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [draftStatus, setDraftStatus] = useState<
"unsaved" | "saving" | "saved"
>("saved");
const savePayload = useMemo(
() => ({
tailoredSummary: summary,
tailoredHeadline: headline,
tailoredSkills: skillsJson,
jobDescription,
selectedProjectIds: selectedIdsCsv,
}),
[summary, headline, skillsJson, jobDescription, selectedIdsCsv],
);
const persistCurrent = useCallback(async () => {
await api.updateJob(props.job.id, savePayload);
markCurrentAsSaved();
if (tailorProps) {
setDraftStatus("saved");
}
}, [props.job.id, savePayload, markCurrentAsSaved, tailorProps]);
useEffect(() => {
if (!tailorProps) return;
if (isDirty && draftStatus === "saved") {
setDraftStatus("unsaved");
}
if (!isDirty && draftStatus === "unsaved") {
setDraftStatus("saved");
}
}, [tailorProps, isDirty, draftStatus]);
useEffect(() => {
if (!tailorProps) return;
if (!isDirty || draftStatus !== "unsaved") return;
const timeout = setTimeout(async () => {
try {
setDraftStatus("saving");
await persistCurrent();
} catch {
setDraftStatus("unsaved");
}
}, 1500);
return () => clearTimeout(timeout);
}, [tailorProps, isDirty, draftStatus, persistCurrent]);
const saveChanges = useCallback(
async ({ showToast = true }: { showToast?: boolean } = {}) => {
if (!editorProps) return;
try {
setIsSaving(true);
await api.updateJob(props.job.id, savePayload);
markCurrentAsSaved();
if (showToast) toast.success("Changes saved");
await editorProps.onUpdate();
} catch (error) {
if (showToast) toast.error("Failed to save changes");
throw error;
} finally {
setIsSaving(false);
}
},
[editorProps, props.job.id, savePayload, markCurrentAsSaved],
);
useEffect(() => {
if (!editorProps?.onRegisterSave) return;
editorProps.onRegisterSave(() => saveChanges({ showToast: false }));
}, [editorProps, saveChanges]);
const handleFieldBlur = useCallback(
(field: "summary" | "headline" | "description" | "skills") => {
setActiveField((prev) => (prev === field ? null : prev));
},
[setActiveField],
);
const handleSummarizeEditor = useCallback(async () => {
if (!editorProps) return;
try {
setIsSummarizing(true);
if (isDirty) {
await saveChanges({ showToast: false });
}
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
applyIncomingDraft(updatedJob);
toast.success("AI Summary & Projects generated");
await editorProps.onUpdate();
} catch (error) {
const message =
error instanceof Error ? error.message : "AI summarization failed";
toast.error(message);
} finally {
setIsSummarizing(false);
}
}, [editorProps, isDirty, saveChanges, props.job.id, applyIncomingDraft]);
const handleGenerateWithAi = useCallback(async () => {
if (!tailorProps) return;
try {
setIsGenerating(true);
if (isDirty) {
await persistCurrent();
}
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
applyIncomingDraft(updatedJob);
setDraftStatus("saved");
toast.success("Draft generated with AI", {
description: "Review and edit before finalizing.",
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to generate AI draft";
toast.error(message);
} finally {
setIsGenerating(false);
}
}, [tailorProps, isDirty, persistCurrent, props.job.id, applyIncomingDraft]);
const handleGeneratePdf = useCallback(async () => {
if (!editorProps) return;
try {
const shouldProceed = editorProps.onBeforeGenerate
? await editorProps.onBeforeGenerate()
: true;
if (shouldProceed === false) return;
setIsGeneratingPdf(true);
await saveChanges({ showToast: false });
await api.generateJobPdf(props.job.id);
toast.success("Resume PDF generated");
await editorProps.onUpdate();
} catch {
toast.error("PDF generation failed");
} finally {
setIsGeneratingPdf(false);
}
}, [editorProps, saveChanges, props.job.id]);
const handleFinalize = useCallback(async () => {
if (!tailorProps) return;
if (isDirty) {
try {
setIsSaving(true);
await persistCurrent();
} catch {
toast.error("Failed to save draft before finalizing");
setIsSaving(false);
return;
} finally {
setIsSaving(false);
}
}
tailorProps.onFinalize();
}, [tailorProps, isDirty, persistCurrent]);
const disableInputs = editorProps
? isSummarizing || isGeneratingPdf || isSaving
: isGenerating || Boolean(tailorProps?.isFinalizing) || isSaving;
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
if (editorProps) {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
Editor
</h3>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
size="sm"
variant="outline"
onClick={handleSummarizeEditor}
disabled={isSummarizing || isGeneratingPdf || isSaving}
className="w-full sm:w-auto"
>
{isSummarizing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
AI Summarize
</Button>
<Button
size="sm"
onClick={handleGeneratePdf}
disabled={
isSummarizing || isGeneratingPdf || isSaving || !summary
}
className="w-full sm:w-auto"
>
{isGeneratingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Generate PDF
</Button>
</div>
</div>
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<TailoringSections
catalog={catalog}
summary={summary}
headline={headline}
jobDescription={jobDescription}
skillsDraft={skillsDraft}
selectedIds={selectedIds}
openSkillGroupId={openSkillGroupId}
disableInputs={disableInputs}
onSummaryChange={setSummary}
onHeadlineChange={setHeadline}
onDescriptionChange={setJobDescription}
onSkillGroupOpenChange={setOpenSkillGroupId}
onAddSkillGroup={handleAddSkillGroup}
onUpdateSkillGroup={handleUpdateSkillGroup}
onRemoveSkillGroup={handleRemoveSkillGroup}
onToggleProject={handleToggleProject}
onFieldFocus={setActiveField}
onFieldBlur={handleFieldBlur}
/>
<div className="flex justify-end border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => void saveChanges()}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Selection
</Button>
</div>
</div>
</div>
);
}
if (!tailorProps) return null;
const finalizeVariant = tailorProps.variant ?? "discovered";
return (
<div className="flex h-full flex-col">
<div className="flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={tailorProps.onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to overview
</button>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{draftStatus === "saving" && (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Saving...
</>
)}
{draftStatus === "saved" && !isDirty && (
<>
<Check className="h-3 w-3 text-emerald-400" />
Saved
</>
)}
{draftStatus === "unsaved" && (
<span className="text-amber-400">Unsaved changes</span>
)}
</div>
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div className="flex flex-col gap-3 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
<span className="text-xs font-medium text-amber-300">
Draft tailoring for this role
</span>
</div>
<p className="ml-4 mt-1 text-[10px] text-muted-foreground">
AI can draft summary, headline, skills, and project selection.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={handleGenerateWithAi}
disabled={isGenerating || tailorProps.isFinalizing || isSaving}
className="h-8 w-full text-xs sm:w-auto"
>
{isGenerating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
Generate draft
</Button>
</div>
<TailoringSections
catalog={catalog}
summary={summary}
headline={headline}
jobDescription={jobDescription}
skillsDraft={skillsDraft}
selectedIds={selectedIds}
openSkillGroupId={openSkillGroupId}
disableInputs={disableInputs}
onSummaryChange={setSummary}
onHeadlineChange={setHeadline}
onDescriptionChange={setJobDescription}
onSkillGroupOpenChange={setOpenSkillGroupId}
onAddSkillGroup={handleAddSkillGroup}
onUpdateSkillGroup={handleUpdateSkillGroup}
onRemoveSkillGroup={handleRemoveSkillGroup}
onToggleProject={handleToggleProject}
onFieldFocus={setActiveField}
onFieldBlur={handleFieldBlur}
/>
</div>
<Separator className="my-4 opacity-50" />
<div className="space-y-2">
{!canFinalize && (
<p className="text-center text-[10px] text-muted-foreground">
Add a summary and select at least one project to{" "}
{finalizeVariant === "ready" ? "regenerate" : "finalize"}.
</p>
)}
<Button
onClick={() => void handleFinalize()}
disabled={tailorProps.isFinalizing || !canFinalize || isGenerating}
className="h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500"
>
{tailorProps.isFinalizing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{finalizeVariant === "ready"
? "Regenerating PDF..."
: "Finalizing & generating PDF..."}
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
{finalizeVariant === "ready"
? "Regenerate PDF"
: "Finalize & Move to Ready"}
</>
)}
</Button>
<p className="text-center text-[10px] text-muted-foreground/70">
{finalizeVariant === "ready"
? "This will save your changes and regenerate the tailored PDF."
: "This will generate your tailored PDF and move the job to Ready."}
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,277 @@
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "../../api";
import {
createTailoredSkillDraftId,
type EditableSkillGroup,
fromEditableSkillGroups,
parseTailoredSkills,
serializeTailoredSkills,
toEditableSkillGroups,
} from "../tailoring-utils";
export type TailoringActiveField =
| "summary"
| "headline"
| "description"
| "skills"
| null;
const parseSelectedIds = (value: string | null | undefined) =>
new Set(value?.split(",").filter(Boolean) ?? []);
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
if (current.size !== saved.size) return true;
for (const id of current) {
if (!saved.has(id)) return true;
}
return false;
};
const parseIncomingDraft = (incomingJob: Job) => {
const summary = incomingJob.tailoredSummary || "";
const headline = incomingJob.tailoredHeadline || "";
const description = incomingJob.jobDescription || "";
const selectedIds = parseSelectedIds(incomingJob.selectedProjectIds);
const skillsDraft = toEditableSkillGroups(
parseTailoredSkills(incomingJob.tailoredSkills),
);
const skillsJson = serializeTailoredSkills(
fromEditableSkillGroups(skillsDraft),
);
return {
summary,
headline,
description,
selectedIds,
skillsDraft,
skillsJson,
};
};
interface UseTailoringDraftParams {
job: Job;
onDirtyChange?: (isDirty: boolean) => void;
}
export function useTailoringDraft({
job,
onDirtyChange,
}: UseTailoringDraftParams) {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [headline, setHeadline] = useState(job.tailoredHeadline || "");
const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [skillsDraft, setSkillsDraft] = useState<EditableSkillGroup[]>(() =>
toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)),
);
const [openSkillGroupId, setOpenSkillGroupId] = useState<string>("");
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedHeadline, setSavedHeadline] = useState(
job.tailoredHeadline || "",
);
const [savedDescription, setSavedDescription] = useState(
job.jobDescription || "",
);
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [savedSkillsJson, setSavedSkillsJson] = useState(() =>
serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)),
);
const [activeField, setActiveField] = useState<TailoringActiveField>(null);
const lastJobIdRef = useRef(job.id);
const skillsJson = useMemo(
() => serializeTailoredSkills(fromEditableSkillGroups(skillsDraft)),
[skillsDraft],
);
const selectedIdsCsv = useMemo(
() => Array.from(selectedIds).join(","),
[selectedIds],
);
const isDirty = useMemo(() => {
if (summary !== savedSummary) return true;
if (headline !== savedHeadline) return true;
if (jobDescription !== savedDescription) return true;
if (skillsJson !== savedSkillsJson) return true;
return hasSelectionDiff(selectedIds, savedSelectedIds);
}, [
summary,
savedSummary,
headline,
savedHeadline,
jobDescription,
savedDescription,
skillsJson,
savedSkillsJson,
selectedIds,
savedSelectedIds,
]);
const syncSavedSnapshot = useCallback(
(
nextSummary: string,
nextHeadline: string,
nextDescription: string,
nextSelectedIds: Set<string>,
nextSkillsDraft: EditableSkillGroup[],
) => {
setSavedSummary(nextSummary);
setSavedHeadline(nextHeadline);
setSavedDescription(nextDescription);
setSavedSelectedIds(new Set(nextSelectedIds));
setSavedSkillsJson(
serializeTailoredSkills(fromEditableSkillGroups(nextSkillsDraft)),
);
},
[],
);
const markCurrentAsSaved = useCallback(() => {
syncSavedSnapshot(
summary,
headline,
jobDescription,
selectedIds,
skillsDraft,
);
}, [
syncSavedSnapshot,
summary,
headline,
jobDescription,
selectedIds,
skillsDraft,
]);
const applyIncomingDraft = useCallback((incomingJob: Job) => {
const next = parseIncomingDraft(incomingJob);
setSummary(next.summary);
setHeadline(next.headline);
setJobDescription(next.description);
setSelectedIds(next.selectedIds);
setSkillsDraft(next.skillsDraft);
setSavedSummary(next.summary);
setSavedHeadline(next.headline);
setSavedDescription(next.description);
setSavedSelectedIds(next.selectedIds);
setSavedSkillsJson(next.skillsJson);
}, []);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
return () => onDirtyChange?.(false);
}, [onDirtyChange]);
useEffect(() => {
api
.getResumeProjectsCatalog()
.then(setCatalog)
.catch(() => setCatalog([]));
}, []);
useEffect(() => {
if (job.id !== lastJobIdRef.current) {
lastJobIdRef.current = job.id;
applyIncomingDraft(job);
return;
}
if (isDirty || activeField !== null) return;
applyIncomingDraft(job);
}, [
job,
job.id,
job.tailoredSummary,
job.tailoredHeadline,
job.tailoredSkills,
job.jobDescription,
job.selectedProjectIds,
isDirty,
activeField,
applyIncomingDraft,
]);
useEffect(() => {
if (
openSkillGroupId.length > 0 &&
!skillsDraft.some((group) => group.id === openSkillGroupId)
) {
setOpenSkillGroupId("");
}
}, [skillsDraft, openSkillGroupId]);
const handleToggleProject = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleAddSkillGroup = useCallback(() => {
const nextId = createTailoredSkillDraftId();
setSkillsDraft((prev) => [
...prev,
{ id: nextId, name: "", keywordsText: "" },
]);
setOpenSkillGroupId(nextId);
}, []);
const handleUpdateSkillGroup = useCallback(
(id: string, key: "name" | "keywordsText", value: string) => {
setSkillsDraft((prev) =>
prev.map((group) =>
group.id === id ? { ...group, [key]: value } : group,
),
);
},
[],
);
const handleRemoveSkillGroup = useCallback((id: string) => {
setSkillsDraft((prev) => prev.filter((group) => group.id !== id));
}, []);
return {
catalog,
summary,
setSummary,
headline,
setHeadline,
jobDescription,
setJobDescription,
selectedIds,
selectedIdsCsv,
skillsDraft,
openSkillGroupId,
setOpenSkillGroupId,
skillsJson,
isDirty,
activeField,
setActiveField,
markCurrentAsSaved,
applyIncomingDraft,
syncSavedSnapshot,
handleToggleProject,
handleAddSkillGroup,
handleUpdateSkillGroup,
handleRemoveSkillGroup,
};
}

View File

@ -0,0 +1,96 @@
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils";
describe.sequential("Jobs tailoring PATCH route", () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
async function createManualJobId(): Promise<string> {
const response = await fetch(`${baseUrl}/api/manual-jobs/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
job: {
title: "Backend Engineer",
employer: "Acme",
jobDescription: "Build backend systems",
},
}),
});
expect(response.status).toBe(200);
const body = (await response.json()) as {
ok: boolean;
data?: { id: string };
};
expect(body.ok).toBe(true);
expect(body.data?.id).toBeTruthy();
const jobId = body.data?.id;
if (!jobId) {
throw new Error("Expected manual job import to return job id");
}
return jobId;
}
it("accepts tailoredHeadline and tailoredSkills when JSON shape is valid", async () => {
const jobId = await createManualJobId();
const skills = JSON.stringify([
{ name: "Backend", keywords: ["TypeScript", "Node.js"] },
]);
const response = await fetch(`${baseUrl}/api/jobs/${jobId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tailoredHeadline: "Senior Backend Engineer",
tailoredSkills: skills,
}),
});
expect(response.status).toBe(200);
const body = (await response.json()) as {
ok: boolean;
data?: { tailoredHeadline: string; tailoredSkills: string };
};
expect(body.ok).toBe(true);
expect(body.data?.tailoredHeadline).toBe("Senior Backend Engineer");
expect(body.data?.tailoredSkills).toBe(skills);
});
it("rejects malformed tailoredSkills payload with 400", async () => {
const jobId = await createManualJobId();
const response = await fetch(`${baseUrl}/api/jobs/${jobId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tailoredHeadline: "Senior Backend Engineer",
tailoredSkills: '{"name":"Backend","keywords":["TypeScript"]}',
}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as {
ok?: boolean;
error?: { message?: string } | string;
};
if (typeof body.error === "string") {
expect(body.error).toContain("JSON array");
return;
}
expect(body.ok).toBe(false);
expect(body.error?.message || "").toContain("JSON array");
});
});

View File

@ -45,6 +45,13 @@ import * as visaSponsors from "../../services/visa-sponsors/index";
export const jobsRouter = Router();
const tailoredSkillsPayloadSchema = z.array(
z.object({
name: z.string(),
keywords: z.array(z.string()),
}),
);
async function notifyJobCompleteWebhook(job: Job) {
const overrideWebhookUrl = await settingsRepo.getSetting(
"jobCompleteWebhookUrl",
@ -115,6 +122,35 @@ const updateJobSchema = z.object({
suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(),
tailoredSummary: z.string().optional(),
tailoredHeadline: z.string().optional(),
tailoredSkills: z
.string()
.optional()
.superRefine((value, ctx) => {
if (value === undefined || value.trim().length === 0) return;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tailoredSkills must be a JSON array of { name, keywords } objects",
});
return;
}
const parseResult = tailoredSkillsPayloadSchema.safeParse(parsed);
if (!parseResult.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tailoredSkills must be a JSON array of { name, keywords } objects",
});
}
}),
selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(),
sponsorMatchScore: z.number().min(0).max(100).optional(),