- 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.
-
- )}
+
{/* 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/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..6df665cd 100644
--- a/src/lib/job-search/CLAUDE.md
+++ b/src/lib/job-search/CLAUDE.md
@@ -23,6 +23,34 @@ 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.
+
+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
+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..13f44268
--- /dev/null
+++ b/src/lib/job-search/location-match.test.ts
@@ -0,0 +1,112 @@
+// 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("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);
+ 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("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("further", "Portland, ME")];
+ const result = filterPostingsByLocation(postings, "Austin, TX");
+ expect(result.postings.map((p) => p.id)).toEqual(["far", "further"]);
+ 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..35386fc2
--- /dev/null
+++ b/src/lib/job-search/location-match.ts
@@ -0,0 +1,138 @@
+// 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 nothing that imports it pays for a tier it wasn't
+ * already loading.
+ */
+
+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);
+}
+
+/** "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 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); 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 = 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)
+ );
+}
+
+/**
+ * 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.
+ *
+ * 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.
+ * 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[],
+ queryLocation: string | undefined,
+): { postings: T[]; suppressed: boolean } {
+ const query = queryLocation?.trim();
+ if (!query) return { postings: [...postings], suppressed: false };
+ 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 };
+ }
+ 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..ea60dff1 100644
--- a/src/lib/job-search/refine.test.ts
+++ b/src/lib/job-search/refine.test.ts
@@ -108,3 +108,122 @@ 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: "further", location: "Portland, ME" }),
+ ];
+ 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("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" }),
+ 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