Merge pull request #9 from DaKheera47/refactoring-large-files

Refactoring large files
This commit is contained in:
Shaheer Sarfaraz 2026-01-20 11:45:13 +00:00 committed by GitHub
commit 97984be84f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 6725 additions and 4096 deletions

View File

@ -3,7 +3,7 @@
*/
import React, { useRef } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner";
@ -16,11 +16,20 @@ export const App: React.FC = () => {
const location = useLocation();
const nodeRef = useRef<HTMLDivElement>(null);
// Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs
const pageKey = React.useMemo(() => {
const firstSegment = location.pathname.split("/")[1] || "ready";
if (["ready", "discovered", "applied", "all"].includes(firstSegment)) {
return "orchestrator";
}
return firstSegment;
}, [location.pathname]);
return (
<>
<SwitchTransition mode="out-in">
<CSSTransition
key={location.pathname}
key={pageKey}
nodeRef={nodeRef}
timeout={100}
classNames="page"
@ -28,10 +37,12 @@ export const App: React.FC = () => {
>
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<OrchestratorPage />} />
<Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</div>
</CSSTransition>

View File

@ -21,7 +21,7 @@ import type {
VisaSponsorStatusResponse,
VisaSponsor,
} from '../../shared/types';
import { trackEvent } from '../lib/analytics';
import { trackEvent } from "@/lib/analytics";
const API_BASE = '/api';

View File

@ -1,699 +0,0 @@
/**
* DiscoveredPanel - Two-mode triage workspace for Discovered jobs.
*
* Mode A: Decide (default) - Quick assessment to Skip or Tailor
* Mode B: Tailor - Draft tailoring data before moving to Ready
*
* Moving to Ready generates the PDF using the current tailored draft.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
ArrowLeft,
Calendar,
Check,
ChevronDown,
ChevronUp,
DollarSign,
ExternalLink,
Loader2,
MapPin,
Sparkles,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { formatDate } from "../lib/dateUtils";
import * as api from "../api";
import { FitAssessment } from ".";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
type PanelMode = "decide" | "tailor";
interface DiscoveredPanelProps {
job: Job | null;
onJobUpdated: () => void | Promise<void>;
onJobMoved: (jobId: string) => void;
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
const stripHtml = (value: string) =>
value
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
const sourceLabel: Record<Job["source"], string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
linkedin: "LinkedIn",
ukvisajobs: "UK Visa Jobs",
manual: "Manual",
};
// ─────────────────────────────────────────────────────────────────────────────
// Decide Mode Panel
// ─────────────────────────────────────────────────────────────────────────────
interface DecideModeProps {
job: Job;
onTailor: () => void;
onSkip: () => void;
isSkipping: boolean;
}
const DecideMode: React.FC<DecideModeProps> = ({
job,
onTailor,
onSkip,
isSkipping,
}) => {
const [showDescription, setShowDescription] = useState(false);
const deadline = formatDate(job.deadline);
const jobLink = job.applicationLink || job.jobUrl;
const description = useMemo(() => {
if (!job.jobDescription) return "No description available.";
const jd = job.jobDescription;
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
return jd;
}, [job.jobDescription]);
return (
<div className='flex flex-col h-full'>
{/* Header */}
<div className='space-y-3 pb-4'>
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0 flex-1'>
<h2 className='text-base font-semibold text-foreground/90 leading-tight'>
{job.title}
</h2>
<p className='text-sm text-muted-foreground mt-0.5'>
{job.employer}
</p>
</div>
<div className="flex flex-col items-center justify-center">
<Badge
variant='outline'
className='text-[10px] uppercase tracking-wide text-muted-foreground border-border/50 shrink-0'
>
{sourceLabel[job.source]}
</Badge>
</div>
</div>
{/* Metadata row */}
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 justify-between'>
{job.location && (
<span className='flex items-center gap-1'>
<MapPin className='h-3 w-3' />
{job.location}
</span>
)}
{deadline && (
<span className='flex items-center gap-1'>
<Calendar className='h-3 w-3' />
{deadline}
</span>
)}
{job.salary && (
<span className='flex items-center gap-1'>
<DollarSign className='h-3 w-3' />
{job.salary}
</span>
)}
</div>
{/* Primary/Secondary actions */}
<div className='flex flex-col gap-2 pt-2 sm:flex-row'>
<Button
variant='outline'
size='default'
onClick={onSkip}
disabled={isSkipping}
className='flex-1 h-11 text-sm text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
>
{isSkipping ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<XCircle className='mr-2 h-4 w-4' />
)}
Skip
</Button>
<Button
size='default'
onClick={onTailor}
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs'
>
<Sparkles className='mr-2 h-4 w-4' />
Tailor
</Button>
</div>
</div>
<Separator className='opacity-50' />
{/* Fit Summary - the core content */}
<div className='flex-1 py-4 space-y-4 overflow-y-auto'>
<FitAssessment job={job} />
{/* Collapsible full description */}
<div className='space-y-2'>
<button
type='button'
onClick={() => setShowDescription(!showDescription)}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
>
{showDescription ? (
<ChevronUp className='h-3.5 w-3.5' />
) : (
<ChevronDown className='h-3.5 w-3.5' />
)}
{showDescription ? "Hide" : "View"} full job description
</button>
{showDescription && (
<div className='rounded-lg border border-border/40 bg-muted/5 p-3 max-h-[300px] overflow-y-auto'>
<p className='text-xs text-muted-foreground/80 whitespace-pre-wrap leading-relaxed'>
{description}
</p>
</div>
)}
</div>
</div>
<Separator className='opacity-50' />
{/* Actions - clear hierarchy */}
<div className='pt-4 pb-2'>
{/* External link - tertiary */}
<div className='flex justify-center'>
<a
href={jobLink}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
>
<ExternalLink className='h-3 w-3' />
View original listing
</a>
</div>
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Tailor Mode Panel
// ─────────────────────────────────────────────────────────────────────────────
interface TailorModeProps {
job: Job;
onBack: () => void;
onFinalize: () => void;
isFinalizing: boolean;
}
const TailorMode: React.FC<TailorModeProps> = ({
job,
onBack,
onFinalize,
isFinalizing,
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved);
});
const [isGenerating, setIsGenerating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [draftStatus, setDraftStatus] = useState<
"unsaved" | "saving" | "saved"
>("saved");
const [showDescription, setShowDescription] = useState(false);
// Load project catalog
useEffect(() => {
api.getProfileProjects().then(setCatalog).catch(console.error);
}, []);
// Reset form when job changes
useEffect(() => {
setSummary(job.tailoredSummary || "");
setJobDescription(job.jobDescription || "");
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
setSelectedIds(new Set(saved));
setDraftStatus("saved");
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
// Track unsaved changes
const savedSummary = job.tailoredSummary || "";
const savedDescription = job.jobDescription || "";
const savedIds = useMemo(() => {
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved);
}, [job.selectedProjectIds]);
const hasChanges = useMemo(() => {
if (summary !== savedSummary) return true;
if (jobDescription !== savedDescription) return true;
if (selectedIds.size !== savedIds.size) return true;
for (const id of selectedIds) {
if (!savedIds.has(id)) return true;
}
return false;
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]);
// Update draft status when changes are made
useEffect(() => {
if (hasChanges && draftStatus === "saved") {
setDraftStatus("unsaved");
}
}, [hasChanges, draftStatus]);
// Auto-save draft (debounced)
useEffect(() => {
if (!hasChanges || draftStatus !== "unsaved") return;
const timeout = setTimeout(async () => {
try {
setDraftStatus("saving");
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
setDraftStatus("saved");
} catch {
setDraftStatus("unsaved");
}
}, 1500);
return () => clearTimeout(timeout);
}, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]);
const handleToggleProject = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
const handleGenerateWithAI = async () => {
try {
setIsGenerating(true);
// Save any pending changes first so AI uses the latest description
if (hasChanges) {
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
}
const updatedJob = await api.summarizeJob(job.id, { force: true });
setSummary(updatedJob.tailoredSummary || "");
setJobDescription(updatedJob.jobDescription || "");
if (updatedJob.selectedProjectIds) {
setSelectedIds(
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
);
}
setDraftStatus("saved"); // AI response is saved server-side
toast.success("Draft generated with AI", {
description: "Review and edit before finalizing.",
});
} catch {
toast.error("Failed to generate AI draft");
} finally {
setIsGenerating(false);
}
};
const handleFinalize = async () => {
// Save any pending changes first
if (hasChanges) {
try {
setIsSaving(true);
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
} catch {
toast.error("Failed to save draft before finalizing");
setIsSaving(false);
return;
} finally {
setIsSaving(false);
}
}
// Now finalize (which generates PDF and moves to Ready)
onFinalize();
};
const maxProjects = 3;
const tooManyProjects = selectedIds.size > maxProjects;
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
return (
<div className='flex flex-col h-full'>
{/* Header with back navigation */}
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
<button
type='button'
onClick={onBack}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
>
<ArrowLeft className='h-3.5 w-3.5' />
Back to overview
</button>
{/* Draft status indicator */}
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
{draftStatus === "saving" && (
<>
<Loader2 className='h-3 w-3 animate-spin' />
Saving...
</>
)}
{draftStatus === "saved" && !hasChanges && (
<>
<Check className='h-3 w-3 text-emerald-400' />
Saved
</>
)}
{draftStatus === "unsaved" && (
<span className='text-amber-400'>Unsaved changes</span>
)}
</div>
</div>
{/* Draft framing */}
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'>
<div className='flex items-center gap-2'>
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
<span className='text-xs font-medium text-amber-300'>
Draft tailoring for this role
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
Edit below, then finalize to generate your PDF and move to Ready.
</p>
</div>
{/* Scrollable content */}
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
{/* AI Generate option */}
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
<div>
<div className='text-xs font-medium'>
Need help getting started?
</div>
<div className='text-[10px] text-muted-foreground'>
AI can draft a summary and select projects for you
</div>
</div>
<Button
size='sm'
variant='outline'
onClick={handleGenerateWithAI}
disabled={isGenerating || isFinalizing}
className='h-8 w-full text-xs sm:w-auto'
>
{isGenerating ? (
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
) : (
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
)}
Generate draft
</Button>
</div>
{/* Job Description - collapsible */}
<div className='space-y-2'>
<button
type='button'
onClick={() => setShowDescription(!showDescription)}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
>
{showDescription ? (
<ChevronUp className='h-3.5 w-3.5' />
) : (
<ChevronDown className='h-3.5 w-3.5' />
)}
{showDescription ? "Hide" : "Edit"} job description
</button>
{showDescription && (
<div className='space-y-1'>
<label className='text-[10px] font-medium text-muted-foreground/70'>
Edit to help AI tailoring
</label>
<textarea
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={(e) => setJobDescription(e.target.value)}
placeholder='The raw job description...'
disabled={isGenerating || isFinalizing}
/>
</div>
)}
</div>
{/* Tailored Summary */}
<div className='space-y-2'>
<label className='text-xs font-medium text-muted-foreground'>
Tailored Summary
</label>
<textarea
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={(e) => setSummary(e.target.value)}
placeholder='Write a tailored summary for this role, or generate with AI...'
disabled={isGenerating || isFinalizing}
/>
</div>
{/* Selected Projects */}
<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'>
Selected Projects
</label>
{tooManyProjects && (
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
<AlertTriangle className='h-3 w-3' />
Max {maxProjects} recommended
</span>
)}
</div>
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
{catalog.length === 0 ? (
<div className='text-xs text-muted-foreground text-center py-4'>
Loading projects...
</div>
) : (
catalog.map((project) => (
<div
key={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"
)}
onClick={() =>
!isGenerating &&
!isFinalizing &&
handleToggleProject(project.id)
}
>
<Checkbox
id={`project-${project.id}`}
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleProject(project.id)}
disabled={isGenerating || isFinalizing}
className='mt-0.5'
/>
<div className='flex-1 min-w-0'>
<div className='font-medium truncate'>{project.name}</div>
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
{project.description}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
<Separator className='opacity-50 my-4' />
{/* Actions */}
<div className='space-y-2'>
{!canFinalize && (
<p className='text-[10px] text-center text-muted-foreground'>
Add a summary and select at least one project to finalize.
</p>
)}
<Button
onClick={handleFinalize}
disabled={isFinalizing || !canFinalize || isGenerating}
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
>
{isFinalizing ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Finalizing & generating PDF...
</>
) : (
<>
<Check className='mr-2 h-4 w-4' />
Finalize & Move to Ready
</>
)}
</Button>
<p className='text-[10px] text-center text-muted-foreground/70'>
This will generate your tailored PDF and move the job to Ready.
</p>
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Main Panel Component
// ─────────────────────────────────────────────────────────────────────────────
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
job,
onJobUpdated,
onJobMoved,
}) => {
const [mode, setMode] = useState<PanelMode>("decide");
const [isSkipping, setIsSkipping] = useState(false);
const [isFinalizing, setIsFinalizing] = useState(false);
// Reset mode when job changes
useEffect(() => {
setMode("decide");
setIsSkipping(false);
setIsFinalizing(false);
}, [job?.id]);
const handleSkip = async () => {
if (!job) return;
try {
setIsSkipping(true);
await api.skipJob(job.id);
toast.message("Job skipped");
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to skip job";
toast.error(message);
} finally {
setIsSkipping(false);
}
};
const handleFinalize = async () => {
if (!job) return;
try {
setIsFinalizing(true);
// Generate PDF - this also transitions to Ready status
await api.processJob(job.id);
toast.success("Job moved to Ready", {
description: "Your tailored PDF has been generated.",
});
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to finalize job";
toast.error(message);
} finally {
setIsFinalizing(false);
}
};
// Empty state
if (!job) {
return (
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'>
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'>
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
</div>
<div className='text-sm font-medium text-muted-foreground'>
No job selected
</div>
<p className='text-xs text-muted-foreground/70 max-w-[200px]'>
Select a job from the list to see details and decide whether to
tailor.
</p>
</div>
);
}
// Processing state (job is being processed by pipeline)
if (job.status === "processing") {
return (
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'>
<Loader2 className='h-8 w-8 animate-spin text-amber-400' />
<div className='text-sm font-medium text-foreground/80'>
Processing job...
</div>
<p className='text-xs text-muted-foreground max-w-[220px]'>
This job is currently being analyzed by the pipeline. Please wait.
</p>
</div>
);
}
return (
<div className='h-full'>
{mode === "decide" ? (
<DecideMode
job={job}
onTailor={() => setMode("tailor")}
onSkip={handleSkip}
isSkipping={isSkipping}
/>
) : (
<TailorMode
job={job}
onBack={() => setMode("decide")}
onFinalize={handleFinalize}
isFinalizing={isFinalizing}
/>
)}
</div>
);
};
export default DiscoveredPanel;

View File

@ -33,6 +33,7 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../shared/types";
interface HeaderProps {
@ -55,14 +56,6 @@ export const Header: React.FC<HeaderProps> = ({
const location = useLocation();
const [sheetOpen, setSheetOpen] = React.useState(false);
const sourceLabel: Record<JobSource, string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
linkedin: "LinkedIn",
ukvisajobs: "UK Visa Jobs",
manual: "Manual",
};
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
const navLinks = [

View File

@ -0,0 +1,90 @@
import React from "react";
import { Calendar, DollarSign, MapPin } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn, formatDate, sourceLabel } from "@/lib/utils";
import type { Job, JobStatus } from "../../shared/types";
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
interface JobHeaderProps {
job: Job;
className?: string;
}
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
const tokens = statusTokens[status] ?? defaultStatusToken;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
{tokens.label}
</span>
);
};
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
if (score == null) {
return <span className="text-[10px] text-muted-foreground/60">-</span>;
}
return (
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
<div className="h-1 w-12 rounded-full bg-muted/30">
<div
className="h-1 rounded-full bg-primary/50"
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
/>
</div>
<span className="tabular-nums">{score}</span>
</div>
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
const deadline = formatDate(job.deadline);
return (
<div className={cn("space-y-3", className)}>
{/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
<div className="text-xs text-muted-foreground">{job.employer}</div>
</div>
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
{sourceLabel[job.source]}
</Badge>
</div>
{/* Tertiary metadata - subdued */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{job.location}
</span>
)}
{deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{deadline}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{job.salary}
</span>
)}
</div>
{/* Status and score: single line, subdued */}
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
<StatusPill status={job.status} />
<ScoreMeter score={job.suitabilityScore} />
</div>
</div>
);
};

