Skip to content

feat(job-search): a local-only filter and narrowing controls beside the results (#809) - #905

Open
rohithgollapalli wants to merge 1 commit into
mainfrom
feat/809-local-only-refine-strip
Open

feat(job-search): a local-only filter and narrowing controls beside the results (#809)#905
rohithgollapalli wants to merge 1 commit into
mainfrom
feat/809-local-only-refine-strip

Conversation

@rohithgollapalli

Copy link
Copy Markdown
Collaborator

Closes #809.

What the feedback actually hit

Three respondents in the Aug 2026 round reported the same thing: the search returns postings they did not ask for, and nothing they can reach makes it stop. Both halves were true, for different reasons.

Reachability. Every narrowing lever the lane has — role chips, exclude terms, target level — lives inside the four-step query form, and FindJobsPanel folds that whole form to a one-line summary the moment Search is clicked. At the exact moment a user finally has a result set to react to, all of it sits behind an "Edit search" button, in a walk, on a step they have to pick. Nobody found them.

Locality. location was a bounded soft axis and nothing else, so a stated city returned the whole feed, reordered. There was no hard location filter anywhere in the tree.

Freshers. SENIORITY_PATTERNS derives a level from title text, so a candidate with no prior title derives nothing — and the form only reveals LevelSelect once a level was derived. The gate's condition is exactly the person it excludes.

What this does

JobResultRefineStrip renders the three highest-value levers with the results, outside the fold. It is not a second query surface: it edits the same JobQuery through the same setter and reuses the same controls (LevelSelect, ChipListEditor, EditableField, Card, Checkbox), so a chip removed there is gone from the form's Narrow step too. No new primitives.

JobQuery.locationOnly adds the hard arm of the location axis. refineSearchResult drops postings locationMatches rejects — but only because the user armed a visible toggle.

The strip's LevelSelect is ungated, which is the fresher's way in.

What this deliberately does not do

It does not reopen #570 / #716. The default behaviour of every axis is byte-identical. Location, level and comp floor still rank and drop nothing unless a user explicitly says otherwise. The answer to "the search returns everything" is an explicit lever, not a re-inflated implicit boost.

No radius, no geocoding. The predicate is a string comparison over a feed's free-text location field. A distance model needs a geocoder, which is a network call this app does not get to make. Remote postings always pass, so the toggle narrows to "near me OR anywhere", never to "on-site only".

No new remover without a control. refine.ts now has three hard filters — role families, exclude terms, local-only — and all three are armed by something the user can see and clear. That rule is now written down in the lane's CLAUDE.md.

One correction to the issue thread

Option 2 already shipped. The relevance floor is not a resumption — weakMatchThreshold.ts (2.5★) + WeakMatchesSection.tsx landed in #569 and are live: cutoff, count, and a "Show weak matches (N)" disclosure. The comment on #809 saying "there is no fold, threshold, or show-all in JobSearchResults.tsx" is out of date. Nothing about the floor is declined here; there was nothing left to decide.

Separately, the issue body says refineSearchResult hard-filters on "role families, exclude terms, target level, comp floor, and location". Before this PR it applied only role families and exclude terms; level, comp floor and location were passed to rankPostings as soft axes and removed nothing. That is why acceptance criterion 1 was unreachable and why this PR needed a filter rather than only a surfacing change.

Egress

Unchanged. The filter is a local set operation over already-fetched postings; every strip edit re-ranks through refineSearchResult with no new fetch (query.locationOnly joins the live re-rank dep array in useJobSearch). providers/keywords.ts stays the sole resume-derived egress helper — untouched. work-authorization-egress.test.ts and the keyword egress tests are green.

Never-fail-closed

Both new signals follow the floor excludeTerms and the #566 role filter already apply. A filter that would reduce a non-empty set to empty is skipped, the input is kept, and locationSuppressed goes back for a notice — the keyless feeds are inconsistent about populating location at all, and a blank panel the user cannot diagnose is worse than an unfiltered one. locationFilteredOut states what the filter did remove, and unticking the toggle is the recovery.

Acceptance criteria

  • A user who states a role and a location can reach a result set that excludes non-matching postings, in at most one interaction from the results view
  • A candidate with no prior job title can express their level and see it change the ranking
  • Whatever is hidden is stated as a count and is recoverable — never silently dropped
  • Control edits still re-rank through refineSearchResult with no new fetch
  • No change to what leaves the browser; egress tests stay green

Files

File Change
src/lib/job-search/location-match.ts (new) The one location predicate + filterPostingsByLocation
rank.ts Private copies deleted; imports the shared predicate. Rating behaviour unchanged
query-builder.ts locationOnly; withExcludeTerm/withoutExcludeTerm extracted now that two editors write that field
refine.ts Applies the filter last, after role/exclude, so the notice names the right control
search.ts locationSuppressed + locationFilteredOut on JobSearchResult
JobResultRefineStrip.tsx (new, 137 LOC) The strip
FindJobsPanel.tsx Mounts it outside the fold, only on phase.kind === "loaded"
JobSearchResults.tsx Hidden-count line + suppression notice
useJobSearch.ts query.locationOnly in the live re-rank deps
src/lib/job-search/CLAUDE.md New section: soft axes rank, three hard filters remove, all user-armed

Verification

  • tsc -b --noEmit, eslint ., check:nul, check:fixtures, check:baselines — clean
  • Lane suite 677 passed / 0 failed. New: 9 (location-match) + 6 (refine) + 8 (strip) + 4 (results notices)
  • vite build green
  • fallow audit --base origin/main: dead code 0, complexity 0; duplication down 6 lines vs base, remaining groups inherited

Two caveats on the local run, both verified environmental rather than introduced here:

  • src/lib/jd-extract/schema-org*.test.ts fails 4 tests on this machine (USD 1,50,000 vs USD 150,000) — the box's locale is en-IN, so toLocaleString groups in lakhs. Confirmed identical on a clean main checkout. Untouched by this PR; CI's locale should not hit it.
  • npm run verify cannot run end-to-end here (cmd.exe cannot parse the || echo subshell) and check:core dies on tar -tzf C:\… under git-bash. Each gate was run individually instead, and npm run build -w @offlinecv/core builds green.

Provenance

Written with Claude Code (Opus 5).

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploying offlinecv with  Cloudflare Pages  Cloudflare Pages

Latest commit: 65a7bd3
Status: ✅  Deploy successful!
Preview URL: https://22225796.offlinecv.pages.dev
Branch Preview URL: https://feat-809-local-only-refine-s.offlinecv.pages.dev

View logs

…he results (#809)

Three respondents in the Aug 2026 round reported the same thing: the job search
returns postings they did not ask for, and nothing they can reach makes it stop.
Both halves of that were true.

Reachability. Every narrowing lever the lane has — role chips, exclude terms,
target level — lives inside the four-step query form, and `FindJobsPanel` folds
that whole form to a one-line summary the moment Search is clicked. At the exact
moment a user finally has a result set to react to, all of it is behind an "Edit
search" button, in a walk, on a step they have to pick. `JobResultRefineStrip`
now renders the three highest-value levers with the results, outside the fold. It
is not a second query surface: it edits the same `JobQuery` through the same
setter and reuses the same controls, so a chip removed there is gone from the
form too.

Locality. `location` was a bounded soft axis and nothing else, so a stated city
returned the whole feed, reordered. `JobQuery.locationOnly` adds the hard arm:
`refineSearchResult` drops postings `locationMatches` rejects, but only because
the user armed a visible toggle. This does not reopen #570/#716 — the default
behaviour of every axis is unchanged, and the answer to "returns everything" is
an explicit lever, never a re-inflated implicit boost. Remote postings always
pass, so it narrows to "near me OR anywhere", never to "on-site only". The
predicate moved to `location-match.ts` and both readers import it, so the toggle
cannot hide a posting whose own card shows a location match. There is no radius
and no geocoding: a distance model needs a geocoder, which is a network call this
app does not make.

Freshers. `SENIORITY_PATTERNS` derives a level from title text, so a candidate
with no prior title derives nothing — and the form only reveals `LevelSelect`
once a level WAS derived. The gate's condition is exactly the person it excludes.
The strip's copy is ungated.

Both never-fail-closed, the floor `excludeTerms` and the role filter already
apply: a filter that would empty a non-empty set is skipped and flagged for a
notice, because a blank panel the user cannot diagnose is worse than an
unfiltered one. `locationFilteredOut` states what the filter did remove, and
unticking the toggle is the recovery.

No egress change: the filter is a local set operation over already-fetched
postings, it re-ranks through `refineSearchResult` with no new fetch, and
`providers/keywords.ts` stays the sole resume-derived egress helper.

`withExcludeTerm`/`withoutExcludeTerm` are extracted to `query-builder.ts` now
that two editors write that field — the `undefined`-means-`[]` contract should
not be half-remembered in one of them.

`check:core` asserts the `.` entry's value-edge closure exactly, so this moves
that constant from 27 to 28: `rank.ts` takes a value edge on `location-match.ts`
and `rank.ts` is on the barrel. The network-free claim is unaffected —
`location-match.ts` is zero-dep and pure, the closure still names no network
primitive, `./job-search` is unchanged at 11, and the two closures still share
zero modules. The surrounding prose numbers were already stale by more than this
change makes them (the emit is 65 modules with 26 unreachable, not 62/24, and 15
of the unreachable sit behind a value edge, not 13); they are corrected to the
measured values rather than carried forward with a +1 on a wrong base.
@rohithgollapalli
rohithgollapalli force-pushed the feat/809-local-only-refine-strip branch from b6588d7 to 65a7bd3 Compare August 26, 2026 21:04

@Samhit21 Samhit21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: a local-only filter and narrowing controls beside the results (#809)

Summary

Strong PR, and unusually well-reasoned about what it deliberately does not do — keeping level and comp floor as soft axes rather than re-inflating #570/#716 is the right call, and saying so in the lane's CLAUDE.md is better than leaving it as tribal knowledge. The reachability diagnosis is the valuable part: "the levers exist but fold away at the exact moment the user has results to react to" explains the feedback better than the feature request did. location-match.ts correctly collapses two private copies into one predicate so the soft axis and the hard filter cannot disagree on screen, the never-fail-closed floor matches the existing excludeTerms/role precedent, and locationFilteredOut vs locationSuppressed is a genuinely useful distinction — "declined to run" and "ran and removed N" are different things a user needs told differently.

I checked two things I expected to be problems and both were fine, so noting them so nobody re-checks: editing location in the strip cannot desync from the fetched snapshot (no provider reads query.location — it is a purely local knob, and it was already in the live re-rank deps), and the . entry's closure growth is machine-enforced safe (check-core-package.mjs asserts disjointness and networkBearingModules: 0, and check:core is green in CI).

Findings

  1. location-match.ts:55 — the predicate keeps the wrong city under a hard filter. Portland, OR matches Portland, ME; San Jose, CA matches San Jose, Costa Rica; a bare IN matches Austin, TX. Verified by running it on this branch, not inferred. Tolerable when location only nudged a rating; now it is the filter's whole job. The one finding I would want addressed.
  2. JobSearchResults.tsx:214 — both notices quote "only jobs near me" while the checkbox they point at reads "Only jobs near {location}" in exactly the situations the notices fire.
  3. Description accuracy (nit). The Files table lists 10 of the 18 changed files, omitting packages/core/src/index.ts, packages/core/tsconfig.build.json, scripts/check-core-package.mjs and JobQueryEditor.tsx. The first three are where the core package's network-free claim is written down, and this PR moves the . entry's closure from 27 to 28 modules. The claim still holdsnetworkBearingModules stays 0, the closures stay disjoint, CI enforces both — but a reviewer reading "## Egress — Unchanged" has no signal to go look. Worth a line in the body given how much care the rest of the description takes.

Two verification notes from the body worth a second pair of eyes, though neither is a code issue: the en-IN locale failures in schema-org*.test.ts reproduce on clean main (so genuinely environmental), and npm run verify never ran end-to-end locally. CI's verify is green on 65a7bd3, which covers it.

Verdict

Action: COMMENT — nothing merge-blocking. Finding 1 is the one I would want resolved before this ships, since a location filter that returns the wrong Portland is the same class of complaint #809 was filed about.

if (!posting || !query) return false;
const postingCity = posting.split(",")[0].trim();
const queryCity = query.split(",")[0].trim();
return postingCity === queryCity || posting.includes(query) || query.includes(posting);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion]: the city-token comparison ignores the state/country qualifier, so a hard filter keeps the wrong city

I ran the predicate against this branch rather than reasoning about it, because the two-directional includes is hard to eyeball. Results:

query posting locationMatches
Portland, OR Portland, ME true
San Jose, CA San Jose, Costa Rica true
Austin, TX IN true
Norwich, UK OR true
New York, NY York, England false (correct)
Boston, MA Chicago, IL false (correct)

Two distinct causes:

  • postingCity === queryCity drops everything after the first comma, so same-name cities collide. Portland OR/ME, Springfield, Columbus, Kansas City MO/KS, San Jose CA/Costa Rica — these are exactly the pairs a location filter has to get right.
  • The bare bidirectional includes matches substrings that aren't tokens. "austin, tx".includes("in") is why a posting located in Indiana passes an Austin search; "norwich, uk".includes("or") is why Oregon passes Norwich. A feed emitting a bare state code is enough to trigger it.

As a soft ranking axis this was tolerable — a wrong location nudged a star rating. As the hard arm it is now the feature's core promise, and #809's complaint is specifically "the search returns postings I did not ask for". A user in Portland, Oregon who ticks "Only jobs near Portland, OR" still gets Maine.

Rough shape of a fix — compare the qualifier when both sides have one, and make the fallback token-aware rather than substring:

const [postingCity, ...postingRest] = posting.split(",").map((s) => s.trim());
const [queryCity, ...queryRest] = query.split(",").map((s) => s.trim());
if (postingCity !== queryCity) return posting.includes(query) || query.includes(posting); // or a token check
// Same city name: require the qualifiers not to conflict.
return postingRest.length === 0 || queryRest.length === 0
  || postingRest[0] === queryRest[0];

Whatever shape you pick, Portland, OR vs Portland, ME is the case worth a test — it needs no unusual feed data and it is the one a user would report.

{locationFilteredOut > 0 && (
<p className="text-sm text-content-tertiary">
{locationFilteredOut} posting{locationFilteredOut === 1 ? "" : "s"}{" "}
hidden as too far away — untick &ldquo;only jobs near me&rdquo; to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit]: the notice quotes a label the user may not have on screen

Both notices tell the user to untick "only jobs near me", but JobResultRefineStrip's localOnlyLabel renders Only jobs near ${location} whenever a location is set — which is exactly when these notices can fire. So a user looking at a checkbox reading "Only jobs near Austin, TX" is told to untick "only jobs near me".

The strip's own comment on that helper makes the case for why this matters:

The toggle's label … must name the place it filters on so the user can check it against what they typed.

The same argument applies to the copy pointing at it. Passing the location down, or phrasing both notices to reference the control without quoting it ("untick the local-only filter above"), would keep the two in step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Job search returns everything: soft-only ranking gives a user no way to exclude

2 participants