From 2bb22dd1f286eb1dcf93ab6cb4fe85865747d1e7 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 3 Jan 2026 06:53:42 -0600 Subject: [PATCH] feat(worksheet-parsing): add cancel button for parsing in progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now immediately cancel a parsing operation instead of waiting for it to complete or fail. This prevents attachments from getting stuck in "processing" state with no way to recover. Changes: - Added DELETE endpoint to parse API route to cancel/reset parsing - Added useCancelParsing mutation hook with optimistic updates - Added cancel button (✕) to the "Analyzing..." badge in OfflineWorkSection - Cancel button resets the attachment to unparsed state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../attachments/[attachmentId]/parse/route.ts | 76 +++++++++++++++++++ .../[studentId]/summary/SummaryClient.tsx | 7 +- .../practice/OfflineWorkSection.tsx | 35 +++++++++ apps/web/src/hooks/useWorksheetParsing.ts | 66 ++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/parse/route.ts b/apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/parse/route.ts index 5e6da3b6..6e0ebc9d 100644 --- a/apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/parse/route.ts +++ b/apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/parse/route.ts @@ -384,3 +384,79 @@ export async function GET(_request: Request, { params }: RouteParams) { ); } } + +/** + * DELETE - Cancel/reset parsing status + * + * Allows user to cancel a stuck or in-progress parsing operation. + * Resets the parsing status to null so they can retry. + */ +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { playerId, attachmentId } = await params; + + if (!playerId || !attachmentId) { + return NextResponse.json( + { error: "Player ID and Attachment ID required" }, + { status: 400 }, + ); + } + + // Authorization check + const userId = await getDbUserId(); + const canModify = await canPerformAction(userId, playerId, "start-session"); + if (!canModify) { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + + // Get attachment to verify it exists and belongs to player + const attachment = await db + .select() + .from(practiceAttachments) + .where(eq(practiceAttachments.id, attachmentId)) + .get(); + + if (!attachment || attachment.playerId !== playerId) { + return NextResponse.json( + { error: "Attachment not found" }, + { status: 404 }, + ); + } + + // Reset parsing status + await db + .update(practiceAttachments) + .set({ + parsingStatus: null, + parsedAt: null, + parsingError: null, + rawParsingResult: null, + approvedResult: null, + confidenceScore: null, + needsReview: null, + // Clear LLM metadata + llmProvider: null, + llmModel: null, + llmPromptUsed: null, + llmRawResponse: null, + llmJsonSchema: null, + llmImageSource: null, + llmAttempts: null, + llmPromptTokens: null, + llmCompletionTokens: null, + llmTotalTokens: null, + }) + .where(eq(practiceAttachments.id, attachmentId)); + + return NextResponse.json({ + success: true, + message: "Parsing cancelled", + }); + } catch (error) { + console.error("Error cancelling parse:", error); + return NextResponse.json( + { error: "Failed to cancel parsing" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx b/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx index 7373d740..262675aa 100644 --- a/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx +++ b/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx @@ -47,6 +47,7 @@ import { useApproveAndCreateSession, useSubmitCorrections, useReparseSelected, + useCancelParsing, } from "@/hooks/useWorksheetParsing"; import { PARSING_MODEL_CONFIGS } from "@/lib/worksheet-parsing"; import { @@ -223,8 +224,9 @@ export function SummaryClient({ sessionCreated: att.sessionCreated, })); - // Worksheet parsing mutation + // Worksheet parsing mutations const startParsing = useStartParsing(studentId, session?.id ?? ""); + const cancelParsing = useCancelParsing(studentId, session?.id ?? ""); // Approve and create session mutation const approveAndCreateSession = useApproveAndCreateSession( @@ -766,6 +768,9 @@ export function SummaryClient({ onParse={(attachmentId) => startParsing.mutate({ attachmentId }) } + onCancelParsing={(attachmentId) => + cancelParsing.mutate(attachmentId) + } /> {/* All Problems - complete session listing */} {hasProblems && ( diff --git a/apps/web/src/components/practice/OfflineWorkSection.tsx b/apps/web/src/components/practice/OfflineWorkSection.tsx index 4981506e..8517934d 100644 --- a/apps/web/src/components/practice/OfflineWorkSection.tsx +++ b/apps/web/src/components/practice/OfflineWorkSection.tsx @@ -56,6 +56,8 @@ export interface OfflineWorkSectionProps { onDeletePhoto: (id: string) => void; /** Start parsing a worksheet photo */ onParse?: (id: string) => void; + /** Cancel parsing in progress */ + onCancelParsing?: (id: string) => void; } /** @@ -88,6 +90,7 @@ export function OfflineWorkSection({ onOpenViewer, onDeletePhoto, onParse, + onCancelParsing, }: OfflineWorkSectionProps) { const photoCount = attachments.length; // Show add tile unless we have 8+ photos (max reasonable gallery size) @@ -474,6 +477,38 @@ export function OfflineWorkSection({ : att.parsingStatus === "approved" ? `${att.rawParsingResult?.problems?.length ?? "?"} problems` : att.parsingStatus} + {/* Cancel button for processing state */} + {att.parsingStatus === "processing" && onCancelParsing && ( + + )} )} diff --git a/apps/web/src/hooks/useWorksheetParsing.ts b/apps/web/src/hooks/useWorksheetParsing.ts index 3bb2cf9a..5824af34 100644 --- a/apps/web/src/hooks/useWorksheetParsing.ts +++ b/apps/web/src/hooks/useWorksheetParsing.ts @@ -462,6 +462,72 @@ export function useReparseSelected(playerId: string, sessionId: string) { }); } +/** + * Hook to cancel/reset parsing status + */ +export function useCancelParsing(playerId: string, sessionId: string) { + const queryClient = useQueryClient(); + const queryKey = attachmentKeys.session(playerId, sessionId); + + return useMutation({ + mutationFn: async (attachmentId: string) => { + const res = await api( + `curriculum/${playerId}/attachments/${attachmentId}/parse`, + { + method: "DELETE", + }, + ); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || "Failed to cancel parsing"); + } + return res.json(); + }, + + onMutate: async (attachmentId) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey }); + + // Snapshot current state + const previous = queryClient.getQueryData(queryKey); + + // Optimistic update: reset to unparsed + if (previous) { + queryClient.setQueryData(queryKey, { + ...previous, + attachments: previous.attachments.map((a) => + a.id === attachmentId + ? { + ...a, + parsingStatus: null, + parsedAt: null, + parsingError: null, + rawParsingResult: null, + confidenceScore: null, + needsReview: false, + } + : a, + ), + }); + } + + return { previous }; + }, + + onError: (_err, _attachmentId, context) => { + // Revert on error + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + + onSettled: () => { + // Always refetch to ensure consistency + queryClient.invalidateQueries({ queryKey }); + }, + }); +} + /** * Get parsing status badge color */