View File

@ -39,10 +39,9 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
import * as api from "../api";
import { FitAssessment } from ".";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
interface ReadyPanelProps {
@ -96,8 +95,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
.filter(Boolean) as string[];
}, [catalog, selectedProjectIds]);
const tailoredSummary = job?.tailoredSummary || null;
// Handle mark as applied with undo capability
const handleMarkApplied = useCallback(async () => {
if (!job) return;
@ -218,6 +215,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
return (
<div className="flex flex-col h-full">
<JobHeader job={job} className="pb-4 border-b border-border/40" />
{/*
PRIMARY ACTION CLUSTER
All actions in one line: View, Save, Open, and Mark Applied
@ -276,18 +275,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
{/* Job identity - confirm this is the right role */}
<div className="space-y-3">
<FitAssessment job={job} />
{/* Tailored summary snippet - shows what's in the PDF */}
{tailoredSummary && (
<div className="rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
Tailored Summary
</div>
<p className="text-xs text-foreground/80 leading-relaxed italic whitespace-pre-wrap">
"{tailoredSummary}"
</p>
</div>
)}
<TailoredSummary job={job} />
{/* Project selection - expandable accordion */}
<Accordion type="single" collapsible className="w-full">

View File

@ -0,0 +1,23 @@
import React from "react";
import { cn } from "@/lib/utils";
import type { Job } from "../../shared/types";
interface TailoredSummaryProps {
job: Job;
className?: string;
}
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({ job, className }) => {
if (!job.tailoredSummary) return null;
return (
<div className={cn("rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5", className)}>
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
Tailored Summary
</div>
<p className="text-xs text-foreground/80 leading-relaxed italic whitespace-pre-wrap">
"{job.tailoredSummary}"
</p>
</div>
);
};

View File

@ -0,0 +1,34 @@
import React from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
interface CollapsibleSectionProps {
isOpen: boolean;
label: string;
onToggle: () => void;
children: React.ReactNode;
}
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
isOpen,
label,
onToggle,
children,
}) => {
return (
<div className='space-y-2'>
<button
type='button'
onClick={onToggle}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
>
{isOpen ? (
<ChevronUp className='h-3.5 w-3.5' />
) : (
<ChevronDown className='h-3.5 w-3.5' />
)}
{label}
</button>
{isOpen ? children : null}
</div>
);
};

View File

@ -0,0 +1,103 @@
import React, { useMemo, useState } from "react";
import { ExternalLink, Loader2, Sparkles, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { FitAssessment, JobHeader, TailoredSummary } from "..";
import type { Job } from "../../../shared/types";
import { CollapsibleSection } from "./CollapsibleSection";
import { getPlainDescription } from "./helpers";
interface DecideModeProps {
job: Job;
onTailor: () => void;
onSkip: () => void;
isSkipping: boolean;
}
export const DecideMode: React.FC<DecideModeProps> = ({
job,
onTailor,
onSkip,
isSkipping,
}) => {
const [showDescription, setShowDescription] = useState(false);
const jobLink = job.applicationLink || job.jobUrl;
const description = useMemo(
() => getPlainDescription(job.jobDescription),
[job.jobDescription]
);
return (
<div className='flex flex-col h-full'>
<div className='space-y-4 pb-4'>
<JobHeader job={job} />
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
<Button
variant='outline'
size='default'
onClick={onSkip}
disabled={isSkipping}
className='flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
>
{isSkipping ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<XCircle className='mr-2 h-4 w-4' />
)}
Skip Job
</Button>
<Button
size='default'
onClick={onTailor}
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm'
>
<Sparkles className='mr-2 h-4 w-4' />
Start Tailoring
</Button>
</div>
</div>
<Separator className='opacity-40' />
<div className='flex-1 py-6 space-y-6 overflow-y-auto'>
<FitAssessment job={job} />
<TailoredSummary job={job} />
<CollapsibleSection
isOpen={showDescription}
onToggle={() => setShowDescription((prev) => !prev)}
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
>
<div className='rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner'>
<p className='text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed'>
{description}
</p>
</div>
</CollapsibleSection>
</div>
<Separator className='opacity-40' />
<div className='pt-6 pb-2'>
{jobLink ? (
<div className='flex justify-center'>
<a
href={jobLink}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors'
>
<ExternalLink className='h-3.5 w-3.5' />
Original Job Listing
</a>
</div>
) : null}
</div>
</div>
);
};

View File

@ -0,0 +1,101 @@
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import * as api from "../../api";
import type { Job } from "../../../shared/types";
import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState";
import { ProcessingState } from "./ProcessingState";
import { TailorMode } from "./TailorMode";
type PanelMode = "decide" | "tailor";
interface DiscoveredPanelProps {
job: Job | null;
onJobUpdated: () => void | Promise<void>;
onJobMoved: (jobId: string) => void;
}
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
job,
onJobUpdated,
onJobMoved,
}) => {
const [mode, setMode] = useState<PanelMode>("decide");
const [isSkipping, setIsSkipping] = useState(false);
const [isFinalizing, setIsFinalizing] = useState(false);
useEffect(() => {
setMode("decide");
setIsSkipping(false);
setIsFinalizing(false);
}, [job?.id]);
const handleSkip = async () => {
if (!job) return;
try {
setIsSkipping(true);
await api.skipJob(job.id);
toast.message("Job skipped");
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to skip job";
toast.error(message);
} finally {
setIsSkipping(false);
}
};
const handleFinalize = async () => {
if (!job) return;
try {
setIsFinalizing(true);
await api.processJob(job.id);
toast.success("Job moved to Ready", {
description: "Your tailored PDF has been generated.",
});
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to finalize job";
toast.error(message);
} finally {
setIsFinalizing(false);
}
};
if (!job) {
return <EmptyState />;
}
if (job.status === "processing") {
return <ProcessingState />;
}
return (
<div className='h-full'>
{mode === "decide" ? (
<DecideMode
job={job}
onTailor={() => setMode("tailor")}
onSkip={handleSkip}
isSkipping={isSkipping}
/>
) : (
<TailorMode
job={job}
onBack={() => setMode("decide")}
onFinalize={handleFinalize}
isFinalizing={isFinalizing}
/>
)}
</div>
);
};
export default DiscoveredPanel;

View File

@ -0,0 +1,18 @@
import React from "react";
import { Sparkles } from "lucide-react";
export const EmptyState: React.FC = () => {
return (
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'>
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'>
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
</div>
<div className='text-sm font-medium text-muted-foreground'>
No job selected
</div>
<p className='text-xs text-muted-foreground/70 max-w-[200px]'>
Select a job from the list to see details and decide whether to tailor.
</p>
</div>
);
};

View File

@ -0,0 +1,16 @@
import React from "react";
import { Loader2 } from "lucide-react";
export const ProcessingState: React.FC = () => {
return (
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'>
<Loader2 className='h-8 w-8 animate-spin text-amber-400' />
<div className='text-sm font-medium text-foreground/80'>
Processing job...
</div>
<p className='text-xs text-muted-foreground max-w-[220px]'>
This job is currently being analyzed by the pipeline. Please wait.
</p>
</div>
);
};

View File

@ -0,0 +1,75 @@
import React from "react";
import { AlertTriangle } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { ResumeProjectCatalogItem } from "../../../shared/types";
interface ProjectSelectorProps {
catalog: ResumeProjectCatalogItem[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
maxProjects: number;
disabled: boolean;
}
export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
catalog,
selectedIds,
onToggle,
maxProjects,
disabled,
}) => {
const tooManyProjects = selectedIds.size > maxProjects;
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'>
Selected Projects
</label>
{tooManyProjects && (
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
<AlertTriangle className='h-3 w-3' />
Max {maxProjects} recommended
</span>
)}
</div>
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
{catalog.length === 0 ? (
<div className='text-xs text-muted-foreground text-center py-4'>
Loading projects...
</div>
) : (
catalog.map((project) => (
<div
key={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"
)}
onClick={() => !disabled && onToggle(project.id)}
>
<Checkbox
id={`project-${project.id}`}
checked={selectedIds.has(project.id)}
onCheckedChange={() => onToggle(project.id)}
disabled={disabled}
className='mt-0.5'
/>
<div className='flex-1 min-w-0'>
<div className='font-medium truncate'>{project.name}</div>
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
{project.description}
</div>
</div>
</div>
))
)}
</div>
</div>
);
};

View File

@ -0,0 +1,303 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import * as api from "../../api";
import type { Job, ResumeProjectCatalogItem } from "../../../shared/types";
import { CollapsibleSection } from "./CollapsibleSection";
import { ProjectSelector } from "./ProjectSelector";
interface TailorModeProps {
job: Job;
onBack: () => void;
onFinalize: () => void;
isFinalizing: boolean;
}
export const TailorMode: React.FC<TailorModeProps> = ({
job,
onBack,
onFinalize,
isFinalizing,
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved);
});
const [isGenerating, setIsGenerating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [draftStatus, setDraftStatus] = useState<
"unsaved" | "saving" | "saved"
>("saved");
const [showDescription, setShowDescription] = useState(false);
useEffect(() => {
api.getProfileProjects().then(setCatalog).catch(console.error);
}, []);
useEffect(() => {
setSummary(job.tailoredSummary || "");
setJobDescription(job.jobDescription || "");
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
setSelectedIds(new Set(saved));
setDraftStatus("saved");
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
const savedSummary = job.tailoredSummary || "";
const savedDescription = job.jobDescription || "";
const savedIds = useMemo(() => {
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
return new Set(saved);
}, [job.selectedProjectIds]);
const hasChanges = useMemo(() => {
if (summary !== savedSummary) return true;
if (jobDescription !== savedDescription) return true;
if (selectedIds.size !== savedIds.size) return true;
for (const id of selectedIds) {
if (!savedIds.has(id)) return true;
}
return false;
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]);
useEffect(() => {
if (hasChanges && draftStatus === "saved") {
setDraftStatus("unsaved");
}
}, [hasChanges, draftStatus]);
useEffect(() => {
if (!hasChanges || draftStatus !== "unsaved") return;
const timeout = setTimeout(async () => {
try {
setDraftStatus("saving");
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
setDraftStatus("saved");
} catch {
setDraftStatus("unsaved");
}
}, 1500);
return () => clearTimeout(timeout);
}, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]);
const handleToggleProject = useCallback(
(id: string) => {
if (isGenerating || isFinalizing) return;
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[isGenerating, isFinalizing]
);
const handleGenerateWithAI = async () => {
try {
setIsGenerating(true);
if (hasChanges) {
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
}
const updatedJob = await api.summarizeJob(job.id, { force: true });
setSummary(updatedJob.tailoredSummary || "");
setJobDescription(updatedJob.jobDescription || "");
if (updatedJob.selectedProjectIds) {
setSelectedIds(
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
);
}
setDraftStatus("saved");
toast.success("Draft generated with AI", {
description: "Review and edit before finalizing.",
});
} catch {
toast.error("Failed to generate AI draft");
} finally {
setIsGenerating(false);
}
};
const handleFinalize = async () => {
if (hasChanges) {
try {
setIsSaving(true);
await api.updateJob(job.id, {
tailoredSummary: summary,
jobDescription: jobDescription,
selectedProjectIds: Array.from(selectedIds).join(","),
});
} catch {
toast.error("Failed to save draft before finalizing");
setIsSaving(false);
return;
} finally {
setIsSaving(false);
}
}
onFinalize();
};
const maxProjects = 3;
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
const disableInputs = isGenerating || isFinalizing || isSaving;
return (
<div className='flex flex-col h-full'>
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
<button
type='button'
onClick={onBack}
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
>
<ArrowLeft className='h-3.5 w-3.5' />
Back to overview
</button>
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
{draftStatus === "saving" && (
<>
<Loader2 className='h-3 w-3 animate-spin' />
Saving...
</>
)}
{draftStatus === "saved" && !hasChanges && (
<>
<Check className='h-3 w-3 text-emerald-400' />
Saved
</>
)}
{draftStatus === "unsaved" && (
<span className='text-amber-400'>Unsaved changes</span>
)}
</div>
</div>
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'>
<div className='flex items-center gap-2'>
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
<span className='text-xs font-medium text-amber-300'>
Draft tailoring for this role
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
Edit below, then finalize to generate your PDF and move to Ready.
</p>
</div>
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
<div>
<div className='text-xs font-medium'>Need help getting started?</div>
<div className='text-[10px] text-muted-foreground'>
AI can draft a summary and select projects for you
</div>
</div>
<Button
size='sm'
variant='outline'
onClick={handleGenerateWithAI}
disabled={isGenerating || isFinalizing}
className='h-8 w-full text-xs sm:w-auto'
>
{isGenerating ? (
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
) : (
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
)}
Generate draft
</Button>
</div>
<CollapsibleSection
isOpen={showDescription}
onToggle={() => setShowDescription((prev) => !prev)}
label={`${showDescription ? "Hide" : "Edit"} job description`}
>
<div className='space-y-1'>
<label className='text-[10px] font-medium text-muted-foreground/70'>
Edit to help AI tailoring
</label>
<textarea
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)}
placeholder='The raw job description...'
disabled={disableInputs}
/>
</div>
</CollapsibleSection>
<div className='space-y-2'>
<label className='text-xs font-medium text-muted-foreground'>
Tailored Summary
</label>
<textarea
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)}
placeholder='Write a tailored summary for this role, or generate with AI...'
disabled={disableInputs}
/>
</div>
<ProjectSelector
catalog={catalog}
selectedIds={selectedIds}
onToggle={handleToggleProject}
maxProjects={maxProjects}
disabled={disableInputs}
/>
</div>
<Separator className='opacity-50 my-4' />
<div className='space-y-2'>
{!canFinalize && (
<p className='text-[10px] text-center text-muted-foreground'>
Add a summary and select at least one project to finalize.
</p>
)}
<Button
onClick={handleFinalize}
disabled={isFinalizing || !canFinalize || isGenerating}
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
>
{isFinalizing ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Finalizing & generating PDF...
</>
) : (
<>
<Check className='mr-2 h-4 w-4' />
Finalize & Move to Ready
</>
)}
</Button>
<p className='text-[10px] text-center text-muted-foreground/70'>
This will generate your tailored PDF and move the job to Ready.
</p>
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1 @@
export { DiscoveredPanel } from "./DiscoveredPanel";

View File

@ -2,10 +2,12 @@ export { Header } from './Header';
export { Stats } from './Stats';
export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator';
export { JobHeader } from './JobHeader';
export { TailoredSummary } from './TailoredSummary';
export { FitAssessment } from './FitAssessment';
export { PipelineProgress } from './PipelineProgress';
export { TailoringEditor } from './TailoringEditor';
export { DiscoveredPanel } from './DiscoveredPanel';
export { DiscoveredPanel } from './discovered-panel';
export { ReadyPanel } from './ReadyPanel';
export { ManualImportSheet } from './ManualImportSheet';
export * from './layout';

