In progress flow (#163)
* initial commit
* move from applied to in-progress
* KANBAN BOARD!
* backfill jobs
* backfill rejected jobs
* drag events 😋
* fix backfill bug
* UI improvements
* remove applied
* gold near offer
* team match meeting swim lane
* formatting
* Add tests for InProgressBoardPage and enhance job stage handling
This commit is contained in:
parent
f114fb6200
commit
f8b5dc2f42
@ -12,6 +12,7 @@ import { OnboardingGate } from "./components/OnboardingGate";
|
||||
import { useDemoInfo } from "./hooks/useDemoInfo";
|
||||
import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { InProgressBoardPage } from "./pages/InProgressBoardPage";
|
||||
import { JobPage } from "./pages/JobPage";
|
||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
@ -28,6 +29,10 @@ const REDIRECTS: Array<{ from: string; to: string }> = [
|
||||
{ from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" },
|
||||
{ from: "/applied", to: "/jobs/applied" },
|
||||
{ from: "/applied/:jobId", to: "/jobs/applied/:jobId" },
|
||||
{ from: "/in-progress", to: "/applications/in-progress" },
|
||||
{ from: "/in-progress/:jobId", to: "/applications/in-progress" },
|
||||
{ from: "/jobs/in_progress", to: "/applications/in-progress" },
|
||||
{ from: "/jobs/in_progress/:jobId", to: "/applications/in-progress" },
|
||||
{ from: "/all", to: "/jobs/all" },
|
||||
{ from: "/all/:jobId", to: "/jobs/all/:jobId" },
|
||||
];
|
||||
@ -83,6 +88,10 @@ export const App: React.FC = () => {
|
||||
element={<GmailOauthCallbackPage />}
|
||||
/>
|
||||
<Route path="/job/:id" element={<JobPage />} />
|
||||
<Route
|
||||
path="/applications/in-progress"
|
||||
element={<InProgressBoardPage />}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
|
||||
|
||||
@ -27,6 +27,7 @@ const statConfig: Array<{
|
||||
{ key: "processing", label: "Processing", Icon: Loader2 },
|
||||
{ key: "ready", label: "Ready", Icon: Sparkles },
|
||||
{ key: "applied", label: "Applied", Icon: CheckCircle2 },
|
||||
{ key: "in_progress", label: "In Progress", Icon: CheckCircle2 },
|
||||
{ key: "skipped", label: "Skipped", Icon: XCircle },
|
||||
{ key: "expired", label: "Expired", Icon: Clock },
|
||||
];
|
||||
@ -42,7 +43,7 @@ export const Stats: React.FC<StatsProps> = ({ stats }) => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-7">
|
||||
{statConfig.map(({ key, label, Icon }) => (
|
||||
<Card key={key} className="bg-muted/20">
|
||||
<CardContent className="p-4">
|
||||
|
||||
@ -17,6 +17,7 @@ const statusLabels: Record<JobStatus, string> = {
|
||||
processing: "Processing",
|
||||
ready: "Ready",
|
||||
applied: "Applied",
|
||||
in_progress: "In Progress",
|
||||
skipped: "Skipped",
|
||||
expired: "Expired",
|
||||
};
|
||||
@ -35,6 +36,10 @@ const statusStyles: Record<
|
||||
variant: "outline",
|
||||
className: "text-emerald-400 border-emerald-500/30",
|
||||
},
|
||||
in_progress: {
|
||||
variant: "outline",
|
||||
className: "text-cyan-400 border-cyan-500/30",
|
||||
},
|
||||
skipped: { variant: "destructive" },
|
||||
expired: { variant: "outline", className: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { Home, Inbox, LayoutDashboard, Settings, Shield } from "lucide-react";
|
||||
import {
|
||||
Columns3,
|
||||
Home,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavLink = {
|
||||
to: string;
|
||||
@ -20,6 +27,12 @@ export const NAV_LINKS: NavLink[] = [
|
||||
"/jobs/all",
|
||||
],
|
||||
},
|
||||
{
|
||||
to: "/applications/in-progress",
|
||||
label: "In Progress",
|
||||
icon: Columns3,
|
||||
activePaths: ["/applications/in-progress"],
|
||||
},
|
||||
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
|
||||
@ -48,7 +48,7 @@ export const HomePage: React.FC = () => {
|
||||
|
||||
api
|
||||
.getJobs({
|
||||
statuses: ["applied"],
|
||||
statuses: ["applied", "in_progress"],
|
||||
view: "list",
|
||||
})
|
||||
.then(async (response) => {
|
||||
|
||||
163
orchestrator/src/client/pages/InProgressBoardPage.test.tsx
Normal file
163
orchestrator/src/client/pages/InProgressBoardPage.test.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import type { JobListItem, StageEvent } from "@shared/types";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { InProgressBoardPage } from "./InProgressBoardPage";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getJobs: vi.fn(),
|
||||
getJobStageEvents: vi.fn(),
|
||||
transitionJobStage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeJob = (overrides: Partial<JobListItem>): JobListItem => ({
|
||||
id: "job-1",
|
||||
source: "manual",
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
jobUrl: "https://example.com/jobs/1",
|
||||
applicationLink: null,
|
||||
datePosted: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: null,
|
||||
status: "in_progress",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: null,
|
||||
sponsorMatchScore: null,
|
||||
jobType: null,
|
||||
jobFunction: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
discoveredAt: "2026-01-01T00:00:00.000Z",
|
||||
appliedAt: null,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEvent = (overrides: Partial<StageEvent>): StageEvent => ({
|
||||
id: "evt-1",
|
||||
applicationId: "job-1",
|
||||
title: "Recruiter Screen",
|
||||
groupId: null,
|
||||
fromStage: "applied",
|
||||
toStage: "recruiter_screen",
|
||||
occurredAt: 1_700_000_000,
|
||||
metadata: null,
|
||||
outcome: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(api.getJobs).mockResolvedValue({
|
||||
jobs: [makeJob({})],
|
||||
total: 1,
|
||||
byStatus: {
|
||||
discovered: 0,
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
in_progress: 1,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
},
|
||||
revision: "r1",
|
||||
} as Awaited<ReturnType<typeof api.getJobs>>);
|
||||
vi.mocked(api.getJobStageEvents).mockResolvedValue([makeEvent({})]);
|
||||
vi.mocked(api.transitionJobStage).mockResolvedValue(
|
||||
makeEvent({ toStage: "offer", title: "Offer" }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("InProgressBoardPage", () => {
|
||||
it("loads in-progress jobs and renders cards", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InProgressBoardPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getJobs).toHaveBeenCalledWith({
|
||||
statuses: ["in_progress"],
|
||||
view: "list",
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows cards even when no stage events are present", async () => {
|
||||
vi.mocked(api.getJobStageEvents).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InProgressBoardPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("transitions a job stage when dropped into another lane", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InProgressBoardPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const card = await screen.findByRole("link", { name: /Backend Engineer/i });
|
||||
const offerHeader = await screen.findByText("Offer");
|
||||
const offerLane = offerHeader.closest("section");
|
||||
|
||||
if (!offerLane) {
|
||||
throw new Error("Offer lane section not found");
|
||||
}
|
||||
|
||||
fireEvent.dragStart(card, {
|
||||
dataTransfer: {
|
||||
effectAllowed: "move",
|
||||
},
|
||||
});
|
||||
fireEvent.dragOver(offerLane);
|
||||
fireEvent.drop(offerLane);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.transitionJobStage).toHaveBeenCalledWith("job-1", {
|
||||
toStage: "offer",
|
||||
metadata: {
|
||||
actor: "user",
|
||||
eventType: "status_update",
|
||||
eventLabel: "Moved to Offer",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces load errors", async () => {
|
||||
vi.mocked(api.getJobs).mockRejectedValue(new Error("Failed to load board"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InProgressBoardPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to load board");
|
||||
});
|
||||
});
|
||||
});
|
||||
366
orchestrator/src/client/pages/InProgressBoardPage.tsx
Normal file
366
orchestrator/src/client/pages/InProgressBoardPage.tsx
Normal file
@ -0,0 +1,366 @@
|
||||
import { PageHeader, PageMain } from "@client/components/layout";
|
||||
import {
|
||||
APPLICATION_STAGES,
|
||||
type ApplicationStage,
|
||||
type JobListItem,
|
||||
STAGE_LABELS,
|
||||
type StageEvent,
|
||||
} from "@shared/types.js";
|
||||
import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn, formatTimestamp } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
|
||||
type BoardCard = {
|
||||
job: JobListItem;
|
||||
stage: ApplicationStage;
|
||||
latestEventAt: number | null;
|
||||
};
|
||||
|
||||
type BoardStage = Exclude<ApplicationStage, "applied">;
|
||||
|
||||
const sortByRecent = (a: BoardCard, b: BoardCard) => {
|
||||
if (a.latestEventAt != null && b.latestEventAt != null) {
|
||||
return b.latestEventAt - a.latestEventAt;
|
||||
}
|
||||
if (a.latestEventAt != null) return -1;
|
||||
if (b.latestEventAt != null) return 1;
|
||||
return Date.parse(b.job.discoveredAt) - Date.parse(a.job.discoveredAt);
|
||||
};
|
||||
|
||||
const sortByTitle = (a: BoardCard, b: BoardCard) =>
|
||||
a.job.title.localeCompare(b.job.title);
|
||||
|
||||
const sortByCompany = (a: BoardCard, b: BoardCard) =>
|
||||
a.job.employer.localeCompare(b.job.employer);
|
||||
|
||||
const BOARD_STAGES = APPLICATION_STAGES.filter(
|
||||
(stage) => stage !== "applied",
|
||||
) as BoardStage[];
|
||||
|
||||
const toBoardStage = (stage: ApplicationStage): BoardStage =>
|
||||
stage === "applied" ? "recruiter_screen" : stage;
|
||||
|
||||
const getCardLeftAccentClass = (stage: ApplicationStage) => {
|
||||
if (stage === "technical_interview") {
|
||||
return "border-l-2 border-l-amber-400/45";
|
||||
}
|
||||
if (stage === "onsite") {
|
||||
return "border-l-2 border-l-amber-400/65";
|
||||
}
|
||||
if (stage === "offer") {
|
||||
return "border-2 border-amber-300/50 shadow-[0_4px_12px_-4px_rgba(251,191,36,0.7)]";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolveCurrentStage = (
|
||||
events: StageEvent[] | null,
|
||||
): { stage: ApplicationStage; latestEventAt: number | null } => {
|
||||
const latest = events?.at(-1) ?? null;
|
||||
if (latest) {
|
||||
return { stage: latest.toStage, latestEventAt: latest.occurredAt };
|
||||
}
|
||||
return { stage: "applied", latestEventAt: null };
|
||||
};
|
||||
|
||||
export const InProgressBoardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [cards, setCards] = React.useState<BoardCard[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [dragging, setDragging] = React.useState<{
|
||||
jobId: string;
|
||||
fromStage: ApplicationStage;
|
||||
} | null>(null);
|
||||
const [dropTargetStage, setDropTargetStage] =
|
||||
React.useState<ApplicationStage | null>(null);
|
||||
const [movingJobId, setMovingJobId] = React.useState<string | null>(null);
|
||||
const [sortMode, setSortMode] = React.useState<
|
||||
"updated" | "title" | "company"
|
||||
>("updated");
|
||||
|
||||
const loadBoard = React.useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.getJobs({
|
||||
statuses: ["in_progress"],
|
||||
view: "list",
|
||||
});
|
||||
|
||||
const jobs = response.jobs;
|
||||
const eventResults = await Promise.allSettled(
|
||||
jobs.map((job) => api.getJobStageEvents(job.id)),
|
||||
);
|
||||
|
||||
const nextCards = jobs.map((job, index) => {
|
||||
const result = eventResults[index];
|
||||
const events =
|
||||
result?.status === "fulfilled"
|
||||
? [...result.value].sort((a, b) => a.occurredAt - b.occurredAt)
|
||||
: null;
|
||||
const resolved = resolveCurrentStage(events);
|
||||
return {
|
||||
job,
|
||||
stage: resolved.stage,
|
||||
latestEventAt: resolved.latestEventAt,
|
||||
};
|
||||
});
|
||||
|
||||
setCards(nextCards);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load in-progress board";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadBoard();
|
||||
}, [loadBoard]);
|
||||
|
||||
const lanes = React.useMemo(() => {
|
||||
const sortFn =
|
||||
sortMode === "title"
|
||||
? sortByTitle
|
||||
: sortMode === "company"
|
||||
? sortByCompany
|
||||
: sortByRecent;
|
||||
|
||||
const grouped: Record<BoardStage, BoardCard[]> = {
|
||||
recruiter_screen: [],
|
||||
assessment: [],
|
||||
hiring_manager_screen: [],
|
||||
technical_interview: [],
|
||||
onsite: [],
|
||||
offer: [],
|
||||
closed: [],
|
||||
};
|
||||
|
||||
for (const card of cards) {
|
||||
grouped[toBoardStage(card.stage)].push(card);
|
||||
}
|
||||
|
||||
for (const stage of BOARD_STAGES) {
|
||||
grouped[stage].sort(sortFn);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}, [cards, sortMode]);
|
||||
|
||||
const handleDropToStage = React.useCallback(
|
||||
async (toStage: ApplicationStage) => {
|
||||
if (!dragging || dragging.fromStage === toStage) {
|
||||
setDropTargetStage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { jobId } = dragging;
|
||||
const previousCards = cards;
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
|
||||
setMovingJobId(jobId);
|
||||
setCards((current) =>
|
||||
current.map((card) =>
|
||||
card.job.id === jobId
|
||||
? { ...card, stage: toStage, latestEventAt: nowEpoch }
|
||||
: card,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await api.transitionJobStage(jobId, {
|
||||
toStage,
|
||||
metadata: {
|
||||
actor: "user",
|
||||
eventType: "status_update",
|
||||
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
|
||||
},
|
||||
});
|
||||
toast.success(`Moved to ${STAGE_LABELS[toStage]}`);
|
||||
await loadBoard();
|
||||
} catch (error) {
|
||||
setCards(previousCards);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to move stage";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setMovingJobId(null);
|
||||
setDragging(null);
|
||||
setDropTargetStage(null);
|
||||
}
|
||||
},
|
||||
[cards, dragging, loadBoard],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={Columns3}
|
||||
title="In Progress Board"
|
||||
subtitle="Kanban view of application stages"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Select
|
||||
value={sortMode}
|
||||
onValueChange={(value) =>
|
||||
setSortMode(value as "updated" | "title" | "company")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[132px] text-xs">
|
||||
<ArrowDownAZ className="mr-1.5 h-3.5 w-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="updated">Recent</SelectItem>
|
||||
<SelectItem value="title">Title</SelectItem>
|
||||
<SelectItem value="company">Company</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => navigate("/jobs/ready")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PageMain className="max-w-[1600px]">
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-6 text-sm text-muted-foreground">
|
||||
Loading board...
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex min-w-max items-start gap-4">
|
||||
{BOARD_STAGES.map((stage) => {
|
||||
const laneCards = lanes[stage];
|
||||
return (
|
||||
<section
|
||||
key={stage}
|
||||
aria-label={`${STAGE_LABELS[stage]} lane`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
if (!dragging || dragging.fromStage === stage) return;
|
||||
setDropTargetStage(stage);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDropToStage(stage);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dropTargetStage === stage) {
|
||||
setDropTargetStage(null);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-[320px] self-start rounded-xl border border-border/70 bg-muted/30 shadow-[0_10px_24px_-20px_rgba(0,0,0,0.8)] transition-colors",
|
||||
dropTargetStage === stage &&
|
||||
"border-sky-400/70 bg-sky-500/15",
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={
|
||||
"flex items-center justify-between border-b border-border/60 px-3 py-2.5"
|
||||
}
|
||||
>
|
||||
<h2 className="text-xs font-semibold tracking-[0.03em] text-foreground/90 uppercase">
|
||||
{STAGE_LABELS[stage]}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="tabular-nums border-border/50 bg-transparent text-foreground/70"
|
||||
>
|
||||
{laneCards.length}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<div className="max-h-[calc(100vh-15rem)] space-y-2 overflow-y-auto p-2.5">
|
||||
{laneCards.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/35 bg-background/20 px-2.5 py-2 text-[11px] text-muted-foreground/80">
|
||||
Drop a card here or log a stage.
|
||||
</div>
|
||||
) : (
|
||||
laneCards.map(({ job, latestEventAt, stage }) => (
|
||||
<Link
|
||||
key={job.id}
|
||||
to={`/job/${job.id}`}
|
||||
draggable={movingJobId !== job.id}
|
||||
onDragStart={(event) => {
|
||||
setDragging({ jobId: job.id, fromStage: stage });
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragging(null);
|
||||
setDropTargetStage(null);
|
||||
}}
|
||||
className={cn(
|
||||
"block rounded-lg border border-border/60 bg-background/95 p-3 shadow-[0_8px_20px_-18px_rgba(0,0,0,1)] transition-colors",
|
||||
"hover:border-border hover:bg-background hover:shadow-[0_12px_24px_-16px_rgba(0,0,0,1)]",
|
||||
getCardLeftAccentClass(stage),
|
||||
movingJobId === job.id && "opacity-70",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="line-clamp-2 text-sm font-semibold leading-snug text-foreground">
|
||||
{job.title}
|
||||
</div>
|
||||
<ExternalLink className="mt-0.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/90">
|
||||
{job.employer}
|
||||
</div>
|
||||
{stage === "closed" && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/60 bg-muted/30 text-foreground/80"
|
||||
>
|
||||
Closed
|
||||
</Badge>
|
||||
{job.outcome ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize"
|
||||
>
|
||||
{job.outcome.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-[11px] text-muted-foreground/70">
|
||||
{latestEventAt != null
|
||||
? `Updated ${formatTimestamp(latestEventAt)}`
|
||||
: "No stage events yet"}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -75,6 +75,10 @@ export const JobPage: React.FC = () => {
|
||||
eventId?: string,
|
||||
) => {
|
||||
if (!job) return;
|
||||
if (job.status !== "in_progress") {
|
||||
toast.error("Move this job to In Progress to track stages.");
|
||||
return;
|
||||
}
|
||||
|
||||
let toStage: ApplicationStage | "no_change" = values.stage as
|
||||
| ApplicationStage
|
||||
@ -89,8 +93,7 @@ export const JobPage: React.FC = () => {
|
||||
outcome = "withdrawn";
|
||||
}
|
||||
|
||||
const currentStage =
|
||||
events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null);
|
||||
const currentStage = events.at(-1)?.toStage ?? "applied";
|
||||
const effectiveStage =
|
||||
toStage === "no_change" ? (currentStage ?? "applied") : toStage;
|
||||
|
||||
@ -181,8 +184,12 @@ export const JobPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const currentStage = job
|
||||
? (events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null))
|
||||
? (events.at(-1)?.toStage ??
|
||||
(job.status === "applied" || job.status === "in_progress"
|
||||
? "applied"
|
||||
: null))
|
||||
: null;
|
||||
const canTrackStages = job?.status === "in_progress";
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
@ -200,7 +207,7 @@ export const JobPage: React.FC = () => {
|
||||
size="sm"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setIsLogModalOpen(true)}
|
||||
disabled={!job}
|
||||
disabled={!job || !canTrackStages}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Log Event
|
||||
@ -250,10 +257,15 @@ export const JobPage: React.FC = () => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!canTrackStages && (
|
||||
<div className="mb-4 rounded-md border border-dashed border-border/60 p-3 text-sm text-muted-foreground">
|
||||
Move this job to In Progress to track application stages.
|
||||
</div>
|
||||
)}
|
||||
<JobTimeline
|
||||
events={events}
|
||||
onEdit={handleEditEvent}
|
||||
onDelete={confirmDeleteEvent}
|
||||
onEdit={canTrackStages ? handleEditEvent : undefined}
|
||||
onDelete={canTrackStages ? confirmDeleteEvent : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -83,11 +83,15 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
// Effect to sync URL if it was invalid
|
||||
useEffect(() => {
|
||||
if (tab === "in_progress") {
|
||||
navigate("/applications/in-progress", { replace: true });
|
||||
return;
|
||||
}
|
||||
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
|
||||
if (tab && !validTabs.includes(tab as FilterTab)) {
|
||||
navigateWithContext("ready", null, true);
|
||||
}
|
||||
}, [tab, navigateWithContext]);
|
||||
}, [tab, navigate, navigateWithContext]);
|
||||
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
|
||||
|
||||
@ -238,7 +238,7 @@ describe("TrackingInboxPage", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getJobs).toHaveBeenCalledWith({
|
||||
statuses: ["applied"],
|
||||
statuses: ["applied", "in_progress"],
|
||||
view: "list",
|
||||
});
|
||||
});
|
||||
|
||||
@ -118,10 +118,14 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
setIsAppliedJobsLoading(true);
|
||||
try {
|
||||
const response = await api.getJobs({
|
||||
statuses: ["applied"],
|
||||
statuses: ["applied", "in_progress"],
|
||||
view: "list",
|
||||
});
|
||||
setAppliedJobs(response.jobs.filter((job) => job.status === "applied"));
|
||||
setAppliedJobs(
|
||||
response.jobs.filter(
|
||||
(job) => job.status === "applied" || job.status === "in_progress",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load jobs";
|
||||
|
||||
@ -261,6 +261,30 @@ describe("JobDetailPanel", () => {
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves an applied job to in progress from the action button", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
||||
|
||||
await renderJobDetailPanel({
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({ status: "applied" }),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated,
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /move to in progress/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updateJob).toHaveBeenCalledWith("job-1", {
|
||||
status: "in_progress",
|
||||
}),
|
||||
);
|
||||
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);
|
||||
|
||||
@ -250,6 +250,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToInProgress = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
||||
toast.success("Moved to in progress");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to move to in progress";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInfo = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
@ -280,6 +295,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
||||
: "#";
|
||||
const canApply = selectedJob?.status === "ready";
|
||||
const canMoveToInProgress = selectedJob?.status === "applied";
|
||||
const canProcess = selectedJob
|
||||
? ["discovered", "ready"].includes(selectedJob.status)
|
||||
: false;
|
||||
@ -407,6 +423,17 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canMoveToInProgress && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs bg-cyan-600/20 text-cyan-300 hover:bg-cyan-600/30 border border-cyan-500/30"
|
||||
onClick={handleMoveToInProgress}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Move to In Progress
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="More actions">
|
||||
|
||||
@ -41,6 +41,11 @@ export const statusTokens: Record<
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
badge: "border-cyan-500/30 bg-cyan-500/10 text-cyan-200",
|
||||
dot: "bg-cyan-400",
|
||||
},
|
||||
skipped: {
|
||||
label: "Skipped",
|
||||
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
|
||||
@ -24,7 +24,7 @@ export const useFilteredJobs = (
|
||||
sort: JobSort,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
let filtered = jobs;
|
||||
let filtered = jobs.filter((job) => job.status !== "in_progress");
|
||||
|
||||
if (activeTab === "ready") {
|
||||
filtered = filtered.filter((job) => job.status === "ready");
|
||||
@ -36,6 +36,10 @@ export const useFilteredJobs = (
|
||||
filtered = filtered.filter((job) => job.status === "applied");
|
||||
}
|
||||
|
||||
if (activeTab !== "all") {
|
||||
filtered = filtered.filter((job) => job.closedAt == null);
|
||||
}
|
||||
|
||||
if (sourceFilter !== "all") {
|
||||
filtered = filtered.filter((job) => job.source === sourceFilter);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ const initialStats: Record<JobStatus, number> = {
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
in_progress: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
@ -143,6 +143,8 @@ export const getJobCounts = (
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.closedAt != null) continue;
|
||||
if (job.status === "in_progress") continue;
|
||||
if (job.status === "ready") byTab.ready += 1;
|
||||
if (job.status === "applied") byTab.applied += 1;
|
||||
if (job.status === "discovered" || job.status === "processing")
|
||||
|
||||
@ -51,7 +51,7 @@ describe("DangerZoneSection", () => {
|
||||
<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />,
|
||||
);
|
||||
|
||||
const appliedButton = screen.getByRole("button", { name: /applied/i });
|
||||
const appliedButton = screen.getByRole("button", { name: /^applied\b/i });
|
||||
const clearButton = screen.getByRole("button", { name: /clear selected/i });
|
||||
|
||||
expect(clearButton).toBeEnabled();
|
||||
|
||||
@ -10,6 +10,7 @@ export const ALL_JOB_STATUSES: JobStatus[] = [
|
||||
"processing",
|
||||
"ready",
|
||||
"applied",
|
||||
"in_progress",
|
||||
"skipped",
|
||||
"expired",
|
||||
];
|
||||
@ -19,7 +20,8 @@ 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",
|
||||
applied: "Application sent",
|
||||
in_progress: "Application moved beyond applied stage",
|
||||
skipped: "User skipped this job",
|
||||
expired: "Deadline passed",
|
||||
};
|
||||
|
||||
@ -119,6 +119,7 @@ const updateJobSchema = z.object({
|
||||
"processing",
|
||||
"ready",
|
||||
"applied",
|
||||
"in_progress",
|
||||
"skipped",
|
||||
"expired",
|
||||
])
|
||||
@ -1004,7 +1005,7 @@ jobsRouter.delete("/status/:status", async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jobs/score/:threshold - Clear jobs with score below threshold (excluding applied)
|
||||
* DELETE /api/jobs/score/:threshold - Clear jobs with score below threshold (excluding post-apply statuses)
|
||||
*/
|
||||
jobsRouter.delete("/score/:threshold", async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@ -678,7 +678,7 @@ export const DEMO_BASE_STAGE_EVENTS: DemoDefaultStageEvent[] = [
|
||||
toStage: "hiring_manager_screen",
|
||||
title: "Hiring manager interview",
|
||||
occurredOffsetMinutes: 1320,
|
||||
metadata: { eventLabel: "Hiring Manager Screen", actor: "user" },
|
||||
metadata: { eventLabel: "Team Match", actor: "user" },
|
||||
},
|
||||
{
|
||||
id: "demo-event-offer-4",
|
||||
|
||||
@ -61,7 +61,7 @@ const migrations = [
|
||||
degree_required TEXT,
|
||||
starting TEXT,
|
||||
job_description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')),
|
||||
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')),
|
||||
outcome TEXT,
|
||||
closed_at INTEGER,
|
||||
suitability_score REAL,
|
||||
@ -285,6 +285,10 @@ const migrations = [
|
||||
`DROP TABLE IF EXISTS post_application_message_candidates`,
|
||||
`DROP TABLE IF EXISTS post_application_message_links`,
|
||||
|
||||
// Protect child tables (stage_events/tasks/interviews) during parent table rebuilds.
|
||||
// Without this, dropping/replacing `jobs` can cascade-delete historical stage data.
|
||||
`PRAGMA foreign_keys = OFF`,
|
||||
|
||||
// Ensure pipeline_runs status supports "cancelled" for existing databases.
|
||||
`CREATE TABLE IF NOT EXISTS pipeline_runs_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
@ -301,6 +305,93 @@ const migrations = [
|
||||
`DROP TABLE IF EXISTS pipeline_runs`,
|
||||
`ALTER TABLE pipeline_runs_new RENAME TO pipeline_runs`,
|
||||
|
||||
// Ensure jobs status supports "in_progress" for existing databases.
|
||||
`CREATE TABLE IF NOT EXISTS jobs_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL DEFAULT 'gradcracker',
|
||||
source_job_id TEXT,
|
||||
job_url_direct TEXT,
|
||||
date_posted TEXT,
|
||||
job_type TEXT,
|
||||
salary_source TEXT,
|
||||
salary_interval TEXT,
|
||||
salary_min_amount REAL,
|
||||
salary_max_amount REAL,
|
||||
salary_currency TEXT,
|
||||
is_remote INTEGER,
|
||||
job_level TEXT,
|
||||
job_function TEXT,
|
||||
listing_type TEXT,
|
||||
emails TEXT,
|
||||
company_industry TEXT,
|
||||
company_logo TEXT,
|
||||
company_url_direct TEXT,
|
||||
company_addresses TEXT,
|
||||
company_num_employees TEXT,
|
||||
company_revenue TEXT,
|
||||
company_description TEXT,
|
||||
skills TEXT,
|
||||
experience_range TEXT,
|
||||
company_rating REAL,
|
||||
company_reviews_count INTEGER,
|
||||
vacancy_count INTEGER,
|
||||
work_from_home_type TEXT,
|
||||
title TEXT NOT NULL,
|
||||
employer TEXT NOT NULL,
|
||||
employer_url TEXT,
|
||||
job_url TEXT NOT NULL UNIQUE,
|
||||
application_link TEXT,
|
||||
disciplines TEXT,
|
||||
deadline TEXT,
|
||||
salary TEXT,
|
||||
location TEXT,
|
||||
degree_required TEXT,
|
||||
starting TEXT,
|
||||
job_description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')),
|
||||
outcome TEXT,
|
||||
closed_at INTEGER,
|
||||
suitability_score REAL,
|
||||
suitability_reason TEXT,
|
||||
tailored_summary TEXT,
|
||||
tailored_headline TEXT,
|
||||
tailored_skills TEXT,
|
||||
selected_project_ids TEXT,
|
||||
pdf_path TEXT,
|
||||
sponsor_match_score REAL,
|
||||
sponsor_match_names TEXT,
|
||||
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
processed_at TEXT,
|
||||
applied_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`INSERT OR REPLACE INTO jobs_new (
|
||||
id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
|
||||
salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
|
||||
emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
|
||||
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
||||
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
||||
selected_project_ids, pdf_path, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||
applied_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
|
||||
salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
|
||||
emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
|
||||
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
||||
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
||||
selected_project_ids, pdf_path, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||
applied_at, created_at, updated_at
|
||||
FROM jobs`,
|
||||
`DROP TABLE IF EXISTS jobs`,
|
||||
`ALTER TABLE jobs_new RENAME TO jobs`,
|
||||
`PRAGMA foreign_keys = ON`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_status_discovered_at ON jobs(status, discovered_at)`,
|
||||
@ -326,6 +417,83 @@ const migrations = [
|
||||
FROM jobs
|
||||
WHERE applied_at IS NOT NULL
|
||||
AND id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'applied')`,
|
||||
|
||||
// Backfill: Create "Closed" events for legacy jobs already closed via outcome.
|
||||
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata, outcome)
|
||||
SELECT
|
||||
'backfill-closed-' || jobs.id,
|
||||
jobs.id,
|
||||
'Closed',
|
||||
(
|
||||
SELECT se.to_stage
|
||||
FROM stage_events se
|
||||
WHERE se.application_id = jobs.id
|
||||
ORDER BY se.occurred_at DESC, se.id DESC
|
||||
LIMIT 1
|
||||
),
|
||||
'closed',
|
||||
COALESCE(
|
||||
jobs.closed_at,
|
||||
CAST(strftime('%s', jobs.applied_at) AS INTEGER),
|
||||
CAST(strftime('%s', jobs.updated_at) AS INTEGER),
|
||||
CAST(strftime('%s', jobs.discovered_at) AS INTEGER),
|
||||
CAST(strftime('%s', 'now') AS INTEGER)
|
||||
),
|
||||
'{"eventLabel":"Closed","actor":"system"}',
|
||||
jobs.outcome
|
||||
FROM jobs
|
||||
WHERE jobs.outcome IS NOT NULL
|
||||
AND jobs.id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'closed')`,
|
||||
|
||||
// Backfill: Sync legacy workflow status from latest stage event.
|
||||
`UPDATE jobs
|
||||
SET
|
||||
status = 'in_progress',
|
||||
updated_at = datetime('now')
|
||||
WHERE status = 'applied'
|
||||
AND COALESCE((
|
||||
SELECT se.to_stage
|
||||
FROM stage_events se
|
||||
WHERE se.application_id = jobs.id
|
||||
ORDER BY se.occurred_at DESC, se.id DESC
|
||||
LIMIT 1
|
||||
), 'applied') IN (
|
||||
'recruiter_screen',
|
||||
'assessment',
|
||||
'hiring_manager_screen',
|
||||
'technical_interview',
|
||||
'onsite',
|
||||
'offer',
|
||||
'closed'
|
||||
)`,
|
||||
|
||||
// Backfill: Mark closed applications from latest stage event.
|
||||
`UPDATE jobs
|
||||
SET
|
||||
status = 'in_progress',
|
||||
closed_at = (
|
||||
SELECT se.occurred_at
|
||||
FROM stage_events se
|
||||
WHERE se.application_id = jobs.id
|
||||
ORDER BY se.occurred_at DESC, se.id DESC
|
||||
LIMIT 1
|
||||
),
|
||||
outcome = COALESCE((
|
||||
SELECT se.outcome
|
||||
FROM stage_events se
|
||||
WHERE se.application_id = jobs.id
|
||||
ORDER BY se.occurred_at DESC, se.id DESC
|
||||
LIMIT 1
|
||||
), outcome),
|
||||
updated_at = datetime('now')
|
||||
WHERE status IN ('applied', 'in_progress')
|
||||
AND COALESCE((
|
||||
SELECT se.to_stage
|
||||
FROM stage_events se
|
||||
WHERE se.application_id = jobs.id
|
||||
ORDER BY se.occurred_at DESC, se.id DESC
|
||||
LIMIT 1
|
||||
), 'applied') = 'closed'`,
|
||||
];
|
||||
|
||||
console.log("🔧 Running database migrations...");
|
||||
|
||||
@ -90,6 +90,7 @@ export const jobs = sqliteTable("jobs", {
|
||||
"processing",
|
||||
"ready",
|
||||
"applied",
|
||||
"in_progress",
|
||||
"skipped",
|
||||
"expired",
|
||||
],
|
||||
|
||||
@ -56,6 +56,8 @@ export async function getJobListItems(
|
||||
salary: jobs.salary,
|
||||
location: jobs.location,
|
||||
status: jobs.status,
|
||||
outcome: jobs.outcome,
|
||||
closedAt: jobs.closedAt,
|
||||
suitabilityScore: jobs.suitabilityScore,
|
||||
sponsorMatchScore: jobs.sponsorMatchScore,
|
||||
jobType: jobs.jobType,
|
||||
@ -294,6 +296,7 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
in_progress: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
@ -350,13 +353,17 @@ export async function deleteJobsByStatus(status: JobStatus): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete jobs with suitability score below threshold (excluding applied jobs).
|
||||
* Delete jobs with suitability score below threshold (excluding applied and in_progress jobs).
|
||||
*/
|
||||
export async function deleteJobsBelowScore(threshold: number): Promise<number> {
|
||||
const result = await db
|
||||
.delete(jobs)
|
||||
.where(
|
||||
and(lt(jobs.suitabilityScore, threshold), ne(jobs.status, "applied")),
|
||||
and(
|
||||
lt(jobs.suitabilityScore, threshold),
|
||||
ne(jobs.status, "applied"),
|
||||
ne(jobs.status, "in_progress"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
return result.changes;
|
||||
|
||||
@ -66,13 +66,13 @@ describe.sequential("Application Tracking Service", () => {
|
||||
expect(event2.fromStage).toBe("applied");
|
||||
expect(event2.toStage).toBe("recruiter_screen");
|
||||
|
||||
// Check Job Status (still applied for recruiter screen)
|
||||
// Check Job Status (moves to in_progress beyond applied stage)
|
||||
const jobAfter2 = await db
|
||||
.select()
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, job.id))
|
||||
.get();
|
||||
expect(jobAfter2?.status).toBe("applied");
|
||||
expect(jobAfter2?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("updates stage event and reflects in job status if latest", async () => {
|
||||
@ -105,7 +105,7 @@ describe.sequential("Application Tracking Service", () => {
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, job.id))
|
||||
.get();
|
||||
expect(jobUpdated?.status).toBe("applied"); // 'offer' maps to 'applied' in status (active)
|
||||
expect(jobUpdated?.status).toBe("in_progress");
|
||||
expect(jobUpdated?.outcome).toBe("offer_accepted");
|
||||
});
|
||||
|
||||
@ -135,7 +135,7 @@ describe.sequential("Application Tracking Service", () => {
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, job.id))
|
||||
.get();
|
||||
expect(jobCheck?.status).toBe("applied");
|
||||
expect(jobCheck?.status).toBe("in_progress");
|
||||
expect(jobCheck?.outcome).toBe("rejected");
|
||||
|
||||
// Delete event2
|
||||
@ -235,6 +235,28 @@ describe.sequential("Application Tracking Service", () => {
|
||||
expect(jobCheck?.closedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("sets closedAt when a closed stage event is logged without outcome", async () => {
|
||||
const job = await jobsRepo.createJob({
|
||||
source: "manual",
|
||||
title: "Platform Engineer",
|
||||
employer: "Infra Co",
|
||||
jobUrl: "https://example.com/job/7",
|
||||
});
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
applicationTracking.transitionStage(job.id, "applied", now - 100);
|
||||
applicationTracking.transitionStage(job.id, "closed", now);
|
||||
|
||||
const jobCheck = await db
|
||||
.select()
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, job.id))
|
||||
.get();
|
||||
expect(jobCheck?.status).toBe("in_progress");
|
||||
expect(jobCheck?.outcome).toBeNull();
|
||||
expect(jobCheck?.closedAt).toBe(now);
|
||||
});
|
||||
|
||||
it("preserves explicit outcome when updating metadata", async () => {
|
||||
const job = await jobsRepo.createJob({
|
||||
source: "manual",
|
||||
|
||||
@ -17,13 +17,13 @@ const { jobs, stageEvents, tasks } = schema;
|
||||
|
||||
const STAGE_TO_STATUS: Record<ApplicationStage, JobStatus> = {
|
||||
applied: "applied",
|
||||
recruiter_screen: "applied",
|
||||
assessment: "applied",
|
||||
hiring_manager_screen: "applied",
|
||||
technical_interview: "applied",
|
||||
onsite: "applied",
|
||||
offer: "applied",
|
||||
closed: "applied",
|
||||
recruiter_screen: "in_progress",
|
||||
assessment: "in_progress",
|
||||
hiring_manager_screen: "in_progress",
|
||||
technical_interview: "in_progress",
|
||||
onsite: "in_progress",
|
||||
offer: "in_progress",
|
||||
closed: "in_progress",
|
||||
};
|
||||
|
||||
export const stageEventMetadataSchema = z
|
||||
@ -151,6 +151,10 @@ export function transitionStage(
|
||||
if (finalToStage === "applied" && !job.appliedAt) {
|
||||
updates.appliedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
if (finalToStage === "closed") {
|
||||
updates.closedAt = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome) {
|
||||
@ -242,13 +246,14 @@ export function updateStageEvent(
|
||||
storedOutcome ??
|
||||
inferredOutcome ??
|
||||
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
||||
const closedAt = outcome
|
||||
? storedOutcome || inferredOutcome
|
||||
const closedAt =
|
||||
lastStage === "closed"
|
||||
? lastEvent.occurredAt
|
||||
: (job.closedAt ?? null)
|
||||
: closingStage
|
||||
? (job.closedAt ?? null)
|
||||
: null;
|
||||
: outcome
|
||||
? storedOutcome || inferredOutcome
|
||||
? lastEvent.occurredAt
|
||||
: (job.closedAt ?? null)
|
||||
: null;
|
||||
|
||||
tx.update(jobs)
|
||||
.set({
|
||||
@ -300,13 +305,14 @@ export function deleteStageEvent(eventId: string): void {
|
||||
storedOutcome ??
|
||||
inferredOutcome ??
|
||||
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
||||
const closedAt = outcome
|
||||
? storedOutcome || inferredOutcome
|
||||
const closedAt =
|
||||
lastStage === "closed"
|
||||
? lastEvent.occurredAt
|
||||
: (job.closedAt ?? null)
|
||||
: closingStage
|
||||
? (job.closedAt ?? null)
|
||||
: null;
|
||||
: outcome
|
||||
? storedOutcome || inferredOutcome
|
||||
? lastEvent.occurredAt
|
||||
: (job.closedAt ?? null)
|
||||
: null;
|
||||
|
||||
tx.update(jobs)
|
||||
.set({
|
||||
|
||||
@ -822,7 +822,11 @@ export async function runGmailIngestionSync(args: {
|
||||
searchDays,
|
||||
maxMessages,
|
||||
);
|
||||
const activeJobs = await getAllJobs(["applied", "processing"]);
|
||||
const activeJobs = await getAllJobs([
|
||||
"applied",
|
||||
"in_progress",
|
||||
"processing",
|
||||
]);
|
||||
const activeJobMinified = minifyActiveJobs(activeJobs);
|
||||
const activeJobIds = new Set(activeJobMinified.map((job) => job.id));
|
||||
const concurrency = Math.max(
|
||||
|
||||
@ -6,7 +6,8 @@ export type JobStatus =
|
||||
| "discovered" // Crawled but not processed
|
||||
| "processing" // Currently generating resume
|
||||
| "ready" // PDF generated, waiting for user to apply
|
||||
| "applied" // User marked as applied
|
||||
| "applied" // Application sent
|
||||
| "in_progress" // In process beyond initial application
|
||||
| "skipped" // User skipped this job
|
||||
| "expired"; // Deadline passed
|
||||
|
||||
@ -27,7 +28,7 @@ export const STAGE_LABELS: Record<ApplicationStage, string> = {
|
||||
applied: "Applied",
|
||||
recruiter_screen: "Recruiter Screen",
|
||||
assessment: "Assessment",
|
||||
hiring_manager_screen: "Hiring Manager Screen",
|
||||
hiring_manager_screen: "Team Match",
|
||||
technical_interview: "Technical Interview",
|
||||
onsite: "Final Round",
|
||||
offer: "Offer",
|
||||
@ -210,6 +211,8 @@ export type JobListItem = Pick<
|
||||
| "salary"
|
||||
| "location"
|
||||
| "status"
|
||||
| "outcome"
|
||||
| "closedAt"
|
||||
| "suitabilityScore"
|
||||
| "sponsorMatchScore"
|
||||
| "jobType"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user