diff --git a/src/components/features/JobLetterIndicator.test.tsx b/src/components/features/JobLetterIndicator.test.tsx index 9f40060c..ddd35191 100644 --- a/src/components/features/JobLetterIndicator.test.tsx +++ b/src/components/features/JobLetterIndicator.test.tsx @@ -172,3 +172,295 @@ describe("JobLetterIndicator", () => { } }); }); + +/** #767: inheritance reaches the user through the DIALOGS, never through the + * glyph. These are the two halves of that: what the row still says, and what + * the customize path hands the editor. */ +describe("JobLetterIndicator inherited letters (#767)", () => { + const standard: LetterRecord = { + id: "standard-1", + createdAt: 1, + updatedAt: 9, + body: "My standard letter.", + }; + const inherited = { letter: standard, label: "your standard letter" }; + + it("a standard letter does NOT flip the row's glyph to has-letter", () => { + // The acceptance criterion with teeth: the row must keep offering to WRITE + // one. Flipping it would claim a letter the user never wrote for this + // employer, and the reveal would then show text they did not intend for it. + dom.render( + , + ); + expect( + dom.container.querySelector('button[aria-label="Write a cover letter"]'), + ).toBeTruthy(); + expect( + dom.container.querySelector('button[aria-label="View cover letter"]'), + ).toBeNull(); + }); + + it("offers the inherited letter as a starting point in the editor", () => { + dom.render( + , + ); + clickButton("Write a cover letter"); + + // Offered, capitalized for a standalone chip — and NOT seeded: the body is + // still empty until the user picks it. + const chip = [...dom.container.querySelectorAll("button")].find( + (b) => b.textContent === "Your standard letter", + ); + expect(chip).toBeTruthy(); + expect(dom.container.querySelector("textarea")!.value).toBe(""); + }); + + it("Customize seeds the editor from the inherited letter", () => { + dom.render( + , + ); + clickButton("View cover letter"); + clickButton("Your standard letter"); + clickButton("Customize for this job"); + + // Seeded with the SOURCE's text, in an editor composing a new draft — the + // copy notice is what proves it is not revising the standard letter. + expect(dom.container.querySelector("textarea")!.value).toBe( + "My standard letter.", + ); + expect(dom.container.textContent).toContain("Started from"); + }); + + it("a plain write-one click never seeds, even with something to inherit", () => { + dom.render( + , + ); + clickButton("Write a cover letter"); + expect(dom.container.querySelector("textarea")!.value).toBe(""); + expect(dom.container.textContent).not.toContain("Started from"); + }); +}); + +/** #767 review, blocking 1: the egress acknowledgement must gate everything + * this component can put on screen, not just the job's OWN letters. Adding the + * inherited entry added two routes to an outside-produced body — the reveal's + * chip and the editor's picker — and neither read its `producer`. */ +describe("JobLetterIndicator egress gate over inherited letters (#767)", () => { + const outsideStandard: LetterRecord = { + id: "standard-1", + createdAt: 1, + updatedAt: 9, + body: "My standard letter.", + producer: { contract: 1, producer: "some-outside-producer" }, + }; + const inherited = { letter: outsideStandard, label: "your standard letter" }; + + it("warns before revealing, when only the INHERITED letter came from outside", () => { + // The job's own letter was hand-typed, so the pre-#767 test + // (`hasOutsideProducer(letters)`) says don't warn — but one click on the + // inherited chip would put outside-produced text on screen. + dom.render( + , + ); + clickButton("View cover letter"); + expect(dom.openDialogText()).toContain("Before you view this letter"); + }); + + it("warns before the EDITOR too, when the job has no letters of its own", () => { + // The worse path: no own letters means the glyph opens the editor directly, + // and the picker chip is one click from the same outside-produced body. + dom.render( + , + ); + clickButton("Write a cover letter"); + expect(dom.openDialogText()).toContain("Before you view this letter"); + }); + + it("lands on the right surface after acknowledging, per own-letter state", () => { + // The ack path used to hard-code `reveal`, which was correct only while the + // empty case could never warn. It can now. + dom.render( + , + ); + clickButton("Write a cover letter"); + clickButton("Got it"); + expect(dom.openDialogText()).toContain("Write a cover letter"); + expect(dom.container.querySelector("textarea")).toBeTruthy(); + }); + + it("does not warn when nothing reachable came from outside", () => { + // The other direction, still true: a hand-typed own letter and a hand-typed + // inherited one must not be gated behind a warning that would be false. + const { producer: _drop, ...cleanStandard } = outsideStandard; + void _drop; + dom.render( + , + ); + clickButton("View cover letter"); + expect(dom.openDialogText()).not.toContain("Before you view this letter"); + }); +}); + +/** #767 review, blocking 2: the company tier had no write path at all, so + * `scope: "company"` could only ever fire for a record an outside producer + * wrote. "Customize for this company" is that path. */ +describe("JobLetterIndicator company write path (#767)", () => { + it("offers to lift a letter to company scope when the job has a company key", () => { + dom.render( + , + ); + clickButton("View cover letter"); + expect( + [...dom.container.querySelectorAll("button")].some( + (b) => b.textContent === "Customize for this company", + ), + ).toBe(true); + }); + + it("offers nothing of the sort when the job has no company to key on", () => { + dom.render( + , + ); + clickButton("View cover letter"); + expect( + [...dom.container.querySelectorAll("button")].some( + (b) => b.textContent === "Customize for this company", + ), + ).toBe(false); + }); + + it("opens the editor in COMPANY scope, seeded, with no jobId", () => { + dom.render( + , + ); + clickButton("View cover letter"); + clickButton("Customize for this company"); + + // The title is what tells the user which letter they are about to write, + // and it is derived from the scope keys the save will carry. + expect(dom.openDialogText()).toContain("Write a company letter"); + expect(dom.container.querySelector("textarea")!.value).toBe("Own body."); + }); +}); + +/** + * #767 review, secondary: the company offer used to ALWAYS insert, so with the + * company's own letter on screen it offered to fork that letter from itself. + * The duplicate was then unreachable forever — the chain surfaces only the + * most-recently-updated record per rung, it has no `jobId` for + * `deleteLettersForJob` to cascade to, and there is no company-letter list to + * delete it from. + */ +describe("JobLetterIndicator company tier: edit, not fork (#767 review)", () => { + const companyLetter = ownLetter({ + id: "company-1", + jobId: undefined, + companyKey: "northwind", + label: "Northwind", + body: "Company letter v1.", + }); + + const inheritedCompany = { + letter: companyLetter, + label: "your Northwind letter", + }; + + it("edits in place when the letter on screen IS this company's letter", () => { + dom.render( + , + ); + clickButton("View cover letter"); + clickButton("Your Northwind letter"); + + // The offer renames itself, because it is now a different action. + expect( + [...dom.container.querySelectorAll("button")].some( + (b) => b.textContent === "Customize for this company", + ), + ).toBe(false); + clickButton("Edit this company letter"); + + // REVISING: the editor titles it as an edit and carries the body through + // with no copy notice — the notice is what marks an insert. + expect(dom.openDialogText()).toContain("Edit company letter"); + expect(dom.openDialogText()).not.toContain("Started from"); + expect(dom.container.querySelector("textarea")!.value).toBe( + "Company letter v1.", + ); + }); + + it("still forks when the letter on screen belongs to another scope", () => { + // Lifting one job's draft to company scope is the offer's original job, and + // it must stay an insert — that is a new record at a new scope. + dom.render( + , + ); + clickButton("View cover letter"); + clickButton("Customize for this company"); + + expect(dom.openDialogText()).toContain("Write a company letter"); + expect(dom.openDialogText()).toContain("Started from"); + expect(dom.container.querySelector("textarea")!.value).toBe("Own body."); + }); + + it("forks, not edits, when the selected letter is ANOTHER company's", () => { + // Same key check the chain reads the rung with: a letter at a different key + // is not this company's letter, whatever else it carries. + dom.render( + , + ); + clickButton("View cover letter"); + clickButton("Your Contoso letter"); + expect( + [...dom.container.querySelectorAll("button")].some( + (b) => b.textContent === "Edit this company letter", + ), + ).toBe(false); + }); +}); diff --git a/src/components/features/JobLetterIndicator.tsx b/src/components/features/JobLetterIndicator.tsx index 62655793..2c66d6fc 100644 --- a/src/components/features/JobLetterIndicator.tsx +++ b/src/components/features/JobLetterIndicator.tsx @@ -27,7 +27,9 @@ * the résumé and the job details to that model's API, and the user deserves to * be told once. A letter typed into `LetterEditorDialog` sent nothing * anywhere, so gating it behind that warning would be telling the user - * something untrue about their own typing. `hasOutsideProducer` is the test: + * something untrue about their own typing. {@link letterEgressNeedsAck} is the + * test — shared with `StandardLetterButton` since #767 gave that surface its own + * door onto the same class of text, so one wording and one flag serve both: * `docs/cover-letter-contract.md` §6 reads an absent `producer` block as * "written by offlinecv itself", and the editor never writes one. That marker * is self-reported and optional, so it is used ONLY in this direction — a @@ -57,13 +59,17 @@ */ import { useState } from "react"; -import { Button, Dialog } from "@design-system"; +import { Button } from "@design-system"; import { - hasAcknowledgedLetterEgress, - recordLetterEgressAcknowledged, -} from "../../lib/letter-egress-ack.ts"; -import { LetterRevealDialog } from "./LetterRevealDialog.tsx"; -import { LetterEditorDialog } from "./LetterEditorDialog.tsx"; + LetterEgressAckDialog, + letterEgressNeedsAck, +} from "./LetterEgressAckDialog.tsx"; +import { isCompanyLetter } from "../../lib/letters/resolve-letter.ts"; +import { LetterRevealDialog, type InheritedLetter } from "./LetterRevealDialog.tsx"; +import { + LetterEditorDialog, + type LetterStartingPoint, +} from "./LetterEditorDialog.tsx"; import type { LetterRecord } from "../../lib/storage/index.ts"; /** Shared frame for both glyphs, so the two states differ only in the mark @@ -113,8 +119,24 @@ interface JobLetterIndicatorProps { /** The job these letters belong to — needed to write a new one. */ jobId: string; /** Every letter for this one job, most-recently-updated first. Empty (or - * omitted) renders the "write one" state, not nothing. */ + * omitted) renders the "write one" state, not nothing. + * + * THIS JOB'S OWN letters only, and that is what the glyph reports (#767). A + * company or standard letter existing must never flip a row to "has letter": + * the row would claim a letter the user never wrote for that employer, and + * the reveal would then show text they did not intend for it. Inheritance + * surfaces inside the dialogs, where there is room to say what it is. */ letters?: readonly LetterRecord[]; + /** The letter this job would inherit — its company's, or the standard one + * (#767). Drives the reveal's extra entry and the editor's "Start from…" + * picker. Omitted when the user has written nothing this job can reach. */ + inherited?: InheritedLetter; + /** This job's company as a DERIVED key (`deriveCompanyKey`), when it has one + * (#767). Present enables "Customize for this company", which is the only + * write path to the company tier — absent when the job's `company` is blank + * or all punctuation, which is exactly when a company letter would have no + * key to be found by. */ + companyKey?: string; /** Re-read the letter store after a write. Optional so a caller that only * displays letters (a test, a future read-only view) need not supply one; * without it a saved letter will not appear until the view remounts. */ @@ -123,45 +145,110 @@ interface JobLetterIndicatorProps { type Stage = "closed" | "ack" | "reveal" | "edit"; -/** True when any letter here was written OUTSIDE this app — the only case the - * egress warning is about. See the docblock on why this is read one-way. */ -function hasOutsideProducer(letters: readonly LetterRecord[]): boolean { - return letters.some((letter) => letter.producer !== undefined); -} - export function JobLetterIndicator({ jobId, letters = [], + inherited, + companyKey, onSaved = () => {}, }: JobLetterIndicatorProps) { const [stage, setStage] = useState("closed"); // Which letter the editor is revising. `undefined` composes a new draft, // which is also the empty-state path — one editor, both jobs. const [editing, setEditing] = useState(undefined); + // Set only by a Customize click — the user picking a letter to copy. Every + // other route into the editor clears it, which is what keeps a plain "Write a + // cover letter" click opening an empty draft. + const [seed, setSeed] = useState(undefined); + // Which scope the editor is composing FOR. "job" everywhere except + // "Customize for this company", which is the only write path to the company + // tier — see `openEditor`. + const [composeScope, setComposeScope] = useState<"job" | "company">("job"); const hasLetters = letters.length > 0; + + // What the editor may be started from. One entry, because `resolveLetterForJob` + // already picked the single most specific inherited letter — offering both a + // company and a standard letter here would ask the user to redo a decision the + // chain exists to make. Empty while revising is enforced by the editor itself. + // The label stays the lowercase FRAGMENT it arrives as; the editor's picker + // capitalizes it for the chip and its copy notice embeds it mid-sentence. + // See `scope-phrase.ts` for why the casing cannot be decided here. + const startFrom: readonly LetterStartingPoint[] = inherited + ? [{ letter: inherited.letter, label: inherited.label }] + : []; const label = !hasLetters ? "Write a cover letter" : letters.length === 1 ? "View cover letter" : `View cover letters (${letters.length})`; + /** What to call a letter being copied FROM, in the editor's copy notice. The + * inherited letter has a scope phrase; one of this job's own drafts has only + * its user-set label, and falls back to the same wording the reveal titles + * an unlabelled draft with. */ + function labelFor(source: LetterRecord): string { + if (inherited && source.id === inherited.letter.id) return inherited.label; + return source.label || "this job's letter"; + } + + /** + * Open into an editor composing a fresh draft for `scope`, optionally seeded + * from `from`. The ONE route into compose mode, so the three pieces of state + * that define it can never drift apart: no `editing` record (which is what + * makes a save an insert rather than an upsert over the source), the seed, + * and the scope key the save will carry. + */ + function openEditor( + scope: "job" | "company", + from?: LetterStartingPoint, + ): void { + setEditing(undefined); + setSeed(from); + setComposeScope(scope); + setStage("edit"); + } + + /** + * Open into an editor REVISING `letter` under `scope` — the counterpart of + * {@link openEditor}, and the one route in, so the seed can never survive + * from a previous Customize click into a revise that must not be a copy. + */ + function editLetter(letter: LetterRecord, scope: "job" | "company"): void { + setEditing(letter); + setSeed(undefined); + setComposeScope(scope); + setStage("edit"); + } + function open() { - if (!hasLetters) { - setEditing(undefined); - setStage("edit"); - return; - } + // Gate on everything a click from here can put on screen, not just this + // job's own letters. Since #767 the reveal offers the inherited letter as + // an entry and the editor offers it as a starting point, so an + // outside-produced STANDARD letter reaches the screen through a job whose + // own letters are all hand-typed — and the warning is about egress that + // already happened to the text being shown, whichever scope holds it. + // // Read the acknowledgement fresh, not from a cached hook value: several // rows' indicators are mounted at once on this page, and it is meant to be // "once, ever" — not "once per row." See `letter-egress-ack.ts`. - const mustWarn = - hasOutsideProducer(letters) && !hasAcknowledgedLetterEgress(); - setStage(mustWarn ? "ack" : "reveal"); + if (letterEgressNeedsAck([...letters, inherited?.letter])) { + setStage("ack"); + return; + } + reveal(); } - function acknowledge() { - recordLetterEgressAcknowledged(); + /** Where `open` lands once the warning (if any) is out of the way — the + * reveal for a job with its own drafts, the editor for one without. Shared + * with `acknowledge` so the post-warning destination cannot diverge from the + * no-warning one; before #767 the ack path hard-coded `"reveal"`, which was + * right only while the empty case could never warn. */ + function reveal() { + if (!hasLetters) { + openEditor("job"); + return; + } setStage("reveal"); } @@ -182,51 +269,69 @@ export function JobLetterIndicator({ {hasLetters ? : } - setStage("closed")} - title="Before you view this letter" - className="max-w-md" - > -
-

- offlinecv stores this letter — it did not write it. To draft it, - whatever generated the text sent your résumé and the job details - out to a model’s API. That step happened outside this - app’s on-device guarantee. Reading the letter here, or - copying it, sends nothing further. -

-

- Your confirmation is saved in this browser, so you normally see - this once. If this browser blocks that storage, it will ask again. -

-
- -
-
-
+ onAcknowledged={reveal} + /> setStage("closed")} letters={letters} - onEdit={(letter) => { - setEditing(letter); - setStage("edit"); - }} - onCompose={() => { - setEditing(undefined); - setStage("edit"); - }} + inherited={inherited} + onEdit={(letter) => editLetter(letter, "job")} + onCompose={() => openEditor("job")} + // Compose, NOT revise — `openEditor` leaves `editing` undefined so the + // editor writes a new record with no id. Handing the source record to + // `editing` would make Save OVERWRITE the letter being copied, which is + // the one failure this whole flow is arranged to prevent. + // + // `source` is the letter the reveal actually has on screen, taken from + // the argument rather than reached for in `startFrom` — the two are the + // same record while there is one inherited entry, and taking the + // argument keeps this correct if a second is ever offered. + onCustomize={(source) => + openEditor("job", { letter: source, label: labelFor(source) }) + } + // Fork OR revise, decided by what is actually on screen. Lifting a job + // letter to company scope must insert — that is a new record at a new + // scope. But the reveal also offers this while the COMPANY's own letter + // is selected, and inserting there forks the letter from itself: the + // first record keeps the same key, only the newest is ever surfaced by + // the chain, it has no `jobId` for `deleteLettersForJob` to cascade to, + // and there is no company-letter list to delete it from — so it is + // unreachable forever (#767 review). Same predicate the chain reads the + // rung with, so the two cannot disagree about what "the company's + // letter" is. + companyOffer={ + companyKey !== undefined + ? { + companyKey, + onCustomize: (source) => + isCompanyLetter(source, companyKey) + ? editLetter(source, "company") + : openEditor("company", { + letter: source, + label: labelFor(source), + }), + } + : undefined + } /> setStage("closed")} - jobId={jobId} + // Exactly one scope key, never both — the contract refuses a record + // carrying two. Composing for the company tier drops `jobId` entirely, + // which is what makes the saved letter reachable from every job at that + // employer rather than just this one. + jobId={composeScope === "company" ? undefined : jobId} + companyKey={composeScope === "company" ? companyKey : undefined} letter={editing} + startFrom={startFrom} + seed={seed} onSaved={onSaved} /> diff --git a/src/components/features/JobTracker.test.tsx b/src/components/features/JobTracker.test.tsx index 1343cd97..445468bc 100644 --- a/src/components/features/JobTracker.test.tsx +++ b/src/components/features/JobTracker.test.tsx @@ -21,6 +21,7 @@ import type { JobRecord, LetterRecord } from "../../lib/storage/index.ts"; import type { JobRating } from "../../lib/job-search/rating.ts"; import type { JobDuplicateSuggestion } from "../../hooks/useJobDuplicates.ts"; import { findRepostClusters } from "../../lib/job-repost-clusters.ts"; +import { groupByScope } from "../../hooks/useJobLetters.ts"; import { installDialogPolyfill } from "./__test-utils__/dialog-dom.ts"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = @@ -45,6 +46,20 @@ afterEach(() => { container.remove(); }); +/** Click the button whose visible text is `label`, and hand it back so a caller + * can assert on presence in the same line. `undefined` rather than a throw when + * there is no match: "this control is absent" is asserted directly here. + * + * Module scope, one copy — three describes had grown their own identical one + * and fallow was reporting the clone family. */ +function clickButton(label: string) { + const button = [...container.querySelectorAll("button")].find( + (b) => b.textContent === label, + ); + act(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + return button; +} + function job(over: Partial): JobRecord { return { id: over.id ?? crypto.randomUUID(), @@ -254,14 +269,6 @@ describe("JobTracker: resume link picker", () => { { id: "r2", filename: "resume-v2.pdf" }, ]; - function clickButton(label: string) { - const button = [...container.querySelectorAll("button")].find( - (b) => b.textContent === label, - ); - act(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - return button; - } - it("offers the saved resumes and links the one picked", () => { const tracker = makeTracker([job({ id: "j1" })]); act(() => @@ -339,6 +346,150 @@ describe("JobTracker: letter indicator (#715)", () => { }); }); +describe("JobTracker: inherited letters, wired as the app wires them (#767)", () => { + /** + * The wiring is the point of this block, not the components. + * + * `lettersById` and `allLetters` are BOTH derived here from one letter set + * through the real `groupByScope`, exactly as `JobTrackerSection` derives them + * from one store read. That is the only arrangement in which the #767 review's + * blocker is visible: the component-level suites pass `letters` and + * `inherited` as independent props, so they can hand a row its own letter AND + * something to inherit — a pair the production wiring cannot produce, because + * owning a letter is exactly what used to make `inheritedFor` answer + * `undefined`. Every assertion below was red before `inheritedLetterForJob`. + */ + function wire(letters: readonly LetterRecord[]) { + const grouped = groupByScope(letters); + return { lettersById: grouped.byJobId, allLetters: letters }; + } + + function standardLetter(over: Partial = {}): LetterRecord { + return { + id: "standard-1", + createdAt: 1, + updatedAt: 5, + body: "STANDARD-BODY.", + ...over, + }; + } + + function ownLetter(jobId: string): LetterRecord { + return { + id: "own-1", + jobId, + createdAt: 1, + updatedAt: 9, + label: "Mine", + body: "OWN-BODY.", + }; + } + + function mount(letters: readonly LetterRecord[]) { + const tracker = makeTracker([ + job({ id: "j1", title: "SWE", company: "Northwind" }), + ]); + act(() => root.render()); + } + + it("offers the standard letter inside the reveal of a job that HAS its own", () => { + mount([ownLetter("j1"), standardLetter()]); + clickButton("View cover letter"); + + // The row's own draft is what opens — the glyph promised a letter for this + // job, so the inherited one must never be what is showing first. + expect(container.textContent).toContain("OWN-BODY."); + expect(container.textContent).not.toContain("STANDARD-BODY."); + + // …and the inherited entry is offered beside it. This is the half that was + // dead in production: `inherited` arrived `undefined` for exactly the jobs + // whose reveal opens. + expect(clickButton("Your standard letter")).toBeTruthy(); + expect(container.textContent).toContain("STANDARD-BODY."); + expect(container.textContent).toContain( + "This is your standard letter, not a letter for this job", + ); + }); + + it("swaps Edit for Customize on that inherited entry (AC 6 + 7, in the product)", () => { + mount([ownLetter("j1"), standardLetter()]); + clickButton("View cover letter"); + clickButton("Your standard letter"); + + expect( + [...container.querySelectorAll("button")].some( + (b) => b.textContent === "Edit", + ), + ).toBe(false); + expect( + [...container.querySelectorAll("button")].some( + (b) => b.textContent === "Customize for this job", + ), + ).toBe(true); + }); + + it("prefers the company letter over the standard one, through the same wiring", () => { + mount([ + ownLetter("j1"), + standardLetter(), + { + id: "company-1", + companyKey: "northwind", + createdAt: 1, + updatedAt: 3, + body: "COMPANY-BODY.", + }, + ]); + clickButton("View cover letter"); + + // Specificity, not recency: the standard letter is the newer record. + expect(clickButton("Your Northwind letter")).toBeTruthy(); + expect(container.textContent).toContain("COMPANY-BODY."); + expect(container.textContent).not.toContain("STANDARD-BODY."); + }); + + it("still offers nothing to inherit when the only letter IS the job's own", () => { + mount([ownLetter("j1")]); + clickButton("View cover letter"); + expect(container.textContent).not.toContain("not a letter for this job"); + expect(clickButton("Customize for this job")).toBeUndefined(); + }); +}); + +describe("JobTracker: the standard-letter button waits for the store (#767)", () => { + it("renders no standard-letter control while the letter read is in flight", () => { + // `standardLetter` is `standard[0]`, and `[0]` of a not-yet-loaded list is + // `undefined` — the same value as "the user has none". Offering to COMPOSE + // in that window writes a second unscoped record and orphans the first, + // because only `[0]` is ever surfaced (#767 review). + act(() => + root.render( + , + ), + ); + expect(clickButton("Write a standard letter")).toBeUndefined(); + expect(clickButton("Edit standard letter")).toBeUndefined(); + }); + + it("renders it once the read has landed", () => { + act(() => + root.render( + , + ), + ); + expect(clickButton("Write a standard letter")).toBeTruthy(); + }); + + it("defaults to ready, so a caller holding the record is unaffected", () => { + // The flag is about a live store read in flight; a caller that passes the + // record directly has already done one. + act(() => + root.render(), + ); + expect(clickButton("Write a standard letter")).toBeTruthy(); + }); +}); + describe("JobTracker: origin phrase on the tracker row (#745)", () => { it("shows a short phrase for a recognised origin", () => { const tracker = makeTracker([job({ id: "j1", title: "Shared job", origin: "shared" })]); diff --git a/src/components/features/JobTracker.tsx b/src/components/features/JobTracker.tsx index 5bc95105..985843aa 100644 --- a/src/components/features/JobTracker.tsx +++ b/src/components/features/JobTracker.tsx @@ -78,6 +78,10 @@ import { JobArchiveSweepDialog } from "./JobArchiveSweepDialog.tsx"; import { useJobTracker, type JobTracker as Tracker } from "../../hooks/useJobTracker.ts"; import { useSavedJobRatings } from "../../hooks/useSavedJobRatings.ts"; import { useJobLetters } from "../../hooks/useJobLetters.ts"; +import { StandardLetterButton } from "./StandardLetterButton.tsx"; +import type { InheritedLetter } from "./LetterRevealDialog.tsx"; +import { inheritedLetterForJob } from "../../lib/letters/resolve-letter.ts"; +import { deriveCompanyKey } from "../../lib/storage/company-key.ts"; import { useJobDuplicates, type JobDuplicateSuggestion, @@ -87,6 +91,50 @@ import type { JobRepostCluster } from "../../lib/job-repost-clusters.ts"; import type { HeuristicParsedResume } from "../../lib/heuristics/types.ts"; import type { JobRating } from "../../lib/job-search/rating.ts"; +/** + * The letter one row INHERITS, phrased for display (#767), or `undefined` when + * the job has its own letter or there is nothing to inherit. + * + * This surface asks the narrower "what would this job inherit", not the chain's + * "which letter applies" — the row's own drafts already reach it through + * `lettersById` — so it calls the entry that starts one rung down. See + * {@link inheritedLetterForJob} for why asking the wide question and dropping + * the `"job"` answer is not the same thing. + * + * The phrase is built here because this is the layer holding `job.company`; + * the dialogs downstream only need something to print. It is a lowercase + * FRAGMENT — the render sites that stand it alone capitalize it themselves + * (`scope-phrase.ts`), because the same phrase is embedded mid-sentence + * elsewhere and no one string can be right in both. + */ +function inheritedFor( + job: JobRecord, + letters: readonly LetterRecord[] | undefined, +): InheritedLetter | undefined { + if (!letters || letters.length === 0) return undefined; + // `inheritedLetterForJob`, NOT `resolveLetterForJob` with the `"job"` answer + // filtered out. The two are not the same question, and filtering answers the + // wrong one: `resolveLetterForJob` returns `"job"` exactly when some letter + // carries this job's id, which is exactly when `lettersById` is non-empty, + // which is what makes the indicator open the REVEAL rather than the editor. + // So the filtered form was `undefined` for precisely the jobs whose reveal + // opens — the inherited chip, the scope notice and "Customize for this job" + // could never render in the product, though their unit tests passed on a + // `letters` + `inherited` pair this component cannot produce (#767 review). + const resolved = inheritedLetterForJob(job, letters); + if (!resolved) return undefined; + return { + letter: resolved.letter, + // `job.company` verbatim, not the normalised key: the key is a lookup + // token ("northwind"), and printing it back at the user would show them a + // lowercased, suffix-stripped version of a name they typed. + label: + resolved.scope === "company" + ? `your ${job.company} letter` + : "your standard letter", + }; +} + interface JobTrackerProps { tracker: Tracker; /** Fitness rating per job id, or null when the library has not been rated — @@ -115,6 +163,30 @@ interface JobTrackerProps { /** Every letter, grouped by job id (#715) — `useJobLetters`' shape. A job id * absent from the map has no letters, so its row renders no indicator. */ lettersById?: ReadonlyMap; + /** Every live letter, flat (#767) — what each row's `inheritedLetterForJob` + * runs against to find the company or standard letter it would inherit. + * Omitted resolves nothing, so a caller that has not read the store gets + * exactly the pre-#767 behaviour. */ + allLetters?: readonly LetterRecord[]; + /** The user's standard letter, if written (#767) — the panel-level button's + * state. Absent renders "Write a standard letter", so it is only a truthful + * answer once {@link JobTrackerProps.lettersReady} is true. */ + standardLetter?: LetterRecord; + /** Whether the letter store has actually been read (#767 review). + * + * `standardLetter` is `standard[0]`, and `[0]` of a not-yet-loaded list is + * `undefined` — indistinguishable from "the user has none". The tracker + * early-returns on ITS OWN `ready` only, and `useJobLetters` resolves + * independently, so there is a window where the rows are on screen and the + * letters are not. Composing in that window writes a SECOND unscoped record, + * and since only `[0]` is ever surfaced the original becomes unreachable — + * the standard tier is the one that loses a record this way, because it is + * the one with a single window onto it. + * + * Defaults to `true` so a caller that passes `standardLetter` directly (every + * test, any future read-only view) is unaffected: the flag is about a live + * store read in flight, which a caller holding the record has already done. */ + lettersReady?: boolean; /** Re-read the letter store after a row writes one. Optional so a caller * that only displays letters need not supply one; without it a saved letter * will not appear until this view remounts. */ @@ -150,6 +222,9 @@ export function JobTrackerSection({ | "ratings" | "hasResume" | "lettersById" + | "allLetters" + | "standardLetter" + | "lettersReady" | "onLettersChanged" | "duplicatesByJobId" | "onDismissDuplicate" @@ -171,6 +246,17 @@ export function JobTrackerSection({ ratings={ratings} hasResume={parsed !== undefined} lettersById={letters.byJobId} + allLetters={letters.all} + // `standard` is most-recently-updated first, so `[0]` is the current + // standard letter. Nothing writes a second one — the panel button edits + // the existing record — but the store holds a list, so this reads the + // newest rather than assuming there is exactly one. + // + // `ready` travels with it because that "nothing writes a second one" holds + // only AFTER the read lands: before it, `standard` is `[]`, `[0]` is + // `undefined`, and an unguarded button would offer to compose one. + standardLetter={letters.standard[0]} + lettersReady={letters.ready} onLettersChanged={letters.refresh} duplicatesByJobId={duplicates.byJobId} onDismissDuplicate={duplicates.dismiss} @@ -188,6 +274,9 @@ export function JobTracker({ resumeName, resumeOptions, lettersById, + allLetters, + standardLetter, + lettersReady = true, onLettersChanged, duplicatesByJobId, onDismissDuplicate, @@ -244,6 +333,35 @@ export function JobTracker({ // "only non-empty bucket is rejected" case is the single-bucket case of it. const anyOpenByDefault = groups.some(({ bucket }) => !isCollapsedByDefault(bucket)); + // One pass over the letter set for the whole library, not one per row per + // render. `inheritedLetterForJob` walks `allLetters` up to twice, and the row + // `.map()` below re-runs on every keystroke in an `EditableField` and every + // status-filter toggle — without this the cost is O(jobs x letters x 2) per + // render. Keyed by job id so a row still gets its own answer. + const inheritedByJobId = useMemo(() => { + const byId = new Map(); + if (!allLetters || allLetters.length === 0) return byId; + for (const job of jobs) { + const resolved = inheritedFor(job, allLetters); + if (resolved) byId.set(job.id, resolved); + } + return byId; + }, [jobs, allLetters]); + + // Same reason, its own memo: `deriveCompanyKey` is a unicode regex replace, a + // split, a filter and an 11-entry suffix scan, and it was running per row per + // render directly under the memo above (#767 review). Keyed off `jobs` alone + // because the key is derived from `job.company` and owes nothing to the + // letter set — so a letter write does not recompute it. + const companyKeyByJobId = useMemo(() => { + const byId = new Map(); + for (const job of jobs) { + const key = deriveCompanyKey(job.company); + if (key !== undefined) byId.set(job.id, key); + } + return byId; + }, [jobs]); + if (!ready) return null; return ( @@ -262,6 +380,21 @@ export function JobTracker({ {persisted ? "Persistent" : "Best-effort"} + {/* Panel-level, not per-row (#767): the standard letter is the one + letter with no job to hang off. See `StandardLetterButton`. + + Held back until the letter store has been read, rather than + rendered against an `undefined` that cannot yet be told from "the + user has none" — see `lettersReady`. Absent, not disabled: the + control appears once with the right label, instead of flickering + from a disabled "Write a standard letter" to "Edit standard + letter" and inviting a click at the wrong moment. */} + {lettersReady && ( + + )}