View File

@ -81,7 +81,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
onClick={() => handleNavClick(to)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
)}

View File

@ -1,36 +0,0 @@
export const formatDate = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
return parsed.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return dateStr;
}
};
export const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
const date = parsed.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
const time = parsed.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
});
return `${date} ${time}`;
} catch {
return dateStr;
}
};

View File

@ -1,39 +0,0 @@
import type { Job } from "@shared/types";
export const formatJobForWebhook = (job: Job) => {
return JSON.stringify(
{
event: "job.completed",
sentAt: new Date().toISOString(),
job,
},
null,
2,
);
};
export async function copyTextToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) {
throw new Error("Copy failed");
}
}

View File

@ -0,0 +1,275 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { OrchestratorPage } from "./OrchestratorPage";
import type { Job } from "../../shared/types";
import type { FilterTab } from "./orchestrator/constants";
const jobFixture: Job = {
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
};
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
const createMatchMedia = (matches: boolean) =>
vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.mock("./orchestrator/useOrchestratorData", () => ({
useOrchestratorData: () => ({
jobs: [jobFixture, job2],
stats: {
discovered: 1,
processing: 0,
ready: 1,
applied: 0,
skipped: 0,
expired: 0,
},
isLoading: false,
isPipelineRunning: false,
setIsPipelineRunning: vi.fn(),
loadJobs: vi.fn(),
}),
}));
vi.mock("./orchestrator/usePipelineSources", () => ({
usePipelineSources: () => ({
pipelineSources: ["linkedin"],
setPipelineSources: vi.fn(),
toggleSource: vi.fn(),
}),
}));
vi.mock("./orchestrator/OrchestratorHeader", () => ({
OrchestratorHeader: () => <div data-testid="header" />,
}));
vi.mock("./orchestrator/OrchestratorSummary", () => ({
OrchestratorSummary: () => <div data-testid="summary" />,
}));
vi.mock("./orchestrator/OrchestratorFilters", () => ({
OrchestratorFilters: ({
onTabChange,
onSearchQueryChange,
onSortChange,
}: {
onTabChange: (t: FilterTab) => void;
onSearchQueryChange: (q: string) => void;
onSortChange: (s: any) => void;
}) => (
<div data-testid="filters">
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button>
</div>
),
}));
vi.mock("./orchestrator/JobDetailPanel", () => ({
JobDetailPanel: () => <div data-testid="detail-panel" />,
}));
vi.mock("./orchestrator/JobListPanel", () => ({
JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => (
<div>
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
<button data-testid="select-job-1" type="button" onClick={() => onSelectJob("job-1")}>
Select job 1
</button>
<button data-testid="select-job-2" type="button" onClick={() => onSelectJob("job-2")}>
Select job 2
</button>
</div>
),
}));
vi.mock("../components", () => ({
ManualImportSheet: () => <div data-testid="manual-import" />,
}));
const LocationWatcher = () => {
const location = useLocation();
return <div data-testid="location">{location.pathname + location.search}</div>;
};
describe("OrchestratorPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("syncs tab selection to the URL", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getByText("To Discovered"));
expect(screen.getByTestId("location").textContent).toContain("/discovered");
});
it("syncs job selection to the URL", async () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/all"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
// Initial load will auto-select the first matching job (job-1 for all tab)
const locationText = () => screen.getByTestId("location").textContent;
expect(locationText()).toContain("/all/job-1");
// Clicking job-2 should update URL
const job2Button = screen.getByTestId("select-job-2");
fireEvent.click(job2Button);
// Wait for URL to update
await waitFor(() => {
expect(locationText()).toContain("/all/job-2");
});
});
it("syncs search query to URL as a parameter", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getByText("Set Search"));
expect(screen.getByTestId("location").textContent).toContain("q=test+search");
});
it("syncs sorting to URL and removes it when default", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getByText("Set Sort"));
expect(screen.getByTestId("location").textContent).toContain("sort=title-asc");
});
it("opens the detail drawer on mobile when a job is selected", () => {
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("select-job-1"));
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
it("renders the detail panel inline on desktop", () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { SettingsPage } from "./SettingsPage"
import * as api from "../api"
import { toast } from "sonner"
import type { AppSettings } from "@shared/types"
vi.mock("../api", () => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(),
}))
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
const baseSettings: AppSettings = {
model: "openai/gpt-4o-mini",
defaultModel: "openai/gpt-4o-mini",
overrideModel: null,
modelScorer: "openai/gpt-4o-mini",
overrideModelScorer: null,
modelTailoring: "openai/gpt-4o-mini",
overrideModelTailoring: null,
modelProjectSelection: "openai/gpt-4o-mini",
overrideModelProjectSelection: null,
pipelineWebhookUrl: "",
defaultPipelineWebhookUrl: "",
overridePipelineWebhookUrl: null,
jobCompleteWebhookUrl: "",
defaultJobCompleteWebhookUrl: "",
overrideJobCompleteWebhookUrl: null,
profileProjects: [
{
id: "proj-1",
name: "Project One",
description: "Desc 1",
date: "2024",
isVisibleInBase: true,
},
{
id: "proj-2",
name: "Project Two",
description: "Desc 2",
date: "2023",
isVisibleInBase: false,
},
],
resumeProjects: {
maxProjects: 2,
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1", "proj-2"],
},
defaultResumeProjects: {
maxProjects: 2,
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1", "proj-2"],
},
overrideResumeProjects: null,
ukvisajobsMaxJobs: 50,
defaultUkvisajobsMaxJobs: 50,
overrideUkvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: 50,
defaultGradcrackerMaxJobsPerTerm: 50,
overrideGradcrackerMaxJobsPerTerm: null,
searchTerms: ["engineer"],
defaultSearchTerms: ["engineer"],
overrideSearchTerms: null,
jobspyLocation: "UK",
defaultJobspyLocation: "UK",
overrideJobspyLocation: null,
jobspyResultsWanted: 200,
defaultJobspyResultsWanted: 200,
overrideJobspyResultsWanted: null,
jobspyHoursOld: 72,
defaultJobspyHoursOld: 72,
overrideJobspyHoursOld: null,
jobspyCountryIndeed: "UK",
defaultJobspyCountryIndeed: "UK",
overrideJobspyCountryIndeed: null,
jobspySites: ["indeed", "linkedin"],
defaultJobspySites: ["indeed", "linkedin"],
overrideJobspySites: null,
jobspyLinkedinFetchDescription: true,
defaultJobspyLinkedinFetchDescription: true,
overrideJobspyLinkedinFetchDescription: null,
}
const renderPage = () => {
return render(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsPage />
</MemoryRouter>
)
}
describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("saves trimmed model overrides", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
overrideModel: "gpt-4",
model: "gpt-4",
})
renderPage()
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
const modelInput = within(modelField).getByRole("textbox")
fireEvent.change(modelInput, { target: { value: " gpt-4 " } })
const saveButton = screen.getByRole("button", { name: /^save$/i })
await waitFor(() => expect(saveButton).toBeEnabled())
fireEvent.click(saveButton)
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
model: "gpt-4",
})
)
expect(toast.success).toHaveBeenCalledWith("Settings saved")
})
it("clears jobs by status and summarizes results", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
renderPage()
const dangerTrigger = await screen.findByRole("button", { name: /danger zone/i })
fireEvent.click(dangerTrigger)
const clearSelectedButton = await screen.findByRole("button", { name: /clear selected/i })
fireEvent.click(clearSelectedButton)
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
fireEvent.click(confirmButton)
await waitFor(() => expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered"))
expect(toast.success).toHaveBeenCalledWith(
"Jobs cleared",
expect.objectContaining({
description: "Deleted 2 jobs: 2 discovered",
})
)
})
})

View File

