From 9b80c2e05d2af43ac4542cb0381a4236e8e4f5e5 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Sun, 8 Feb 2026 01:30:36 +0000 Subject: [PATCH] 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. --- documentation/orchestrator.md | 6 +- .../components/TailoringEditor.test.tsx | 111 +++++ .../src/client/components/TailoringEditor.tsx | 356 +------------- .../discovered-panel/TailorMode.test.tsx | 134 ++++++ .../discovered-panel/TailorMode.tsx | 403 +--------------- .../client/components/tailoring-utils.test.ts | 47 ++ .../src/client/components/tailoring-utils.ts | 95 ++++ .../tailoring/TailoringSections.tsx | 261 ++++++++++ .../tailoring/TailoringWorkspace.tsx | 455 ++++++++++++++++++ .../components/tailoring/useTailoringDraft.ts | 277 +++++++++++ .../server/api/routes/jobs-tailoring.test.ts | 96 ++++ orchestrator/src/server/api/routes/jobs.ts | 36 ++ 12 files changed, 1537 insertions(+), 740 deletions(-) create mode 100644 orchestrator/src/client/components/tailoring-utils.test.ts create mode 100644 orchestrator/src/client/components/tailoring-utils.ts create mode 100644 orchestrator/src/client/components/tailoring/TailoringSections.tsx create mode 100644 orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx create mode 100644 orchestrator/src/client/components/tailoring/useTailoringDraft.ts create mode 100644 orchestrator/src/server/api/routes/jobs-tailoring.test.ts diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index 93bcec0..fd9c509 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -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": "", "tailoredSummary": "", + "tailoredHeadline": "", + "tailoredSkills": "[{\"name\":\"Backend\",\"keywords\":[\"TypeScript\",\"Node.js\"]}]", "selectedProjectIds": "p1,p2" } ``` diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx index 245d548..a43d68d 100644 --- a/orchestrator/src/client/components/TailoringEditor.test.tsx +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -22,11 +22,22 @@ const createJob = (overrides: Partial = {}): 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( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + ensureAccordionOpen("Headline"); + + fireEvent.change(screen.getByLabelText("Tailored Headline"), { + target: { value: "Local headline draft" }, + }); + + rerender( + , + ); + ensureAccordionOpen("Headline"); + + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Local headline draft", + ); + }); + + it("saves headline and skills in update payload", async () => { + render(); + 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(); + 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(); + }); }); diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx index 544c5eb..7e098a8 100644 --- a/orchestrator/src/client/components/TailoringEditor.tsx +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -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; } -const parseSelectedIds = (value: string | null | undefined) => - new Set(value?.split(",").filter(Boolean) ?? []); - -const hasSelectionDiff = (current: Set, saved: Set) => { - 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 = ({ job, onUpdate, @@ -40,327 +17,14 @@ export const TailoringEditor: React.FC = ({ onRegisterSave, onBeforeGenerate, }) => { - const [catalog, setCatalog] = useState([]); - const [summary, setSummary] = useState(job.tailoredSummary || ""); - const [jobDescription, setJobDescription] = useState( - job.jobDescription || "", - ); - const [selectedIds, setSelectedIds] = useState>(() => - parseSelectedIds(job.selectedProjectIds), - ); - const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); - const [savedDescription, setSavedDescription] = useState( - job.jobDescription || "", - ); - const [savedSelectedIds, setSavedSelectedIds] = useState>(() => - 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, - ) => { - 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 ( -
-
-

Editor

-
- - -
-
- -
-
- -