Fix Tailor CV adding new skills and restore original skills on revert (#190)

* Initial commit

* refactor slightly

* refactor and fix bugs
This commit is contained in:
Shaheer Sarfaraz 2026-02-18 23:23:33 +00:00 committed by GitHub
parent a148030d46
commit 16dd17ebea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 806 additions and 259 deletions

View File

@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { useProfile } from "../hooks/useProfile";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { TailoringEditor } from "./TailoringEditor";
@ -14,6 +15,10 @@ vi.mock("../api", () => ({
getTracerReadiness: vi.fn(),
}));
vi.mock("../hooks/useProfile", () => ({
useProfile: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
@ -54,6 +59,32 @@ describe("TailoringEditor", () => {
lastSuccessAt: Date.now(),
reason: null,
});
vi.mocked(useProfile).mockReturnValue({
profile: {
basics: {
summary: "Original base summary",
label: "Original base headline",
},
sections: {
skills: {
items: [
{
id: "s1",
name: "Backend",
description: "",
level: 0,
keywords: ["Node.js", "TypeScript"],
visible: true,
},
],
},
},
},
error: null,
isLoading: false,
personName: "Resume",
refreshProfile: vi.fn(),
});
});
it("does not rehydrate local edits from same-job prop updates", async () => {
@ -277,4 +308,93 @@ describe("TailoringEditor", () => {
),
);
});
it("supports undo to template and redo to AI draft", async () => {
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Original base summary",
);
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Saved summary",
);
ensureAccordionOpen("Headline");
fireEvent.click(screen.getAllByLabelText("Undo to template")[1]);
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"Original base headline",
);
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]);
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"Saved headline",
);
ensureAccordionOpen("Tailored Skills");
fireEvent.click(screen.getAllByLabelText("Undo to template")[2]);
ensureAccordionOpen("Backend");
expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]);
ensureAccordionOpen("Core");
expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument();
});
it("resets redo baseline when switching jobs", async () => {
const { rerender } = render(
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Original base summary",
);
rerender(
<TailoringEditor
job={createJob({
id: "job-2",
tailoredSummary: "Second job summary",
})}
onUpdate={vi.fn()}
/>,
);
ensureAccordionOpen("Summary");
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Second job summary",
);
});
it("keeps undo disabled until profile template is loaded", async () => {
vi.mocked(useProfile).mockReturnValue({
profile: null,
error: null,
isLoading: true,
personName: "Resume",
refreshProfile: vi.fn(),
});
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
ensureAccordionOpen("Headline");
ensureAccordionOpen("Tailored Skills");
for (const button of screen.getAllByLabelText("Undo to template")) {
expect(button).toBeDisabled();
}
});
});

View File