@ -3,70 +3,25 @@
*/
import React, { useEffect, useMemo, useState } from "react"
import { AlertTriangle, Settings, Trash2 } from "lucide-react"
import { Settings } from "lucide-react"
import { toast } from "sonner"
import { PageHeader } from "../components/layout"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
import * as api from "../api"
/** All available job statuses for clearing */
const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
/** Status descriptions for UI */
const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: 'Crawled but not processed',
processing: 'Currently generating resume',
ready: 'PDF generated, waiting for user to apply',
applied: 'User marked as applied',
skipped: 'User skipped this job',
expired: 'Deadline passed',
}
function arraysEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
return (
a.maxProjects === b.maxProjects &&
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
)
}
function clampInt(value: number, min: number, max: number) {
const int = Math.floor(value)
if (Number.isNaN(int)) return min
return Math.min(max, Math.max(min, int))
}
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
@ -332,7 +287,7 @@ export const SettingsPage: React.FC = () => {
setIsSaving(true)
let totalDeleted = 0
const results: string[] = []
for (const status of statusesToClear) {
const result = await api.deleteJobsByStatus(status)
totalDeleted += result.count
@ -340,14 +295,14 @@ export const SettingsPage: React.FC = () => {
results.push(`${result.count} ${status}`)
}
}
if (totalDeleted > 0) {
toast.success("Jobs cleared", {
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`
toast.success("Jobs cleared", {
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`,
})
} else {
toast.info("No jobs found", {
description: `No jobs with selected statuses found`
toast.info("No jobs found", {
description: `No jobs with selected statuses found`,
})
}
} catch (error) {
@ -359,8 +314,8 @@ export const SettingsPage: React.FC = () => {
}
const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear(prev =>
prev.includes(status)
setStatusesToClear(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
)
@ -422,707 +377,119 @@ export const SettingsPage: React.FC = () => {
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion type="multiple" className="w-full space-y-4">
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Model</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<ModelSettingsSection
modelDraft={modelDraft}
setModelDraft={setModelDraft}
modelScorerDraft={modelScorerDraft}
setModelScorerDraft={setModelScorerDraft}
modelTailoringDraft={modelTailoringDraft}
setModelTailoringDraft={setModelTailoringDraft}
modelProjectSelectionDraft={modelProjectSelectionDraft}
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
effectiveModel={effectiveModel}
effectiveModelScorer={effectiveModelScorer}
effectiveModelTailoring={effectiveModelTailoring}
effectiveModelProjectSelection={effectiveModelProjectSelection}
defaultModel={defaultModel}
isLoading={isLoading}
isSaving={isSaving}
/>
<PipelineWebhookSection
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobCompleteWebhookSection
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<UkvisajobsSection
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
isLoading={isLoading}
isSaving={isSaving}
/>
<GradcrackerSection
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
isLoading={isLoading}
isSaving={isSaving}
/>
<SearchTermsSection
searchTermsDraft={searchTermsDraft}
setSearchTermsDraft={setSearchTermsDraft}
defaultSearchTerms={defaultSearchTerms}
effectiveSearchTerms={effectiveSearchTerms}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={defaultJobspySites}
effectiveJobspySites={effectiveJobspySites}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation={defaultJobspyLocation}
effectiveJobspyLocation={effectiveJobspyLocation}
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={defaultJobspyHoursOld}
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
isLoading={isLoading}
isSaving={isSaving}
/>
<ResumeProjectsSection
resumeProjectsDraft={resumeProjectsDraft}
setResumeProjectsDraft={setResumeProjectsDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={maxProjectsTotal}
isLoading={isLoading}
isSaving={isSaving}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}
handleClearByStatuses={handleClearByStatuses}
handleClearDatabase={handleClearDatabase}
isLoading={isLoading}
isSaving={isSaving}
/>
</Accordion>
<Separator />
<div className="space-y-4">
<div className="text-sm font-medium">Task-Specific Overrides</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
</div>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Pipeline Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job Complete Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Gradcracker Extractor</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Search Terms</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full 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={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">JobSpy Scraper</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-3">
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
placeholder={defaultJobspyLocation || "UK"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLocation || "—"}</span>
<span>Default: {defaultJobspyLocation || "—"}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
<span>Default: {defaultJobspyResultsWanted}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
<span>Default: {defaultJobspyHoursOld}h</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
placeholder={defaultJobspyCountryIndeed || "UK"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
</div>
</div>
</div>
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="linkedin-desc"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Fetch LinkedIn Description
</label>
<p className="text-xs text-muted-foreground">
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Resume Projects</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
/>
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4 pt-2">
<div className="p-3 rounded-md space-y-4">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
<div className="text-xs text-muted-foreground">
Select which job statuses you want to clear.
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_JOB_STATUSES.map((status) => {
const isSelected = statusesToClear.includes(status)
return (
<button
key={status}
type="button"
onClick={() => toggleStatusToClear(status)}
disabled={isLoading || isSaving}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
}`}
>
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
isSelected ? 'border-destructive' : 'border-muted-foreground'
}`}>
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
</div>
<div className="grid gap-0.5">
<span className="text-sm font-medium capitalize">{status}</span>
<span className="text-xs text-muted-foreground">
{STATUS_DESCRIPTIONS[status]}
</span>
</div>
</button>
)
})}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={isLoading || isSaving || statusesToClear.length === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Selected ({statusesToClear.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Separator />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
<div className="text-xs text-muted-foreground">
Delete all jobs and pipeline runs from the database.
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
<Trash2 className="mr-2 h-4 w-4" />
Clear Database
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<AlertDialogDescription>
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
</main>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
</main>
</>
)
}

View File

@ -37,13 +37,10 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { formatDate, formatDateTime } from "../lib/dateUtils";
import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils";
import * as api from "../api";
import type { CreateJobInput } from "../../shared/types";
const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value);
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
@ -347,7 +344,7 @@ export const UkVisaJobsPage: React.FC = () => {
}}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
)}

View File

@ -24,7 +24,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { cn, formatDateTime } from "@/lib/utils";
import {
PageHeader,
StatusIndicator,
@ -43,26 +43,6 @@ import type {
VisaSponsorStatusResponse,
} from "../../shared/types";
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return "Never";
try {
const parsed = new Date(dateStr);
if (Number.isNaN(parsed.getTime())) return dateStr;
const date = parsed.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
const time = parsed.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
});
return `${date} ${time}`;
} catch {
return dateStr;
}
};
const getScoreTokens = (score: number) => {
if (score >= 90)
return { badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" };
@ -329,7 +309,7 @@ export const VisaSponsorsPage: React.FC = () => {
</span>
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{formatDateTime(status.lastUpdated)}
{formatDateTime(status.lastUpdated) || "Never"}
</span>
</div>
)}

View File

@ -0,0 +1,278 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { JobDetailPanel } from "./JobDetailPanel";
import type { Job } from "../../../shared/types";
import * as api from "../../api";
vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
...props
}: {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
};
});
vi.mock("../../components", () => ({
DiscoveredPanel: ({ job }: { job: Job | null }) => (
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
),
}));
vi.mock("../../components/ReadyPanel", () => ({
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
<div>
<div data-testid="ready-panel" />
<button type="button" onClick={() => onEditDescription?.()}>
Edit description
</button>
</div>
),
}));
vi.mock("../../components/TailoringEditor", () => ({
TailoringEditor: () => <div data-testid="tailoring-editor" />,
}));
vi.mock("@/lib/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/utils")>();
return {
...actual,
copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
formatJobForWebhook: vi.fn(() => "payload"),
};
});
vi.mock("../../api", () => ({
updateJob: vi.fn(),
processJob: vi.fn(),
generateJobPdf: vi.fn(),
markAsApplied: vi.fn(),
skipJob: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
message: vi.fn(),
},
}));
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: "https://example.com/apply",
disciplines: null,
deadline: "2025-02-01",
salary: "GBP 50k",
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
suitabilityScore: 82,
suitabilityReason: "Strong fit",
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
});
describe("JobDetailPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the discovered panel when active tab is discovered", () => {
const job = createJob({ id: "job-99", status: "discovered" });
render(
<JobDetailPanel
activeTab="discovered"
activeJobs={[job]}
selectedJob={job}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()}
/>
);
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
});
it("wires ready panel edit actions back to the page", () => {
const onSetActiveTab = vi.fn();
render(
<JobDetailPanel
activeTab="ready"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={onSetActiveTab}
/>
);
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
});
it("shows an empty state when no job is selected", () => {
render(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={null}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()}
/>
);
expect(screen.getByText("No job selected")).toBeInTheDocument();
});
it("renders a stripped description preview for html content", () => {
render(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ jobDescription: "<p>Hello <strong>world</strong></p>" })}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={vi.fn()}
/>
);
expect(screen.getByText("Hello world")).toBeInTheDocument();
});
it("saves an edited description", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
render(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ jobDescription: "Original" })}
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()}
/>
);
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
fireEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
fireEvent.change(screen.getByPlaceholderText("Enter job description..."), {
target: { value: "Updated description" },
});
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" })
);
expect(onJobUpdated).toHaveBeenCalled();
});
it("marks a job as applied from the action button", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
render(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /applied/i }));
await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1"));
expect(onJobUpdated).toHaveBeenCalled();
});
it("skips a job from the menu", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.skipJob).mockResolvedValue(undefined as any);
render(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
onSetActiveTab={vi.fn()}
/>
);
fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i }));
const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
fireEvent.click(skipItem);
await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1"));
expect(onJobUpdated).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,568 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CheckCircle2,
Copy,
Edit2,
ExternalLink,
FileText,
Loader2,
MoreHorizontal,
RefreshCcw,
Save,
XCircle,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } from "@/lib/utils";
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
import { ReadyPanel } from "../../components/ReadyPanel";
import { TailoringEditor } from "../../components/TailoringEditor";
import * as api from "../../api";
import type { Job } from "../../../shared/types";
import type { FilterTab } from "./constants";
interface JobDetailPanelProps {
activeTab: FilterTab;
activeJobs: Job[];
selectedJob: Job | null;
onSelectJobId: (jobId: string | null) => void;
onJobUpdated: () => Promise<void>;
onSetActiveTab: (tab: FilterTab) => void;
}
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
activeTab,
activeJobs,
selectedJob,
onSelectJobId,
onJobUpdated,
onSetActiveTab,
}) => {
const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview");
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [editedDescription, setEditedDescription] = useState("");
const [isSavingDescription, setIsSavingDescription] = useState(false);
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
useEffect(() => {
setHasUnsavedTailoring(false);
saveTailoringRef.current = null;
}, [selectedJob?.id]);
const description = useMemo(() => {
if (!selectedJob?.jobDescription) return "No description available.";
const jd = selectedJob.jobDescription;
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
return jd;
}, [selectedJob]);
useEffect(() => {
if (!selectedJob) {
setIsEditingDescription(false);
setEditedDescription("");
return;
}
setIsEditingDescription(false);
setEditedDescription(selectedJob.jobDescription || "");
}, [selectedJob?.id]);
useEffect(() => {
if (!selectedJob) return;
if (!isEditingDescription) {
setEditedDescription(selectedJob.jobDescription || "");
}
}, [selectedJob?.jobDescription, isEditingDescription, selectedJob]);
const handleSaveDescription = async () => {
if (!selectedJob) return;
try {
setIsSavingDescription(true);
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
toast.success("Job description updated");
setIsEditingDescription(false);
await onJobUpdated();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update description";
toast.error(message);
} finally {
setIsSavingDescription(false);
}
};
const hasUnsavedDescription =
!!selectedJob &&
isEditingDescription &&
editedDescription !== (selectedJob.jobDescription || "");
const confirmAndSaveEdits = useCallback(
async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => {
const pendingDescription = hasUnsavedDescription;
const pendingTailoring = includeTailoring && hasUnsavedTailoring;
if (!pendingDescription && !pendingTailoring) return true;
const parts = [];
if (pendingDescription) parts.push("job description");
if (pendingTailoring) parts.push("tailoring changes");
const message = `You have unsaved ${parts.join(" and ")}. Save before generating the PDF?`;
if (!window.confirm(message)) return false;
try {
if (pendingDescription && selectedJob) {
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
}
if (pendingTailoring) {
const saveTailoring = saveTailoringRef.current;
if (!saveTailoring) {
toast.error("Could not save tailoring changes");
return false;
}
await saveTailoring();
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to save changes";
toast.error(errorMessage);
return false;
}
return true;
},
[editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob],
);
const handleProcess = async () => {
if (!selectedJob) return;
try {
const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true });
if (!shouldProceed) return;
setProcessingJobId(selectedJob.id);
if (selectedJob.status === "ready") {
await api.generateJobPdf(selectedJob.id);
toast.success("Resume regenerated successfully");
} else {
await api.processJob(selectedJob.id);
toast.success("Resume generated successfully");
}
await onJobUpdated();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to process job";
toast.error(message);
} finally {
setProcessingJobId(null);
}
};
const handleApply = async () => {
if (!selectedJob) return;
try {
await api.markAsApplied(selectedJob.id);
toast.success("Marked as applied");
await onJobUpdated();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message);
}
};
const handleSkip = async () => {
if (!selectedJob) return;
try {
await api.skipJob(selectedJob.id);
toast.message("Job skipped");
await onJobUpdated();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to skip job";
toast.error(message);
}
};
const handleCopyInfo = async () => {
if (!selectedJob) return;
try {
await copyTextToClipboard(formatJobForWebhook(selectedJob));
toast.success("Copied job info", { description: "Webhook payload copied to clipboard." });
} catch {
toast.error("Could not copy job info");
}
};
const handleJobMoved = useCallback(
(jobId: string) => {
const currentIndex = activeJobs.findIndex((job) => job.id === jobId);
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
onSelectJobId(nextJob?.id ?? null);
},
[activeJobs, onSelectJobId],
);
const selectedHasPdf = !!selectedJob?.pdfPath;
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
const selectedPdfHref = selectedJob
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
: "#";
const canApply = selectedJob?.status === "ready";
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
const showReadyPdf = activeTab === "ready";
const showGeneratePdf = activeTab === "discovered";
const isProcessingSelected =
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
if (activeTab === "discovered") {
return (
<DiscoveredPanel
job={selectedJob}
onJobUpdated={onJobUpdated}
onJobMoved={handleJobMoved}
/>
);
}
if (activeTab === "ready") {
return (
<ReadyPanel
job={selectedJob}
onJobUpdated={onJobUpdated}
onJobMoved={handleJobMoved}
onEditTailoring={() => {
onSetActiveTab("discovered");
setTimeout(() => setDetailTab("tailoring"), 50);
}}
onEditDescription={() => {
onSetActiveTab("discovered");
setTimeout(() => {
setDetailTab("description");
setIsEditingDescription(true);
}, 50);
}}
/>
);
}
if (!selectedJob) {
return (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
</div>
);
}
return (
<div className="space-y-3">
<JobHeader job={selectedJob} />
<div className="flex flex-wrap items-center gap-1.5">
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
View
</a>
</Button>
{showReadyPdf &&
(selectedHasPdf ? (
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
<FileText className="h-3.5 w-3.5" />
PDF
</a>
</Button>
) : (
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled>
<FileText className="h-3.5 w-3.5" />
PDF
</Button>
))}
{showGeneratePdf && (
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5 text-xs"
onClick={handleProcess}
disabled={!canProcess || isProcessingSelected}
>
{isProcessingSelected ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCcw className="h-3.5 w-3.5" />
)}
{isProcessingSelected ? "Generating..." : "Generate"}
</Button>
)}
{canApply && (
<Button
size="sm"
className="h-8 gap-1.5 text-xs bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 border border-emerald-500/30"
onClick={handleApply}
>
<CheckCircle2 className="h-3.5 w-3.5" />
Applied
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" aria-label="More actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canProcess && !showGeneratePdf && (
<DropdownMenuItem
onSelect={() => void handleProcess()}
disabled={isProcessingSelected}
>
<RefreshCcw className="mr-2 h-4 w-4" />
{isProcessingSelected
? "Processing..."
: selectedJob.status === "ready"
? "Regenerate PDF"
: "Generate PDF"}
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
setDetailTab("description");
setIsEditingDescription(true);
}}
>
<Edit2 className="mr-2 h-4 w-4" />
Edit description
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleCopyInfo()}>
<Copy className="mr-2 h-4 w-4" />
Copy info
</DropdownMenuItem>
{selectedHasPdf && (
<>
{!showReadyPdf && (
<DropdownMenuItem asChild>
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View PDF
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<a
href={selectedPdfHref}
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
>
<FileText className="mr-2 h-4 w-4" />
Download PDF
</a>
</DropdownMenuItem>
</>
)}
{canSkip && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => void handleSkip()}
className="text-destructive focus:text-destructive"
>
<XCircle className="mr-2 h-4 w-4" />
Skip job
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-3 pt-2">
<FitAssessment job={selectedJob} />
<TailoredSummary job={selectedJob} />
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
</div>
</div>
<div className="space-y-1.5">
<button
type="button"
className="w-full text-left rounded border border-border/30 bg-muted/5 px-2.5 py-2 text-[11px] text-muted-foreground/80 line-clamp-4 whitespace-pre-wrap leading-relaxed hover:bg-muted/10 transition-colors"
onClick={() => setDetailTab("description")}
>
{description}
</button>
<div className="text-center">
<button
type="button"
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
onClick={() => setDetailTab("description")}
>
View full description 
</button>
</div>
</div>
</TabsContent>
<TabsContent value="tailoring" className="pt-3">
<TailoringEditor
job={selectedJob}
onUpdate={onJobUpdated}
onDirtyChange={setHasUnsavedTailoring}
onRegisterSave={(save) => {
saveTailoringRef.current = save;
}}
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
/>
</TabsContent>
<TabsContent value="description" className="space-y-3 pt-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Job description
</div>
<div className="flex items-center gap-1">
{!isEditingDescription ? (
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditingDescription(true)}
className="h-8 px-2 text-xs"
>
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsEditingDescription(false);
setEditedDescription(selectedJob.jobDescription || "");
}}
className="h-8 px-2 text-xs text-muted-foreground"
disabled={isSavingDescription}
>
Cancel
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleSaveDescription}
className="h-8 px-3 text-xs"
disabled={isSavingDescription}
>
{isSavingDescription ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Changes
</Button>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
void copyTextToClipboard(selectedJob.jobDescription || "");
toast.success("Copied raw description");
}}
>
<Copy className="mr-2 h-4 w-4" />
Copy raw text
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground">
{isEditingDescription ? (
<div className="space-y-3">
<Textarea
value={editedDescription}
onChange={(event) => setEditedDescription(event.target.value)}
className="min-h-[400px] font-mono text-sm leading-relaxed focus-visible:ring-1"
placeholder="Enter job description..."
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditingDescription(false);
setEditedDescription(selectedJob.jobDescription || "");
}}
disabled={isSavingDescription}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveDescription}
disabled={isSavingDescription}
>
{isSavingDescription ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Save Description
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
};

View File

@ -0,0 +1,140 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { JobListPanel } from "./JobListPanel";
import type { Job } from "../../../shared/types";
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
suitabilityScore: 72,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
});
describe("JobListPanel", () => {
it("shows a loading state when fetching jobs", () => {
render(
<JobListPanel
isLoading
jobs={[]}
activeJobs={[]}
selectedJobId={null}
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
/>
);
expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
});
it("shows the tab empty state copy when no jobs exist", () => {
render(
<JobListPanel
isLoading={false}
jobs={[]}
activeJobs={[]}
selectedJobId={null}
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
/>
);
expect(screen.getByText("No jobs found")).toBeInTheDocument();
expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument();
});
it("shows the query-specific empty state when searching", () => {
render(
<JobListPanel
isLoading={false}
jobs={[]}
activeJobs={[]}
selectedJobId={null}
activeTab="ready"
searchQuery="iOS"
onSelectJob={vi.fn()}
/>
);
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
});
it("renders jobs and notifies when a job is selected", () => {
const onSelectJob = vi.fn();
const jobs = [
createJob({ id: "job-1", title: "Backend Engineer" }),
createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }),
];
render(
<JobListPanel
isLoading={false}
jobs={jobs}
activeJobs={jobs}
selectedJobId="job-1"
activeTab="ready"
searchQuery=""
onSelectJob={onSelectJob}
/>
);
expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
expect(onSelectJob).toHaveBeenCalledWith("job-2");
});
});

View File

@ -0,0 +1,111 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Job } from "../../../shared/types";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
import type { FilterTab } from "./constants";
interface JobListPanelProps {
isLoading: boolean;
jobs: Job[];
activeJobs: Job[];
selectedJobId: string | null;
activeTab: FilterTab;
searchQuery: string;
onSelectJob: (jobId: string) => void;
}
export const JobListPanel: React.FC<JobListPanelProps> = ({
isLoading,
jobs,
activeJobs,
selectedJobId,
activeTab,
searchQuery,
onSelectJob,
}) => (
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
{isLoading && jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="text-sm text-muted-foreground">Loading jobs...</div>
</div>
) : activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div>
<p className="max-w-md text-sm text-muted-foreground">
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
</p>
</div>
) : (
<div className="divide-y divide-border/40">
{activeJobs.map((job) => {
const isSelected = job.id === selectedJobId;
const hasScore = job.suitabilityScore != null;
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
return (
<button
key={job.id}
type="button"
onClick={() => onSelectJob(job.id)}
data-testid={`select-${job.id}`}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
isSelected
? "bg-primary/5 border-l-2 border-l-primary"
: "hover:bg-muted/20 border-l-2 border-l-transparent",
)}
aria-pressed={isSelected}
>
{/* Single status indicator: subtle dot */}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
statusToken.dot,
!isSelected && "opacity-70",
)}
title={statusToken.label}
/>
{/* Primary content: title strongest, company secondary */}
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm leading-tight",
isSelected ? "font-semibold" : "font-medium",
)}
>
{job.title}
</div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && <span className="before:content-['_in_']">{job.location}</span>}
</div>
</div>
{/* Single triage cue: score only (status shown via dot) */}
{hasScore && (
<div className="shrink-0 text-right">
<span
className={cn(
"text-xs tabular-nums",
job.suitabilityScore! >= 70
? "text-emerald-400/90"
: job.suitabilityScore! >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)}
>
{job.suitabilityScore}
</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
);

View File

@ -0,0 +1,107 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { OrchestratorFilters } from "./OrchestratorFilters";
import type { FilterTab, JobSort } from "./constants";
vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react");
const RadioGroupContext = React.createContext<((value: string) => void) | null>(null);
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
...props
}: {
children: React.ReactNode;
onSelect?: () => void;
}) => (
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuRadioGroup: ({
children,
onValueChange,
}: {
children: React.ReactNode;
onValueChange?: (value: string) => void;
}) => (
<RadioGroupContext.Provider value={onValueChange ?? null}>
<div role="radiogroup">{children}</div>
</RadioGroupContext.Provider>
),
DropdownMenuRadioItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => {
const onValueChange = React.useContext(RadioGroupContext);
return (
<button type="button" role="menuitemradio" onClick={() => onValueChange?.(value)}>
{children}
</button>
);
},
};
});
const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>) => {
const props = {
activeTab: "ready" as FilterTab,
onTabChange: vi.fn(),
counts: {
ready: 2,
discovered: 1,
applied: 3,
all: 6,
},
searchQuery: "",
onSearchQueryChange: vi.fn(),
sourceFilter: "all" as const,
onSourceFilterChange: vi.fn(),
sort: { key: "score", direction: "desc" } as JobSort,
onSortChange: vi.fn(),
...overrides,
};
return {
props,
...render(<OrchestratorFilters {...props} />),
};
};
describe("OrchestratorFilters", () => {
it("notifies when tabs and search are updated", () => {
const { props } = renderFilters();
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
expect(props.onTabChange).toHaveBeenCalledWith("applied");
fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } });
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
});
it("updates source and sort selections", async () => {
const { props } = renderFilters();
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i }));
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
});
});

