Persist the company shortlist a search targets, so it survives the session — and expose it to a companion extension that asks for it.
The gap
useCompanyTargets.ts looks like a watchlist and is not one. Its docblock is explicit about the second of its "two deliberate choices":
SEEDED ONCE, ON MOUNT. parsed is a fresh object on many parent renders, so keying the effect on it would re-classify (and stomp the user's chip edits) on unrelated re-renders.
That choice is correct for what the hook does today — it is scratch state for one search, seeded from the sector guess, edited with chips, handed to searchJobs. But it means the shortlist is in-memory only. Reload the tab and the companies a user hand-picked are gone, replaced by whatever the heuristic classifier suggests for their résumé's sector.
So a user who has decided "these are the eight companies I care about" has to re-decide it every single visit. src/lib/storage/db.ts already persists résumés, jobs, boards, letters and a sync cursor. The one thing that is purely a statement of user intent is the one thing that is not saved.
What already exists
| Piece |
Where |
State |
Curated company registry (CompanyEntry, Ats) |
src/lib/job-search/company-registry.ts |
present |
| Sector-seeded selection + add/remove chips |
src/hooks/useCompanyTargets.ts |
present, ephemeral |
| Board fan-out over selected companies |
src/lib/job-search/company-boards.ts |
present |
IndexedDB with a versioned upgrade() ladder |
src/lib/storage/db.ts — DB_NAME = "offlinecv", DB_VERSION = 4, stores resumes / jobs / boards / letters / sync |
present |
| Same-tab bridge to a companion extension |
src/lib/extension-profile.ts |
present, scoped to a résumé digest |
Nothing here needs inventing. This is a new store on an existing ladder, a writer beside an existing reader, and one more message on an existing bridge.
Scope
1. A persisted watched store
A new object store on the existing db.ts ladder — DB_VERSION 4 → 5, with a matching branch in upgrade(). The module's own docblock states the rule: the upgrade() callback fires once for the range (oldVersion, DB_VERSION], so the new branch must be guarded rather than assumed to run alone.
The record is the shortlist entry a search already understands:
interface WatchedCompany {
id: string; // `${ats}:${slug}`, the natural key
ats: Ats; // "greenhouse" | "lever" | "ashby"
slug: string;
displayName: string;
addedAt: number;
}
id is ${ats}:${slug} rather than a random uuid because the same company on two vendors is two different boards, and the same company added twice is one row. Deriving the key from the pair makes that a storage property instead of a caller's discipline.
2. useCompanyTargets reads it, and the seed becomes a fallback
The hook keeps its seeded-once behaviour only when the store is empty. Once a user has saved a shortlist, that shortlist is what a fresh mount shows — the sector heuristic stops overwriting a decision the user already made.
This is the load-bearing behavioural change, and it must not regress the reason the current design exists: a re-render must still not stomp edits. Reading persisted state once on mount and treating it as the seed preserves that property rather than weakening it.
3. Explicit save, not implicit
Toggling a chip for one search should not silently rewrite the saved shortlist — that would make an exploratory search permanently change the user's targets. A distinct affordance ("save these as my companies" / a per-chip pin) writes to the store. Everything else stays scratch state.
Decide and document which affordance it is in the hook's docblock, in the same style as the two existing deliberate choices. The distinction between "targets for this search" and "companies I am watching" is the whole feature; leaving it implicit is how the two collapse back into one.
4. Expose the shortlist over the extension bridge
src/lib/extension-profile.ts is the existing same-tab postMessage door between this app and an installed companion extension. It is currently scoped to a résumé digest, and its docblock says outright that nothing in it fetches and it reaches no network — a property that must survive.
Add a read-only message for the watched shortlist: the extension asks, the app answers with the stored list. No write path from the extension into this store in this issue — the app owns its own data, and a one-way read is the smallest thing that makes the shortlist useful to a companion without handing an extension a writer.
The bridge's no-network claim is unaffected and must stay asserted. Reading an IndexedDB store and posting it to a same-tab listener touches no network primitive; whatever test pins that property today must still pass unchanged.
What this does not reach, and why it is not a dead end
company-registry.ts already records the boundary, and it is worth restating so nobody scopes this issue as "watch any company":
STRUCTURAL LIMITATION — large self-hosted-careers employers: Apple, Google, Meta, and most other FAANG-scale companies run their own careers site rather than a Greenhouse/Lever/Ashby board... The actual boundary is that their responses carry no Access-Control-Allow-Origin header, so a browser blocks OUR origin from reading them — a CORS rule, not a missing endpoint.
So a saved shortlist here covers the three vendors this app can actually read from a page origin. Self-hosted employers stay on the path company-search-link.ts already provides — linking the user's own browser, with the user's own session, into the employer's own careers search. This issue does not change that split and should not try to.
Acceptance criteria
Out of scope
- Any background or timed polling of the saved boards. This issue persists a list; it does not add a scheduler.
- A write path from the extension into this store.
- Extending
Ats beyond the three vendors, or any attempt to read a self-hosted careers site from this origin — see the CORS boundary above.
- A shared or account-backed shortlist. This app is local-first and has no account; the store is per-browser, like everything else in
db.ts.
- Re-verifying that a saved slug still resolves. Board churn is documented as expected drift; a periodic re-verify pass remains future work.
Persist the company shortlist a search targets, so it survives the session — and expose it to a companion extension that asks for it.
The gap
useCompanyTargets.tslooks like a watchlist and is not one. Its docblock is explicit about the second of its "two deliberate choices":That choice is correct for what the hook does today — it is scratch state for one search, seeded from the sector guess, edited with chips, handed to
searchJobs. But it means the shortlist is in-memory only. Reload the tab and the companies a user hand-picked are gone, replaced by whatever the heuristic classifier suggests for their résumé's sector.So a user who has decided "these are the eight companies I care about" has to re-decide it every single visit.
src/lib/storage/db.tsalready persists résumés, jobs, boards, letters and a sync cursor. The one thing that is purely a statement of user intent is the one thing that is not saved.What already exists
CompanyEntry,Ats)src/lib/job-search/company-registry.tssrc/hooks/useCompanyTargets.tssrc/lib/job-search/company-boards.tsupgrade()laddersrc/lib/storage/db.ts—DB_NAME = "offlinecv",DB_VERSION = 4, storesresumes/jobs/boards/letters/syncsrc/lib/extension-profile.tsNothing here needs inventing. This is a new store on an existing ladder, a writer beside an existing reader, and one more message on an existing bridge.
Scope
1. A persisted
watchedstoreA new object store on the existing
db.tsladder —DB_VERSION4 → 5, with a matching branch inupgrade(). The module's own docblock states the rule: theupgrade()callback fires once for the range(oldVersion, DB_VERSION], so the new branch must be guarded rather than assumed to run alone.The record is the shortlist entry a search already understands:
idis${ats}:${slug}rather than a random uuid because the same company on two vendors is two different boards, and the same company added twice is one row. Deriving the key from the pair makes that a storage property instead of a caller's discipline.2.
useCompanyTargetsreads it, and the seed becomes a fallbackThe hook keeps its seeded-once behaviour only when the store is empty. Once a user has saved a shortlist, that shortlist is what a fresh mount shows — the sector heuristic stops overwriting a decision the user already made.
This is the load-bearing behavioural change, and it must not regress the reason the current design exists: a re-render must still not stomp edits. Reading persisted state once on mount and treating it as the seed preserves that property rather than weakening it.
3. Explicit save, not implicit
Toggling a chip for one search should not silently rewrite the saved shortlist — that would make an exploratory search permanently change the user's targets. A distinct affordance ("save these as my companies" / a per-chip pin) writes to the store. Everything else stays scratch state.
Decide and document which affordance it is in the hook's docblock, in the same style as the two existing deliberate choices. The distinction between "targets for this search" and "companies I am watching" is the whole feature; leaving it implicit is how the two collapse back into one.
4. Expose the shortlist over the extension bridge
src/lib/extension-profile.tsis the existing same-tabpostMessagedoor between this app and an installed companion extension. It is currently scoped to a résumé digest, and its docblock says outright that nothing in it fetches and it reaches no network — a property that must survive.Add a read-only message for the watched shortlist: the extension asks, the app answers with the stored list. No write path from the extension into this store in this issue — the app owns its own data, and a one-way read is the smallest thing that makes the shortlist useful to a companion without handing an extension a writer.
The bridge's no-network claim is unaffected and must stay asserted. Reading an IndexedDB store and posting it to a same-tab listener touches no network primitive; whatever test pins that property today must still pass unchanged.
What this does not reach, and why it is not a dead end
company-registry.tsalready records the boundary, and it is worth restating so nobody scopes this issue as "watch any company":So a saved shortlist here covers the three vendors this app can actually read from a page origin. Self-hosted employers stay on the path
company-search-link.tsalready provides — linking the user's own browser, with the user's own session, into the employer's own careers search. This issue does not change that split and should not try to.Acceptance criteria
DB_VERSIONis 5 andupgrade()carries a guarded branch creating thewatchedstoreuseCompanyTargetsbehaves exactly as it does today — sector-seeded, edits not stomped by re-renderats+slugtwice yields one rowuseCompanyTargets.ts's docblock as a third deliberate choice, with its reasoningOut of scope
Atsbeyond the three vendors, or any attempt to read a self-hosted careers site from this origin — see the CORS boundary above.db.ts.