diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index fd9c509..e4cfc5e 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -94,6 +94,7 @@ POST /api/jobs/:id/generate-pdf - `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`. - The PDF is served at `/pdfs/resume_.pdf` and cache-busted with the job?s `updatedAt` timestamp. - If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`. +- Job text search is handled through the command bar (`Cmd/Ctrl+K`) and is not persisted as a URL filter. ## External payload and sanitization defaults diff --git a/orchestrator/README.md b/orchestrator/README.md index 8ae94b1..9ad0cda 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -89,6 +89,7 @@ orchestrator/ 2. **You review in the UI:** - See jobs at `http://localhost:5173` - "Ready" tab shows jobs with generated PDFs + - Use command bar search (`Cmd/Ctrl+K`) to quickly find and open jobs - Click "View Job" to open application - Download PDF and apply manually - Click "Mark Applied" → syncs to Notion diff --git a/orchestrator/src/client/lib/meta-key.test.ts b/orchestrator/src/client/lib/meta-key.test.ts new file mode 100644 index 0000000..a62e2d4 --- /dev/null +++ b/orchestrator/src/client/lib/meta-key.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + getMetaKeyLabel, + getMetaShortcutLabel, + isMetaKeyPressed, +} from "./meta-key"; + +describe("meta-key helper", () => { + it("returns command symbol for apple platforms", () => { + expect( + getMetaKeyLabel({ + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", + }), + ).toBe("⌘"); + }); + + it("returns ctrl label for non-apple platforms", () => { + expect( + getMetaKeyLabel({ + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }), + ).toBe("Ctrl"); + }); + + it("formats shortcut labels by platform", () => { + expect(getMetaShortcutLabel("k", { platform: "MacIntel" })).toBe("⌘K"); + expect(getMetaShortcutLabel("k", { platform: "Linux x86_64" })).toBe( + "Ctrl+K", + ); + }); + + it("detects pressed meta/ctrl modifier", () => { + expect(isMetaKeyPressed({ metaKey: true, ctrlKey: false })).toBe(true); + expect(isMetaKeyPressed({ metaKey: false, ctrlKey: true })).toBe(true); + expect(isMetaKeyPressed({ metaKey: false, ctrlKey: false })).toBe(false); + }); +}); diff --git a/orchestrator/src/client/lib/meta-key.ts b/orchestrator/src/client/lib/meta-key.ts new file mode 100644 index 0000000..207a528 --- /dev/null +++ b/orchestrator/src/client/lib/meta-key.ts @@ -0,0 +1,34 @@ +interface NavigatorLike { + platform?: string; + userAgent?: string; +} + +const isApplePlatform = (value: string) => + /(mac|iphone|ipad|ipod)/i.test(value); + +const getRuntimeNavigator = (): NavigatorLike | null => { + if (typeof navigator === "undefined") return null; + return navigator; +}; + +export const getMetaKeyLabel = ( + nav: NavigatorLike | null = getRuntimeNavigator(), +) => { + const platform = nav?.platform ?? ""; + const userAgent = nav?.userAgent ?? ""; + return isApplePlatform(`${platform} ${userAgent}`) ? "⌘" : "Ctrl"; +}; + +export const getMetaShortcutLabel = ( + key: string, + nav: NavigatorLike | null = getRuntimeNavigator(), +) => { + const normalizedKey = key.trim().toUpperCase(); + const meta = getMetaKeyLabel(nav); + return meta === "⌘" ? `${meta}${normalizedKey}` : `${meta}+${normalizedKey}`; +}; + +export const isMetaKeyPressed = (event: { + metaKey: boolean; + ctrlKey: boolean; +}) => event.metaKey || event.ctrlKey; diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index f049ba1..20c9b70 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -164,10 +164,32 @@ vi.mock("./orchestrator/OrchestratorSummary", () => ({ OrchestratorSummary: () =>
, })); +vi.mock("./orchestrator/JobCommandBar", () => ({ + JobCommandBar: ({ + onSelectJob, + open, + onOpenChange, + }: { + onSelectJob: (tab: FilterTab, id: string) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => ( +
+
{open ? "open" : "closed"}
+ + +
+ ), +})); + vi.mock("./orchestrator/OrchestratorFilters", () => ({ OrchestratorFilters: ({ onTabChange, - onSearchQueryChange, + onOpenCommandBar, onSourceFilterChange, onSponsorFilterChange, onSalaryFilterChange, @@ -177,7 +199,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({ filteredCount, }: { onTabChange: (t: FilterTab) => void; - onSearchQueryChange: (q: string) => void; + onOpenCommandBar: () => void; onSourceFilterChange: (source: string) => void; onSponsorFilterChange: (value: string) => void; onSalaryFilterChange: (value: { @@ -196,8 +218,8 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({ -
); diff --git a/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx b/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx new file mode 100644 index 0000000..c2731a4 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx @@ -0,0 +1,76 @@ +import type { Job } from "@shared/types.js"; +import { cn } from "@/lib/utils"; +import { defaultStatusToken, statusTokens } from "./constants"; + +interface JobRowContentProps { + job: Job; + isSelected?: boolean; + showStatusDot?: boolean; + statusDotClassName?: string; + className?: string; +} + +export const JobRowContent = ({ + job, + isSelected = false, + showStatusDot = true, + statusDotClassName, + className, +}: JobRowContentProps) => { + const hasScore = job.suitabilityScore != null; + const statusToken = statusTokens[job.status] ?? defaultStatusToken; + + return ( +
+ + +
+
+ {job.title} +
+
+ {job.employer} + {job.location && ( + {job.location} + )} +
+ {job.salary?.trim() && ( +
+ {job.salary} +
+ )} +
+ + {hasScore && ( +
+ = 70 + ? "text-emerald-400/90" + : (job.suitabilityScore ?? 0) >= 50 + ? "text-foreground/60" + : "text-muted-foreground/60", + )} + > + {job.suitabilityScore} + +
+ )} +
+ ); +}; diff --git a/orchestrator/src/client/pages/orchestrator/JobStatusBadge.tsx b/orchestrator/src/client/pages/orchestrator/JobStatusBadge.tsx new file mode 100644 index 0000000..49a5ca8 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/JobStatusBadge.tsx @@ -0,0 +1,29 @@ +import type { JobStatus } from "@shared/types.js"; +import { cn } from "@/lib/utils"; +import { defaultStatusToken, statusTokens } from "./constants"; + +interface JobStatusBadgeProps { + status: JobStatus; + label?: string; + className?: string; +} + +export const JobStatusBadge = ({ + status, + label, + className, +}: JobStatusBadgeProps) => { + const statusToken = statusTokens[status] ?? defaultStatusToken; + return ( + + + {label ?? statusToken.label} + + ); +}; diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx index 120b797..17d7151 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx @@ -33,8 +33,7 @@ const renderFilters = ( applied: 3, all: 6, }, - searchQuery: "", - onSearchQueryChange: vi.fn(), + onOpenCommandBar: vi.fn(), sourceFilter: "all" as const, onSourceFilterChange: vi.fn(), sponsorFilter: "all" as SponsorFilter, @@ -60,16 +59,14 @@ const renderFilters = ( }; describe("OrchestratorFilters", () => { - it("notifies when tabs and search are updated", () => { + it("notifies when tabs and command search shortcut are used", () => { const { props } = renderFilters(); fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i })); expect(props.onTabChange).toHaveBeenCalledWith("applied"); - fireEvent.change(screen.getByPlaceholderText("Search..."), { - target: { value: "Design" }, - }); - expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design"); + fireEvent.click(screen.getByRole("button", { name: /search jobs/i })); + expect(props.onOpenCommandBar).toHaveBeenCalled(); }); it("updates source, sponsor, salary range, and sort from the drawer", async () => { diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx index 268dfe5..d44e3cd 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx @@ -1,3 +1,4 @@ +import { getMetaShortcutLabel } from "@client/lib/meta-key"; import type { JobSource } from "@shared/types.js"; import { Filter, Search } from "lucide-react"; import type React from "react"; @@ -37,8 +38,7 @@ interface OrchestratorFiltersProps { activeTab: FilterTab; onTabChange: (value: FilterTab) => void; counts: Record; - searchQuery: string; - onSearchQueryChange: (value: string) => void; + onOpenCommandBar: () => void; sourceFilter: JobSource | "all"; onSourceFilterChange: (value: JobSource | "all") => void; sponsorFilter: SponsorFilter; @@ -113,8 +113,7 @@ export const OrchestratorFilters: React.FC = ({ activeTab, onTabChange, counts, - searchQuery, - onSearchQueryChange, + onOpenCommandBar, sourceFilter, onSourceFilterChange, sponsorFilter, @@ -146,6 +145,7 @@ export const OrchestratorFilters: React.FC = ({ salaryFilter.mode === "at_least" || salaryFilter.mode === "between"; const showSalaryMax = salaryFilter.mode === "at_most" || salaryFilter.mode === "between"; + const commandShortcutLabel = getMetaShortcutLabel("K"); return ( = ({
-
- - onSearchQueryChange(event.target.value)} - placeholder="Search..." - className="h-8 pl-8 text-sm" - /> -
+ diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts index 14ecab5..a01acca 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts @@ -81,7 +81,6 @@ describe("useFilteredJobs", () => { "all", "confirmed", { mode: "at_least", min: null, max: null }, - "", { key: "score", direction: "desc", @@ -107,7 +106,6 @@ describe("useFilteredJobs", () => { "all", "all", { mode: "between", min: 60000, max: 80000 }, - "", { key: "score", direction: "desc", @@ -136,7 +134,6 @@ describe("useFilteredJobs", () => { "all", "all", { mode: "at_least", min: null, max: null }, - "", { key: "salary", direction: "desc", diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts index 570e288..d79ba24 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts @@ -6,7 +6,7 @@ import type { SalaryFilter, SponsorFilter, } from "./constants"; -import { compareJobs, jobMatchesQuery, parseSalaryBounds } from "./utils"; +import { compareJobs, parseSalaryBounds } from "./utils"; const getSponsorCategory = (score: number | null): SponsorFilter => { if (score == null) return "unknown"; @@ -21,7 +21,6 @@ export const useFilteredJobs = ( sourceFilter: JobSource | "all", sponsorFilter: SponsorFilter, salaryFilter: SalaryFilter, - searchQuery: string, sort: JobSort, ) => useMemo(() => { @@ -85,17 +84,5 @@ export const useFilteredJobs = ( }); } - if (searchQuery.trim()) { - filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery)); - } - return [...filtered].sort((a, b) => compareJobs(a, b, sort)); - }, [ - jobs, - activeTab, - sourceFilter, - sponsorFilter, - salaryFilter, - searchQuery, - sort, - ]); + }, [jobs, activeTab, sourceFilter, sponsorFilter, salaryFilter, sort]); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts index 8b57983..92153d6 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts @@ -1,5 +1,5 @@ import type { JobSource } from "@shared/types.js"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import type { JobSort, @@ -33,20 +33,16 @@ 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], - ); + useEffect(() => { + if (!searchParams.has("q")) return; + setSearchParams( + (prev) => { + prev.delete("q"); + return prev; + }, + { replace: true }, + ); + }, [searchParams, setSearchParams]); const sourceFilter = (searchParams.get("source") as JobSource | "all") || "all"; @@ -181,8 +177,6 @@ export const useOrchestratorFilters = () => { return { searchParams, - searchQuery, - setSearchQuery, sourceFilter, setSourceFilter, sponsorFilter, diff --git a/orchestrator/src/components/ui/command.tsx b/orchestrator/src/components/ui/command.tsx index 5033694..feb7b47 100644 --- a/orchestrator/src/components/ui/command.tsx +++ b/orchestrator/src/components/ui/command.tsx @@ -20,11 +20,31 @@ const Command = React.forwardRef< )); Command.displayName = CommandPrimitive.displayName; -const CommandDialog = ({ children, ...props }: DialogProps) => { +interface CommandDialogProps extends DialogProps { + contentClassName?: string; + commandClassName?: string; + onEscapeKeyDown?: (event: KeyboardEvent) => void; +} + +const CommandDialog = ({ + children, + contentClassName, + commandClassName, + onEscapeKeyDown, + ...props +}: CommandDialogProps) => { return ( - - + + {children} @@ -32,12 +52,27 @@ const CommandDialog = ({ children, ...props }: DialogProps) => { ); }; +interface CommandInputProps + extends Omit< + React.ComponentPropsWithoutRef, + "prefix" + > { + prefix?: React.ReactNode; + wrapperClassName?: string; +} + const CommandInput = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
+ CommandInputProps +>(({ className, prefix, wrapperClassName, ...props }, ref) => ( +
+ {prefix ? ( +
{prefix}
+ ) : null}