Skip to content

Commit e087723

Browse files
committed
fix(export): address PR #421 review — score-moving edits, error UI, wrap + reuse
Reviewer feedback from @Vaishnavi1709 (CHANGES_REQUESTED) and @Samhit21. Blocking: - #1 Added LinkedIn/GitHub link (guided picker → addedProfiles) now back-fills the empty legacy `_url` slot the scorer + contact gap read, so the add moves the score. applyProfileOverrides returns the back-filled keys. - #2 Anonymous scorer now treats a GitHub link as satisfying the "Professional profile" completeness check (parity with the ContactCard rule), so a GitHub-but-no-LinkedIn résumé isn't docked / listed as missing LinkedIn. - #3 applyOverrides returns an edited `fieldConfidence` (user-affirmed contact edits bumped to present, clears dropped to 0), threaded onto both the score input and displayResult so a typed-in / added link stops reading as absent against the frozen base parse. - #4 useDownloadReport.download returns a boolean; the dialog closes only on success, so a generation failure no longer unmounts the error UI. - #5 New shared src/lib/pdf/text-wrap.ts breaks a single overlong word at char boundaries (opt-in); the audit-report identity header uses it so a long URL no longer overflows the page. Secondary: - #6 Audit-report identity sourced via buildContact + basicsFromContact (no full buildAtsResumeModel + toJsonResume walk just to read .basics). - #7 render-audit-report + serialize are now dynamic-imported in useDownloadReport, keeping the ~470 LOC report path out of the entry chunk. - #8 Extracted src/lib/download/blob-download.ts (slugifyName + triggerBlobDownload); useDownloadPdf/useDownloadReport/useReportGap consume it. - #9 wrapWordsToLines shared by both PDF renderers (render-ats-pdf keeps its no-mid-word-break contract; the report opts into breaking). - #12 Achievements now map to JSON Resume `awards[]` (title + optional date; omitted entirely when absent, so achievement-free exports are byte-identical). Nits: - #14 usernameFromUrl drops its unreachable try/catch. - #16 render-audit-report embeds Helvetica + Helvetica-Bold via Promise.all. - #17 LinkedIn non-profile-path carve-out is now a `HostRule.nonProfilePath` field instead of an inline special case in the classify loop. Deferred (with rationale in the PR reply): #10 override-channel consolidation, #11 profiles-mirror memo split, #13 ISO-3166 location country codes, #15 unconditional profiles assign (breaks the empty-override no-op invariant), and @Samhit21's non-profile-LinkedIn label nit (by-design).
1 parent 14ad7bd commit e087723

18 files changed

Lines changed: 600 additions & 168 deletions

