From 8deb3ca6f3094f3dbb7a129e71aad4152fc4099a Mon Sep 17 00:00:00 2001 From: Sory Kaba <131464999+SoryAK@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:52:29 -0400 Subject: [PATCH 1/3] Review every added resume on one timeline, and accept several resumes in one selection. Co-authored-by: Cursor --- core/index.ts | 1 + core/onboarding-review.test.ts | 16 +- core/onboarding-review.ts | 6 +- core/resume-compare.test.ts | 9 + core/resume-compare.ts | 16 ++ web/src/Onboarding.tsx | 150 ++++++++----- web/src/OnboardingCheck.tsx | 10 +- web/src/OnboardingCompare.tsx | 398 ++++++++++++++++++++++++++++++++- web/src/onboarding-session.ts | 12 + 9 files changed, 555 insertions(+), 63 deletions(-) diff --git a/core/index.ts b/core/index.ts index e2eb78f..eb69fd8 100644 --- a/core/index.ts +++ b/core/index.ts @@ -34,6 +34,7 @@ export { holdVerifiedRoles, parseHistoryEvidence, queuedResumeDuplicate, + resumesToQueue, rememberSetAside, rememberSkills, roleFieldDiffs, diff --git a/core/onboarding-review.test.ts b/core/onboarding-review.test.ts index 7efb829..29ad264 100644 --- a/core/onboarding-review.test.ts +++ b/core/onboarding-review.test.ts @@ -74,20 +74,26 @@ function resume(): ExtractedResume { } describe("onboardingExtractSummary", () => { - it("states how many jobs, schools, and internships were extracted", () => { - expect(onboardingExtractSummary(onboardingCheckItems(resume()))).toBe( - "2 jobs, 1 school, and 0 internships were extracted.", + it("states how many resumes, jobs, schools, and internships were extracted", () => { + expect(onboardingExtractSummary(onboardingCheckItems(resume()), 1)).toBe( + "1 resume, 2 jobs, 1 school, and 0 internships were extracted.", + ); + }); + + it("uses the plural when more than one resume was read", () => { + expect(onboardingExtractSummary(onboardingCheckItems(resume()), 2)).toBe( + "2 resumes, 2 jobs, 1 school, and 0 internships were extracted.", ); }); it("uses the singular for one job and one school", () => { const items = onboardingCheckItems(resume()).filter((item) => item.org !== "Dropped Co"); - expect(onboardingExtractSummary(items)).toBe("1 job, 1 school, and 0 internships were extracted."); + expect(onboardingExtractSummary(items, 1)).toBe("1 resume, 1 job, 1 school, and 0 internships were extracted."); }); it("leaves out a role the occupant added", () => { const items = [...onboardingCheckItems(resume()), blankOnboardingCheckItem("Job", -1)]; - expect(onboardingExtractSummary(items)).toBe("2 jobs, 1 school, and 0 internships were extracted."); + expect(onboardingExtractSummary(items, 1)).toBe("1 resume, 2 jobs, 1 school, and 0 internships were extracted."); }); }); diff --git a/core/onboarding-review.ts b/core/onboarding-review.ts index 44662bb..d2f61c4 100644 --- a/core/onboarding-review.ts +++ b/core/onboarding-review.ts @@ -39,14 +39,14 @@ export type OnboardingLinkAnswer = { portfolioUrl: string; }; -/** How many jobs, schools, and internships the file yielded. Added records are not part of the extract. */ -export function onboardingExtractSummary(items: OnboardingCheckItem[]): string { +/** How many resumes, jobs, schools, and internships the read yielded. Added records are not part of the extract. */ +export function onboardingExtractSummary(items: OnboardingCheckItem[], resumes: number): string { const extracted = items.filter((item) => item.sourceIndex >= 0); const jobs = extracted.filter((item) => item.kind === "Job").length; const schools = extracted.filter((item) => item.kind === "School").length; // An internship is kept as a job until the role records that kind. const internships = 0; - return `${countLabel(jobs, "job", "jobs")}, ${countLabel(schools, "school", "schools")}, and ${countLabel(internships, "internship", "internships")} were extracted.`; + return `${countLabel(resumes, "resume", "resumes")}, ${countLabel(jobs, "job", "jobs")}, ${countLabel(schools, "school", "schools")}, and ${countLabel(internships, "internship", "internships")} were extracted.`; } function countLabel(count: number, singular: string, plural: string): string { diff --git a/core/resume-compare.test.ts b/core/resume-compare.test.ts index 1a143d0..a9eb64a 100644 --- a/core/resume-compare.test.ts +++ b/core/resume-compare.test.ts @@ -12,6 +12,7 @@ import { holdVerifiedRoles, parseHistoryEvidence, queuedResumeDuplicate, + resumesToQueue, rememberSetAside, rememberSkills, roleFieldDiffs, @@ -45,6 +46,14 @@ describe("queued resumes", () => { expect(queuedResumeDuplicate(["abc", "def"], "ABC")).toBe(true); expect(queuedResumeDuplicate(["abc"], "xyz")).toBe(false); }); + + it("keeps a selection of new files and skips ones already queued", () => { + expect(resumesToQueue(["abc"], ["abc", "def", "ghi", "def"])).toEqual({ indexes: [1, 2], skipped: 2 }); + }); + + it("keeps every file when none are queued yet", () => { + expect(resumesToQueue([], ["abc", "def"])).toEqual({ indexes: [0, 1], skipped: 0 }); + }); }); describe("resume compare", () => { diff --git a/core/resume-compare.ts b/core/resume-compare.ts index fafb0cb..5c45a5f 100644 --- a/core/resume-compare.ts +++ b/core/resume-compare.ts @@ -71,6 +71,22 @@ export function queuedResumeDuplicate(digests: readonly string[], digest: string return digests.some((row) => row.trim().toLowerCase() === next); } +/** Indexes from one selection that are not already queued. A repeat inside the selection counts once. */ +export function resumesToQueue(existing: readonly string[], incoming: readonly string[]): { indexes: number[]; skipped: number } { + const seen = [...existing]; + const indexes: number[] = []; + let skipped = 0; + for (let index = 0; index < incoming.length; index += 1) { + if (queuedResumeDuplicate(seen, incoming[index] ?? "")) { + skipped += 1; + continue; + } + seen.push(incoming[index] ?? ""); + indexes.push(index); + } + return { indexes, skipped }; +} + /** Roles that survived Verify, attributed to the file they came from. */ export function holdVerifiedRoles(items: OnboardingCheckItem[], fileIndex: number): HeldRole[] { return items.filter((item) => !isBlankOnboardingCheckItem(item)).map((item) => holdRole(item, fileIndex)); diff --git a/web/src/Onboarding.tsx b/web/src/Onboarding.tsx index 7ae82fc..0722d25 100644 --- a/web/src/Onboarding.tsx +++ b/web/src/Onboarding.tsx @@ -22,8 +22,7 @@ import type { ExtractedProfile, ExtractedResume } from "@core/resume-extract"; import { dropSetAside, historyFromHeld, - holdVerifiedRoles, - queuedResumeDuplicate, + resumesToQueue, rememberSetAside, rememberSkills, type HeldRole, @@ -31,10 +30,10 @@ import { type SetAsideRole, } from "@core/resume-compare"; import { AddressFields } from "./OnboardingAddress"; -import { CheckOverview, ResumePreview } from "./OnboardingCheck"; -import { ResumeCompare } from "./OnboardingCompare"; +import { ResumePreview } from "./OnboardingCheck"; +import { AllResumesVerify, ResumeCompare, type ResumeBundle } from "./OnboardingCompare"; import { ResumeReview } from "./ResumeReview"; -import { clearOnboardingDraft, loadOnboardingDraft, saveOnboardingDraft } from "./onboarding-session"; +import { clearOnboardingDraft, loadOnboardingDraft, saveOnboardingDraft, type OnboardingTimelineRow } from "./onboarding-session"; import type { OnboardingProfileValue } from "./OnboardingProfile"; import { YesNo } from "./onboarding-offer"; import { IntegrationSettings } from "./IntegrationSettings"; @@ -112,11 +111,16 @@ export function Onboarding({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [readDigests, setReadDigests] = useState([]); + const [timeline, setTimeline] = useState(null); + const [declinedPairs, setDeclinedPairs] = useState([]); const extractsRef = useRef>(new Map()); const profileRef = useRef(initialProfile); const abortRef = useRef(null); const scanAbort = useRef(null); const fileInputRef = useRef(null); + const filesRef = useRef(files); + const addChain = useRef(Promise.resolve()); + filesRef.current = files; const previewDialogRef = useRef(null); useEffect(() => { @@ -155,6 +159,8 @@ export function Onboarding({ setCursor(draft.cursor); setPass(draft.pass); setItems(draft.items); + setTimeline(draft.timeline ?? null); + setDeclinedPairs(draft.declinedPairs ?? []); setProfileExtract(draft.profileExtract); setExtracted(draft.extracts.find((row) => row.digest === draft.files[draft.cursor]?.digest)?.data ?? null); setStep(draft.step); @@ -193,6 +199,8 @@ export function Onboarding({ pass, step: step === "check" || step === "file" ? step : "file", items, + timeline, + declinedPairs, profileExtract, }); } catch { @@ -202,7 +210,7 @@ export function Onboarding({ return () => { cancel = true; }; - }, [draftReady, files, readDigests, held, heldSkills, setAsides, cursor, pass, step, items, profileExtract]); + }, [draftReady, files, readDigests, held, heldSkills, setAsides, cursor, pass, step, items, timeline, declinedPairs, profileExtract]); useEffect(() => { let cancel = false; @@ -296,22 +304,41 @@ export function Onboarding({ setPhoto(next); } - async function addResume(next: File) { - const digest = await digestFile(next); - let duplicate = false; - setFiles((current) => { - if (queuedResumeDuplicate(current.map((row) => row.digest), digest)) { - duplicate = true; - return current; - } - return [...current, { file: next, digest }]; + function addResumes(picked: File[]) { + if (extracting || picked.length === 0) return; + const batch = picked; + const run = addChain.current.then(() => appendResumes(batch)); + addChain.current = run.then( + () => undefined, + () => undefined, + ); + } + + async function appendResumes(batch: File[]) { + if (extracting || batch.length === 0) return; + const incoming = await Promise.all(batch.map(async (file) => ({ file, digest: await digestFile(file) }))); + const queued = resumesToQueue( + filesRef.current.map((row) => row.digest), + incoming.map((row) => row.digest), + ); + const added = queued.indexes.flatMap((index) => { + const row = incoming[index]; + return row ? [row] : []; }); - setError(duplicate ? "That resume is already in the list." : null); + if (added.length > 0) { + const next = [...filesRef.current, ...added]; + filesRef.current = next; + setFiles(next); + } + setError( + queued.skipped === 0 ? null : queued.skipped === 1 ? "That resume is already in the list." : "Those resumes are already in the list.", + ); } function removeResume(index: number) { if (extracting) return; if (held.length > 0 && index < cursor) return; + setError(null); const digest = files[index]?.digest; if (digest) { extractsRef.current.delete(digest); @@ -407,23 +434,28 @@ export function Onboarding({ } function startQuestions() { - if (held.length === 0) { - openAt(0); - return; - } - const index = cursor > 0 ? Math.min(cursor, files.length - 1) : 1; - if (index >= files.length) { - void finishSession(held, heldSkills, true); + if (held.length > 0) { + const index = cursor > 0 ? Math.min(cursor, files.length - 1) : 1; + if (index >= files.length) { + void finishSession(held, heldSkills, true); + return; + } + openAt(index); return; } - openAt(index); + const first = files[0] ? extractsRef.current.get(files[0].digest) : undefined; + if (first) setProfileExtract(first.profile); + setTimeline(null); + setDeclinedPairs([]); + setItems([]); + setPass("verify"); + setStep("check"); } - function acceptVerified(kept: OnboardingCheckItem[]) { - const nextHeld = holdVerifiedRoles(kept, cursor); - const jobsKept = kept.some((item) => item.source === "job"); - const skills = rememberSkills([], extracted?.skills ?? [], cursor, jobsKept); - advance(nextHeld, skills); + function acceptTimeline(nextHeld: HeldRole[], skills: HeldSkill[]) { + setHeld(nextHeld); + setHeldSkills(skills); + void finishSession(nextHeld, skills, nextHeld.length > 0); } function acceptCompared(nextHeld: HeldRole[], acceptSkills: boolean) { @@ -431,17 +463,6 @@ export function Onboarding({ advance(nextHeld, skills); } - function changeVerified(next: OnboardingCheckItem[]) { - const nextKeys = new Set(next.map((item) => `${item.source}-${item.sourceIndex}`)); - const removed = items.filter( - (item) => item.org.trim() && !nextKeys.has(`${item.source}-${item.sourceIndex}`), - ); - if (removed.length > 0) { - setSetAsides((current) => removed.reduce((list, item) => rememberSetAside(list, item), current)); - } - setItems(next); - } - function advance(nextHeld: HeldRole[], skills: HeldSkill[]) { for (let index = cursor + 1; index < files.length; index += 1) { if (!extractsRef.current.has(files[index].digest)) continue; @@ -878,15 +899,16 @@ export function Onboarding({ {extracting ? : null} {files.length === 0 ? ( @@ -943,17 +965,18 @@ export function Onboarding({ disabled={extracting} onClick={() => fileInputRef.current?.click()} > - Add another resume + Add resumes { - const next = event.target.files?.[0]; + const picked = event.target.files ? [...event.target.files] : []; event.target.value = ""; - if (next) void addResume(next); + addResumes(picked); }} /> @@ -1035,14 +1058,23 @@ export function Onboarding({ ) : null} {step === "check" && pass === "verify" ? ( - setSetAsides((current) => rememberSetAside(current, item))} + onForgetAside={(id) => setSetAsides((current) => dropSetAside(current, id))} + onRows={(rows, declined) => { + setTimeline(rows); + setDeclinedPairs(declined); + }} + onDone={acceptTimeline} /> ) : null} {step === "check" && pass === "compare" ? ( @@ -1088,6 +1120,22 @@ export function Onboarding({ ); } +function resumeBundles(queued: QueuedResume[], extracts: Map): ResumeBundle[] { + return queued.flatMap((row, fileIndex) => { + const data = extracts.get(row.digest); + if (!data) return []; + return [ + { + file: row.file, + fileIndex, + name: row.file.name, + skills: data.skills ?? [], + items: onboardingCheckItems(data), + }, + ]; + }); +} + function WashProgress({ step }: { step: OnboardingStep }) { const current = STEP_INDEX[step]; return ( diff --git a/web/src/OnboardingCheck.tsx b/web/src/OnboardingCheck.tsx index 52742bc..bc79b77 100644 --- a/web/src/OnboardingCheck.tsx +++ b/web/src/OnboardingCheck.tsx @@ -39,7 +39,7 @@ export function CheckOverview({ const [reviewIndex, setReviewIndex] = useState(0); const [openedKeys, setOpenedKeys] = useState([]); const [pointOutUnopened, setPointOutUnopened] = useState(false); - const summary = onboardingExtractSummary(items); + const summary = onboardingExtractSummary(items, 1); function updateAt(index: number, next: OnboardingCheckItem) { onChange(items.map((row, rowIndex) => (rowIndex === index ? stamped(next) : row))); @@ -167,6 +167,7 @@ function ReviewCarousel({ open, items, file, + files, index, onIndex, onChange, @@ -176,6 +177,7 @@ function ReviewCarousel({ open: boolean; items: OnboardingCheckItem[]; file: File | null; + files?: Array; index: number; onIndex: (index: number) => void; onChange: (index: number, item: OnboardingCheckItem) => void; @@ -184,6 +186,7 @@ function ReviewCarousel({ }) { const dialog = useRef(null); const item = items[index]; + const slideFile = files?.[index] ?? file; const [shown, setShown] = useState(false); useEffect(() => { const node = dialog.current; @@ -200,14 +203,14 @@ function ReviewCarousel({ {shown && item ? ( onChange(index, next)} @@ -542,6 +545,7 @@ export function CheckReview(props: { open: boolean; items: OnboardingCheckItem[]; file: File | null; + files?: Array; index: number; onIndex: (index: number) => void; onChange: (index: number, item: OnboardingCheckItem) => void; diff --git a/web/src/OnboardingCompare.tsx b/web/src/OnboardingCompare.tsx index 83cd4b1..0ab2fa2 100644 --- a/web/src/OnboardingCompare.tsx +++ b/web/src/OnboardingCompare.tsx @@ -5,14 +5,25 @@ import { applySameRole, hintHeldRoles, hintSetAside, + rememberSkills, roleFieldDiffs, + roleTookFile, type HeldRole, + type HeldSkill, type ResumeFieldDiff, type ResumeFieldTake, type ResumeMatchReason, type SetAsideRole, } from "@core/resume-compare"; -import { isBlankOnboardingCheckItem, ONBOARDING_NO_PLACE, type OnboardingCheckItem } from "@core/onboarding-review"; +import { + blankOnboardingCheckItem, + isBlankOnboardingCheckItem, + nextOnboardingSourceIndex, + onboardingExtractSummary, + ONBOARDING_NO_PLACE, + type OnboardingCheckItem, +} from "@core/onboarding-review"; +import type { OnboardingTimelineRow } from "./onboarding-session"; import { CheckReview, VerifyBoard, WorkList, cardMeta, cardTitle, workLines, yearLabel, type VerifyCard } from "./OnboardingCheck"; export function ResumeCompare({ @@ -445,3 +456,388 @@ function hintCopy(reason: ResumeMatchReason, kept: OnboardingCheckItem): string if (reason === "employer") return `Same employer as ${label}.`; return `Might be the same as ${label}.`; } + +export type ResumeBundle = { + file: File; + fileIndex: number; + name: string; + skills: string[]; + items: OnboardingCheckItem[]; +}; + +export function AllResumesVerify({ + bundles, + initialRows, + initialDeclined, + editedItems, + editedFileIndex, + asides, + busy, + error, + onBack, + onSetAside, + onForgetAside, + onRows, + onDone, +}: { + bundles: ResumeBundle[]; + initialRows: OnboardingTimelineRow[] | null; + initialDeclined: string[]; + editedItems: OnboardingCheckItem[]; + editedFileIndex: number; + asides: SetAsideRole[]; + busy: boolean; + error: string | null; + onBack: () => void; + onSetAside: (item: OnboardingCheckItem) => void; + onForgetAside: (id: string) => void; + onRows: (rows: OnboardingTimelineRow[], declined: string[]) => void; + onDone: (held: HeldRole[], skills: HeldSkill[]) => void; +}) { + const [rows, setRows] = useState( + () => initialRows ?? overlayFileEdits(buildTimeline(bundles), editedItems, editedFileIndex), + ); + const [declined, setDeclined] = useState(initialDeclined); + const [opened, setOpened] = useState([]); + const [pointOut, setPointOut] = useState(false); + const [editId, setEditId] = useState(null); + const [compareId, setCompareId] = useState(null); + const [confirming, setConfirming] = useState(false); + const [picks, setPicks] = useState>({}); + const visible = rows.filter((row) => !row.removed); + const summary = onboardingExtractSummary( + bundles.flatMap((bundle) => bundle.items), + bundles.length, + ); + const pair = comparePair(rows, compareId); + const choices = pair ? identityChoices(pair.earlier.item, pair.later.item) : []; + + useEffect(() => { + onRows(rows, declined); + }, [rows, declined, onRows]); + + function markOpened(id: string) { + setOpened((current) => (current.includes(id) ? current : [...current, id])); + } + + function patch(id: string, item: OnboardingCheckItem) { + setRows((current) => + current.map((row) => + row.id === id ? { ...row, item, role: row.role ? { ...row.role, item } : null } : row, + ), + ); + } + + function removeRow(id: string) { + const row = rows.find((item) => item.id === id); + if (!row || isBlankOnboardingCheckItem(row.item)) { + setRows((current) => current.filter((item) => item.id !== id)); + } else { + onSetAside(row.item); + setRows((current) => current.map((item) => (item.id === id ? { ...item, removed: true } : item))); + } + setEditId(null); + setCompareId(null); + } + + function keepDifferent(laterId: string) { + const match = comparePair(rows, laterId); + if (!match) return; + const aside = hintSetAside(asides, match.later.item); + if (aside) onForgetAside(aside.id); + setDeclined((current) => { + const key = pairKey(match.earlier.id, match.later.id); + return current.includes(key) ? current : [...current, key]; + }); + setRows((current) => + current.map((row) => (row.id === match.later.id ? { ...row, adopted: true } : row)), + ); + setCompareId(null); + setConfirming(false); + setPicks({}); + } + + function adopt(id: string) { + const row = rows.find((item) => item.id === id); + if (!row) return; + const aside = hintSetAside(asides, row.item); + if (aside) onForgetAside(aside.id); + setRows((current) => current.map((item) => (item.id === id ? { ...item, adopted: true } : item))); + } + + function merge() { + if (!pair) return; + if (!confirming && choices.length > 0) { + setConfirming(true); + return; + } + if (choices.some((choice) => !picks[choice.key])) return; + const takes = [ + ...lineTakes(pair.earlier.item, pair.later.item), + ...choices.filter((choice) => picks[choice.key] === "incoming").flatMap((choice) => choice.takes), + ]; + const base = pair.earlier.role ?? addHeldRole([], pair.earlier.item, pair.earlier.fileIndex)[0]; + if (!base) return; + const nextRole = applySameRole(base, pair.later.item, pair.later.fileIndex, takes); + const aside = hintSetAside(asides, pair.later.item); + if (aside) onForgetAside(aside.id); + setRows((current) => + current.map((row) => { + if (row.id === pair.earlier.id) return { ...row, item: nextRole.item, role: nextRole, adopted: true }; + if (row.id === pair.later.id) return { ...row, removed: true, adopted: true }; + return row; + }), + ); + setCompareId(null); + setConfirming(false); + setPicks({}); + setEditId(null); + } + + function finish() { + const next = rows.filter((row) => !isBlankOnboardingCheckItem(row.item)); + const unopened = next.filter((row) => !row.removed && !opened.includes(row.id)); + if (unopened.length > 0 && !pointOut) { + setRows(next); + setPointOut(true); + return; + } + let held: HeldRole[] = []; + for (const row of next) { + if (row.removed) continue; + if (!row.adopted && hintSetAside(asides, row.item)) continue; + const role = row.role ?? addHeldRole([], row.item, row.fileIndex)[0]; + if (role) held = [...held, { ...role, item: row.item }]; + } + let skills: HeldSkill[] = []; + for (const bundle of bundles) { + const tookJob = held.some((role) => role.item.source === "job" && roleTookFile(role, bundle.fileIndex)); + skills = rememberSkills(skills, bundle.skills, bundle.fileIndex, tookJob); + } + onDone(held, skills); + } + + function add(kind: OnboardingCheckItem["kind"]) { + const item = blankOnboardingCheckItem(kind, nextOnboardingSourceIndex(rows.map((row) => row.item), kind)); + const fileIndex = bundles[0]?.fileIndex ?? 0; + const row: OnboardingTimelineRow = { + id: `file-${fileIndex}-${item.source}-${item.sourceIndex}`, + fileIndex, + item, + removed: false, + adopted: true, + role: null, + }; + setRows((current) => [...current, row]); + markOpened(row.id); + setEditId(row.id); + } + + const cards = (section: "job" | "school"): VerifyCard[] => + visible + .filter((row) => row.item.source === section) + .map((row) => { + const fileName = bundles.find((bundle) => bundle.fileIndex === row.fileIndex)?.name ?? null; + const match = matchFor(rows, row, declined); + const asideHit = !match && hintSetAside(asides, row.item); + return { + key: row.id, + title: cardTitle(row.item), + meta: cardMeta(row.item, fileName), + year: yearLabel(row.item), + start: row.item.startDate, + opened: opened.includes(row.id), + pending: pointOut && !opened.includes(row.id) && !isBlankOnboardingCheckItem(row.item), + hint: match + ? hintCopy(match.hint.reason, match.earlier.item) + : asideHit && !row.adopted + ? "You set this aside before." + : undefined, + keepLabel: asideHit && !row.adopted && !match ? "Add this role" : undefined, + onOpen: () => { + markOpened(row.id); + setCompareId(null); + setEditId(row.id); + }, + onCompare: match + ? () => { + markOpened(row.id); + setEditId(null); + setCompareId(row.id); + setConfirming(false); + setPicks({}); + } + : undefined, + onKeep: asideHit && !row.adopted && !match ? () => adopt(row.id) : undefined, + }; + }); + + const editIndex = visible.findIndex((row) => row.id === editId); + const unopenedCount = visible.filter( + (row) => !isBlankOnboardingCheckItem(row.item) && !opened.includes(row.id), + ).length; + + return ( + <> +

Verify

+

+ Here's what was extracted. +

+

{summary}

+

+ {bundles.length > 1 + ? "Open a job or a school to review it. Compare a role when it might be the same as another." + : "Open a job or a school to review it against the resume."} +

+ +
+ + + +
+ {error ? ( +

+ {error} +

+ ) : null} + {pointOut && unopenedCount > 0 ? ( +

+ {unopenedCount === 1 ? "1 has not been opened." : `${unopenedCount} have not been opened.`} Open them, or continue again to keep them. +

+ ) : null} +
+ + +
+ {editIndex >= 0 ? ( + row.item)} + file={null} + files={visible.map((row) => bundles.find((bundle) => bundle.fileIndex === row.fileIndex)?.file ?? null)} + index={editIndex} + onIndex={(index) => { + const row = visible[index]; + if (!row) return; + markOpened(row.id); + setEditId(row.id); + }} + onChange={(index, item) => { + const row = visible[index]; + if (row) patch(row.id, item); + }} + onRemove={(index) => { + const row = visible[index]; + if (row) removeRow(row.id); + }} + onClose={() => { + setRows((current) => current.filter((row) => !isBlankOnboardingCheckItem(row.item))); + setEditId(null); + }} + /> + ) : null} + {pair ? ( + bundle.fileIndex === pair.later.fileIndex)?.name ?? "Resume"} + kept={pair.earlier.item} + incoming={pair.later.item} + confirming={confirming} + choices={choices} + picks={picks} + busy={busy} + onPick={(key, side) => setPicks((current) => ({ ...current, [key]: side }))} + onClose={() => { + setCompareId(null); + setConfirming(false); + setPicks({}); + }} + onBack={() => setConfirming(false)} + onKeep={() => keepDifferent(pair.later.id)} + onMerge={merge} + /> + ) : null} + + ); +} + +function overlayFileEdits( + rows: OnboardingTimelineRow[], + edited: OnboardingCheckItem[], + fileIndex: number, +): OnboardingTimelineRow[] { + if (edited.length === 0) return rows; + return rows.map((row) => { + if (row.fileIndex !== fileIndex) return row; + const next = edited.find((item) => item.source === row.item.source && item.sourceIndex === row.item.sourceIndex); + return next ? { ...row, item: next } : row; + }); +} + +function buildTimeline(bundles: ResumeBundle[]): OnboardingTimelineRow[] { + const rows: OnboardingTimelineRow[] = []; + for (const bundle of bundles) { + for (const item of bundle.items) { + if (isBlankOnboardingCheckItem(item)) continue; + rows.push({ + id: `file-${bundle.fileIndex}-${item.source}-${item.sourceIndex}`, + fileIndex: bundle.fileIndex, + item, + removed: false, + adopted: false, + role: null, + }); + } + } + return rows; +} + +function pairKey(earlierId: string, laterId: string): string { + return `${earlierId}|${laterId}`; +} + +function matchFor( + rows: readonly OnboardingTimelineRow[], + row: OnboardingTimelineRow, + declined: readonly string[], +): { earlier: OnboardingTimelineRow; hint: { reason: ResumeMatchReason } } | null { + const index = rows.findIndex((item) => item.id === row.id); + const earlier = rows.filter((item, itemIndex) => itemIndex < index && !item.removed && item.fileIndex < row.fileIndex); + const held = earlier.flatMap((item) => { + const role = item.role ?? addHeldRole([], item.item, item.fileIndex)[0]; + return role ? [{ ...role, id: item.id }] : []; + }); + const hint = hintHeldRoles(held, row.item)[0]; + if (!hint) return null; + const match = earlier.find((item) => item.id === hint.roleId); + if (!match || declined.includes(pairKey(match.id, row.id))) return null; + return { earlier: match, hint }; +} + +function comparePair(rows: readonly OnboardingTimelineRow[], laterId: string | null) { + if (!laterId) return null; + const later = rows.find((row) => row.id === laterId && !row.removed); + if (!later) return null; + const match = matchFor(rows, later, []); + if (!match) return null; + return { earlier: match.earlier, later }; +} diff --git a/web/src/onboarding-session.ts b/web/src/onboarding-session.ts index f432da4..9c09e92 100644 --- a/web/src/onboarding-session.ts +++ b/web/src/onboarding-session.ts @@ -2,6 +2,16 @@ import type { OnboardingCheckItem } from "@core/onboarding-review"; import type { ExtractedProfile, ExtractedResume } from "@core/resume-extract"; import type { HeldRole, HeldSkill, SetAsideRole } from "@core/resume-compare"; +/** One extracted role on the combined verify timeline. */ +export type OnboardingTimelineRow = { + id: string; + fileIndex: number; + item: OnboardingCheckItem; + removed: boolean; + adopted: boolean; + role: HeldRole | null; +}; + const DB_NAME = "proforna-onboarding"; const STORE = "session"; const KEY = "current"; @@ -18,6 +28,8 @@ export type OnboardingDraft = { step: "file" | "check"; items: OnboardingCheckItem[]; profileExtract: ExtractedProfile | null; + timeline?: OnboardingTimelineRow[] | null; + declinedPairs?: string[]; }; export async function loadOnboardingDraft(): Promise { From 5aabc9f5e75d83567375fc23301a37da408ff8b5 Mon Sep 17 00:00:00 2001 From: Sory Kaba <131464999+SoryAK@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:45:55 -0400 Subject: [PATCH 2/3] Attach linked evidence from public sources to a role, pinned to an achievement or event. Closes #111 Co-authored-by: Cursor --- CONTEXT.md | 9 + core/career-memory.ts | 10 +- core/index.ts | 18 ++ core/linked-page.test.ts | 64 ++++++ core/linked-page.ts | 129 +++++++++++ core/projection.test.ts | 1 + core/work-map.test.ts | 420 ++++++++++++++++++++++++++++++++++++ core/work-map.ts | 257 ++++++++++++++++++++++ relay/app.test.ts | 70 ++++++ relay/app.ts | 32 ++- relay/mcp.ts | 2 +- server/app.ts | 89 ++++++++ server/career-memory.ts | 3 +- server/db.ts | 20 ++ server/history.ts | 1 + server/page-capture.test.ts | 29 +++ server/page-capture.ts | 124 +++++++++++ server/portability.ts | 2 + server/work-map.test.ts | 193 +++++++++++++++++ server/work-map.ts | 252 ++++++++++++++++++++++ web/src/RoleDetailPanel.tsx | 325 +++++++++++++++++++++++++++- web/src/WorkMap.tsx | 26 +++ web/src/WorkMapPreview.tsx | 48 ++++- web/src/work-map.css | 41 ++++ 24 files changed, 2153 insertions(+), 12 deletions(-) create mode 100644 core/linked-page.test.ts create mode 100644 core/linked-page.ts create mode 100644 server/page-capture.test.ts create mode 100644 server/page-capture.ts diff --git a/CONTEXT.md b/CONTEXT.md index 1c02515..8fb9066 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -21,6 +21,15 @@ stored resume file is Evidence. Extracted jobs, schools, and skills are Career Facts that point at that file. _Avoid_: Attachment, proof +**Linked Evidence**: +Evidence captured from a public link, such as a review, article, talk, demo, +press release, social post, or award, and attached to a Role. It may be pinned +to one achievement or event on that Role. The occupant records what they did +and whether the source names them or is about work they contributed to. The +captured copy stays private; the occupant's record is published only when they +mark it public. +_Avoid_: Public mention, press clipping, source + **Career Fact**: A versioned assertion about the occupant's identity, work, education, skills, achievements, or preferences, linked to its evidence. diff --git a/core/career-memory.ts b/core/career-memory.ts index 15f97a5..d80b598 100644 --- a/core/career-memory.ts +++ b/core/career-memory.ts @@ -11,7 +11,15 @@ export type Sensitivity = "private" | "restricted" | "public"; export type Evidence = { id: string; occupantId: string; - sourceType: "user" | "resume" | "worklog" | "work-map" | "document" | "import" | "message"; + sourceType: + | "user" + | "resume" + | "worklog" + | "work-map" + | "document" + | "import" + | "message" + | "link"; sourceRef: string; title: string; capturedAt: string; diff --git a/core/index.ts b/core/index.ts index eb69fd8..5bb3637 100644 --- a/core/index.ts +++ b/core/index.ts @@ -347,12 +347,20 @@ export { } from "./map-settings"; export { EMPTY_WORK_MAP_DETAILS, + LINKED_EVIDENCE_CREDITS, + LINKED_EVIDENCE_KINDS, attachFactsToWorkMapRole, buildWorkMapSnapshot, + groupLinkedEvidence, + isLinkedEvidenceUrl, + linkedEvidenceFromPage, normalizeSlug, normalizeWorkMapDetails, + planLinkedEvidenceFacts, planWorkMapPublicationSettings, planWorkMapRoleFactSync, + prepareLinkedEvidence, + prepareLinkedEvidenceDelete, prepareWorkMapLocation, prepareWorkMapRoleCreate, nominatimAddressLine, @@ -360,6 +368,10 @@ export { roleCoverPhoto, publicationAllowsSnapshot, publicationNeedsAudienceConfirm, + type LinkedEvidence, + type LinkedEvidenceError, + type PreparedLinkedEvidence, + type PublishedLinkedEvidence, type PreparedWorkMapLocation, type WorkMapClaim, type WorkMapLocation, @@ -373,6 +385,12 @@ export { type WorkMapShare, type WorkMapSnapshot, } from "./work-map"; +export { + LINKED_PAGE_TEXT_LIMIT, + isPublicAddress, + readLinkedPage, + type LinkedPage, +} from "./linked-page"; export { careerFrame, careerMoments, diff --git a/core/linked-page.test.ts b/core/linked-page.test.ts new file mode 100644 index 0000000..b44856b --- /dev/null +++ b/core/linked-page.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { isPublicAddress, readLinkedPage } from "./linked-page"; + +describe("reading a linked page", () => { + it("keeps the title, social tags, and readable text", () => { + const page = readLinkedPage(` + + Fold review & verdict + + + + + + +

The crease

+

You cannot feel the fold anymore.

+ + `); + expect(page.title).toBe("Fold review & verdict"); + expect(page.meta).toEqual({ + "og:title": 'The crease is "gone"', + "og:site_name": "Reviews Weekly", + description: "Hands on.", + }); + expect(page.text).toContain("The crease You cannot feel the fold anymore."); + expect(page.text).not.toContain("SECRET_"); + expect(page.text).not.toContain("color"); + }); + + it("caps the stored text", () => { + const page = readLinkedPage(`

${"word ".repeat(100_000)}

`); + expect(page.text.length).toBeLessThanOrEqual(100_000); + }); +}); + +describe("public addresses", () => { + it("allows addresses on the public internet", () => { + for (const address of ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]) { + expect(isPublicAddress(address)).toBe(true); + } + }); + + it("refuses this machine, the local network, and reserved ranges", () => { + for (const address of [ + "127.0.0.1", + "10.0.0.5", + "172.16.4.2", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "224.0.0.1", + "::1", + "::", + "fe80::1", + "fd00::1", + "::ffff:127.0.0.1", + "::ffff:7f00:1", + "not-an-address", + ]) { + expect(isPublicAddress(address)).toBe(false); + } + }); +}); diff --git a/core/linked-page.ts b/core/linked-page.ts new file mode 100644 index 0000000..ad3b015 --- /dev/null +++ b/core/linked-page.ts @@ -0,0 +1,129 @@ +export const LINKED_PAGE_TEXT_LIMIT = 100_000; + +export type LinkedPage = { + title: string; + meta: Record; + text: string; +}; + +export function readLinkedPage(html: string): LinkedPage { + const title = decodeEntities( + /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "", + ) + .replace(/\s+/g, " ") + .trim(); + const meta: Record = {}; + for (const tag of html.match(/]*>/gi) ?? []) { + const attributes = readAttributes(tag); + const key = (attributes.property ?? attributes.name ?? "").toLowerCase(); + const content = attributes.content; + if (!key || content === undefined || key in meta) continue; + meta[key] = decodeEntities(content).trim(); + } + const text = decodeEntities( + html + .replace(//g, " ") + .replace(//gi, " ") + .replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1>/gi, " ") + .replace(/<[^>]+>/g, " "), + ) + .replace(/\s+/g, " ") + .trim() + .slice(0, LINKED_PAGE_TEXT_LIMIT); + return { title, meta, text }; +} + +function readAttributes(tag: string): Record { + const attributes: Record = {}; + const pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g; + for (const match of tag.matchAll(pattern)) { + attributes[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? ""; + } + return attributes; +} + +function decodeEntities(value: string): string { + const named: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + }; + return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, body: string) => { + if (body[0] === "#") { + const code = + body[1] === "x" || body[1] === "X" + ? Number.parseInt(body.slice(2), 16) + : Number.parseInt(body.slice(1), 10); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : entity; + } + return named[body.toLowerCase()] ?? entity; + }); +} + +export function isPublicAddress(address: string): boolean { + const v4 = parseIpv4(address); + if (v4) return isPublicIpv4(v4); + const v6 = parseIpv6(address); + if (!v6) return false; + if (v6.every((group) => group === 0)) return false; + if (v6.slice(0, 7).every((group) => group === 0) && v6[7] === 1) return false; + if (v6.slice(0, 5).every((group) => group === 0) && v6[5] === 0xffff) { + return isPublicIpv4(embeddedIpv4(v6)); + } + if (v6[0] === 0x64 && v6[1] === 0xff9b && v6.slice(2, 6).every((g) => g === 0)) { + return isPublicIpv4(embeddedIpv4(v6)); + } + if ((v6[0] & 0xfe00) === 0xfc00) return false; + if ((v6[0] & 0xffc0) === 0xfe80) return false; + if ((v6[0] & 0xff00) === 0xff00) return false; + if (v6[0] === 0x2001 && v6[1] === 0x0db8) return false; + return true; +} + +function isPublicIpv4([a, b, c]: number[]): boolean { + if (a === 0 || a === 10 || a === 127 || a >= 224) return false; + if (a === 100 && b >= 64 && b <= 127) return false; + if (a === 169 && b === 254) return false; + if (a === 172 && b >= 16 && b <= 31) return false; + if (a === 192 && b === 168) return false; + if (a === 192 && b === 0 && (c === 0 || c === 2)) return false; + if (a === 198 && (b === 18 || b === 19)) return false; + if (a === 198 && b === 51 && c === 100) return false; + if (a === 203 && b === 0 && c === 113) return false; + return true; +} + +function embeddedIpv4(groups: number[]): number[] { + return [groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff]; +} + +function parseIpv4(value: string): number[] | null { + if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) return null; + const parts = value.split(".").map(Number); + return parts.every((part) => part <= 255) ? parts : null; +} + +function parseIpv6(value: string): number[] | null { + let address = value.replace(/^\[|\]$/g, "").split("%")[0] ?? ""; + if (!address.includes(":")) return null; + const tail = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(address); + if (tail) { + const v4 = parseIpv4(tail[1]); + if (!v4) return null; + address = `${address.slice(0, -tail[1].length)}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`; + } + const halves = address.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + const missing = 8 - head.length - rest.length; + if (halves.length === 1 ? missing !== 0 : missing < 1) return null; + const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest]; + if (!groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group))) return null; + return groups.map((group) => Number.parseInt(group, 16)); +} diff --git a/core/projection.test.ts b/core/projection.test.ts index 82134f2..c0fcec6 100644 --- a/core/projection.test.ts +++ b/core/projection.test.ts @@ -34,6 +34,7 @@ const snapshot: WorkMapSnapshot = { achievements: ["Cut recovery from 42 to 11 minutes."], locations: [], media: [], + linkedEvidence: [], milestones: [], events: [], techStack: [], diff --git a/core/work-map.test.ts b/core/work-map.test.ts index e4e5b5e..6825f64 100644 --- a/core/work-map.test.ts +++ b/core/work-map.test.ts @@ -4,9 +4,14 @@ import { EMPTY_WORK_MAP_DETAILS, attachFactsToWorkMapRole, buildWorkMapSnapshot, + groupLinkedEvidence, + linkedEvidenceFromPage, roleCoverPhoto, + planLinkedEvidenceFacts, planWorkMapPublicationSettings, planWorkMapRoleFactSync, + prepareLinkedEvidence, + prepareLinkedEvidenceDelete, prepareWorkMapLocation, prepareWorkMapRoleCreate, nominatimAddressLine, @@ -514,6 +519,401 @@ describe("Work Map role create", () => { }); }); +describe("Linked Evidence", () => { + it("accepts a review that credits work on the role", () => { + expect( + prepareLinkedEvidence( + { + url: " https://reviews.example/fold-hands-on ", + title: " The crease is finally gone ", + outlet: " Reviews Weekly ", + publishedOn: "2026-08-14", + kind: "review", + contribution: " Designed the hinge fix the review praises. ", + credit: "about-my-work", + quote: " You cannot feel the fold anymore. ", + timestamp: "", + pin: { kind: "achievement", statement: "Reduced recovery time." }, + isPublic: true, + }, + sampleRole(), + ), + ).toEqual({ + ok: true, + value: { + url: "https://reviews.example/fold-hands-on", + title: "The crease is finally gone", + outlet: "Reviews Weekly", + publishedOn: "2026-08-14", + kind: "review", + contribution: "Designed the hinge fix the review praises.", + credit: "about-my-work", + quote: "You cannot feel the fold anymore.", + timestamp: "", + pin: { kind: "achievement", statement: "Reduced recovery time." }, + isPublic: true, + }, + }); + }); + + it("only links to a web page without credentials", () => { + for (const url of [ + "", + "reviews.example/fold", + "javascript:alert(1)", + "file:///etc/passwd", + "https://me:secret@reviews.example/fold", + ]) { + expect(prepareLinkedEvidence(linkedInput({ url }), sampleRole())).toEqual({ + ok: false, + error: "url-invalid", + }); + } + }); + + it("needs a title, a known kind, and an honest credit", () => { + const role = sampleRole(); + expect(prepareLinkedEvidence(linkedInput({ title: " " }), role)).toEqual({ + ok: false, + error: "title-required", + }); + expect(prepareLinkedEvidence(linkedInput({ kind: "rumor" }), role)).toEqual({ + ok: false, + error: "kind-invalid", + }); + expect(prepareLinkedEvidence(linkedInput({ credit: "" }), role)).toEqual({ + ok: false, + error: "credit-invalid", + }); + expect( + prepareLinkedEvidence(linkedInput({ kind: "talk", credit: "names-me" }), role), + ).toMatchObject({ ok: true, value: { kind: "talk", credit: "names-me" } }); + }); + + it("pins only to an achievement or event on the same role", () => { + const role = sampleRole({ + details: { + ...structuredClone(EMPTY_WORK_MAP_DETAILS), + events: [ + { + id: "demo-day", + date: "2026-05-02", + title: "Demo day", + detail: "", + isPublic: true, + }, + ], + }, + }); + expect( + prepareLinkedEvidence(linkedInput({ pin: { kind: "event", id: "demo-day" } }), role), + ).toMatchObject({ ok: true, value: { pin: { kind: "event", id: "demo-day" } } }); + expect( + prepareLinkedEvidence( + linkedInput({ + pin: { kind: "achievement", statement: " reduced recovery time. " }, + }), + role, + ), + ).toMatchObject({ + ok: true, + value: { pin: { kind: "achievement", statement: "Reduced recovery time." } }, + }); + for (const pin of [ + { kind: "event", id: "other-role-event" }, + { kind: "achievement", statement: "Won an award I never mentioned." }, + { kind: "milestone", id: "demo-day" }, + ]) { + expect(prepareLinkedEvidence(linkedInput({ pin }), role)).toEqual({ + ok: false, + error: "pin-missing", + }); + } + expect(prepareLinkedEvidence(linkedInput({ pin: null }), role)).toMatchObject({ + ok: true, + value: { pin: null }, + }); + }); + + it("keeps the publish date and video moment readable", () => { + const role = sampleRole(); + for (const publishedOn of ["", "2026-08-14"]) { + expect(prepareLinkedEvidence(linkedInput({ publishedOn }), role)).toMatchObject({ + ok: true, + }); + } + for (const publishedOn of ["August 2026", "2026-13-01", "2026-02-30"]) { + expect(prepareLinkedEvidence(linkedInput({ publishedOn }), role)).toEqual({ + ok: false, + error: "date-invalid", + }); + } + for (const timestamp of ["", "14:32", "1:02:03"]) { + expect(prepareLinkedEvidence(linkedInput({ timestamp }), role)).toMatchObject({ + ok: true, + value: { timestamp }, + }); + } + for (const timestamp of ["soon", "14:75", "1:2:3"]) { + expect(prepareLinkedEvidence(linkedInput({ timestamp }), role)).toEqual({ + ok: false, + error: "timestamp-invalid", + }); + } + }); + + it("removes by id", () => { + expect(prepareLinkedEvidenceDelete({ id: " linked-1 " })).toEqual({ + ok: true, + value: { id: "linked-1" }, + }); + expect(prepareLinkedEvidenceDelete({ id: "" })).toEqual({ + ok: false, + error: "linked-evidence-missing", + }); + }); + + it("fills the form from the page's own tags", () => { + expect( + linkedEvidenceFromPage({ + url: "https://www.reviews.example/fold-hands-on", + title: "Fold review | Reviews Weekly", + meta: { + "og:title": "The crease is finally gone", + "og:site_name": "Reviews Weekly", + "og:type": "article", + "article:published_time": "2026-08-14T09:30:00-04:00", + }, + }), + ).toEqual({ + title: "The crease is finally gone", + outlet: "Reviews Weekly", + publishedOn: "2026-08-14", + kind: "article", + }); + }); + + it("falls back to the page title and host, and reads videos as talks", () => { + expect( + linkedEvidenceFromPage({ + url: "https://www.video.example/watch?v=demo", + title: " Demo day keynote ", + meta: { "og:type": "video.other", "article:published_time": "soon" }, + }), + ).toEqual({ + title: "Demo day keynote", + outlet: "video.example", + publishedOn: "", + kind: "talk", + }); + }); + + it("backs the pinned achievement with the captured copy and lets go when unpinned", () => { + const facts = [ + sampleFact({ + id: "fact-recovery", + factType: "achievement", + value: { statement: "Reduced recovery time." }, + evidenceIds: ["resume"], + }), + sampleFact({ + id: "fact-award", + factType: "achievement", + value: { statement: "Won the design award." }, + evidenceIds: ["capture-1"], + }), + ]; + const pinned = (statement: string, evidenceId: string) => ({ + evidenceId, + pin: { kind: "achievement" as const, statement }, + }); + + expect( + planLinkedEvidenceFacts({ + roleId: "role-1", + facts, + before: null, + after: pinned("Reduced recovery time.", "capture-1"), + }), + ).toEqual([ + { + action: "supersede", + entityType: "career-fact", + entityId: "fact-recovery", + values: { + factType: "achievement", + subjectId: "role-1", + value: { statement: "Reduced recovery time." }, + evidenceIds: ["resume", "capture-1"], + }, + }, + ]); + + expect( + planLinkedEvidenceFacts({ + roleId: "role-1", + facts: [ + sampleFact({ + id: "fact-recovery", + factType: "achievement", + value: { statement: "Reduced recovery time." }, + evidenceIds: ["resume", "capture-1"], + }), + ], + before: pinned("Reduced recovery time.", "capture-1"), + after: null, + }), + ).toEqual([ + expect.objectContaining({ + entityId: "fact-recovery", + values: expect.objectContaining({ evidenceIds: ["resume"] }), + }), + ]); + + expect( + planLinkedEvidenceFacts({ + roleId: "role-1", + facts, + before: pinned("Won the design award.", "capture-1"), + after: null, + }), + ).toEqual([]); + + expect( + planLinkedEvidenceFacts({ + roleId: "role-1", + facts, + before: { evidenceId: "capture-1", pin: { kind: "event", id: "demo-day" } }, + after: { evidenceId: null, pin: { kind: "achievement", statement: "Reduced recovery time." } }, + }), + ).toEqual([]); + }); + + it("groups evidence under the achievement it supports for readers", () => { + const review = { id: "review", pin: { kind: "achievement" as const, statement: "Solved the crease." } }; + const talk = { id: "talk", pin: { kind: "event" as const, id: "demo-day" } }; + const press = { id: "press", pin: null }; + const stale = { id: "stale", pin: { kind: "achievement" as const, statement: "Old wording." } }; + expect( + groupLinkedEvidence(["Solved the crease.", "Mentored interns."], [review, talk, press, stale]), + ).toEqual({ + achievements: [ + { statement: "Solved the crease.", evidence: [review] }, + { statement: "Mentored interns.", evidence: [] }, + ], + other: [talk, press, stale], + }); + expect(groupLinkedEvidence(["Solved the crease."], undefined)).toEqual({ + achievements: [{ statement: "Solved the crease.", evidence: [] }], + other: [], + }); + }); + + it("publishes only public Linked Evidence when the evidence section is on", () => { + const role = sampleRole({ + linkedEvidence: [ + { + id: "shout-out", + evidenceId: "SECRET_CAPTURE", + url: "https://video.example/demo-day", + title: "Demo day keynote", + outlet: "video.example", + publishedOn: "2026-05-02", + kind: "talk", + contribution: "Built the live demo.", + credit: "names-me", + quote: "Huge thanks to the engineer who fixed the fold.", + timestamp: "14:32", + pin: { kind: "event", id: "private-event" }, + isPublic: true, + }, + { + id: "draft", + evidenceId: null, + url: "https://SECRET_LINK.example/", + title: "SECRET_TITLE", + outlet: "", + publishedOn: "", + kind: "article", + contribution: "", + credit: "about-my-work", + quote: "", + timestamp: "", + pin: null, + isPublic: false, + }, + ], + details: { + ...structuredClone(EMPTY_WORK_MAP_DETAILS), + events: [ + { + id: "private-event", + date: "2026-05-02", + title: "Demo day", + detail: "", + isPublic: false, + }, + ], + }, + }); + const publish = ( + sections: WorkMapPublicationSettings["sections"], + visibility: WorkMapPublicationSettings["visibility"] = "public", + ) => + buildWorkMapSnapshot({ + id: "snapshot-1", + occupantId: "local", + profile: { + fullName: "Sory Kaba", + headline: "", + city: "", + state: "", + bio: "", + linkedinUrl: "", + githubUrl: "", + portfolioUrl: "", + }, + roles: [role], + skills: [], + settings: { + slug: "sory", + targetRole: "", + theme: "dark", + visibility, + sections, + hideCurrentEmployer: false, + showExactLocations: false, + expiresAt: null, + }, + sourceFingerprint: "abc", + createdAt: "2026-10-17T20:00:00.000Z", + }); + + const published = publish(["history", "milestones", "evidence"]); + expect(published.roles[0].linkedEvidence).toEqual([ + { + id: "shout-out", + url: "https://video.example/demo-day", + title: "Demo day keynote", + outlet: "video.example", + publishedOn: "2026-05-02", + kind: "talk", + contribution: "Built the live demo.", + credit: "names-me", + quote: "Huge thanks to the engineer who fixed the fold.", + timestamp: "14:32", + pin: null, + isPublic: true, + }, + ]); + expect(JSON.stringify(published)).not.toContain("SECRET_"); + expect(publish(["history"]).roles[0].linkedEvidence).toEqual([]); + expect( + publish(["history", "evidence"], "anonymous").roles[0].linkedEvidence, + ).toEqual([]); + }); +}); + describe("Work Map publication settings", () => { const current: WorkMapPublicationSettings = { slug: "career-map", @@ -694,6 +1094,7 @@ function sampleRole(overrides: Partial = {}): WorkMapRole { isPublic: false, }, ], + linkedEvidence: [], details: { ...structuredClone(EMPTY_WORK_MAP_DETAILS), compensation: { @@ -719,6 +1120,25 @@ function sampleRole(overrides: Partial = {}): WorkMapRole { }; } +function linkedInput( + overrides: Record = {}, +): Record { + return { + url: "https://reviews.example/fold-hands-on", + title: "The crease is finally gone", + outlet: "Reviews Weekly", + publishedOn: "2026-08-14", + kind: "review", + contribution: "", + credit: "about-my-work", + quote: "", + timestamp: "", + pin: null, + isPublic: false, + ...overrides, + }; +} + function sampleFact( overrides: Partial & Pick, ): CareerFactVersion { diff --git a/core/work-map.ts b/core/work-map.ts index 35d29a1..8e8436f 100644 --- a/core/work-map.ts +++ b/core/work-map.ts @@ -36,6 +36,187 @@ export function roleCoverPhoto(media: WorkMapMedia[]): WorkMapMedia | null { return null; } +export const LINKED_EVIDENCE_KINDS = [ + "review", + "article", + "talk", + "press-release", + "social-post", + "award", +] as const; + +export const LINKED_EVIDENCE_CREDITS = ["names-me", "about-my-work"] as const; + +export type LinkedEvidence = { + id: string; + evidenceId: string | null; + url: string; + title: string; + outlet: string; + publishedOn: string; + kind: (typeof LINKED_EVIDENCE_KINDS)[number]; + contribution: string; + credit: (typeof LINKED_EVIDENCE_CREDITS)[number]; + quote: string; + timestamp: string; + pin: + | { kind: "achievement"; statement: string } + | { kind: "event"; id: string } + | null; + isPublic: boolean; +}; + +export type PreparedLinkedEvidence = Omit; + +export type PublishedLinkedEvidence = Omit; + +export type LinkedEvidenceError = + | "url-invalid" + | "title-required" + | "kind-invalid" + | "credit-invalid" + | "pin-missing" + | "date-invalid" + | "timestamp-invalid"; + +export function prepareLinkedEvidence( + input: Record, + role: Pick, +): + | { ok: true; value: PreparedLinkedEvidence } + | { ok: false; error: LinkedEvidenceError } { + const url = text(input.url); + if (!isLinkedEvidenceUrl(url)) return { ok: false, error: "url-invalid" }; + const title = text(input.title); + if (!title) return { ok: false, error: "title-required" }; + const kind = oneOf(LINKED_EVIDENCE_KINDS, input.kind); + if (!kind) return { ok: false, error: "kind-invalid" }; + const credit = oneOf(LINKED_EVIDENCE_CREDITS, input.credit); + if (!credit) return { ok: false, error: "credit-invalid" }; + const pin = linkedEvidencePin(input.pin, role); + if (pin === undefined) return { ok: false, error: "pin-missing" }; + const publishedOn = text(input.publishedOn); + if (publishedOn && !isCalendarDate(publishedOn)) { + return { ok: false, error: "date-invalid" }; + } + const timestamp = text(input.timestamp); + if (timestamp && !/^(?:\d+:[0-5]\d:[0-5]\d|\d{1,3}:[0-5]\d)$/.test(timestamp)) { + return { ok: false, error: "timestamp-invalid" }; + } + return { + ok: true, + value: { + url, + title, + outlet: text(input.outlet), + publishedOn, + kind, + contribution: text(input.contribution), + credit, + quote: text(input.quote), + timestamp, + pin, + isPublic: input.isPublic === true, + }, + }; +} + +export function groupLinkedEvidence>( + achievements: string[], + items: T[] | undefined, +): { achievements: Array<{ statement: string; evidence: T[] }>; other: T[] } { + const grouped = achievements.map((statement) => ({ statement, evidence: [] as T[] })); + const other: T[] = []; + for (const item of items ?? []) { + const wanted = + item.pin?.kind === "achievement" ? item.pin.statement.trim().toLowerCase() : null; + const home = wanted + ? grouped.find((entry) => entry.statement.trim().toLowerCase() === wanted) + : undefined; + if (home) home.evidence.push(item); + else other.push(item); + } + return { achievements: grouped, other }; +} + +export function prepareLinkedEvidenceDelete(input: { + id?: unknown; +}): + | { ok: true; value: { id: string } } + | { ok: false; error: "linked-evidence-missing" } { + const id = text(input.id); + if (!id) return { ok: false, error: "linked-evidence-missing" }; + return { ok: true, value: { id } }; +} + +export function linkedEvidenceFromPage(page: { + url: string; + title: string; + meta: Record; +}): Pick { + const published = text(page.meta["article:published_time"]).slice(0, 10); + return { + title: text(page.meta["og:title"]) || text(page.title), + outlet: text(page.meta["og:site_name"]) || pageHost(page.url), + publishedOn: isCalendarDate(published) ? published : "", + kind: text(page.meta["og:type"]).startsWith("video") ? "talk" : "article", + }; +} + +function pageHost(value: string): string { + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +function linkedEvidencePin( + value: unknown, + role: Pick, +): LinkedEvidence["pin"] | undefined { + if (value == null) return null; + if (typeof value !== "object") return undefined; + const pin = value as Record; + if (pin.kind === "event") { + const id = text(pin.id); + return role.details.events.some((event) => event.id === id) + ? { kind: "event", id } + : undefined; + } + if (pin.kind === "achievement") { + const wanted = text(pin.statement).toLowerCase(); + const statement = role.achievements.find( + (achievement) => achievement.trim().toLowerCase() === wanted, + ); + return statement ? { kind: "achievement", statement: statement.trim() } : undefined; + } + return undefined; +} + +function isCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function oneOf(allowed: readonly T[], value: unknown): T | null { + return (allowed as readonly unknown[]).includes(value) ? (value as T) : null; +} + +export function isLinkedEvidenceUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password + ); + } catch { + return false; + } +} + export type WorkMapMoment = { id: string; date: string; @@ -108,6 +289,7 @@ export type WorkMapRole = { claims: WorkMapClaim[]; locations: WorkMapLocation[]; media: WorkMapMedia[]; + linkedEvidence: LinkedEvidence[]; details: WorkMapRoleDetails; }; @@ -117,6 +299,7 @@ export type WorkMapPublishSection = | "map" | "milestones" | "media" + | "evidence" | "skills" | "contact"; @@ -188,6 +371,7 @@ export type WorkMapSnapshot = { achievements: string[]; locations: WorkMapLocation[]; media: WorkMapMedia[]; + linkedEvidence: PublishedLinkedEvidence[]; milestones: WorkMapMoment[]; events: WorkMapMoment[]; techStack: string[]; @@ -323,6 +507,10 @@ export function buildWorkMapSnapshot(input: { media: sections.has("media") ? role.media.filter((item) => item.isPublic) : [], + linkedEvidence: + sections.has("evidence") && !anonymous + ? publishedLinkedEvidence(role, sections.has("milestones")) + : [], milestones: sections.has("milestones") ? role.details.milestones.filter((item) => item.isPublic) : [], @@ -366,6 +554,24 @@ export function buildWorkMapSnapshot(input: { }; } +function publishedLinkedEvidence( + role: WorkMapRole, + eventsPublished: boolean, +): PublishedLinkedEvidence[] { + const publicEvents = new Set( + eventsPublished + ? role.details.events.filter((event) => event.isPublic).map((event) => event.id) + : [], + ); + return role.linkedEvidence + .filter((item) => item.isPublic) + .map(({ evidenceId: _private, ...item }) => + item.pin?.kind === "event" && !publicEvents.has(item.pin.id) + ? { ...item, pin: null } + : item, + ); +} + function publishedRoleConditions(details: WorkMapRoleDetails) { const detailsNormalized = normalizeWorkMapDetails(details); const shared: { @@ -530,6 +736,57 @@ export function planWorkMapRoleFactSync(input: { return operations; } +type LinkedEvidenceBacking = { + evidenceId: string | null; + pin: LinkedEvidence["pin"]; +}; + +export function planLinkedEvidenceFacts(input: { + roleId: string; + facts: CareerFactVersion[]; + before: LinkedEvidenceBacking | null; + after: LinkedEvidenceBacking | null; +}): ChangeOperation[] { + const achievements = canonicalFacts(input.facts).filter( + (fact) => fact.factType === "achievement" && fact.subjectId === input.roleId, + ); + const backedFact = (backing: LinkedEvidenceBacking | null) => { + if (!backing?.evidenceId || backing.pin?.kind !== "achievement") return null; + const wanted = backing.pin.statement.toLowerCase(); + return achievements.find((fact) => factStatement(fact).toLowerCase() === wanted) ?? null; + }; + const next = new Map(); + const from = backedFact(input.before); + if (from && input.before?.evidenceId) { + next.set( + from.id, + from.evidenceIds.filter((id) => id !== input.before?.evidenceId), + ); + } + const to = backedFact(input.after); + if (to && input.after?.evidenceId) { + next.set(to.id, unique([...(next.get(to.id) ?? to.evidenceIds), input.after.evidenceId])); + } + const operations: ChangeOperation[] = []; + for (const [factId, evidenceIds] of next) { + const fact = achievements.find((item) => item.id === factId); + if (!fact || evidenceIds.length === 0) continue; + if (stableStringify(evidenceIds) === stableStringify(fact.evidenceIds)) continue; + operations.push({ + action: "supersede", + entityType: "career-fact", + entityId: fact.id, + values: { + factType: "achievement", + subjectId: input.roleId, + value: fact.value, + evidenceIds, + }, + }); + } + return operations; +} + export function planWorkMapPublicationSettings(input: { current: WorkMapPublicationSettings; next: WorkMapPublicationSettings; diff --git a/relay/app.test.ts b/relay/app.test.ts index ddf0642..f883733 100644 --- a/relay/app.test.ts +++ b/relay/app.test.ts @@ -50,6 +50,76 @@ const projection = { createdAt: "2026-09-19T20:00:00.000Z", }; +describe("Linked Evidence on a publication", () => { + it("shows sources under the achievement they support and keeps links safe", async () => { + const db = openRelayDatabase(":memory:"); + const app = createRelayApp(db, "owner-secret"); + const publish = (slug: string, roles: unknown[]) => + app.request(`/relay/publications/${slug}`, { + method: "PUT", + headers: { + authorization: "Bearer owner-secret", + "content-type": "application/json", + }, + body: JSON.stringify({ projection: { ...projection, slug, roles } }), + }); + try { + await publish("evidence", [ + { + ...projection.roles[0], + linkedEvidence: [ + { + id: "review", + url: "https://reviews.example/fold", + title: "The crease is gone", + outlet: "Reviews Weekly", + publishedOn: "2026-08-14", + kind: "review", + contribution: "Designed the hinge fix.", + credit: "names-me", + quote: "", + timestamp: "", + pin: { kind: "achievement", statement: "Cut recovery from 42 to 11 minutes." }, + isPublic: true, + }, + { + id: "odd", + url: "javascript:alert(1)", + title: "", + outlet: "", + publishedOn: "", + kind: "talk", + contribution: "", + credit: "about-my-work", + quote: "Thanks to the team.", + timestamp: "14:32", + pin: null, + isPublic: true, + }, + ], + }, + ]); + const html = await (await app.request("/r/evidence")).text(); + const achievement = html.indexOf("Cut recovery from 42 to 11 minutes."); + const review = html.indexOf('href="https://reviews.example/fold"'); + expect(achievement).toBeGreaterThan(-1); + expect(review).toBeGreaterThan(achievement); + expect(html).toContain("Credited by name"); + expect(html).toContain("Covers work they contributed to"); + expect(html).toContain("at 14:32"); + expect(html).not.toContain("javascript:alert"); + expect(html).not.toContain("