feat: add undo toast for skip, apply, and status moves

Accidental skips and status changes can be reversed within a few seconds via toast Undo, including bulk actions and keyboard shortcuts.
This commit is contained in:
ilia 2026-07-15 12:55:47 -04:00
parent 6a5c9e8cc4
commit d3d9130bbf
16 changed files with 542 additions and 41 deletions

View File

@ -645,6 +645,15 @@ export async function updateJob(
});
}
export async function restoreJobStatuses(
restores: Array<{ jobId: string; status: Job["status"] }>,
): Promise<{ restored: number; jobs: Job[] }> {
return fetchApi<{ restored: number; jobs: Job[] }>("/jobs/restore-status", {
method: "POST",
body: JSON.stringify({ restores }),
});
}
export async function getTracerAnalytics(options?: {
jobId?: string;
from?: number;

View File

@ -25,6 +25,7 @@ import {
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toastWithUndo } from "@/client/lib/jobActionUndo";
import { Button, buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
@ -135,8 +136,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const handleUndoApplied = useCallback(
async (jobId: string) => {
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
await api.restoreJobStatuses([{ jobId, status: "ready" }]);
trackProductEvent("jobs_job_action_completed", {
action: "move_to_ready",
result: "success",
@ -199,9 +199,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
description: `${job.title} at ${job.employer}`,
action: {
label: "Undo",
onClick: () => handleUndoApplied(job.id),
onClick: () => void handleUndoApplied(job.id),
},
duration: 6000,
duration: 8000,
});
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
@ -254,14 +254,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
if (!job) return;
try {
await skipJobMutation.mutateAsync(job.id);
const { restores } = await skipJobMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: job.status,
to_status: "skipped",
});
toast.message("Job skipped");
toastWithUndo({
title: "Job skipped",
restores,
onRestored: onJobUpdated,
successMessage: "Skip undone",
});
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {

View File

@ -6,6 +6,7 @@ import type { Job } from "@shared/types.js";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { trackProductEvent } from "@/lib/analytics";
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
import { DecideMode } from "./DecideMode";
@ -63,14 +64,19 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
if (!job) return;
try {
setIsSkipping(true);
await skipJobMutation.mutateAsync(job.id);
const { restores } = await skipJobMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: job.status,
to_status: "skipped",
});
toast.message("Job skipped");
toastWithUndo({
title: "Job skipped",
restores,
onRestored: onJobUpdated,
successMessage: "Skip undone",
});
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
@ -100,8 +106,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
to_status: "ready",
});
toast.success("Job moved to Ready", {
toastMoveToReadyUndo({
jobId: job.id,
description: "Your tailored PDF has been generated.",
onRestored: onJobUpdated,
});
onJobMoved(job.id);

View File

@ -46,7 +46,28 @@ export function useMarkAsAppliedMutation() {
export function useSkipJobMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.skipJob(id),
mutationFn: async (id: string) => {
const response = await api.runJobAction({
action: "skip",
jobIds: [id],
});
const result = response.results.find((entry) => entry.jobId === id);
if (!result?.ok) {
throw new Error(
result && !result.ok
? result.error.message
: "Job action did not return a result for the job",
);
}
const restores: Array<{ jobId: string; status: Job["status"] }> = [
{
jobId: id,
status: result.undo?.previousStatus ?? "discovered",
},
...(result.undo?.alsoSkipped ?? []),
];
return { job: result.job, restores };
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
const previousJob = queryClient.getQueryData<Job>(

View File

@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
message: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@client/api", () => ({
restoreJobStatuses: vi.fn(async () => ({ restored: 1, jobs: [] })),
}));
import * as api from "@client/api";
import { toast } from "sonner";
import {
restoresFromSkipResults,
toastWithUndo,
} from "./jobActionUndo";
describe("restoresFromSkipResults", () => {
it("includes primary and also-skipped restores", () => {
const restores = restoresFromSkipResults([
{
jobId: "a",
ok: true,
job: { id: "a" } as never,
undo: {
previousStatus: "ready",
alsoSkipped: [{ jobId: "b", status: "discovered" }],
},
},
{ jobId: "c", ok: false, error: { code: "X", message: "nope" } },
]);
expect(restores).toEqual([
{ jobId: "a", status: "ready" },
{ jobId: "b", status: "discovered" },
]);
});
});
describe("toastWithUndo", () => {
it("wires Undo action to restoreJobStatuses", async () => {
toastWithUndo({
title: "Job skipped",
restores: [{ jobId: "j1", status: "discovered" }],
});
expect(toast.message).toHaveBeenCalled();
const call = vi.mocked(toast.message).mock.calls[0];
const opts = call?.[1] as {
action?: { onClick: () => void };
};
expect(opts?.action?.label ?? opts?.action).toBeTruthy();
opts?.action?.onClick();
await vi.waitFor(() => {
expect(api.restoreJobStatuses).toHaveBeenCalledWith([
{ jobId: "j1", status: "discovered" },
]);
});
});
});

View File

@ -0,0 +1,146 @@
import * as api from "@client/api";
import type { JobActionResult, JobStatus, JobStatusRestore } from "@shared/types";
import { toast } from "sonner";
const DEFAULT_UNDO_MS = 8_000;
export type StatusRestore = JobStatusRestore;
/** Collect restore snapshots from skip action results (includes auto-skipped dupes). */
export function restoresFromSkipResults(
results: JobActionResult[],
): StatusRestore[] {
const byId = new Map<string, StatusRestore>();
for (const result of results) {
if (!result.ok || !result.undo) continue;
byId.set(result.jobId, {
jobId: result.jobId,
status: result.undo.previousStatus,
});
for (const also of result.undo.alsoSkipped ?? []) {
if (!byId.has(also.jobId)) {
byId.set(also.jobId, also);
}
}
}
return Array.from(byId.values());
}
export async function restoreJobStatuses(
restores: StatusRestore[],
): Promise<void> {
if (restores.length === 0) return;
await api.restoreJobStatuses(restores);
}
/**
* Show a success/message toast with an Undo action that restores prior statuses.
*/
export function toastWithUndo(input: {
title: string;
description?: string;
restores: StatusRestore[];
variant?: "success" | "message";
durationMs?: number;
onRestored?: () => void | Promise<void>;
successMessage?: string;
}): void {
const {
title,
description,
restores,
variant = "message",
durationMs = DEFAULT_UNDO_MS,
onRestored,
successMessage = restores.length === 1
? "Undone"
: `Restored ${restores.length} jobs`,
} = input;
if (restores.length === 0) {
if (variant === "success") {
toast.success(title, { description, duration: durationMs });
} else {
toast.message(title, { description, duration: durationMs });
}
return;
}
const action = {
label: "Undo",
onClick: () => {
void (async () => {
try {
await restoreJobStatuses(restores);
toast.success(successMessage);
await onRestored?.();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
})();
},
};
if (variant === "success") {
toast.success(title, { description, action, duration: durationMs });
} else {
toast.message(title, { description, action, duration: durationMs });
}
}
/** Convenience for a single skip with known previous status (when action result undo is unavailable). */
export function toastSkipUndo(input: {
jobId: string;
previousStatus: JobStatus;
title?: string;
description?: string;
alsoSkipped?: StatusRestore[];
onRestored?: () => void | Promise<void>;
}): void {
const restores: StatusRestore[] = [
{ jobId: input.jobId, status: input.previousStatus },
...(input.alsoSkipped ?? []),
];
toastWithUndo({
title: input.title ?? "Job skipped",
description: input.description,
restores,
variant: "message",
onRestored: input.onRestored,
successMessage: "Skip undone",
});
}
export function toastAppliedUndo(input: {
jobId: string;
title?: string;
description?: string;
onRestored?: () => void | Promise<void>;
}): void {
toastWithUndo({
title: input.title ?? "Marked as applied",
description: input.description,
restores: [{ jobId: input.jobId, status: "ready" }],
variant: "success",
onRestored: input.onRestored,
successMessage: "Reverted to Ready",
});
}
export function toastMoveToReadyUndo(input: {
jobId: string;
title?: string;
description?: string;
onRestored?: () => void | Promise<void>;
}): void {
toastWithUndo({
title: input.title ?? "Job moved to Ready",
description: input.description,
restores: [{ jobId: input.jobId, status: "discovered" }],
variant: "success",
onRestored: input.onRestored,
successMessage: "Moved back to Discovered",
});
}

View File

@ -27,6 +27,7 @@ import {
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
import {
useCheckSponsorMutation,
@ -257,7 +258,11 @@ export const JobPage: React.FC = () => {
await runAction("mark-applied", async () => {
if (!job) return;
await markAsAppliedMutation.mutateAsync(job.id);
toast.success("Marked as applied");
toastAppliedUndo({
jobId: job.id,
description: `${job.title} at ${job.employer}`,
onRestored: loadData,
});
});
};
@ -268,15 +273,26 @@ export const JobPage: React.FC = () => {
id: job.id,
update: { status: "in_progress" },
});
toast.success("Moved to in progress");
toastWithUndo({
title: "Moved to in progress",
restores: [{ jobId: job.id, status: "applied" }],
variant: "success",
onRestored: loadData,
successMessage: "Reverted to Applied",
});
});
};
const handleSkip = async () => {
await runAction("skip", async () => {
if (!job) return;
await skipJobMutation.mutateAsync(job.id);
toast.message("Job skipped");
const { restores } = await skipJobMutation.mutateAsync(job.id);
toastWithUndo({
title: "Job skipped",
restores,
onRestored: loadData,
successMessage: "Skip undone",
});
});
};

View File

@ -34,6 +34,24 @@ vi.mock("../api", () => ({
}),
getProfile: vi.fn().mockResolvedValue({ personName: "Test User" }),
skipJob: vi.fn().mockResolvedValue({}),
runJobAction: vi.fn().mockImplementation(async (input: { action: string; jobIds: string[] }) => {
const jobId = input.jobIds[0] ?? "job-1";
return {
action: input.action,
requested: 1,
succeeded: 1,
failed: 0,
results: [
{
jobId,
ok: true,
job: { id: jobId, status: "skipped" },
undo: { previousStatus: "ready", alsoSkipped: [] },
},
],
};
}),
restoreJobStatuses: vi.fn().mockResolvedValue({ restored: 1, jobs: [] }),
markAsApplied: vi.fn().mockResolvedValue({}),
processJob: vi.fn().mockResolvedValue({}),
}));
@ -1096,8 +1114,16 @@ describe("OrchestratorPage", () => {
pressKey("s");
await waitFor(() => {
expect(api.skipJob).toHaveBeenCalledWith("job-1");
expect(toast.message).toHaveBeenCalledWith("Job skipped");
expect(api.runJobAction).toHaveBeenCalledWith({
action: "skip",
jobIds: ["job-1"],
});
expect(toast.message).toHaveBeenCalledWith(
"Job skipped",
expect.objectContaining({
action: expect.objectContaining({ label: "Undo" }),
}),
);
});
pressKey("a");
@ -1105,7 +1131,9 @@ describe("OrchestratorPage", () => {
expect(api.markAsApplied).toHaveBeenCalledWith("job-1");
expect(toast.success).toHaveBeenCalledWith(
"Marked as applied",
expect.anything(),
expect.objectContaining({
action: expect.objectContaining({ label: "Undo" }),
}),
);
});
@ -1127,6 +1155,12 @@ describe("OrchestratorPage", () => {
await waitFor(() => {
expect(toast.message).toHaveBeenCalledWith("Moving job to Ready...");
expect(api.processJob).toHaveBeenCalledWith("job-2");
expect(toast.success).toHaveBeenCalledWith(
"Job moved to Ready",
expect.objectContaining({
action: expect.objectContaining({ label: "Undo" }),
}),
);
});
});

View File

@ -33,6 +33,7 @@ import {
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
import { Button } from "@/components/ui/button";
@ -261,7 +262,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
from_status: selectedJob.status,
to_status: "applied",
});
toast.success("Marked as applied");
toastAppliedUndo({
jobId: selectedJob.id,
description: `${selectedJob.title} at ${selectedJob.employer}`,
onRestored: onJobUpdated,
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
@ -279,14 +284,19 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const handleSkip = async () => {
if (!selectedJob) return;
try {
await skipJobMutation.mutateAsync(selectedJob.id);
const { restores } = await skipJobMutation.mutateAsync(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: selectedJob.status,
to_status: "skipped",
});
toast.message("Job skipped");
toastWithUndo({
title: "Job skipped",
restores,
onRestored: onJobUpdated,
successMessage: "Skip undone",
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
@ -304,14 +314,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const handleMoveToInProgress = async () => {
if (!selectedJob) return;
try {
const fromStatus = selectedJob.status;
await api.updateJob(selectedJob.id, { status: "in_progress" });
trackProductEvent("jobs_job_action_completed", {
action: "move_in_progress",
result: "success",
from_status: selectedJob.status,
from_status: fromStatus,
to_status: "in_progress",
});
toast.success("Moved to in progress");
toastWithUndo({
title: "Moved to in progress",
restores: [{ jobId: selectedJob.id, status: fromStatus === "applied" ? "applied" : "applied" }],
variant: "success",
onRestored: onJobUpdated,
successMessage: "Reverted to Applied",
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {

View File

@ -7,6 +7,7 @@ import {
} from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { restoresFromSkipResults, toastWithUndo } from "@/client/lib/jobActionUndo";
import { trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants";
import { JobActionProgressToast } from "./JobActionProgressToast";
@ -250,7 +251,30 @@ export function useJobSelectionActions({
});
if (result.failed === 0) {
toast.success(`${result.succeeded} ${successLabel}`);
if (action === "skip") {
const restores = restoresFromSkipResults(result.results);
toastWithUndo({
title: `${result.succeeded} ${successLabel}`,
restores,
variant: "success",
onRestored: loadJobs,
successMessage: "Skip undone",
});
} else if (action === "move_to_ready") {
const restores = selectedAtStart.map((jobId) => ({
jobId,
status: "discovered" as const,
}));
toastWithUndo({
title: `${result.succeeded} ${successLabel}`,
restores,
variant: "success",
onRestored: loadJobs,
successMessage: "Moved back to Discovered",
});
} else {
toast.success(`${result.succeeded} ${successLabel}`);
}
} else {
toast.error(
`${result.succeeded} succeeded, ${result.failed} failed.`,

View File

@ -9,6 +9,7 @@ import { SHORTCUTS } from "@client/lib/shortcut-map";
import type { JobAction, JobListItem } from "@shared/types.js";
import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { toastAppliedUndo, toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { safeFilenamePart } from "@/lib/utils";
import type { FilterTab } from "./constants";
import { tabs } from "./constants";
@ -145,10 +146,18 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
if (!selectedJob) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
const previousStatus = selectedJob.status;
skipJobMutation
.mutateAsync(jobId)
.then(async () => {
toast.message("Job skipped");
.then(async ({ restores }) => {
toastWithUndo({
title: "Job skipped",
restores: restores.length
? restores
: [{ jobId, status: previousStatus }],
onRestored: loadJobs,
successMessage: "Skip undone",
});
selectNextAfterAction(jobId);
await loadJobs();
})
@ -171,8 +180,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
markAsAppliedMutation
.mutateAsync(jobId)
.then(async () => {
toast.success("Marked as applied", {
toastAppliedUndo({
jobId,
description: `${selectedJob.title} at ${selectedJob.employer}`,
onRestored: loadJobs,
});
selectNextAfterAction(jobId);
await loadJobs();
@ -205,8 +216,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
api
.processJob(jobId)
.then(async () => {
toast.success("Job moved to Ready", {
toastMoveToReadyUndo({
jobId,
description: "Your tailored PDF has been generated.",
onRestored: loadJobs,
});
selectNextAfterAction(jobId);
await loadJobs();

View File

@ -23,6 +23,7 @@ import {
getTasks,
stageEventMetadataSchema,
transitionStage,
unapplyJob,
updateStageEvent,
} from "@server/services/applicationTracking";
import {
@ -178,6 +179,7 @@ const updateJobSchema = z.object({
selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(),
tracerLinksEnabled: z.boolean().optional(),
appliedAt: z.string().nullable().optional(),
sponsorMatchScore: z.number().min(0).max(100).optional(),
sponsorMatchNames: z.string().optional(),
notes: z.string().max(10000).nullable().optional(),
@ -380,6 +382,7 @@ async function executeJobActionForJob(
});
}
const previousStatus = job.status;
const updated = await jobsRepo.updateJob(
jobId,
{ status: "skipped" },
@ -402,14 +405,25 @@ async function executeJobActionForJob(
updated.ownerProfileId,
updated.id,
);
if (alsoSkipped > 0) {
if (alsoSkipped.length > 0) {
logger.info("Auto-skipped duplicate open jobs", {
jobId: updated.id,
alsoSkipped,
alsoSkipped: alsoSkipped.length,
});
}
return { jobId, ok: true, job: updated };
return {
jobId,
ok: true,
job: updated,
undo: {
previousStatus,
alsoSkipped: alsoSkipped.map((entry) => ({
jobId: entry.id,
status: entry.previousStatus,
})),
},
};
}
if (action === "move_to_ready") {
@ -964,6 +978,87 @@ jobsRouter.post("/actions/stream", async (req: Request, res: Response) => {
}
});
const restoreStatusSchema = z.object({
restores: z
.array(
z.object({
jobId: z.string().min(1),
status: z.enum([
"discovered",
"processing",
"ready",
"applied",
"in_progress",
"skipped",
]),
}),
)
.min(1)
.max(2500),
});
/**
* POST /api/jobs/restore-status - Undo skip / apply / move by restoring prior statuses.
*/
jobsRouter.post("/restore-status", async (req: Request, res: Response) => {
try {
const parsed = restoreStatusSchema.safeParse(req.body);
if (!parsed.success) {
return fail(res, badRequest("Invalid restore payload", {
issues: parsed.error.issues,
}));
}
const restored: Job[] = [];
for (const entry of parsed.data.restores) {
const job = await jobsRepo.getJobById(entry.jobId);
if (!job) {
return fail(res, notFound(`Job not found: ${entry.jobId}`));
}
const from = job.status;
const to = entry.status;
const allowed =
(from === "skipped" && (to === "discovered" || to === "ready")) ||
(from === "applied" && to === "ready") ||
(from === "ready" && to === "discovered") ||
(from === "in_progress" && to === "applied") ||
(from === "processing" && to === "discovered") ||
from === to;
if (!allowed) {
return fail(
res,
badRequest(
`Cannot restore job ${entry.jobId} from "${from}" to "${to}"`,
{ jobId: entry.jobId, from, to },
),
);
}
if (from === "applied" && to === "ready") {
await unapplyJob(job.id);
}
const updated = await jobsRepo.updateJob(
job.id,
{
status: to,
...(to === "ready" || to === "discovered" ? { appliedAt: null } : {}),
},
job.ownerProfileId,
);
if (updated) restored.push(updated);
}
return ok(res, { restored: restored.length, jobs: restored });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === "1" || forceRaw === "true";
@ -1375,6 +1470,7 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
}
});
/**
* POST /api/jobs/:id/apply - Mark a job as applied
*/
@ -1433,10 +1529,10 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
updatedJob.ownerProfileId,
updatedJob.id,
);
if (alsoSkipped > 0) {
if (alsoSkipped.length > 0) {
logger.info("Auto-skipped duplicate open jobs after apply", {
jobId: updatedJob.id,
alsoSkipped,
alsoSkipped: alsoSkipped.length,
});
}

View File

@ -170,13 +170,14 @@ export async function skipOpenJobsWithMatchingDedupKeys(
},
ownerProfileId: string,
excludeJobId: string,
): Promise<number> {
): Promise<Array<{ id: string; previousStatus: JobStatus }>> {
const anchorKeys = collectJobDedupKeys(anchor);
if (anchorKeys.length === 0) return 0;
if (anchorKeys.length === 0) return [];
const rows = await db
.select({
id: jobs.id,
status: jobs.status,
employer: jobs.employer,
title: jobs.title,
jobDescription: jobs.jobDescription,
@ -190,7 +191,7 @@ export async function skipOpenJobsWithMatchingDedupKeys(
),
);
let skipped = 0;
const skipped: Array<{ id: string; previousStatus: JobStatus }> = [];
for (const row of rows) {
const rowKeys = collectJobDedupKeys({
employer: row.employer,
@ -198,12 +199,15 @@ export async function skipOpenJobsWithMatchingDedupKeys(
jobDescription: row.jobDescription,
});
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
const previousStatus = row.status as JobStatus;
const updated = await updateJob(
row.id,
{ status: "skipped" },
ownerProfileId,
);
if (updated) skipped += 1;
if (updated) {
skipped.push({ id: row.id, previousStatus });
}
}
return skipped;
}
@ -771,15 +775,26 @@ export async function updateJob(
): Promise<Job | null> {
const now = new Date().toISOString();
// Leaving applied / in_progress (e.g. undo mark-as-applied) clears appliedAt
// unless the caller sets it explicitly.
const leavingAppliedTrack =
input.status !== undefined &&
input.status !== "applied" &&
input.status !== "in_progress" &&
input.appliedAt === undefined;
await db
.update(jobs)
.set({
...input,
updatedAt: now,
...(input.status === "processing" ? { processedAt: now } : {}),
...(input.status === "applied" && !input.appliedAt
...(input.status === "applied" && input.appliedAt === undefined
? { appliedAt: now }
: {}),
...(leavingAppliedTrack || input.appliedAt === null
? { appliedAt: null }
: {}),
})
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));

View File

@ -310,11 +310,11 @@ export function deleteStageEvent(eventId: string): void {
.where(eq(jobs.id, event.applicationId))
.run();
} else {
// If no events left, maybe revert to discovered?
// For now just keep it as is or set to discovered if it was applied
// No stage events left — undo apply should land on Ready (PDF may exist),
// not Discovered.
tx.update(jobs)
.set({
status: "discovered",
status: "ready",
appliedAt: null,
outcome: null,
closedAt: null,
@ -326,6 +326,29 @@ export function deleteStageEvent(eventId: string): void {
});
}
/**
* Undo mark-as-applied: clear the Applied stage event(s) and return the job to Ready.
* If later stage events exist, keeps them and sets status from the latest remaining event.
*/
export async function unapplyJob(applicationId: string): Promise<void> {
const events = await getStageEvents(applicationId);
if (events.length === 0) {
return;
}
const appliedOnly = events.every((event) => event.toStage === "applied");
if (appliedOnly) {
for (const event of events) {
deleteStageEvent(event.id);
}
return;
}
// Mixed timeline: remove only the initial Applied event(s), keep later stages.
for (const event of events.filter((e) => e.toStage === "applied")) {
deleteStageEvent(event.id);
}
}
function parseMetadata(raw: unknown): StageEventMetadata | null {
if (!raw) return null;
if (typeof raw === "string") {

View File

@ -322,7 +322,8 @@ export interface UpdateJobInput {
selectedProjectIds?: string;
pdfPath?: string;
tracerLinksEnabled?: boolean;
appliedAt?: string;
/** Pass `null` to clear (e.g. undo mark-as-applied). */
appliedAt?: string | null;
coverLetter?: string | null;
sponsorMatchScore?: number;
sponsorMatchNames?: string;

View File

@ -66,11 +66,22 @@ export type JobActionRequest =
};
};
/** Snapshot used by client Undo toasts after skip / apply / move. */
export type JobStatusRestore = {
jobId: string;
status: Job["status"];
};
export type JobActionResult =
| {
jobId: string;
ok: true;
job: Job;
/** Present when the action can be reversed (e.g. skip). */
undo?: {
previousStatus: Job["status"];
alsoSkipped?: JobStatusRestore[];
};
}
| {
jobId: string;