Additional Filter support (#103)

* Initial commit

* drawer initial implementation

* fix resetting

* formattign

* sort by salary

* sentence reading

* salary bounds

* responsiveness

* fix copy

* floating bar on mobile

* allow selecting on mobile

* formatting

* refactor

* don't count test files

* validate url after parsing
This commit is contained in:
Shaheer Sarfaraz 2026-02-08 00:19:26 +00:00 committed by GitHub
parent 2fe0dc2c2f
commit cf7032ce5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1220 additions and 299 deletions

View File

@ -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;
}) => (
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<div data-testid="filtered-count">{filteredCount}</div>
<button type="button" onClick={() => onTabChange("discovered")}>
To Discovered
</button>
@ -180,6 +195,27 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
>
Set Sort
</button>
<button type="button" onClick={() => onSourceFilterChange("linkedin")}>
Set Source
</button>
<button type="button" onClick={() => onSponsorFilterChange("confirmed")}>
Set Sponsor
</button>
<button
type="button"
onClick={() =>
onSalaryFilterChange({
mode: "between",
min: 60000,
max: 90000,
})
}
>
Set Salary Range
</button>
<button type="button" onClick={onResetFilters}>
Reset Filters
</button>
</div>
),
}));
@ -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(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
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,

View File

@ -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}
/>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<main
className={`container mx-auto max-w-7xl space-y-6 px-4 py-6 ${
selectedJobIds.size > 0 ? "pb-36 lg:pb-12" : "pb-12"
}`}
>
<OrchestratorSummary
stats={stats}
isPipelineRunning={isPipelineRunning}
@ -401,9 +356,15 @@ export const OrchestratorPage: React.FC = () => {
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 */}

View File

@ -44,22 +44,24 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
if (!isMounted) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4">
<div
className={cn(
"pointer-events-auto flex flex-wrap items-center gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85",
"pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center",
"transition-all duration-200 ease-out",
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
)}
>
<div className="text-xs text-muted-foreground tabular-nums">
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
{selectedCount} selected
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{canMoveSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={bulkActionInFlight}
onClick={onMoveToReady}
>
@ -71,6 +73,7 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={bulkActionInFlight}
onClick={onSkipSelected}
>
@ -82,6 +85,7 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={bulkActionInFlight}
onClick={onRescoreSelected}
>
@ -92,6 +96,7 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
type="button"
size="sm"
variant="ghost"
className="w-full sm:w-auto"
onClick={onClear}
disabled={bulkActionInFlight}
>
@ -99,5 +104,6 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
</Button>
</div>
</div>
</div>
);
};

View File

@ -48,7 +48,7 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
</div>
) : (
<div className="divide-y divide-border/40">
<div className="flex items-center justify-between gap-3 px-4 py-2 opacity-50 hover:opacity-100 transition-opacity">
<div className="flex items-center justify-between gap-3 px-4 py-2 opacity-100 transition-opacity sm:opacity-50 sm:hover:opacity-100">
<label
htmlFor="job-list-select-all"
className="flex items-center gap-2 text-xs text-muted-foreground"
@ -103,7 +103,7 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
"data-[state=checked]:shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]",
isChecked || isSelected
? "opacity-100"
: "opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100",
: "opacity-100 pointer-events-auto sm:opacity-0 sm:pointer-events-none sm:group-hover:pointer-events-auto sm:group-hover:opacity-100",
)}
/>
{/* Single status indicator: subtle dot */}

View File