@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
import { useProfile } from "../../hooks/useProfile";
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
import { TailorMode } from "./TailorMode";
@ -13,6 +14,10 @@ vi.mock("../../api", () => ({
getTracerReadiness: vi.fn(),
}));
vi.mock("../../hooks/useProfile", () => ({
useProfile: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
@ -53,6 +58,32 @@ describe("TailorMode", () => {
lastSuccessAt: Date.now(),
reason: null,
});
vi.mocked(useProfile).mockReturnValue({
profile: {
basics: {
summary: "Original base summary",
label: "Original base headline",
},
sections: {
skills: {
items: [
{
id: "s1",
name: "Backend",
description: "",
level: 0,
keywords: ["Node.js", "TypeScript"],
visible: true,
},
],
},
},
},
error: null,
isLoading: false,
personName: "Resume",
refreshProfile: vi.fn(),
});
});
it("does not rehydrate local edits from same-job prop updates", async () => {
@ -270,4 +301,46 @@ describe("TailorMode", () => {
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
});
it("supports undo to template and redo to AI draft", async () => {
render(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
ensureAccordionOpen("Summary");
fireEvent.click(screen.getAllByLabelText("Undo to template")[0]);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Original base summary",
);
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Saved summary",
);
ensureAccordionOpen("Headline");
fireEvent.click(screen.getAllByLabelText("Undo to template")[1]);
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"Original base headline",
);
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]);
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
"Saved headline",
);
ensureAccordionOpen("Tailored Skills");
fireEvent.click(screen.getAllByLabelText("Undo to template")[2]);
ensureAccordionOpen("Backend");
expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]);
ensureAccordionOpen("Core");
expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument();
});
});

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseTailoredSkills } from "./tailoring-utils";
import {
getOriginalHeadline,
getOriginalSkills,
getOriginalSummary,
parseTailoredSkills,
} from "./tailoring-utils";
describe("parseTailoredSkills", () => {
it("parses object-based tailored skills payload", () => {
@ -44,4 +49,45 @@ describe("parseTailoredSkills", () => {
[],
);
});
it("extracts original summary and headline from profile basics", () => {
const profile = {
basics: {
summary: " Base summary ",
label: " Base headline ",
},
};
expect(getOriginalSummary(profile)).toBe("Base summary");
expect(getOriginalHeadline(profile)).toBe("Base headline");
});
it("extracts original skills from profile skills items", () => {
const profile = {
sections: {
skills: {
items: [
{
id: "1",
name: "Backend",
description: "",
level: 0,
keywords: [" Node.js ", "TypeScript"],
visible: true,
},
],
},
},
};
expect(getOriginalSkills(profile)).toEqual([
{ name: "Backend", keywords: ["Node.js", "TypeScript"] },
]);
});
it("returns defaults when profile sections are missing", () => {
expect(getOriginalSummary(null)).toBe("");
expect(getOriginalHeadline(null)).toBe("");
expect(getOriginalSkills(null)).toEqual([]);
});
});

View File

@ -1,3 +1,5 @@
import type { ResumeProfile } from "@shared/types";
export interface TailoredSkillGroup {
name: string;
keywords: string[];
@ -93,3 +95,42 @@ export function fromEditableSkillGroups(
return normalized;
}
export function getOriginalSummary(profile: ResumeProfile | null): string {
if (!profile) return "";
return profile.basics?.summary?.trim() ?? "";
}
export function getOriginalHeadline(profile: ResumeProfile | null): string {
if (!profile) return "";
return profile.basics?.label?.trim() ?? "";
}
export function getOriginalSkills(
profile: ResumeProfile | null,
): TailoredSkillGroup[] {
if (!profile) return [];
const items = profile.sections?.skills?.items;
if (!Array.isArray(items)) return [];
const groups: TailoredSkillGroup[] = [];
for (const item of items) {
if (!item || typeof item !== "object") continue;
const name =
typeof item.name === "string"
? item.name.trim()
: typeof item.description === "string"
? item.description.trim()
: "";
const keywordsRaw = Array.isArray(item.keywords) ? item.keywords : [];
const keywords = keywordsRaw
.filter((value: unknown): value is string => typeof value === "string")
.map((value: string) => value.trim())
.filter(Boolean);
if (!name && keywords.length === 0) continue;
groups.push({ name, keywords });
}
return groups;
}

View File

@ -1,5 +1,5 @@
import type { ResumeProjectCatalogItem } from "@shared/types.js";
import { Plus, Trash2 } from "lucide-react";
import { Plus, Redo2, Trash2, Undo2 } from "lucide-react";
import type React from "react";
import {
Accordion,
@ -9,6 +9,12 @@ import {
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
import type { EditableSkillGroup } from "../tailoring-utils";
@ -28,6 +34,19 @@ interface TailoringSectionsProps {
disableInputs: boolean;
onSummaryChange: (value: string) => void;
onHeadlineChange: (value: string) => void;
onUndoSummary: () => void;
onUndoHeadline: () => void;
onUndoSkills: () => void;
onRedoSummary: () => void;
onRedoHeadline: () => void;
onRedoSkills: () => void;
canUndoSummary: boolean;
canUndoHeadline: boolean;
canUndoSkills: boolean;
canRedoSummary: boolean;
canRedoHeadline: boolean;
canRedoSkills: boolean;
undoDisabledReason?: string | null;
onDescriptionChange: (value: string) => void;
onSkillGroupOpenChange: (value: string) => void;
onAddSkillGroup: () => void;
@ -63,6 +82,19 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
disableInputs,
onSummaryChange,
onHeadlineChange,
onUndoSummary,
onUndoHeadline,
onUndoSkills,
onRedoSummary,
onRedoHeadline,
onRedoSkills,
canUndoSummary,
canUndoHeadline,
canUndoSkills,
canRedoSummary,
canRedoHeadline,
canRedoSkills,
undoDisabledReason = null,
onDescriptionChange,
onSkillGroupOpenChange,
onAddSkillGroup,
@ -73,226 +105,345 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
}) => {
const tracerToggleDisabled =
disableInputs || (!tracerLinksEnabled && tracerEnableBlocked);
const undoTooltip = "Undo to template";
const redoTooltip = "Redo to AI draft";
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)}
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)}
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)}
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,
)
}
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,
)
}
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>
{!isCatalogLoading && catalog.length > 0 && (
<AccordionItem value="projects" className={sectionClass}>
<TooltipProvider>
<Accordion type="multiple" className="space-y-3">
<AccordionItem value="job-description" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Selected Projects
Job Description
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<ProjectSelector
catalog={catalog}
selectedIds={selectedIds}
onToggle={onToggleProject}
maxProjects={3}
<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)}
placeholder="The raw job description..."
disabled={disableInputs}
/>
</AccordionContent>
</AccordionItem>
)}
<AccordionItem value="tracer-links" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Tracer Links
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<div className="rounded-md border border-border/60 bg-background/60 p-3">
<label
htmlFor="tailor-tracer-links-enabled"
className="flex cursor-pointer items-center gap-3"
>
<Checkbox
id="tailor-tracer-links-enabled"
checked={tracerLinksEnabled}
onCheckedChange={(checked) =>
onTracerLinksEnabledChange(Boolean(checked))
}
disabled={tracerToggleDisabled}
/>
<span className="text-sm font-medium text-foreground">
Enable tracer links for this job
</span>
<AccordionItem value="summary" className={sectionClass}>
<AccordionTrigger className={triggerClass}>Summary</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<div className="mb-2 flex justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onUndoSummary}
disabled={disableInputs || !canUndoSummary}
aria-label={undoTooltip}
title={
!canUndoSummary
? (undoDisabledReason ?? undefined)
: undefined
}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{undoTooltip}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onRedoSummary}
disabled={disableInputs || !canRedoSummary}
aria-label={redoTooltip}
>
<Redo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{redoTooltip}</TooltipContent>
</Tooltip>
</div>
<label htmlFor="tailor-summary-edit" className="sr-only">
Tailored Summary
</label>
<p className="mt-2 text-xs text-muted-foreground">
{tracerReadinessChecking
? "Checking tracer-link readiness..."
: "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
</p>
{tracerEnableBlockedReason && !tracerLinksEnabled ? (
<p className="mt-2 text-xs text-destructive">
Tracer links are unavailable: {tracerEnableBlockedReason}
<textarea
id="tailor-summary-edit"
className={`${inputClass} min-h-[120px]`}
value={summary}
onChange={(event) => onSummaryChange(event.target.value)}
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">
<div className="mb-2 flex justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onUndoHeadline}
disabled={disableInputs || !canUndoHeadline}
aria-label={undoTooltip}
title={
!canUndoHeadline
? (undoDisabledReason ?? undefined)
: undefined
}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{undoTooltip}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onRedoHeadline}
disabled={disableInputs || !canRedoHeadline}
aria-label={redoTooltip}
>
<Redo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{redoTooltip}</TooltipContent>
</Tooltip>
</div>
<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)}
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">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onUndoSkills}
disabled={disableInputs || !canUndoSkills}
aria-label={undoTooltip}
title={
!canUndoSkills
? (undoDisabledReason ?? undefined)
: undefined
}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{undoTooltip}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onRedoSkills}
disabled={disableInputs || !canRedoSkills}
aria-label={redoTooltip}
>
<Redo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{redoTooltip}</TooltipContent>
</Tooltip>
<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,
)
}
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,
)
}
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>
{!isCatalogLoading && catalog.length > 0 && (
<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>
)}
<AccordionItem value="tracer-links" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Tracer Links
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<div className="rounded-md border border-border/60 bg-background/60 p-3">
<label
htmlFor="tailor-tracer-links-enabled"
className="flex cursor-pointer items-center gap-3"
>
<Checkbox
id="tailor-tracer-links-enabled"
checked={tracerLinksEnabled}
onCheckedChange={(checked) =>
onTracerLinksEnabledChange(Boolean(checked))
}
disabled={tracerToggleDisabled}
/>
<span className="text-sm font-medium text-foreground">
Enable tracer links for this job
</span>
</label>
<p className="mt-2 text-xs text-muted-foreground">
{tracerReadinessChecking
? "Checking tracer-link readiness..."
: "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
</p>
) : null}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{tracerEnableBlockedReason && !tracerLinksEnabled ? (
<p className="mt-2 text-xs text-destructive">
Tracer links are unavailable: {tracerEnableBlockedReason}
</p>
) : null}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</TooltipProvider>
);
};