src/components/features/DownloadReportDialog.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ export function ReportDownloadControl({
5252
);
5353

5454
async function handleConfirm() {
55-
await download({ format, includeIdentity });
56-
setOpen(false);
55+
// Close ONLY on success — a failed generation sets `error`, and closing
56+
// here would unmount the dialog before the error line renders, leaving the
57+
// user with no artifact and no message (#421 Blocking #4).
58+
const ok = await download({ format, includeIdentity });
59+
if (ok) setOpen(false);
5760
}
5861

5962
return (

src/hooks/useAnalyzedResume.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,19 @@ import {
4949
import type {
5050
CascadeResult,
5151
HeuristicParsedResume,
52+
FieldConfidence,
5253
} from "../lib/heuristics/types.ts";
5354
import { buildBlankResult } from "../lib/heuristics/empty-result.ts";
5455

5556
export interface EditedResume {
5657
parsed: HeuristicParsedResume;
5758
rawText: string;
5859
score: AnonymousAtsScore;
60+
/** Edited per-field confidence (user-affirmed contact edits bumped to
61+
* present). Threaded onto `displayResult` so the ContactCard's
62+
* "GitHub satisfies Professional profile" gap reads the same edited
63+
* confidence the score did (#421 Blocking #3). */
64+
fieldConfidence: FieldConfidence;
5965
}
6066

6167
export interface AnalyzedResume {
@@ -138,7 +144,7 @@ export function useAnalyzedResume(): AnalyzedResume {
138144
// Fold overrides back into a fresh { parsed, rawText } and re-grade live.
139145
const edited = useMemo<EditedResume | null>(() => {
140146
if (base === null) return null;
141-
const { parsed, rawText, sections } = applyOverrides(
147+
const { parsed, rawText, sections, fieldConfidence } = applyOverrides(
142148
base.parsed,
143149
base.rawText,
144150
base.sections,
@@ -152,18 +158,21 @@ export function useAnalyzedResume(): AnalyzedResume {
152158
addedBullets,
153159
removedBullets,
154160
addedProfiles,
161+
base.fieldConfidence,
155162
);
156163
// The anonymous scorer pools its bullet set from `sections` (#133), so the
157164
// edited section view — not the original — must feed re-grading or a live
158-
// bullet edit would not move Specificity / Structure.
165+
// bullet edit would not move Specificity / Structure. `fieldConfidence` is
166+
// the edited view (contact edits + added linkedin/github bumped to present),
167+
// so a user-added professional profile moves completeness (#421).
159168
const score = computeAnonymousAtsScore({
160169
parsed,
161-
fieldConfidence: base.fieldConfidence,
170+
fieldConfidence,
162171
triggers: base.triggers,
163172
rawText,
164173
sections,
165174
});
166-
return { parsed, rawText, score };
175+
return { parsed, rawText, score, fieldConfidence };
167176
}, [
168177
base,
169178
doneScoreBullets,
@@ -180,7 +189,11 @@ export function useAnalyzedResume(): AnalyzedResume {
180189

181190
const displayResult = useMemo<CascadeResult | null>(() => {
182191
if (base === null || edited === null) return null;
183-
return { ...base, parsed: edited.parsed };
192+
return {
193+
...base,
194+
parsed: edited.parsed,
195+
fieldConfidence: edited.fieldConfidence,
196+
};
184197
}, [base, edited]);
185198

186199
// Clear edits whenever a fresh parse lands (new file, reset) or a fresh

src/hooks/useDownloadPdf.ts

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { CascadeResult } from "../lib/heuristics/types.ts";
1616
import type { AnonymousAtsScore } from "../lib/score/score.ts";
1717
import { buildAtsResumeModel } from "../lib/pdf/ats-resume-model.ts";
1818
import { renderAtsResumePdf } from "../lib/pdf/render-ats-pdf.ts";
19+
import { slugifyName, triggerBlobDownload } from "../lib/download/blob-download.ts";
1920
import type { EditableParse } from "./useEditableParse.ts";
2021
import { trackDownloadCompleted, type DownloadSource } from "../lib/analytics.ts";
2122
import { clearBlankDraft } from "./useResumeAnalysis.ts";
@@ -28,12 +29,7 @@ export interface UseDownloadPdf {
2829

2930
/** Turn a candidate name into a safe, lower-kebab PDF filename. */
3031
function filenameFromName(name: string | undefined): string {
31-
const slug = (name ?? "")
32-
.normalize("NFKD")
33-
.replace(/[^\w\s-]/g, "")
34-
.trim()
35-
.replace(/\s+/g, "-")
36-
.toLowerCase();
32+
const slug = slugifyName(name);
3733
return slug ? `${slug}-resume-ats.pdf` : "resume-ats.pdf";
3834
}
3935

@@ -48,27 +44,16 @@ export function useDownloadPdf(
4844
const download = useCallback(async () => {
4945
setIsGenerating(true);
5046
setError(null);
51-
let url: string | null = null;
5247
try {
5348
const model = buildAtsResumeModel(result, score, edit);
5449
const bytes = await renderAtsResumePdf(model);
55-
// Copy into a fresh ArrayBuffer-backed view so Blob gets a clean buffer.
56-
const blob = new Blob([bytes.slice()], { type: "application/pdf" });
57-
url = URL.createObjectURL(blob);
58-
const a = document.createElement("a");
59-
a.href = url;
60-
a.download = filenameFromName(model.contact.name);
61-
document.body.appendChild(a);
62-
a.click();
63-
a.remove();
64-
// Defer the revoke: a.click() only SCHEDULES the download — the browser
65-
// reads the object URL asynchronously afterward. Revoking synchronously
66-
// (e.g. in finally) invalidates the URL before the fetch starts, which
67-
// silently kills the download on slower/remote contexts and on
68-
// Firefox/Safari. Hand the URL off, then revoke on a later task.
69-
const settledUrl = url;
70-
url = null;
71-
setTimeout(() => URL.revokeObjectURL(settledUrl), 60_000);
50+
// `bytes.slice()` copies into a fresh ArrayBuffer-backed view so Blob gets
51+
// a clean buffer.
52+
triggerBlobDownload(
53+
bytes.slice(),
54+
"application/pdf",
55+
filenameFromName(model.contact.name),
56+
);
7257

7358
// Distinguish a from-scratch authored download from an uploaded one
7459
// (#313). `tiers` is empty ONLY for `buildBlankResult()`'s output —
@@ -84,7 +69,6 @@ export function useDownloadPdf(
8469
} catch (err) {
8570
setError(err instanceof Error ? err.message : "Could not generate PDF.");
8671
} finally {
87-
if (url) URL.revokeObjectURL(url);
8872
setIsGenerating(false);
8973
}
9074
}, [result, score, edit]);

src/hooks/useDownloadReport.ts

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,10 @@ import type { CascadeResult } from "../lib/heuristics/types.ts";
2727
import type { LayoutTrigger } from "../lib/heuristics/types.ts";
2828
import type { AnonymousAtsScore } from "../lib/score/score.ts";
2929
import { getScoreRecommendation } from "../lib/score/recommendation.ts";
30-
import { buildAtsResumeModel } from "../lib/pdf/ats-resume-model.ts";
31-
import { toJsonResume } from "../lib/pdf/to-json-resume.ts";
32-
import { renderAuditReportPdf } from "../lib/pdf/render-audit-report.ts";
33-
import {
34-
serializeAuditReportJson,
35-
type AuditReportInput,
36-
} from "../lib/report/serialize.ts";
30+
import { buildContact } from "../lib/pdf/ats-resume-model.ts";
31+
import { basicsFromContact } from "../lib/pdf/to-json-resume.ts";
32+
import { slugifyName, triggerBlobDownload } from "../lib/download/blob-download.ts";
33+
import type { AuditReportInput } from "../lib/report/serialize.ts";
3734
import type { EditableParse } from "./useEditableParse.ts";
3835
import { trackReportDownloaded, type ReportFormat } from "../lib/analytics.ts";
3936

@@ -44,36 +41,15 @@ export interface DownloadReportOptions {
4441
}
4542

4643
export interface UseDownloadReport {
47-
download: (opts: DownloadReportOptions) => Promise<void>;
44+
/** Generate + download the report. Resolves `true` on success, `false` when
45+
* generation failed (the error is surfaced via `error`) — the caller gates
46+
* closing the dialog on this so a failure doesn't unmount the error UI
47+
* (#421 Blocking #4). */
48+
download: (opts: DownloadReportOptions) => Promise<boolean>;
4849
isGenerating: boolean;
4950
error: string | null;
5051
}
5152

52-
/** Lower-kebab slug for the report filename; empty in → generic name. */
53-
function slugFromName(name: string | undefined): string {
54-
return (name ?? "")
55-
.normalize("NFKD")
56-
.replace(/[^\w\s-]/g, "")
57-
.trim()
58-
.replace(/\s+/g, "-")
59-
.toLowerCase();
60-
}
61-
62-
/** Trigger a same-document download of `bytes` as `filename`. */
63-
function triggerDownload(bytes: BlobPart, mime: string, filename: string): void {
64-
const blob = new Blob([bytes], { type: mime });
65-
const url = URL.createObjectURL(blob);
66-
const a = document.createElement("a");
67-
a.href = url;
68-
a.download = filename;
69-
document.body.appendChild(a);
70-
a.click();
71-
a.remove();
72-
// Defer revoke: a.click() only schedules the download; revoking synchronously
73-
// can kill it on slower/remote contexts + Firefox/Safari (mirrors useDownloadPdf).
74-
setTimeout(() => URL.revokeObjectURL(url), 60_000);
75-
}
76-
7753
export function useDownloadReport(
7854
result: CascadeResult,
7955
score: AnonymousAtsScore,
@@ -83,14 +59,16 @@ export function useDownloadReport(
8359
const [error, setError] = useState<string | null>(null);
8460

8561
const download = useCallback(
86-
async ({ format, includeIdentity }: DownloadReportOptions) => {
62+
async ({ format, includeIdentity }: DownloadReportOptions): Promise<boolean> => {
8763
setIsGenerating(true);
8864
setError(null);
8965
try {
9066
// Identity is sourced ONLY when opted in — never build a basics block
9167
// we're about to strip (defense in depth alongside the serializer gate).
68+
// Build it from the contact block directly (no full resume-model walk
69+
// just to read `.basics`, #421 Secondary #6).
9270
const identity = includeIdentity
93-
? toJsonResume(buildAtsResumeModel(result, score, edit)).basics
71+
? basicsFromContact(buildContact(result, edit?.contactOverrides ?? {}))
9472
: undefined;
9573

9674
const input: AuditReportInput = {
@@ -104,22 +82,33 @@ export function useDownloadReport(
10482

10583
// Filename carries the name ONLY when identity is included — otherwise a
10684
// generic name so the download itself leaks nothing.
107-
const slug = includeIdentity ? slugFromName(identity?.name) : "";
85+
const slug = includeIdentity ? slugifyName(identity?.name) : "";
10886
const base = slug ? `${slug}-resume-audit-report` : "resume-audit-report";
10987

88+
// Lazy-load the renderer/serializer so the ~470 LOC audit-report path
89+
// stays out of the entry chunk for the sessions that never click
90+
// "Download report" (#421 Secondary #7, mirroring load-pdf-lib.ts).
11091
if (format === "pdf") {
92+
const { renderAuditReportPdf } = await import(
93+
"../lib/pdf/render-audit-report.ts"
94+
);
11195
const bytes = await renderAuditReportPdf(input);
112-
triggerDownload(bytes.slice(), "application/pdf", `${base}.pdf`);
96+
triggerBlobDownload(bytes.slice(), "application/pdf", `${base}.pdf`);
11397
} else {
98+
const { serializeAuditReportJson } = await import(
99+
"../lib/report/serialize.ts"
100+
);
114101
const json = serializeAuditReportJson(input);
115-
triggerDownload(json, "application/json", `${base}.json`);
102+
triggerBlobDownload(json, "application/json", `${base}.json`);
116103
}
117104

118105
trackReportDownloaded({ format, includeIdentity });
106+
return true;
119107
} catch (err) {
120108
setError(
121109
err instanceof Error ? err.message : "Could not generate report.",
122110
);
111+
return false;
123112
} finally {
124113
setIsGenerating(false);
125114
}

src/hooks/useReportGap.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useCallback, useState } from "react";
2323
import type { CascadeResult } from "../lib/heuristics/types.ts";
2424
import type { ParseDisagreement } from "../lib/heuristics/disagreement.ts";
2525
import { buildReproArtifact } from "../lib/heuristics/repro-artifact.ts";
26+
import { triggerBlobDownload } from "../lib/download/blob-download.ts";
2627
import { trackGapReported } from "../lib/analytics.ts";
2728

2829
export interface UseReportGap {
@@ -53,35 +54,20 @@ export function useReportGap(
5354

5455
const report = useCallback(() => {
5556
setError(null);
56-
let url: string | null = null;
5757
try {
5858
const artifact = buildReproArtifact(result, disagreements);
5959
const json = JSON.stringify(artifact, null, 2);
60-
const blob = new Blob([json], { type: "application/json" });
61-
url = URL.createObjectURL(blob);
62-
const a = document.createElement("a");
63-
a.href = url;
64-
a.download = artifactFilename();
65-
document.body.appendChild(a);
66-
a.click();
67-
a.remove();
60+
triggerBlobDownload(json, "application/json", artifactFilename());
6861
// Count-only, env-gated telemetry — never the artifact contents.
6962
trackGapReported({
7063
disagreementCount: disagreements.length,
7164
triggers: result.triggers,
7265
});
73-
// Defer the revoke: a.click() only schedules the download; revoking
74-
// synchronously can kill it on slower/remote contexts (see useDownloadPdf).
75-
const settledUrl = url;
76-
url = null;
77-
setTimeout(() => URL.revokeObjectURL(settledUrl), 60_000);
7866
setReported(true);
7967
} catch (err) {
8068
setError(
8169
err instanceof Error ? err.message : "Could not generate the report.",
8270
);
83-
} finally {
84-
if (url) URL.revokeObjectURL(url);
8571
}
8672
}, [result, disagreements]);
8773

src/lib/contact/profile-registry.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ interface HostRule {
3131
/** Human-facing network label shown in the UI. */
3232
network: string;
3333
kind: ProfileLink["kind"];
34+
/** Paths on this host that are NOT a personal identity profile (e.g. a
35+
* LinkedIn company/jobs/feed page, a GitHub org page). Tested against the
36+
* full URL; a match keeps the link but downgrades it to a generic `other`
37+
* link on the bare host — never the network's `social`/`code`/… kind. Keeps
38+
* the per-host exclusion inside the `HostRule` shape instead of accreting a
39+
* special case in the classify loop (#421 review, nit 17). */
40+
nonProfilePath?: RegExp;
3441
/** UI-only guided-add hint. When set, this host surfaces as a quick-pick chip
3542
* in the profile-add affordance (`PROFILE_QUICK_PICKS`): tapping the chip
3643
* pre-fills `prefix` and the caret lands after it so the user types only
@@ -50,6 +57,7 @@ export const PROFILE_HOSTS: readonly HostRule[] = [
5057
match: /(^|\.)linkedin\.com$/i,
5158
network: "LinkedIn",
5259
kind: "social",
60+
nonProfilePath: LINKEDIN_NONPROFILE_RE,
5361
quickPick: { prefix: "https://linkedin.com/in/", hint: "your-handle" },
5462
},
5563
{
@@ -141,17 +149,15 @@ export function classifyProfile(rawUrl: string): ProfileLink | undefined {
141149
}
142150
if (hostname.length === 0) return undefined;
143151

144-
// A LinkedIn URL that is a feed/company/jobs/… page is not a personal
145-
// identity profile — keep it, but do not label it `social`.
146-
const isLinkedinHost = /(^|\.)linkedin\.com$/i.test(hostname);
147-
if (isLinkedinHost && LINKEDIN_NONPROFILE_RE.test(url)) {
148-
return { url, network: hostname, kind: "other" };
149-
}
150-
151152
for (const rule of PROFILE_HOSTS) {
152-
if (rule.match.test(hostname)) {
153-
return { url, network: rule.network, kind: rule.kind };
153+
if (!rule.match.test(hostname)) continue;
154+
// A non-profile path on this host (e.g. a LinkedIn feed/company/jobs page)
155+
// is kept but downgraded to a generic `other` link on the bare host — never
156+
// the network's identity kind.
157+
if (rule.nonProfilePath?.test(url)) {
158+
return { url, network: hostname, kind: "other" };
154159
}
160+
return { url, network: rule.network, kind: rule.kind };
155161
}
156162
return { url, network: hostname, kind: "other" };
157163
}

0 commit comments

Comments
 (0)