From 65a7bd32c765e47c38f517408c53602b1cefeb2b Mon Sep 17 00:00:00 2001
From: rohitgollapalli
Date: Wed, 26 Aug 2026 17:04:13 -0400
Subject: [PATCH 1/2] feat(job-search): a local-only filter and narrowing
controls beside the results (#809)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/core/src/index.ts | 10 +-
packages/core/tsconfig.build.json | 8 +-
scripts/check-core-package.mjs | 6 +-
src/components/features/FindJobsPanel.tsx | 20 ++-
src/components/features/JobQueryEditor.tsx | 9 +-
.../features/JobResultRefineStrip.test.tsx | 164 ++++++++++++++++++
.../features/JobResultRefineStrip.tsx | 137 +++++++++++++++
.../features/JobSearchResults.test.tsx | 40 +++++
src/components/features/JobSearchResults.tsx | 24 ++-
src/hooks/useJobSearch.ts | 15 +-
src/lib/job-search/CLAUDE.md | 21 +++
src/lib/job-search/location-match.test.ts | 82 +++++++++
src/lib/job-search/location-match.ts | 83 +++++++++
src/lib/job-search/query-builder.ts | 41 +++++
src/lib/job-search/rank.ts | 36 +---
src/lib/job-search/refine.test.ts | 101 +++++++++++
src/lib/job-search/refine.ts | 29 +++-
src/lib/job-search/search.ts | 20 +++
18 files changed, 794 insertions(+), 52 deletions(-)
create mode 100644 src/components/features/JobResultRefineStrip.test.tsx
create mode 100644 src/components/features/JobResultRefineStrip.tsx
create mode 100644 src/lib/job-search/location-match.test.ts
create mode 100644 src/lib/job-search/location-match.ts
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 57d39dcb..1e0e11ee 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -46,7 +46,7 @@
* privacy defect — sees a whole file the moment anything imports it, not just
* the one export it used. Routing `computeCoverageFromCorpus` through the barrel
* would drag a network primitive onto that graph for nothing. Measured: the
- * value-edge closure of the specifiers below is 27 modules and reaches no
+ * value-edge closure of the specifiers below is 28 modules and reaches no
* `fetch`/`WebSocket`/`XMLHttpRequest`/`EventSource` at all. That is a statement
* about **this entry's** value-edge closure, and not about the tarball's file
* list, which is larger — see "The tarball ships more files than the graph
@@ -75,7 +75,7 @@
* cannot arrive here by accident. Read `src/job-search.ts`'s own docblock for
* the full argument; the two facts that belong on this side of the seam are:
*
- * - **The two runtime closures are disjoint.** Measured: 27 modules from this
+ * - **The two runtime closures are disjoint.** Measured: 28 modules from this
* entry, 11 from `./job-search`, zero modules in common. Importing one
* cannot pull the other in, in either direction, which is what makes the
* network-free claim above survive the subpath's existence rather than merely
@@ -91,8 +91,8 @@
*
* `tsc` emits a `.js` for every file in the program, the ones reached only by
* `import type` included, and `files` ships all of them. So `npm pack` produces
- * 62 modules, of which 38 are reachable — 27 from this barrel and 11 from
- * `./job-search` — and two of the unreachable 24 read exactly like the thing
+ * 65 modules, of which 39 are reachable — 28 from this barrel and 11 from
+ * `./job-search` — and two of the unreachable 26 read exactly like the thing
* this file says is absent:
*
* - `dist/src/lib/analytics.js` — `import.meta.env`, `await import("posthog-js")`
@@ -111,7 +111,7 @@
* an egress path.
*
* The network-free claim above is therefore about **this entry's** RUNTIME GRAPH
- * rather than about the file list, and on that graph it holds exactly: 27
+ * rather than about the file list, and on that graph it holds exactly: 28
* modules, no network primitive, one bare import (`idb`). Which means grepping
* the tarball for `fetch(` is the wrong audit twice over — it finds the seven
* modules that legitimately fetch on the OTHER entry, plus prose mentions in
diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json
index 4709f180..7a50f446 100644
--- a/packages/core/tsconfig.build.json
+++ b/packages/core/tsconfig.build.json
@@ -24,12 +24,12 @@
"rewriteRelativeImportExtensions": true,
// Load-bearing for the privacy property the barrels' docblocks claim. The
- // program is 62 modules: 38 are value-reachable (27 from `src/index.ts`, 11
+ // program is 65 modules: 39 are value-reachable (28 from `src/index.ts`, 11
// from `src/job-search.ts`, and those two sets are disjoint), and the other
- // 24 are reachable from neither entry.
+ // 26 are reachable from neither entry.
//
- // `import type` edges are how those 24 get into the program, but NOT how all
- // of them got there directly — 13 of the 24 have an ordinary value edge into
+ // `import type` edges are how those 26 get into the program, but NOT how all
+ // of them got there directly — 15 of the 26 have an ordinary value edge into
// them from another module that is itself only type-reachable, so they enter
// BEHIND a type edge rather than through one. `job-search/sector.ts` is the
// clearest case: nothing imports it for a value, but its own
diff --git a/scripts/check-core-package.mjs b/scripts/check-core-package.mjs
index b40b8e92..f9539705 100644
--- a/scripts/check-core-package.mjs
+++ b/scripts/check-core-package.mjs
@@ -197,7 +197,7 @@ const EXPECTED_EXPORTS = {
* `EXPECTED_EXPORTS` above catches a provider SYMBOL moved onto `.`. It cannot
* catch a value EDGE, which is the cheaper mistake by far: one
* `import "…/fetch-jd.ts";` side-effect line in `src/index.ts` leaves the export
- * set byte-identical while taking that closure from 27 modules to 29, putting a
+ * set byte-identical while taking that closure from 28 modules to 30, putting a
* live `fetch(` on it, and making the two closures overlap. At that point every
* network-free claim in `src/index.ts`, `src/job-search.ts` and
* `tsconfig.build.json` is false — in a public repo — and the downstream
@@ -222,7 +222,7 @@ const EXPECTED_EXPORTS = {
* the assertion silently covering two of three surfaces.
*/
const ENTRY_CLOSURES = {
- ".": { modules: 27, networkBearingModules: 0 },
+ ".": { modules: 28, networkBearingModules: 0 },
"./job-search": { modules: 11, networkBearingModules: 7 },
};
@@ -233,7 +233,7 @@ const ENTRY_CLOSURES = {
* same reason `importSpecifiers` does — and here the difference is not
* theoretical but load-bearing on the very first run. `tsc` preserves docblocks
* into the emit verbatim, and the emitted `.` entry contains the sentence "the
- * value-edge closure of the specifiers below is 27 modules and reaches no
+ * value-edge closure of the specifiers below is 28 modules and reaches no
* `fetch`/`WebSocket`/…" — so the obvious `/\b(fetch|…)\s*\(/` sweep reports
* FOUR network primitives in the one file whose whole claim is that it has
* none. A comment is not a node; the parse simply does not see it.
diff --git a/src/components/features/FindJobsPanel.tsx b/src/components/features/FindJobsPanel.tsx
index 13e09224..b21cd3ce 100644
--- a/src/components/features/FindJobsPanel.tsx
+++ b/src/components/features/FindJobsPanel.tsx
@@ -24,7 +24,14 @@
* Everything was reachable and nothing was findable. The steps ARE the
* reading order of the work, and each rail entry states its own current
* value (`describeQuerySteps`), so a closed step is still legible.
- * 2. **Results**, owning the full width.
+ * 2. **The narrowing strip** (#809) — `JobResultRefineStrip`, rendered only
+ * once a search has loaded. The fold above is what made this necessary: it
+ * is right that a form worth the full page width while being filled in is
+ * worth none of it afterwards, but it also put every narrowing lever behind
+ * "Edit search" at the exact moment a user finally has results to react to.
+ * The strip is not a second query surface — it edits this same `query`
+ * through this same `setQuery`.
+ * 3. **Results**, owning the full width.
*
* The whole query folds to a one-line `JobQuerySummary` + Search again on
* submit — the rail included, since a form worth the full page width while
@@ -88,6 +95,7 @@ import type { HeuristicParsedResume } from "../../lib/heuristics/types.ts";
import { JobSearchResults } from "./JobSearchResults.tsx";
import { JobQueryEditor } from "./JobQueryEditor.tsx";
import { JobQuerySummary } from "./JobQuerySummary.tsx";
+import { JobResultRefineStrip } from "./JobResultRefineStrip.tsx";
import { PasteJdPanel } from "./PasteJdPanel.tsx";
import { PendingCompaniesNotice } from "./PendingCompaniesNotice.tsx";
import { useCompanyTargets } from "../../hooks/useCompanyTargets.ts";
@@ -239,6 +247,16 @@ export function FindJobsPanel({
/>
)}
+ {/* The narrowing controls, WITH the results (#809). Only over a real
+ * result set: before the first search there is nothing to narrow, and
+ * over the loading skeleton or the error state the strip would be a
+ * control with no subject. Mounted OUTSIDE the fold on purpose — the
+ * fold is what hid these levers from the three respondents who reported
+ * the search "returns everything". */}
+ {phase.kind === "loaded" && (
+
+ )}
+
diff --git a/src/components/features/JobQueryEditor.tsx b/src/components/features/JobQueryEditor.tsx
index 7a589697..8240a6fb 100644
--- a/src/components/features/JobQueryEditor.tsx
+++ b/src/components/features/JobQueryEditor.tsx
@@ -49,6 +49,8 @@ import { ROLE_HINT } from "../../lib/job-search/query-steps.ts";
import {
canonicalSkillLabels,
parseSeniorityLabel,
+ withExcludeTerm,
+ withoutExcludeTerm,
} from "../../lib/job-search/query-builder.ts";
import { promoteSkill, promoteTitle } from "../../lib/job-search/search-plan.ts";
import type { RoleFamily } from "../../lib/job-search/role-keywords.ts";
@@ -131,12 +133,9 @@ export function JobQueryEditor({
onChange((q) => withSkills(q, q.skills.filter((s) => s !== skill)));
const addExcludeTerm = (term: string) =>
- onChange((q) => ({ ...q, excludeTerms: [...(q.excludeTerms ?? []), term] }));
+ onChange((q) => withExcludeTerm(q, term));
const removeExcludeTerm = (term: string) =>
- onChange((q) => ({
- ...q,
- excludeTerms: (q.excludeTerms ?? []).filter((t) => t !== term),
- }));
+ onChange((q) => withoutExcludeTerm(q, term));
// Role families (#568): REMOVAL only — see RoleFamilyChips' doc for why
// there's no free-text add. Narrowing to an empty list is safe: readers
diff --git a/src/components/features/JobResultRefineStrip.test.tsx b/src/components/features/JobResultRefineStrip.test.tsx
new file mode 100644
index 00000000..2a7d0539
--- /dev/null
+++ b/src/components/features/JobResultRefineStrip.test.tsx
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+// @vitest-environment jsdom
+
+/**
+ * Render + interaction coverage for `JobResultRefineStrip` (#809).
+ *
+ * The three assertions that matter are the three #809 acceptance criteria this
+ * component is responsible for: the local-only toggle exists and writes
+ * `locationOnly`; the level control is present for a query that derived NO
+ * seniority (the fresher case the form's `AddPill` gate hides); and every edit
+ * goes through the caller's single `onChange` — the strip owns no query state
+ * of its own, which is what keeps it from becoming a second query surface.
+ *
+ * Raw createRoot + act, matching `JobSearchResults.test.tsx`.
+ */
+
+import { describe, it, expect, afterEach } from "vitest";
+import { createElement } from "react";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { JobResultRefineStrip } from "./JobResultRefineStrip.tsx";
+import type { JobQuery } from "../../lib/job-search/query-builder.ts";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
+ true;
+
+let container: HTMLDivElement;
+let root: Root;
+
+/** Renders the strip and returns the container plus every query the component
+ * asked for. `onChange` takes an updater, so applying it here is what the real
+ * `FindJobsPanel` `setQuery` does. */
+function render(query: JobQuery) {
+ const seen: JobQuery[] = [];
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ root.render(
+ createElement(JobResultRefineStrip, {
+ query,
+ onChange: (next: (q: JobQuery) => JobQuery) => seen.push(next(query)),
+ }),
+ );
+ });
+ return { el: container, seen };
+}
+
+afterEach(() => {
+ act(() => root?.unmount());
+ container?.remove();
+});
+
+/** The checkbox whose label mentions locality — found by label text, the way a
+ * user finds it, rather than by DOM position. */
+function localOnlyBox(el: HTMLElement): HTMLInputElement {
+ const label = [...el.querySelectorAll("label")].find((l) =>
+ /only jobs near/i.test(l.textContent ?? ""),
+ );
+ const input = label?.querySelector("input[type=checkbox]");
+ if (!input) throw new Error("local-only checkbox not found");
+ return input as HTMLInputElement;
+}
+
+function levelButton(el: HTMLElement, label: string): HTMLButtonElement {
+ const button = [...el.querySelectorAll("button[role=radio]")].find(
+ (b) => b.textContent?.trim() === label,
+ );
+ if (!button) throw new Error(`level "${label}" not found`);
+ return button as HTMLButtonElement;
+}
+
+const baseQuery: JobQuery = { titles: ["Frontend Engineer"], skills: ["React"] };
+
+describe("JobResultRefineStrip (issue 809)", () => {
+ it("names the user's own location in the toggle, so it can be checked", () => {
+ const { el } = render({ ...baseQuery, location: "Austin, TX" });
+ expect(el.textContent).toContain("Only jobs near Austin, TX");
+ });
+
+ it("turning the toggle on sets locationOnly through the caller's onChange", () => {
+ const { el, seen } = render({ ...baseQuery, location: "Austin, TX" });
+ act(() => {
+ localOnlyBox(el).click();
+ });
+ expect(seen).toHaveLength(1);
+ expect(seen[0].locationOnly).toBe(true);
+ // The whole rest of the query is carried through untouched — the strip
+ // replaces the query wholesale, same contract as `JobQueryEditor`.
+ expect(seen[0].titles).toEqual(["Frontend Engineer"]);
+ expect(seen[0].location).toBe("Austin, TX");
+ });
+
+ it("turning it back off clears the flag rather than storing false", () => {
+ const { el, seen } = render({
+ ...baseQuery,
+ location: "Austin, TX",
+ locationOnly: true,
+ });
+ act(() => {
+ localOnlyBox(el).click();
+ });
+ expect(seen[0].locationOnly).toBeUndefined();
+ });
+
+ it("disables the toggle until a location is set, and says why", () => {
+ const { el } = render(baseQuery);
+ expect(localOnlyBox(el).disabled).toBe(true);
+ expect(el.textContent).toContain("Add a location above to turn this on.");
+ });
+
+ it("offers the level control to a query that derived no seniority (the fresher case)", () => {
+ const { el, seen } = render(baseQuery);
+ expect(baseQuery.seniority).toBeUndefined();
+ act(() => {
+ levelButton(el, "Junior").click();
+ });
+ expect(seen[0].seniority).toBe("Junior");
+ });
+
+ it("offers the entry-level rungs, not just the ones a title can derive", () => {
+ const { el } = render(baseQuery);
+ for (const level of ["Intern", "Junior", "Mid"]) {
+ expect(levelButton(el, level)).toBeTruthy();
+ }
+ });
+
+ it("adds an exclude term through the same onChange", () => {
+ const { el, seen } = render({ ...baseQuery, excludeTerms: ["Sales"] });
+ const input = el.querySelector(
+ 'input[aria-label="Add exclude term"]',
+ );
+ if (!input) throw new Error("exclude input not found");
+ // React tracks the DOM node's value, so assigning `.value` directly is
+ // swallowed as a no-op change — go through the prototype setter, same as
+ // `JobQueryEditor.test.tsx`'s `setNativeValue`.
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )!.set!;
+ act(() => {
+ setter.call(input, "Manager");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const add = [...el.querySelectorAll("button")].find(
+ (b) => b.textContent === "Add",
+ );
+ if (!add) throw new Error("Add button not found");
+ act(() => add.click());
+ expect(seen.at(-1)?.excludeTerms).toEqual(["Sales", "Manager"]);
+ });
+
+ it("removes an exclude term through the same onChange", () => {
+ const { el, seen } = render({ ...baseQuery, excludeTerms: ["Sales"] });
+ const remove = [...el.querySelectorAll("button")].find((b) =>
+ /remove/i.test(b.getAttribute("aria-label") ?? ""),
+ );
+ if (!remove) throw new Error("remove control not found");
+ act(() => remove.click());
+ expect(seen.at(-1)?.excludeTerms).toEqual([]);
+ });
+});
diff --git a/src/components/features/JobResultRefineStrip.tsx b/src/components/features/JobResultRefineStrip.tsx
new file mode 100644
index 00000000..8e0ff8fe
--- /dev/null
+++ b/src/components/features/JobResultRefineStrip.tsx
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * JobResultRefineStrip — the narrowing controls, rendered WITH the results
+ * instead of only inside the folded query form (#809).
+ *
+ * WHY IT EXISTS. 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. The controls that would have stopped it already
+ * existed — role chips, exclude terms and target level all live in
+ * `JobQueryEditor`'s steps — but the moment Search is clicked `FindJobsPanel`
+ * folds the whole form to a one-line summary, so at the exact moment a user has
+ * a result set to react to, every narrowing lever is behind an "Edit search"
+ * button, inside a four-step walk, on a step they have to pick. Nobody found
+ * them. This strip puts the three highest-value levers one interaction from the
+ * results view, which is the #809 acceptance criterion stated literally.
+ *
+ * NOT A SECOND SURFACE. It edits the SAME `JobQuery` the form does, through the
+ * same `onChange`, and reuses the same controls (`LevelSelect`,
+ * `ChipListEditor`, `EditableField`) — a chip removed here is gone from the
+ * form's Narrow step too, because there is one query. Every edit re-ranks
+ * through `refineSearchResult` via `useJobSearch`'s live-re-rank effect, so
+ * nothing here fetches and nothing here egresses: `providers/keywords.ts` stays
+ * the sole resume-derived egress helper, untouched by this file.
+ *
+ * WHY THESE THREE. Level answers the fresher case (#809 case 3) — a candidate
+ * with no prior title has nothing for `SENIORITY_PATTERNS` to derive from, and
+ * in the form the level control is hidden behind an `AddPill` that only appears
+ * once a level WAS derived, i.e. never for them. Here it is always visible.
+ * Local-only answers the near-locality case (case 2). Exclude answers the
+ * off-role case (case 1) with the bluntest instrument the lane has. Comp floor
+ * and the company boards are deliberately absent: the floor is soft by design
+ * (#564) so it belongs with the query, and adding a board needs a fetch, which
+ * this strip must never trigger.
+ */
+
+import { Card, Checkbox, EditableField } from "@design-system";
+import {
+ withExcludeTerm,
+ withoutExcludeTerm,
+ type JobQuery,
+} from "../../lib/job-search/query-builder.ts";
+import { ChipListEditor } from "./ChipListEditor.tsx";
+import { LevelSelect } from "./LevelSelect.tsx";
+import { QueryStepSection } from "./QueryStepSection.tsx";
+
+/** The toggle's label, which must name the place it filters on so the user can
+ * check it against what they typed. Kept beside the strip's other copy for the
+ * same reason `query-steps.ts` centralises its own: consequence only. */
+function localOnlyLabel(location: string | undefined): string {
+ return location ? `Only jobs near ${location}` : "Only jobs near me";
+}
+
+const LOCAL_ONLY_HINT =
+ "Remote postings always stay — this hides the ones tied to somewhere else.";
+
+const NO_LOCATION_HINT =
+ "Add a location above to turn this on.";
+
+export function JobResultRefineStrip({
+ query,
+ onChange,
+}: {
+ query: JobQuery;
+ /** Same whole-query replacement contract as `JobQueryEditor` — the panel owns
+ * the state, and both editors write through this one setter. */
+ onChange: (next: (q: JobQuery) => JobQuery) => void;
+}) {
+ const hasLocation = (query.location ?? "").trim().length > 0;
+
+ const addExcludeTerm = (term: string) =>
+ onChange((q) => withExcludeTerm(q, term));
+ const removeExcludeTerm = (term: string) =>
+ onChange((q) => withoutExcludeTerm(q, term));
+
+ return (
+
+
+
+ Location
+ onChange((q) => ({ ...q, location: v || undefined }))}
+ />
+
+ {/* Disabled rather than hidden while no location is set: a control that
+ * vanishes teaches nothing, and the hint names the field to fill in.
+ * `locationOnly` is left as-is when disabled — a user who clears their
+ * location and retypes it gets their toggle back rather than a silent
+ * reset. `refineSearchResult` ignores the flag without a location, so
+ * the retained state cannot filter anything in the meantime. */}
+
+ onChange((q) => ({ ...q, locationOnly: checked || undefined }))
+ }
+ label={localOnlyLabel(query.location)}
+ hint={hasLocation ? LOCAL_ONLY_HINT : NO_LOCATION_HINT}
+ disabled={!hasLocation}
+ />
+
+
+ {/* Always shown, unlike the form's `AddPill`-gated copy — see the
+ * docblock: the gate's condition is exactly the fresher it excludes. */}
+
+ onChange((q) => ({ ...q, seniority }))}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/JobSearchResults.test.tsx b/src/components/features/JobSearchResults.test.tsx
index 74194bba..43394e1a 100644
--- a/src/components/features/JobSearchResults.test.tsx
+++ b/src/components/features/JobSearchResults.test.tsx
@@ -66,6 +66,8 @@ function loaded(
providerCount = 3,
excludeSuppressed = false,
roleSuppressed = false,
+ locationSuppressed = false,
+ locationFilteredOut = 0,
): JobSearchResult {
const jobs = rankPostings(
parsed,
@@ -77,6 +79,8 @@ function loaded(
providerCount,
excludeSuppressed,
roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut,
rawPostings: [],
};
}
@@ -231,6 +235,8 @@ describe("JobSearchResults", () => {
providerCount: 1,
excludeSuppressed: false,
roleSuppressed: false,
+ locationSuppressed: false,
+ locationFilteredOut: 0,
rawPostings: [],
};
const el = render({ kind: "loaded", result });
@@ -265,6 +271,8 @@ describe("JobSearchResults", () => {
providerCount: 1,
excludeSuppressed: false,
roleSuppressed: false,
+ locationSuppressed: false,
+ locationFilteredOut: 0,
rawPostings: [],
};
const el = render({ kind: "loaded", result });
@@ -280,3 +288,35 @@ describe("JobSearchResults", () => {
expect(toggle.textContent).toContain("Hide weak matches (2)");
});
});
+
+describe("JobSearchResults local-only notices (issue 809)", () => {
+ it("states how many postings the local-only filter hid, and how to get them back", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, false, 4),
+ });
+ expect(el.textContent).toContain("4 postings hidden as too far away");
+ expect(el.textContent).toContain("untick");
+ });
+
+ it("says posting, singular, for one", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, false, 1),
+ });
+ expect(el.textContent).toContain("1 posting hidden as too far away");
+ });
+
+ it("says nothing at all when the filter removed nothing", () => {
+ const el = render({ kind: "loaded", result: loaded(2) });
+ expect(el.textContent).not.toContain("hidden as too far away");
+ });
+
+ it("explains a suppressed local-only filter rather than showing an empty page", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, true, 0),
+ });
+ expect(el.textContent).toContain("None of these postings say where they are");
+ });
+});
diff --git a/src/components/features/JobSearchResults.tsx b/src/components/features/JobSearchResults.tsx
index f96dd15b..8fead548 100644
--- a/src/components/features/JobSearchResults.tsx
+++ b/src/components/features/JobSearchResults.tsx
@@ -108,7 +108,15 @@ function Loaded({
onRetry: () => void;
onTailor?: (jdContext: string) => void;
}) {
- const { jobs, degradedProviders, providerCount, excludeSuppressed, roleSuppressed } = result;
+ const {
+ jobs,
+ degradedProviders,
+ providerCount,
+ excludeSuppressed,
+ roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut,
+ } = result;
const [page, setPage] = useState(1);
// Anchor for the scroll-to-top on a page change. A numbered jump replaces the
// whole list under a scroll position that was meaningful for the old page, so
@@ -200,6 +208,20 @@ function Loaded({
and apply role filtering again.
)}
+ {locationFilteredOut > 0 && (
+
+ {locationFilteredOut} posting{locationFilteredOut === 1 ? "" : "s"}{" "}
+ hidden as too far away — untick “only jobs near me” to
+ see {locationFilteredOut === 1 ? "it" : "them"} again.
+
+ )}
+ {locationSuppressed && (
+
+ None of these postings say where they are, so we kept them all
+ rather than showing you an empty page — turn off “only jobs
+ near me” to stop asking, or try a broader location.
+
+ )}
{strong.length > 0 && (
diff --git a/src/hooks/useJobSearch.ts b/src/hooks/useJobSearch.ts
index 26fc1430..cb41b37a 100644
--- a/src/hooks/useJobSearch.ts
+++ b/src/hooks/useJobSearch.ts
@@ -20,7 +20,8 @@
* fetch — that's what lets `FindJobsPanel` re-rank live without breaking
* responsibility 1's invariant. Scoped to exactly the controls #568
* wires (role families, target level, exclude terms, comp floor,
- * location); a titles/skills edit still requires a fresh Search, since
+ * location) plus #809's local-only toggle; a titles/skills edit still
+ * requires a fresh Search, since
* `matchesQuery` already ran against the OLD titles/skills when the
* snapshot was taken.
* 3. The company selection, which is deliberately ASYMMETRIC because the two
@@ -257,7 +258,17 @@ export function useJobSearch(
// this array is deliberately scoped to the five refinement knobs #568
// wires, not titles/skills (see the file docblock) and not `parsed`
// (stable per panel mount — the résumé isn't edited from here).
- }, [query.families, query.excludeTerms, query.seniority, query.compFloor, query.location]);
+ // #809 adds `locationOnly` — a HARD filter rather than an axis, but the
+ // same class of knob: it changes `refineSearchResult`'s output over an
+ // unchanged snapshot, so it re-ranks live with no fetch like the other five.
+ }, [
+ query.families,
+ query.excludeTerms,
+ query.seniority,
+ query.compFloor,
+ query.location,
+ query.locationOnly,
+ ]);
return {
phase,
diff --git a/src/lib/job-search/CLAUDE.md b/src/lib/job-search/CLAUDE.md
index b73413ba..21dc2434 100644
--- a/src/lib/job-search/CLAUDE.md
+++ b/src/lib/job-search/CLAUDE.md
@@ -23,6 +23,27 @@ adds only the lane-specific rules that are silent to break.
- Before adding any `fetch()` here, confirm what leaves. A new adapter that sends more
than its slug breaks epic #528's privacy posture and the root-`CLAUDE.md` custody claim.
+## Soft axes rank; three hard filters remove
+
+Only `refineSearchResult` (`refine.ts`) removes a posting, and only through three
+user-armed filters: role families (#568), exclude terms (#563), and local-only (#809).
+Everything else that sounds like narrowing — target level, comp floor, and location's
+DEFAULT behavior — is a bounded soft axis inside `rankPostings` that reorders and drops
+nothing. That was a deliberate correction (#570 de-boosted location from a sort key,
+#716 bounded the axes), and #809 re-litigating "search returns everything" does **not**
+reopen it: the answer is an explicit lever the user can see, never a re-inflated implicit
+boost. Do not add a fourth remover without a visible control that arms it.
+
+All three share the **never-fail-closed** floor: when a filter would reduce a non-empty
+set to empty, it is skipped, the input is kept, and a `*Suppressed` flag goes back for the
+panel's notice. A blank panel the user cannot diagnose is worse than an unfiltered one.
+
+`location-match.ts` owns the ONE location predicate. `rank.ts` reads it for the soft
+axis, `refine.ts` for the hard filter — so the local-only toggle can never hide a posting
+whose own card shows a location match. It is a string comparison, not geography: no
+radius, no geocoding, because a distance model needs a geocoder and that is a network
+call this app does not make.
+
## Per-vendor adapters duplicate on purpose
Each provider in `providers/` is its own factory with its own inline `mapJob`/post-filter
diff --git a/src/lib/job-search/location-match.test.ts b/src/lib/job-search/location-match.test.ts
new file mode 100644
index 00000000..ebdeafcb
--- /dev/null
+++ b/src/lib/job-search/location-match.test.ts
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+import { describe, it, expect } from "vitest";
+import {
+ filterPostingsByLocation,
+ isRemotePosting,
+ locationMatches,
+} from "./location-match.ts";
+
+/** Minimal structural stub — the filter reads `location` and nothing else. */
+function at(id: string, location: string) {
+ return { id, location };
+}
+
+describe("locationMatches", () => {
+ it("matches on the leading city token, so a feed's longer form still counts", () => {
+ expect(locationMatches("Austin, TX", "Austin, TX, USA")).toBe(true);
+ });
+
+ it("matches either-direction substrings for postings that aren't 'City, ST'", () => {
+ expect(locationMatches("Berlin", "Berlin Office")).toBe(true);
+ expect(locationMatches("Greater Boston", "Boston")).toBe(true);
+ });
+
+ it("rejects a different city", () => {
+ expect(locationMatches("Austin, TX", "Seattle, WA")).toBe(false);
+ });
+
+ it("counts every remote spelling as a match for any query location", () => {
+ for (const remote of ["Remote", "Worldwide", "Anywhere", "WFH"]) {
+ expect(isRemotePosting(remote)).toBe(true);
+ expect(locationMatches("Austin, TX", remote)).toBe(true);
+ }
+ });
+
+ it("treats an unstated posting location as no evidence, not as a match", () => {
+ expect(locationMatches("Austin, TX", "")).toBe(false);
+ expect(locationMatches("Austin, TX", " ")).toBe(false);
+ });
+});
+
+describe("filterPostingsByLocation (issue 809)", () => {
+ it("keeps the whole set when no location is given — the toggle is inert", () => {
+ const postings = [at("a", "Austin, TX"), at("b", "Seattle, WA")];
+ expect(filterPostingsByLocation(postings, undefined)).toEqual({
+ postings,
+ suppressed: false,
+ });
+ expect(filterPostingsByLocation(postings, " ")).toEqual({
+ postings,
+ suppressed: false,
+ });
+ });
+
+ it("drops postings elsewhere and keeps local + remote ones", () => {
+ const result = filterPostingsByLocation(
+ [
+ at("local", "Austin, TX, USA"),
+ at("far", "Seattle, WA"),
+ at("remote", "Remote"),
+ ],
+ "Austin, TX",
+ );
+ expect(result.postings.map((p) => p.id)).toEqual(["local", "remote"]);
+ expect(result.suppressed).toBe(false);
+ });
+
+ it("never fails closed: a set it would empty is kept whole and flagged", () => {
+ const postings = [at("far", "Seattle, WA"), at("unstated", "")];
+ const result = filterPostingsByLocation(postings, "Austin, TX");
+ expect(result.postings.map((p) => p.id)).toEqual(["far", "unstated"]);
+ expect(result.suppressed).toBe(true);
+ });
+
+ it("does not flag suppression for an already-empty input", () => {
+ expect(filterPostingsByLocation([], "Austin, TX")).toEqual({
+ postings: [],
+ suppressed: false,
+ });
+ });
+});
diff --git a/src/lib/job-search/location-match.ts b/src/lib/job-search/location-match.ts
new file mode 100644
index 00000000..efff355c
--- /dev/null
+++ b/src/lib/job-search/location-match.ts
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * The ONE "does this posting sit where the candidate asked" predicate (#809).
+ *
+ * It used to be two private helpers inside `rank.ts`, where location was only
+ * ever a bounded soft axis — a flag feeding the star rating, never a reason to
+ * drop a posting. #809 adds an explicit user-set `locationOnly` mode that HARD
+ * filters on the same question, and a hard filter that disagreed with the soft
+ * axis would be indefensible on screen: a posting the card renders with a
+ * location tick would vanish when the toggle flips, or survive it while the
+ * card says the location doesn't match. So the predicate moved here and both
+ * readers import it — `rank.ts` for `RatingInput.locationMatch`, `refine.ts`
+ * for the filter. Neither owns a second definition.
+ *
+ * The MODEL is a string comparison, not geography: there is no radius, no
+ * geocoding, no distance. "Near me" in the #809 feedback is served by "the
+ * posting names my city, my region, or is remote" — which is what a feed's
+ * free-text `location` field can actually support. Anything finer needs a
+ * geocoder, which is a network call this app does not get to make.
+ *
+ * Zero-dep and pure, so it stays out of the dynamic-import tiers: `refine.ts`
+ * can filter before it has paid for `rank.ts`.
+ */
+
+const REMOTE_PATTERN = /\b(remote|worldwide|anywhere|wfh)\b/i;
+
+/** True for a posting location that reads as remote/location-agnostic — a remote
+ * posting fits any candidate location, so it always counts as a match. */
+export function isRemotePosting(location: string): boolean {
+ return REMOTE_PATTERN.test(location);
+}
+
+/**
+ * True when `postingLocation` should count as a match for `queryLocation`.
+ * Compares the leading city/region token (text before the first comma) so
+ * "Austin, TX" matches a feed's "Austin, TX, USA" without requiring an exact
+ * string match, and falls back to a loose substring check either direction for
+ * postings that don't follow the "City, ST" shape.
+ *
+ * An EMPTY posting location returns false — a feed that told us nothing about
+ * where the job is has not told us it is near you. That is the conservative
+ * read for the rating axis (no evidence, no credit) and, since #809, the reason
+ * `locationOnly` needs its never-fail-closed floor: a feed whose postings all
+ * carry an empty location would otherwise be filtered to nothing.
+ */
+export function locationMatches(queryLocation: string, postingLocation: string): boolean {
+ if (isRemotePosting(postingLocation)) return true;
+ const posting = postingLocation.trim().toLowerCase();
+ const query = queryLocation.trim().toLowerCase();
+ 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);
+}
+
+/**
+ * Keep only the postings that sit at `queryLocation` (or are remote) — the hard
+ * arm of the location axis, applied ONLY when the user turns on `locationOnly`
+ * (#809). Returns the input untouched when there is no location to filter on,
+ * so an unset location is byte-identical to pre-#809 behavior.
+ *
+ * NEVER FAIL CLOSED, the same floor `filterPostingsByExcludeTerms` and the
+ * #566 role filter already apply: when the filter would reduce a NON-EMPTY set
+ * to EMPTY, the input is kept and `suppressed` is set for the panel's notice.
+ * The keyless aggregator feeds skew remote and are inconsistent about filling
+ * `location` at all, so "local only" over a set that named no locations is a
+ * blank screen the user cannot diagnose — the notice points them back at the
+ * toggle instead.
+ */
+export function filterPostingsByLocation(
+ postings: readonly T[],
+ queryLocation: string | undefined,
+): { postings: T[]; suppressed: boolean } {
+ const query = queryLocation?.trim();
+ if (!query) return { postings: [...postings], suppressed: false };
+ const kept = postings.filter((posting) => locationMatches(query, posting.location));
+ if (kept.length === 0 && postings.length > 0) {
+ return { postings: [...postings], suppressed: true };
+ }
+ return { postings: kept, suppressed: false };
+}
diff --git a/src/lib/job-search/query-builder.ts b/src/lib/job-search/query-builder.ts
index af05a3b6..41cdd34f 100644
--- a/src/lib/job-search/query-builder.ts
+++ b/src/lib/job-search/query-builder.ts
@@ -93,6 +93,24 @@ export interface JobQuery {
* to union together. Undefined when the parse has no location and the
* user hasn't typed one. */
location?: string;
+ /** Hard "only jobs at `location`" mode (#809). Off/undefined (the default,
+ * and byte-identical to pre-#809 behavior) leaves location exactly where
+ * #545/#570 put it: a bounded soft axis that edges the ranking and drops
+ * nothing. ON makes `refineSearchResult` DROP every posting that
+ * `locationMatches` rejects — the one place in the lane where location can
+ * remove a result, and only ever because the user armed it.
+ *
+ * Three respondents in the Aug 2026 round asked for this: the ranker's soft
+ * axes meant a stated city returned the whole feed, reordered, with nothing
+ * reachable that made it stop. Deliberately a BOOLEAN, not a radius — the
+ * predicate is a string comparison over a feed's free-text location field
+ * (`location-match.ts`), and a distance model would need a geocoder, which
+ * is a network call this app does not make.
+ *
+ * Inert while `location` is unset: a filter with nothing to filter on keeps
+ * the whole set. Remote postings always pass (they fit any location), so
+ * this narrows to "near me OR anywhere", never to "on-site only". */
+ locationOnly?: boolean;
/** Title-only exclude terms (#563) — a posting is dropped when its TITLE
* (never its description) contains one of these as a case-insensitive
* substring. User-editable chips, same interaction as `titles`/`skills`.
@@ -171,6 +189,29 @@ export interface JobQuery {
titleNoise?: string[];
}
+/**
+ * Add / remove one exclude-term chip, as pure whole-query transforms.
+ *
+ * They live here rather than inline in a component because TWO editors now
+ * write the same field — `JobQueryEditor`'s Narrow step and #809's
+ * `JobResultRefineStrip` beside the results — and an `excludeTerms` handler
+ * copied into the second one is how the `undefined`-means-`[]` contract above
+ * gets half-remembered in one of them. One definition, both callers.
+ *
+ * Neither dedups or trims: `ChipListEditor` already does both before it calls
+ * `onAdd`, and duplicating that here would put the rule in two places too.
+ */
+export function withExcludeTerm(query: JobQuery, term: string): JobQuery {
+ return { ...query, excludeTerms: [...(query.excludeTerms ?? []), term] };
+}
+
+export function withoutExcludeTerm(query: JobQuery, term: string): JobQuery {
+ return {
+ ...query,
+ excludeTerms: (query.excludeTerms ?? []).filter((t) => t !== term),
+ };
+}
+
/**
* Structural subset of `ParsedResume` this module actually reads. The live
* caller (`ResultDetail`) holds a `HeuristicParsedResume`
diff --git a/src/lib/job-search/rank.ts b/src/lib/job-search/rank.ts
index ab29857e..e570db7c 100644
--- a/src/lib/job-search/rank.ts
+++ b/src/lib/job-search/rank.ts
@@ -36,9 +36,13 @@
* specificity factor (#561) discounts a high score resting on few extracted
* terms: a thin vague JD fully covered (100% over 6 terms) yields a smaller
* base than a well-specified JD covered 30/45, so it cannot outrank it.
- * - location (#545) — a MATCH flag, remote always matching. Feeds a bounded
- * minor axis in `rateJobs`; a non-local strong fit is never dropped, only
- * edged by an equal-fit local one. No longer a flat sort-key boost (#570).
+ * - location (#545) — a MATCH flag, remote always matching, read from the
+ * shared `location-match.ts` predicate. Feeds a bounded minor axis in
+ * `rateJobs`; a non-local strong fit is never dropped HERE, only edged by
+ * an equal-fit local one. No longer a flat sort-key boost (#570). Dropping
+ * is #809's separate, explicitly user-armed `locationOnly` filter in
+ * `refine.ts` — it reads the SAME predicate, so the toggle can never hide a
+ * posting whose card shows a location match.
* - seniority (#562) — the ladder-rung DISTANCE between the query's derived
* level and the posting title's level, or null when there is no comparison
* (no query seniority, or an unrecognized title level). Feeds a minor axis;
@@ -64,6 +68,7 @@ import type { JobQuery } from "./query-builder.ts";
import { parseSeniorityLabel } from "./query-builder.ts";
import { seniorityRung } from "./seniority.ts";
import { extractCompensation, isBelowFloor, annualizedTop } from "./compensation.ts";
+import { locationMatches } from "./location-match.ts";
import { rateJobs, type JobRating, type RatingInput } from "./rating.ts";
/** The keyword arm of `JdMatchResult` — the only shape produced here. */
@@ -118,31 +123,6 @@ function specificityConfidence(termCount: number): number {
return termCount / (termCount + SPECIFICITY_HALF_SATURATION);
}
-const REMOTE_PATTERN = /\b(remote|worldwide|anywhere|wfh)\b/i;
-
-/** True for a posting location that reads as remote/location-agnostic — a remote
- * posting fits any candidate location, so it always counts as a match. */
-function isRemotePosting(location: string): boolean {
- return REMOTE_PATTERN.test(location);
-}
-
-/**
- * True when `postingLocation` should count as a match for `queryLocation`.
- * Compares the leading city/region token (text before the first comma) so
- * "Austin, TX" matches a feed's "Austin, TX, USA" without requiring an exact
- * string match, and falls back to a loose substring check either direction for
- * postings that don't follow the "City, ST" shape.
- */
-function locationMatches(queryLocation: string, postingLocation: string): boolean {
- if (isRemotePosting(postingLocation)) return true;
- const posting = postingLocation.trim().toLowerCase();
- const query = queryLocation.trim().toLowerCase();
- 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);
-}
-
/**
* The ladder-rung DISTANCE between the query's seniority rung and the level
* parsed out of a posting title (#562), or null when there is no comparison to
diff --git a/src/lib/job-search/refine.test.ts b/src/lib/job-search/refine.test.ts
index 2b092540..90cd84e7 100644
--- a/src/lib/job-search/refine.test.ts
+++ b/src/lib/job-search/refine.test.ts
@@ -108,3 +108,104 @@ describe("refineSearchResult (issue 568)", () => {
expect(result.providerCount).toBe(2);
});
});
+
+describe("refineSearchResult — local-only (issue 809)", () => {
+ const raw = [
+ posting({ id: "local", title: "Frontend Engineer", location: "Austin, TX, USA" }),
+ posting({ id: "far", title: "Frontend Engineer", location: "Seattle, WA" }),
+ posting({ id: "remote", title: "Frontend Engineer", location: "Remote" }),
+ ];
+
+ it("changes nothing while the toggle is off — location stays a soft axis", async () => {
+ const result = await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, location: "Austin, TX" },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id).sort()).toEqual([
+ "far",
+ "local",
+ "remote",
+ ]);
+ expect(result.locationSuppressed).toBe(false);
+ expect(result.locationFilteredOut).toBe(0);
+ });
+
+ it("drops non-local postings once the user turns it on, keeping remote", async () => {
+ const result = await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id).sort()).toEqual(["local", "remote"]);
+ expect(result.locationFilteredOut).toBe(1);
+ expect(result.locationSuppressed).toBe(false);
+ });
+
+ it("is inert with no location set, however the toggle reads", async () => {
+ const result = await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs).toHaveLength(3);
+ expect(result.locationFilteredOut).toBe(0);
+ });
+
+ it("never fails closed: a set it would empty is kept whole and flagged", async () => {
+ const elsewhere = [
+ posting({ id: "far", location: "Seattle, WA" }),
+ posting({ id: "unstated", location: "" }),
+ ];
+ const result = await refineSearchResult(
+ elsewhere,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs).toHaveLength(2);
+ expect(result.locationSuppressed).toBe(true);
+ expect(result.locationFilteredOut).toBe(0);
+ });
+
+ it("counts only what IT removed, not what the exclude filter already took", async () => {
+ const mixed = [
+ posting({ id: "local", location: "Austin, TX" }),
+ posting({ id: "far", location: "Seattle, WA" }),
+ posting({ id: "excluded", title: "Sales Engineer", location: "Austin, TX" }),
+ ];
+ const result = await refineSearchResult(
+ mixed,
+ parsed,
+ {
+ ...query,
+ location: "Austin, TX",
+ locationOnly: true,
+ excludeTerms: ["Sales"],
+ },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id)).toEqual(["local"]);
+ expect(result.locationFilteredOut).toBe(1);
+ });
+
+ it("still egresses nothing — the filter is a pure local set operation", async () => {
+ const before = raw.map((p) => ({ ...p }));
+ await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(raw).toEqual(before);
+ });
+});
diff --git a/src/lib/job-search/refine.ts b/src/lib/job-search/refine.ts
index ba4defe7..afbd66aa 100644
--- a/src/lib/job-search/refine.ts
+++ b/src/lib/job-search/refine.ts
@@ -3,8 +3,16 @@
/**
* refineSearchResult — apply the query's LOCAL refinement knobs (role
- * families #568, exclude terms #563) and rank (#545/#561/#562/#564) over an
- * already-fetched, already-deduped posting set.
+ * families #568, exclude terms #563, local-only #809) and rank
+ * (#545/#561/#562/#564) over an already-fetched, already-deduped posting set.
+ *
+ * The three hard filters here are the ONLY things in the lane that remove a
+ * posting, and all three are user-armed: chips the user can see and clear.
+ * Everything else about narrowing — level, comp floor, and location's default
+ * behavior — is a bounded soft axis inside `rankPostings` that reorders and
+ * drops nothing (#570/#716). Keep it that way: #809's fix for "the search
+ * returns everything" is giving the user an explicit lever, not re-inflating an
+ * implicit boost.
*
* Pulled out of `searchJobs` (`search.ts`) so `FindJobsPanel` can re-run the
* SAME pipeline on every edit to a refinement control (role family, target
@@ -30,6 +38,7 @@ import {
filterPostingsByExcludeTerms,
roleFilterForFamilies,
} from "./role-keywords.ts";
+import { filterPostingsByLocation } from "./location-match.ts";
import type { JobSearchResult } from "./search.ts";
export async function refineSearchResult(
@@ -75,15 +84,29 @@ export async function refineSearchResult(
roleFiltered = [...rawPostings];
}
- const { postings: filtered, suppressed: excludeSuppressed } =
+ const { postings: excludeFiltered, suppressed: excludeSuppressed } =
filterPostingsByExcludeTerms(roleFiltered, query.excludeTerms);
+ // Local-only (#809): the hard arm of the location axis, applied LAST so its
+ // never-fail-closed check reads the set the user will actually see — running
+ // it before the role/exclude filters could keep a location that those then
+ // empty anyway, and the notice would name the wrong control. Skipped entirely
+ // unless the user turned the toggle on AND a location is set; the soft axis
+ // in `rankPostings` is unchanged either way.
+ const { postings: filtered, suppressed: locationSuppressed } =
+ filterPostingsByLocation(
+ excludeFiltered,
+ query.locationOnly ? query.location : undefined,
+ );
+
return {
jobs: rankPostings(parsed, filtered, query),
degradedProviders: [...degradedProviders],
providerCount,
excludeSuppressed,
roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut: excludeFiltered.length - filtered.length,
rawPostings: [...rawPostings],
};
}
diff --git a/src/lib/job-search/search.ts b/src/lib/job-search/search.ts
index 4a587162..32add878 100644
--- a/src/lib/job-search/search.ts
+++ b/src/lib/job-search/search.ts
@@ -109,6 +109,26 @@ export interface JobSearchResult {
* panel surfaces this as a notice pointing at the Role chips rather than
* showing a misleading empty state. */
roleSuppressed: boolean;
+ /** True when `locationOnly` (#809) would have emptied the WHOLE filtered
+ * result set — the local-only filter was skipped (never-fail-closed, the
+ * same floor as `excludeSuppressed`/`roleSuppressed`) and every posting
+ * below is un-location-filtered. The keyless aggregator feeds are
+ * inconsistent about populating a posting's `location` at all, so a set that
+ * named no locations would otherwise blank the panel with no way to tell
+ * "nothing near you" from "the feed didn't say". The panel surfaces this as
+ * a notice pointing at the local-only toggle. Always false when the toggle
+ * is off or no location is set. */
+ locationSuppressed: boolean;
+ /** How many postings the local-only filter (#809) actually removed. Zero
+ * whenever the toggle is off, no location is set, or the filter was
+ * suppressed. Distinct from `locationSuppressed`, which reports the filter
+ * DECLINING to run: this is the count when it did run and dropped things.
+ * Rendered as a line beside the match count — #809 requires that whatever is
+ * hidden is stated as a count and recoverable, and unticking the toggle is
+ * the recovery. The role/exclude filters state no such count; they predate
+ * the requirement, and adding it for them is a separate change to their own
+ * copy, not something to smuggle in here. */
+ locationFilteredOut: number;
/** The deduped, `matchesQuery`-filtered postings BEFORE role/exclude
* filtering and ranking (#568) — everything `refineSearchResult` needs to
* redo that local work. `FindJobsPanel` keeps this snapshot from the last
From 8b52a38ccea045bb5fbff0ecb929ba36f3dbdc92 Mon Sep 17 00:00:00 2001
From: rohitgollapalli
Date: Wed, 2 Sep 2026 13:35:56 -0400
Subject: [PATCH 2/2] fix(job-search): address review comments on PR #905
---
src/components/features/FindJobsPanel.tsx | 17 ++--
src/components/features/JobQueryEditor.tsx | 35 ++-----
.../features/JobResultRefineStrip.tsx | 49 +++-------
src/components/features/JobSearchNotices.tsx | 85 +++++++++++++++++
.../features/JobSearchResults.test.tsx | 14 ++-
src/components/features/JobSearchResults.tsx | 46 +--------
src/components/features/LevelSelect.tsx | 10 +-
src/components/features/QueryFilterFields.tsx | 73 ++++++++++++++
src/lib/job-search/CLAUDE.md | 7 ++
src/lib/job-search/location-match.test.ts | 34 ++++++-
src/lib/job-search/location-match.ts | 95 +++++++++++++++----
src/lib/job-search/refine.test.ts | 20 +++-
12 files changed, 349 insertions(+), 136 deletions(-)
create mode 100644 src/components/features/JobSearchNotices.tsx
create mode 100644 src/components/features/QueryFilterFields.tsx
diff --git a/src/components/features/FindJobsPanel.tsx b/src/components/features/FindJobsPanel.tsx
index b21cd3ce..beb48119 100644
--- a/src/components/features/FindJobsPanel.tsx
+++ b/src/components/features/FindJobsPanel.tsx
@@ -247,13 +247,16 @@ export function FindJobsPanel({
/>
)}
- {/* The narrowing controls, WITH the results (#809). Only over a real
- * result set: before the first search there is nothing to narrow, and
- * over the loading skeleton or the error state the strip would be a
- * control with no subject. Mounted OUTSIDE the fold on purpose — the
- * fold is what hid these levers from the three respondents who reported
- * the search "returns everything". */}
- {phase.kind === "loaded" && (
+ {/* The narrowing controls, WITH the results (#809). Gated on a non-empty
+ * ranked set, not merely on `kind === "loaded"`: `searchJobs` never
+ * rejects, so a total provider failure and a zero-match search BOTH
+ * arrive as `loaded` and render `JobSearchResults`' error states — under
+ * which the strip would be a control with no subject, and the zero-match
+ * copy tells the user to BROADEN while narrowing controls sit above it
+ * (#905 review). Mounted OUTSIDE the fold on purpose — the fold is what
+ * hid these levers from the three respondents who reported the search
+ * "returns everything". */}
+ {phase.kind === "loaded" && phase.result.jobs.length > 0 && (
)}
diff --git a/src/components/features/JobQueryEditor.tsx b/src/components/features/JobQueryEditor.tsx
index 8240a6fb..55b1210c 100644
--- a/src/components/features/JobQueryEditor.tsx
+++ b/src/components/features/JobQueryEditor.tsx
@@ -43,14 +43,12 @@
*/
import { useState } from "react";
-import { EditableField, StepPanel } from "@design-system";
+import { StepPanel } from "@design-system";
import type { JobQuery } from "../../lib/job-search/query-builder.ts";
import { ROLE_HINT } from "../../lib/job-search/query-steps.ts";
import {
canonicalSkillLabels,
parseSeniorityLabel,
- withExcludeTerm,
- withoutExcludeTerm,
} from "../../lib/job-search/query-builder.ts";
import { promoteSkill, promoteTitle } from "../../lib/job-search/search-plan.ts";
import type { RoleFamily } from "../../lib/job-search/role-keywords.ts";
@@ -59,6 +57,11 @@ import type { JobBoardLink } from "../../lib/job-search/deep-links.ts";
import type { CompanyTargets as CompanyTargetsState } from "../../hooks/useCompanyTargets.ts";
import { ChipListEditor } from "./ChipListEditor.tsx";
import { CompanyTargets } from "./CompanyTargets.tsx";
+import {
+ EXCLUDE_TERMS_HINT,
+ ExcludeTermsEditor,
+ LocationField,
+} from "./QueryFilterFields.tsx";
import { CompFloorInput } from "./CompFloorInput.tsx";
import { ExternalBoardLinks } from "./ExternalBoardLinks.tsx";
import { RoleFamilyChips } from "./RoleFamilyChips.tsx";
@@ -132,10 +135,6 @@ export function JobQueryEditor({
const removeSkill = (skill: string) =>
onChange((q) => withSkills(q, q.skills.filter((s) => s !== skill)));
- const addExcludeTerm = (term: string) =>
- onChange((q) => withExcludeTerm(q, term));
- const removeExcludeTerm = (term: string) =>
- onChange((q) => withoutExcludeTerm(q, term));
// Role families (#568): REMOVAL only — see RoleFamilyChips' doc for why
// there's no free-text add. Narrowing to an empty list is safe: readers
@@ -279,12 +278,7 @@ export function JobQueryEditor({
* behavior at all. */}
- onChange((q) => ({ ...q, location: v || undefined }))}
- />
+
@@ -292,19 +286,8 @@ export function JobQueryEditor({
* contains one of these, never when only its description does.
* Removable chips may already be seeded from the role-family
* classification (e.g. GTM/field roles for an engineering search). */}
-
-
+
+
diff --git a/src/components/features/JobResultRefineStrip.tsx b/src/components/features/JobResultRefineStrip.tsx
index 8e0ff8fe..a43a9ea4 100644
--- a/src/components/features/JobResultRefineStrip.tsx
+++ b/src/components/features/JobResultRefineStrip.tsx
@@ -17,9 +17,11 @@
* results view, which is the #809 acceptance criterion stated literally.
*
* NOT A SECOND SURFACE. It edits the SAME `JobQuery` the form does, through the
- * same `onChange`, and reuses the same controls (`LevelSelect`,
- * `ChipListEditor`, `EditableField`) — a chip removed here is gone from the
- * form's Narrow step too, because there is one query. Every edit re-ranks
+ * same `onChange`, and renders the SAME controls the form does — `LevelSelect`
+ * and, since the #905 review, `LocationField`/`ExcludeTermsEditor` out of
+ * `QueryFilterFields.tsx`, so the exclude hint has one definition rather than a
+ * copy per surface. A chip removed here is gone from the form's Narrow step
+ * too, because there is one query. Every edit re-ranks
* through `refineSearchResult` via `useJobSearch`'s live-re-rank effect, so
* nothing here fetches and nothing here egresses: `providers/keywords.ts` stays
* the sole resume-derived egress helper, untouched by this file.
@@ -35,14 +37,14 @@
* this strip must never trigger.
*/
-import { Card, Checkbox, EditableField } from "@design-system";
-import {
- withExcludeTerm,
- withoutExcludeTerm,
- type JobQuery,
-} from "../../lib/job-search/query-builder.ts";
-import { ChipListEditor } from "./ChipListEditor.tsx";
+import { Card, Checkbox } from "@design-system";
+import type { JobQuery } from "../../lib/job-search/query-builder.ts";
import { LevelSelect } from "./LevelSelect.tsx";
+import {
+ EXCLUDE_TERMS_HINT,
+ ExcludeTermsEditor,
+ LocationField,
+} from "./QueryFilterFields.tsx";
import { QueryStepSection } from "./QueryStepSection.tsx";
/** The toggle's label, which must name the place it filters on so the user can
@@ -69,11 +71,6 @@ export function JobResultRefineStrip({
}) {
const hasLocation = (query.location ?? "").trim().length > 0;
- const addExcludeTerm = (term: string) =>
- onChange((q) => withExcludeTerm(q, term));
- const removeExcludeTerm = (term: string) =>
- onChange((q) => withoutExcludeTerm(q, term));
-
return (
Location
- onChange((q) => ({ ...q, location: v || undefined }))}
- />
+
{/* Disabled rather than hidden while no location is set: a control that
* vanishes teaches nothing, and the hint names the field to fill in.
@@ -118,19 +110,8 @@ export function JobResultRefineStrip({
/>
-
-
+
+
);
diff --git a/src/components/features/JobSearchNotices.tsx b/src/components/features/JobSearchNotices.tsx
new file mode 100644
index 00000000..c5bc14ca
--- /dev/null
+++ b/src/components/features/JobSearchNotices.tsx
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * JobSearchNotices — the "what the lane did to your result set, and how to undo
+ * it" paragraph stack that sits above the ranked cards.
+ *
+ * Extracted out of `JobSearchResults`'s `Loaded` (#905 review): each of the
+ * lane's three hard filters (#568 role, #563 exclude, #809 local-only) can be
+ * SKIPPED by the never-fail-closed floor or can REMOVE postings, and every one
+ * of those outcomes owes the user a sentence naming the control that caused it.
+ * That is five branches of pure copy, which had grown `Loaded` into the largest
+ * function in the file; none of it reads any of `Loaded`'s state.
+ *
+ * COPY RULE: name the control the way the control names ITSELF. The local-only
+ * checkbox is `Only jobs near {location}` whenever a location is set — and it
+ * cannot filter without one — so no notice here may quote the location-less
+ * "only jobs near me" spelling; it refers to the filter by role instead ("the
+ * local-only filter above"), which stays true whichever label is rendered.
+ */
+
+import type { JobSearchResult } from "../../lib/job-search/search.ts";
+
+type NoticeFlags = Pick<
+ JobSearchResult,
+ | "degradedProviders"
+ | "excludeSuppressed"
+ | "roleSuppressed"
+ | "locationSuppressed"
+ | "locationFilteredOut"
+>;
+
+function Notice({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+export function JobSearchNotices({
+ degradedProviders,
+ excludeSuppressed,
+ roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut,
+}: NoticeFlags) {
+ return (
+ <>
+ {degradedProviders.length > 0 && (
+
+ Couldn't reach {degradedProviders.join(", ")} — showing results
+ from the other feeds.
+
+ )}
+ {excludeSuppressed && (
+
+ Your exclude terms would have removed every match, so we skipped them
+ for this search — open Edit search to remove or narrow a term and
+ apply exclusion again.
+
+ )}
+ {roleSuppressed && (
+
+ Role filter skipped — it would have hidden every result, so we kept
+ them all for this search. Open Edit search to adjust the Role chips
+ and apply role filtering again.
+
+ )}
+ {locationFilteredOut > 0 && (
+
+ {locationFilteredOut} posting{locationFilteredOut === 1 ? "" : "s"}{" "}
+ hidden as too far away — untick the local-only filter above to see{" "}
+ {locationFilteredOut === 1 ? "it" : "them"} again.
+
+ )}
+ {/* Deliberately does NOT say the postings stated no location: the floor
+ * fires whenever the filter would empty a non-empty set, and the common
+ * cause is a set that stated locations, all elsewhere. */}
+ {locationSuppressed && (
+
+ Local-only filter skipped — it would have hidden every result, so we
+ kept them all for this search. Untick the local-only filter above, or
+ try a broader location.
+
+ )}
+ >
+ );
+}
diff --git a/src/components/features/JobSearchResults.test.tsx b/src/components/features/JobSearchResults.test.tsx
index 43394e1a..4b066fb9 100644
--- a/src/components/features/JobSearchResults.test.tsx
+++ b/src/components/features/JobSearchResults.test.tsx
@@ -317,6 +317,18 @@ describe("JobSearchResults local-only notices (issue 809)", () => {
kind: "loaded",
result: loaded(2, [], 3, false, false, true, 0),
});
- expect(el.textContent).toContain("None of these postings say where they are");
+ expect(el.textContent).toContain("Local-only filter skipped");
+ // The floor fires for any reason the filter would empty the set, so the
+ // copy must not blame the postings for stating no location (#905 review).
+ expect(el.textContent).not.toContain("None of these postings say where they are");
+ });
+
+ it("names the local-only filter by role, never by its location-less label", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, true, 4),
+ });
+ expect(el.textContent).toContain("untick the local-only filter above");
+ expect(el.textContent?.toLowerCase()).not.toContain("only jobs near me");
});
});
diff --git a/src/components/features/JobSearchResults.tsx b/src/components/features/JobSearchResults.tsx
index 8fead548..233f3ce8 100644
--- a/src/components/features/JobSearchResults.tsx
+++ b/src/components/features/JobSearchResults.tsx
@@ -27,6 +27,7 @@
import { useEffect, useRef, useState } from "react";
import { Button, ErrorState, Pagination, StatusBadge } from "@design-system";
import { JobResultCard } from "./JobResultCard.tsx";
+import { JobSearchNotices } from "./JobSearchNotices.tsx";
import { WeakMatchesSection } from "./WeakMatchesSection.tsx";
import { isWeakMatch } from "./weakMatchThreshold.ts";
import type { JobSearchResult } from "../../lib/job-search/search.ts";
@@ -108,15 +109,7 @@ function Loaded({
onRetry: () => void;
onTailor?: (jdContext: string) => void;
}) {
- const {
- jobs,
- degradedProviders,
- providerCount,
- excludeSuppressed,
- roleSuppressed,
- locationSuppressed,
- locationFilteredOut,
- } = result;
+ const { jobs, degradedProviders, providerCount } = result;
const [page, setPage] = useState(1);
// Anchor for the scroll-to-top on a page change. A numbered jump replaces the
// whole list under a scroll position that was meaningful for the old page, so
@@ -188,40 +181,7 @@ function Loaded({
{SAMPLE_LABEL}
- {degradedProviders.length > 0 && (
-
- Couldn't reach {degradedProviders.join(", ")} — showing results
- from the other feeds.
-
- )}
- {excludeSuppressed && (
-
- Your exclude terms would have removed every match, so we skipped
- them for this search — open Edit search to remove or narrow a term
- and apply exclusion again.
-
- )}
- {roleSuppressed && (
-
- Role filter skipped — it would have hidden every result, so we kept
- them all for this search. Open Edit search to adjust the Role chips
- and apply role filtering again.
-
- )}
- {locationFilteredOut > 0 && (
-
- {locationFilteredOut} posting{locationFilteredOut === 1 ? "" : "s"}{" "}
- hidden as too far away — untick “only jobs near me” to
- see {locationFilteredOut === 1 ? "it" : "them"} again.
-
- )}
- {locationSuppressed && (
-
- None of these postings say where they are, so we kept them all
- rather than showing you an empty page — turn off “only jobs
- near me” to stop asking, or try a broader location.
-
- )}
+
{strong.length > 0 && (
diff --git a/src/components/features/LevelSelect.tsx b/src/components/features/LevelSelect.tsx
index d170a7e1..5d77de14 100644
--- a/src/components/features/LevelSelect.tsx
+++ b/src/components/features/LevelSelect.tsx
@@ -24,6 +24,7 @@
* so clearing is discoverable two ways, not just via the toggle.
*/
+import { useId } from "react";
import { Button } from "@design-system";
import { SENIORITY_LADDER } from "../../lib/job-search/seniority.ts";
@@ -40,12 +41,17 @@ interface LevelSelectProps {
}
export function LevelSelect({ value, onChange }: LevelSelectProps) {
+ // Minted per instance, not hardcoded: since #809 two LevelSelects can be
+ // mounted at once — the results strip and the query form's Narrow step, which
+ // `StepPanel` keeps mounted while inactive — and a shared literal id would
+ // point the second radiogroup's `aria-labelledby` at the first one's node.
+ const labelId = useId();
return (
{/* Named visibly by the caller's section heading (#602); kept here so
the radiogroup still has an accessible name. */}
-
+
Target level
{value !== undefined && (
@@ -56,7 +62,7 @@ export function LevelSelect({ value, onChange }: LevelSelectProps) {
{LEVELS.map((level) => {
diff --git a/src/components/features/QueryFilterFields.tsx b/src/components/features/QueryFilterFields.tsx
new file mode 100644
index 00000000..a30f2113
--- /dev/null
+++ b/src/components/features/QueryFilterFields.tsx
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * The two `JobQuery` fields that BOTH query editors render — the query form's
+ * Narrow step (`JobQueryEditor`) and the results-side strip
+ * (`JobResultRefineStrip`, #809).
+ *
+ * Extracted in the #905 review for the reason `withExcludeTerm` was extracted
+ * one round earlier: once two surfaces write the same field, the rule for
+ * writing it needs one definition. The exclude hint is the sharp end of that —
+ * it states a real behavioural contract (title only, never the description),
+ * so a copy of it that drifts from `filterPostingsByExcludeTerms` is a lie on
+ * one of the two screens with nothing to catch it.
+ *
+ * These render the CONTROL only, never a `QueryStepSection` wrapper or a
+ * layout — the two callers legitimately differ there (the form has a section
+ * heading, the strip has an inline label beside the field), and hoisting that
+ * would force one surface's layout onto the other.
+ */
+
+import { EditableField } from "@design-system";
+import {
+ withExcludeTerm,
+ withoutExcludeTerm,
+ type JobQuery,
+} from "../../lib/job-search/query-builder.ts";
+import { ChipListEditor } from "./ChipListEditor.tsx";
+
+/** Whole-query replacement, the contract both editors already use — the panel
+ * owns the state and every control writes through this one setter. */
+type QueryChange = (next: (q: JobQuery) => JobQuery) => void;
+
+/** The one statement of what exclusion actually does. Shown by both editors. */
+export const EXCLUDE_TERMS_HINT =
+ "A posting is dropped when its title contains one of these — its description is not checked.";
+
+export function LocationField({
+ query,
+ onChange,
+}: {
+ query: JobQuery;
+ onChange: QueryChange;
+}) {
+ return (
+ onChange((q) => ({ ...q, location: v || undefined }))}
+ />
+ );
+}
+
+export function ExcludeTermsEditor({
+ query,
+ onChange,
+}: {
+ query: JobQuery;
+ onChange: QueryChange;
+}) {
+ return (
+ onChange((q) => withExcludeTerm(q, term))}
+ onRemove={(term) => onChange((q) => withoutExcludeTerm(q, term))}
+ placeholder="Add a title to exclude…"
+ addAriaLabel="Add exclude term"
+ />
+ );
+}
diff --git a/src/lib/job-search/CLAUDE.md b/src/lib/job-search/CLAUDE.md
index 21dc2434..6df665cd 100644
--- a/src/lib/job-search/CLAUDE.md
+++ b/src/lib/job-search/CLAUDE.md
@@ -38,6 +38,13 @@ All three share the **never-fail-closed** floor: when a filter would reduce a no
set to empty, it is skipped, the input is kept, and a `*Suppressed` flag goes back for the
panel's notice. A blank panel the user cannot diagnose is worse than an unfiltered one.
+A remover must also never report a fact it does not have. A posting whose feed omitted
+`location` **passes** the local-only filter — `locationMatches` reads the blank as a
+non-match because it is scoring a rating with no evidence to credit, but hiding it and
+calling it "too far away" would state a location the app never saw. The two readers of
+that blank differ on purpose (`filterPostingsByLocation`), and `locationFilteredOut`
+counts only postings that stated a location somewhere else.
+
`location-match.ts` owns the ONE location predicate. `rank.ts` reads it for the soft
axis, `refine.ts` for the hard filter — so the local-only toggle can never hide a posting
whose own card shows a location match. It is a string comparison, not geography: no
diff --git a/src/lib/job-search/location-match.test.ts b/src/lib/job-search/location-match.test.ts
index ebdeafcb..13f44268 100644
--- a/src/lib/job-search/location-match.test.ts
+++ b/src/lib/job-search/location-match.test.ts
@@ -27,6 +27,27 @@ describe("locationMatches", () => {
expect(locationMatches("Austin, TX", "Seattle, WA")).toBe(false);
});
+ it("rejects a same-named city in a different state or country (#905 review)", () => {
+ expect(locationMatches("Portland, OR", "Portland, ME")).toBe(false);
+ expect(locationMatches("Columbus, OH", "Columbus, GA")).toBe(false);
+ expect(locationMatches("Kansas City, MO", "Kansas City, KS")).toBe(false);
+ expect(locationMatches("San Jose, CA", "San Jose, Costa Rica")).toBe(false);
+ });
+
+ it("compares whole words, so a bare state code isn't a substring match", () => {
+ expect(locationMatches("Austin, TX", "IN")).toBe(false);
+ expect(locationMatches("Norwich, UK", "OR")).toBe(false);
+ });
+
+ it("keeps a city the feed spells one word longer", () => {
+ expect(locationMatches("New York, NY", "New York City, NY")).toBe(true);
+ });
+
+ it("still matches when only one side names a state", () => {
+ expect(locationMatches("Austin, TX", "Austin")).toBe(true);
+ expect(locationMatches("Austin", "Austin, TX")).toBe(true);
+ });
+
it("counts every remote spelling as a match for any query location", () => {
for (const remote of ["Remote", "Worldwide", "Anywhere", "WFH"]) {
expect(isRemotePosting(remote)).toBe(true);
@@ -66,10 +87,19 @@ describe("filterPostingsByLocation (issue 809)", () => {
expect(result.suppressed).toBe(false);
});
+ it("keeps a posting whose feed stated no location — unknown is not far (#905 review)", () => {
+ const result = filterPostingsByLocation(
+ [at("local", "Austin, TX"), at("far", "Seattle, WA"), at("unstated", "")],
+ "Austin, TX",
+ );
+ expect(result.postings.map((p) => p.id)).toEqual(["local", "unstated"]);
+ expect(result.suppressed).toBe(false);
+ });
+
it("never fails closed: a set it would empty is kept whole and flagged", () => {
- const postings = [at("far", "Seattle, WA"), at("unstated", "")];
+ const postings = [at("far", "Seattle, WA"), at("further", "Portland, ME")];
const result = filterPostingsByLocation(postings, "Austin, TX");
- expect(result.postings.map((p) => p.id)).toEqual(["far", "unstated"]);
+ expect(result.postings.map((p) => p.id)).toEqual(["far", "further"]);
expect(result.suppressed).toBe(true);
});
diff --git a/src/lib/job-search/location-match.ts b/src/lib/job-search/location-match.ts
index efff355c..35386fc2 100644
--- a/src/lib/job-search/location-match.ts
+++ b/src/lib/job-search/location-match.ts
@@ -20,8 +20,8 @@
* free-text `location` field can actually support. Anything finer needs a
* geocoder, which is a network call this app does not get to make.
*
- * Zero-dep and pure, so it stays out of the dynamic-import tiers: `refine.ts`
- * can filter before it has paid for `rank.ts`.
+ * Zero-dep and pure, so nothing that imports it pays for a tier it wasn't
+ * already loading.
*/
const REMOTE_PATTERN = /\b(remote|worldwide|anywhere|wfh)\b/i;
@@ -32,27 +32,71 @@ export function isRemotePosting(location: string): boolean {
return REMOTE_PATTERN.test(location);
}
+/** "Austin, TX, USA" → ["austin", "tx", "usa"]. Empty segments are dropped so a
+ * stray comma can't produce an empty city that matches everything below. */
+function segments(location: string): string[] {
+ return location
+ .toLowerCase()
+ .split(",")
+ .map((segment) => segment.trim())
+ .filter((segment) => segment.length > 0);
+}
+
+/** Whole words of a city segment — the unit the fallback compares, because raw
+ * substrings are what let a bare "IN" posting survive an "Austin, TX" filter. */
+function words(city: string): string[] {
+ return city.split(/\s+/).filter((word) => word.length > 0);
+}
+
+/** The state/country behind the city must not CONTRADICT the one asked for.
+ * Either side may omit it (a feed's "Austin" against a query's "Austin, TX"),
+ * but "Portland, OR" and "Portland, ME" are exactly the pair a location filter
+ * exists to separate, so two stated qualifiers that differ are a mismatch. */
+function qualifiersAgree(a: string | undefined, b: string | undefined): boolean {
+ return a === undefined || b === undefined || a === b;
+}
+
+/** True when every word of `inner` appears in `outer` — "new york" inside
+ * "new york city", never "in" inside "austin". */
+function containsAllWords(outer: readonly string[], inner: readonly string[]): boolean {
+ const haystack = new Set(outer);
+ return inner.length > 0 && inner.every((word) => haystack.has(word));
+}
+
/**
* True when `postingLocation` should count as a match for `queryLocation`.
- * Compares the leading city/region token (text before the first comma) so
- * "Austin, TX" matches a feed's "Austin, TX, USA" without requiring an exact
- * string match, and falls back to a loose substring check either direction for
- * postings that don't follow the "City, ST" shape.
+ *
+ * Compares the city segment (text before the first comma) and requires the
+ * qualifier behind it not to conflict, so "Austin, TX" matches a feed's
+ * "Austin, TX, USA" without an exact string match while "Portland, OR" does
+ * NOT match "Portland, ME". When the city segments differ, it falls back to
+ * WHOLE-WORD containment either direction, which admits "New York, NY" against
+ * "New York City, NY" without admitting a bare "IN" posting against "Austin,
+ * TX" the way a raw substring test did (#905 review).
+ *
+ * Known limits, both needing data this module deliberately doesn't carry: an
+ * alias table would be required for "SF Bay Area" vs "San Francisco, CA", and a
+ * gazetteer to tell a city refinement from a company name in "Boston Consulting
+ * Group, London". Both fail toward the soft axis, and the never-fail-closed
+ * floor below is what keeps either from emptying the panel.
*
* An EMPTY posting location returns false — a feed that told us nothing about
* where the job is has not told us it is near you. That is the conservative
- * read for the rating axis (no evidence, no credit) and, since #809, the reason
- * `locationOnly` needs its never-fail-closed floor: a feed whose postings all
- * carry an empty location would otherwise be filtered to nothing.
+ * read for the RATING axis (no evidence, no credit); the hard filter takes the
+ * opposite read on the same fact, see `filterPostingsByLocation`.
*/
export function locationMatches(queryLocation: string, postingLocation: string): boolean {
if (isRemotePosting(postingLocation)) return true;
- const posting = postingLocation.trim().toLowerCase();
- const query = queryLocation.trim().toLowerCase();
- 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);
+ const posting = segments(postingLocation);
+ const query = segments(queryLocation);
+ if (posting.length === 0 || query.length === 0) return false;
+ if (!qualifiersAgree(posting[1], query[1])) return false;
+ if (posting[0] === query[0]) return true;
+ const postingWords = words(posting[0]);
+ const queryWords = words(query[0]);
+ return (
+ containsAllWords(postingWords, queryWords) || containsAllWords(queryWords, postingWords)
+ );
}
/**
@@ -61,13 +105,21 @@ export function locationMatches(queryLocation: string, postingLocation: string):
* (#809). Returns the input untouched when there is no location to filter on,
* so an unset location is byte-identical to pre-#809 behavior.
*
+ * UNKNOWN IS NOT FAR. A posting whose feed omitted `location` (documented as
+ * `""` on `JobPosting`, and the keyless aggregator feeds are inconsistent about
+ * filling it at all) PASSES this filter, the same way a remote posting does.
+ * `locationMatches` reads the same blank as a non-match because it is scoring a
+ * rating and has no evidence to credit; a remover cannot borrow that read
+ * without telling the user it hid a posting "as too far away" when it has no
+ * idea where the posting is (#905 review). So the two readers of the blank
+ * differ on purpose, and `locationFilteredOut` counts only postings that stated
+ * a location somewhere else.
+ *
* NEVER FAIL CLOSED, the same floor `filterPostingsByExcludeTerms` and the
* #566 role filter already apply: when the filter would reduce a NON-EMPTY set
* to EMPTY, the input is kept and `suppressed` is set for the panel's notice.
- * The keyless aggregator feeds skew remote and are inconsistent about filling
- * `location` at all, so "local only" over a set that named no locations is a
- * blank screen the user cannot diagnose — the notice points them back at the
- * toggle instead.
+ * A blank screen the user cannot diagnose is worse than an unfiltered one, and
+ * the notice points them back at the toggle.
*/
export function filterPostingsByLocation(
postings: readonly T[],
@@ -75,7 +127,10 @@ export function filterPostingsByLocation(
): { postings: T[]; suppressed: boolean } {
const query = queryLocation?.trim();
if (!query) return { postings: [...postings], suppressed: false };
- const kept = postings.filter((posting) => locationMatches(query, posting.location));
+ const kept = postings.filter(
+ (posting) =>
+ posting.location.trim().length === 0 || locationMatches(query, posting.location),
+ );
if (kept.length === 0 && postings.length > 0) {
return { postings: [...postings], suppressed: true };
}
diff --git a/src/lib/job-search/refine.test.ts b/src/lib/job-search/refine.test.ts
index 90cd84e7..ea60dff1 100644
--- a/src/lib/job-search/refine.test.ts
+++ b/src/lib/job-search/refine.test.ts
@@ -161,7 +161,7 @@ describe("refineSearchResult — local-only (issue 809)", () => {
it("never fails closed: a set it would empty is kept whole and flagged", async () => {
const elsewhere = [
posting({ id: "far", location: "Seattle, WA" }),
- posting({ id: "unstated", location: "" }),
+ posting({ id: "further", location: "Portland, ME" }),
];
const result = await refineSearchResult(
elsewhere,
@@ -175,6 +175,24 @@ describe("refineSearchResult — local-only (issue 809)", () => {
expect(result.locationFilteredOut).toBe(0);
});
+ it("does not count a posting whose feed stated no location as hidden (#905 review)", async () => {
+ const mixed = [
+ posting({ id: "local", location: "Austin, TX" }),
+ posting({ id: "far", location: "Seattle, WA" }),
+ posting({ id: "unstated", location: "" }),
+ ];
+ const result = await refineSearchResult(
+ mixed,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id).sort()).toEqual(["local", "unstated"]);
+ expect(result.locationFilteredOut).toBe(1);
+ expect(result.locationSuppressed).toBe(false);
+ });
+
it("counts only what IT removed, not what the exclude filter already took", async () => {
const mixed = [
posting({ id: "local", location: "Austin, TX" }),