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
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:
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.tsalready 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_POSTINGSand the description-stripping rule inwriteCachedBoardare 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:
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:
This is observation, not inference about an employer's intent. The copy should describe what we saw and when, and stop there.
Scope
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.Out of scope
makeBoardProviderdeliberately 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