View File

@ -1,12 +1,23 @@
import type { Job } from "@shared/types.js";
import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react";
import type React from "react";
import type { ComponentProps } 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 { useProfile } from "../../hooks/useProfile";
import { useTracerReadiness } from "../../hooks/useTracerReadiness";
import {
fromEditableSkillGroups,
getOriginalHeadline,
getOriginalSkills,
getOriginalSummary,
parseTailoredSkills,
serializeTailoredSkills,
toEditableSkillGroups,
} from "../tailoring-utils";
import { canFinalizeTailoring } from "./rules";
import { TailoringSections } from "./TailoringSections";
import { useTailoringDraft } from "./useTailoringDraft";
@ -34,6 +45,22 @@ interface TailoringWorkspaceTailorProps extends TailoringWorkspaceBaseProps {
type TailoringWorkspaceProps =
| TailoringWorkspaceEditorProps
| TailoringWorkspaceTailorProps;
type TailoringSectionsProps = ComponentProps<typeof TailoringSections>;
interface TailoringBaseline {
summary: string;
headline: string;
skillsJson: string;
}
const normalizeSkillsJson = (value: string | null | undefined) =>
serializeTailoredSkills(parseTailoredSkills(value));
const toBaselineFromJob = (job: Job): TailoringBaseline => ({
summary: job.tailoredSummary ?? "",
headline: job.tailoredHeadline ?? "",
skillsJson: normalizeSkillsJson(job.tailoredSkills),
});
export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
props,
@ -55,6 +82,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
tracerLinksEnabled,
setTracerLinksEnabled,
skillsDraft,
setSkillsDraft,
openSkillGroupId,
setOpenSkillGroupId,
skillsJson,
@ -73,9 +101,36 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const { profile, error: profileError } = useProfile();
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
useTracerReadiness();
const originalValues = useMemo(() => {
const skillsDraft = toEditableSkillGroups(getOriginalSkills(profile));
return {
summary: getOriginalSummary(profile),
headline: getOriginalHeadline(profile),
skillsDraft,
skillsJson: serializeTailoredSkills(fromEditableSkillGroups(skillsDraft)),
};
}, [profile]);
const canUseOriginalValues = Boolean(profile) && !profileError;
const [aiBaseline, setAiBaseline] = useState<TailoringBaseline>(() =>
toBaselineFromJob(props.job),
);
useEffect(() => {
setAiBaseline({
summary: props.job.tailoredSummary ?? "",
headline: props.job.tailoredHeadline ?? "",
skillsJson: normalizeSkillsJson(props.job.tailoredSkills),
});
}, [
props.job.tailoredSummary,
props.job.tailoredHeadline,
props.job.tailoredSkills,
]);
const tracerEnableBlocked =
!tracerLinksEnabled && !tracerReadiness?.canEnable;
const tracerEnableBlockedReason =
@ -149,6 +204,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
applyIncomingDraft(updatedJob);
setAiBaseline(toBaselineFromJob(updatedJob));
toast.success("AI Summary & Projects generated");
await editorProps.onUpdate();
} catch (error) {
@ -172,6 +228,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
applyIncomingDraft(updatedJob);
setAiBaseline(toBaselineFromJob(updatedJob));
toast.success("Draft generated with AI", {
description: "Review and edit before finalizing.",
@ -233,11 +290,115 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
tailorProps.onFinalize();
}, [tailorProps, isDirty, persistCurrent]);
const handleUndoSummary = useCallback(() => {
setSummary(originalValues.summary);
}, [originalValues.summary, setSummary]);
const handleUndoHeadline = useCallback(() => {
setHeadline(originalValues.headline);
}, [originalValues.headline, setHeadline]);
const handleUndoSkills = useCallback(() => {
setSkillsDraft(originalValues.skillsDraft);
}, [originalValues.skillsDraft, setSkillsDraft]);
const handleRedoSummary = useCallback(() => {
setSummary(aiBaseline.summary);
}, [aiBaseline.summary, setSummary]);
const handleRedoHeadline = useCallback(() => {
setHeadline(aiBaseline.headline);
}, [aiBaseline.headline, setHeadline]);
const handleRedoSkills = useCallback(() => {
setSkillsDraft(
toEditableSkillGroups(parseTailoredSkills(aiBaseline.skillsJson)),
);
}, [aiBaseline.skillsJson, setSkillsDraft]);
const disableInputs = editorProps
? isSummarizing || isGeneratingPdf || isSaving
: isGenerating || Boolean(tailorProps?.isFinalizing) || isSaving;
const canFinalize = canFinalizeTailoring(summary);
const tailoringSectionsProps = useMemo<TailoringSectionsProps>(
() => ({
catalog,
isCatalogLoading,
summary,
headline,
jobDescription,
skillsDraft,
selectedIds,
tracerLinksEnabled,
tracerEnableBlocked,
tracerEnableBlockedReason,
tracerReadinessChecking: isTracerReadinessChecking,
openSkillGroupId,
disableInputs,
onSummaryChange: setSummary,
onHeadlineChange: setHeadline,
onUndoSummary: handleUndoSummary,
onUndoHeadline: handleUndoHeadline,
onUndoSkills: handleUndoSkills,
onRedoSummary: handleRedoSummary,
onRedoHeadline: handleRedoHeadline,
onRedoSkills: handleRedoSkills,
canUndoSummary:
canUseOriginalValues && summary !== originalValues.summary,
canUndoHeadline:
canUseOriginalValues && headline !== originalValues.headline,
canUndoSkills:
canUseOriginalValues && skillsJson !== originalValues.skillsJson,
canRedoSummary: summary !== aiBaseline.summary,
canRedoHeadline: headline !== aiBaseline.headline,
canRedoSkills: skillsJson !== aiBaseline.skillsJson,
undoDisabledReason: canUseOriginalValues
? null
: "Original base CV unavailable.",
onDescriptionChange: setJobDescription,
onSkillGroupOpenChange: setOpenSkillGroupId,
onAddSkillGroup: handleAddSkillGroup,
onUpdateSkillGroup: handleUpdateSkillGroup,
onRemoveSkillGroup: handleRemoveSkillGroup,
onToggleProject: handleToggleProject,
onTracerLinksEnabledChange: setTracerLinksEnabled,
}),
[
catalog,
isCatalogLoading,
summary,
headline,
jobDescription,
skillsDraft,
selectedIds,
tracerLinksEnabled,
tracerEnableBlocked,
tracerEnableBlockedReason,
isTracerReadinessChecking,
openSkillGroupId,
disableInputs,
setSummary,
setHeadline,
handleUndoSummary,
handleUndoHeadline,
handleUndoSkills,
handleRedoSummary,
handleRedoHeadline,
handleRedoSkills,
canUseOriginalValues,
originalValues,
skillsJson,
aiBaseline,
setJobDescription,
setOpenSkillGroupId,
handleAddSkillGroup,
handleUpdateSkillGroup,
handleRemoveSkillGroup,
handleToggleProject,
setTracerLinksEnabled,
],
);
if (editorProps) {
return (
@ -280,30 +441,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
</div>
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<TailoringSections
catalog={catalog}
isCatalogLoading={isCatalogLoading}
summary={summary}
headline={headline}
jobDescription={jobDescription}
skillsDraft={skillsDraft}
selectedIds={selectedIds}
tracerLinksEnabled={tracerLinksEnabled}
tracerEnableBlocked={tracerEnableBlocked}
tracerEnableBlockedReason={tracerEnableBlockedReason}
tracerReadinessChecking={isTracerReadinessChecking}
openSkillGroupId={openSkillGroupId}
disableInputs={disableInputs}
onSummaryChange={setSummary}
onHeadlineChange={setHeadline}
onDescriptionChange={setJobDescription}
onSkillGroupOpenChange={setOpenSkillGroupId}
onAddSkillGroup={handleAddSkillGroup}
onUpdateSkillGroup={handleUpdateSkillGroup}
onRemoveSkillGroup={handleRemoveSkillGroup}
onToggleProject={handleToggleProject}
onTracerLinksEnabledChange={setTracerLinksEnabled}
/>
<TailoringSections {...tailoringSectionsProps} />
<div className="flex justify-end border-t pt-4">
<Button
@ -372,30 +510,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
</Button>
</div>
<TailoringSections
catalog={catalog}
isCatalogLoading={isCatalogLoading}
summary={summary}
headline={headline}
jobDescription={jobDescription}
skillsDraft={skillsDraft}
selectedIds={selectedIds}
tracerLinksEnabled={tracerLinksEnabled}
tracerEnableBlocked={tracerEnableBlocked}
tracerEnableBlockedReason={tracerEnableBlockedReason}
tracerReadinessChecking={isTracerReadinessChecking}
openSkillGroupId={openSkillGroupId}
disableInputs={disableInputs}
onSummaryChange={setSummary}
onHeadlineChange={setHeadline}
onDescriptionChange={setJobDescription}
onSkillGroupOpenChange={setOpenSkillGroupId}
onAddSkillGroup={handleAddSkillGroup}
onUpdateSkillGroup={handleUpdateSkillGroup}
onRemoveSkillGroup={handleRemoveSkillGroup}
onToggleProject={handleToggleProject}
onTracerLinksEnabledChange={setTracerLinksEnabled}
/>
<TailoringSections {...tailoringSectionsProps} />
</div>
<Separator className="my-4 opacity-50" />

View File

@ -224,6 +224,7 @@ export function useTailoringDraft({
selectedIds,
selectedIdsCsv,
skillsDraft,
setSkillsDraft,
openSkillGroupId,
setOpenSkillGroupId,
skillsJson,