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 e2eb78f..cfb9934 100644 --- a/core/index.ts +++ b/core/index.ts @@ -15,10 +15,12 @@ export { keptOnboardingHistory, nextOnboardingSourceIndex, onboardingCheckItems, + onboardingDone, onboardingExtractSummary, profileAfterOnboarding, ONBOARDING_NO_PLACE, type OnboardingCheckItem, + type OnboardingDone, type OnboardingLinkAnswer, type OnboardingPlaceAnswer, } from "./onboarding-review"; @@ -34,8 +36,11 @@ export { holdVerifiedRoles, parseHistoryEvidence, queuedResumeDuplicate, + resumesToQueue, rememberSetAside, rememberSkills, + reviseSkills, + skillLines, roleFieldDiffs, roleTookFile, type HeldHistory, @@ -346,12 +351,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, @@ -359,6 +372,10 @@ export { roleCoverPhoto, publicationAllowsSnapshot, publicationNeedsAudienceConfirm, + type LinkedEvidence, + type LinkedEvidenceError, + type PreparedLinkedEvidence, + type PublishedLinkedEvidence, type PreparedWorkMapLocation, type WorkMapClaim, type WorkMapLocation, @@ -372,6 +389,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/model-onboarding.test.ts b/core/model-onboarding.test.ts index a46bdc6..b356f67 100644 --- a/core/model-onboarding.test.ts +++ b/core/model-onboarding.test.ts @@ -168,6 +168,18 @@ describe("planModelOnboarding", () => { ).toBe("Qwen3.8-27B-Q6_K.gguf"); }); + it("leaves out embedding and reranking models, which cannot read a resume", () => { + expect( + presentRangeHits([ + { + baseUrl: DEFAULT_LOCAL_BASE_URL, + models: ["nomic-embed-text:latest", "qwen3.8:27b", "mxbai-embed-large", "bge-m3", "all-MiniLM-L6-v2"], + }, + { baseUrl: LLAMA_CPP_BASE_URL, models: ["bge-reranker-v2-m3.gguf", "text-embedding-3-small"] }, + ]), + ).toEqual([{ baseUrl: DEFAULT_LOCAL_BASE_URL, models: ["qwen3.8:27b"] }]); + }); + it("keeps addresses that listed models and orders them by port", () => { expect( presentRangeHits([ diff --git a/core/model-onboarding.ts b/core/model-onboarding.ts index f7bb113..b0683de 100644 --- a/core/model-onboarding.ts +++ b/core/model-onboarding.ts @@ -76,12 +76,15 @@ export function modelCountLabel(count: number): string { return count === 1 ? "1 model" : `${count} models`; } +/** A model list gives names only, so embedding and reranking models are told apart by name. */ +const NON_READER = /embed|rerank|minilm|(^|[^a-z])bge([^a-z]|$)/i; + export function presentRangeHits( hits: readonly { baseUrl: string; models: readonly string[] }[], ): LocalScanHit[] { const kept = hits.flatMap((hit) => { const baseUrl = hit.baseUrl.trim(); - const models = hit.models.map((id) => id.trim()).filter(Boolean); + const models = hit.models.map((id) => id.trim()).filter((id) => id && !NON_READER.test(presentModelId(id))); if (!baseUrl || models.length === 0) return []; return [{ baseUrl, models }]; }); diff --git a/core/onboarding-review.test.ts b/core/onboarding-review.test.ts index 7efb829..63cb7fb 100644 --- a/core/onboarding-review.test.ts +++ b/core/onboarding-review.test.ts @@ -5,6 +5,7 @@ import { keptOnboardingHistory, nextOnboardingSourceIndex, onboardingCheckItems, + onboardingDone, onboardingExtractSummary, profileAfterOnboarding, } from "./onboarding-review"; @@ -74,20 +75,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."); }); }); @@ -261,4 +268,48 @@ describe("profileAfterOnboarding", () => { expect(next.linkedinUrl).toBe("https://www.linkedin.com/in/ada"); expect(next.portfolioUrl).toBe("https://ada.example"); }); + + it("keeps links and a place read from the resume when the answers are blank", () => { + const blank: ProfileFields = { + ...current, + address: "", + city: "", + state: "", + linkedinUrl: "", + githubUrl: "", + portfolioUrl: "", + }; + const next = profileAfterOnboarding({ + current: blank, + extracted: resume().profile, + fullName: "Ada Lovelace", + place: { street: "", city: "", state: "" }, + links: { linkedinUrl: "", githubUrl: "https://github.com/typed", portfolioUrl: "" }, + }); + expect(next.city).toBe("Allentown"); + expect(next.state).toBe("PA"); + expect(next.linkedinUrl).toBe("https://www.linkedin.com/in/other"); + expect(next.githubUrl).toBe("https://github.com/typed"); + expect(next.portfolioUrl).toBe("https://other.example"); + }); +}); + +describe("onboardingDone", () => { + it("counts what was saved and how many roles were set aside", () => { + expect(onboardingDone({ jobs: 6, schools: 1, skills: 14, resumes: 2, setAside: 3 })).toEqual({ + tiles: [ + { count: 6, label: "jobs" }, + { count: 1, label: "school" }, + { count: 14, label: "skills" }, + { count: 2, label: "resumes kept as evidence" }, + ], + setAside: "3 set aside. Import a resume from Career History to bring them back.", + }); + }); + + it("leaves out the set-aside line when nothing was set aside", () => { + const done = onboardingDone({ jobs: 0, schools: 0, skills: 0, resumes: 1, setAside: 0 }); + expect(done.setAside).toBeNull(); + expect(done.tiles.at(-1)).toEqual({ count: 1, label: "resume kept as evidence" }); + }); }); diff --git a/core/onboarding-review.ts b/core/onboarding-review.ts index 44662bb..60bf7ca 100644 --- a/core/onboarding-review.ts +++ b/core/onboarding-review.ts @@ -39,20 +39,51 @@ 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 { return `${count} ${count === 1 ? singular : plural}`; } +export type OnboardingDone = { + tiles: Array<{ count: number; label: string }>; + setAside: string | null; +}; + +/** What the last step reports once the session is saved. */ +export function onboardingDone(saved: { + jobs: number; + schools: number; + skills: number; + resumes: number; + setAside: number; +}): OnboardingDone { + const word = (count: number, singular: string, plural: string) => (count === 1 ? singular : plural); + return { + tiles: [ + { count: saved.jobs, label: word(saved.jobs, "job", "jobs") }, + { count: saved.schools, label: word(saved.schools, "school", "schools") }, + { count: saved.skills, label: word(saved.skills, "skill", "skills") }, + { + count: saved.resumes, + label: word(saved.resumes, "resume kept as evidence", "resumes kept as evidence"), + }, + ], + setAside: + saved.setAside > 0 + ? `${saved.setAside} set aside. Import a resume from Career History to bring them back.` + : null, + }; +} + /** Roles and schools the screen can confirm. */ export function onboardingCheckItems(data: ExtractedResume): OnboardingCheckItem[] { const jobs = data.experience.map((job, sourceIndex) => @@ -208,15 +239,6 @@ function keptJob( ? { label: place, address: place, latitude: item.lat, longitude: item.lng } : (base?.site ?? null); return { - company: "", - title: "", - location: "", - startDate: "", - endDate: "", - isCurrent: false, - description: "", - achievements: [], - site: null, ...base, company: item.org.trim() || "Untitled role", title: item.title.trim() || "Role", @@ -242,14 +264,6 @@ function keptSchool( ? { label: place, address: place, latitude: item.lat, longitude: item.lng } : (base?.site ?? null); return { - institution: "", - degree: "", - field: "", - location: "", - startDate: "", - endDate: "", - description: "", - site: null, ...base, institution: item.org.trim() || "Untitled school", degree: item.title.trim(), @@ -263,8 +277,8 @@ function keptSchool( } /** - * Profile to store after onboarding. A skipped place or links answer leaves - * those fields as they already are. A blank field in a given answer does too. + * Profile to store after onboarding. A skipped place or links answer, or a + * blank field in one, keeps the saved value, or the resume's when none is saved. */ export function profileAfterOnboarding(input: { current: ProfileFields; @@ -281,22 +295,14 @@ export function profileAfterOnboarding(input: { fullName: input.fullName.trim().replace(/\s+/g, " "), }; if (input.place) { - next.address = input.place.street.trim() || input.current.address; - next.city = input.place.city.trim() || input.current.city; - next.state = input.place.state.trim() || input.current.state; - } else { - next.address = input.current.address; - next.city = input.current.city; - next.state = input.current.state; + next.address = input.place.street.trim() || base.address; + next.city = input.place.city.trim() || base.city; + next.state = input.place.state.trim() || base.state; } if (input.links) { - next.linkedinUrl = input.links.linkedinUrl.trim() || input.current.linkedinUrl; - next.githubUrl = input.links.githubUrl.trim() || input.current.githubUrl; - next.portfolioUrl = input.links.portfolioUrl.trim() || input.current.portfolioUrl; - } else { - next.linkedinUrl = input.current.linkedinUrl; - next.githubUrl = input.current.githubUrl; - next.portfolioUrl = input.current.portfolioUrl; + next.linkedinUrl = input.links.linkedinUrl.trim() || base.linkedinUrl; + next.githubUrl = input.links.githubUrl.trim() || base.githubUrl; + next.portfolioUrl = input.links.portfolioUrl.trim() || base.portfolioUrl; } return next; } 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/resume-compare.test.ts b/core/resume-compare.test.ts index 1a143d0..2be399b 100644 --- a/core/resume-compare.test.ts +++ b/core/resume-compare.test.ts @@ -12,9 +12,12 @@ import { holdVerifiedRoles, parseHistoryEvidence, queuedResumeDuplicate, + resumesToQueue, rememberSetAside, rememberSkills, + reviseSkills, roleFieldDiffs, + skillLines, roleTookFile, type HeldRole, } from "./resume-compare"; @@ -45,6 +48,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", () => { @@ -229,6 +240,18 @@ describe("historyFromHeld", () => { { name: "Machining", sourceFile: 0 }, ]); }); + it("revises found skills from one line each", () => { + const found = [ + { name: "Machining", sourceFile: 1 }, + { name: "Welding", sourceFile: 0 }, + ]; + expect(reviseSkills(found, [" machining ", "", "PLC", "plc", "CAD"], 0)).toEqual([ + { name: "machining", sourceFile: 1 }, + { name: "PLC", sourceFile: 0 }, + { name: "CAD", sourceFile: 0 }, + ]); + expect(skillLines(found)).toBe("Machining\nWelding"); + }); }); describe("parseHistoryEvidence", () => { diff --git a/core/resume-compare.ts b/core/resume-compare.ts index fafb0cb..87d7cd1 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)); @@ -286,6 +302,29 @@ export function rememberSkills( return next; } +/** A typed skill not found in any file is credited to `fileIndex`. */ +export function reviseSkills( + found: readonly HeldSkill[], + lines: readonly string[], + fileIndex: number, +): HeldSkill[] { + const seen = new Set(); + const next: HeldSkill[] = []; + for (const line of lines) { + const name = line.trim(); + const key = name.toLowerCase(); + if (!name || seen.has(key)) continue; + seen.add(key); + const match = found.find((skill) => skill.name.trim().toLowerCase() === key); + next.push({ name, sourceFile: match?.sourceFile ?? fileIndex }); + } + return next; +} + +export function skillLines(skills: readonly HeldSkill[]): string { + return skills.map((skill) => skill.name).join("\n"); +} + /** One history write for the whole session. Each field still names the file it came from. */ export function historyFromHeld(roles: readonly HeldRole[], skills: readonly HeldSkill[]): HeldHistory { const experience: ExtractedJob[] = []; 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("