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