feat(job-search): a local-only filter and narrowing controls beside the results (#809) - #905
feat(job-search): a local-only filter and narrowing controls beside the results (#809)#905rohithgollapalli wants to merge 1 commit into
Conversation
Deploying offlinecv with
|
| 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 |
…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.
b6588d7 to
65a7bd3
Compare
Samhit21
left a comment
There was a problem hiding this comment.
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
location-match.ts:55— the predicate keeps the wrong city under a hard filter.Portland, ORmatchesPortland, ME;San Jose, CAmatchesSan Jose, Costa Rica; a bareINmatchesAustin, 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.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.- 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.mjsandJobQueryEditor.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 holds —networkBearingModulesstays 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); |
There was a problem hiding this comment.
[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 === queryCitydrops 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
includesmatches 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 “only jobs near me” to |
There was a problem hiding this comment.
[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.
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
FindJobsPanelfolds 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.
locationwas 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_PATTERNSderives a level from title text, so a candidate with no prior title derives nothing — and the form only revealsLevelSelectonce a level was derived. The gate's condition is exactly the person it excludes.What this does
JobResultRefineStriprenders the three highest-value levers with the results, outside the fold. It is not a second query surface: it edits the sameJobQuerythrough 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.locationOnlyadds the hard arm of the location axis.refineSearchResultdrops postingslocationMatchesrejects — but only because the user armed a visible toggle.The strip's
LevelSelectis 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
locationfield. 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.tsnow 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'sCLAUDE.md.One correction to the issue thread
Option 2 already shipped. The relevance floor is not a resumption —
weakMatchThreshold.ts(2.5★) +WeakMatchesSection.tsxlanded 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 inJobSearchResults.tsx" is out of date. Nothing about the floor is declined here; there was nothing left to decide.Separately, the issue body says
refineSearchResulthard-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 torankPostingsas 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
refineSearchResultwith no new fetch (query.locationOnlyjoins the live re-rank dep array inuseJobSearch).providers/keywords.tsstays the sole resume-derived egress helper — untouched.work-authorization-egress.test.tsand the keyword egress tests are green.Never-fail-closed
Both new signals follow the floor
excludeTermsand the #566 role filter already apply. A filter that would reduce a non-empty set to empty is skipped, the input is kept, andlocationSuppressedgoes back for a notice — the keyless feeds are inconsistent about populatinglocationat all, and a blank panel the user cannot diagnose is worse than an unfiltered one.locationFilteredOutstates what the filter did remove, and unticking the toggle is the recovery.Acceptance criteria
refineSearchResultwith no new fetchFiles
src/lib/job-search/location-match.ts(new)filterPostingsByLocationrank.tsquery-builder.tslocationOnly;withExcludeTerm/withoutExcludeTermextracted now that two editors write that fieldrefine.tssearch.tslocationSuppressed+locationFilteredOutonJobSearchResultJobResultRefineStrip.tsx(new, 137 LOC)FindJobsPanel.tsxphase.kind === "loaded"JobSearchResults.tsxuseJobSearch.tsquery.locationOnlyin the live re-rank depssrc/lib/job-search/CLAUDE.mdVerification
tsc -b --noEmit,eslint .,check:nul,check:fixtures,check:baselines— cleanlocation-match) + 6 (refine) + 8 (strip) + 4 (results notices)vite buildgreenfallow audit --base origin/main: dead code 0, complexity 0; duplication down 6 lines vs base, remaining groups inheritedTwo caveats on the local run, both verified environmental rather than introduced here:
src/lib/jd-extract/schema-org*.test.tsfails 4 tests on this machine (USD 1,50,000vsUSD 150,000) — the box's locale isen-IN, sotoLocaleStringgroups in lakhs. Confirmed identical on a cleanmaincheckout. Untouched by this PR; CI's locale should not hit it.npm run verifycannot run end-to-end here (cmd.exe cannot parse the|| echosubshell) andcheck:coredies ontar -tzf C:\…under git-bash. Each gate was run individually instead, andnpm run build -w @offlinecv/corebuilds green.Provenance
Written with Claude Code (Opus 5).