gem3 flash lint fix
This commit is contained in:
parent
76957e6f92
commit
d4e83c0674
25
biome.json
25
biome.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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) => ({
|
||||
|
||||
@ -121,7 +121,6 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({
|
||||
case "completed":
|
||||
case "failed":
|
||||
return 100;
|
||||
case "idle":
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -34,7 +34,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
setMode("decide");
|
||||
setIsSkipping(false);
|
||||
setIsFinalizing(false);
|
||||
}, [job?.id]);
|
||||
}, []);
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!job) return;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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.";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -414,7 +414,6 @@ export const SettingsPage: React.FC = () => {
|
||||
envSettings,
|
||||
defaultResumeProjects,
|
||||
profileProjects,
|
||||
maxProjectsTotal,
|
||||
} = derived;
|
||||
|
||||
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -33,7 +33,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}[];
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user