diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 8333d6d..f79f6f3 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -158,16 +158,31 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({ OrchestratorFilters: ({ onTabChange, onSearchQueryChange, + onSourceFilterChange, + onSponsorFilterChange, + onSalaryFilterChange, + onResetFilters, onSortChange, sourcesWithJobs, + filteredCount, }: { onTabChange: (t: FilterTab) => void; onSearchQueryChange: (q: string) => void; + onSourceFilterChange: (source: string) => void; + onSponsorFilterChange: (value: string) => void; + onSalaryFilterChange: (value: { + mode: "at_least" | "at_most" | "between"; + min: number | null; + max: number | null; + }) => void; + onResetFilters: () => void; onSortChange: (s: any) => void; sourcesWithJobs: string[]; + filteredCount: number; }) => (
{sourcesWithJobs.join(",")}
+
{filteredCount}
@@ -180,6 +195,27 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({ > Set Sort + + + +
), })); @@ -412,6 +448,57 @@ describe("OrchestratorPage", () => { ); }); + it("syncs source, sponsor, and salary range filters to URL and resets them", () => { + window.matchMedia = createMatchMedia( + true, + ) as unknown as typeof window.matchMedia; + + render( + + + + } /> + } /> + + , + ); + + fireEvent.click(screen.getByText("Set Source")); + expect(screen.getByTestId("location").textContent).toContain( + "source=linkedin", + ); + + fireEvent.click(screen.getByText("Set Sponsor")); + expect(screen.getByTestId("location").textContent).toContain( + "sponsor=confirmed", + ); + + fireEvent.click(screen.getByText("Set Salary Range")); + expect(screen.getByTestId("location").textContent).toContain( + "salaryMode=between", + ); + expect(screen.getByTestId("location").textContent).toContain( + "salaryMin=60000", + ); + expect(screen.getByTestId("location").textContent).toContain( + "salaryMax=90000", + ); + + fireEvent.click(screen.getByText("Set Sort")); + expect(screen.getByTestId("location").textContent).toContain( + "sort=title-asc", + ); + + fireEvent.click(screen.getByText("Reset Filters")); + const locationText = screen.getByTestId("location").textContent || ""; + expect(locationText).not.toContain("source="); + expect(locationText).not.toContain("sponsor="); + expect(locationText).not.toContain("salaryMode="); + expect(locationText).not.toContain("salaryMin="); + expect(locationText).not.toContain("salaryMax="); + expect(locationText).not.toContain("sort="); + }); + it("opens the detail drawer on mobile when a job is selected", () => { window.matchMedia = createMatchMedia( false, diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 0019998..1e536c9 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -6,15 +6,14 @@ import { useSettings } from "@client/hooks/useSettings"; import type { JobSource } from "@shared/types.js"; import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import * as api from "../api"; import type { AutomaticRunValues } from "./orchestrator/automatic-run"; import { deriveExtractorLimits } from "./orchestrator/automatic-run"; -import type { FilterTab, JobSort } from "./orchestrator/constants"; -import { DEFAULT_SORT } from "./orchestrator/constants"; +import type { FilterTab } from "./orchestrator/constants"; import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar"; import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; import { JobListPanel } from "./orchestrator/JobListPanel"; @@ -26,6 +25,7 @@ import type { RunMode } from "./orchestrator/run-mode"; import { useBulkJobSelection } from "./orchestrator/useBulkJobSelection"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; +import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters"; import { usePipelineSources } from "./orchestrator/usePipelineSources"; import { getEnabledSources, @@ -36,7 +36,20 @@ import { export const OrchestratorPage: React.FC = () => { const { tab, jobId } = useParams<{ tab: string; jobId?: string }>(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); + const { + searchParams, + searchQuery, + setSearchQuery, + sourceFilter, + setSourceFilter, + sponsorFilter, + setSponsorFilter, + salaryFilter, + setSalaryFilter, + sort, + setSort, + resetFilters, + } = useOrchestratorFilters(); const activeTab = useMemo(() => { const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"]; @@ -61,70 +74,6 @@ export const OrchestratorPage: React.FC = () => { const selectedJobId = jobId || null; - // Sync searchQuery with URL - const searchQuery = searchParams.get("q") || ""; - const setSearchQuery = useCallback( - (q: string) => { - setSearchParams( - (prev) => { - if (q) prev.set("q", q); - else prev.delete("q"); - return prev; - }, - { replace: true }, - ); - }, - [setSearchParams], - ); - - // Sync sourceFilter with URL - const sourceFilter = - (searchParams.get("source") as JobSource | "all") || "all"; - const setSourceFilter = useCallback( - (source: JobSource | "all") => { - setSearchParams( - (prev) => { - if (source !== "all") prev.set("source", source); - else prev.delete("source"); - return prev; - }, - { replace: true }, - ); - }, - [setSearchParams], - ); - - // Sync sort with URL - const sort = useMemo((): JobSort => { - const s = searchParams.get("sort"); - if (!s) return DEFAULT_SORT; - const [key, direction] = s.split("-"); - return { - key: key as JobSort["key"], - direction: direction as JobSort["direction"], - }; - }, [searchParams]); - - const setSort = useCallback( - (newSort: JobSort) => { - setSearchParams( - (prev) => { - if ( - newSort.key === DEFAULT_SORT.key && - newSort.direction === DEFAULT_SORT.direction - ) { - prev.delete("sort"); - } else { - prev.set("sort", `${newSort.key}-${newSort.direction}`); - } - return prev; - }, - { replace: true }, - ); - }, - [setSearchParams], - ); - // Effect to sync URL if it was invalid useEffect(() => { const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"]; @@ -178,6 +127,8 @@ export const OrchestratorPage: React.FC = () => { jobs, activeTab, sourceFilter, + sponsorFilter, + salaryFilter, searchQuery, sort, ); @@ -385,7 +336,11 @@ export const OrchestratorPage: React.FC = () => { onCancelPipeline={handleCancelPipeline} /> -
+
0 ? "pb-36 lg:pb-12" : "pb-12" + }`} + > { onSearchQueryChange={setSearchQuery} sourceFilter={sourceFilter} onSourceFilterChange={setSourceFilter} + sponsorFilter={sponsorFilter} + onSponsorFilterChange={setSponsorFilter} + salaryFilter={salaryFilter} + onSalaryFilterChange={setSalaryFilter} sourcesWithJobs={sourcesWithJobs} sort={sort} onSortChange={setSort} + onResetFilters={resetFilters} + filteredCount={activeJobs.length} /> {/* List/Detail grid - directly under tabs, no extra section */} diff --git a/orchestrator/src/client/pages/orchestrator/FloatingBulkActionsBar.tsx b/orchestrator/src/client/pages/orchestrator/FloatingBulkActionsBar.tsx index b8db8a0..897487a 100644 --- a/orchestrator/src/client/pages/orchestrator/FloatingBulkActionsBar.tsx +++ b/orchestrator/src/client/pages/orchestrator/FloatingBulkActionsBar.tsx @@ -44,59 +44,65 @@ export const FloatingBulkActionsBar: React.FC = ({ if (!isMounted) return null; return ( -
+
-
+
{selectedCount} selected
- {canMoveSelected && ( +
+ {canMoveSelected && ( + + )} + {canSkipSelected && ( + + )} + {canRescoreSelected && ( + + )} - )} - {canSkipSelected && ( - - )} - {canRescoreSelected && ( - - )} - +
); diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx index 1ff71b1..203afb4 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx @@ -48,7 +48,7 @@ export const JobListPanel: React.FC = ({
) : (
-
+
- - + + + - - - Filter by source - - - onSourceFilterChange(value as JobSource | "all") - } - > - - All Sources - - {visibleSources.map((source) => ( - - {sourceLabel[source]} - - ))} - - - - - - - - - - Sort by - - - onSortChange({ - key: value as JobSort["key"], - direction: defaultSortDirection[value as JobSort["key"]], - }) - } - > - {(Object.keys(sortLabels) as Array).map( - (key) => ( - - {sortLabels[key]} - - ), + Filters + {activeFilterCount > 0 && ( + + {activeFilterCount} + )} - - - - onSortChange({ - ...sort, - direction: sort.direction === "asc" ? "desc" : "asc", - }) - } - > - Direction:{" "} - {sort.direction === "asc" ? "Ascending" : "Descending"} - - - + + + + +
+ + + Filters + {activeFilterCount > 0 && ( + + {activeFilterCount} + + )} + + + Refine sources, sponsor status, salary, and sorting. + + + + + +
+ + + Sources + + + + {visibleSources.map((source) => ( + + ))} + + + + + + Sponsor status + + + {sponsorOptions.map((option) => ( + + ))} + + + + + + Salary + + +
+ Salary is + +
+ +
+ {showSalaryMin && ( +
+ + { + const raw = event.target.value.trim(); + const parsed = Number.parseInt(raw, 10); + onSalaryFilterChange({ + ...salaryFilter, + min: + Number.isFinite(parsed) && parsed > 0 + ? parsed + : null, + }); + }} + inputMode="numeric" + placeholder="e.g. 60000" + /> +
+ )} + + {showSalaryMax && ( +
+ + { + const raw = event.target.value.trim(); + const parsed = Number.parseInt(raw, 10); + onSalaryFilterChange({ + ...salaryFilter, + max: + Number.isFinite(parsed) && parsed > 0 + ? parsed + : null, + }); + }} + inputMode="numeric" + placeholder="e.g. 100000" + /> +
+ )} +
+
+
+ + + + Sort + + +
+
+ Sort by + +
+ +
+ and + +
+
+
+
+
+ +
+ + +
+
+
+
diff --git a/orchestrator/src/client/pages/orchestrator/constants.ts b/orchestrator/src/client/pages/orchestrator/constants.ts index 958b657..e21f6fe 100644 --- a/orchestrator/src/client/pages/orchestrator/constants.ts +++ b/orchestrator/src/client/pages/orchestrator/constants.ts @@ -60,8 +60,26 @@ export const defaultStatusToken = { export type FilterTab = "ready" | "discovered" | "applied" | "all"; -export type SortKey = "discoveredAt" | "score" | "title" | "employer"; +export type SortKey = + | "discoveredAt" + | "score" + | "salary" + | "title" + | "employer"; export type SortDirection = "asc" | "desc"; +export type SponsorFilter = + | "all" + | "confirmed" + | "potential" + | "not_found" + | "unknown"; +export type SalaryFilterMode = "at_least" | "at_most" | "between"; + +export interface SalaryFilter { + mode: SalaryFilterMode; + min: number | null; + max: number | null; +} export interface JobSort { key: SortKey; @@ -73,6 +91,7 @@ export const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" }; export const sortLabels: Record = { discoveredAt: "Discovered", score: "Score", + salary: "Salary", title: "Title", employer: "Company", }; @@ -80,6 +99,7 @@ export const sortLabels: Record = { export const defaultSortDirection: Record = { discoveredAt: "desc", score: "desc", + salary: "desc", title: "asc", employer: "asc", }; diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts new file mode 100644 index 0000000..14ecab5 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts @@ -0,0 +1,154 @@ +import type { Job } from "@shared/types"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useFilteredJobs } from "./useFilteredJobs"; + +const baseJob: Job = { + id: "job-1", + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: "Engineer", + employer: "Acme", + employerUrl: null, + jobUrl: "https://example.com/job-1", + applicationLink: null, + disciplines: null, + deadline: null, + salary: null, + location: "London", + degreeRequired: null, + starting: null, + jobDescription: "Desc", + status: "ready", + outcome: null, + closedAt: null, + suitabilityScore: 90, + suitabilityReason: null, + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: null, + jobType: null, + salarySource: null, + salaryInterval: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + isRemote: null, + jobLevel: null, + jobFunction: null, + listingType: null, + emails: null, + companyIndustry: null, + companyLogo: null, + companyUrlDirect: null, + companyAddresses: null, + companyNumEmployees: null, + companyRevenue: null, + companyDescription: null, + skills: null, + experienceRange: null, + companyRating: null, + companyReviewsCount: null, + vacancyCount: null, + workFromHomeType: null, + discoveredAt: "2025-01-01T00:00:00Z", + processedAt: null, + appliedAt: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", +}; + +describe("useFilteredJobs", () => { + it("filters by sponsor status categories", () => { + const jobs: Job[] = [ + { ...baseJob, id: "confirmed", sponsorMatchScore: 99 }, + { ...baseJob, id: "potential", sponsorMatchScore: 82 }, + { ...baseJob, id: "not-found", sponsorMatchScore: 45 }, + { ...baseJob, id: "unknown", sponsorMatchScore: null }, + ]; + + const { result } = renderHook(() => + useFilteredJobs( + jobs, + "all", + "all", + "confirmed", + { mode: "at_least", min: null, max: null }, + "", + { + key: "score", + direction: "desc", + }, + ), + ); + + expect(result.current.map((job) => job.id)).toEqual(["confirmed"]); + }); + + it("filters by salary range using structured and text salary fields", () => { + const jobs: Job[] = [ + { ...baseJob, id: "structured", salaryMinAmount: 70000 }, + { ...baseJob, id: "k-format", salary: "GBP 65k" }, + { ...baseJob, id: "below", salary: "GBP 55k" }, + { ...baseJob, id: "none", salary: null }, + ]; + + const { result } = renderHook(() => + useFilteredJobs( + jobs, + "all", + "all", + "all", + { mode: "between", min: 60000, max: 80000 }, + "", + { + key: "score", + direction: "desc", + }, + ), + ); + + expect(result.current.map((job) => job.id)).toEqual( + expect.arrayContaining(["structured", "k-format"]), + ); + expect(result.current).toHaveLength(2); + }); + + it("sorts by salary with highest first and missing salaries last", () => { + const jobs: Job[] = [ + { ...baseJob, id: "max", salaryMinAmount: 120000 }, + { ...baseJob, id: "mid", salary: "GBP 65k" }, + { ...baseJob, id: "low", salaryMinAmount: 50000 }, + { ...baseJob, id: "none", salary: null, salaryMinAmount: null }, + ]; + + const { result } = renderHook(() => + useFilteredJobs( + jobs, + "all", + "all", + "all", + { mode: "at_least", min: null, max: null }, + "", + { + key: "salary", + direction: "desc", + }, + ), + ); + + expect(result.current.map((job) => job.id)).toEqual([ + "max", + "mid", + "low", + "none", + ]); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts index 4b3150f..570e288 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts @@ -1,12 +1,26 @@ import type { Job, JobSource } from "@shared/types"; import { useMemo } from "react"; -import type { FilterTab, JobSort } from "./constants"; -import { compareJobs, jobMatchesQuery } from "./utils"; +import type { + FilterTab, + JobSort, + SalaryFilter, + SponsorFilter, +} from "./constants"; +import { compareJobs, jobMatchesQuery, parseSalaryBounds } from "./utils"; + +const getSponsorCategory = (score: number | null): SponsorFilter => { + if (score == null) return "unknown"; + if (score >= 95) return "confirmed"; + if (score >= 80) return "potential"; + return "not_found"; +}; export const useFilteredJobs = ( jobs: Job[], activeTab: FilterTab, sourceFilter: JobSource | "all", + sponsorFilter: SponsorFilter, + salaryFilter: SalaryFilter, searchQuery: string, sort: JobSort, ) => @@ -27,9 +41,61 @@ export const useFilteredJobs = ( filtered = filtered.filter((job) => job.source === sourceFilter); } + if (sponsorFilter !== "all") { + filtered = filtered.filter( + (job) => getSponsorCategory(job.sponsorMatchScore) === sponsorFilter, + ); + } + + const hasMin = + typeof salaryFilter.min === "number" && + Number.isFinite(salaryFilter.min) && + salaryFilter.min > 0; + const hasMax = + typeof salaryFilter.max === "number" && + Number.isFinite(salaryFilter.max) && + salaryFilter.max > 0; + + if ( + (salaryFilter.mode === "at_least" && hasMin) || + (salaryFilter.mode === "at_most" && hasMax) || + (salaryFilter.mode === "between" && (hasMin || hasMax)) + ) { + filtered = filtered.filter((job) => { + const bounds = parseSalaryBounds(job); + if (!bounds) return false; + + if (salaryFilter.mode === "at_least") { + return hasMin ? bounds.max >= (salaryFilter.min as number) : true; + } + + if (salaryFilter.mode === "at_most") { + return hasMax ? bounds.min <= (salaryFilter.max as number) : true; + } + + const min = hasMin ? (salaryFilter.min as number) : null; + const max = hasMax ? (salaryFilter.max as number) : null; + + if (min != null && max != null) { + return bounds.max >= min && bounds.min <= max; + } + if (min != null) return bounds.max >= min; + if (max != null) return bounds.min <= max; + return true; + }); + } + if (searchQuery.trim()) { filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery)); } return [...filtered].sort((a, b) => compareJobs(a, b, sort)); - }, [jobs, activeTab, sourceFilter, searchQuery, sort]); + }, [ + jobs, + activeTab, + sourceFilter, + sponsorFilter, + salaryFilter, + searchQuery, + sort, + ]); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx new file mode 100644 index 0000000..d2720af --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx @@ -0,0 +1,42 @@ +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_SORT } from "./constants"; +import { useOrchestratorFilters } from "./useOrchestratorFilters"; + +const createWrapper = (initialEntry: string) => { + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = "RouterWrapper"; + return Wrapper; +}; + +describe("useOrchestratorFilters", () => { + it("parses a valid sort query param", () => { + const { result } = renderHook(() => useOrchestratorFilters(), { + wrapper: createWrapper("/ready?sort=title-asc"), + }); + + expect(result.current.sort).toEqual({ + key: "title", + direction: "asc", + }); + }); + + it("falls back to default sort for invalid sort query params", () => { + const cases = [ + "/ready?sort=title", + "/ready?sort=invalid-asc", + "/ready?sort=title-sideways", + ]; + + for (const entry of cases) { + const { result } = renderHook(() => useOrchestratorFilters(), { + wrapper: createWrapper(entry), + }); + expect(result.current.sort).toEqual(DEFAULT_SORT); + } + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts new file mode 100644 index 0000000..8b57983 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts @@ -0,0 +1,196 @@ +import type { JobSource } from "@shared/types.js"; +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { + JobSort, + SalaryFilter, + SalaryFilterMode, + SponsorFilter, +} from "./constants"; +import { DEFAULT_SORT } from "./constants"; + +const allowedSponsorFilters: SponsorFilter[] = [ + "all", + "confirmed", + "potential", + "not_found", + "unknown", +]; +const allowedSalaryModes: SalaryFilterMode[] = [ + "at_least", + "at_most", + "between", +]; +const allowedSortKeys: JobSort["key"][] = [ + "discoveredAt", + "score", + "salary", + "title", + "employer", +]; +const allowedSortDirections: JobSort["direction"][] = ["asc", "desc"]; + +export const useOrchestratorFilters = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const searchQuery = searchParams.get("q") || ""; + const setSearchQuery = useCallback( + (query: string) => { + setSearchParams( + (prev) => { + if (query) prev.set("q", query); + else prev.delete("q"); + return prev; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const sourceFilter = + (searchParams.get("source") as JobSource | "all") || "all"; + const setSourceFilter = useCallback( + (source: JobSource | "all") => { + setSearchParams( + (prev) => { + if (source !== "all") prev.set("source", source); + else prev.delete("source"); + return prev; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const sponsorFilter = useMemo((): SponsorFilter => { + const raw = searchParams.get("sponsor") ?? "all"; + return allowedSponsorFilters.includes(raw as SponsorFilter) + ? (raw as SponsorFilter) + : "all"; + }, [searchParams]); + + const setSponsorFilter = useCallback( + (value: SponsorFilter) => { + setSearchParams( + (prev) => { + if (value === "all") prev.delete("sponsor"); + else prev.set("sponsor", value); + return prev; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const salaryFilter = useMemo((): SalaryFilter => { + const modeRaw = searchParams.get("salaryMode") ?? "at_least"; + const mode = allowedSalaryModes.includes(modeRaw as SalaryFilterMode) + ? (modeRaw as SalaryFilterMode) + : "at_least"; + + const minRaw = + searchParams.get("salaryMin") ?? searchParams.get("minSalary"); + const minParsed = minRaw == null ? Number.NaN : Number.parseInt(minRaw, 10); + const min = Number.isFinite(minParsed) && minParsed > 0 ? minParsed : null; + + const maxRaw = searchParams.get("salaryMax"); + const maxParsed = maxRaw == null ? Number.NaN : Number.parseInt(maxRaw, 10); + const max = Number.isFinite(maxParsed) && maxParsed > 0 ? maxParsed : null; + + return { mode, min, max }; + }, [searchParams]); + + const setSalaryFilter = useCallback( + (value: SalaryFilter) => { + setSearchParams( + (prev) => { + if (value.mode === "at_least") prev.delete("salaryMode"); + else prev.set("salaryMode", value.mode); + + if (value.min == null || value.min <= 0) prev.delete("salaryMin"); + else prev.set("salaryMin", String(value.min)); + + if (value.max == null || value.max <= 0) prev.delete("salaryMax"); + else prev.set("salaryMax", String(value.max)); + + prev.delete("minSalary"); + return prev; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const sort = useMemo((): JobSort => { + const sortValue = searchParams.get("sort"); + if (!sortValue) return DEFAULT_SORT; + + const [key, direction] = sortValue.split("-"); + if ( + !allowedSortKeys.includes(key as JobSort["key"]) || + !allowedSortDirections.includes(direction as JobSort["direction"]) + ) { + return DEFAULT_SORT; + } + + return { + key: key as JobSort["key"], + direction: direction as JobSort["direction"], + }; + }, [searchParams]); + + const setSort = useCallback( + (newSort: JobSort) => { + setSearchParams( + (prev) => { + if ( + newSort.key === DEFAULT_SORT.key && + newSort.direction === DEFAULT_SORT.direction + ) { + prev.delete("sort"); + } else { + prev.set("sort", `${newSort.key}-${newSort.direction}`); + } + return prev; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const resetFilters = useCallback(() => { + setSearchParams( + (prev) => { + prev.delete("source"); + prev.delete("sponsor"); + prev.delete("salaryMode"); + prev.delete("salaryMin"); + prev.delete("salaryMax"); + prev.delete("minSalary"); + prev.delete("sort"); + return prev; + }, + { replace: true }, + ); + }, [setSearchParams]); + + return { + searchParams, + searchQuery, + setSearchQuery, + sourceFilter, + setSourceFilter, + sponsorFilter, + setSponsorFilter, + salaryFilter, + setSalaryFilter, + sort, + setSort, + resetFilters, + }; +}; diff --git a/orchestrator/src/client/pages/orchestrator/utils.ts b/orchestrator/src/client/pages/orchestrator/utils.ts index f9c3004..6749d03 100644 --- a/orchestrator/src/client/pages/orchestrator/utils.ts +++ b/orchestrator/src/client/pages/orchestrator/utils.ts @@ -12,6 +12,46 @@ const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); const compareNumber = (a: number, b: number) => a - b; +export const parseSalaryBounds = ( + job: Job, +): { min: number; max: number } | null => { + if ( + typeof job.salaryMinAmount === "number" && + Number.isFinite(job.salaryMinAmount) + ) { + if ( + typeof job.salaryMaxAmount === "number" && + Number.isFinite(job.salaryMaxAmount) + ) { + return { min: job.salaryMinAmount, max: job.salaryMaxAmount }; + } + return { min: job.salaryMinAmount, max: job.salaryMinAmount }; + } + if ( + typeof job.salaryMaxAmount === "number" && + Number.isFinite(job.salaryMaxAmount) + ) { + return { min: job.salaryMaxAmount, max: job.salaryMaxAmount }; + } + if (!job.salary) return null; + + const normalized = job.salary.toLowerCase().replace(/,/g, ""); + const values: number[] = []; + + const kPattern = /(\d+(?:\.\d+)?)\s*k\b/g; + for (const match of normalized.matchAll(kPattern)) { + values.push(Math.round(Number.parseFloat(match[1]) * 1000)); + } + + const plainPattern = /(\d{4,6}(?:\.\d+)?)/g; + for (const match of normalized.matchAll(plainPattern)) { + values.push(Math.round(Number.parseFloat(match[1]))); + } + + if (values.length === 0) return null; + return { min: Math.min(...values), max: Math.max(...values) }; +}; + export const compareJobs = (a: Job, b: Job, sort: JobSort) => { let value = 0; @@ -35,6 +75,21 @@ export const compareJobs = (a: Job, b: Job, sort: JobSort) => { value = compareNumber(aScore, bScore); break; } + case "salary": { + const aSalary = parseSalaryBounds(a); + const bSalary = parseSalaryBounds(b); + if (aSalary == null && bSalary == null) { + value = 0; + break; + } + if (aSalary == null) return 1; + if (bSalary == null) return -1; + value = compareNumber(aSalary.max, bSalary.max); + if (value === 0) { + value = compareNumber(aSalary.min, bSalary.min); + } + break; + } case "discoveredAt": { const aDate = dateValue(a.discoveredAt); const bDate = dateValue(b.discoveredAt); diff --git a/scripts/linecount.py b/scripts/linecount.py index a5f39b7..1ae67af 100644 --- a/scripts/linecount.py +++ b/scripts/linecount.py @@ -21,6 +21,12 @@ SKIP_DIRS_DEFAULT = { ".idea", ".vscode", "data", + "tests", + "__tests__", + "spec", + "specs", + "cypress", + "e2e", } ALLOWED_EXTS = { @@ -140,6 +146,23 @@ def count_file(path: Path): return code, comment, blank +def is_test_file(path: Path) -> bool: + stem = path.stem.lower() + return ( + stem.startswith("test_") + or stem.startswith("test-") + or stem.endswith("_test") + or stem.endswith("-test") + or stem.endswith(".test") + or stem.endswith(".spec") + or stem == "test" + or "setuptests" in stem + or "vitest.setup" in stem + or "jest.setup" in stem + or "test-utils" in stem + ) + + def parse_args(): parser = argparse.ArgumentParser( description="Count code/comment/blank lines by extension." @@ -162,6 +185,16 @@ def parse_args(): default=[], help="Directory name to exclude (repeatable).", ) + parser.add_argument( + "--list-files", + action="store_true", + help="List all files that are counted.", + ) + parser.add_argument( + "--include-tests", + action="store_true", + help="Count test files (normally skipped).", + ) return parser.parse_args() @@ -186,18 +219,15 @@ def main(): if ext not in ALLOWED_EXTS: continue - stem = path.stem.lower() - if ( - stem.startswith("test_") - or stem.endswith("_test") - or stem.endswith(".test") - or stem.endswith(".spec") - ): + if not args.include_tests and is_test_file(path): continue if is_binary(str(path)): continue + if args.list_files: + print(f"Counting: {path.relative_to(root)}") + code, comment, blank = count_file(path) key = ext if ext else ""