Skip to content

Persist the company shortlist, and expose it read-only over the extension bridge #864

Description

@s-annam

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.tsDB_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

  • DB_VERSION is 5 and upgrade() carries a guarded branch creating the watched store
  • A saved shortlist survives a full page reload
  • With an empty store, useCompanyTargets behaves exactly as it does today — sector-seeded, edits not stomped by re-render
  • With a non-empty store, the saved shortlist is what a fresh mount shows; the sector heuristic does not overwrite it
  • Toggling a chip for one search does not mutate the saved shortlist unless the user takes the explicit save action
  • Adding the same ats + slug twice yields one row
  • The chosen save affordance is documented in useCompanyTargets.ts's docblock as a third deliberate choice, with its reasoning
  • The extension bridge answers a read of the shortlist and offers no write path
  • The bridge's existing no-network assertion still passes unchanged
  • Lint, types and tests green

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureSystem design / coupling / representation decisionsfeatureNew functionality

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions