Skip to content

job-search: record what each board fetch saw, and surface posting age, reposts and still-listed on the result card #846

Description

@s-annam

Problem

We search company ATS boards and show what is on them right now. We never show how long it has been there, and that is often the most decision-relevant thing about a posting.

A result card today gives a user no way to tell apart:

  • a req that appeared this morning
  • a req that has been live for four months
  • a req that vanished last week and came back under a new id

Those are very different things to spend an evening tailoring a résumé for, and we already fetch the data that would distinguish them — we just throw it away after each search.

We are already most of the way there

src/lib/job-search/board-cache.ts already keeps one board's light index in IndexedDB under ${ats}:${slug} with a TTL, precisely because a company board has no server-side search and every query pulls the whole board. So on every search we already hold a full, timestamped snapshot of a company's open reqs — and then overwrite it.

The delta is to stop overwriting the fact that we saw it, while continuing to overwrite the cached payload exactly as today.

Proposal

1. An append-only observation log. A small IndexedDB store beside the board cache, one row per (board, posting id, observed_at). Keep it narrow — board key, posting id, normalized title, whether it was present — not a second copy of the posting.

The existing cache and this log answer different questions and should not be merged: the cache is a payload with a TTL and is supposed to be evicted; the log is a sequence whose whole value is that it is never overwritten. MAX_CACHED_POSTINGS and the description-stripping rule in writeCachedBoard are the cache's invariants and stay the cache's alone.

2. Derive three signals from it, in their own module so the panel and any later surface read the same code:

  • First seen / days observed. Bounded below by our own first observation, plus whatever posted date the board itself reported where it gives one.
  • Reposted. Same board and normalized title reappearing under a new posting id, or a posting id that disappeared and returned.
  • Still listed. Present in the most recent fetch of that board, versus seen before and now absent.

3. Surface them on the result card, next to the existing relevance signals.

The honesty constraint, which is most of the design

This log is sparse and user-shaped. It only records boards the user actually searched, only on days the app was open. That has a specific consequence worth writing into the module rather than discovering in a bug report:

Absent is not the same as not-observed. A posting missing from a board we fetched today is evidence. A posting missing because nobody ran a search for six weeks is not evidence of anything. The log therefore has to record the fetch, not only the hits, so a later reader can tell "we looked and it was gone" from "we did not look." Without that distinction "still listed" is not a signal, it is a coin flip with a confident label.

Two rules follow, and they are the acceptance criteria that matter more than the UI:

  • Never render a signal we cannot back with observations. "First seen 41 days ago, reposted twice" is checkable and useful. A bare confidence number over two data points is not, and it invites trust we have not earned.
  • "Not enough history yet" is a legitimate and frequent state, not an error state. For a new install it is the only state, and the panel has to say so plainly rather than defaulting to something that looks like a verdict.

This is observation, not inference about an employer's intent. The copy should describe what we saw and when, and stop there.

Scope

  • New IndexedDB store + reader/writer, following board-cache.ts's discipline: never sink a search. Every failure mode — storage unavailable, blocked upgrade, corrupt row from an older build — degrades to "no history available", and neither function rejects.
  • Recording the fetch scope, not only the hits, per above.
  • Signal derivation as its own module.
  • Result-card presentation, evidence-first.
  • Retention. Roughly (boards searched × reqs per board × searches). Decide a bound and a rollup up front rather than meeting it as a storage-quota failure later; collapsing runs of identical consecutive observations to first-seen/last-seen plus transitions is probably enough, but measure first.

Out of scope

  • Any additional network request. This derives entirely from fetches search already makes. If it needs a new fetch, it is out of scope by definition.
  • Background or scheduled polling. Observations accrue as a side effect of searches the user ran.
  • Publishing or exporting anything about a named employer. This is a local record of what this user's own searches saw, and it stays on their machine like the rest of the library.
  • Ashby history. makeBoardProvider deliberately never caches Ashby — its board is small and monolithic and is re-fetched fresh — so decide explicitly whether it gets observations by another route or is documented as unsupported. Do not silently half-support it.

Acceptance criteria

  • A search records observations for every posting on every board it fetched, including an explicit record that the board was fetched
  • A posting seen previously and absent from a later fetch of the same board is distinguishable from one whose board has not been fetched since
  • First-seen and last-seen for a posting are answerable by query, without application logic
  • A result card shows days observed, repost count and still-listed state, each traceable to specific observation rows
  • Where history is insufficient the card says so, and renders no derived signal
  • No new network request is introduced; the search path's request count is unchanged
  • Storage failures degrade to "no history" and never fail a search
  • Retention behaviour is decided and documented in the module docblock

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew functionalityux:job-searchUX program: job-search relevance, filters, result set

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions