feat(workspace): unified application checklist (Phase 5 milestone 2)
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import ApplicationChecklist from "./components/ApplicationChecklist";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const item = (over: Partial<any> = {}) => ({
|
||||
id: 1,
|
||||
systemKey: "prepare-cv",
|
||||
title: "Prepare a CV for this role",
|
||||
description: "Attach a CV variant tailored to this application.",
|
||||
category: "preparation",
|
||||
status: "pending",
|
||||
section: "cv",
|
||||
sortOrder: 0,
|
||||
isSystemGenerated: true,
|
||||
isAutoCompleted: false,
|
||||
completedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const checklist = (items: any[]) => ({
|
||||
items,
|
||||
progress: {
|
||||
total: items.filter((i) => i.status !== "dismissed").length,
|
||||
completed: items.filter((i) => i.status === "done").length,
|
||||
dismissed: items.filter((i) => i.status === "dismissed").length,
|
||||
percent: 0,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: checklist([
|
||||
item(),
|
||||
item({ id: 2, systemKey: "create-cover-letter", title: "Create a cover letter", status: "done", isAutoCompleted: true }),
|
||||
item({ id: 3, systemKey: null, title: "Ask Sara for a referral", category: "custom", isSystemGenerated: false, description: null }),
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
test("renders the checklist grouped by category with progress", async () => {
|
||||
render(<ApplicationChecklist jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Prepare a CV for this role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Before applying")).toBeInTheDocument();
|
||||
expect(screen.getByText("Your own tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 of 3 done")).toBeInTheDocument();
|
||||
// Items already satisfied by an existing readiness signal are marked as detected, not hand-ticked.
|
||||
expect(screen.getByText("Detected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("completing an item patches its status", async () => {
|
||||
mockedApi.patch.mockResolvedValue({ data: item({ status: "done" }) } as any);
|
||||
|
||||
render(<ApplicationChecklist jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("checkbox", { name: "Prepare a CV for this role" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/7/checklist/1", { status: "done" }));
|
||||
});
|
||||
|
||||
test("un-ticking a completed item sends it back to pending", async () => {
|
||||
mockedApi.patch.mockResolvedValue({ data: item({ id: 2, status: "pending" }) } as any);
|
||||
|
||||
render(<ApplicationChecklist jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("checkbox", { name: "Create a cover letter" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/7/checklist/2", { status: "pending" }));
|
||||
});
|
||||
|
||||
test("adding a custom task posts the title", async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: item({ id: 9, systemKey: null, isSystemGenerated: false }) } as any);
|
||||
|
||||
render(<ApplicationChecklist jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText(/Add your own task/i), { target: { value: "Email the hiring manager" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/checklist",
|
||||
{ title: "Email the hiring manager", description: undefined, category: undefined },
|
||||
));
|
||||
});
|
||||
|
||||
test("reordering sends the new id order", async () => {
|
||||
mockedApi.put.mockResolvedValue({ data: checklist([]) } as any);
|
||||
|
||||
render(<ApplicationChecklist jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Move down: Prepare a CV for this role" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/checklist/order", [2, 1, 3]));
|
||||
});
|
||||
|
||||
test("removing an item calls delete", async () => {
|
||||
mockedApi.delete.mockResolvedValue({ data: undefined } as any);
|
||||
|
||||
render(<ApplicationChecklist jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove: Ask Sara for a referral" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/checklist/3"));
|
||||
});
|
||||
@@ -35,8 +35,38 @@ export type WorkspaceOverview = {
|
||||
lastAiAtUtc: string | null;
|
||||
recentActivity: WorkspaceActivity[];
|
||||
nextStep: WorkspaceNextStep | null;
|
||||
checklistProgress: ChecklistProgress | null;
|
||||
};
|
||||
|
||||
// Milestone 2 — the application checklist. One workflow surface: system items seed from the same
|
||||
// readiness signals the backend already computed, and the user owns everything after that.
|
||||
export type ChecklistStatus = "pending" | "done" | "dismissed";
|
||||
|
||||
export type ChecklistItem = {
|
||||
id: number;
|
||||
systemKey: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
status: ChecklistStatus;
|
||||
section: string | null;
|
||||
sortOrder: number;
|
||||
isSystemGenerated: boolean;
|
||||
isAutoCompleted: boolean;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
export type ChecklistProgress = { total: number; completed: number; dismissed: number; percent: number };
|
||||
export type Checklist = { items: ChecklistItem[]; progress: ChecklistProgress };
|
||||
|
||||
export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [
|
||||
{ key: "preparation", label: "Before applying" },
|
||||
{ key: "submission", label: "Submitting" },
|
||||
{ key: "follow-up", label: "Follow-up" },
|
||||
{ key: "interview", label: "Interview" },
|
||||
{ key: "custom", label: "Your own tasks" },
|
||||
];
|
||||
|
||||
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
|
||||
// so the workspace is always usable rather than a shell of placeholders.
|
||||
export type WorkspaceSectionKey =
|
||||
@@ -48,7 +78,7 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile
|
||||
{ key: "job-details", label: "Job Details" },
|
||||
{ key: "analysis", label: "Analysis" },
|
||||
{ key: "match", label: "Match" },
|
||||
{ key: "checklist", label: "Checklist", milestone: 2 },
|
||||
{ key: "checklist", label: "Checklist" },
|
||||
{ key: "cv", label: "CV", milestone: 6 },
|
||||
{ key: "cover-letter", label: "Cover Letter", milestone: 7 },
|
||||
{ key: "portfolio", label: "Portfolio", milestone: 8 },
|
||||
@@ -63,3 +93,16 @@ export const applicationWorkspaceApi = {
|
||||
overview: (jobId: number) =>
|
||||
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const applicationChecklistApi = {
|
||||
get: (jobId: number) =>
|
||||
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
|
||||
add: (jobId: number, title: string, description?: string, category?: string) =>
|
||||
api.post<ChecklistItem>(`/jobapplications/${jobId}/checklist`, { title, description, category }).then((r) => r.data),
|
||||
update: (jobId: number, itemId: number, patch: Partial<Pick<ChecklistItem, "title" | "description" | "category" | "status">>) =>
|
||||
api.patch<ChecklistItem>(`/jobapplications/${jobId}/checklist/${itemId}`, patch).then((r) => r.data),
|
||||
remove: (jobId: number, itemId: number) =>
|
||||
api.delete(`/jobapplications/${jobId}/checklist/${itemId}`).then(() => undefined),
|
||||
reorder: (jobId: number, orderedIds: number[]) =>
|
||||
api.put<Checklist>(`/jobapplications/${jobId}/checklist/order`, orderedIds).then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Checkbox, Chip, IconButton, LinearProgress, Paper, Skeleton, Stack,
|
||||
TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import {
|
||||
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi,
|
||||
} from "../applicationWorkspace";
|
||||
|
||||
// Phase 5 Milestone 2 — the application checklist.
|
||||
//
|
||||
// One workflow surface, not a new tracker: the system items arrive already ticked wherever the
|
||||
// existing readiness signals say the work is done (CV attached, cover letter written, follow-up
|
||||
// scheduled...). Everything here is the user's to tick, add to, reorder or dismiss.
|
||||
// docs/architecture/application-workspace.md.
|
||||
export default function ApplicationChecklist({ jobId, onChanged }: { jobId: number; onChanged?: () => void }) {
|
||||
const [checklist, setChecklist] = useState<Checklist | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setChecklist(await applicationChecklistApi.get(jobId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not load the checklist."));
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Every mutation re-reads: the backend re-syncs the auto-completed items on each read, so the
|
||||
// response is the only thing that knows the real state.
|
||||
const mutate = useCallback(async (run: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await run();
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not update the checklist."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [load, onChanged]);
|
||||
|
||||
const toggle = (item: ChecklistItem) =>
|
||||
mutate(() => applicationChecklistApi.update(jobId, item.id, {
|
||||
status: item.status === "done" ? "pending" : "done",
|
||||
}));
|
||||
|
||||
const remove = (item: ChecklistItem) => mutate(() => applicationChecklistApi.remove(jobId, item.id));
|
||||
|
||||
const move = (item: ChecklistItem, delta: -1 | 1) => {
|
||||
if (!checklist) return;
|
||||
const ids = checklist.items.map((i) => i.id);
|
||||
const from = ids.indexOf(item.id);
|
||||
const to = from + delta;
|
||||
if (to < 0 || to >= ids.length) return;
|
||||
[ids[from], ids[to]] = [ids[to], ids[from]];
|
||||
return mutate(() => applicationChecklistApi.reorder(jobId, ids));
|
||||
};
|
||||
|
||||
const add = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = draft.trim();
|
||||
if (!title) return;
|
||||
setDraft("");
|
||||
return mutate(() => applicationChecklistApi.add(jobId, title));
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const live = checklist?.items.filter((i) => i.status !== "dismissed") ?? [];
|
||||
return CHECKLIST_CATEGORIES
|
||||
.map((c) => ({ ...c, items: live.filter((i) => i.category === c.key) }))
|
||||
.filter((c) => c.items.length > 0);
|
||||
}, [checklist]);
|
||||
|
||||
if (!checklist && !error) {
|
||||
return <Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={52} />)}</Stack>;
|
||||
}
|
||||
|
||||
const progress = checklist?.progress;
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{progress && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Application checklist</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{progress.completed} of {progress.total} done
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percent}
|
||||
aria-label="Checklist completion"
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{grouped.map((group) => (
|
||||
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="overline" color="text.secondary">{group.label}</Typography>
|
||||
<Stack sx={{ mt: 0.5 }}>
|
||||
{group.items.map((item) => (
|
||||
<Stack
|
||||
key={item.id}
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
spacing={1}
|
||||
sx={{ py: 0.75, borderRadius: 2, "&:hover .checklist-actions": { opacity: 1 } }}
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={item.status === "done"}
|
||||
disabled={busy}
|
||||
onChange={() => toggle(item)}
|
||||
inputProps={{ "aria-label": item.title }}
|
||||
sx={{ mt: -0.25 }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textDecoration: item.status === "done" ? "line-through" : "none",
|
||||
color: item.status === "done" ? "text.disabled" : "text.primary",
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
{!item.isSystemGenerated && <Chip size="small" label="Yours" variant="outlined" />}
|
||||
{item.isAutoCompleted && <Chip size="small" label="Detected" color="success" variant="outlined" />}
|
||||
</Stack>
|
||||
{item.description && (
|
||||
<Typography variant="caption" color="text.secondary">{item.description}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
|
||||
<Tooltip title="Move up">
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy} aria-label={`Move up: ${item.title}`} onClick={() => move(item, -1)}>
|
||||
<ArrowUpwardIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down">
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy} aria-label={`Move down: ${item.title}`} onClick={() => move(item, 1)}>
|
||||
<ArrowDownwardIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={item.isSystemGenerated ? "Not relevant for this role" : "Delete"}>
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy} aria-label={`Remove: ${item.title}`} onClick={() => remove(item)}>
|
||||
<DeleteOutlineIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<Paper component="form" onSubmit={add} sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Add your own task"
|
||||
value={draft}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>Add</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import ChecklistIcon from "@mui/icons-material/Checklist";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import Attachments from "../components/Attachments";
|
||||
import Correspondence from "../components/Correspondence";
|
||||
import AiWorkspacePanel from "../components/AiWorkspacePanel";
|
||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import {
|
||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
|
||||
} from "../applicationWorkspace";
|
||||
@@ -101,7 +103,10 @@ export default function ApplicationWorkspacePage() {
|
||||
{section === "communication" && jobId > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><Correspondence jobId={jobId} job={null as any} /></Paper>
|
||||
)}
|
||||
{["checklist", "cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && (
|
||||
{section === "checklist" && jobId > 0 && (
|
||||
<ApplicationChecklist jobId={jobId} onChanged={load} />
|
||||
)}
|
||||
{["cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && (
|
||||
<ComingInMilestone section={section} />
|
||||
)}
|
||||
</Box>
|
||||
@@ -146,6 +151,7 @@ function OverviewSection({ overview, onGo, onReload }: {
|
||||
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
||||
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const },
|
||||
{ icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const },
|
||||
{ icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const },
|
||||
] : [], [overview]);
|
||||
|
||||
if (!overview) {
|
||||
@@ -170,7 +176,7 @@ function OverviewSection({ overview, onGo, onReload }: {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(4, 1fr)" }, gap: 1.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.5 }}>
|
||||
{stats.map((s) => (
|
||||
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
|
||||
onClick={() => onGo(s.go)}
|
||||
|
||||
Reference in New Issue
Block a user