View File

@ -0,0 +1,142 @@
import React from "react";
import { ArrowUpDown, Filter, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types";
import { defaultSortDirection, sortLabels, tabs } from "./constants";
import type { FilterTab, JobSort } from "./constants";
interface OrchestratorFiltersProps {
activeTab: FilterTab;
onTabChange: (value: FilterTab) => void;
counts: Record<FilterTab, number>;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
sourceFilter: JobSource | "all";
onSourceFilterChange: (value: JobSource | "all") => void;
sort: JobSort;
onSortChange: (sort: JobSort) => void;
}
export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
activeTab,
onTabChange,
counts,
searchQuery,
onSearchQueryChange,
sourceFilter,
onSourceFilterChange,
sort,
onSortChange,
}) => (
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
<span>{tab.label}</span>
{counts[tab.id] > 0 && (
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
)}
</TabsTrigger>
))}
</TabsList>
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="Search..."
className="h-8 pl-8 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
>
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sourceLabel[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sort.key}
onValueChange={(value) =>
onSortChange({
key: value as JobSort["key"],
direction: defaultSortDirection[value as JobSort["key"]],
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
onSortChange({
...sort,
direction: sort.direction === "asc" ? "desc" : "asc",
})
}
>
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Tabs>
);

View File

@ -0,0 +1,207 @@
import React from "react";
import {
Briefcase,
ChevronDown,
FileText,
Home,
Loader2,
Menu,
Play,
Settings,
Shield,
Sparkles,
} from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types";
import { orderedSources } from "./constants";
interface OrchestratorHeaderProps {
navOpen: boolean;
onNavOpenChange: (open: boolean) => void;
isPipelineRunning: boolean;
pipelineSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void;
onSetPipelineSources: (sources: JobSource[]) => void;
onRunPipeline: () => void;
onOpenManualImport: () => void;
}
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
navOpen,
onNavOpenChange,
isPipelineRunning,
pipelineSources,
onToggleSource,
onSetPipelineSources,
onRunPipeline,
onOpenManualImport,
}) => {
const location = useLocation();
const navigate = useNavigate();
return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<Sheet open={navOpen} onOpenChange={onNavOpenChange}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => (
<button
key={to}
type="button"
onClick={() => {
if (location.pathname === to) {
onNavOpenChange(false);
return;
}
onNavOpenChange(false);
setTimeout(() => navigate(to), 150);
}}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
</div>
</div>
{isPipelineRunning && (
<span className="hidden sm:inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
Pipeline running
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onOpenManualImport}
className="gap-2"
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">Manual import</span>
</Button>
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={onRunPipeline}
disabled={isPipelineRunning}
className="gap-2"
>
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
<span className="hidden sm:inline">{isPipelineRunning ? "Running" : "Run pipeline"}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
disabled={isPipelineRunning}
aria-label="Select pipeline sources"
className="shrink-0"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) => onToggleSource(source, Boolean(checked))}
onSelect={(event) => event.preventDefault()}
>
{sourceLabel[source]}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSetPipelineSources(orderedSources);
}}
>
All sources
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSetPipelineSources(["gradcracker"]);
}}
>
Gradcracker only
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSetPipelineSources(["indeed", "linkedin"]);
}}
>
Indeed + LinkedIn only
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
);
};

View File

@ -0,0 +1,47 @@
import React from "react";
import { PipelineProgress } from "../../components";
import type { JobStatus } from "../../../shared/types";
interface OrchestratorSummaryProps {
stats: Record<JobStatus, number>;
isPipelineRunning: boolean;
}
export const OrchestratorSummary: React.FC<OrchestratorSummaryProps> = ({
stats,
isPipelineRunning,
}) => {
const totalJobs = Object.values(stats).reduce((a, b) => a + b, 0);
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
</div>
{isPipelineRunning && (
<div className="max-w-3xl">
<PipelineProgress isRunning={isPipelineRunning} />
</div>
)}
{/* Compact metrics summary - demoted visual weight */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
<span className="text-border"></span>
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
{(stats.skipped > 0 || stats.expired > 0) && (
<>
<span className="text-border"></span>
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
</>
)}
</div>
</section>
);
};

View File

@ -0,0 +1,85 @@
import type { JobSource, JobStatus } from "../../../shared/types";
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = {
discovered: {
label: "Discovered",
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
dot: "bg-sky-400",
},
processing: {
label: "Processing",
badge: "border-amber-500/30 bg-amber-500/10 text-amber-200",
dot: "bg-amber-400",
},
ready: {
label: "Ready",
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
dot: "bg-emerald-400",
},
applied: {
label: "Applied",
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
dot: "bg-emerald-400",
},
skipped: {
label: "Skipped",
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
dot: "bg-rose-400",
},
expired: {
label: "Expired",
badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
dot: "bg-muted-foreground",
},
};
export const defaultStatusToken = {
label: "Unknown",
badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
dot: "bg-muted-foreground",
};
export type FilterTab = "ready" | "discovered" | "applied" | "all";
export type SortKey = "discoveredAt" | "score" | "title" | "employer";
export type SortDirection = "asc" | "desc";
export interface JobSort {
key: SortKey;
direction: SortDirection;
}
export const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" };
export const sortLabels: Record<JobSort["key"], string> = {
discoveredAt: "Discovered",
score: "Score",
title: "Title",
employer: "Company",
};
export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
discoveredAt: "desc",
score: "desc",
title: "asc",
employer: "asc",
};
export const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
{ id: "ready", label: "Ready", statuses: ["ready"] },
{ id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] },
{ id: "applied", label: "Applied", statuses: ["applied"] },
{ id: "all", label: "All Jobs", statuses: [] },
];
export const emptyStateCopy: Record<FilterTab, string> = {
ready: "Run the pipeline to discover and process new jobs.",
discovered: "All discovered jobs have been processed.",
applied: "You have not applied to any jobs yet.",
all: "No jobs in the system yet. Run the pipeline to get started.",
};

View File

@ -0,0 +1,34 @@
import { useMemo } from "react";
import type { Job, JobSource } from "../../../shared/types";
import type { FilterTab, JobSort } from "./constants";
import { compareJobs, jobMatchesQuery } from "./utils";
export const useFilteredJobs = (
jobs: Job[],
activeTab: FilterTab,
sourceFilter: JobSource | "all",
searchQuery: string,
sort: JobSort,
) =>
useMemo(() => {
let filtered = jobs;
if (activeTab === "ready") {
filtered = filtered.filter((job) => job.status === "ready");
} else if (activeTab === "discovered") {
filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing");
} else if (activeTab === "applied") {
filtered = filtered.filter((job) => job.status === "applied");
}
if (sourceFilter !== "all") {
filtered = filtered.filter((job) => job.source === sourceFilter);
}
if (searchQuery.trim()) {
filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery));
}
return [...filtered].sort((a, b) => compareJobs(a, b, sort));
}, [jobs, activeTab, sourceFilter, searchQuery, sort]);

View File

@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import type { Job, JobStatus } from "../../../shared/types";
import * as api from "../../api";
const initialStats: Record<JobStatus, number> = {
discovered: 0,
processing: 0,
ready: 0,
applied: 0,
skipped: 0,
expired: 0,
};
export const useOrchestratorData = () => {
const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
const [isLoading, setIsLoading] = useState(true);
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
const loadJobs = useCallback(async () => {
try {
setIsLoading(true);
const data = await api.getJobs();
setJobs(data.jobs);
setStats(data.byStatus);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load jobs";
toast.error(message);
} finally {
setIsLoading(false);
}
}, []);
const checkPipelineStatus = useCallback(async () => {
try {
const status = await api.getPipelineStatus();
setIsPipelineRunning(status.isRunning);
} catch {
// Ignore errors
}
}, []);
useEffect(() => {
loadJobs();
checkPipelineStatus();
const interval = setInterval(() => {
loadJobs();
checkPipelineStatus();
}, 10000);
return () => clearInterval(interval);
}, [loadJobs, checkPipelineStatus]);
return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus };
};

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
import type { JobSource } from "../../../shared/types";
import {
DEFAULT_PIPELINE_SOURCES,
PIPELINE_SOURCES_STORAGE_KEY,
orderedSources,
} from "./constants";
export const usePipelineSources = () => {
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
if (!raw) return DEFAULT_PIPELINE_SOURCES;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES;
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource));
return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES;
} catch {
return DEFAULT_PIPELINE_SOURCES;
}
});
useEffect(() => {
try {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
} catch {
// Ignore localStorage errors
}
}, [pipelineSources]);
const toggleSource = useCallback((source: JobSource, checked: boolean) => {
setPipelineSources((current) => {
const next = checked
? Array.from(new Set([...current, source]))
: current.filter((value) => value !== source);
return next.length === 0 ? current : next;
});
}, []);
return { pipelineSources, setPipelineSources, toggleSource };
};

View File

@ -0,0 +1,89 @@
import type { Job } from "../../../shared/types";
import type { FilterTab, JobSort } from "./constants";
const dateValue = (value: string | null) => {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
};
const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" });
const compareNumber = (a: number, b: number) => a - b;
export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
let value = 0;
switch (sort.key) {
case "title":
value = compareString(a.title, b.title);
break;
case "employer":
value = compareString(a.employer, b.employer);
break;
case "score": {
const aScore = a.suitabilityScore;
const bScore = b.suitabilityScore;
if (aScore == null && bScore == null) {
value = 0;
break;
}
if (aScore == null) return 1;
if (bScore == null) return -1;
value = compareNumber(aScore, bScore);
break;
}
case "discoveredAt": {
const aDate = dateValue(a.discoveredAt);
const bDate = dateValue(b.discoveredAt);
if (aDate == null && bDate == null) {
value = 0;
break;
}
if (aDate == null) return 1;
if (bDate == null) return -1;
value = compareNumber(aDate, bDate);
break;
}
default:
value = 0;
}
if (value !== 0) return sort.direction === "asc" ? value : -value;
return a.id.localeCompare(b.id);
};
export const jobMatchesQuery = (job: Job, query: string) => {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
const haystack = [
job.title,
job.employer,
job.location,
job.source,
job.status,
job.jobType,
job.jobFunction,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(normalized);
};
export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
const byTab: Record<FilterTab, number> = {
ready: 0,
discovered: 0,
applied: 0,
all: jobs.length,
};
for (const job of jobs) {
if (job.status === "ready") byTab.ready += 1;
if (job.status === "applied") byTab.applied += 1;
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
}
return byTab;
};

View File

@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useState } from "react"
import { Accordion } from "@/components/ui/accordion"
import { DangerZoneSection } from "./DangerZoneSection"
import type { JobStatus } from "@shared/types"
const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { initialStatuses?: JobStatus[]; onClear?: () => void }) => {
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(initialStatuses)
const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear((prev) =>
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
)
}
return (
<Accordion type="multiple" defaultValue={["danger-zone"]}>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}
handleClearByStatuses={onClear ?? (() => {})}
handleClearDatabase={() => {}}
isLoading={false}
isSaving={false}
/>
</Accordion>
)
}
describe("DangerZoneSection", () => {
it("disables clear when no statuses are selected", () => {
render(<DangerZoneHarness initialStatuses={[]} />)
const clearButton = screen.getByRole("button", { name: /clear selected/i })
expect(clearButton).toBeDisabled()
})
it("toggles status selection and confirms clear", async () => {
const onClear = vi.fn()
render(<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />)
const appliedButton = screen.getByRole("button", { name: /applied/i })
const clearButton = screen.getByRole("button", { name: /clear selected/i })
expect(clearButton).toBeEnabled()
fireEvent.click(clearButton)
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
fireEvent.click(confirmButton)
expect(onClear).toHaveBeenCalledTimes(1)
fireEvent.click(appliedButton)
expect(clearButton).toBeDisabled()
})
})

View File

@ -0,0 +1,150 @@
import React from "react"
import { AlertTriangle, Trash2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
type DangerZoneSectionProps = {
statusesToClear: JobStatus[]
toggleStatusToClear: (status: JobStatus) => void
handleClearByStatuses: () => void
handleClearDatabase: () => void
isLoading: boolean
isSaving: boolean
}
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
statusesToClear,
toggleStatusToClear,
handleClearByStatuses,
handleClearDatabase,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4 pt-2">
<div className="p-3 rounded-md space-y-4">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
<div className="text-xs text-muted-foreground">
Select which job statuses you want to clear.
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_JOB_STATUSES.map((status) => {
const isSelected = statusesToClear.includes(status)
return (
<button
key={status}
type="button"
onClick={() => toggleStatusToClear(status)}
disabled={isLoading || isSaving}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
}`}
>
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
isSelected ? 'border-destructive' : 'border-muted-foreground'
}`}>
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
</div>
<div className="grid gap-0.5">
<span className="text-sm font-medium capitalize">{status}</span>
<span className="text-xs text-muted-foreground">
{STATUS_DESCRIPTIONS[status]}
</span>
</div>
</button>
)
})}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={isLoading || isSaving || statusesToClear.length === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Selected ({statusesToClear.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Separator />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
<div className="text-xs text-muted-foreground">
Delete all jobs and pipeline runs from the database.
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
<Trash2 className="mr-2 h-4 w-4" />
Clear Database
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<AlertDialogDescription>
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,70 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type GradcrackerSectionProps = {
gradcrackerMaxJobsPerTermDraft: number | null
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
defaultGradcrackerMaxJobsPerTerm: number
effectiveGradcrackerMaxJobsPerTerm: number
isLoading: boolean
isSaving: boolean
}
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
gradcrackerMaxJobsPerTermDraft,
setGradcrackerMaxJobsPerTermDraft,
defaultGradcrackerMaxJobsPerTerm,
effectiveGradcrackerMaxJobsPerTerm,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Gradcracker Extractor</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,60 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type JobCompleteWebhookSectionProps = {
jobCompleteWebhookUrlDraft: string
setJobCompleteWebhookUrlDraft: (value: string) => void
defaultJobCompleteWebhookUrl: string
effectiveJobCompleteWebhookUrl: string
isLoading: boolean
isSaving: boolean
}
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
jobCompleteWebhookUrlDraft,
setJobCompleteWebhookUrlDraft,
defaultJobCompleteWebhookUrl,
effectiveJobCompleteWebhookUrl,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job Complete Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useState } from "react"
import { Accordion } from "@/components/ui/accordion"
import { JobspySection } from "./JobspySection"
const JobspyHarness = () => {
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
return (
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={["indeed", "linkedin"]}
effectiveJobspySites={["indeed", "linkedin"]}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation="UK"
effectiveJobspyLocation="UK"
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={200}
effectiveJobspyResultsWanted={200}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={72}
effectiveJobspyHoursOld={72}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed="UK"
effectiveJobspyCountryIndeed="UK"
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={true}
effectiveJobspyLinkedinFetchDescription={true}
isLoading={false}
isSaving={false}
/>
</Accordion>
)
}
describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />)
const indeedCheckbox = screen.getByLabelText("Indeed")
const linkedinCheckbox = screen.getByLabelText("LinkedIn")
expect(indeedCheckbox).toBeChecked()
expect(linkedinCheckbox).toBeChecked()
fireEvent.click(indeedCheckbox)
expect(indeedCheckbox).not.toBeChecked()
expect(linkedinCheckbox).toBeChecked()
fireEvent.click(indeedCheckbox)
expect(indeedCheckbox).toBeChecked()
})
it("clamps numeric inputs to allowed ranges", () => {
render(<JobspyHarness />)
const numericInputs = screen.getAllByRole("spinbutton")
const resultsWantedInput = numericInputs[0]
const hoursOldInput = numericInputs[1]
fireEvent.change(resultsWantedInput, { target: { value: "999" } })
expect(resultsWantedInput).toHaveValue(500)
fireEvent.change(hoursOldInput, { target: { value: "0" } })
expect(hoursOldInput).toHaveValue(1)
})
})

