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
28 changed files with 901 additions and 47 deletions
+9
View File
@@ -12,6 +12,7 @@ import { OnboardingGate } from "./components/OnboardingGate";
import { useDemoInfo } from "./hooks/useDemoInfo"; import { useDemoInfo } from "./hooks/useDemoInfo";
import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage"; import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { InProgressBoardPage } from "./pages/InProgressBoardPage";
import { JobPage } from "./pages/JobPage"; import { JobPage } from "./pages/JobPage";
import { OrchestratorPage } from "./pages/OrchestratorPage"; import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
@@ -28,6 +29,10 @@ const REDIRECTS: Array<{ from: string; to: string }> = [
{ from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" }, { from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" },
{ from: "/applied", to: "/jobs/applied" }, { from: "/applied", to: "/jobs/applied" },
{ from: "/applied/:jobId", to: "/jobs/applied/:jobId" }, { 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", to: "/jobs/all" },
{ from: "/all/:jobId", to: "/jobs/all/:jobId" }, { from: "/all/:jobId", to: "/jobs/all/:jobId" },
]; ];
@@ -83,6 +88,10 @@ export const App: React.FC = () => {
element={<GmailOauthCallbackPage />} element={<GmailOauthCallbackPage />}
/> />
<Route path="/job/:id" element={<JobPage />} /> <Route path="/job/:id" element={<JobPage />} />
<Route
path="/applications/in-progress"
element={<InProgressBoardPage />}
/>
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} /> <Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/tracking-inbox" element={<TrackingInboxPage />} /> <Route path="/tracking-inbox" element={<TrackingInboxPage />} />
+2 -1
View File
@@ -27,6 +27,7 @@ const statConfig: Array<{
{ key: "processing", label: "Processing", Icon: Loader2 }, { key: "processing", label: "Processing", Icon: Loader2 },
{ key: "ready", label: "Ready", Icon: Sparkles }, { key: "ready", label: "Ready", Icon: Sparkles },
{ key: "applied", label: "Applied", Icon: CheckCircle2 }, { key: "applied", label: "Applied", Icon: CheckCircle2 },
{ key: "in_progress", label: "In Progress", Icon: CheckCircle2 },
{ key: "skipped", label: "Skipped", Icon: XCircle }, { key: "skipped", label: "Skipped", Icon: XCircle },
{ key: "expired", label: "Expired", Icon: Clock }, { key: "expired", label: "Expired", Icon: Clock },
]; ];
@@ -42,7 +43,7 @@ export const Stats: React.FC<StatsProps> = ({ stats }) => {
</CardHeader> </CardHeader>
<CardContent> <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 }) => ( {statConfig.map(({ key, label, Icon }) => (
<Card key={key} className="bg-muted/20"> <Card key={key} className="bg-muted/20">
<CardContent className="p-4"> <CardContent className="p-4">
@@ -17,6 +17,7 @@ const statusLabels: Record<JobStatus, string> = {
processing: "Processing", processing: "Processing",
ready: "Ready", ready: "Ready",
applied: "Applied", applied: "Applied",
in_progress: "In Progress",
skipped: "Skipped", skipped: "Skipped",
expired: "Expired", expired: "Expired",
}; };
@@ -35,6 +36,10 @@ const statusStyles: Record<
variant: "outline", variant: "outline",
className: "text-emerald-400 border-emerald-500/30", className: "text-emerald-400 border-emerald-500/30",
}, },
in_progress: {
variant: "outline",
className: "text-cyan-400 border-cyan-500/30",
},
skipped: { variant: "destructive" }, skipped: { variant: "destructive" },
expired: { variant: "outline", className: "text-muted-foreground" }, 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 = { export type NavLink = {
to: string; to: string;
@@ -20,6 +27,12 @@ export const NAV_LINKS: NavLink[] = [
"/jobs/all", "/jobs/all",
], ],
}, },
{
to: "/applications/in-progress",
label: "In Progress",
icon: Columns3,
activePaths: ["/applications/in-progress"],
},
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox }, { to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield }, { to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/settings", label: "Settings", icon: Settings }, { to: "/settings", label: "Settings", icon: Settings },
+1 -1
View File
@@ -48,7 +48,7 @@ export const HomePage: React.FC = () => {
api api
.getJobs({ .getJobs({
statuses: ["applied"], statuses: ["applied", "in_progress"],
view: "list", view: "list",
}) })
.then(async (response) => { .then(async (response) => {
@@ -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");
});
});
});
@@ -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>
</>
);
};
+18 -6
View File
@@ -75,6 +75,10 @@ export const JobPage: React.FC = () => {
eventId?: string, eventId?: string,
) => { ) => {
if (!job) return; 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 let toStage: ApplicationStage | "no_change" = values.stage as
| ApplicationStage | ApplicationStage
@@ -89,8 +93,7 @@ export const JobPage: React.FC = () => {
outcome = "withdrawn"; outcome = "withdrawn";
} }
const currentStage = const currentStage = events.at(-1)?.toStage ?? "applied";
events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null);
const effectiveStage = const effectiveStage =
toStage === "no_change" ? (currentStage ?? "applied") : toStage; toStage === "no_change" ? (currentStage ?? "applied") : toStage;
@@ -181,8 +184,12 @@ export const JobPage: React.FC = () => {
}; };
const currentStage = job 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; : null;
const canTrackStages = job?.status === "in_progress";
if (!id) { if (!id) {
return null; return null;
@@ -200,7 +207,7 @@ export const JobPage: React.FC = () => {
size="sm" size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsLogModalOpen(true)} onClick={() => setIsLogModalOpen(true)}
disabled={!job} disabled={!job || !canTrackStages}
> >
<PlusCircle className="mr-2 h-4 w-4" /> <PlusCircle className="mr-2 h-4 w-4" />
Log Event Log Event
@@ -250,10 +257,15 @@ export const JobPage: React.FC = () => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <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 <JobTimeline
events={events} events={events}
onEdit={handleEditEvent} onEdit={canTrackStages ? handleEditEvent : undefined}
onDelete={confirmDeleteEvent} onDelete={canTrackStages ? confirmDeleteEvent : undefined}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -83,11 +83,15 @@ export const OrchestratorPage: React.FC = () => {
// Effect to sync URL if it was invalid // Effect to sync URL if it was invalid
useEffect(() => { useEffect(() => {
if (tab === "in_progress") {
navigate("/applications/in-progress", { replace: true });
return;
}
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"]; const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
if (tab && !validTabs.includes(tab as FilterTab)) { if (tab && !validTabs.includes(tab as FilterTab)) {
navigateWithContext("ready", null, true); navigateWithContext("ready", null, true);
} }
}, [tab, navigateWithContext]); }, [tab, navigate, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false); const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
@@ -238,7 +238,7 @@ describe("TrackingInboxPage", () => {
await waitFor(() => { await waitFor(() => {
expect(api.getJobs).toHaveBeenCalledWith({ expect(api.getJobs).toHaveBeenCalledWith({
statuses: ["applied"], statuses: ["applied", "in_progress"],
view: "list", view: "list",
}); });
}); });
@@ -118,10 +118,14 @@ export const TrackingInboxPage: React.FC = () => {
setIsAppliedJobsLoading(true); setIsAppliedJobsLoading(true);
try { try {
const response = await api.getJobs({ const response = await api.getJobs({
statuses: ["applied"], statuses: ["applied", "in_progress"],
view: "list", view: "list",
}); });
setAppliedJobs(response.jobs.filter((job) => job.status === "applied")); setAppliedJobs(
response.jobs.filter(
(job) => job.status === "applied" || job.status === "in_progress",
),
);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Failed to load jobs"; error instanceof Error ? error.message : "Failed to load jobs";
@@ -261,6 +261,30 @@ describe("JobDetailPanel", () => {
expect(onJobUpdated).toHaveBeenCalled(); 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 () => { it("skips a job from the menu", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined); const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.skipJob).mockResolvedValue(undefined as any); 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 () => { const handleCopyInfo = async () => {
if (!selectedJob) return; if (!selectedJob) return;
try { try {
@@ -280,6 +295,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}` ? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
: "#"; : "#";
const canApply = selectedJob?.status === "ready"; const canApply = selectedJob?.status === "ready";
const canMoveToInProgress = selectedJob?.status === "applied";
const canProcess = selectedJob const canProcess = selectedJob
? ["discovered", "ready"].includes(selectedJob.status) ? ["discovered", "ready"].includes(selectedJob.status)
: false; : false;
@@ -407,6 +423,17 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</Button> </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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" aria-label="More actions"> <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", badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
dot: "bg-emerald-400", 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: { skipped: {
label: "Skipped", label: "Skipped",
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200", badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
@@ -24,7 +24,7 @@ export const useFilteredJobs = (
sort: JobSort, sort: JobSort,
) => ) =>
useMemo(() => { useMemo(() => {
let filtered = jobs; let filtered = jobs.filter((job) => job.status !== "in_progress");
if (activeTab === "ready") { if (activeTab === "ready") {
filtered = filtered.filter((job) => job.status === "ready"); filtered = filtered.filter((job) => job.status === "ready");
@@ -36,6 +36,10 @@ export const useFilteredJobs = (
filtered = filtered.filter((job) => job.status === "applied"); filtered = filtered.filter((job) => job.status === "applied");
} }
if (activeTab !== "all") {
filtered = filtered.filter((job) => job.closedAt == null);
}
if (sourceFilter !== "all") { if (sourceFilter !== "all") {
filtered = filtered.filter((job) => job.source === sourceFilter); filtered = filtered.filter((job) => job.source === sourceFilter);
} }
@@ -8,6 +8,7 @@ const initialStats: Record<JobStatus, number> = {
processing: 0, processing: 0,
ready: 0, ready: 0,
applied: 0, applied: 0,
in_progress: 0,
skipped: 0, skipped: 0,
expired: 0, expired: 0,
}; };
@@ -143,6 +143,8 @@ export const getJobCounts = (
}; };
for (const job of jobs) { 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 === "ready") byTab.ready += 1;
if (job.status === "applied") byTab.applied += 1; if (job.status === "applied") byTab.applied += 1;
if (job.status === "discovered" || job.status === "processing") if (job.status === "discovered" || job.status === "processing")
@@ -51,7 +51,7 @@ describe("DangerZoneSection", () => {
<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />, <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 }); const clearButton = screen.getByRole("button", { name: /clear selected/i });
expect(clearButton).toBeEnabled(); expect(clearButton).toBeEnabled();
@@ -10,6 +10,7 @@ export const ALL_JOB_STATUSES: JobStatus[] = [
"processing", "processing",
"ready", "ready",
"applied", "applied",
"in_progress",
"skipped", "skipped",
"expired", "expired",
]; ];
@@ -19,7 +20,8 @@ export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: "Crawled but not processed", discovered: "Crawled but not processed",
processing: "Currently generating resume", processing: "Currently generating resume",
ready: "PDF generated, waiting for user to apply", 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", skipped: "User skipped this job",
expired: "Deadline passed", expired: "Deadline passed",
}; };
+2 -1
View File
@@ -119,6 +119,7 @@ const updateJobSchema = z.object({
"processing", "processing",
"ready", "ready",
"applied", "applied",
"in_progress",
"skipped", "skipped",
"expired", "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) => { jobsRouter.delete("/score/:threshold", async (req: Request, res: Response) => {
try { try {
@@ -678,7 +678,7 @@ export const DEMO_BASE_STAGE_EVENTS: DemoDefaultStageEvent[] = [
toStage: "hiring_manager_screen", toStage: "hiring_manager_screen",
title: "Hiring manager interview", title: "Hiring manager interview",
occurredOffsetMinutes: 1320, occurredOffsetMinutes: 1320,
metadata: { eventLabel: "Hiring Manager Screen", actor: "user" }, metadata: { eventLabel: "Team Match", actor: "user" },
}, },
{ {
id: "demo-event-offer-4", id: "demo-event-offer-4",
+169 -1
View File
@@ -61,7 +61,7 @@ const migrations = [
degree_required TEXT, degree_required TEXT,
starting TEXT, starting TEXT,
job_description 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, outcome TEXT,
closed_at INTEGER, closed_at INTEGER,
suitability_score REAL, suitability_score REAL,
@@ -285,6 +285,10 @@ const migrations = [
`DROP TABLE IF EXISTS post_application_message_candidates`, `DROP TABLE IF EXISTS post_application_message_candidates`,
`DROP TABLE IF EXISTS post_application_message_links`, `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. // Ensure pipeline_runs status supports "cancelled" for existing databases.
`CREATE TABLE IF NOT EXISTS pipeline_runs_new ( `CREATE TABLE IF NOT EXISTS pipeline_runs_new (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -301,6 +305,93 @@ const migrations = [
`DROP TABLE IF EXISTS pipeline_runs`, `DROP TABLE IF EXISTS pipeline_runs`,
`ALTER TABLE pipeline_runs_new RENAME TO 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_status ON jobs(status)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`, `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)`, `CREATE INDEX IF NOT EXISTS idx_jobs_status_discovered_at ON jobs(status, discovered_at)`,
@@ -326,6 +417,83 @@ const migrations = [
FROM jobs FROM jobs
WHERE applied_at IS NOT NULL WHERE applied_at IS NOT NULL
AND id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'applied')`, 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..."); console.log("🔧 Running database migrations...");
+1
View File
@@ -90,6 +90,7 @@ export const jobs = sqliteTable("jobs", {
"processing", "processing",
"ready", "ready",
"applied", "applied",
"in_progress",
"skipped", "skipped",
"expired", "expired",
], ],
+9 -2
View File
@@ -56,6 +56,8 @@ export async function getJobListItems(
salary: jobs.salary, salary: jobs.salary,
location: jobs.location, location: jobs.location,
status: jobs.status, status: jobs.status,
outcome: jobs.outcome,
closedAt: jobs.closedAt,
suitabilityScore: jobs.suitabilityScore, suitabilityScore: jobs.suitabilityScore,
sponsorMatchScore: jobs.sponsorMatchScore, sponsorMatchScore: jobs.sponsorMatchScore,
jobType: jobs.jobType, jobType: jobs.jobType,
@@ -294,6 +296,7 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
processing: 0, processing: 0,
ready: 0, ready: 0,
applied: 0, applied: 0,
in_progress: 0,
skipped: 0, skipped: 0,
expired: 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> { export async function deleteJobsBelowScore(threshold: number): Promise<number> {
const result = await db const result = await db
.delete(jobs) .delete(jobs)
.where( .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(); .run();
return result.changes; return result.changes;
@@ -66,13 +66,13 @@ describe.sequential("Application Tracking Service", () => {
expect(event2.fromStage).toBe("applied"); expect(event2.fromStage).toBe("applied");
expect(event2.toStage).toBe("recruiter_screen"); 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 const jobAfter2 = await db
.select() .select()
.from(schema.jobs) .from(schema.jobs)
.where(eq(schema.jobs.id, job.id)) .where(eq(schema.jobs.id, job.id))
.get(); .get();
expect(jobAfter2?.status).toBe("applied"); expect(jobAfter2?.status).toBe("in_progress");
}); });
it("updates stage event and reflects in job status if latest", async () => { it("updates stage event and reflects in job status if latest", async () => {
@@ -105,7 +105,7 @@ describe.sequential("Application Tracking Service", () => {
.from(schema.jobs) .from(schema.jobs)
.where(eq(schema.jobs.id, job.id)) .where(eq(schema.jobs.id, job.id))
.get(); .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"); expect(jobUpdated?.outcome).toBe("offer_accepted");
}); });
@@ -135,7 +135,7 @@ describe.sequential("Application Tracking Service", () => {
.from(schema.jobs) .from(schema.jobs)
.where(eq(schema.jobs.id, job.id)) .where(eq(schema.jobs.id, job.id))
.get(); .get();
expect(jobCheck?.status).toBe("applied"); expect(jobCheck?.status).toBe("in_progress");
expect(jobCheck?.outcome).toBe("rejected"); expect(jobCheck?.outcome).toBe("rejected");
// Delete event2 // Delete event2
@@ -235,6 +235,28 @@ describe.sequential("Application Tracking Service", () => {
expect(jobCheck?.closedAt).toBeNull(); 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 () => { it("preserves explicit outcome when updating metadata", async () => {
const job = await jobsRepo.createJob({ const job = await jobsRepo.createJob({
source: "manual", source: "manual",
@@ -17,13 +17,13 @@ const { jobs, stageEvents, tasks } = schema;
const STAGE_TO_STATUS: Record<ApplicationStage, JobStatus> = { const STAGE_TO_STATUS: Record<ApplicationStage, JobStatus> = {
applied: "applied", applied: "applied",
recruiter_screen: "applied", recruiter_screen: "in_progress",
assessment: "applied", assessment: "in_progress",
hiring_manager_screen: "applied", hiring_manager_screen: "in_progress",
technical_interview: "applied", technical_interview: "in_progress",
onsite: "applied", onsite: "in_progress",
offer: "applied", offer: "in_progress",
closed: "applied", closed: "in_progress",
}; };
export const stageEventMetadataSchema = z export const stageEventMetadataSchema = z
@@ -151,6 +151,10 @@ export function transitionStage(
if (finalToStage === "applied" && !job.appliedAt) { if (finalToStage === "applied" && !job.appliedAt) {
updates.appliedAt = new Date().toISOString(); updates.appliedAt = new Date().toISOString();
} }
if (finalToStage === "closed") {
updates.closedAt = timestamp;
}
} }
if (outcome) { if (outcome) {
@@ -242,12 +246,13 @@ export function updateStageEvent(
storedOutcome ?? storedOutcome ??
inferredOutcome ?? inferredOutcome ??
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null); (closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
const closedAt = outcome const closedAt =
lastStage === "closed"
? lastEvent.occurredAt
: outcome
? storedOutcome || inferredOutcome ? storedOutcome || inferredOutcome
? lastEvent.occurredAt ? lastEvent.occurredAt
: (job.closedAt ?? null) : (job.closedAt ?? null)
: closingStage
? (job.closedAt ?? null)
: null; : null;
tx.update(jobs) tx.update(jobs)
@@ -300,12 +305,13 @@ export function deleteStageEvent(eventId: string): void {
storedOutcome ?? storedOutcome ??
inferredOutcome ?? inferredOutcome ??
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null); (closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
const closedAt = outcome const closedAt =
lastStage === "closed"
? lastEvent.occurredAt
: outcome
? storedOutcome || inferredOutcome ? storedOutcome || inferredOutcome
? lastEvent.occurredAt ? lastEvent.occurredAt
: (job.closedAt ?? null) : (job.closedAt ?? null)
: closingStage
? (job.closedAt ?? null)
: null; : null;
tx.update(jobs) tx.update(jobs)
@@ -822,7 +822,11 @@ export async function runGmailIngestionSync(args: {
searchDays, searchDays,
maxMessages, maxMessages,
); );
const activeJobs = await getAllJobs(["applied", "processing"]); const activeJobs = await getAllJobs([
"applied",
"in_progress",
"processing",
]);
const activeJobMinified = minifyActiveJobs(activeJobs); const activeJobMinified = minifyActiveJobs(activeJobs);
const activeJobIds = new Set(activeJobMinified.map((job) => job.id)); const activeJobIds = new Set(activeJobMinified.map((job) => job.id));
const concurrency = Math.max( const concurrency = Math.max(
+5 -2
View File
@@ -6,7 +6,8 @@ export type JobStatus =
| "discovered" // Crawled but not processed | "discovered" // Crawled but not processed
| "processing" // Currently generating resume | "processing" // Currently generating resume
| "ready" // PDF generated, waiting for user to apply | "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 | "skipped" // User skipped this job
| "expired"; // Deadline passed | "expired"; // Deadline passed
@@ -27,7 +28,7 @@ export const STAGE_LABELS: Record<ApplicationStage, string> = {
applied: "Applied", applied: "Applied",
recruiter_screen: "Recruiter Screen", recruiter_screen: "Recruiter Screen",
assessment: "Assessment", assessment: "Assessment",
hiring_manager_screen: "Hiring Manager Screen", hiring_manager_screen: "Team Match",
technical_interview: "Technical Interview", technical_interview: "Technical Interview",
onsite: "Final Round", onsite: "Final Round",
offer: "Offer", offer: "Offer",
@@ -210,6 +211,8 @@ export type JobListItem = Pick<
| "salary" | "salary"
| "location" | "location"
| "status" | "status"
| "outcome"
| "closedAt"
| "suitabilityScore" | "suitabilityScore"
| "sponsorMatchScore" | "sponsorMatchScore"
| "jobType" | "jobType"