You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In the reconstructed résumé on /, a parsed entry cannot be deleted. Only a user-added entry can. If the parser produces a phantom achievement, a duplicated role, or an education entry stitched out of two, the user can edit its text but cannot remove it — and it ships into the Download PDF.
Reported against the Achievements section ("I can't delete an achievement once shown from a parsed resume"), but it is not achievements-specific. Every section gates its remove affordance on the same added flag:
src/components/features/ReconstructedEducationSkills.tsx:163 — onRemove only passed when added (:258)
Experience
RemoveButton label="Remove role"
src/components/features/ReconstructedRole.tsx:432 — onRemove only passed when added (ReconstructedResume.tsx:651)
The root cause is one level down: no removed-parsed-entry state exists.removeEntry in src/hooks/useEditableParse.ts:1349 only filters the added-entry list:
There is no equivalent of removedBullets (useEditableParse.ts:1078, folded into the résumé by applyRemovedBulletOverrides in src/lib/edit/apply-overrides.ts:782) at the entry level. applyOverrides (apply-overrides.ts:1027) takes no such parameter.
Achievements feel worse than the other sections because achievement (and project) bullets are rendered read-only — ReconstructedResume.tsx:981 and :764 pass only bullet={b} to ResumeBulletRow, no onBulletChange and no onRemove. In Experience a user can at least delete the bullets under a bad role; in Achievements nothing under the entry is removable either. That bullet gap is out of scope here (see "Not in scope").
Epic #453 brought Achievements to "full edit fidelity" (inline type/description/year via #454, structured add via #455). Deletion was never in its acceptance criteria.
Why it matters
The Download PDF is built from the canonical fields (apply-overrides.ts:1102 → toCanonicalResume → src/lib/pdf/ats-resume-model.ts), so an un-deletable phantom entry is exported verbatim.
The score reads input.sections for its bullet pool (src/lib/score/score.ts:986-989), so a phantom entry's bullets keep grading the résumé.
The only workaround today is blanking every field, which leaves an empty-but-present entry. pruneEmptyAddedEntries (useEditableParse.ts:1361) only prunes added entries, so a blanked parsed entry is never dropped.
Proposed design — a removedEntries tombstone set
Mirror the existing removedBullets shape, one level up. Two decisions carry the design:
1. Tombstone, do not splice. Achievement, experience, and education overrides are keyed by parsed array index (applyAchievementOverrides, apply-overrides.ts:391-405; applyExperienceHeaderOverrides; applyEducationFieldOverrides). Splicing an entry out of parsed.heuristic_achievements shifts every later index and silently rebinds each later entry's edits to the wrong entry. Instead: keep the array intact through the override passes, mark the removed entries, and filter them out once, at the end of applyOverrides — after every index-keyed pass has run.
2. Reuse removedBullets for the entry's bullets. The UI's delete handler removes the entry and calls the existing removeBullet(id) for every bullet id in the entry's group. That reuses removeBulletFromRawText / removeBulletFromSections / removeBulletFromDescriptions (apply-overrides.ts:654-690) unchanged, so the bullets leave rawText, sections (hence the score) and the role descriptions with no new machinery. The only genuinely new text surgery is the entry's own header line.
Key: parsedEntryKey(section, index) — "achievements:2" — already exported from useEditableParse.ts:372 and already the key space addedBullets uses for parsed entries. Added-entry ids ("added:<n>") live in the same namespace without collision (isAddedEntryKey, :386), so one removedEntries set can hold both and removeEntry can serve both paths.
Implementation plan
Step 1 — removedEntries state in useEditableParse.ts
Add const [removedEntries, setRemovedEntries] = useState<ReadonlySet<string>>(new Set()) alongside removedBullets (:851), plus a removedEntriesRef mirror matching the removedBulletsRef pattern (:898).
Widen removeEntry (:1349) to handle both key kinds:
Export removedEntries from the hook's return value (:1792+) and add it to hasEdits (:1764): if (removedEntries.size > 0) return true;.
Add it to the reset callback (:1570+).
Step 2 — undo / draft snapshot
Add removedEntries: string[] to EditSnapshot and to the snapshot memo (:1587-1616), serialized like removedBullets: [...removedBullets] (:1593).
In replay (:1618), restore with a backward-compatible default — (snap.removedEntries ?? []).forEach((key) => removeEntry(key)) — following the snap.descriptionOverrides ?? {} precedent at :1665. EditSnapshot is persisted to the résumé library, so a draft written before this change has no such field; see [edit] a draft saved before #682 replays an un-normalised date pair, so the card contradicts the export #815 for what a snapshot that replays wrong costs.
Step 3 — applyOverrides fold
Add an 18th positional parameter removedEntries: ReadonlySet<string> = new Set() to applyOverrides (apply-overrides.ts:1027), matching the existing convention (removedBullets is already positional at :1039). The parameter count is pre-existing debt — do not refactor the signature to an options object in this issue.
After applyAddedEntriesAndBullets (:1094) and before toCanonicalResume (:1102), add a single applyRemovedEntries(nextParsed, views, removedEntries) pass that, for each key "<section>:<index>":
filters the entry out of the matching array (experience, education, projects, heuristic_achievements);
removes the entry's header line from views.rawText and views.sections, reusing the normalise-and-match-first-line helper the bullet removers share (apply-overrides.ts:489-500, :649-690) rather than writing a second matcher.
Ordering is load-bearing: it must run after every index-keyed override pass (applyExperienceHeaderOverrides:1069, applyEducationFieldOverrides:1083, applyAchievementOverrides:1086, applyDescriptionOverrides:1091) so no key is rebound, and after the added-entry append (:1094) so the parsed indices those passes used are still the ones in the array.
Wire the new argument through the one caller: src/hooks/useAnalyzedResume.ts:162-196 (argument list and the useMemo dep array — exhaustive-deps is not enforced in this repo, so a missing dep lints green and produces a stale résumé).
Step 4 — UI
Drop the added && gate at ReconstructedResume.tsx:971 (achievements), :755 (projects), :651 (experience) and ReconstructedEducationSkills.tsx:258 (education). Pass onRemove unconditionally, resolving the key: added ? added.id : parsedEntryKey(<section>, i).
The delete handler also drops the entry's bullets: group?.bullets.forEach((b) => onRemoveBullet(b.id)) before onRemoveEntry(key). Achievements/projects currently receive no onRemoveBullet prop, so thread it in from ReconstructedResume (the same removeBullet already passed to the Experience path at :646).
Achievements is the section the report came from — ship it first and verify end-to-end before doing the other three in the same PR.
Step 5 — tests
New src/hooks/useEditableParse.removed-entry.test.tsx: removing a parsed entry sets hasEdits, survives a snapshot → replay round-trip, and a pre-change snapshot (no removedEntries field) replays without throwing.
New src/lib/edit/apply-overrides.removed-entry.test.ts: the index-rebinding regression — override achievement index 2's title, remove achievement index 0, assert the surviving entry at old-index-2 still carries its own override and not a neighbour's. Same assertion for experience and education.
Extend src/components/features/AchievementTypePicker.test.tsx's neighbours (or a new ReconstructedResume.remove-parsed-entry.test.tsx) for the rendered affordance.
Round-trip: assert a résumé with an entry removed exports and re-parses without the entry (corpus-roundtrip.test.ts / render-roundtrip.repro.test.ts must stay green).
Reuse analysis
Capability: delete an entry from a section of the reconstructed résumé.
Existing surfaces found:
RemoveButton — src/components/features/ReconstructedAdd.tsx (already used by all four sections for added entries).
removeBullet / removedBullets — src/hooks/useEditableParse.ts:1078, src/lib/edit/apply-overrides.ts:782. The exact one-level-down analogue of what this issue needs.
parsedEntryKey / isAddedEntryKey — src/hooks/useEditableParse.ts:372, :386. The key space already exists.
Decision:extend — no new component, no new panel, no new dialog. This adds one state set to an existing hook, one pass to an existing function, and removes four added && gates around a primitive that is already rendered in all four sections.
Acceptance criteria
A parsed achievement can be deleted from the reconstructed résumé, and stays deleted across a re-render.
The same works for a parsed experience role, education entry, and project.
Deleting an entry also drops its bullets from the score (sections) and from rawText / the "Raw text & flags" disclosure.
The deleted entry is absent from the Download PDF, and the exported PDF re-parses without it (round-trip invariant holds).
Index integrity: with per-entry field overrides on entries at indices 1 and 2, deleting index 0 leaves each surviving entry holding its own override. Covered by an explicit test.
Deleting an entry sets hasEdits, is captured in EditSnapshot, and is restored by replay.
A draft/snapshot written before this change replays without error (no removedEntries field present).
reset clears removedEntries.
No regression to added-entry removal, bullet removal, prune-on-blur (pruneEmptyAddedEntries), or any existing edit surface.
npm run verify green, including corpus.test.ts and corpus-roundtrip.test.ts.
Not in scope
Achievement and project bullets are read-only (ReconstructedResume.tsx:981, :764 pass only bullet={b} — no onBulletChange, no per-row onRemove), so an individual bullet under an achievement still cannot be edited or removed after this lands. Worth a separate issue; this one delivers entry-level deletion, which is what the report asked for. (Step 4 threads onRemoveBullet into those sections for the entry-delete cascade, which makes the follow-up mostly prop-threading.)
Undo-as-a-visible-affordance for a deleted entry. The snapshot/replay path covers restore; a dedicated "undo delete" toast is a separate UX question.
Problem
In the reconstructed résumé on
/, a parsed entry cannot be deleted. Only a user-added entry can. If the parser produces a phantom achievement, a duplicated role, or an education entry stitched out of two, the user can edit its text but cannot remove it — and it ships into the Download PDF.Reported against the Achievements section ("I can't delete an achievement once shown from a parsed resume"), but it is not achievements-specific. Every section gates its remove affordance on the same
addedflag:RemoveButton label="Remove achievement"src/components/features/ReconstructedResume.tsx:971—{added && …}RemoveButton label="Remove project"src/components/features/ReconstructedResume.tsx:755—{added && …}RemoveButton label="Remove education"src/components/features/ReconstructedEducationSkills.tsx:163—onRemoveonly passed whenadded(:258)RemoveButton label="Remove role"src/components/features/ReconstructedRole.tsx:432—onRemoveonly passed whenadded(ReconstructedResume.tsx:651)The root cause is one level down: no removed-parsed-entry state exists.
removeEntryinsrc/hooks/useEditableParse.ts:1349only filters the added-entry list:There is no equivalent of
removedBullets(useEditableParse.ts:1078, folded into the résumé byapplyRemovedBulletOverridesinsrc/lib/edit/apply-overrides.ts:782) at the entry level.applyOverrides(apply-overrides.ts:1027) takes no such parameter.Achievements feel worse than the other sections because achievement (and project) bullets are rendered read-only —
ReconstructedResume.tsx:981and:764pass onlybullet={b}toResumeBulletRow, noonBulletChangeand noonRemove. In Experience a user can at least delete the bullets under a bad role; in Achievements nothing under the entry is removable either. That bullet gap is out of scope here (see "Not in scope").Epic #453 brought Achievements to "full edit fidelity" (inline type/description/year via #454, structured add via #455). Deletion was never in its acceptance criteria.
Why it matters
apply-overrides.ts:1102→toCanonicalResume→src/lib/pdf/ats-resume-model.ts), so an un-deletable phantom entry is exported verbatim.input.sectionsfor its bullet pool (src/lib/score/score.ts:986-989), so a phantom entry's bullets keep grading the résumé.pruneEmptyAddedEntries(useEditableParse.ts:1361) only prunes added entries, so a blanked parsed entry is never dropped.Proposed design — a
removedEntriestombstone setMirror the existing
removedBulletsshape, one level up. Two decisions carry the design:1. Tombstone, do not splice. Achievement, experience, and education overrides are keyed by parsed array index (
applyAchievementOverrides,apply-overrides.ts:391-405;applyExperienceHeaderOverrides;applyEducationFieldOverrides). Splicing an entry out ofparsed.heuristic_achievementsshifts every later index and silently rebinds each later entry's edits to the wrong entry. Instead: keep the array intact through the override passes, mark the removed entries, and filter them out once, at the end ofapplyOverrides— after every index-keyed pass has run.2. Reuse
removedBulletsfor the entry's bullets. The UI's delete handler removes the entry and calls the existingremoveBullet(id)for every bullet id in the entry's group. That reusesremoveBulletFromRawText/removeBulletFromSections/removeBulletFromDescriptions(apply-overrides.ts:654-690) unchanged, so the bullets leaverawText,sections(hence the score) and the role descriptions with no new machinery. The only genuinely new text surgery is the entry's own header line.Key:
parsedEntryKey(section, index)—"achievements:2"— already exported fromuseEditableParse.ts:372and already the key spaceaddedBulletsuses for parsed entries. Added-entry ids ("added:<n>") live in the same namespace without collision (isAddedEntryKey,:386), so oneremovedEntriesset can hold both andremoveEntrycan serve both paths.Implementation plan
Step 1 —
removedEntriesstate inuseEditableParse.tsconst [removedEntries, setRemovedEntries] = useState<ReadonlySet<string>>(new Set())alongsideremovedBullets(:851), plus aremovedEntriesRefmirror matching theremovedBulletsRefpattern (:898).removeEntry(:1349) to handle both key kinds:removedEntriesfrom the hook's return value (:1792+) and add it tohasEdits(:1764):if (removedEntries.size > 0) return true;.resetcallback (:1570+).Step 2 — undo / draft snapshot
removedEntries: string[]toEditSnapshotand to thesnapshotmemo (:1587-1616), serialized likeremovedBullets: [...removedBullets](:1593).replay(:1618), restore with a backward-compatible default —(snap.removedEntries ?? []).forEach((key) => removeEntry(key))— following thesnap.descriptionOverrides ?? {}precedent at:1665.EditSnapshotis persisted to the résumé library, so a draft written before this change has no such field; see [edit] a draft saved before #682 replays an un-normalised date pair, so the card contradicts the export #815 for what a snapshot that replays wrong costs.Step 3 —
applyOverridesfoldremovedEntries: ReadonlySet<string> = new Set()toapplyOverrides(apply-overrides.ts:1027), matching the existing convention (removedBulletsis already positional at:1039). The parameter count is pre-existing debt — do not refactor the signature to an options object in this issue.applyAddedEntriesAndBullets(:1094) and beforetoCanonicalResume(:1102), add a singleapplyRemovedEntries(nextParsed, views, removedEntries)pass that, for each key"<section>:<index>":experience,education,projects,heuristic_achievements);views.rawTextandviews.sections, reusing the normalise-and-match-first-line helper the bullet removers share (apply-overrides.ts:489-500,:649-690) rather than writing a second matcher.applyExperienceHeaderOverrides:1069,applyEducationFieldOverrides:1083,applyAchievementOverrides:1086,applyDescriptionOverrides:1091) so no key is rebound, and after the added-entry append (:1094) so the parsed indices those passes used are still the ones in the array.src/hooks/useAnalyzedResume.ts:162-196(argument list and theuseMemodep array —exhaustive-depsis not enforced in this repo, so a missing dep lints green and produces a stale résumé).Step 4 — UI
added &&gate atReconstructedResume.tsx:971(achievements),:755(projects),:651(experience) andReconstructedEducationSkills.tsx:258(education). PassonRemoveunconditionally, resolving the key:added ? added.id : parsedEntryKey(<section>, i).group?.bullets.forEach((b) => onRemoveBullet(b.id))beforeonRemoveEntry(key). Achievements/projects currently receive noonRemoveBulletprop, so thread it in fromReconstructedResume(the sameremoveBulletalready passed to the Experience path at:646).Step 5 — tests
src/hooks/useEditableParse.removed-entry.test.tsx: removing a parsed entry setshasEdits, survives a snapshot →replayround-trip, and a pre-change snapshot (noremovedEntriesfield) replays without throwing.src/lib/edit/apply-overrides.removed-entry.test.ts: the index-rebinding regression — override achievement index 2's title, remove achievement index 0, assert the surviving entry at old-index-2 still carries its own override and not a neighbour's. Same assertion for experience and education.src/components/features/AchievementTypePicker.test.tsx's neighbours (or a newReconstructedResume.remove-parsed-entry.test.tsx) for the rendered affordance.corpus-roundtrip.test.ts/render-roundtrip.repro.test.tsmust stay green).Reuse analysis
Capability: delete an entry from a section of the reconstructed résumé.
Existing surfaces found:
RemoveButton—src/components/features/ReconstructedAdd.tsx(already used by all four sections for added entries).removeBullet/removedBullets—src/hooks/useEditableParse.ts:1078,src/lib/edit/apply-overrides.ts:782. The exact one-level-down analogue of what this issue needs.parsedEntryKey/isAddedEntryKey—src/hooks/useEditableParse.ts:372,:386. The key space already exists.Decision: extend — no new component, no new panel, no new dialog. This adds one state set to an existing hook, one pass to an existing function, and removes four
added &&gates around a primitive that is already rendered in all four sections.Acceptance criteria
sections) and fromrawText/ the "Raw text & flags" disclosure.hasEdits, is captured inEditSnapshot, and is restored byreplay.removedEntriesfield present).resetclearsremovedEntries.pruneEmptyAddedEntries), or any existing edit surface.npm run verifygreen, includingcorpus.test.tsandcorpus-roundtrip.test.ts.Not in scope
ReconstructedResume.tsx:981,:764pass onlybullet={b}— noonBulletChange, no per-rowonRemove), so an individual bullet under an achievement still cannot be edited or removed after this lands. Worth a separate issue; this one delivers entry-level deletion, which is what the report asked for. (Step 4 threadsonRemoveBulletinto those sections for the entry-delete cascade, which makes the follow-up mostly prop-threading.)replaypath covers restore; a dedicated "undo delete" toast is a separate UX question.applyOverrides's 18-parameter positional signature.