View File

@ -0,0 +1,240 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type JobspySectionProps = {
jobspySitesDraft: string[] | null
setJobspySitesDraft: (value: string[] | null) => void
defaultJobspySites: string[]
effectiveJobspySites: string[]
jobspyLocationDraft: string | null
setJobspyLocationDraft: (value: string | null) => void
defaultJobspyLocation: string
effectiveJobspyLocation: string
jobspyResultsWantedDraft: number | null
setJobspyResultsWantedDraft: (value: number | null) => void
defaultJobspyResultsWanted: number
effectiveJobspyResultsWanted: number
jobspyHoursOldDraft: number | null
setJobspyHoursOldDraft: (value: number | null) => void
defaultJobspyHoursOld: number
effectiveJobspyHoursOld: number
jobspyCountryIndeedDraft: string | null
setJobspyCountryIndeedDraft: (value: string | null) => void
defaultJobspyCountryIndeed: string
effectiveJobspyCountryIndeed: string
jobspyLinkedinFetchDescriptionDraft: boolean | null
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
defaultJobspyLinkedinFetchDescription: boolean
effectiveJobspyLinkedinFetchDescription: boolean
isLoading: boolean
isSaving: boolean
}
export const JobspySection: React.FC<JobspySectionProps> = ({
jobspySitesDraft,
setJobspySitesDraft,
defaultJobspySites,
effectiveJobspySites,
jobspyLocationDraft,
setJobspyLocationDraft,
defaultJobspyLocation,
effectiveJobspyLocation,
jobspyResultsWantedDraft,
setJobspyResultsWantedDraft,
defaultJobspyResultsWanted,
effectiveJobspyResultsWanted,
jobspyHoursOldDraft,
setJobspyHoursOldDraft,
defaultJobspyHoursOld,
effectiveJobspyHoursOld,
jobspyCountryIndeedDraft,
setJobspyCountryIndeedDraft,
defaultJobspyCountryIndeed,
effectiveJobspyCountryIndeed,
jobspyLinkedinFetchDescriptionDraft,
setJobspyLinkedinFetchDescriptionDraft,
defaultJobspyLinkedinFetchDescription,
effectiveJobspyLinkedinFetchDescription,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">JobSpy Scraper</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-3">
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
placeholder={defaultJobspyLocation || "UK"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLocation || "—"}</span>
<span>Default: {defaultJobspyLocation || "—"}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
<span>Default: {defaultJobspyResultsWanted}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
<span>Default: {defaultJobspyHoursOld}h</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
placeholder={defaultJobspyCountryIndeed || "UK"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
</div>
</div>
</div>
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="linkedin-desc"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Fetch LinkedIn Description
</label>
<p className="text-xs text-muted-foreground">
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,125 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type ModelSettingsSectionProps = {
modelDraft: string
setModelDraft: (value: string) => void
modelScorerDraft: string
setModelScorerDraft: (value: string) => void
modelTailoringDraft: string
setModelTailoringDraft: (value: string) => void
modelProjectSelectionDraft: string
setModelProjectSelectionDraft: (value: string) => void
effectiveModel: string
effectiveModelScorer: string
effectiveModelTailoring: string
effectiveModelProjectSelection: string
defaultModel: string
isLoading: boolean
isSaving: boolean
}
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
modelDraft,
setModelDraft,
modelScorerDraft,
setModelScorerDraft,
modelTailoringDraft,
setModelTailoringDraft,
modelProjectSelectionDraft,
setModelProjectSelectionDraft,
effectiveModel,
effectiveModelScorer,
effectiveModelTailoring,
effectiveModelProjectSelection,
defaultModel,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Model</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="text-sm font-medium">Task-Specific Overrides</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
</div>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,60 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type PipelineWebhookSectionProps = {
pipelineWebhookUrlDraft: string
setPipelineWebhookUrlDraft: (value: string) => void
defaultPipelineWebhookUrl: string
effectivePipelineWebhookUrl: string
isLoading: boolean
isSaving: boolean
}
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
pipelineWebhookUrlDraft,
setPipelineWebhookUrlDraft,
defaultPipelineWebhookUrl,
effectivePipelineWebhookUrl,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Pipeline Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { useState } from "react"
import { Accordion } from "@/components/ui/accordion"
import { ResumeProjectsSection } from "./ResumeProjectsSection"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
const profileProjects: ResumeProjectCatalogItem[] = [
{
id: "proj-1",
name: "Project One",
description: "Desc 1",
date: "2024",
isVisibleInBase: true,
},
{
id: "proj-2",
name: "Project Two",
description: "Desc 2",
date: "2023",
isVisibleInBase: false,
},
]
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => {
const [draft, setDraft] = useState<ResumeProjectsSettings | null>(initialDraft)
const lockedCount = draft?.lockedProjectIds.length ?? 0
return (
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
resumeProjectsDraft={draft}
setResumeProjectsDraft={setDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
)
}
describe("ResumeProjectsSection", () => {
it("clamps max projects to the locked count", async () => {
render(
<ResumeProjectsHarness
initialDraft={{
maxProjects: 2,
lockedProjectIds: ["proj-1"],
aiSelectableProjectIds: ["proj-2"],
}}
/>
)
const input = screen.getByRole("spinbutton")
fireEvent.change(input, { target: { value: "0" } })
await waitFor(() => expect(input).toHaveValue(1))
})
it("locks projects and enforces maxProjects >= locked count", () => {
render(
<ResumeProjectsHarness
initialDraft={{
maxProjects: 0,
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1"],
}}
/>
)
const checkboxes = screen.getAllByRole("checkbox")
const lockedCheckbox = checkboxes[0]
const aiSelectableCheckbox = checkboxes[1]
fireEvent.click(lockedCheckbox)
expect(lockedCheckbox).toBeChecked()
expect(aiSelectableCheckbox).toBeChecked()
expect(aiSelectableCheckbox).toBeDisabled()
const input = screen.getByRole("spinbutton")
expect(input).toHaveValue(1)
})
})

View File

@ -0,0 +1,148 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import { clampInt } from "@/lib/utils"
type ResumeProjectsSectionProps = {
resumeProjectsDraft: ResumeProjectsSettings | null
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
isLoading: boolean
isSaving: boolean
}
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
resumeProjectsDraft,
setResumeProjectsDraft,
profileProjects,
lockedCount,
maxProjectsTotal,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Resume Projects</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
/>
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,71 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
type SearchTermsSectionProps = {
searchTermsDraft: string[] | null
setSearchTermsDraft: (value: string[] | null) => void
defaultSearchTerms: string[]
effectiveSearchTerms: string[]
isLoading: boolean
isSaving: boolean
}
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
searchTermsDraft,
setSearchTermsDraft,
defaultSearchTerms,
effectiveSearchTerms,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Search Terms</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full 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={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,70 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type UkvisajobsSectionProps = {
ukvisajobsMaxJobsDraft: number | null
setUkvisajobsMaxJobsDraft: (value: number | null) => void
defaultUkvisajobsMaxJobs: number
effectiveUkvisajobsMaxJobs: number
isLoading: boolean
isSaving: boolean
}
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
ukvisajobsMaxJobsDraft,
setUkvisajobsMaxJobsDraft,
defaultUkvisajobsMaxJobs,
effectiveUkvisajobsMaxJobs,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,18 @@
/**
* Settings page constants.
*/
import type { JobStatus } from "@shared/types"
/** All available job statuses for clearing */
export const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
/** Status descriptions for UI */
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: 'Crawled but not processed',
processing: 'Currently generating resume',
ready: 'PDF generated, waiting for user to apply',
applied: 'User marked as applied',
skipped: 'User skipped this job',
expired: 'Deadline passed',
}

View File

@ -0,0 +1,14 @@
/**
* Settings page helpers.
*/
import { arraysEqual } from "@/lib/utils"
import type { ResumeProjectsSettings } from "@shared/types"
export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
return (
a.maxProjects === b.maxProjects &&
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
)
}

View File

@ -9,6 +9,6 @@ declare global {
}
export function trackEvent(event: string, data?: Record<string, unknown>) {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
window.umami?.track(event, data);
}

View File

@ -1,6 +1,118 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { Job } from "@shared/types"
// --- CSS ---
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// --- Dates ---
export const formatDate = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
return parsed.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return dateStr;
}
};
export const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
const date = parsed.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
const time = parsed.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
});
return `${date} ${time}`;
} catch {
return dateStr;
}
};
// --- DOM & Clipboard ---
export async function copyTextToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) {
throw new Error("Copy failed");
}
}
// --- Text Processing ---
export const stripHtml = (value: string) =>
value
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
// --- Comparisons & Math ---
export function arraysEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function clampInt(value: number, min: number, max: number) {
const int = Math.floor(value);
if (Number.isNaN(int)) return min;
return Math.min(max, Math.max(min, int));
}
// --- Job Specific Helpers ---
export const formatJobForWebhook = (job: Job) => {
return JSON.stringify(
{
event: "job.completed",
sentAt: new Date().toISOString(),
job,
},
null,
2,
);
};
export const sourceLabel: Record<Job["source"], string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
linkedin: "LinkedIn",
ukvisajobs: "UK Visa Jobs",
manual: "Manual",
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Database API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('clears jobs and pipeline runs', async () => {
const { createJob } = await import('../../repositories/jobs.js');
await createJob({
source: 'manual',
title: 'Cleanup Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/cleanup',
jobDescription: 'Test description',
});
const res = await fetch(`${baseUrl}/api/database`, { method: 'DELETE' });
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.jobsDeleted).toBe(1);
});
});

View File

