Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
@@ -19,9 +19,10 @@ import {
} from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
@@ -47,6 +48,12 @@ type PackageWorkspaceState = {
recruiterMessage: string;
};
type TailoredCvPreviewResponse = {
templateId: string;
html: string;
suggestedFileName: string;
};
interface Props {
open: boolean;
jobId: number | null;
@@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) {
return { label: "Empty", color: "default" as const };
}
function serializeTailoredDraft(draft: TailoredCvDraft) {
const normalized = normalizeTailoredCvDraft(draft);
return JSON.stringify({
templateId: normalized.templateId,
headline: normalized.headline ?? "",
summary: normalized.summary,
selectedSkills: normalized.selectedSkills,
experience: normalized.experience,
education: normalized.education,
customSections: normalized.customSections,
renderOptions: normalized.renderOptions,
status: normalized.status,
isLegacyFallback: normalized.isLegacyFallback,
});
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
const { toast } = useToast();
const { t } = useI18n();
@@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [loadingReadiness, setLoadingReadiness] = useState(false);
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
const [generatingPackage, setGeneratingPackage] = useState(false);
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
const [tailoredCvText, setTailoredCvText] = useState("");
const [tailoredCvDraft, setTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false);
const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false);
const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false);
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
@@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setJobAttachments([]);
setSelectedAttachmentIds([]);
setPackageGeneratedAt(null);
setTailoredCvDraft(emptyTailoredCvDraft());
setSavedTailoredCvDraft(emptyTailoredCvDraft());
setTailoredCvPreview(null);
setProfileAvatarImageDataUrl(null);
setCustomPhotoDataUrl(null);
setUseProfilePhoto(true);
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
setJob(r.data);
setTailoredCvText(r.data.tailoredCvText ?? "");
const savedWorkspace = {
coverLetter: r.data.coverLetterText ?? "",
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
@@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setJobAttachments([]);
setSelectedAttachmentIds([]);
});
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
api.get(`/auth/me`).then((r) => {
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
}).catch(() => {
setIsAdmin(false);
setProfileAvatarImageDataUrl(null);
});
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
}, [open, jobId, initialTab, initialFollowUpMode]);
useEffect(() => {
if (!open || !jobId || tab !== 3) return;
setLoadingTailoredCvDraft(true);
api.get<TailoredCvDraft>(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => {
const normalized = normalizeTailoredCvDraft(r.data);
setTailoredCvDraft(normalized);
setSavedTailoredCvDraft(normalized);
}).catch(() => {
const empty = emptyTailoredCvDraft();
setTailoredCvDraft(empty);
setSavedTailoredCvDraft(empty);
}).finally(() => setLoadingTailoredCvDraft(false));
}, [open, jobId, tab]);
useEffect(() => {
if (!open || !jobId || tab !== 4) return;
setLoadingDraft(true);
@@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
) : null;
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText);
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft);
const hasUnsavedPackageChanges = [
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
].some(Boolean);
const saveTailoredCvDraft = async () => {
if (!jobId) return;
const normalized = normalizeTailoredCvDraft({
...tailoredCvDraft,
status: tailoredCvDraft.status === "empty" ? "edited" : tailoredCvDraft.status,
});
try {
setSavingTailoredCvDraft(true);
await api.put(`/jobapplications/${jobId}/tailored-cv-draft`, {
templateId: normalized.templateId,
headline: normalized.headline,
summary: normalized.summary,
selectedSkills: normalized.selectedSkills,
experience: normalized.experience,
education: normalized.education,
customSections: normalized.customSections,
renderOptions: normalized.renderOptions,
status: normalized.status,
});
setTailoredCvDraft(normalized);
setSavedTailoredCvDraft(normalized);
setJob((prev) => prev ? {
...prev,
tailoredCvText: normalized.renderedText,
tailoredCvUpdatedAt: new Date().toISOString(),
} : prev);
setReadiness(null);
toast("Tailored CV draft saved.", "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save the tailored CV draft."), "error");
} finally {
setSavingTailoredCvDraft(false);
}
};
const generateTailoredCvDraft = async () => {
if (!jobId) return;
if (hasUnsavedTailoredCvDraftChanges) {
const confirmed = await confirmAction("Regenerating the tailored CV draft will replace your unsaved edits.", {
title: "Replace unsaved tailored CV edits?",
confirmLabel: "Regenerate draft",
});
if (!confirmed) return;
}
try {
setGeneratingTailoredCvDraft(true);
const res = await api.post<TailoredCvDraft>(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } });
const normalized = normalizeTailoredCvDraft(res.data);
setTailoredCvDraft(normalized);
setSavedTailoredCvDraft(normalized);
setJob((prev) => prev ? {
...prev,
tailoredCvText: normalized.renderedText,
tailoredCvUpdatedAt: new Date().toISOString(),
} : prev);
toast("Tailored CV draft generated.", "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to generate a tailored CV draft."), "error");
} finally {
setGeneratingTailoredCvDraft(false);
}
};
const resetTailoredCvDraftToSaved = () => {
setTailoredCvDraft(savedTailoredCvDraft);
toast("Restored the last saved tailored CV draft.", "info");
};
const buildTailoredCvRenderPayload = () => ({
templateId: tailoredCvDraft.templateId,
headline: tailoredCvDraft.headline,
summary: tailoredCvDraft.summary,
selectedSkills: tailoredCvDraft.selectedSkills,
experience: tailoredCvDraft.experience,
education: tailoredCvDraft.education,
customSections: tailoredCvDraft.customSections,
renderOptions: tailoredCvDraft.renderOptions,
photoDataUrl: customPhotoDataUrl,
useProfileAvatar: useProfilePhoto,
});
const refreshTailoredCvPreview = async () => {
if (!jobId) return;
try {
setLoadingTailoredCvPreview(true);
const res = await api.post<TailoredCvPreviewResponse>(`/jobapplications/${jobId}/tailored-cv-preview`, buildTailoredCvRenderPayload());
setTailoredCvPreview(res.data);
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to build the CV preview."), "error");
} finally {
setLoadingTailoredCvPreview(false);
}
};
const exportTailoredCvPdf = async () => {
if (!jobId) return;
try {
setExportingTailoredCvPdf(true);
const response = await api.post(`/jobapplications/${jobId}/export-tailored-cv-pdf`, buildTailoredCvRenderPayload(), { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = tailoredCvPreview?.suggestedFileName || `${(job?.jobTitle ?? "tailored-cv").replace(/\s+/g, "-").toLowerCase()}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast("Tailored CV PDF downloaded.", "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to export the CV PDF."), "error");
} finally {
setExportingTailoredCvPdf(false);
}
};
const savePackageWorkspace = async () => {
if (!jobId || !job) return;
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
const draftsChanged =
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
if (!tailoredCvChanged && !draftsChanged) {
if (!draftsChanged) {
toast("No unsaved package changes.", "info");
return;
}
try {
if (tailoredCvChanged) {
setSavingTailoredCv(true);
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
}
if (draftsChanged) {
setSavingApplicationDrafts(true);
await api.put(`/jobapplications/${jobId}/application-drafts`, {
coverLetterText: packageWorkspace.coverLetter,
notes: nextNotes,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
});
}
setSavingApplicationDrafts(true);
await api.put(`/jobapplications/${jobId}/application-drafts`, {
coverLetterText: packageWorkspace.coverLetter,
notes: nextNotes,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
});
setJob((prev) => prev ? {
...prev,
tailoredCvText,
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
coverLetterText: packageWorkspace.coverLetter,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
notes: nextNotes,
@@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
} finally {
setSavingTailoredCv(false);
setSavingApplicationDrafts(false);
}
};
const resetPackageWorkspaceToSaved = () => {
setTailoredCvText(job?.tailoredCvText ?? "");
setPackageWorkspace(savedPackageWorkspace);
toast("Restored the last saved package.", "info");
};
@@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 3 && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedTailoredCvDraftChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box>
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
<Typography variant="overline">Tailored CV draft</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>This draft is job-scoped. It stays separate from your master CV and from the package drafts below.</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
@@ -506,6 +670,214 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Template</InputLabel>
<Select value={tailoredCvDraft.templateId} label="Template" onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, templateId: e.target.value, status: "edited" }))}>
<MenuItem value="ats-minimal">ATS Minimal</MenuItem>
<MenuItem value="harvard">Harvard</MenuItem>
<MenuItem value="auckland">Auckland</MenuItem>
<MenuItem value="edinburgh">Edinburgh</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Accent"
type="color"
value={tailoredCvDraft.renderOptions.accentColor?.startsWith("#") ? tailoredCvDraft.renderOptions.accentColor : "#334155"}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
renderOptions: { ...current.renderOptions, accentColor: e.target.value },
status: "edited",
}))}
sx={{ width: 110 }}
InputLabelProps={{ shrink: true }}
/>
<Button size="small" variant={tailoredCvDraft.renderOptions.showPhoto ? "contained" : "outlined"} onClick={() => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
renderOptions: { ...current.renderOptions, showPhoto: !current.renderOptions.showPhoto },
status: "edited",
}))}>{tailoredCvDraft.renderOptions.showPhoto ? "Photo on" : "Photo off"}</Button>
<Button size="small" variant={useProfilePhoto ? "contained" : "outlined"} onClick={() => setUseProfilePhoto((current) => !current)}>{useProfilePhoto ? "Using profile photo" : "Profile photo off"}</Button>
<Button size="small" variant="outlined" component="label">
Pick photo
<input hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setCustomPhotoDataUrl(typeof reader.result === "string" ? reader.result : null);
reader.readAsDataURL(file);
}} />
</Button>
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
<Button size="small" variant="contained" disabled={savingTailoredCvDraft || loadingTailoredCvDraft} onClick={saveTailoredCvDraft}>{savingTailoredCvDraft ? t("jobDetailsSaving") : "Save tailored draft"}</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
<Chip size="small" label={`Tailored CV · ${tailoredCvDraftStatus.label}`} color={tailoredCvDraftStatus.color} />
<Chip size="small" variant="outlined" label={`Template · ${tailoredCvDraft.templateId}`} />
{tailoredCvDraft.isLegacyFallback ? <Chip size="small" color="warning" variant="outlined" label="Legacy text fallback" /> : null}
{tailoredCvDraft.lastGeneratedAtUtc ? <Chip size="small" variant="outlined" label={`Generated ${new Date(tailoredCvDraft.lastGeneratedAtUtc).toLocaleString()}`} /> : null}
{tailoredCvDraft.canonicalProfileVersion ? <Chip size="small" variant="outlined" label={`Profile v${tailoredCvDraft.canonicalProfileVersion}`} /> : null}
</Box>
{loadingTailoredCvDraft ? (
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.1fr 0.9fr" }, gap: 2 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
label="Headline"
value={tailoredCvDraft.headline ?? ""}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))}
fullWidth
/>
<TextField
label="Summary bullets"
value={joinLines(tailoredCvDraft.summary)}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))}
multiline
minRows={5}
fullWidth
helperText="One bullet per line."
/>
<TextField
label="Selected skills"
value={joinLines(tailoredCvDraft.selectedSkills)}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))}
multiline
minRows={4}
fullWidth
helperText="One skill per line."
/>
<TextField
label="Experience"
value={tailoredCvDraft.experience.map((item) => [
[item.title, item.company].filter(Boolean).join(" — "),
[item.location, item.start, item.end].filter(Boolean).join(" | "),
...(item.bullets ?? []).map((bullet) => `- ${bullet}`),
].filter(Boolean).join("\n")).join("\n\n")}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
experience: e.target.value
.split(/\n\s*\n/)
.map((block) => block.trim())
.filter(Boolean)
.map((block) => {
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const [titleCompany = "", meta = "", ...bulletLines] = lines;
const [title = "", company = ""] = titleCompany.split("—").map((part) => part.trim());
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
return {
title,
company,
location,
start,
end,
bullets: bulletLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
};
}),
status: "edited",
}))}
multiline
minRows={10}
fullWidth
helperText="Separate entries with a blank line. First line: Title — Company. Second line: Location | Start | End."
/>
<TextField
label="Education"
value={tailoredCvDraft.education.map((item) => [
[item.qualification, item.institution].filter(Boolean).join(" — "),
[item.location, item.start, item.end].filter(Boolean).join(" | "),
...(item.details ?? []).map((detail) => `- ${detail}`),
].filter(Boolean).join("\n")).join("\n\n")}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
education: e.target.value
.split(/\n\s*\n/)
.map((block) => block.trim())
.filter(Boolean)
.map((block) => {
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const [qualificationInstitution = "", meta = "", ...detailLines] = lines;
const [qualification = "", institution = ""] = qualificationInstitution.split("—").map((part) => part.trim());
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
return {
qualification,
institution,
location,
start,
end,
details: detailLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
};
}),
status: "edited",
}))}
multiline
minRows={8}
fullWidth
helperText="Separate entries with a blank line. First line: Qualification — Institution. Second line: Location | Start | End."
/>
<TextField
label="Custom sections"
value={tailoredCvDraft.customSections.map((section) => `${section.title || "Additional Information"}\n${(section.items ?? []).join("\n")}`).join("\n\n")}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
customSections: e.target.value
.split(/\n\s*\n/)
.map((block) => block.trim())
.filter(Boolean)
.map((block) => {
const [title = "", ...items] = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return { title, items };
}),
status: "edited",
}))}
multiline
minRows={7}
fullWidth
helperText="Each block starts with the section title, followed by one item per line."
/>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">Rendered CV snapshot</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft.</Typography>
<TextField value={tailoredCvDraft.renderedText} multiline minRows={12} fullWidth InputProps={{ readOnly: true }} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">PDF-style preview</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here.</Typography>
{tailoredCvPreview ? (
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
) : (
<Typography sx={{ color: "text.secondary" }}>Build the PDF layout preview to inspect the ATS template before downloading.</Typography>
)}
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">Saved job material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Saving the tailored draft updates the job-scoped CV text without touching your master profile.</Typography>
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Master CV:</strong> Never overwritten here</Typography>
<Typography variant="body2"><strong>Photo source:</strong> {customPhotoDataUrl ? "Custom preview photo" : useProfilePhoto && profileAvatarImageDataUrl ? "Profile picture" : "No photo source selected"}</Typography>
</Box>
</Box>
</Box>
)}
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box>
<Typography variant="overline">Application package drafts</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>These drafts stay separate from the tailored CV draft. Save them when you want reusable role-specific copy on the job.</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
@@ -515,22 +887,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
</Select>
</FormControl>
<Button size="small" variant="outlined" onClick={async () => {
try {
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
setTailoredCvText(me.data?.profileCvText ?? "");
toast(t("jobDetailsLoadedMasterCv"), "success");
} catch {
toast(t("jobDetailsLoadMasterCvFailed"), "error");
}
}}>{t("jobDetailsStartFromMasterCv")}</Button>
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
if (!jobId) return;
setGeneratingPackage(true);
try {
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
setApplicationPackage(res.data);
setTailoredCvText(res.data.tailoredCvText ?? "");
setPackageWorkspace({
coverLetter: res.data.coverLetterDraft ?? "",
applicationAnswer: res.data.applicationAnswerDraft ?? "",
@@ -545,58 +907,53 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button>
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<WorkspaceDraftCard
title={t("jobDetailsCoverLetterDraft")}
value={packageWorkspace.coverLetter}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
statusLabel={coverLetterStatus.label}
statusColor={coverLetterStatus.color}
/>
<WorkspaceDraftCard
title={t("jobDetailsShortApplicationAnswer")}
value={packageWorkspace.applicationAnswer}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
statusLabel={applicationAnswerStatus.label}
statusColor={applicationAnswerStatus.color}
/>
<WorkspaceDraftCard
title={t("jobDetailsRecruiterMessageDraft")}
value={packageWorkspace.recruiterMessage}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
statusLabel={recruiterMessageStatus.label}
statusColor={recruiterMessageStatus.color}
/>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Typography variant="overline">Saved working material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<WorkspaceDraftCard
title={t("jobDetailsCoverLetterDraft")}
value={packageWorkspace.coverLetter}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
statusLabel={coverLetterStatus.label}
statusColor={coverLetterStatus.color}
/>
<WorkspaceDraftCard
title={t("jobDetailsShortApplicationAnswer")}
value={packageWorkspace.applicationAnswer}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
statusLabel={applicationAnswerStatus.label}
statusColor={applicationAnswerStatus.color}
/>
<WorkspaceDraftCard
title={t("jobDetailsRecruiterMessageDraft")}
value={packageWorkspace.recruiterMessage}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
statusLabel={recruiterMessageStatus.label}
statusColor={recruiterMessageStatus.color}
/>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">Saved working material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
</Box>
</Box>
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
</Box>
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
</Box>
</Box>
)}
@@ -43,6 +43,10 @@ beforeEach(() => {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
Object.assign(URL, {
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
revokeObjectURL: jest.fn(),
});
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
@@ -61,7 +65,7 @@ beforeEach(() => {
} } as any);
}
if (url === '/auth/me') {
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
}
if (url === '/jobapplications/42/history') {
return Promise.resolve({ data: [] } as any);
@@ -69,6 +73,26 @@ beforeEach(() => {
if (url === '/attachments/42') {
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
}
if (url === '/jobapplications/42/tailored-cv-draft') {
return Promise.resolve({ data: {
id: 5,
canonicalProfileVersion: 3,
templateId: 'ats-minimal',
headline: 'Backend Engineer',
summary: ['Built APIs', 'Shipped backend work'],
selectedSkills: ['.NET', 'SQL'],
experience: [],
education: [],
customSections: [],
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
generationContextHash: 'abc123',
lastGeneratedAtUtc: new Date().toISOString(),
lastEditedAtUtc: null,
status: 'generated',
renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work',
isLegacyFallback: false,
} } as any);
}
if (url === '/jobapplications/42/candidate-fit') {
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
}
@@ -77,7 +101,44 @@ beforeEach(() => {
}
return Promise.resolve({ data: [] } as any);
});
mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => {
if (url === '/jobapplications/42/generate-tailored-cv-draft') {
return Promise.resolve({ data: {
id: 5,
canonicalProfileVersion: 3,
templateId: 'ats-minimal',
headline: 'Senior Backend Engineer',
summary: ['Owned API delivery', 'Improved SQL workflows'],
selectedSkills: ['.NET', 'SQL', 'APIs'],
experience: [],
education: [],
customSections: [],
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
generationContextHash: 'def456',
lastGeneratedAtUtc: new Date().toISOString(),
lastEditedAtUtc: null,
status: 'generated',
renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows',
isLegacyFallback: false,
} } as any);
}
if (url === '/jobapplications/42/tailored-cv-preview') {
return Promise.resolve({ data: {
templateId: body?.templateId ?? 'ats-minimal',
suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`,
html: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></html>`,
} } as any);
}
if (url === '/jobapplications/42/export-tailored-cv-pdf') {
return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any);
}
if (url === '/jobapplications/42/generate-application-package') {
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
});
@@ -85,20 +146,40 @@ afterEach(() => {
jest.clearAllMocks();
});
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
fireEvent.click(screen.getByRole('button', { name: /generate application package/i }));
fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i }));
expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument();
const headline = screen.getByDisplayValue('Senior Backend Engineer');
fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } });
fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i }));
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({
headline: 'Principal Backend Engineer',
summary: ['Owned API delivery', 'Improved SQL workflows'],
selectedSkills: ['.NET', 'SQL', 'APIs'],
status: 'edited',
}));
});
expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything());
});
test('application package drafts save separately from the tailored cv draft', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
fireEvent.click(await screen.findByRole('button', { name: /generate application package/i }));
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
const coverLetter = await screen.findByDisplayValue('Draft letter');
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
@@ -107,18 +188,59 @@ test('application package workspace reflects saved job material, generated draft
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
coverLetterText: 'Edited cover letter',
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
recruiterMessageDraft: 'Edited recruiter note',
});
});
});
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
test('template switching refreshes preview and export uses the selected template payload', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
const comboboxes = await screen.findAllByRole('combobox');
fireEvent.mouseDown(comboboxes[1]);
fireEvent.click(await screen.findByRole('option', { name: 'Harvard' }));
const accent = screen.getByLabelText('Accent');
fireEvent.change(accent, { target: { value: '#123456' } });
fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({
templateId: 'harvard',
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
useProfileAvatar: true,
}));
});
expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument();
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
fireEvent.click(screen.getByRole('button', { name: /download pdf/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({
templateId: 'harvard',
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
}), expect.objectContaining({ responseType: 'blob' }));
expect(URL.createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
});
appendChildSpy.mockRestore();
removeSpy.mockRestore();
clickSpy.mockRestore();
});
test('strategy snapshot can be generated from overview', async () => {
+135
View File
@@ -0,0 +1,135 @@
import { joinLines, splitLines } from "./profileCv";
import { TailoredCvDraft } from "./types";
const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "custom"];
export function emptyTailoredCvDraft(): TailoredCvDraft {
return {
templateId: "ats-minimal",
headline: "",
summary: [],
selectedSkills: [],
experience: [],
education: [],
customSections: [],
renderOptions: {
showPhoto: false,
pageMode: "one-page",
accentColor: "slate",
sectionOrder: DEFAULT_SECTION_ORDER,
bulletDensity: "balanced",
},
status: "empty",
renderedText: "",
isLegacyFallback: false,
};
}
function formatDateRange(start?: string | null, end?: string | null, isCurrent?: boolean) {
const normalizedStart = start?.trim();
const normalizedEnd = end?.trim();
if (!normalizedStart && !normalizedEnd) return "";
if (!normalizedStart) return normalizedEnd ?? "";
return `${normalizedStart} - ${isCurrent ? "Present" : normalizedEnd || "Present"}`;
}
export function renderTailoredCvDraftText(source?: Partial<TailoredCvDraft> | null) {
const draft = emptyTailoredCvDraft();
const normalized = {
...draft,
...source,
summary: Array.isArray(source?.summary) ? source.summary.filter(Boolean) : [],
selectedSkills: Array.isArray(source?.selectedSkills) ? source.selectedSkills.filter(Boolean) : [],
experience: Array.isArray(source?.experience) ? source.experience.filter(Boolean) : [],
education: Array.isArray(source?.education) ? source.education.filter(Boolean) : [],
customSections: Array.isArray(source?.customSections) ? source.customSections.filter(Boolean) : [],
};
const sections: string[] = [];
if (normalized.headline?.trim()) {
sections.push(normalized.headline.trim());
}
if (normalized.summary.length) {
sections.push(`Professional Summary\n${normalized.summary.map((item) => `- ${item.trim()}`).join("\n")}`);
}
if (normalized.selectedSkills.length) {
sections.push(`Core Skills\n${normalized.selectedSkills.map((item) => item.trim()).join("\n")}`);
}
if (normalized.experience.length) {
const body = normalized.experience.map((item) => {
const header = [item.title, item.company, item.location, formatDateRange(item.start, item.end, item.isCurrent)]
.map((value) => value?.trim())
.filter(Boolean)
.join(" | ");
const bullets = (item.bullets ?? []).filter(Boolean).map((bullet) => `- ${bullet.trim()}`).join("\n");
return [header, bullets].filter(Boolean).join("\n");
}).filter(Boolean).join("\n\n");
if (body) sections.push(`Experience\n${body}`);
}
if (normalized.education.length) {
const body = normalized.education.map((item) => {
const header = [item.qualification, item.institution, item.location, formatDateRange(item.start, item.end, false)]
.map((value) => value?.trim())
.filter(Boolean)
.join(" | ");
const details = (item.details ?? []).filter(Boolean).map((detail) => `- ${detail.trim()}`).join("\n");
return [header, details].filter(Boolean).join("\n");
}).filter(Boolean).join("\n\n");
if (body) sections.push(`Education\n${body}`);
}
normalized.customSections.forEach((section) => {
const title = section.title?.trim() || "Additional Information";
const items = (section.items ?? []).filter(Boolean).map((item) => item.trim()).join("\n");
if (items) sections.push(`${title}\n${items}`);
});
return sections.join("\n\n").trim();
}
export function normalizeTailoredCvDraft(source?: Partial<TailoredCvDraft> | null): TailoredCvDraft {
const empty = emptyTailoredCvDraft();
const normalized: TailoredCvDraft = {
...empty,
...source,
templateId: source?.templateId?.trim() || empty.templateId,
headline: source?.headline ?? "",
summary: Array.isArray(source?.summary) ? source!.summary.filter(Boolean) : [],
selectedSkills: Array.isArray(source?.selectedSkills) ? source!.selectedSkills.filter(Boolean) : [],
experience: Array.isArray(source?.experience) ? source!.experience.map((item) => ({
title: item?.title ?? "",
company: item?.company ?? "",
location: item?.location ?? "",
start: item?.start ?? "",
end: item?.end ?? "",
isCurrent: Boolean(item?.isCurrent),
bullets: Array.isArray(item?.bullets) ? item!.bullets.filter(Boolean) : [],
})) : [],
education: Array.isArray(source?.education) ? source!.education.map((item) => ({
qualification: item?.qualification ?? "",
institution: item?.institution ?? "",
location: item?.location ?? "",
start: item?.start ?? "",
end: item?.end ?? "",
details: Array.isArray(item?.details) ? item!.details.filter(Boolean) : [],
})) : [],
customSections: Array.isArray(source?.customSections) ? source!.customSections.map((item) => ({
title: item?.title ?? "",
items: Array.isArray(item?.items) ? item!.items.filter(Boolean) : [],
})) : [],
renderOptions: {
...empty.renderOptions,
...source?.renderOptions,
sectionOrder: Array.isArray(source?.renderOptions?.sectionOrder) && source.renderOptions.sectionOrder.length > 0
? source.renderOptions.sectionOrder.filter(Boolean)
: DEFAULT_SECTION_ORDER,
},
status: source?.status?.trim() || empty.status,
renderedText: "",
isLegacyFallback: Boolean(source?.isLegacyFallback),
};
normalized.renderedText = renderTailoredCvDraftText(normalized);
return normalized;
}
export { joinLines, splitLines };
+51
View File
@@ -28,6 +28,57 @@ export interface WorkflowSignal {
hasInterviewPrepNotes: boolean;
}
export interface TailoredCvExperienceItem {
title?: string | null;
company?: string | null;
location?: string | null;
start?: string | null;
end?: string | null;
isCurrent?: boolean;
bullets: string[];
}
export interface TailoredCvEducationItem {
qualification?: string | null;
institution?: string | null;
location?: string | null;
start?: string | null;
end?: string | null;
details: string[];
}
export interface TailoredCvCustomSection {
title?: string | null;
items: string[];
}
export interface TailoredCvRenderOptions {
showPhoto: boolean;
pageMode: string;
accentColor: string;
sectionOrder: string[];
bulletDensity: string;
}
export interface TailoredCvDraft {
id?: number | null;
canonicalProfileVersion?: number | null;
templateId: string;
headline?: string | null;
summary: string[];
selectedSkills: string[];
experience: TailoredCvExperienceItem[];
education: TailoredCvEducationItem[];
customSections: TailoredCvCustomSection[];
renderOptions: TailoredCvRenderOptions;
generationContextHash?: string | null;
lastGeneratedAtUtc?: string | null;
lastEditedAtUtc?: string | null;
status: string;
renderedText: string;
isLegacyFallback: boolean;
}
export interface JobApplication {
id: number;
jobTitle: string;