Skip to content

Letter tiering UI: job → company → standard resolution, picker, and customize-from #767

Description

@s-annam

Part of #765. Depends on the letter scope keys issue — this one consumes the lattice that issue stores.

Read #765 first for the standard-is-the-source framing and the never-inferred rule.

Problem

With scope keys stored, a job can be reached by up to three letters — its own, its company's, and the standard one. Nothing decides which the user sees, and nothing lets them create anything but a job letter.

Today the whole surface is JobLetterIndicator (src/components/features/JobLetterIndicator.tsx), one per Saved-jobs row, driving LetterRevealDialog and LetterEditorDialog. It reads letters for one jobId and nothing else.

Design

The resolution chain

For a given job, the letter that applies resolves job → company → standard, first hit wins. The chain is a pure function over the letter set, so it belongs in src/lib/ and is unit-testable without a DOM:

// src/lib/letters/resolve-letter.ts
export function resolveLetterForJob(
  job: Pick<JobRecord, "id" | "company">,
  letters: readonly LetterRecord[],
): { letter: LetterRecord; scope: "job" | "company" | "standard" } | undefined

The returned scope is not decoration — every surface below needs it to tell the user why they are looking at this text.

The row glyph keeps meaning "this job's own letter"

JobLetterIndicator renders two glyphs today: an envelope (has letters) and an envelope-plus (write one). Neither changes meaning here. A standard letter existing must NOT flip every row to "has letter" — that would claim a letter the user never wrote for that job, and the reveal would then show text they did not intend for that employer.

Instead, an inherited letter surfaces inside the dialogs, labelled with its scope, where there is room to say what it is.

Customize-from is a COPY, not a link

Starting a job letter from the company or standard letter writes a new record with a new id and the job's jobId. It does not link to its source.

This is deliberate and is the same reasoning that makes the résumé half hard (#765): if job B's letter were a live reference to the standard letter, editing it would rewrite the letter already submitted for job A. For prose there is no merge that could make that safe, so the copy is the honest model — and it is why letters ship before résumés.

The dialog must say so plainly, once, at the moment of copying. "Starting from your Northwind letter" with no further explanation reads as a link.

Steps

  1. src/lib/letters/resolve-letter.ts (new) — the chain above. Pure, no React, no storage import. A job with no company (the field may be empty, src/lib/storage/types.ts:198) skips the company rung rather than matching a blank key.

  2. src/hooks/useJobLetters.ts — expose company and standard letters alongside byJobId so a row can resolve without a second store read. The hook already reads the whole store once and groups in memory for exactly this reason (see its docblock).

  3. src/components/features/LetterEditorDialog.tsxjobId becomes optional (absent = editing a company or standard letter, per the props the scope-keys issue lands). Add a "Start from…" picker: shown only when composing (never when revising), only when there is something to start from, and never seeding automatically. Selecting seeds body and writes a new record on save — no id carried over, which is what makes it a copy.

  4. src/components/features/LetterRevealDialog.tsx — when the letter shown is inherited, label the scope ("Your standard letter" / "Your Northwind letter") and offer Customize for this job, which opens the editor pre-seeded per step 3.

  5. src/components/features/StandardLetterButton.tsx (new, ~40 LOC) — one panel-level affordance on /jobs/ → Saved jobs that opens LetterEditorDialog with no jobId. This is where a standard letter is created and edited. Company letters are created via "Customize for this company" from a job row, so they need no separate entry point.

  6. src/components/features/JobTracker.tsx — mount the button, thread the resolved letter and scope down to each row.

Reuse analysis

Capability: author, view, and pick among cover letters at three scopes.

Existing surfaces found:

  • src/components/features/LetterEditorDialog.tsx — already owns authoring and revising a LetterRecord. Extend (optional jobId + the picker); do not add a second editor for company/standard letters. They are the same act on the same record type.
  • src/components/features/LetterRevealDialog.tsx — already owns reading and copying. Extend with the scope label.
  • src/components/features/JobLetterIndicator.tsx — already owns the row's click → acknowledge → reveal/edit state machine. Extend; its two glyphs are unchanged.
  • @design-systemDialog, Button, TextAreaField already cover every control needed. No new primitive.

Decision: extend all three, plus one new file — StandardLetterButton — because no existing surface owns a panel-level (not per-row) letter affordance, and folding it into JobTracker would push that file further past the ~200 LOC guideline it already sits near.

Acceptance criteria

  • A job with its own letter resolves to that letter with scope job, even when a company and standard letter also exist
  • A job with no own letter but a company letter resolves to it with scope company
  • A job with only a standard letter available resolves to it with scope standard
  • A job whose company is empty never matches a company letter
  • The row glyph reflects that job's own letters only — a standard letter existing does not flip any row's icon
  • The reveal names the scope whenever the letter is inherited, and says nothing extra when it is the job's own
  • "Customize for this job" writes a new record with a new id and this job's jobId; the source letter is byte-identical afterwards
  • Editing a job's customized letter leaves the company and standard letters untouched
  • The standard-letter button opens the editor with no jobId and saves a letter with neither key
  • "Start from…" never seeds without an explicit pick, and is absent when revising an existing letter
  • Existing JobLetterIndicator behaviour is unchanged for a store containing only job letters — the whole pre-existing test file still passes untouched
  • npm run verify green

Out of scope

Metadata

Metadata

Assignees

Labels

featureNew functionality

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions