src/lib/resume-fit.ts
Rama main · réplica de lectura del repositorio original.
import type { ResumeDoc } from "@/lib/resume-schema";
/**
* What to cut when a resume runs long.
*
* Pure and client-safe, like resume-text.ts and resume-diff.ts, so the editor
* and the MCP tool rank candidates the same way.
*
* This ranks; it does not measure. How many pages a document actually makes is
* a question only a browser can answer — the editor asks one (see
* resume-pagination.ts) and export_resume_pdf renders one. What is knowable
* without a layout engine is which pieces are big, and that is what a person
* asking "what do I cut?" wants: the longest bullets, and the sections carrying
* the most weight.
*/
/** Roughly how many rendered lines a string of body text occupies. */
const linesFor = (text: string, perLine = 105) => Math.max(1, Math.ceil(text.trim().length / perLine));
export type TrimCandidate = {
/** A `data-rp` path, so the editor can jump to it and the tool can name it. */
path: string;
/** Where it lives: "Senior Engineer — Meridian". */
entry: string;
text: string;
/** Characters, and the lines they cost. */
length: number;
lines: number;
};
export type SectionWeight = {
path: string;
heading: string;
kind: string;
visible: boolean;
lines: number;
/** How many entries or bullets it holds. */
items: number;
};
export type FitReport = {
/** Longest first. The obvious things to shorten or drop. */
bullets: TrimCandidate[];
/** Heaviest first. A section you can hide outright is the biggest lever. */
sections: SectionWeight[];
/** Total estimated lines, and the estimate's own page count. */
estimatedLines: number;
approxPages: number;
};
const entryLabel = (title: string, company: string) =>
[title, company].filter(Boolean).join(" — ") || "an entry";
/**
* Rank what is worth cutting. `perPage` is the estimator's lines-per-page, kept
* as an argument so the caller states the assumption rather than this file
* hiding a second copy of it.
*/
export function fitReport(doc: ResumeDoc, perPage: number): FitReport {
const bullets: TrimCandidate[] = [];
const sections: SectionWeight[] = [];
let total = 5; // the header
doc.sections.forEach((section, index) => {
const path = `s${index}`;
let lines = 2; // the heading and its rule
let items = 0;
if (section.kind === "summary" && section.text.trim()) {
lines += linesFor(section.text, 110);
items += 1;
}
// Every entry kind that can carry bullets, with the letter its `data-rp`
// path uses so the editor can jump straight to what it names.
const entries: { letter: string; label: string; bullets: string[] }[] = [
...section.experience.map((item) => ({
letter: "e",
label: entryLabel(item.title, item.company),
bullets: item.bullets,
})),
...section.projects.map((item) => ({
letter: "p",
label: item.name || "a project",
bullets: item.bullets,
})),
...section.items.map((item) => ({
letter: "x",
label: item.title || "an entry",
bullets: item.bullets,
})),
...section.education.map((item) => ({
letter: "d",
label: entryLabel(item.degree, item.school),
bullets: item.details,
})),
];
// Positions are per kind, matching how the renderer indexes each list.
const seen: Record<string, number> = {};
for (const entry of entries) {
const position = seen[entry.letter] ?? 0;
seen[entry.letter] = position + 1;
items += 1;
lines += 2;
entry.bullets.forEach((text, bulletIndex) => {
if (!text.trim()) return;
const cost = linesFor(text);
lines += cost;
bullets.push({
path: `${path}/${entry.letter}${position}/b${bulletIndex}`,
entry: entry.label,
text,
length: text.trim().length,
lines: cost,
});
});
}
if (section.kind === "skills") {
for (const group of section.skills) {
items += 1;
lines += linesFor(`${group.name}: ${group.skills.join(", ")}`, 95);
}
}
if (section.kind === "certifications") {
items += section.certifications.length;
lines += section.certifications.length;
}
sections.push({
path,
heading: section.heading || section.kind,
kind: section.kind,
visible: section.visible,
lines,
items,
});
if (section.visible) total += lines;
});
bullets.sort((a, b) => b.length - a.length);
const visibleSections = [...sections].sort((a, b) => b.lines - a.lines);
return {
bullets,
sections: visibleSections,
estimatedLines: total,
approxPages: Math.max(1, Math.ceil(total / perPage)),
};
}
// ---------------------------------------------------------------------------
// The cuts themselves
// ---------------------------------------------------------------------------
/**
* Remove the bullet a path names, returning a new document.
*
* Returns the document unchanged when the path does not resolve, which is what
* happens if the layout it came from is a beat behind an edit. Silently doing
* nothing is right here: the alternative is deleting whatever now sits at that
* index, which would be somebody else's sentence.
*/
export function removeBulletAt(doc: ResumeDoc, path: string): ResumeDoc {
const match = /^s(\d+)\/([epxd])(\d+)\/b(\d+)$/.exec(path);
if (!match) return doc;
const [, rawSection, letter, rawEntry, rawBullet] = match;
const sectionIndex = Number(rawSection);
const entryIndex = Number(rawEntry);
const bulletIndex = Number(rawBullet);
const section = doc.sections[sectionIndex];
if (!section) return doc;
const drop = (list: string[]) =>
bulletIndex < list.length ? list.filter((_, index) => index !== bulletIndex) : list;
const sections = [...doc.sections];
if (letter === "e" && section.experience[entryIndex]) {
const experience = [...section.experience];
experience[entryIndex] = {
...experience[entryIndex],
bullets: drop(experience[entryIndex].bullets),
};
sections[sectionIndex] = { ...section, experience };
} else if (letter === "p" && section.projects[entryIndex]) {
const projects = [...section.projects];
projects[entryIndex] = { ...projects[entryIndex], bullets: drop(projects[entryIndex].bullets) };
sections[sectionIndex] = { ...section, projects };
} else if (letter === "x" && section.items[entryIndex]) {
const items = [...section.items];
items[entryIndex] = { ...items[entryIndex], bullets: drop(items[entryIndex].bullets) };
sections[sectionIndex] = { ...section, items };
} else if (letter === "d" && section.education[entryIndex]) {
const education = [...section.education];
education[entryIndex] = {
...education[entryIndex],
details: drop(education[entryIndex].details),
};
sections[sectionIndex] = { ...section, education };
} else {
return doc;
}
return { ...doc, sections };
}
/** Hide or show a section without deleting it. The biggest single lever. */
export function setSectionVisible(doc: ResumeDoc, index: number, visible: boolean): ResumeDoc {
const section = doc.sections[index];
if (!section) return doc;
const sections = [...doc.sections];
sections[index] = { ...section, visible };
return { ...doc, sections };
}