gem3 flash lint fix

This commit is contained in:
DaKheera47 2026-01-25 13:14:59 +00:00
parent 76957e6f92
commit d4e83c0674
74 changed files with 691 additions and 516 deletions

View File

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2
@ -9,5 +9,26 @@
"**",
"!!**/dist"
]
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": [
"**/*.test.ts",
"**/*.test.tsx",
"**/test-utils.ts"
],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
}
}
}
}
]
}

View File

@ -287,11 +287,15 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
{step === "paste" && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="fetch-url"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job URL (optional)
</label>
<div className="flex gap-2">
<Input
id="fetch-url"
value={fetchUrl}
onChange={(event) => setFetchUrl(event.target.value)}
placeholder="https://example.com/job-posting"
@ -338,10 +342,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="raw-description"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job description
</label>
<Textarea
id="raw-description"
value={rawDescription}
onChange={(event) => setRawDescription(event.target.value)}
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
@ -408,10 +416,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-title"
className="text-xs font-medium text-muted-foreground"
>
Title *
</label>
<Input
id="draft-title"
value={draft.title}
onChange={(event) =>
setDraft((prev) => ({
@ -423,10 +435,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-employer"
className="text-xs font-medium text-muted-foreground"
>
Employer *
</label>
<Input
id="draft-employer"
value={draft.employer}
onChange={(event) =>
setDraft((prev) => ({
@ -438,10 +454,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-location"
className="text-xs font-medium text-muted-foreground"
>
Location
</label>
<Input
id="draft-location"
value={draft.location}
onChange={(event) =>
setDraft((prev) => ({
@ -453,10 +473,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-salary"
className="text-xs font-medium text-muted-foreground"
>
Salary
</label>
<Input
id="draft-salary"
value={draft.salary}
onChange={(event) =>
setDraft((prev) => ({
@ -468,10 +492,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-deadline"
className="text-xs font-medium text-muted-foreground"
>
Deadline
</label>
<Input
id="draft-deadline"
value={draft.deadline}
onChange={(event) =>
setDraft((prev) => ({
@ -483,10 +511,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobType"
className="text-xs font-medium text-muted-foreground"
>
Job type
</label>
<Input
id="draft-jobType"
value={draft.jobType}
onChange={(event) =>
setDraft((prev) => ({
@ -498,10 +530,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobLevel"
className="text-xs font-medium text-muted-foreground"
>
Job level
</label>
<Input
id="draft-jobLevel"
value={draft.jobLevel}
onChange={(event) =>
setDraft((prev) => ({
@ -513,10 +549,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobFunction"
className="text-xs font-medium text-muted-foreground"
>
Job function
</label>
<Input
id="draft-jobFunction"
value={draft.jobFunction}
onChange={(event) =>
setDraft((prev) => ({
@ -528,10 +568,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-disciplines"
className="text-xs font-medium text-muted-foreground"
>
Disciplines
</label>
<Input
id="draft-disciplines"
value={draft.disciplines}
onChange={(event) =>
setDraft((prev) => ({
@ -543,10 +587,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-degreeRequired"
className="text-xs font-medium text-muted-foreground"
>
Degree required
</label>
<Input
id="draft-degreeRequired"
value={draft.degreeRequired}
onChange={(event) =>
setDraft((prev) => ({
@ -558,10 +606,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-starting"
className="text-xs font-medium text-muted-foreground"
>
Starting
</label>
<Input
id="draft-starting"
value={draft.starting}
onChange={(event) =>
setDraft((prev) => ({
@ -573,10 +625,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobUrl"
className="text-xs font-medium text-muted-foreground"
>
Job URL
</label>
<Input
id="draft-jobUrl"
value={draft.jobUrl}
onChange={(event) =>
setDraft((prev) => ({
@ -588,10 +644,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-applicationLink"
className="text-xs font-medium text-muted-foreground"
>
Application link
</label>
<Input
id="draft-applicationLink"
value={draft.applicationLink}
onChange={(event) =>
setDraft((prev) => ({
@ -605,10 +665,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobDescription"
className="text-xs font-medium text-muted-foreground"
>
Job description *
</label>
<Textarea
id="draft-jobDescription"
value={draft.jobDescription}
onChange={(event) =>
setDraft((prev) => ({

View File

@ -121,7 +121,6 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({
case "completed":
case "failed":
return 100;
case "idle":
default:
return 0;
}

View File

@ -34,7 +34,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
};
});

View File

@ -8,8 +8,6 @@
*/
import {
Briefcase,
Building2,
CheckCircle2,
ChevronUp,
Copy,
@ -19,7 +17,6 @@ import {
FileText,
FolderKanban,
Loader2,
MoreHorizontal,
RefreshCcw,
Undo2,
XCircle,
@ -87,7 +84,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// Reset mode when job changes
useEffect(() => {
setMode("ready");
}, [job?.id]);
}, []);
// Compute derived values
const pdfHref = job
@ -100,12 +97,26 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
return job?.selectedProjectIds?.split(",").filter(Boolean) ?? [];
}, [job?.selectedProjectIds]);
const selectedProjectNames = useMemo(() => {
if (!catalog.length || !selectedProjectIds.length) return [];
return selectedProjectIds
.map((id) => catalog.find((p) => p.id === id)?.name)
.filter(Boolean) as string[];
}, [catalog, selectedProjectIds]);
const handleUndoApplied = useCallback(
async (jobId: string) => {
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
clearTimeout(recentlyApplied.timeoutId);
}
setRecentlyApplied(null);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
},
[onJobUpdated, recentlyApplied],
);
// Handle mark as applied with undo capability
const handleMarkApplied = useCallback(async () => {
@ -146,28 +157,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
} finally {
setIsMarkingApplied(false);
}
}, [job, onJobMoved, onJobUpdated]);
const handleUndoApplied = useCallback(
async (jobId: string) => {
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
clearTimeout(recentlyApplied.timeoutId);
}
setRecentlyApplied(null);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
},
[onJobUpdated, recentlyApplied],
);
}, [job, onJobMoved, onJobUpdated, handleUndoApplied]);
const handleRegenerate = useCallback(async () => {
if (!job) return;
@ -368,10 +358,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</AccordionTrigger>
<AccordionContent className="pt-1 pl-11">
<ul className="list-disc text-xs text-muted-foreground space-y-1">
{selectedProjectNames.map((name, i) => (
<li key={i}>{name}</li>
))}
{selectedProjectNames.length === 0 && (
{selectedProjectIds.map((id) => {
const name = catalog.find((p) => p.id === id)?.name;
if (!name) return null;
return <li key={id}>{name}</li>;
})}
{selectedProjectIds.length === 0 && (
<li className="list-none italic">No projects selected</li>
)}
</ul>

View File

@ -10,7 +10,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
@ -137,7 +136,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}
toast.success("AI Summary & Projects generated");
await onUpdate();
} catch (error) {
} catch (_error) {
toast.error("AI summarization failed");
} finally {
setIsSummarizing(false);
@ -156,7 +155,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
await api.generateJobPdf(job.id);
toast.success("Resume PDF generated");
await onUpdate();
} catch (error) {
} catch (_error) {
toast.error("PDF generation failed");
} finally {
setIsGeneratingPdf(false);
@ -203,10 +202,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<label className="text-sm font-medium">
<label htmlFor="tailor-jd" className="text-sm font-medium">
Job Description (Edit to help AI tailoring)
</label>
<textarea
id="tailor-jd"
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
@ -217,8 +217,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium">Tailored Summary</label>
<label htmlFor="tailor-summary" className="text-sm font-medium">
Tailored Summary
</label>
<textarea
id="tailor-summary"
className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(e) => setSummary(e.target.value)}
@ -230,7 +233,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<div className="space-y-3">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className="text-sm font-medium">Selected Projects</label>
<span className="text-sm font-medium">Selected Projects</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<AlertTriangle className="h-3 w-3" />

View File

@ -34,7 +34,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
};
});

View File

@ -31,7 +31,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
setMode("decide");
setIsSkipping(false);
setIsFinalizing(false);
}, [job?.id]);
}, []);
const handleSkip = async () => {
if (!job) return;

View File

@ -25,9 +25,9 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
return (
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className="text-xs font-medium text-muted-foreground">
<span className="text-xs font-medium text-muted-foreground">
Selected Projects
</label>
</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
<AlertTriangle className="h-3 w-3" />
@ -43,15 +43,16 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
</div>
) : (
catalog.map((project) => (
<div
<label
key={project.id}
htmlFor={`project-${project.id}`}
className={cn(
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
selectedIds.has(project.id)
? "border-primary/40 bg-primary/5"
: "border-border/40 bg-muted/5 hover:bg-muted/10",
disabled && "opacity-50 cursor-not-allowed",
)}
onClick={() => !disabled && onToggle(project.id)}
>
<Checkbox
id={`project-${project.id}`}
@ -66,7 +67,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
{project.description}
</div>
</div>
</div>
</label>
))
)}
</div>

View File

@ -52,7 +52,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
setSelectedIds(new Set(saved));
setDraftStatus("saved");
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
}, [job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
const savedSummary = job.tailoredSummary || "";
const savedDescription = job.jobDescription || "";
@ -248,10 +248,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
label={`${showDescription ? "Hide" : "Edit"} job description`}
>
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground/70">
<label
htmlFor="tailor-jd-edit"
className="text-[10px] font-medium text-muted-foreground/70"
>
Edit to help AI tailoring
</label>
<textarea
id="tailor-jd-edit"
className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription}
onChange={(event) => setJobDescription(event.target.value)}
@ -262,10 +266,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
</CollapsibleSection>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="tailor-summary-edit"
className="text-xs font-medium text-muted-foreground"
>
Tailored Summary
</label>
<textarea
id="tailor-summary-edit"
className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(event) => setSummary(event.target.value)}

View File

@ -1,5 +1,4 @@
import { stripHtml } from "@/lib/utils";
import type { Job } from "../../../shared/types";
export const getPlainDescription = (jobDescription?: string | null) => {
if (!jobDescription) return "No description available.";

View File

@ -12,7 +12,7 @@ import {
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View File

@ -43,11 +43,15 @@ export function useProfile() {
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach((sub) => sub(data, null));
subscribers.forEach((sub) => {
sub(data, null);
});
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(profileCache, profileError));
subscribers.forEach((sub) => {
sub(profileCache, profileError);
});
})
.finally(() => {
isFetching = false;
@ -62,17 +66,23 @@ export function useProfile() {
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach((sub) => sub(profileCache, null));
subscribers.forEach((sub) => {
sub(profileCache, null);
});
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach((sub) => sub(data, null));
subscribers.forEach((sub) => {
sub(data, null);
});
return data;
} catch (err) {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(profileCache, profileError));
subscribers.forEach((sub) => {
sub(profileCache, profileError);
});
throw profileError;
} finally {
isFetching = false;

View File

@ -15,7 +15,7 @@ describe("useSettings", () => {
it("fetches settings on mount if not already cached", async () => {
const mockSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValue(mockSettings);
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
const { result } = renderHook(() => useSettings());
@ -31,7 +31,7 @@ describe("useSettings", () => {
});
it("uses default values when settings are null", async () => {
(api.getSettings as any).mockResolvedValue(null);
vi.mocked(api.getSettings).mockResolvedValue(null as any);
const { result } = renderHook(() => useSettings());
@ -45,8 +45,8 @@ describe("useSettings", () => {
const initialSettings = { showSponsorInfo: true };
const updatedSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any);
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
const { result } = renderHook(() => useSettings());
@ -54,7 +54,7 @@ describe("useSettings", () => {
expect(result.current.settings).toEqual(initialSettings);
});
let refreshed;
let refreshed: any;
await waitFor(async () => {
refreshed = await result.current.refreshSettings();
});
@ -66,7 +66,7 @@ describe("useSettings", () => {
it("handles errors when fetching settings", async () => {
const mockError = new Error("Failed to fetch");
(api.getSettings as any).mockRejectedValue(mockError);
vi.mocked(api.getSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings());

View File

@ -39,11 +39,15 @@ export function useSettings() {
.then((data) => {
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => sub(data, null));
subscribers.forEach((sub) => {
sub(data, null);
});
})
.catch((err) => {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(settingsCache, settingsError));
subscribers.forEach((sub) => {
sub(settingsCache, settingsError);
});
})
.finally(() => {
isFetching = false;
@ -58,17 +62,23 @@ export function useSettings() {
const refreshSettings = async () => {
isFetching = true;
settingsError = null;
subscribers.forEach((sub) => sub(settingsCache, null));
subscribers.forEach((sub) => {
sub(settingsCache, null);
});
try {
const data = await api.getSettings();
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => sub(data, null));
subscribers.forEach((sub) => {
sub(data, null);
});
return data;
} catch (err) {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(settingsCache, settingsError));
subscribers.forEach((sub) => {
sub(settingsCache, settingsError);
});
throw settingsError;
} finally {
isFetching = false;

View File

@ -4,7 +4,10 @@ import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "../index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element");
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<BrowserRouter>
<App />

View File

@ -136,11 +136,16 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
}) => (
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>
<button type="button" onClick={() => onTabChange("discovered")}>
To Discovered
</button>
<button type="button" onClick={() => onSearchQueryChange("test search")}>
Set Search
</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>
<button
type="button"
onClick={() => onSortChange({ key: "title", direction: "asc" })}
>
Set Sort
</button>
</div>

View File

@ -58,55 +58,67 @@ export const OrchestratorPage: React.FC = () => {
// Sync searchQuery with URL
const searchQuery = searchParams.get("q") || "";
const setSearchQuery = (q: string) => {
setSearchParams(
(prev) => {
if (q) prev.set("q", q);
else prev.delete("q");
return prev;
},
{ replace: true },
);
};
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 = (source: JobSource | "all") => {
setSearchParams(
(prev) => {
if (source !== "all") prev.set("source", source);
else prev.delete("source");
return prev;
},
{ replace: true },
);
};
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 any, direction: direction as any };
return {
key: key as JobSort["key"],
direction: direction as JobSort["direction"],
};
}, [searchParams]);
const setSort = (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 },
);
};
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(() => {
@ -125,13 +137,19 @@ export const OrchestratorPage: React.FC = () => {
: false,
);
const setActiveTab = (newTab: FilterTab) => {
navigateWithContext(newTab, selectedJobId);
};
const setActiveTab = useCallback(
(newTab: FilterTab) => {
navigateWithContext(newTab, selectedJobId);
},
[navigateWithContext, selectedJobId],
);
const handleSelectJobId = (id: string | null) => {
navigateWithContext(activeTab, id);
};
const handleSelectJobId = useCallback(
(id: string | null) => {
navigateWithContext(activeTab, id);
},
[navigateWithContext, activeTab],
);
const { settings } = useSettings();
const {
@ -229,7 +247,14 @@ export const OrchestratorPage: React.FC = () => {
navigateWithContext(activeTab, activeJobs[0].id, true);
}
}
}, [activeJobs, selectedJobId, isDesktop, activeTab, navigateWithContext]);
}, [
activeJobs,
selectedJobId,
isDesktop,
activeTab,
navigateWithContext,
handleSelectJobId,
]);
useEffect(() => {
if (!selectedJobId) {

View File

@ -414,7 +414,6 @@ export const SettingsPage: React.FC = () => {
envSettings,
defaultResumeProjects,
profileProjects,
maxProjectsTotal,
} = derived;
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;

View File

@ -22,7 +22,7 @@ import {
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@ -120,7 +120,7 @@ export const UkVisaJobsPage: React.FC = () => {
useEffect(() => {
setSelectedJobIds(new Set());
}, [results]);
}, []);
const selectedJob = useMemo(
() =>
@ -431,10 +431,14 @@ export const UkVisaJobsPage: React.FC = () => {
onSubmit={handleSearch}
>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="uk-visa-search"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job title search term
</label>
<Input
id="uk-visa-search"
value={searchTermInput}
onChange={(event) => setSearchTermInput(event.target.value)}
placeholder="e.g. data analyst"
@ -569,27 +573,12 @@ export const UkVisaJobsPage: React.FC = () => {
return (
<div
key={key}
role="button"
tabIndex={0}
onClick={() => handleSelectJob(key)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleSelectJob(key);
}
}}
className={cn(
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
"flex w-full items-start gap-4 px-4 py-3 transition-colors",
isSelected ? "bg-muted/40" : "hover:bg-muted/30",
)}
aria-pressed={isSelected}
>
<div
className="mt-1"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
role="presentation"
>
<div className="mt-1 shrink-0">
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
@ -606,60 +595,67 @@ export const UkVisaJobsPage: React.FC = () => {
aria-label={`Select ${job.title}`}
/>
</div>
<span className="mt-1 flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<div className="truncate text-sm font-semibold">
{job.title}
<button
type="button"
onClick={() => handleSelectJob(key)}
className="flex flex-1 items-start gap-4 text-left"
aria-pressed={isSelected}
>
<span className="mt-1 flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30 shrink-0">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<div className="truncate text-sm font-semibold">
{job.title}
</div>
<div className="text-xs text-muted-foreground">
{job.employer}
</div>
</div>
<div className="text-xs text-muted-foreground">
{job.employer}
{description}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{job.location}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{job.salary}
</span>
)}
{job.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(job.deadline)}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{job.jobType && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobType}
</Badge>
)}
{job.jobLevel && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobLevel}
</Badge>
)}
</div>
</div>
<div className="text-xs text-muted-foreground">
{description}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{job.location}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{job.salary}
</span>
)}
{job.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(job.deadline)}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{job.jobType && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobType}
</Badge>
)}
{job.jobLevel && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobLevel}
</Badge>
)}
</div>
</div>
</button>
</div>
);
})}

View File

@ -76,12 +76,24 @@ export const VisaSponsorsPage: React.FC = () => {
: false,
);
// Fetch status on mount
useEffect(() => {
fetchStatus();
// Fetch organization details
const fetchOrgDetails = useCallback(async (orgName: string) => {
setIsLoadingDetails(true);
setSelectedOrg(orgName);
try {
const details = await api.getVisaSponsorOrganization(orgName);
setOrgDetails(details);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch details";
toast.error(message);
setOrgDetails([]);
} finally {
setIsLoadingDetails(false);
}
}, []);
const fetchStatus = async () => {
const fetchStatus = useCallback(async () => {
setIsLoadingStatus(true);
try {
const data = await api.getVisaSponsorStatus();
@ -93,7 +105,12 @@ export const VisaSponsorsPage: React.FC = () => {
} finally {
setIsLoadingStatus(false);
}
};
}, []);
// Fetch status on mount
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Search with debounce
const handleSearch = useCallback(async (query: string) => {
@ -143,7 +160,7 @@ export const VisaSponsorsPage: React.FC = () => {
setSelectedOrg(firstOrg);
fetchOrgDetails(firstOrg);
}
}, [results]);
}, [results, fetchOrgDetails, selectedOrg]);
useEffect(() => {
if (!selectedOrg) {
@ -170,23 +187,6 @@ export const VisaSponsorsPage: React.FC = () => {
}
}, [isDesktop, isDetailDrawerOpen]);
// Fetch organization details
const fetchOrgDetails = async (orgName: string) => {
setIsLoadingDetails(true);
setSelectedOrg(orgName);
try {
const details = await api.getVisaSponsorOrganization(orgName);
setOrgDetails(details);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch details";
toast.error(message);
setOrgDetails([]);
} finally {
setIsLoadingDetails(false);
}
};
// Trigger manual update
const handleUpdate = async () => {
setIsUpdating(true);
@ -276,9 +276,9 @@ export const VisaSponsorsPage: React.FC = () => {
Licensed Routes ({orgDetails.length})
</div>
<div className="space-y-2">
{orgDetails.map((entry, index) => (
{orgDetails.map((entry) => (
<div
key={index}
key={`${entry.route}-${entry.typeRating}`}
className="rounded-lg border border-border/60 bg-muted/20 p-3"
>
<div className="flex items-start justify-between gap-2 mb-1">
@ -355,12 +355,16 @@ export const VisaSponsorsPage: React.FC = () => {
{/* Search section */}
<section className="rounded-xl border border-border/60 bg-card/40 p-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="sponsor-search"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Company name
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="sponsor-search"
placeholder="Search for a company name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@ -369,6 +373,7 @@ export const VisaSponsorsPage: React.FC = () => {
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>

View File

@ -33,7 +33,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
};
});

View File

@ -51,7 +51,6 @@ interface JobDetailPanelProps {
selectedJob: Job | null;
onSelectJobId: (jobId: string | null) => void;
onJobUpdated: () => Promise<void>;
onSetActiveTab: (tab: FilterTab) => void;
}
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
@ -60,7 +59,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
selectedJob,
onSelectJobId,
onJobUpdated,
onSetActiveTab,
}) => {
const [detailTab, setDetailTab] = useState<
"overview" | "tailoring" | "description"
@ -77,7 +75,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
useEffect(() => {
setHasUnsavedTailoring(false);
saveTailoringRef.current = null;
}, [selectedJob?.id]);
}, []);
const description = useMemo(() => {
if (!selectedJob?.jobDescription) return "No description available.";
@ -94,7 +92,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
}
setIsEditingDescription(false);
setEditedDescription(selectedJob.jobDescription || "");
}, [selectedJob?.id]);
}, [selectedJob?.id, selectedJob]);
useEffect(() => {
if (!selectedJob) return;

View File

@ -97,9 +97,9 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
<span
className={cn(
"text-xs tabular-nums",
job.suitabilityScore! >= 70
(job.suitabilityScore ?? 0) >= 70
? "text-emerald-400/90"
: job.suitabilityScore! >= 50
: (job.suitabilityScore ?? 0) >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)}

View File

@ -1,6 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import type { FilterTab, JobSort } from "./constants";
import { OrchestratorFilters } from "./OrchestratorFilters";
@ -41,7 +40,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
DropdownMenuRadioGroup: ({
children,
onValueChange,
@ -56,15 +55,18 @@ vi.mock("@/components/ui/dropdown-menu", () => {
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}

View File

@ -21,7 +21,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
DropdownMenuItem: ({
children,
onSelect,
@ -42,14 +42,17 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuCheckboxItem: ({
children,
onCheckedChange,
checked,
}: {
children: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
checked?: boolean;
}) => (
<button
type="button"
role="menuitemcheckbox"
onClick={() => onCheckedChange?.(true)}
aria-checked={checked}
onClick={() => onCheckedChange?.(!checked)}
>
{children}
</button>

View File

@ -1,15 +1,7 @@
import * as api from "@client/api";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCallback, useEffect, useState } from "react";
type BaseResumeSelectionProps = {
value: string | null;
@ -30,7 +22,7 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
const [isFetchingResumes, setIsFetchingResumes] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchResumes = async () => {
const fetchResumes = useCallback(async () => {
if (!hasRxResumeAccess) return;
setIsFetchingResumes(true);
@ -50,13 +42,13 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
} finally {
setIsFetchingResumes(false);
}
};
}, [hasRxResumeAccess, onValueChange, value]);
useEffect(() => {
if (hasRxResumeAccess) {
fetchResumes();
}
}, [hasRxResumeAccess]);
}, [hasRxResumeAccess, fetchResumes]);
return (
<div className="space-y-2">

View File

@ -81,10 +81,9 @@ function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
}: React.ComponentProps<"fieldset"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
<fieldset
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
@ -206,8 +205,8 @@ function FieldError({
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul>
);

View File

@ -78,38 +78,41 @@
--background: oklch(0.9818 0.0054 95.0986);
--foreground: oklch(0.3438 0.0269 95.7226);
--card: oklch(0.9818 0.0054 95.0986);
--card-foreground: oklch(0.1908 0.0020 106.5859);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2671 0.0196 98.9390);
--card-foreground: oklch(0.1908 0.002 106.5859);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2671 0.0196 98.939);
--primary: oklch(0.6171 0.1375 39.0427);
--primary-foreground: oklch(1.0000 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9245 0.0138 92.9892);
--secondary-foreground: oklch(0.4334 0.0177 98.6048);
--muted: oklch(0.9341 0.0153 90.2390);
--muted: oklch(0.9341 0.0153 90.239);
--muted-foreground: oklch(0.6059 0.0075 97.4233);
--accent: oklch(0.9245 0.0138 92.9892);
--accent-foreground: oklch(0.2671 0.0196 98.9390);
--destructive: oklch(0.1908 0.0020 106.5859);
--accent-foreground: oklch(0.2671 0.0196 98.939);
--destructive: oklch(0.1908 0.002 106.5859);
--border: oklch(0.8847 0.0069 97.3627);
--input: oklch(0.7621 0.0156 98.3528);
--ring: oklch(0.6171 0.1375 39.0427);
--chart-1: oklch(0.5583 0.1276 42.9956);
--chart-2: oklch(0.6898 0.1581 290.4107);
--chart-3: oklch(0.8816 0.0276 93.1280);
--chart-3: oklch(0.8816 0.0276 93.128);
--chart-4: oklch(0.8822 0.0403 298.1792);
--chart-5: oklch(0.5608 0.1348 42.0584);
--sidebar: oklch(0.9663 0.0080 98.8792);
--sidebar-foreground: oklch(0.3590 0.0051 106.6524);
--sidebar: oklch(0.9663 0.008 98.8792);
--sidebar-foreground: oklch(0.359 0.0051 106.6524);
--sidebar-primary: oklch(0.6171 0.1375 39.0427);
--sidebar-primary-foreground: oklch(0.9881 0 0);
--sidebar-accent: oklch(0.9245 0.0138 92.9892);
--sidebar-accent-foreground: oklch(0.3250 0 0);
--sidebar-accent-foreground: oklch(0.325 0 0);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0.7731 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--destructive-foreground: oklch(1 0 0);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
@ -120,11 +123,15 @@
--spacing: 0.25rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-sm:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
}
@ -135,37 +142,40 @@
--card: oklch(0.2679 0.0036 106.6427);
--card-foreground: oklch(0.9818 0.0054 95.0986);
--popover: oklch(0.3085 0.0035 106.6039);
--popover-foreground: oklch(0.9211 0.0040 106.4781);
--popover-foreground: oklch(0.9211 0.004 106.4781);
--primary: oklch(0.6724 0.1308 38.7559);
--primary-foreground: oklch(1.0000 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9818 0.0054 95.0986);
--secondary-foreground: oklch(0.3085 0.0035 106.6039);
--muted: oklch(0.2213 0.0038 106.7070);
--muted: oklch(0.2213 0.0038 106.707);
--muted-foreground: oklch(0.7713 0.0169 99.0657);
--accent: oklch(0.2130 0.0078 95.4245);
--accent-foreground: oklch(0.9663 0.0080 98.8792);
--accent: oklch(0.213 0.0078 95.4245);
--accent-foreground: oklch(0.9663 0.008 98.8792);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.3618 0.0101 106.8928);
--input: oklch(0.4336 0.0113 100.2195);
--ring: oklch(0.6724 0.1308 38.7559);
--chart-1: oklch(0.5583 0.1276 42.9956);
--chart-2: oklch(0.6898 0.1581 290.4107);
--chart-3: oklch(0.2130 0.0078 95.4245);
--chart-4: oklch(0.3074 0.0516 289.3230);
--chart-3: oklch(0.213 0.0078 95.4245);
--chart-4: oklch(0.3074 0.0516 289.323);
--chart-5: oklch(0.5608 0.1348 42.0584);
--sidebar: oklch(0.2357 0.0024 67.7077);
--sidebar-foreground: oklch(0.8074 0.0142 93.0137);
--sidebar-primary: oklch(0.3250 0 0);
--sidebar-primary: oklch(0.325 0 0);
--sidebar-primary-foreground: oklch(0.9881 0 0);
--sidebar-accent: oklch(0.1680 0.0020 106.6177);
--sidebar-accent: oklch(0.168 0.002 106.6177);
--sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0.7731 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--destructive-foreground: oklch(1 0 0);
--radius: 0.5rem;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
@ -176,11 +186,15 @@
--spacing: 0.25rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-sm:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl:
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -6,7 +6,7 @@ export const databaseRouter = Router();
/**
* DELETE /api/database - Clear all data from the database
*/
databaseRouter.delete("/", async (req: Request, res: Response) => {
databaseRouter.delete("/", async (_req: Request, res: Response) => {
try {
const result = clearDatabase();

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -1,4 +1,4 @@
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import { type Request, type Response, Router } from "express";
import { JSDOM } from "jsdom";
import { z } from "zod";
@ -113,7 +113,9 @@ manualJobsRouter.post("/fetch", async (req: Request, res: Response) => {
'[role="navigation"], [role="banner"], [role="contentinfo"], ' +
".nav, .navbar, .header, .footer, .sidebar, .menu, .cookie, .popup, .modal, .ad, .advertisement",
);
elementsToRemove.forEach((el) => el.remove());
elementsToRemove.forEach((el) => {
el.remove();
});
// Try to find the main job content area
const mainContent =

View File

@ -1,5 +1,5 @@
import type { Server } from "node:http";
import { RxResumeClient } from "@server/services/rxresume-client.js";
import type { Server } from "http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils.js";
@ -171,7 +171,7 @@ describe.sequential("Onboarding API routes", () => {
/**
* Creates a minimal valid RxResume v4 schema compliant JSON
*/
function createMinimalValidResume() {
function _createMinimalValidResume() {
return {
basics: {
name: "Test User",

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -16,7 +16,7 @@ export const pipelineRouter = Router();
/**
* GET /api/pipeline/status - Get pipeline status
*/
pipelineRouter.get("/status", async (req: Request, res: Response) => {
pipelineRouter.get("/status", async (_req: Request, res: Response) => {
try {
const { isRunning } = getPipelineStatus();
const lastRun = await pipelineRepo.getLatestPipelineRun();
@ -70,7 +70,7 @@ pipelineRouter.get("/progress", (req: Request, res: Response) => {
/**
* GET /api/pipeline/runs - Get recent pipeline runs
*/
pipelineRouter.get("/runs", async (req: Request, res: Response) => {
pipelineRouter.get("/runs", async (_req: Request, res: Response) => {
try {
const runs = await pipelineRepo.getRecentPipelineRuns(20);
res.json({ success: true, data: runs });

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -12,7 +12,7 @@ export const profileRouter = Router();
/**
* GET /api/profile/projects - Get all projects available in the base resume
*/
profileRouter.get("/projects", async (req: Request, res: Response) => {
profileRouter.get("/projects", async (_req: Request, res: Response) => {
try {
const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile);
@ -26,7 +26,7 @@ profileRouter.get("/projects", async (req: Request, res: Response) => {
/**
* GET /api/profile - Get the full base resume profile
*/
profileRouter.get("/", async (req: Request, res: Response) => {
profileRouter.get("/", async (_req: Request, res: Response) => {
try {
const profile = await getProfile();
res.json({ success: true, data: profile });

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -1,7 +1,7 @@
import { mkdtemp, rm } from "fs/promises";
import type { Server } from "http";
import { tmpdir } from "os";
import { join } from "path";
import { mkdtemp, rm } from "node:fs/promises";
import type { Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
vi.mock("../../pipeline/index.js", () => {

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -12,7 +12,7 @@ export const visaSponsorsRouter = Router();
/**
* GET /api/visa-sponsors/status - Get status of the visa sponsor service
*/
visaSponsorsRouter.get("/status", async (req: Request, res: Response) => {
visaSponsorsRouter.get("/status", async (_req: Request, res: Response) => {
try {
const status = visaSponsors.getStatus();
const response: ApiResponse<VisaSponsorStatusResponse> = {
@ -92,7 +92,7 @@ visaSponsorsRouter.get(
/**
* POST /api/visa-sponsors/update - Trigger a manual update of the visa sponsor list
*/
visaSponsorsRouter.post("/update", async (req: Request, res: Response) => {
visaSponsorsRouter.post("/update", async (_req: Request, res: Response) => {
try {
const result = await visaSponsors.downloadLatestCsv();

View File

@ -1,4 +1,4 @@
import type { Server } from "http";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils.js";

View File

@ -2,11 +2,11 @@
* Express app factory (useful for tests).
*/
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import cors from "cors";
import express from "express";
import { readFile } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { apiRouter } from "./api/index.js";
import { getDataDir } from "./config/dataDir.js";

View File

@ -1,7 +1,7 @@
import { mkdtemp, rm } from "fs/promises";
import type { Server } from "http";
import { tmpdir } from "os";
import { join } from "path";
import { mkdtemp, rm } from "node:fs/promises";
import type { Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createApp } from "./app.js";

View File

@ -1,5 +1,5 @@
import { existsSync } from "fs";
import { basename, join, resolve } from "path";
import { existsSync } from "node:fs";
import { basename, join, resolve } from "node:path";
let cachedDir: string | null = null;

View File

@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { config } from "dotenv";
import { existsSync } from "fs";
import { join } from "path";
const candidates = [
join(process.cwd(), ".env"),

View File

@ -2,8 +2,8 @@
* Database utility scripts.
*/
import { join } from "node:path";
import Database from "better-sqlite3";
import { join } from "path";
import { getDataDir } from "../config/dataDir.js";
// Database path - can be overridden via env for Docker
@ -36,7 +36,7 @@ export function clearDatabase(): { jobsDeleted: number; runsDeleted: number } {
* Delete database file completely (will recreate on next run).
*/
export function dropDatabase(): void {
const { unlinkSync, existsSync } = require("fs");
const { unlinkSync, existsSync } = require("node:fs");
if (existsSync(DB_PATH)) {
unlinkSync(DB_PATH);

View File

@ -2,10 +2,10 @@
* Database connection and initialization.
*/
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { existsSync, mkdirSync } from "fs";
import { dirname, join } from "path";
import { getDataDir } from "../config/dataDir.js";
import * as schema from "./schema.js";

View File

@ -2,9 +2,9 @@
* Database migration script - creates tables if they don't exist.
*/
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import Database from "better-sqlite3";
import { existsSync, mkdirSync } from "fs";
import { dirname, join } from "path";
import { getDataDir } from "../config/dataDir.js";
// Database path - can be overridden via env for Docker

View File

@ -7,11 +7,10 @@
* 3. Leave all jobs in "discovered" for manual processing
*/
import { join } from "path";
import { join } from "node:path";
import type {
CreateJobInput,
Job,
JobSource,
PipelineConfig,
} from "../../shared/types.js";
import { getDataDir } from "../config/dataDir.js";
@ -539,7 +538,7 @@ export async function summarizeJob(
});
selectedProjectIds = [...locked, ...picked].join(",");
} catch (err) {
} catch (_err) {
console.warn(" ⚠️ Failed to suggest projects, leaving empty");
}
}
@ -563,7 +562,7 @@ export async function summarizeJob(
*/
export async function generateFinalPdf(
jobId: string,
options?: ProcessJobOptions,
_options?: ProcessJobOptions,
): Promise<{
success: boolean;
error?: string;

View File

@ -16,11 +16,11 @@ async function main() {
console.log("=".repeat(60));
const result = await runPipeline({
topN: parseInt(process.env.PIPELINE_TOP_N || "10"),
minSuitabilityScore: parseInt(process.env.PIPELINE_MIN_SCORE || "50"),
topN: parseInt(process.env.PIPELINE_TOP_N || "10", 10),
minSuitabilityScore: parseInt(process.env.PIPELINE_MIN_SCORE || "50", 10),
});
console.log("\n" + "=".repeat(60));
console.log(`\n${"=".repeat(60)}`);
console.log("📊 Pipeline Results:");
console.log(` Success: ${result.success}`);
console.log(` Jobs Discovered: ${result.jobsDiscovered}`);

View File

@ -2,7 +2,7 @@
* Job repository - data access layer for jobs.
*/
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import type {
CreateJobInput,
@ -116,7 +116,11 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
updatedAt: now,
});
return (await getJobById(id))!;
const job = await getJobById(id);
if (!job) {
throw new Error(`Failed to retrieve newly created job with ID ${id}`);
}
return job;
}
/**

View File

@ -2,7 +2,7 @@
* Pipeline run repository.
*/
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import { desc, eq } from "drizzle-orm";
import type { PipelineRun } from "../../shared/types.js";
import { db, schema } from "../db/index.js";

View File

@ -3,11 +3,11 @@
* Wraps the existing Crawlee-based crawler.
*/
import { spawn } from "child_process";
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
import { dirname, join } from "path";
import { createInterface } from "readline";
import { fileURLToPath } from "url";
import { spawn } from "node:child_process";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import type { CreateJobInput } from "../../shared/types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -199,7 +199,7 @@ async function readCrawledJobs(): Promise<CreateJobInput[]> {
* Clear previous crawl results.
*/
async function clearStorageDataset(): Promise<void> {
const { rm } = await import("fs/promises");
const { rm } = await import("node:fs/promises");
try {
await rm(STORAGE_DIR, { recursive: true, force: true });
} catch {

View File

@ -138,9 +138,9 @@ export async function getEnvSettingsData(
}
const basicAuthUser =
activeOverrides["basicAuthUser"] ?? process.env.BASIC_AUTH_USER;
activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER;
const basicAuthPassword =
activeOverrides["basicAuthPassword"] ?? process.env.BASIC_AUTH_PASSWORD;
activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD;
return {
...readableValues,

View File

@ -4,10 +4,10 @@
* Uses a small Python wrapper script that writes both CSV + JSON to disk; we ingest the JSON.
*/
import { spawn } from "child_process";
import { mkdir, readFile, unlink } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { spawn } from "node:child_process";
import { mkdir, readFile, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { CreateJobInput, JobSource } from "../../shared/types.js";
import { getDataDir } from "../config/dataDir.js";

View File

@ -14,7 +14,7 @@ export interface JsonSchemaDefinition {
};
}
export interface OpenRouterRequestOptions<T> {
export interface OpenRouterRequestOptions<_T> {
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
model: string;
/** The prompt messages to send */
@ -41,6 +41,11 @@ export interface OpenRouterError {
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
interface OpenRouterApiError extends Error {
status?: number;
body?: string;
}
/**
* Call OpenRouter API with structured JSON output.
*
@ -100,9 +105,11 @@ export async function callOpenRouter<T>(
if (!response.ok) {
// Throw error with status to allow specific retries
const errorBody = await response.text().catch(() => "No error body");
const err = new Error(`OpenRouter API error: ${response.status}`);
(err as any).status = response.status;
(err as any).body = errorBody;
const err = new Error(
`OpenRouter API error: ${response.status}`,
) as OpenRouterApiError;
err.status = response.status;
err.body = errorBody;
throw err;
}
@ -119,7 +126,7 @@ export async function callOpenRouter<T>(
return { success: true, data: parsed };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = (error as any).status;
const status = (error as OpenRouterApiError).status;
// Retry on:
// 1. Parsing errors (AI returned malformed JSON)

View File

@ -126,11 +126,7 @@ vi.mock("./resumeProjects.js", () => ({
// Mock the RxResumeClient
vi.mock("./rxresume-client.js", () => ({
RxResumeClient: class {
constructor() {
return mockRxResumeClient;
}
},
RxResumeClient: vi.fn().mockImplementation(() => mockRxResumeClient),
}));
// Mock stream pipeline for downloading PDF
@ -268,7 +264,7 @@ describe("PDF Service Skills Validation", () => {
const skillItems = savedResumeJson.sections.skills.items;
// All skills should have IDs
skillItems.forEach((skill: any, index: number) => {
skillItems.forEach((skill: any, _index: number) => {
expect(skill.id).toBeDefined();
expect(typeof skill.id).toBe("string");
expect(skill.id.length).toBeGreaterThanOrEqual(20);

View File

@ -125,11 +125,7 @@ vi.mock("./resumeProjects.js", () => ({
// Mock the RxResumeClient
vi.mock("./rxresume-client.js", () => ({
RxResumeClient: class {
constructor() {
return mockRxResumeClient;
}
},
RxResumeClient: vi.fn().mockImplementation(() => mockRxResumeClient),
}));
// Mock stream pipeline for downloading PDF

View File

@ -2,12 +2,12 @@
* Service for generating PDF resumes using RxResume v4 API.
*/
import { createWriteStream, existsSync } from "node:fs";
import { access, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createId } from "@paralleldrive/cuid2";
import { createWriteStream, existsSync } from "fs";
import { access, mkdir } from "fs/promises";
import { join } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { getDataDir } from "../config/dataDir.js";
import { getSetting } from "../repositories/settings.js";
import { getProfile } from "./profile.js";
@ -29,7 +29,7 @@ export interface PdfResult {
export interface TailoredPdfContent {
summary?: string | null;
headline?: string | null;
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
skills?: Array<{ name: string; keywords: string[] }> | null;
}
/**
@ -78,6 +78,7 @@ async function downloadFile(url: string, outputPath: string): Promise<void> {
}
// Convert Web ReadableStream to Node readable
// biome-ignore lint/suspicious/noExplicitAny: response.body is a ReadableStream in the browser environment, but Node.js fetch implementation might have slight differences in types.
const nodeReadable = Readable.fromWeb(response.body as any);
const fileStream = createWriteStream(outputPath);
@ -126,14 +127,14 @@ export async function generatePdf(
Array.isArray(baseResume.sections.skills.items)
) {
baseResume.sections.skills.items = baseResume.sections.skills.items.map(
(skill: any) => ({
(skill: Record<string, unknown>) => ({
...skill,
id: skill.id || createId(),
visible: skill.visible ?? true,
id: (skill.id as string) || createId(),
visible: (skill.visible as boolean | undefined) ?? true,
// Zod schema requires string, default to empty string if missing
description: skill.description ?? "",
level: skill.level ?? 1,
keywords: skill.keywords || [],
description: (skill.description as string | undefined) ?? "",
level: (skill.level as number | undefined) ?? 1,
keywords: (skill.keywords as string[] | undefined) || [],
}),
);
}
@ -165,31 +166,41 @@ export async function generatePdf(
if (newSkills && baseResume.sections?.skills) {
// Ensure each skill item has required schema fields
const existingSkills = baseResume.sections.skills.items || [];
const skillsWithSchema = newSkills.map((newSkill: any) => {
// Try to find matching existing skill to preserve id and other fields
const existing = existingSkills.find(
(s: any) => s.name === newSkill.name,
);
const existingSkills = (baseResume.sections.skills.items ||
[]) as Array<Record<string, unknown>>;
const skillsWithSchema = newSkills.map(
(newSkill: Record<string, unknown>) => {
// Try to find matching existing skill to preserve id and other fields
const existing = existingSkills.find(
(s) => s.name === newSkill.name,
);
return {
id: newSkill.id || existing?.id || createId(),
visible:
newSkill.visible !== undefined
? newSkill.visible
: (existing?.visible ?? true),
name: newSkill.name || existing?.name || "",
description:
newSkill.description !== undefined
? newSkill.description
: existing?.description || "",
level:
newSkill.level !== undefined
? newSkill.level
: (existing?.level ?? 1),
keywords: newSkill.keywords || existing?.keywords || [],
};
});
return {
id:
(newSkill.id as string) ||
(existing?.id as string) ||
createId(),
visible:
newSkill.visible !== undefined
? (newSkill.visible as boolean)
: ((existing?.visible as boolean | undefined) ?? true),
name:
(newSkill.name as string) || (existing?.name as string) || "",
description:
newSkill.description !== undefined
? (newSkill.description as string)
: (existing?.description as string) || "",
level:
newSkill.level !== undefined
? (newSkill.level as number)
: ((existing?.level as number | undefined) ?? 1),
keywords:
(newSkill.keywords as string[]) ||
(existing?.keywords as string[]) ||
[],
};
},
);
baseResume.sections.skills.items = skillsWithSchema;
}
@ -239,10 +250,10 @@ export async function generatePdf(
if (Array.isArray(projectItems)) {
for (const item of projectItems) {
if (!item || typeof item !== "object") continue;
const id =
typeof (item as any).id === "string" ? (item as any).id : "";
const typedItem = item as Record<string, unknown>;
const id = typeof typedItem.id === "string" ? typedItem.id : "";
if (!id) continue;
(item as any).visible = selectedSet.has(id);
typedItem.visible = selectedSet.has(id);
}
projectsSection.visible = selectedSet.size > 0;
}

View File

@ -5,10 +5,11 @@
* There is no local file fallback.
*/
import type { ResumeProfile } from "../../shared/types.js";
import { getSetting } from "../repositories/settings.js";
import { getResume, RxResumeCredentialsError } from "./rxresume-v4.js";
let cachedProfile: any = null;
let cachedProfile: ResumeProfile | null = null;
let cachedResumeId: string | null = null;
/**
@ -20,7 +21,7 @@ let cachedResumeId: string | null = null;
* @param forceRefresh Force reload from API.
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
*/
export async function getProfile(forceRefresh = false): Promise<any> {
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId");
if (!rxresumeBaseResumeId) {

View File

@ -1,4 +1,5 @@
import type {
ResumeProfile,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
} from "../../shared/types.js";
@ -6,11 +7,11 @@ import type {
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & {
summaryText: string;
};
export function extractProjectsFromProfile(profile: unknown): {
export function extractProjectsFromProfile(profile: ResumeProfile): {
catalog: ResumeProjectCatalogItem[];
selectionItems: ResumeProjectSelectionItem[];
} {
const items = (profile as any)?.sections?.projects?.items;
const items = profile?.sections?.projects?.items;
if (!Array.isArray(items)) return { catalog: [], selectionItems: [] };
const catalog: ResumeProjectCatalogItem[] = [];
@ -19,20 +20,14 @@ export function extractProjectsFromProfile(profile: unknown): {
for (const item of items) {
if (!item || typeof item !== "object") continue;
const id = typeof (item as any).id === "string" ? (item as any).id : "";
const id = item.id || "";
if (!id) continue;
const name =
typeof (item as any).name === "string" ? (item as any).name : "";
const description =
typeof (item as any).description === "string"
? (item as any).description
: "";
const date =
typeof (item as any).date === "string" ? (item as any).date : "";
const isVisibleInBase = Boolean((item as any).visible);
const summary =
typeof (item as any).summary === "string" ? (item as any).summary : "";
const name = item.name || "";
const description = item.description || "";
const date = item.date || "";
const isVisibleInBase = Boolean(item.visible);
const summary = item.summary || "";
const summaryText = stripHtml(summary);
const base: ResumeProjectCatalogItem = {
@ -76,7 +71,7 @@ export function parseResumeProjectsSettings(
): ResumeProjectsSettings | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as any;
const parsed = JSON.parse(raw) as Partial<ResumeProjectsSettings>;
if (!parsed || typeof parsed !== "object") return null;
const maxProjects = parsed.maxProjects;
const lockedProjectIds = parsed.lockedProjectIds;

View File

@ -256,6 +256,8 @@ export class RxResumeClient {
if (!token) {
const setCookieHeader = res.headers.get("set-cookie");
// getSetCookie is a newer method in standard Fetch API, but might not be in all environments
// biome-ignore lint/suspicious/noExplicitAny: headers may not have getSetCookie in all types
const setCookieArray = (res.headers as any).getSetCookie?.() as
| string[]
| undefined;

View File

@ -11,8 +11,8 @@ export interface RxResumeResponse {
id: string;
name: string;
slug: string;
data: any;
[key: string]: any;
data: unknown;
[key: string]: unknown;
}
/**
@ -26,7 +26,7 @@ let lastWorkingKeyIndex = 0;
async function executeWithKeyRetries(
url: string,
options: RequestInit,
): Promise<any> {
): Promise<unknown> {
const rawApiKey = process.env.RXRESUME_API_KEY;
if (!rawApiKey) {
throw new Error("RXRESUME_API_KEY not configured in environment");
@ -42,52 +42,48 @@ async function executeWithKeyRetries(
for (let attempt = 0; attempt < apiKeys.length; attempt++) {
const i = (lastWorkingKeyIndex + attempt) % apiKeys.length;
const apiKey = apiKeys[i];
try {
const headers = {
"x-api-key": apiKey,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
} as Record<string, string>;
const headers = {
"x-api-key": apiKey,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
} as Record<string, string>;
const response = await fetch(url, {
...options,
headers,
});
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: response.statusText }));
const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`;
if (!response.ok) {
const errorData = (await response
.json()
.catch(() => ({ message: response.statusText }))) as {
message?: string;
};
const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`;
// ONLY retry/rotation on 401 Unauthorized
if (
response.status === 401 &&
apiKeys.length > 1 &&
attempt < apiKeys.length - 1
) {
console.warn(
`[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`,
);
continue;
}
throw new Error(errorMsg);
// ONLY retry/rotation on 401 Unauthorized
if (
response.status === 401 &&
apiKeys.length > 1 &&
attempt < apiKeys.length - 1
) {
console.warn(
`[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`,
);
continue;
}
// Success! Cache this key index for future requests
lastWorkingKeyIndex = i;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return response.json();
}
return response.text();
} catch (error) {
// If it was already handled by the 401 check above, it won't reach here
// because of the 'continue'. This catch is for network errors or unexpected throw.
throw error;
throw new Error(errorMsg);
}
// Success! Cache this key index for future requests
lastWorkingKeyIndex = i;
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return response.json();
}
return response.text();
}
// Unmissable error block if all keys fail
@ -111,7 +107,7 @@ async function executeWithKeyRetries(
export async function fetchRxResume(
path: string,
options: RequestInit = {},
): Promise<any> {
): Promise<unknown> {
const baseUrl = process.env.RXRESUME_URL || "https://rxresu.me";
let cleanBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
@ -130,7 +126,7 @@ export async function fetchRxResume(
* Fetch a resume by its ID.
*/
export async function getResume(id: string): Promise<RxResumeResponse> {
return fetchRxResume(`/resume/${id}`);
return (await fetchRxResume(`/resume/${id}`)) as RxResumeResponse;
}
/**
@ -139,7 +135,7 @@ export async function getResume(id: string): Promise<RxResumeResponse> {
export async function importResume(payload: {
name: string;
slug: string;
data: any;
data: unknown;
}): Promise<string> {
// Validate data against schema before sending
try {
@ -151,8 +147,8 @@ export async function importResume(payload: {
// DEBUG: Save payload to file for debugging (temporary)
try {
const fs = await import("fs/promises");
const path = await import("path");
const fs = await import("node:fs/promises");
const path = await import("node:path");
const debugDir = path.join(process.cwd(), "debug");
await fs.mkdir(debugDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@ -163,10 +159,10 @@ export async function importResume(payload: {
console.warn("⚠️ Could not save debug file:", debugErr);
}
const result = await fetchRxResume("/resume/import", {
const result = (await fetchRxResume("/resume/import", {
method: "POST",
body: JSON.stringify(payload),
});
})) as { id: string } | string;
// Reactive Resume returns the full resume object on import in v4+, or just ID in v5.
return typeof result === "string" ? result : result.id;
@ -183,7 +179,9 @@ export async function deleteResume(id: string): Promise<void> {
* Export a resume as PDF. Returns the URL.
*/
export async function exportResumePdf(id: string): Promise<string> {
const result = await fetchRxResume(`/printer/resume/${id}/pdf`);
const result = (await fetchRxResume(`/printer/resume/${id}/pdf`)) as {
url: string;
};
return result.url;
}
@ -192,5 +190,8 @@ export async function exportResumePdf(id: string): Promise<string> {
* According to official OpenAPI spec, the endpoint is /resume/list
*/
export async function listResumes(): Promise<{ id: string; name: string }[]> {
return fetchRxResume("/resume/list");
return (await fetchRxResume("/resume/list")) as {
id: string;
name: string;
}[];
}

View File

@ -75,7 +75,7 @@ export async function scoreJobSuitability(
const { score, reason } = result.data;
// Validate we got a reasonable response
if (typeof score !== "number" || isNaN(score)) {
if (typeof score !== "number" || Number.isNaN(score)) {
console.error(
`❌ [Job ${job.id}] Invalid score in response, using mock scoring`,
);
@ -142,7 +142,9 @@ export function parseJsonFromContent(
// Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON)
// First, let's normalize the string - escape actual newlines inside strings
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, (match) => {
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed to fix broken JSON from AI
const controlCharsRegex = /[\x00-\x1F\x7F]/g;
sanitized = sanitized.replace(controlCharsRegex, (match) => {
if (match === "\n") return "\\n";
if (match === "\r") return "\\r";
if (match === "\t") return "\\t";
@ -170,7 +172,7 @@ export function parseJsonFromContent(
if (scoreMatch) {
const score = Math.round(parseFloat(scoreMatch[1]));
const reason = reasonMatch
? reasonMatch[1].trim().replace(/[\x00-\x1F\x7F]/g, "")
? reasonMatch[1].trim().replace(controlCharsRegex, "")
: "Score extracted from malformed response";
console.log(
`⚠️ [Job ${jobId || "unknown"}] Parsed score via regex fallback: ${score}`,

View File

@ -2,6 +2,7 @@
* Service for generating tailored resume content (Summary, Headline, Skills).
*/
import type { ResumeProfile } from "../../shared/types.js";
import { getSetting } from "../repositories/settings.js";
import { callOpenRouter, type JsonSchemaDefinition } from "./openrouter.js";
@ -62,7 +63,7 @@ const TAILORING_SCHEMA: JsonSchemaDefinition = {
*/
export async function generateTailoring(
jobDescription: string,
profile: Record<string, unknown>,
profile: ResumeProfile,
): Promise<TailoringResult> {
if (!process.env.OPENROUTER_API_KEY) {
console.warn("⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring");
@ -113,7 +114,7 @@ export async function generateTailoring(
*/
export async function generateSummary(
jobDescription: string,
profile: Record<string, unknown>,
profile: ResumeProfile,
): Promise<{ success: boolean; summary?: string; error?: string }> {
// If we just need summary, we can discard the rest (or cache it? but here we just return summary)
const result = await generateTailoring(jobDescription, profile);
@ -124,24 +125,21 @@ export async function generateSummary(
};
}
function buildTailoringPrompt(
profile: Record<string, unknown>,
jd: string,
): string {
function buildTailoringPrompt(profile: ResumeProfile, jd: string): string {
// Extract only needed parts of profile to save tokens
const relevantProfile = {
basics: {
name: (profile as any).basics?.name,
label: (profile as any).basics?.label, // Original headline
summary: (profile as any).basics?.summary,
name: profile.basics?.name,
label: profile.basics?.label, // Original headline
summary: profile.basics?.summary,
},
skills: (profile as any).sections?.skills || (profile as any).skills,
projects: (profile as any).sections?.projects?.items?.map((p: any) => ({
skills: profile.sections?.skills,
projects: profile.sections?.projects?.items?.map((p) => ({
name: p.name,
description: p.description,
keywords: p.keywords,
})),
experience: (profile as any).sections?.experience?.items?.map((e: any) => ({
experience: profile.sections?.experience?.items?.map((e) => ({
company: e.company,
position: e.position,
summary: e.summary,

View File

@ -4,10 +4,10 @@
* Spawns the extractor as a child process and reads its output dataset.
*/
import { spawn } from "child_process";
import { mkdir, readdir, readFile, rm } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { spawn } from "node:child_process";
import { mkdir, readdir, readFile, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { CreateJobInput } from "../../shared/types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -192,7 +192,7 @@ function cleanHtml(html: string): string {
// Limit length to avoid blowing up AI context
if (text.length > 8000) {
text = text.substring(0, 8000) + "...";
text = `${text.substring(0, 8000)}...`;
}
return text;
@ -220,7 +220,7 @@ async function fetchJobDescription(url: string): Promise<string | null> {
};
if (cookieParts.length > 0) {
headers["Cookie"] = cookieParts.join("; ");
headers.Cookie = cookieParts.join("; ");
}
const response = await fetch(url, {
@ -272,8 +272,7 @@ function isAuthErrorResponse(status: number, bodyText: string): boolean {
message?: string;
};
if (parsed?.errorType === "expired") return true;
if (parsed?.message && parsed.message.toLowerCase().includes("expired"))
return true;
if (parsed?.message?.toLowerCase().includes("expired")) return true;
} catch {
// Ignore parse errors
}
@ -413,7 +412,7 @@ export async function fetchUkVisaJobsPage(
let data: UkVisaJobsApiResponse;
try {
data = JSON.parse(text) as UkVisaJobsApiResponse;
} catch (error) {
} catch (_error) {
throw new Error("UK Visa Jobs API returned an invalid response.");
}

View File

@ -78,7 +78,7 @@ describe("calculateSponsorMatchSummary", () => {
const summary = calculateSponsorMatchSummary(results);
expect(summary.sponsorMatchScore).toBe(100);
const names = JSON.parse(summary.sponsorMatchNames!);
const names = JSON.parse(summary.sponsorMatchNames || "[]");
expect(names).toHaveLength(2);
expect(names).toContain("First PerfectMatch");
expect(names).toContain("Second PerfectMatch");

View File

@ -4,8 +4,8 @@
* Manages downloading, storing, and searching the UK visa sponsor list.
*/
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import { getDataDir } from "../../config/dataDir.js";
const DATA_DIR = path.join(getDataDir(), "visa-sponsors");

View File

@ -330,9 +330,23 @@ export interface ResumeProfile {
url?: string;
}>;
};
[key: string]: any;
experience?: {
id?: string;
visible?: boolean;
name?: string;
items?: Array<{
id: string;
company: string;
position: string;
location: string;
date: string;
summary: string;
visible: boolean;
}>;
};
[key: string]: unknown;
};
[key: string]: any;
[key: string]: unknown;
}
export interface ProfileStatusResponse {

View File

@ -1,8 +1,8 @@
/// <reference types="vitest" />
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
export default defineConfig({