@ -1,80 +1,24 @@
import type { JobSource } from "@shared/types.js";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import type { FilterTab, JobSort } from "./constants";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { FilterTab, JobSort, SponsorFilter } from "./constants";
import { OrchestratorFilters } from "./OrchestratorFilters";
vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react");
const RadioGroupContext = React.createContext<
((value: string) => void) | null
>(null);
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
...props
}: {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button
type="button"
role="menuitem"
onClick={() => onSelect?.()}
{...props}
>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuRadioGroup: ({
children,
onValueChange,
}: {
children: React.ReactNode;
onValueChange?: (value: string) => void;
}) => (
<RadioGroupContext.Provider value={onValueChange ?? null}>
<div role="radiogroup">{children}</div>
</RadioGroupContext.Provider>
),
DropdownMenuRadioItem: ({
children,
value,
checked,
}: {
children: React.ReactNode;
value: string;
checked?: boolean;
}) => {
const onValueChange = React.useContext(RadioGroupContext);
return (
<button
type="button"
role="menuitemradio"
aria-checked={checked}
onClick={() => onValueChange?.(value)}
>
{children}
</button>
);
},
};
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterAll(() => {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView,
});
});
const renderFilters = (
@ -93,9 +37,19 @@ const renderFilters = (
onSearchQueryChange: vi.fn(),
sourceFilter: "all" as const,
onSourceFilterChange: vi.fn(),
sponsorFilter: "all" as SponsorFilter,
onSponsorFilterChange: vi.fn(),
salaryFilter: {
mode: "at_least" as const,
min: null,
max: null,
},
onSalaryFilterChange: vi.fn(),
sourcesWithJobs: ["gradcracker", "linkedin", "manual"] as JobSource[],
sort: { key: "score", direction: "desc" } as JobSort,
onSortChange: vi.fn(),
onResetFilters: vi.fn(),
filteredCount: 5,
...overrides,
};
@ -118,41 +72,74 @@ describe("OrchestratorFilters", () => {
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
});
it("updates source and sort selections", async () => {
it("updates source, sponsor, salary range, and sort from the drawer", async () => {
const { props } = renderFilters();
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
fireEvent.click(
await screen.findByRole("menuitemradio", { name: /LinkedIn/i }),
);
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
fireEvent.click(screen.getByRole("button", { name: "LinkedIn" }));
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
fireEvent.click(screen.getByRole("button", { name: "Potential sponsor" }));
expect(props.onSponsorFilterChange).toHaveBeenCalledWith("potential");
fireEvent.change(screen.getByLabelText("Minimum"), {
target: { value: "65000" },
});
expect(props.onSalaryFilterChange).toHaveBeenCalledWith({
mode: "at_least",
min: 65000,
max: null,
});
fireEvent.click(
await screen.findByRole("menuitem", { name: /Direction:/i }),
screen.getByRole("combobox", { name: "Salary range specifier" }),
);
fireEvent.click(await screen.findByText("between"));
expect(props.onSalaryFilterChange).toHaveBeenCalledWith({
mode: "between",
min: null,
max: null,
});
fireEvent.click(screen.getByRole("combobox", { name: "Sort field" }));
fireEvent.click(await screen.findByText("Title"));
expect(props.onSortChange).toHaveBeenCalledWith({
key: "title",
direction: "asc",
});
fireEvent.click(screen.getByRole("combobox", { name: "Sort field" }));
fireEvent.click(await screen.findByText("Company"));
expect(props.onSortChange).toHaveBeenCalledWith({
key: "employer",
direction: "asc",
});
fireEvent.click(screen.getByRole("combobox", { name: "Sort order" }));
fireEvent.click(await screen.findByText("smallest first"));
expect(props.onSortChange).toHaveBeenCalledWith({
key: "score",
direction: "asc",
});
});
it("only shows sources that exist in jobs", async () => {
renderFilters({ sourcesWithJobs: ["gradcracker", "manual"] });
it("resets filters and only shows sources present in jobs", () => {
const { props } = renderFilters({
sourcesWithJobs: ["gradcracker", "manual"],
});
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
expect(
await screen.findByRole("menuitemradio", { name: /Gradcracker/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("menuitemradio", { name: /LinkedIn/i }),
screen.queryByRole("button", { name: "LinkedIn" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i }),
).not.toBeInTheDocument();
expect(
screen.getByRole("menuitemradio", { name: /Manual/i }),
screen.getByRole("button", { name: "Gradcracker" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Manual" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(props.onResetFilters).toHaveBeenCalled();
});
});

View File

@ -1,27 +1,37 @@
import type { JobSource } from "@shared/types.js";
import { ArrowUpDown, Filter, Search } from "lucide-react";
import { Filter, Search } from "lucide-react";
import type React from "react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { sourceLabel } from "@/lib/utils";
import type { FilterTab, JobSort } from "./constants";
import {
defaultSortDirection,
orderedFilterSources,
sortLabels,
tabs,
import type {
FilterTab,
JobSort,
SalaryFilter,
SalaryFilterMode,
SponsorFilter,
} from "./constants";
import { defaultSortDirection, orderedFilterSources, tabs } from "./constants";
interface OrchestratorFiltersProps {
activeTab: FilterTab;
@ -31,11 +41,74 @@ interface OrchestratorFiltersProps {
onSearchQueryChange: (value: string) => void;
sourceFilter: JobSource | "all";
onSourceFilterChange: (value: JobSource | "all") => void;
sponsorFilter: SponsorFilter;
onSponsorFilterChange: (value: SponsorFilter) => void;
salaryFilter: SalaryFilter;
onSalaryFilterChange: (value: SalaryFilter) => void;
sourcesWithJobs: JobSource[];
sort: JobSort;
onSortChange: (sort: JobSort) => void;
onResetFilters: () => void;
filteredCount: number;
}
const sponsorOptions: Array<{
value: SponsorFilter;
label: string;
}> = [
{ value: "all", label: "All statuses" },
{ value: "confirmed", label: "Confirmed sponsor" },
{ value: "potential", label: "Potential sponsor" },
{ value: "not_found", label: "Sponsor not found" },
{ value: "unknown", label: "Unchecked sponsor" },
];
const salaryModeOptions: Array<{
value: SalaryFilterMode;
label: string;
}> = [
{ value: "at_least", label: "at least" },
{ value: "at_most", label: "at most" },
{ value: "between", label: "between" },
];
const sortFieldOrder: JobSort["key"][] = [
"score",
"discoveredAt",
"salary",
"title",
"employer",
];
const sortFieldLabels: Record<JobSort["key"], string> = {
score: "Score",
discoveredAt: "Discovered",
salary: "Salary",
title: "Title",
employer: "Company",
};
const getDirectionOptions = (
key: JobSort["key"],
): Array<{ value: JobSort["direction"]; label: string }> => {
if (key === "discoveredAt") {
return [
{ value: "desc", label: "newest first" },
{ value: "asc", label: "oldest first" },
];
}
if (key === "score" || key === "salary") {
return [
{ value: "desc", label: "largest first" },
{ value: "asc", label: "smallest first" },
];
}
return [
{ value: "asc", label: "A to Z" },
{ value: "desc", label: "Z to A" },
];
};
export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
activeTab,
onTabChange,
@ -44,14 +117,36 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onSearchQueryChange,
sourceFilter,
onSourceFilterChange,
sponsorFilter,
onSponsorFilterChange,
salaryFilter,
onSalaryFilterChange,
sourcesWithJobs,
sort,
onSortChange,
onResetFilters,
filteredCount,
}) => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const visibleSources = orderedFilterSources.filter((source) =>
sourcesWithJobs.includes(source),
);
const activeFilterCount = useMemo(
() =>
Number(sourceFilter !== "all") +
Number(sponsorFilter !== "all") +
Number(
(typeof salaryFilter.min === "number" && salaryFilter.min > 0) ||
(typeof salaryFilter.max === "number" && salaryFilter.max > 0),
),
[sourceFilter, sponsorFilter, salaryFilter.min, salaryFilter.max],
);
const showSalaryMin =
salaryFilter.mode === "at_least" || salaryFilter.mode === "between";
const showSalaryMax =
salaryFilter.mode === "at_most" || salaryFilter.mode === "between";
return (
<Tabs
value={activeTab}
@ -85,85 +180,307 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
className="h-8 pl-8 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all"
? "All sources"
: sourceLabel[sourceFilter]}
Filters
{activeFilterCount > 0 && (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-semibold tabular-nums text-primary">
{activeFilterCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(value) =>
onSourceFilterChange(value as JobSource | "all")
</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-2xl">
<div className="flex h-full min-h-0 flex-col">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
Filters
{activeFilterCount > 0 && (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/20 px-1 text-[11px] font-semibold tabular-nums text-primary">
{activeFilterCount}
</span>
)}
</SheetTitle>
<SheetDescription>
Refine sources, sponsor status, salary, and sorting.
</SheetDescription>
</SheetHeader>
<Separator className="my-4" />
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<Card>
<CardHeader className="pb-3">
<CardTitle>Sources</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={sourceFilter === "all" ? "default" : "outline"}
onClick={() => onSourceFilterChange("all")}
>
All sources
</Button>
{visibleSources.map((source) => (
<Button
key={source}
type="button"
size="sm"
variant={
sourceFilter === source ? "default" : "outline"
}
onClick={() => onSourceFilterChange(source)}
>
{sourceLabel[source]}
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Sponsor status</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{sponsorOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
sponsorFilter === option.value
? "default"
: "outline"
}
onClick={() => onSponsorFilterChange(option.value)}
>
{option.label}
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Salary</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Salary is</span>
<Select
value={salaryFilter.mode}
onValueChange={(value) => {
const nextMode = value as SalaryFilterMode;
if (nextMode === "at_least") {
onSalaryFilterChange({
mode: nextMode,
min: salaryFilter.min,
max: null,
});
return;
}
if (nextMode === "at_most") {
onSalaryFilterChange({
mode: nextMode,
min: null,
max: salaryFilter.max,
});
return;
}
onSalaryFilterChange({
mode: nextMode,
min: salaryFilter.min,
max: salaryFilter.max,
});
}}
>
<SelectTrigger
id="salary-mode"
aria-label="Salary range specifier"
className="h-8 w-[170px] text-foreground"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{salaryModeOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
className={
showSalaryMin && showSalaryMax
? "grid gap-3 md:grid-cols-2"
: "space-y-3"
}
>
<DropdownMenuRadioItem value="all">
All Sources
</DropdownMenuRadioItem>
{visibleSources.map((source) => (
<DropdownMenuRadioItem key={source} value={source}>
{sourceLabel[source]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{showSalaryMin && (
<div className="space-y-1">
<Label htmlFor="salary-min-filter">Minimum</Label>
<Input
id="salary-min-filter"
value={
salaryFilter.min == null
? ""
: String(salaryFilter.min)
}
onChange={(event) => {
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"
/>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
{showSalaryMax && (
<div className="space-y-1">
<Label htmlFor="salary-max-filter">Maximum</Label>
<Input
id="salary-max-filter"
value={
salaryFilter.max == null
? ""
: String(salaryFilter.max)
}
onChange={(event) => {
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"
/>
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Sort</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Sort by</span>
<Select
value={sort.key}
onValueChange={(value) =>
onSortChange({
key: value as JobSort["key"],
direction: defaultSortDirection[value as JobSort["key"]],
direction:
defaultSortDirection[value as JobSort["key"]],
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map(
(key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
),
)}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
<SelectTrigger
id="sort-key"
aria-label="Sort field"
className="h-8 flex-1 sm:w-[180px] text-foreground"
>
<SelectValue
placeholder={sortFieldLabels[sort.key]}
/>
</SelectTrigger>
<SelectContent>
{sortFieldOrder.map((key) => (
<SelectItem key={key} value={key}>
{sortFieldLabels[key]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">and</span>
<Select
value={sort.direction}
onValueChange={(value) =>
onSortChange({
...sort,
direction: sort.direction === "asc" ? "desc" : "asc",
direction: value as JobSort["direction"],
})
}
>
Direction:{" "}
{sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SelectTrigger
id="sort-direction"
aria-label="Sort order"
className="h-8 flex-1 sm:w-[180px] text-foreground"
>
<SelectValue
placeholder={
getDirectionOptions(sort.key).find(
(option) => option.value === sort.direction,
)?.label
}
/>
</SelectTrigger>
<SelectContent>
{getDirectionOptions(sort.key).map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-border/60 bg-background pt-3">
<Button
type="button"
variant="outline"
onClick={onResetFilters}
>
Reset
</Button>
<Button type="button" onClick={() => setIsDrawerOpen(false)}>
Show {filteredCount.toLocaleString()}{" "}
{filteredCount === 1 ? "job" : "jobs"}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</Tabs>

View File

@ -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<JobSort["key"], string> = {
discoveredAt: "Discovered",
score: "Score",
salary: "Salary",
title: "Title",
employer: "Company",
};
@ -80,6 +99,7 @@ export const sortLabels: Record<JobSort["key"], string> = {
export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
discoveredAt: "desc",
score: "desc",
salary: "desc",
title: "asc",
employer: "asc",
};

View File

@ -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",
]);
});
});

View File

@ -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,
]);

View File

@ -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 }) => (
<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>
);
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);
}
});
});

View File

@ -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,
};
};

View File

@ -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);

View File

@ -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 "<none>"