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..beb48119 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,19 @@ export function FindJobsPanel({ /> )} + {/* 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 7a589697..55b1210c 100644 --- a/src/components/features/JobQueryEditor.tsx +++ b/src/components/features/JobQueryEditor.tsx @@ -43,7 +43,7 @@ */ 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 { @@ -57,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"; @@ -130,13 +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) => ({ ...q, excludeTerms: [...(q.excludeTerms ?? []), term] })); - const removeExcludeTerm = (term: string) => - onChange((q) => ({ - ...q, - excludeTerms: (q.excludeTerms ?? []).filter((t) => t !== 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 @@ -280,12 +278,7 @@ export function JobQueryEditor({ * behavior at all. */}
- onChange((q) => ({ ...q, location: v || undefined }))} - /> +
@@ -293,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.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..a43a9ea4 --- /dev/null +++ b/src/components/features/JobResultRefineStrip.tsx @@ -0,0 +1,118 @@ +// 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 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. + * + * 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 } 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 + * 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; + + return ( + + +
+ Location + +
+ {/* 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/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 74194bba..4b066fb9 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,47 @@ 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("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 f96dd15b..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,7 +109,7 @@ function Loaded({ onRetry: () => void; onTailor?: (jdContext: string) => void; }) { - const { jobs, degradedProviders, providerCount, excludeSuppressed, roleSuppressed } = 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 @@ -180,26 +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. -

- )} + {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/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