@ -0,0 +1,25 @@
import { Router, Request, Response } from 'express';
import { clearDatabase } from '../../db/clear.js';
export const databaseRouter = Router();
/**
* DELETE /api/database - Clear all data from the database
*/
databaseRouter.delete('/', async (req: Request, res: Response) => {
try {
const result = clearDatabase();
res.json({
success: true,
data: {
message: 'Database cleared',
jobsDeleted: result.jobsDeleted,
runsDeleted: result.runsDeleted,
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Jobs API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('lists jobs and supports status filtering', async () => {
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({
source: 'manual',
title: 'Test Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/1',
jobDescription: 'Test description',
});
const listRes = await fetch(`${baseUrl}/api/jobs`);
const listBody = await listRes.json();
expect(listBody.success).toBe(true);
expect(listBody.data.total).toBe(1);
expect(listBody.data.jobs[0].id).toBe(job.id);
const filteredRes = await fetch(`${baseUrl}/api/jobs?status=skipped`);
const filteredBody = await filteredRes.json();
expect(filteredBody.data.total).toBe(0);
});
it('returns 404 for missing jobs', async () => {
const res = await fetch(`${baseUrl}/api/jobs/missing-id`);
expect(res.status).toBe(404);
});
it('validates job updates and supports skip/delete flow', async () => {
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({
source: 'manual',
title: 'Test Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/2',
jobDescription: 'Test description',
});
const badRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suitabilityScore: 1000 }),
});
expect(badRes.status).toBe(400);
const skipRes = await fetch(`${baseUrl}/api/jobs/${job.id}/skip`, { method: 'POST' });
const skipBody = await skipRes.json();
expect(skipBody.data.status).toBe('skipped');
const deleteRes = await fetch(`${baseUrl}/api/jobs/status/skipped`, { method: 'DELETE' });
const deleteBody = await deleteRes.json();
expect(deleteBody.data.count).toBe(1);
});
it('applies a job and syncs to Notion', async () => {
const { createNotionEntry } = await import('../../services/notion.js');
vi.mocked(createNotionEntry).mockResolvedValue({ pageId: 'page-123' });
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({
source: 'manual',
title: 'Test Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/3',
jobDescription: 'Test description',
});
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/apply`, { method: 'POST' });
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.status).toBe('applied');
expect(body.data.notionPageId).toBe('page-123');
expect(body.data.appliedAt).toBeTruthy();
expect(createNotionEntry).toHaveBeenCalledWith(
expect.objectContaining({
id: job.id,
title: job.title,
employer: job.employer,
})
);
});
});

View File

@ -0,0 +1,261 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as jobsRepo from '../../repositories/jobs.js';
import * as settingsRepo from '../../repositories/settings.js';
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
import { createNotionEntry } from '../../services/notion.js';
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
export const jobsRouter = Router();
async function notifyJobCompleteWebhook(job: Job) {
const overrideWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl')
const webhookUrl = (overrideWebhookUrl || process.env.JOB_COMPLETE_WEBHOOK_URL || '').trim()
if (!webhookUrl) return
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const secret = process.env.WEBHOOK_SECRET
if (secret) headers.Authorization = `Bearer ${secret}`
const response = await fetch(webhookUrl, {
method: 'POST',
headers,
body: JSON.stringify({
event: 'job.completed',
sentAt: new Date().toISOString(),
job,
}),
})
if (!response.ok) {
console.warn(`ƒsÿ,? Job complete webhook POST failed (${response.status}): ${await response.text()}`)
}
} catch (error) {
console.warn('ƒsÿ,? Job complete webhook POST failed:', error)
}
}
/**
* PATCH /api/jobs/:id - Update a job
*/
const updateJobSchema = z.object({
status: z.enum(['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']).optional(),
jobDescription: z.string().optional(),
suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(),
tailoredSummary: z.string().optional(),
selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(),
});
/**
* GET /api/jobs - List all jobs
* Query params: status (comma-separated list of statuses to filter)
*/
jobsRouter.get('/', async (req: Request, res: Response) => {
try {
const statusFilter = req.query.status as string | undefined;
const statuses = statusFilter?.split(',').filter(Boolean) as JobStatus[] | undefined;
const jobs = await jobsRepo.getAllJobs(statuses);
const stats = await jobsRepo.getJobStats();
const response: ApiResponse<JobsListResponse> = {
success: true,
data: {
jobs,
total: jobs.length,
byStatus: stats,
},
};
res.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/jobs/:id - Get a single job
*/
jobsRouter.get('/:id', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
jobsRouter.patch('/:id', async (req: Request, res: Response) => {
try {
const input = updateJobSchema.parse(req.body);
const job = await jobsRepo.updateJob(req.params.id, input);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
res.json({ success: true, data: job });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/summarize - Generate AI summary and suggest projects
*/
jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
try {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === '1' || forceRaw === 'true';
const result = await summarizeJob(req.params.id, { force });
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
*/
jobsRouter.post('/:id/generate-pdf', async (req: Request, res: Response) => {
try {
const result = await generateFinalPdf(req.params.id);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/process - Process a single job (generate summary + PDF)
*/
jobsRouter.post('/:id/process', async (req: Request, res: Response) => {
try {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === '1' || forceRaw === 'true';
const result = await processJob(req.params.id, { force });
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/apply - Mark a job as applied and sync to Notion
*/
jobsRouter.post('/:id/apply', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
const appliedAt = new Date().toISOString();
// Sync to Notion
const notionResult = await createNotionEntry({
id: job.id,
title: job.title,
employer: job.employer,
applicationLink: job.applicationLink,
deadline: job.deadline,
salary: job.salary,
location: job.location,
pdfPath: job.pdfPath,
appliedAt,
});
// Update job status
const updatedJob = await jobsRepo.updateJob(job.id, {
status: 'applied',
appliedAt,
notionPageId: notionResult.pageId,
});
if (updatedJob) {
notifyJobCompleteWebhook(updatedJob).catch(console.warn)
}
res.json({ success: true, data: updatedJob });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/skip - Mark a job as skipped
*/
jobsRouter.post('/:id/skip', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.updateJob(req.params.id, { status: 'skipped' });
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* DELETE /api/jobs/status/:status - Clear jobs with a specific status
*/
jobsRouter.delete('/status/:status', async (req: Request, res: Response) => {
try {
const status = req.params.status as JobStatus;
const count = await jobsRepo.deleteJobsByStatus(status);
res.json({
success: true,
data: {
message: `Cleared ${count} ${status} jobs`,
count,
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Manual jobs API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('infers manual jobs and rejects empty payloads', async () => {
const badRes = await fetch(`${baseUrl}/api/manual-jobs/infer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(badRes.status).toBe(400);
const { inferManualJobDetails } = await import('../../services/manualJob.js');
vi.mocked(inferManualJobDetails).mockResolvedValue({
job: { title: 'Backend Engineer', employer: 'Acme' },
warning: null,
});
const res = await fetch(`${baseUrl}/api/manual-jobs/infer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobDescription: 'Role description' }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.job.title).toBe('Backend Engineer');
});
it('imports manual jobs and generates a fallback URL', async () => {
const { scoreJobSuitability } = await import('../../services/scorer.js');
vi.mocked(scoreJobSuitability).mockResolvedValue({ score: 88, reason: 'Strong fit' });
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job: {
title: 'Backend Engineer',
employer: 'Acme',
jobDescription: 'Great role',
},
}),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.source).toBe('manual');
expect(body.data.jobUrl).toMatch(/^manual:\/\//);
await new Promise((resolve) => setTimeout(resolve, 25));
});
});

View File

@ -0,0 +1,126 @@
import { Router, Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { z } from 'zod';
import * as jobsRepo from '../../repositories/jobs.js';
import { inferManualJobDetails } from '../../services/manualJob.js';
import { scoreJobSuitability } from '../../services/scorer.js';
import { loadResumeProfile } from '../../services/resumeProjects.js';
import type { ApiResponse, ManualJobInferenceResponse } from '../../../shared/types.js';
export const manualJobsRouter = Router();
const manualJobInferenceSchema = z.object({
jobDescription: z.string().trim().min(1).max(40000),
});
const manualJobImportSchema = z.object({
job: z.object({
title: z.string().trim().min(1).max(500),
employer: z.string().trim().min(1).max(500),
jobUrl: z.string().trim().url().max(2000).optional(),
applicationLink: z.string().trim().url().max(2000).optional(),
location: z.string().trim().max(200).optional(),
salary: z.string().trim().max(200).optional(),
deadline: z.string().trim().max(100).optional(),
jobDescription: z.string().trim().min(1).max(40000),
jobType: z.string().trim().max(200).optional(),
jobLevel: z.string().trim().max(200).optional(),
jobFunction: z.string().trim().max(200).optional(),
disciplines: z.string().trim().max(200).optional(),
degreeRequired: z.string().trim().max(200).optional(),
starting: z.string().trim().max(200).optional(),
}),
});
const cleanOptional = (value?: string | null) => {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
/**
* POST /api/manual-jobs/infer - Infer job details from a pasted description
*/
manualJobsRouter.post('/infer', async (req: Request, res: Response) => {
try {
const input = manualJobInferenceSchema.parse(req.body ?? {});
const result = await inferManualJobDetails(input.jobDescription);
const response: ApiResponse<ManualJobInferenceResponse> = {
success: true,
data: {
job: result.job,
warning: result.warning ?? null,
},
};
res.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/manual-jobs/import - Import a manually curated job into the DB
*/
manualJobsRouter.post('/import', async (req: Request, res: Response) => {
try {
const input = manualJobImportSchema.parse(req.body ?? {});
const job = input.job;
const jobUrl =
cleanOptional(job.jobUrl) ||
cleanOptional(job.applicationLink) ||
`manual://${randomUUID()}`;
const createdJob = await jobsRepo.createJob({
source: 'manual',
title: job.title.trim(),
employer: job.employer.trim(),
jobUrl,
applicationLink: cleanOptional(job.applicationLink) ?? undefined,
location: cleanOptional(job.location) ?? undefined,
salary: cleanOptional(job.salary) ?? undefined,
deadline: cleanOptional(job.deadline) ?? undefined,
jobDescription: job.jobDescription.trim(),
jobType: cleanOptional(job.jobType) ?? undefined,
jobLevel: cleanOptional(job.jobLevel) ?? undefined,
jobFunction: cleanOptional(job.jobFunction) ?? undefined,
disciplines: cleanOptional(job.disciplines) ?? undefined,
degreeRequired: cleanOptional(job.degreeRequired) ?? undefined,
starting: cleanOptional(job.starting) ?? undefined,
});
// Score asynchronously so the import returns immediately.
(async () => {
try {
const rawProfile = await loadResumeProfile();
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
throw new Error('Invalid resume profile format');
}
const profile = rawProfile as Record<string, unknown>;
const { score, reason } = await scoreJobSuitability(createdJob, profile);
await jobsRepo.updateJob(createdJob.id, {
suitabilityScore: score,
suitabilityReason: reason,
});
} catch (error) {
console.warn('Manual job scoring failed:', error);
}
})().catch((error) => {
console.warn('Manual job scoring task failed to start:', error);
});
res.json({ success: true, data: createdJob });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Pipeline API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('reports pipeline status', async () => {
const res = await fetch(`${baseUrl}/api/pipeline/status`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.isRunning).toBe(false);
expect(body.data.lastRun).toBeNull();
});
it('validates pipeline run payloads', async () => {
const badRun = await fetch(`${baseUrl}/api/pipeline/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minSuitabilityScore: 120 }),
});
expect(badRun.status).toBe(400);
const { runPipeline } = await import('../../pipeline/index.js');
const runRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topN: 5, sources: ['gradcracker'] }),
});
const runBody = await runRes.json();
expect(runBody.success).toBe(true);
expect(runPipeline).toHaveBeenCalledWith({ topN: 5, sources: ['gradcracker'] });
});
it('streams pipeline progress over SSE', async () => {
const controller = new AbortController();
const res = await fetch(`${baseUrl}/api/pipeline/progress`, {
signal: controller.signal,
});
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
const reader = res.body?.getReader();
if (reader) {
const chunk = await reader.read();
controller.abort();
await reader.cancel();
const text = new TextDecoder().decode(chunk.value);
expect(text).toContain('data:');
} else {
controller.abort();
}
});
});

View File

@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as pipelineRepo from '../../repositories/pipeline.js';
import { runPipeline, getPipelineStatus, subscribeToProgress } from '../../pipeline/index.js';
import type { ApiResponse, PipelineStatusResponse } from '../../../shared/types.js';
export const pipelineRouter = Router();
/**
* GET /api/pipeline/status - Get pipeline status
*/
pipelineRouter.get('/status', async (req: Request, res: Response) => {
try {
const { isRunning } = getPipelineStatus();
const lastRun = await pipelineRepo.getLatestPipelineRun();
const response: ApiResponse<PipelineStatusResponse> = {
success: true,
data: {
isRunning,
lastRun,
nextScheduledRun: null, // Would come from n8n
},
};
res.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/pipeline/progress - Server-Sent Events endpoint for live progress
*/
pipelineRouter.get('/progress', (req: Request, res: Response) => {
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
// Send initial progress
const sendProgress = (data: unknown) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Subscribe to progress updates
const unsubscribe = subscribeToProgress(sendProgress);
// Send heartbeat every 30 seconds to keep connection alive
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30000);
// Cleanup on close
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
});
/**
* GET /api/pipeline/runs - Get recent pipeline runs
*/
pipelineRouter.get('/runs', async (req: Request, res: Response) => {
try {
const runs = await pipelineRepo.getRecentPipelineRuns(20);
res.json({ success: true, data: runs });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/pipeline/run - Trigger the pipeline manually
*/
const runPipelineSchema = z.object({
topN: z.number().min(1).max(50).optional(),
minSuitabilityScore: z.number().min(0).max(100).optional(),
sources: z.array(z.enum(['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'])).min(1).optional(),
});
pipelineRouter.post('/run', async (req: Request, res: Response) => {
try {
const config = runPipelineSchema.parse(req.body);
// Start pipeline in background
runPipeline(config).catch(console.error);
res.json({
success: true,
data: { message: 'Pipeline started' }
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Profile API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns base resume projects', async () => {
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
});

View File

@ -0,0 +1,18 @@
import { Router, Request, Response } from 'express';
import { extractProjectsFromProfile, loadResumeProfile } from '../../services/resumeProjects.js';
export const profileRouter = Router();
/**
* GET /api/profile/projects - Get all projects available in the base resume
*/
profileRouter.get('/projects', async (req: Request, res: Response) => {
try {
const profile = await loadResumeProfile();
const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: catalog });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Settings API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns settings with defaults', async () => {
const res = await fetch(`${baseUrl}/api/settings`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.defaultModel).toBe('test-model');
expect(Array.isArray(body.data.searchTerms)).toBe(true);
});
it('rejects invalid settings updates and persists overrides', async () => {
const badPatch = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobspyResultsWanted: 9999 }),
});
expect(badPatch.status).toBe(400);
const patchRes = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: ['engineer'] }),
});
const patchBody = await patchRes.json();
expect(patchBody.success).toBe(true);
expect(patchBody.data.searchTerms).toEqual(['engineer']);
expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']);
});
});

View File

@ -0,0 +1,394 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as settingsRepo from '../../repositories/settings.js';
import {
extractProjectsFromProfile,
loadResumeProfile,
normalizeResumeProjectsSettings,
resolveResumeProjectsSettings,
} from '../../services/resumeProjects.js';
export const settingsRouter = Router();
/**
* GET /api/settings - Get app settings (effective + defaults)
*/
settingsRouter.get('/', async (_req: Request, res: Response) => {
try {
const overrideModel = await settingsRepo.getSetting('model');
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
const model = overrideModel || defaultModel;
// Specific AI models
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
const modelScorer = overrideModelScorer || model;
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
const modelTailoring = overrideModelTailoring || model;
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
const modelProjectSelection = overrideModelProjectSelection || model;
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
const profile = await loadResumeProfile();
const { catalog } = extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
const defaultUkvisajobsMaxJobs = 50;
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
const defaultGradcrackerMaxJobsPerTerm = 50;
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
// JobSpy settings (GET)
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
const jobspySites = overrideJobspySites ?? defaultJobspySites;
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
: null;
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
res.json({
success: true,
data: {
model,
defaultModel,
overrideModel,
modelScorer,
overrideModelScorer,
modelTailoring,
overrideModelTailoring,
modelProjectSelection,
overrideModelProjectSelection,
pipelineWebhookUrl,
defaultPipelineWebhookUrl,
overridePipelineWebhookUrl,
jobCompleteWebhookUrl,
defaultJobCompleteWebhookUrl,
overrideJobCompleteWebhookUrl,
...resumeProjectsData,
ukvisajobsMaxJobs,
defaultUkvisajobsMaxJobs,
overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm,
defaultGradcrackerMaxJobsPerTerm,
overrideGradcrackerMaxJobsPerTerm,
searchTerms,
defaultSearchTerms,
overrideSearchTerms,
jobspyLocation,
defaultJobspyLocation,
overrideJobspyLocation,
jobspyResultsWanted,
defaultJobspyResultsWanted,
overrideJobspyResultsWanted,
jobspyHoursOld,
defaultJobspyHoursOld,
overrideJobspyHoursOld,
jobspyCountryIndeed,
defaultJobspyCountryIndeed,
overrideJobspyCountryIndeed,
jobspySites,
defaultJobspySites,
overrideJobspySites,
jobspyLinkedinFetchDescription,
defaultJobspyLinkedinFetchDescription,
overrideJobspyLinkedinFetchDescription,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
const updateSettingsSchema = z.object({
model: z.string().trim().min(1).max(200).nullable().optional(),
modelScorer: z.string().trim().min(1).max(200).nullable().optional(),
modelTailoring: z.string().trim().min(1).max(200).nullable().optional(),
modelProjectSelection: z.string().trim().min(1).max(200).nullable().optional(),
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
resumeProjects: z.object({
maxProjects: z.number().int().min(0).max(50),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
}).nullable().optional(),
ukvisajobsMaxJobs: z.number().int().min(1).max(200).nullable().optional(),
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(200).nullable().optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(50).nullable().optional(),
jobspyLocation: z.string().trim().min(1).max(100).nullable().optional(),
jobspyResultsWanted: z.number().int().min(1).max(500).nullable().optional(),
jobspyHoursOld: z.number().int().min(1).max(168).nullable().optional(),
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
});
/**
* PATCH /api/settings - Update settings overrides
*/
settingsRouter.patch('/', async (req: Request, res: Response) => {
try {
const input = updateSettingsSchema.parse(req.body);
if ('model' in input) {
const model = input.model ?? null;
await settingsRepo.setSetting('model', model);
}
if ('modelScorer' in input) {
await settingsRepo.setSetting('modelScorer', input.modelScorer ?? null);
}
if ('modelTailoring' in input) {
await settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null);
}
if ('modelProjectSelection' in input) {
await settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null);
}
if ('pipelineWebhookUrl' in input) {
const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null;
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
}
if ('jobCompleteWebhookUrl' in input) {
const webhookUrl = input.jobCompleteWebhookUrl ?? null;
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
}
if ('resumeProjects' in input) {
const resumeProjects = input.resumeProjects ?? null;
if (resumeProjects === null) {
await settingsRepo.setSetting('resumeProjects', null);
} else {
const rawProfile = await loadResumeProfile();
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
throw new Error('Invalid resume profile format: expected a non-null object');
}
const profile = rawProfile as Record<string, unknown>;
const { catalog } = extractProjectsFromProfile(profile);
const allowed = new Set(catalog.map((p) => p.id));
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
}
}
if ('ukvisajobsMaxJobs' in input) {
const ukvisajobsMaxJobs = input.ukvisajobsMaxJobs ?? null;
await settingsRepo.setSetting('ukvisajobsMaxJobs', ukvisajobsMaxJobs !== null ? String(ukvisajobsMaxJobs) : null);
}
if ('gradcrackerMaxJobsPerTerm' in input) {
const gradcrackerMaxJobsPerTerm = input.gradcrackerMaxJobsPerTerm ?? null;
await settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', gradcrackerMaxJobsPerTerm !== null ? String(gradcrackerMaxJobsPerTerm) : null);
}
if ('searchTerms' in input) {
const searchTerms = input.searchTerms ?? null;
await settingsRepo.setSetting('searchTerms', searchTerms !== null ? JSON.stringify(searchTerms) : null);
}
if ('jobspyLocation' in input) {
const value = input.jobspyLocation ?? null;
await settingsRepo.setSetting('jobspyLocation', value);
}
if ('jobspyResultsWanted' in input) {
const value = input.jobspyResultsWanted ?? null;
await settingsRepo.setSetting('jobspyResultsWanted', value !== null ? String(value) : null);
}
if ('jobspyHoursOld' in input) {
const value = input.jobspyHoursOld ?? null;
await settingsRepo.setSetting('jobspyHoursOld', value !== null ? String(value) : null);
}
if ('jobspyCountryIndeed' in input) {
const value = input.jobspyCountryIndeed ?? null;
await settingsRepo.setSetting('jobspyCountryIndeed', value);
}
if ('jobspySites' in input) {
const value = input.jobspySites ?? null;
await settingsRepo.setSetting('jobspySites', value !== null ? JSON.stringify(value) : null);
}
if ('jobspyLinkedinFetchDescription' in input) {
const value = input.jobspyLinkedinFetchDescription ?? null;
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
}
const overrideModel = await settingsRepo.getSetting('model');
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
const model = overrideModel || defaultModel;
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
const modelScorer = overrideModelScorer || model;
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
const modelTailoring = overrideModelTailoring || model;
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
const modelProjectSelection = overrideModelProjectSelection || model;
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
const profile = await loadResumeProfile();
const { catalog } = extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
const defaultUkvisajobsMaxJobs = 50;
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
const defaultGradcrackerMaxJobsPerTerm = 50;
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
// Search terms - stored as JSON array, default from env var (pipe-separated)
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
// JobSpy settings (re-fetch to update response)
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
const jobspySites = overrideJobspySites ?? defaultJobspySites;
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
: null;
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
res.json({
success: true,
data: {
model,
defaultModel,
overrideModel,
modelScorer,
overrideModelScorer,
modelTailoring,
overrideModelTailoring,
modelProjectSelection,
overrideModelProjectSelection,
pipelineWebhookUrl,
defaultPipelineWebhookUrl,
overridePipelineWebhookUrl,
jobCompleteWebhookUrl,
defaultJobCompleteWebhookUrl,
overrideJobCompleteWebhookUrl,
...resumeProjectsData,
ukvisajobsMaxJobs,
defaultUkvisajobsMaxJobs,
overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm,
defaultGradcrackerMaxJobsPerTerm,
overrideGradcrackerMaxJobsPerTerm,
searchTerms,
defaultSearchTerms,
overrideSearchTerms,
jobspyLocation,
defaultJobspyLocation,
overrideJobspyLocation,
jobspyResultsWanted,
defaultJobspyResultsWanted,
overrideJobspyResultsWanted,
jobspyHoursOld,
defaultJobspyHoursOld,
overrideJobspyHoursOld,
jobspyCountryIndeed,
defaultJobspyCountryIndeed,
overrideJobspyCountryIndeed,
jobspySites,
defaultJobspySites,
overrideJobspySites,
jobspyLinkedinFetchDescription,
defaultJobspyLinkedinFetchDescription,
overrideJobspyLinkedinFetchDescription,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
// PATCH usually returns 500 for unknown, but let's stick to what was there (400?)
// Wait, the file said 400? Let's verify line 608.
res.status(400).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,112 @@
import { mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import type { Server } from 'http';
import { vi } from 'vitest';
vi.mock('../../pipeline/index.js', () => {
const progress = {
step: 'idle',
message: 'Ready',
crawlingListPagesProcessed: 0,
crawlingListPagesTotal: 0,
crawlingJobCardsFound: 0,
crawlingJobPagesEnqueued: 0,
crawlingJobPagesSkipped: 0,
crawlingJobPagesProcessed: 0,
jobsDiscovered: 0,
jobsScored: 0,
jobsProcessed: 0,
totalToProcess: 0,
};
return {
runPipeline: vi.fn().mockResolvedValue({ success: true, jobsDiscovered: 0, jobsProcessed: 0 }),
processJob: vi.fn().mockResolvedValue({ success: true }),
summarizeJob: vi.fn().mockResolvedValue({ success: true }),
generateFinalPdf: vi.fn().mockResolvedValue({ success: true }),
getPipelineStatus: vi.fn(() => ({ isRunning: false })),
subscribeToProgress: vi.fn((listener: (data: unknown) => void) => {
listener(progress);
return () => {};
}),
};
});
vi.mock('../../services/notion.js', () => ({
createNotionEntry: vi.fn(),
}));
vi.mock('../../services/manualJob.js', () => ({
inferManualJobDetails: vi.fn(),
}));
vi.mock('../../services/scorer.js', () => ({
scoreJobSuitability: vi.fn(),
}));
vi.mock('../../services/ukvisajobs.js', () => ({
fetchUkVisaJobsPage: vi.fn(),
}));
vi.mock('../../services/visa-sponsors/index.js', () => ({
getStatus: vi.fn(),
searchSponsors: vi.fn(),
getOrganizationDetails: vi.fn(),
downloadLatestCsv: vi.fn(),
}));
const originalEnv = { ...process.env };
export async function startServer(options?: {
env?: Record<string, string | undefined>;
}): Promise<{
server: Server;
baseUrl: string;
closeDb: () => void;
tempDir: string;
}> {
vi.resetModules();
const tempDir = await mkdtemp(join(tmpdir(), 'job-ops-api-test-'));
const envOverrides = options?.env ?? {};
process.env = {
...originalEnv,
DATA_DIR: tempDir,
NODE_ENV: 'test',
MODEL: 'test-model',
JOBSPY_SEARCH_TERMS: 'alpha|beta',
...envOverrides,
};
await import('../../db/migrate.js');
const { createApp } = await import('../../app.js');
const { closeDb } = await import('../../db/index.js');
const { getPipelineStatus } = await import('../../pipeline/index.js');
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
const app = createApp();
const server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server address');
}
return {
server,
baseUrl: `http://127.0.0.1:${address.port}`,
closeDb,
tempDir,
};
}
export async function stopServer(args: {
server: Server;
closeDb: () => void;
tempDir: string;
}) {
await new Promise<void>((resolve) => args.server.close(() => resolve()));
args.closeDb();
await rm(args.tempDir, { recursive: true, force: true });
process.env = { ...originalEnv };
vi.clearAllMocks();
}

View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('UK Visa Jobs API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('enforces pagination rules for search', async () => {
const badRes = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: ['one', 'two'] }),
});
expect(badRes.status).toBe(400);
});
it('searches UK Visa Jobs with valid payloads', async () => {
const { fetchUkVisaJobsPage } = await import('../../services/ukvisajobs.js');
vi.mocked(fetchUkVisaJobsPage).mockResolvedValue({
jobs: [
{
source: 'ukvisajobs',
title: 'Engineer',
employer: 'Acme',
jobUrl: 'https://example.com/visa/1',
},
],
totalJobs: 3,
page: 1,
pageSize: 2,
});
const res = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'engineer' }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.totalPages).toBe(2);
expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({ searchKeyword: 'engineer', page: 1 });
});
it('blocks search when pipeline is running', async () => {
const { getPipelineStatus } = await import('../../pipeline/index.js');
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: true });
const res = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'engineer' }),
});
expect(res.status).toBe(409);
});
it('imports UK Visa Jobs and reports created vs skipped', async () => {
const res = await fetch(`${baseUrl}/api/ukvisajobs/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobs: [
{
title: 'Engineer',
employer: 'Acme',
jobUrl: 'https://example.com/visa/2',
},
{
title: 'Engineer Duplicate',
employer: 'Acme',
jobUrl: 'https://example.com/visa/2',
},
],
}),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.created).toBe(1);
expect(body.data.skipped).toBe(1);
});
});

View File

@ -0,0 +1,128 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as jobsRepo from '../../repositories/jobs.js';
import { fetchUkVisaJobsPage } from '../../services/ukvisajobs.js';
import { getPipelineStatus } from '../../pipeline/index.js';
import type { ApiResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse } from '../../../shared/types.js';
export const ukVisaJobsRouter = Router();
let isUkVisaJobsSearchRunning = false;
const ukVisaJobsSearchSchema = z.object({
query: z.string().trim().min(1).max(200).optional(),
searchTerm: z.string().trim().min(1).max(200).optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(20).optional(),
page: z.number().int().min(1).optional(),
});
/**
* POST /api/ukvisajobs/search - Run a UKVisaJobs search without importing into the DB
*/
ukVisaJobsRouter.post('/search', async (req: Request, res: Response) => {
let lockAcquired = false;
try {
const input = ukVisaJobsSearchSchema.parse(req.body ?? {});
if (isUkVisaJobsSearchRunning) {
return res.status(409).json({ success: false, error: 'UK Visa Jobs search is already running' });
}
const { isRunning } = getPipelineStatus();
if (isRunning) {
return res.status(409).json({ success: false, error: 'Pipeline is running. Stop it before running UK Visa Jobs search.' });
}
isUkVisaJobsSearchRunning = true;
lockAcquired = true;
const rawTerms = input.searchTerms ?? [];
if (rawTerms.length > 1) {
return res.status(400).json({ success: false, error: 'Pagination supports a single search term.' });
}
const searchTerm = input.searchTerm ?? input.query ?? rawTerms[0];
const page = input.page ?? 1;
const result = await fetchUkVisaJobsPage({
searchKeyword: searchTerm,
page,
});
const totalPages = Math.max(1, Math.ceil(result.totalJobs / result.pageSize));
const response: ApiResponse<UkVisaJobsSearchResponse> = {
success: true,
data: {
jobs: result.jobs,
totalJobs: result.totalJobs,
page: result.page,
pageSize: result.pageSize,
totalPages,
},
};
res.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
} finally {
if (lockAcquired) {
isUkVisaJobsSearchRunning = false;
}
}
});
const ukVisaJobsImportSchema = z.object({
jobs: z.array(z.object({
title: z.string().trim().min(1).max(500),
employer: z.string().trim().min(1).max(500),
jobUrl: z.string().trim().min(1).max(2000),
sourceJobId: z.string().trim().min(1).max(200).optional(),
employerUrl: z.string().trim().min(1).max(2000).optional(),
applicationLink: z.string().trim().min(1).max(2000).optional(),
location: z.string().trim().max(200).optional(),
deadline: z.string().trim().max(100).optional(),
salary: z.string().trim().max(200).optional(),
jobDescription: z.string().trim().max(20000).optional(),
datePosted: z.string().trim().max(100).optional(),
degreeRequired: z.string().trim().max(200).optional(),
jobType: z.string().trim().max(200).optional(),
jobLevel: z.string().trim().max(200).optional(),
})).min(1).max(200),
});
/**
* POST /api/ukvisajobs/import - Import selected UKVisaJobs results into the DB
*/
ukVisaJobsRouter.post('/import', async (req: Request, res: Response) => {
try {
const input = ukVisaJobsImportSchema.parse(req.body ?? {});
const jobs = input.jobs.map((job) => ({
...job,
source: 'ukvisajobs' as const,
}));
const result = await jobsRepo.bulkCreateJobs(jobs);
const response: ApiResponse<UkVisaJobsImportResponse> = {
success: true,
data: {
created: result.created,
skipped: result.skipped,
},
};
res.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Visa sponsors API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns status and surfaces update errors', async () => {
const { getStatus, downloadLatestCsv } = await import('../../services/visa-sponsors/index.js');
vi.mocked(getStatus).mockReturnValue({
lastUpdated: null,
csvPath: null,
totalSponsors: 0,
isUpdating: false,
nextScheduledUpdate: null,
error: null,
});
vi.mocked(downloadLatestCsv).mockResolvedValue({ success: false, message: 'failed' });
const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`);
const statusBody = await statusRes.json();
expect(statusBody.success).toBe(true);
expect(statusBody.data.totalSponsors).toBe(0);
const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, { method: 'POST' });
expect(updateRes.status).toBe(500);
});
it('validates search payloads and handles missing organizations', async () => {
const { searchSponsors, getOrganizationDetails } = await import('../../services/visa-sponsors/index.js');
vi.mocked(searchSponsors).mockReturnValue([
{
sponsor: {
organisationName: 'Acme',
townCity: 'London',
county: 'London',
typeRating: 'Worker',
route: 'Skilled',
},
score: 95,
matchedName: 'acme',
},
]);
vi.mocked(getOrganizationDetails).mockReturnValue([]);
const badRes = await fetch(`${baseUrl}/api/visa-sponsors/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(badRes.status).toBe(400);
const res = await fetch(`${baseUrl}/api/visa-sponsors/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'Acme' }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.total).toBe(1);
const orgRes = await fetch(`${baseUrl}/api/visa-sponsors/organization/Acme`);
expect(orgRes.status).toBe(404);
});
});

