diff --git a/.gitignore b/.gitignore index a8fdb95..14b152c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ coverage/ # Miscellaneous .env .env.local +*.env *.local *.lock *.bak diff --git a/frontend/src/actions/company-facets.ts b/frontend/src/actions/company-facets.ts new file mode 100644 index 0000000..eec004c --- /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 2413881..b5c82fb 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: [ @@ -293,6 +315,84 @@ 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; 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 0000000..ce6aded --- /dev/null +++ b/frontend/src/components/filters/company-filter-section.tsx @@ -0,0 +1,346 @@ +// 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. 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(() => { + 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. + 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; + 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. + // The ref is updated in an effect (never during render) per react-hooks/refs. + const latest = useRef({ 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, + 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 bb130da..67a30cc 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)} /> + +
diff --git a/frontend/src/components/filters/reset-filters.tsx b/frontend/src/components/filters/reset-filters.tsx index 15d4cb5..5ef83ce 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 3a56955..a5c00c1 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 9bdefcd..dbb941f 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; +}