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:
Shaheer Sarfaraz 2026-02-15 00:45:45 +00:00 committed by GitHub
parent f114fb6200
commit f8b5dc2f42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 901 additions and 47 deletions

View File

@ -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 />} />

View File

@ -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">

View File

@ -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" },
};

View File

@ -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 },

View File

@ -48,7 +48,7 @@ export const HomePage: React.FC = () => {
api
.getJobs({
statuses: ["applied"],
statuses: ["applied", "in_progress"],
view: "list",
})
.then(async (response) => {

View 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");
});
});
});

View 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>
</>
);
};

View File

@ -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>

View File

@ -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);

View File

@ -238,7 +238,7 @@ describe("TrackingInboxPage", () => {
await waitFor(() => {
expect(api.getJobs).toHaveBeenCalledWith({
statuses: ["applied"],
statuses: ["applied", "in_progress"],
view: "list",
});
});

View File

@ -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";

View File

@ -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);

View File

@ -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">

View File

@ -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",

View File

@ -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);
}

View File

@ -8,6 +8,7 @@ const initialStats: Record<JobStatus, number> = {
processing: 0,
ready: 0,
applied: 0,
in_progress: 0,
skipped: 0,
expired: 0,
};

View File

@ -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")

View File

@ -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();

View File

@ -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",
};

View File

@ -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 {

View File

@ -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",

View File

@ -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...");

View File

@ -90,6 +90,7 @@ export const jobs = sqliteTable("jobs", {
"processing",
"ready",
"applied",
"in_progress",
"skipped",
"expired",
],

View File

@ -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;

View File

@ -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",

View File

@ -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({

View File

@ -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(

View File

@ -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"