From b86b34835af1e323932ec35b3721bce5e2bba42e Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:08:19 +1000 Subject: [PATCH 1/8] Add excludedCompanies to filter state and URL --- frontend/src/components/filters/reset-filters.tsx | 13 ++++++++++--- frontend/src/context/filter/filter-provider.tsx | 10 ++++++++++ frontend/src/types/filters.ts | 12 ++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/filters/reset-filters.tsx b/frontend/src/components/filters/reset-filters.tsx index 15d4cb56..5ef83ce7 100644 --- a/frontend/src/components/filters/reset-filters.tsx +++ b/frontend/src/components/filters/reset-filters.tsx @@ -16,14 +16,21 @@ export default function ResetFilters({ // Check if any filters are applied const hasActiveFilters = () => { - const { search, industryFields, jobTypes, locations, workingRights } = - filters.filters; + const { + search, + industryFields, + jobTypes, + locations, + workingRights, + excludedCompanies, + } = filters.filters; return ( search !== "" || industryFields.length > 0 || jobTypes.length > 0 || locations.length > 0 || - workingRights.length > 0 + workingRights.length > 0 || + excludedCompanies.length > 0 ); }; diff --git a/frontend/src/context/filter/filter-provider.tsx b/frontend/src/context/filter/filter-provider.tsx index e51cdc74..f284e729 100644 --- a/frontend/src/context/filter/filter-provider.tsx +++ b/frontend/src/context/filter/filter-provider.tsx @@ -25,6 +25,7 @@ const emptyFilterState: FilterState = { jobTypes: [], locations: [], workingRights: [], + excludedCompanies: [], page: 1, }, isLoading: false, @@ -63,6 +64,15 @@ export function FilterProvider({ children }: { children: ReactNode }) { .filter((field): field is WorkingRight => WORKING_RIGHTS.includes(field as WorkingRight), ) || [], + // Company names are dynamic (not a fixed enum), so we only trim + dedupe. + excludedCompanies: [ + ...new Set( + searchParams + .getAll("excludedCompanies[]") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ), + ], page: Number(searchParams.get("page")) || 1, }, isLoading: false, diff --git a/frontend/src/types/filters.ts b/frontend/src/types/filters.ts index 9bdefcda..dbb941fe 100644 --- a/frontend/src/types/filters.ts +++ b/frontend/src/types/filters.ts @@ -10,6 +10,9 @@ export interface JobFilters { locations: LocationType[]; workingRights: WorkingRight[]; industryFields: IndustryField[]; + // Company names to hide from results (exclusion-based filter). + // Empty = show all companies. + excludedCompanies: string[]; page: number; } @@ -18,3 +21,12 @@ export interface FilterState { isLoading: boolean; error: Error | null; } + +/** + * A single company facet: the company name and how many listings match the + * currently applied filters (excluding the company exclusion itself). + */ +export interface CompanyFacet { + company: string; + count: number; +} From cd85de28656b61858a1682ae1723d55834a26a12 Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:08:19 +1000 Subject: [PATCH 2/8] Add company facet counts and company exclusion to job query --- frontend/src/actions/company-facets.ts | 15 +++ frontend/src/actions/jobs.fetch.ts | 166 +++++++++++++++++++------ 2 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 frontend/src/actions/company-facets.ts diff --git a/frontend/src/actions/company-facets.ts b/frontend/src/actions/company-facets.ts new file mode 100644 index 00000000..eec004cf --- /dev/null +++ b/frontend/src/actions/company-facets.ts @@ -0,0 +1,15 @@ +"use server"; + +import { JobFilters, CompanyFacet } from "@/types/filters"; +import { getCompanyFacets as fetchCompanyFacets } from "./jobs.fetch"; + +/** + * Server action exposing company facet counts to client components. + * Thin wrapper around the data-layer implementation in `jobs.fetch.ts` so that + * MongoDB-dependent code stays server-side and is never bundled to the client. + */ +export async function getCompanyFacets( + filters: Partial, +): Promise { + return fetchCompanyFacets(filters); +} diff --git a/frontend/src/actions/jobs.fetch.ts b/frontend/src/actions/jobs.fetch.ts index 7ac1f23d..cc460bd1 100644 --- a/frontend/src/actions/jobs.fetch.ts +++ b/frontend/src/actions/jobs.fetch.ts @@ -1,5 +1,5 @@ import { MongoClient, ObjectId } from "mongodb"; -import { JobFilters } from "@/types/filters"; +import { JobFilters, CompanyFacet } from "@/types/filters"; import { Job } from "@/types/job"; import serializeJob from "@/lib/utils"; import logger from "@/lib/logger"; @@ -16,6 +16,13 @@ const jobCache = new LRUCache({ allowStale: false, }); +// Separate cache for company facet counts +const facetCache = new LRUCache({ + max: 200, + ttl: 1000 * 3600, // 1 hour + allowStale: false, +}); + // Helper to normalize filters for cache key (handles string vs array for array fields) function normalizeFiltersForKey( filters: Partial, @@ -25,6 +32,7 @@ function normalizeFiltersForKey( "locations[]", "industryFields[]", "jobTypes[]", + "excludedCompanies[]", ]; const normalized: Record = { search: (filters.search || "").toLowerCase().trim(), @@ -53,50 +61,64 @@ function normalizeFiltersForKey( return normalized; } +/** + * Reads an array-valued filter, tolerating both URL-style keys (e.g. + * "jobTypes[]" coming from searchParams) and plain JobFilters keys (e.g. + * "jobTypes" coming from the client). A single value is wrapped into an array. + */ +function readArrayFilter( + source: Record, + bracketKey: string, + plainKey: string, +): string[] { + const val = source[bracketKey] ?? source[plainKey]; + if (val === undefined || val === null) return []; + return Array.isArray(val) ? (val as string[]) : [String(val)]; +} + /** * Helper function to build a query object from filters. - * @param filters - The job filters from the client. - * @param additional - Additional query overrides (e.g. { is_sponsor: true }). + * @param filters - The job filters from the client (URL-style or plain keys). + * @param additional - Additional query overrides (e.g. { is_sponsored: true }). + * @param options - includeCompanyExclusion (default true): set false when + * building the company-facet match so a facet ignores its own selection. * @returns The query object to use with MongoDB. */ function buildJobQuery( filters: Partial, additional?: Record, + options?: { includeCompanyExclusion?: boolean }, ) { - const array_jobs = JSON.parse(JSON.stringify(filters, null, 2)); + const includeCompanyExclusion = options?.includeCompanyExclusion ?? true; + const raw = JSON.parse(JSON.stringify(filters)) as Record; + + const workingRights = readArrayFilter( + raw, + "workingRights[]", + "workingRights", + ); + const locations = readArrayFilter(raw, "locations[]", "locations"); + const industryFields = readArrayFilter( + raw, + "industryFields[]", + "industryFields", + ); + const jobTypes = readArrayFilter(raw, "jobTypes[]", "jobTypes"); + const excludedCompanies = readArrayFilter( + raw, + "excludedCompanies[]", + "excludedCompanies", + ); + const query = { outdated: false, - ...(array_jobs["workingRights[]"] !== undefined && - array_jobs["workingRights[]"].length && { - working_rights: { - $in: Array.isArray(array_jobs["workingRights[]"]) - ? array_jobs["workingRights[]"] - : [array_jobs["workingRights[]"]], - }, - }), - ...(array_jobs["locations[]"] !== undefined && - array_jobs["locations[]"].length && { - locations: { - $in: Array.isArray(array_jobs["locations[]"]) - ? array_jobs["locations[]"] - : [array_jobs["locations[]"]], - }, - }), - ...(array_jobs["industryFields[]"] !== undefined && - array_jobs["industryFields[]"].length && { - industry_field: { - $in: Array.isArray(array_jobs["industryFields[]"]) - ? array_jobs["industryFields[]"] - : [array_jobs["industryFields[]"]], - }, - }), - ...(array_jobs["jobTypes[]"] !== undefined && - array_jobs["jobTypes[]"].length && { - type: { - $in: Array.isArray(array_jobs["jobTypes[]"]) - ? array_jobs["jobTypes[]"] - : [array_jobs["jobTypes[]"]], - }, + ...(workingRights.length && { working_rights: { $in: workingRights } }), + ...(locations.length && { locations: { $in: locations } }), + ...(industryFields.length && { industry_field: { $in: industryFields } }), + ...(jobTypes.length && { type: { $in: jobTypes } }), + ...(includeCompanyExclusion && + excludedCompanies.length && { + "company.name": { $nin: excludedCompanies }, }), ...(filters.search && { $or: [ @@ -277,6 +299,80 @@ export async function getJobById(id: string): Promise { }); } +/** + * Builds a stable cache key for company facets. Intentionally ignores + * `excludedCompanies` and `page`: a facet's counts must not depend on its own + * selection or on pagination. + */ +function companyFacetCacheKey(filters: Partial): string { + const sorted = (val: unknown): string => { + const arr = Array.isArray(val) + ? (val as string[]) + : val + ? [String(val)] + : []; + return [...arr].sort().join(","); + }; + const f = filters as Record; + const norm = { + search: (filters.search || "").toLowerCase().trim(), + jobTypes: sorted(f["jobTypes[]"] ?? f.jobTypes), + locations: sorted(f["locations[]"] ?? f.locations), + industryFields: sorted(f["industryFields[]"] ?? f.industryFields), + workingRights: sorted(f["workingRights[]"] ?? f.workingRights), + }; + return `facets:companies:${JSON.stringify(norm)}`; +} + +/** + * Aggregates the number of listings per company for the current filter set. + * + * The company exclusion (`excludedCompanies`) is deliberately NOT applied so the + * counts reflect "what's available" regardless of which companies are hidden - + * this is the standard faceted-search rule. The results are sorted by count + * descending (then name ascending) and cached for 1 hour. + */ +export async function getCompanyFacets( + filters: Partial, +): Promise { + const cacheKey = companyFacetCacheKey(filters); + + const cached = facetCache.get(cacheKey); + if (cached) { + logger.debug({ cacheKey }, "Returning cached company facets"); + return cached; + } + + logger.info({ filters }, "Fetching company facets"); + + return await withDbConnection(async (client) => { + const collection = client.db("default").collection("active_jobs"); + const query = buildJobQuery(filters, undefined, { + includeCompanyExclusion: false, + }); + + try { + const results = await collection + .aggregate<{ + _id: string; + count: number; + }>([{ $match: query }, { $group: { _id: "$company.name", count: { $sum: 1 } } }, { $sort: { count: -1, _id: 1 } }]) + .toArray(); + + const facets: CompanyFacet[] = results + .filter((r) => typeof r._id === "string" && r._id.length > 0) + .map((r) => ({ company: r._id, count: r.count })); + + facetCache.set(cacheKey, facets); + logger.debug({ count: facets.length }, "Fetched company facets"); + return facets; + } catch (error) { + logger.error({ query, filters }, "Error fetching company facets"); + throw error; + } + }); +} + // Define the MongoJob interface with the correct DB field names. export interface MongoJob extends Omit { _id: ObjectId; From 82dec5fea309c8660424d02b28d3f951a1586b5c Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:08:19 +1000 Subject: [PATCH 3/8] Add Companies filter section with hide/clear and animation --- .../filters/company-filter-section.tsx | 337 ++++++++++++++++++ .../src/components/filters/filter-modal.tsx | 3 + 2 files changed, 340 insertions(+) create mode 100644 frontend/src/components/filters/company-filter-section.tsx diff --git a/frontend/src/components/filters/company-filter-section.tsx b/frontend/src/components/filters/company-filter-section.tsx new file mode 100644 index 00000000..4c74294a --- /dev/null +++ b/frontend/src/components/filters/company-filter-section.tsx @@ -0,0 +1,337 @@ +// frontend/src/components/filters/company-filter-section.tsx +"use client"; + +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Text, + TextInput, + ScrollArea, + Checkbox, + Pill, + Skeleton, + Button, +} from "@mantine/core"; +import { IconSearch } from "@tabler/icons-react"; +import { useFilterContext } from "@/context/filter/filter-context"; +import { getCompanyFacets } from "@/actions/company-facets"; +import { CompanyFacet } from "@/types/filters"; + +// How many "hidden" chips to show before collapsing the rest into "+N more". +const MAX_VISIBLE_HIDDEN = 6; + +// Duration (ms) of the hidden-chips expand/collapse animation (both directions). +const HIDDEN_ANIM_MS = 260; + +/** + * A single company row. Memoised so toggling one company (or unrelated re-renders + * from route navigation) doesn't reconcile the entire list, keeping animations + * smooth. `onToggle` must be referentially stable for the memo to be effective. + */ +const CompanyRow = memo(function CompanyRow({ + company, + count, + checked, + onToggle, +}: { + company: string; + count: number; + checked: boolean; + onToggle: (company: string) => void; +}) { + return ( + + ); +}); + +/** + * Exclusion-based company filter. All companies are shown by default; the user + * unchecks (hides) companies they don't want to see. Counts reflect every other + * active filter but NOT the company exclusion itself (standard faceted search). + */ +export function CompanyFilterSection() { + const { filters, updateFilters } = useFilterContext(); + const excluded = filters.filters.excludedCompanies; + + const [facets, setFacets] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + // Chips rendered in the "hidden" row. Lags behind `excluded` when clearing so + // the area animates closed with content present, instead of snapping shut. + const [displayedHidden, setDisplayedHidden] = useState(excluded); + + useEffect(() => { + if (excluded.length > 0) { + setDisplayedHidden(excluded); + return; + } + const timeout = setTimeout( + () => setDisplayedHidden([]), + HIDDEN_ANIM_MS + 40, + ); + return () => clearTimeout(timeout); + }, [excluded]); + + // Animate the hidden-chips area to its measured content height, so EVERY change + // (open, close, and wrapping to more/fewer rows) transitions smoothly. + const hiddenContentRef = useRef(null); + const [hiddenHeight, setHiddenHeight] = useState(0); + const [animateHidden, setAnimateHidden] = useState(false); + + useLayoutEffect(() => { + const el = hiddenContentRef.current; + if (!el) return; + const measure = () => setHiddenHeight(el.scrollHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + // Enable the transition only after the first measure to avoid a mount jump. + const raf = requestAnimationFrame(() => setAnimateHidden(true)); + return () => { + observer.disconnect(); + cancelAnimationFrame(raf); + }; + }, []); + + // Edge "scroll shadows" so the list reads as scrollable when it overflows. + const viewportRef = useRef(null); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + + const updateScrollFades = useCallback(() => { + const el = viewportRef.current; + if (!el) return; + setShowTopFade(el.scrollTop > 1); + setShowBottomFade(el.scrollTop + el.clientHeight < el.scrollHeight - 1); + }, []); + + // Only the non-company filters affect the counts. Memoised so the fetch effect + // re-runs exactly when one of them changes (not when exclusions change). + const facetFilters = useMemo( + () => ({ + search: filters.filters.search, + jobTypes: filters.filters.jobTypes, + locations: filters.filters.locations, + workingRights: filters.filters.workingRights, + industryFields: filters.filters.industryFields, + }), + [ + filters.filters.search, + filters.filters.jobTypes, + filters.filters.locations, + filters.filters.workingRights, + filters.filters.industryFields, + ], + ); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getCompanyFacets(facetFilters) + .then((res) => { + if (!cancelled) setFacets(res); + }) + .catch(() => { + if (!cancelled) setFacets([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [facetFilters]); + + const excludedSet = useMemo(() => new Set(excluded), [excluded]); + + const visibleFacets = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return facets; + return facets.filter((f) => f.company.toLowerCase().includes(q)); + }, [facets, search]); + + // Recompute fades whenever the rendered list changes (load, search, toggle). + useEffect(() => { + updateScrollFades(); + }, [visibleFacets, loading, updateScrollFades]); + + // Keep latest values in a ref so the handlers can have stable identities + // (empty deps). Stable `onToggle` lets the memoised rows skip re-rendering. + const latest = useRef({ excluded, filters, updateFilters }); + latest.current = { excluded, filters, updateFilters }; + + const setExcluded = useCallback((next: string[]) => { + const { filters, updateFilters } = latest.current; + updateFilters({ + filters: { + ...filters.filters, + excludedCompanies: next, + page: 1, + }, + }); + }, []); + + const toggleCompany = useCallback( + (company: string) => { + const { excluded } = latest.current; + setExcluded( + excluded.includes(company) + ? excluded.filter((c) => c !== company) + : [...excluded, company], + ); + }, + [setExcluded], + ); + + const clearCompanies = useCallback(() => setExcluded([]), [setExcluded]); + + return ( +
+
+ + Companies + {facets.length > 0 && ( + + {" "} + ({facets.length}) + + )} + + {excluded.length > 0 && ( +
+ + {excluded.length} hidden + + +
+ )} +
+ +
0 ? hiddenHeight : 0, + overflow: "hidden", + transition: animateHidden + ? `height ${HIDDEN_ANIM_MS}ms ease-in-out` + : undefined, + }} + > +
+ + {displayedHidden.slice(0, MAX_VISIBLE_HIDDEN).map((company) => ( + toggleCompany(company)} + bg="selected" + > + {company} + + ))} + {displayedHidden.length > MAX_VISIBLE_HIDDEN && ( + + +{displayedHidden.length - MAX_VISIBLE_HIDDEN} more + + )} + +
+
+ + setSearch(e.currentTarget.value)} + leftSection={} + radius="md" + size="sm" + mb="xs" + /> + + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : visibleFacets.length === 0 ? ( + + No companies match the current filters. + + ) : ( +
+ +
+ {visibleFacets.map((facet) => ( + + ))} +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/filters/filter-modal.tsx b/frontend/src/components/filters/filter-modal.tsx index bb130da2..67a30cc1 100644 --- a/frontend/src/components/filters/filter-modal.tsx +++ b/frontend/src/components/filters/filter-modal.tsx @@ -2,6 +2,7 @@ import { Modal, Button, ScrollArea, Group } from "@mantine/core"; import { IconFilter } from "@tabler/icons-react"; import { useState } from "react"; import { FilterSectionGroup } from "./filter-section-group"; +import { CompanyFilterSection } from "./company-filter-section"; import { useFilterContext } from "@/context/filter/filter-context"; import { INDUSTRY_FIELDS, @@ -86,6 +87,8 @@ export default function FilterModal() { selectedValues={filters.filters.jobTypes} onToggle={(value) => handleToggle("jobTypes", value)} /> + +
From 0fb14acc4f2dd30293ee572048c0b3939b79f3f1 Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:08:19 +1000 Subject: [PATCH 4/8] Ignore env files and add env example template --- .gitignore | 1 + frontend/.env.example | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 frontend/.env.example diff --git a/.gitignore b/.gitignore index e3ae6fc0..069e0f84 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ coverage/ # Miscellaneous .env .env.local +*.env *.local *.lock *.bak diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..85dd9bd7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +# Copy this file to `.env.local` and fill in the values. +# `.env.local` is gitignored. + +# MongoDB connection string (must point at a DB containing the `active_jobs` collection) +MONGODB_URI= + +# Optional — defaults used by the app +MONGODB_DATABASE=default +MONGODB_COLLECTION=active_jobs + +NODE_ENV=development From 6e41f76080ba2709ca7d41b3cd6e18eb18fec609 Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:08:19 +1000 Subject: [PATCH 5/8] Add company filter PRP doc --- PRPs/company-filter.md | 286 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 PRPs/company-filter.md diff --git a/PRPs/company-filter.md b/PRPs/company-filter.md new file mode 100644 index 00000000..e0b58d3d --- /dev/null +++ b/PRPs/company-filter.md @@ -0,0 +1,286 @@ +# PRP: Company Filter (exclusion-based, with live counts) + +> Status: Draft / source of truth for `frontend/michael/company-filter` +> Owner: michael +> Last updated: 2026-06-18 + +## 1. Goal + +Add a **Companies** section to the job filters (under "Experience") that: + +1. Lists every company that has listings under the _currently applied_ filters. +2. Shows a **live count** next to each company name (e.g. `Jane Street 34`). +3. Lets the user **hide/exclude** companies from the results. All companies are + shown by default; the user opts _out_ of companies they don't want to see. +4. Always offers a one-click way to bring an excluded company back. + +## 2. Why + +- Users often want "everything except a few companies" (e.g. hide a consultancy + spamming listings) or "only these big employers." Inclusion-only chips don't + serve the "everything except X" case well. +- Per-company counts give users a sense of where the volume is and make the + board feel data-rich and trustworthy. + +## 3. What (user-visible behaviour) + +### Semantics: exclusion, not inclusion + +- The other four sections (Industry, Location, Working Rights, Experience) are + **inclusive**: selecting values narrows results to those values. +- Companies is **exclusion-based**: the empty state means "show all companies." + State stores only the _excluded_ companies (`excludedCompanies: string[]`). +- This keeps the default (`[]`) meaning "no filter active," consistent with + `ResetFilters` and the URL-as-source-of-truth pattern. + +### Faceted counts (important rule) + +- Each company's count reflects **all other active filters** (Industry, + Location, Working Rights, Experience, search) **but NOT** the company + exclusion itself. +- Rationale (standard faceted search): excluding "Canva" must not change the + count shown next to "Jane Street." A facet ignores its own selection. +- The **results total** ("289 Results") and the job list **do** respect the + company exclusion (excluded companies disappear from results). + +### UI (as shipped) + +A searchable checklist (chips do not scale to 100+ companies): + +``` +Companies (268) 3 hidden [Clear] + [Canva ✕] [Acme ✕] [Globex ✕] ← animated chips +┌──────────────────────────────────────────────────────┐ +│ 🔍 Search companies... │ +├──────────────────────────────────────────────────────┤ +│ ☑ Jane Street ................................... 34 │ +│ ☑ Atlassian ..................................... 28 │ +│ ☑ IMC ........................................... 19 │ +│ ☐ Canva ......................................... 12 │ ← unchecked = hidden +│ ☑ Google ........................................ 11 │ +│ … scrolls (with edge fades), count desc … │ +└──────────────────────────────────────────────────────┘ +``` + +- **All checked by default** → "showing all." Unchecking a row hides that + company; the whole row is clickable. +- **Count right-aligned**, sorted **count desc** (then name asc as tiebreak). +- **Search box** filters visible rows client-side. +- **Header summary + Clear**: when anything is hidden, the header shows + `N hidden` plus a single accent **Clear** button that empties the exclusion + (only the company filter; other filters untouched). There is intentionally no + "Hide all" (hiding everything yields 0 results — a footgun, and not requested). +- **Hidden chips row** appears only when `excludedCompanies.length > 0`. Shows up + to `MAX_VISIBLE_HIDDEN` (6) chips then `+N more`; each `Company ✕` chip + un-hides that company. Delivers the requested "✕ to filter out" feel as a + compact summary rather than an ✕ on every one of 100+ rows. + +#### Interaction / polish details + +- **Scroll affordance**: the list shows the scrollbar on overflow (`type="auto"`) + plus top/bottom gradient "scroll shadows" (driven by viewport scroll position) + so it reads as scrollable. +- **Smooth resizing**: the hidden-chips area animates its height for _every_ + change — open, close, and wrapping to more/fewer rows — by transitioning to its + measured content height (via `ResizeObserver`), not Mantine `Collapse` (which + only animates the open/close toggle). A small lagging `displayedHidden` keeps + chips mounted through the close so it slides instead of snapping. One constant + (`HIDDEN_ANIM_MS`) drives both directions. +- **Performance**: each row is a memoised `CompanyRow` with a referentially + stable `onToggle`, so toggling one company (or unrelated re-renders from the + route navigation) doesn't reconcile all ~268 rows — keeping the animation + smooth. + +### Placement + +- **Modal only**, under the Experience section in `filter-modal.tsx`. +- Not added to the mobile `dropdown-filter` bar in v1 (list is too long for a + combobox; revisit later). + +## 4. Current architecture (context for implementer) + +- **Filter state** lives in React context: + - `frontend/src/types/filters.ts` → `JobFilters`, `FilterState`. + - `frontend/src/context/filter/filter-provider.tsx` → parses URL → state, + `updateFilters`, `clearFilters`, `emptyFilterState`, `initialFilterState`. + - `frontend/src/context/filter/filter-context.tsx` → context type. +- **URL is the source of truth.** `CreateQueryString` in `frontend/src/lib/utils.ts` + serialises arrays as `key[]=v1&key[]=v2`. Array fields are re-parsed in + `filter-provider.tsx` via `searchParams.getAll("key[]")`. +- **Modal UI**: `frontend/src/components/filters/filter-modal.tsx` renders four + `FilterSectionGroup`s (`filter-section-group.tsx` → `toggle-tag.tsx`). +- **Results header**: `frontend/src/components/filters/filter-section.tsx` shows + "{total} Results"; `reset-filters.tsx` shows Reset when any filter is active. +- **Data layer**: `frontend/src/actions/jobs.fetch.ts` + - `getJobs(filters)` builds a Mongo query via `buildJobQuery`, paginates + (`PAGE_SIZE = 20`), returns `{ jobs, total }`, LRU-cached (1h). + - Collection: `client.db("default").collection("active_jobs")`. + - Base query always includes `outdated: false`. Company name field is + `company.name`. + - Sponsored-job logic builds on top of the same `query`, so any company + exclusion added to `buildJobQuery` automatically applies to sponsored picks. + +## 5. Design / implementation + +### 5.1 Data model + URL + +`frontend/src/types/filters.ts`: + +```ts +export interface JobFilters { + search: string; + jobTypes: JobType[]; + locations: LocationType[]; + workingRights: WorkingRight[]; + industryFields: IndustryField[]; + excludedCompanies: string[]; // NEW — company names to hide + page: number; +} +``` + +`filter-provider.tsx`: + +- Add `excludedCompanies: []` to `emptyFilterState`. +- Parse `searchParams.getAll("excludedCompanies[]")` in `initialFilterState` + (no enum validation — company names are dynamic; trim + dedupe). +- `CreateQueryString` already serialises arrays, so URL sync is automatic. + +`reset-filters.tsx`: + +- Include `excludedCompanies.length > 0` in `hasActiveFilters()`. + +### 5.2 Backend — facet counts + +New server action in `frontend/src/actions/jobs.fetch.ts`: + +```ts +export interface CompanyFacet { + company: string; + count: number; +} + +export async function getCompanyFacets( + filters: Partial, +): Promise; +``` + +- Reuse `buildJobQuery(filters)` but **omit the company `$nin` clause** (a facet + ignores its own selection). Implementation: strip `excludedCompanies` / + `excludedCompanies[]` before building, OR add a `buildJobQuery` option like + `{ includeCompanyExclusion: false }`. +- Aggregation: + +```js +[ + { $match: }, + { $group: { _id: "$company.name", count: { $sum: 1 } } }, + { $sort: { count: -1, _id: 1 } }, +] +``` + +- Map `_id → company`. LRU-cache with a key derived from the normalised + non-company filters (reuse `normalizeFiltersForKey`, exclude company key). + +Extend `buildJobQuery` to add the exclusion for the job list / total: + +```js +...(excludedCompanies?.length && { + "company.name": { $nin: excludedCompanies }, +}), +``` + +This flows into both the paginated `find` and `countDocuments(query)`, so the +results total respects the exclusion. + +### 5.3 Frontend — Company section component + +New `frontend/src/components/filters/company-filter-section.tsx` (client): + +- On mount / when modal opens, call `getCompanyFacets(filters.filters)`. +- Refetch when the _non-company_ filters change (industry/location/rights/ + experience/search). Do **not** refetch on `excludedCompanies` change (facets + ignore it) to avoid loops. +- State: `facets: CompanyFacet[]`, `loading`, `query` (search box). +- A company is "checked" when it is **not** in `excludedCompanies`. +- Toggling a row updates `excludedCompanies` via `updateFilters({ ..., page: 1 })`. +- Render: + - Header row: "Companies" + Clear/Reset buttons. + - Search `TextInput` (debounced ~200ms, client filter on company name). + - Scrollable list of rows: `Checkbox` + name (left) + count (right). + - "Hidden" chips row when `excludedCompanies.length > 0` — each chip removes + one company from the exclusion list. +- Loading + empty states (skeleton / "No companies match"). + +Wire into `filter-modal.tsx` after the Experience `FilterSectionGroup`. + +### 5.4 Files touched (summary) + +| File | Change | +| ----------------------------------------------- | ---------------------------------------------- | +| `types/filters.ts` | add `excludedCompanies` to `JobFilters` | +| `context/filter/filter-provider.tsx` | parse + default `excludedCompanies` | +| `components/filters/reset-filters.tsx` | include exclusions in active check | +| `actions/jobs.fetch.ts` | `getCompanyFacets` + `$nin` in `buildJobQuery` | +| `components/filters/company-filter-section.tsx` | NEW component | +| `components/filters/filter-modal.tsx` | render new section | + +## 6. Edge cases + +- **Stale exclusions**: a company excluded earlier may no longer appear under the + current other-filters. Still render it in the "Hidden" chips so it stays + restorable even if it's not in the current facet list. +- **Empty facets**: filters yield zero companies → show empty state; the page + already shows `NoResults` when `total <= 0`. +- **Large lists**: virtualise only if needed; start with `ScrollArea` (already + capped at `h={500}` in the modal). Search mitigates length. +- **Name as identity**: companies are keyed by `company.name` (no stable id in + the schema). Acceptable; matches how the job query already works. +- **Caching**: facet cache must key off non-company filters only so toggling + exclusions doesn't thrash the cache. +- **Sponsored jobs**: excluded companies must not appear even as sponsored — + satisfied automatically because sponsored query extends the base `query`. + +## 7. Validation / test plan + +Manual (local, see Section 9): + +1. Open Filters → Companies section lists companies with counts summing to total. +2. Apply Industry = Quant Trading → company list + counts refresh to quant-only. +3. Uncheck a company → it disappears from results, total drops, other counts + unchanged; a "Hidden" chip appears. +4. Click the Hidden chip ✕ → company returns to results. +5. Reload page → exclusions persist via URL (`excludedCompanies[]=...`). +6. Reset Filters → exclusions cleared, all companies shown. +7. Search box filters the company rows. + +Automated/typecheck: + +- `npm run lint` clean, `npx tsc --noEmit` clean, `npm run build` succeeds. + +## 8. Out of scope (v1) + +- Company logos in the checklist rows. +- Inclusion mode ("only these companies") — exclusion covers the requested UX. +- Company filter in the mobile dropdown bar. +- List virtualisation (revisit if perf requires). + +## 9. Running locally (to play & test) + +```bash +cd frontend +npm install +# create env (see frontend/.env.example), then: +npm run dev # http://localhost:3000 -> /jobs +``` + +Requires `MONGODB_URI` pointing at a database with an `active_jobs` collection. +See `frontend/.env.example`. Without it, `getJobs` throws and no jobs render. + +## 10. Resolved decisions + +- **Bulk actions**: shipped a single accent **Clear** (shown only when something + is hidden). Dropped "Hide all"/"Show all" — hiding everything yields 0 results + and confused the clear path. +- **Fetch timing**: lazy — facets are fetched when the component mounts (i.e. + when the modal first opens) and refetched only when a non-company filter + changes. Results are LRU-cached server-side, so re-opening is instant. From c227dfb98f922e2aae918b03a2f2b5f1031a1403 Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:40:27 +1000 Subject: [PATCH 6/8] Fix react-hooks lint errors in company filter section --- .../filters/company-filter-section.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/filters/company-filter-section.tsx b/frontend/src/components/filters/company-filter-section.tsx index 4c74294a..ce6aded0 100644 --- a/frontend/src/components/filters/company-filter-section.tsx +++ b/frontend/src/components/filters/company-filter-section.tsx @@ -82,21 +82,17 @@ export function CompanyFilterSection() { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); - // Chips rendered in the "hidden" row. Lags behind `excluded` when clearing so - // the area animates closed with content present, instead of snapping shut. + // Chips rendered in the "hidden" row. When clearing, this lags behind + // `excluded` so the area animates closed with content still present. It's + // driven from setExcluded (an event handler) to keep setState out of effects. const [displayedHidden, setDisplayedHidden] = useState(excluded); + const clearTimerRef = useRef | null>(null); useEffect(() => { - if (excluded.length > 0) { - setDisplayedHidden(excluded); - return; - } - const timeout = setTimeout( - () => setDisplayedHidden([]), - HIDDEN_ANIM_MS + 40, - ); - return () => clearTimeout(timeout); - }, [excluded]); + return () => { + if (clearTimerRef.current) clearTimeout(clearTimerRef.current); + }; + }, []); // Animate the hidden-chips area to its measured content height, so EVERY change // (open, close, and wrapping to more/fewer rows) transitions smoothly. @@ -152,7 +148,6 @@ export function CompanyFilterSection() { useEffect(() => { let cancelled = false; - setLoading(true); getCompanyFacets(facetFilters) .then((res) => { if (!cancelled) setFacets(res); @@ -183,11 +178,25 @@ export function CompanyFilterSection() { // Keep latest values in a ref so the handlers can have stable identities // (empty deps). Stable `onToggle` lets the memoised rows skip re-rendering. + // The ref is updated in an effect (never during render) per react-hooks/refs. const latest = useRef({ excluded, filters, updateFilters }); - latest.current = { excluded, filters, updateFilters }; + useEffect(() => { + latest.current = { excluded, filters, updateFilters }; + }); const setExcluded = useCallback((next: string[]) => { const { filters, updateFilters } = latest.current; + // Drive the chips row: show `next` while non-empty; when clearing, keep the + // current chips mounted until the collapse animation finishes. + if (clearTimerRef.current) clearTimeout(clearTimerRef.current); + if (next.length > 0) { + setDisplayedHidden(next); + } else { + clearTimerRef.current = setTimeout( + () => setDisplayedHidden([]), + HIDDEN_ANIM_MS + 40, + ); + } updateFilters({ filters: { ...filters.filters, From e512d4120ff48f0fe7516e40d7e5178938172f9c Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:53:03 +1000 Subject: [PATCH 7/8] Remove company filter PRP doc to keep PR code-only --- PRPs/company-filter.md | 286 ----------------------------------------- 1 file changed, 286 deletions(-) delete mode 100644 PRPs/company-filter.md diff --git a/PRPs/company-filter.md b/PRPs/company-filter.md deleted file mode 100644 index e0b58d3d..00000000 --- a/PRPs/company-filter.md +++ /dev/null @@ -1,286 +0,0 @@ -# PRP: Company Filter (exclusion-based, with live counts) - -> Status: Draft / source of truth for `frontend/michael/company-filter` -> Owner: michael -> Last updated: 2026-06-18 - -## 1. Goal - -Add a **Companies** section to the job filters (under "Experience") that: - -1. Lists every company that has listings under the _currently applied_ filters. -2. Shows a **live count** next to each company name (e.g. `Jane Street 34`). -3. Lets the user **hide/exclude** companies from the results. All companies are - shown by default; the user opts _out_ of companies they don't want to see. -4. Always offers a one-click way to bring an excluded company back. - -## 2. Why - -- Users often want "everything except a few companies" (e.g. hide a consultancy - spamming listings) or "only these big employers." Inclusion-only chips don't - serve the "everything except X" case well. -- Per-company counts give users a sense of where the volume is and make the - board feel data-rich and trustworthy. - -## 3. What (user-visible behaviour) - -### Semantics: exclusion, not inclusion - -- The other four sections (Industry, Location, Working Rights, Experience) are - **inclusive**: selecting values narrows results to those values. -- Companies is **exclusion-based**: the empty state means "show all companies." - State stores only the _excluded_ companies (`excludedCompanies: string[]`). -- This keeps the default (`[]`) meaning "no filter active," consistent with - `ResetFilters` and the URL-as-source-of-truth pattern. - -### Faceted counts (important rule) - -- Each company's count reflects **all other active filters** (Industry, - Location, Working Rights, Experience, search) **but NOT** the company - exclusion itself. -- Rationale (standard faceted search): excluding "Canva" must not change the - count shown next to "Jane Street." A facet ignores its own selection. -- The **results total** ("289 Results") and the job list **do** respect the - company exclusion (excluded companies disappear from results). - -### UI (as shipped) - -A searchable checklist (chips do not scale to 100+ companies): - -``` -Companies (268) 3 hidden [Clear] - [Canva ✕] [Acme ✕] [Globex ✕] ← animated chips -┌──────────────────────────────────────────────────────┐ -│ 🔍 Search companies... │ -├──────────────────────────────────────────────────────┤ -│ ☑ Jane Street ................................... 34 │ -│ ☑ Atlassian ..................................... 28 │ -│ ☑ IMC ........................................... 19 │ -│ ☐ Canva ......................................... 12 │ ← unchecked = hidden -│ ☑ Google ........................................ 11 │ -│ … scrolls (with edge fades), count desc … │ -└──────────────────────────────────────────────────────┘ -``` - -- **All checked by default** → "showing all." Unchecking a row hides that - company; the whole row is clickable. -- **Count right-aligned**, sorted **count desc** (then name asc as tiebreak). -- **Search box** filters visible rows client-side. -- **Header summary + Clear**: when anything is hidden, the header shows - `N hidden` plus a single accent **Clear** button that empties the exclusion - (only the company filter; other filters untouched). There is intentionally no - "Hide all" (hiding everything yields 0 results — a footgun, and not requested). -- **Hidden chips row** appears only when `excludedCompanies.length > 0`. Shows up - to `MAX_VISIBLE_HIDDEN` (6) chips then `+N more`; each `Company ✕` chip - un-hides that company. Delivers the requested "✕ to filter out" feel as a - compact summary rather than an ✕ on every one of 100+ rows. - -#### Interaction / polish details - -- **Scroll affordance**: the list shows the scrollbar on overflow (`type="auto"`) - plus top/bottom gradient "scroll shadows" (driven by viewport scroll position) - so it reads as scrollable. -- **Smooth resizing**: the hidden-chips area animates its height for _every_ - change — open, close, and wrapping to more/fewer rows — by transitioning to its - measured content height (via `ResizeObserver`), not Mantine `Collapse` (which - only animates the open/close toggle). A small lagging `displayedHidden` keeps - chips mounted through the close so it slides instead of snapping. One constant - (`HIDDEN_ANIM_MS`) drives both directions. -- **Performance**: each row is a memoised `CompanyRow` with a referentially - stable `onToggle`, so toggling one company (or unrelated re-renders from the - route navigation) doesn't reconcile all ~268 rows — keeping the animation - smooth. - -### Placement - -- **Modal only**, under the Experience section in `filter-modal.tsx`. -- Not added to the mobile `dropdown-filter` bar in v1 (list is too long for a - combobox; revisit later). - -## 4. Current architecture (context for implementer) - -- **Filter state** lives in React context: - - `frontend/src/types/filters.ts` → `JobFilters`, `FilterState`. - - `frontend/src/context/filter/filter-provider.tsx` → parses URL → state, - `updateFilters`, `clearFilters`, `emptyFilterState`, `initialFilterState`. - - `frontend/src/context/filter/filter-context.tsx` → context type. -- **URL is the source of truth.** `CreateQueryString` in `frontend/src/lib/utils.ts` - serialises arrays as `key[]=v1&key[]=v2`. Array fields are re-parsed in - `filter-provider.tsx` via `searchParams.getAll("key[]")`. -- **Modal UI**: `frontend/src/components/filters/filter-modal.tsx` renders four - `FilterSectionGroup`s (`filter-section-group.tsx` → `toggle-tag.tsx`). -- **Results header**: `frontend/src/components/filters/filter-section.tsx` shows - "{total} Results"; `reset-filters.tsx` shows Reset when any filter is active. -- **Data layer**: `frontend/src/actions/jobs.fetch.ts` - - `getJobs(filters)` builds a Mongo query via `buildJobQuery`, paginates - (`PAGE_SIZE = 20`), returns `{ jobs, total }`, LRU-cached (1h). - - Collection: `client.db("default").collection("active_jobs")`. - - Base query always includes `outdated: false`. Company name field is - `company.name`. - - Sponsored-job logic builds on top of the same `query`, so any company - exclusion added to `buildJobQuery` automatically applies to sponsored picks. - -## 5. Design / implementation - -### 5.1 Data model + URL - -`frontend/src/types/filters.ts`: - -```ts -export interface JobFilters { - search: string; - jobTypes: JobType[]; - locations: LocationType[]; - workingRights: WorkingRight[]; - industryFields: IndustryField[]; - excludedCompanies: string[]; // NEW — company names to hide - page: number; -} -``` - -`filter-provider.tsx`: - -- Add `excludedCompanies: []` to `emptyFilterState`. -- Parse `searchParams.getAll("excludedCompanies[]")` in `initialFilterState` - (no enum validation — company names are dynamic; trim + dedupe). -- `CreateQueryString` already serialises arrays, so URL sync is automatic. - -`reset-filters.tsx`: - -- Include `excludedCompanies.length > 0` in `hasActiveFilters()`. - -### 5.2 Backend — facet counts - -New server action in `frontend/src/actions/jobs.fetch.ts`: - -```ts -export interface CompanyFacet { - company: string; - count: number; -} - -export async function getCompanyFacets( - filters: Partial, -): Promise; -``` - -- Reuse `buildJobQuery(filters)` but **omit the company `$nin` clause** (a facet - ignores its own selection). Implementation: strip `excludedCompanies` / - `excludedCompanies[]` before building, OR add a `buildJobQuery` option like - `{ includeCompanyExclusion: false }`. -- Aggregation: - -```js -[ - { $match: }, - { $group: { _id: "$company.name", count: { $sum: 1 } } }, - { $sort: { count: -1, _id: 1 } }, -] -``` - -- Map `_id → company`. LRU-cache with a key derived from the normalised - non-company filters (reuse `normalizeFiltersForKey`, exclude company key). - -Extend `buildJobQuery` to add the exclusion for the job list / total: - -```js -...(excludedCompanies?.length && { - "company.name": { $nin: excludedCompanies }, -}), -``` - -This flows into both the paginated `find` and `countDocuments(query)`, so the -results total respects the exclusion. - -### 5.3 Frontend — Company section component - -New `frontend/src/components/filters/company-filter-section.tsx` (client): - -- On mount / when modal opens, call `getCompanyFacets(filters.filters)`. -- Refetch when the _non-company_ filters change (industry/location/rights/ - experience/search). Do **not** refetch on `excludedCompanies` change (facets - ignore it) to avoid loops. -- State: `facets: CompanyFacet[]`, `loading`, `query` (search box). -- A company is "checked" when it is **not** in `excludedCompanies`. -- Toggling a row updates `excludedCompanies` via `updateFilters({ ..., page: 1 })`. -- Render: - - Header row: "Companies" + Clear/Reset buttons. - - Search `TextInput` (debounced ~200ms, client filter on company name). - - Scrollable list of rows: `Checkbox` + name (left) + count (right). - - "Hidden" chips row when `excludedCompanies.length > 0` — each chip removes - one company from the exclusion list. -- Loading + empty states (skeleton / "No companies match"). - -Wire into `filter-modal.tsx` after the Experience `FilterSectionGroup`. - -### 5.4 Files touched (summary) - -| File | Change | -| ----------------------------------------------- | ---------------------------------------------- | -| `types/filters.ts` | add `excludedCompanies` to `JobFilters` | -| `context/filter/filter-provider.tsx` | parse + default `excludedCompanies` | -| `components/filters/reset-filters.tsx` | include exclusions in active check | -| `actions/jobs.fetch.ts` | `getCompanyFacets` + `$nin` in `buildJobQuery` | -| `components/filters/company-filter-section.tsx` | NEW component | -| `components/filters/filter-modal.tsx` | render new section | - -## 6. Edge cases - -- **Stale exclusions**: a company excluded earlier may no longer appear under the - current other-filters. Still render it in the "Hidden" chips so it stays - restorable even if it's not in the current facet list. -- **Empty facets**: filters yield zero companies → show empty state; the page - already shows `NoResults` when `total <= 0`. -- **Large lists**: virtualise only if needed; start with `ScrollArea` (already - capped at `h={500}` in the modal). Search mitigates length. -- **Name as identity**: companies are keyed by `company.name` (no stable id in - the schema). Acceptable; matches how the job query already works. -- **Caching**: facet cache must key off non-company filters only so toggling - exclusions doesn't thrash the cache. -- **Sponsored jobs**: excluded companies must not appear even as sponsored — - satisfied automatically because sponsored query extends the base `query`. - -## 7. Validation / test plan - -Manual (local, see Section 9): - -1. Open Filters → Companies section lists companies with counts summing to total. -2. Apply Industry = Quant Trading → company list + counts refresh to quant-only. -3. Uncheck a company → it disappears from results, total drops, other counts - unchanged; a "Hidden" chip appears. -4. Click the Hidden chip ✕ → company returns to results. -5. Reload page → exclusions persist via URL (`excludedCompanies[]=...`). -6. Reset Filters → exclusions cleared, all companies shown. -7. Search box filters the company rows. - -Automated/typecheck: - -- `npm run lint` clean, `npx tsc --noEmit` clean, `npm run build` succeeds. - -## 8. Out of scope (v1) - -- Company logos in the checklist rows. -- Inclusion mode ("only these companies") — exclusion covers the requested UX. -- Company filter in the mobile dropdown bar. -- List virtualisation (revisit if perf requires). - -## 9. Running locally (to play & test) - -```bash -cd frontend -npm install -# create env (see frontend/.env.example), then: -npm run dev # http://localhost:3000 -> /jobs -``` - -Requires `MONGODB_URI` pointing at a database with an `active_jobs` collection. -See `frontend/.env.example`. Without it, `getJobs` throws and no jobs render. - -## 10. Resolved decisions - -- **Bulk actions**: shipped a single accent **Clear** (shown only when something - is hidden). Dropped "Hide all"/"Show all" — hiding everything yields 0 results - and confused the clear path. -- **Fetch timing**: lazy — facets are fetched when the component mounts (i.e. - when the modal first opens) and refetched only when a non-company filter - changes. Results are LRU-cached server-side, so re-opening is instant. From 460035f67b82149001889ac193fdb56bded477bb Mon Sep 17 00:00:00 2001 From: michaelyichizhang Date: Fri, 19 Jun 2026 02:57:38 +1000 Subject: [PATCH 8/8] Apply Prettier formatting to jobs.fetch.ts --- frontend/src/actions/jobs.fetch.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/actions/jobs.fetch.ts b/frontend/src/actions/jobs.fetch.ts index d18874ca..b5c82fbb 100644 --- a/frontend/src/actions/jobs.fetch.ts +++ b/frontend/src/actions/jobs.fetch.ts @@ -372,7 +372,11 @@ export async function getCompanyFacets( .aggregate<{ _id: string; count: number; - }>([{ $match: query }, { $group: { _id: "$company.name", count: { $sum: 1 } } }, { $sort: { count: -1, _id: 1 } }]) + }>([ + { $match: query }, + { $group: { _id: "$company.name", count: { $sum: 1 } } }, + { $sort: { count: -1, _id: 1 } }, + ]) .toArray(); const facets: CompanyFacet[] = results