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
+
+
+
+
+
+
+
+
+
+ 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 ""