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:
parent
6a5c9e8cc4
commit
d3d9130bbf
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>(
|
||||
|
||||
62
orchestrator/src/client/lib/jobActionUndo.test.ts
Normal file
62
orchestrator/src/client/lib/jobActionUndo.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
orchestrator/src/client/lib/jobActionUndo.ts
Normal file
146
orchestrator/src/client/lib/jobActionUndo.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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.`,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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)));
|
||||
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user