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:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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...");
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
"processing",
|
"processing",
|
||||||
"ready",
|
"ready",
|
||||||
"applied",
|
"applied",
|
||||||
|
"in_progress",
|
||||||
"skipped",
|
"skipped",
|
||||||
"expired",
|
"expired",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user