View File

@ -0,0 +1,109 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as visaSponsors from '../../services/visa-sponsors/index.js';
import type {
ApiResponse,
VisaSponsorSearchResponse,
VisaSponsorStatusResponse,
} from '../../../shared/types.js';
export const visaSponsorsRouter = Router();
/**
* GET /api/visa-sponsors/status - Get status of the visa sponsor service
*/
visaSponsorsRouter.get('/status', async (req: Request, res: Response) => {
try {
const status = visaSponsors.getStatus();
const response: ApiResponse<VisaSponsorStatusResponse> = {
success: true,
data: status,
};
res.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/visa-sponsors/search - Search for visa sponsors
*/
const visaSponsorSearchSchema = z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(200).optional(),
minScore: z.number().int().min(0).max(100).optional(),
});
visaSponsorsRouter.post('/search', async (req: Request, res: Response) => {
try {
const input = visaSponsorSearchSchema.parse(req.body);
const results = visaSponsors.searchSponsors(input.query, {
limit: input.limit,
minScore: input.minScore,
});
const response: ApiResponse<VisaSponsorSearchResponse> = {
success: true,
data: {
results,
query: input.query,
total: results.length,
},
};
res.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/visa-sponsors/organization/:name - Get all entries for an organization
*/
visaSponsorsRouter.get('/organization/:name', async (req: Request, res: Response) => {
try {
const name = decodeURIComponent(req.params.name);
const entries = visaSponsors.getOrganizationDetails(name);
if (entries.length === 0) {
return res.status(404).json({ success: false, error: 'Organization not found' });
}
res.json({
success: true,
data: entries,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/visa-sponsors/update - Trigger a manual update of the visa sponsor list
*/
visaSponsorsRouter.post('/update', async (req: Request, res: Response) => {
try {
const result = await visaSponsors.downloadLatestCsv();
if (!result.success) {
return res.status(500).json({ success: false, error: result.message });
}
res.json({
success: true,
data: {
message: result.message,
status: visaSponsors.getStatus(),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Webhook API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: { WEBHOOK_SECRET: 'secret' },
}));
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('rejects invalid webhook credentials and accepts valid ones', async () => {
const badRes = await fetch(`${baseUrl}/api/webhook/trigger`, {
method: 'POST',
});
expect(badRes.status).toBe(401);
const goodRes = await fetch(`${baseUrl}/api/webhook/trigger`, {
method: 'POST',
headers: { Authorization: 'Bearer secret' },
});
const goodBody = await goodRes.json();
expect(goodBody.success).toBe(true);
expect(goodBody.data.message).toBe('Pipeline triggered');
});
});

View File

@ -0,0 +1,33 @@
import { Router, Request, Response } from 'express';
import { runPipeline } from '../../pipeline/index.js';
export const webhookRouter = Router();
/**
* POST /api/webhook/trigger - Webhook endpoint for n8n to trigger the pipeline
*/
webhookRouter.post('/trigger', async (req: Request, res: Response) => {
// Optional: Add authentication check
const authHeader = req.headers.authorization;
const expectedToken = process.env.WEBHOOK_SECRET;
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
try {
// Start pipeline in background
runPipeline().catch(console.error);
res.json({
success: true,
data: {
message: 'Pipeline triggered',
triggeredAt: new Date().toISOString(),
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -85,7 +85,7 @@ Keys:
- starting
JOB DESCRIPTION:
${jd.slice(0, 8000)}${jd.length > 8000 ? '... (truncated)' : ''}
${jd}
OUTPUT FORMAT (JSON ONLY):
{

View File

@ -3,3 +3,13 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
if (typeof globalThis.ResizeObserver === "undefined") {
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;
}