From 2d30ed93ac3aeb26508ec37fd55d0b30301ef89e Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Thu, 5 Feb 2026 09:08:14 +0900
Subject: feat(study): add undo support for card reviews

Defer API submission of reviews by storing them as pending. The
previous pending review is flushed when the next card is rated, and
on unmount via fire-and-forget. Undo discards the pending review and
returns to the previous card without any API call.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 src/client/pages/StudyPage.tsx | 135 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 114 insertions(+), 21 deletions(-)

(limited to 'src/client/pages/StudyPage.tsx')

diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx
index 423fdd0..4fb7549 100644
--- a/src/client/pages/StudyPage.tsx
+++ b/src/client/pages/StudyPage.tsx
@@ -2,6 +2,7 @@ import {
 	faCheck,
 	faChevronLeft,
 	faCircleCheck,
+	faRotateLeft,
 } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import { useAtomValue } from "jotai";
@@ -20,6 +21,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary";
 import { renderCard } from "../utils/templateRenderer";
 
 type Rating = 1 | 2 | 3 | 4;
+type PendingReview = { cardId: string; rating: Rating; durationMs: number };
 
 const RatingLabels: Record<Rating, string> = {
 	1: "Again",
@@ -47,6 +49,15 @@ function StudySession({ deckId }: { deckId: string }) {
 	const [submitError, setSubmitError] = useState<string | null>(null);
 	const [completedCount, setCompletedCount] = useState(0);
 	const cardStartTimeRef = useRef<number>(Date.now());
+	const [pendingReview, setPendingReview] = useState<PendingReview | null>(
+		null,
+	);
+	const pendingReviewRef = useRef<PendingReview | null>(null);
+
+	// Keep ref in sync with state for cleanup effect
+	useEffect(() => {
+		pendingReviewRef.current = pendingReview;
+	}, [pendingReview]);
 
 	// biome-ignore lint/correctness/useExhaustiveDependencies: Reset timer when card changes
 	useEffect(() => {
@@ -57,6 +68,19 @@ function StudySession({ deckId }: { deckId: string }) {
 		setIsFlipped(true);
 	}, []);
 
+	const flushPendingReview = useCallback(
+		async (review: PendingReview) => {
+			const res = await apiClient.rpc.api.decks[":deckId"].study[
+				":cardId"
+			].$post({
+				param: { deckId, cardId: review.cardId },
+				json: { rating: review.rating, durationMs: review.durationMs },
+			});
+			await apiClient.handleResponse(res);
+		},
+		[deckId],
+	);
+
 	const handleRating = useCallback(
 		async (rating: Rating) => {
 			if (isSubmitting) return;
@@ -69,35 +93,67 @@ function StudySession({ deckId }: { deckId: string }) {
 
 			const durationMs = Date.now() - cardStartTimeRef.current;
 
-			try {
-				const res = await apiClient.rpc.api.decks[":deckId"].study[
-					":cardId"
-				].$post({
-					param: { deckId, cardId: currentCard.id },
-					json: { rating, durationMs },
-				});
-				await apiClient.handleResponse(res);
-
-				setCompletedCount((prev) => prev + 1);
-				setIsFlipped(false);
-				setCurrentIndex((prev) => prev + 1);
-			} catch (err) {
-				if (err instanceof ApiClientError) {
-					setSubmitError(err.message);
-				} else {
-					setSubmitError("Failed to submit review. Please try again.");
+			// Flush previous pending review first
+			if (pendingReview) {
+				try {
+					await flushPendingReview(pendingReview);
+				} catch (err) {
+					if (err instanceof ApiClientError) {
+						setSubmitError(err.message);
+					} else {
+						setSubmitError("Failed to submit review. Please try again.");
+					}
 				}
-			} finally {
-				setIsSubmitting(false);
 			}
+
+			// Save current review as pending (don't send yet)
+			setPendingReview({ cardId: currentCard.id, rating, durationMs });
+			setCompletedCount((prev) => prev + 1);
+			setIsFlipped(false);
+			setCurrentIndex((prev) => prev + 1);
+			setIsSubmitting(false);
 		},
-		[deckId, isSubmitting, cards, currentIndex],
+		[isSubmitting, cards, currentIndex, pendingReview, flushPendingReview],
 	);
 
+	const handleUndo = useCallback(() => {
+		if (!pendingReview) return;
+		setPendingReview(null);
+		setCurrentIndex((prev) => prev - 1);
+		setCompletedCount((prev) => prev - 1);
+		setIsFlipped(false);
+	}, [pendingReview]);
+
+	// Flush pending review on unmount (fire-and-forget)
+	useEffect(() => {
+		return () => {
+			const review = pendingReviewRef.current;
+			if (review) {
+				apiClient.rpc.api.decks[":deckId"].study[":cardId"]
+					.$post({
+						param: { deckId, cardId: review.cardId },
+						json: { rating: review.rating, durationMs: review.durationMs },
+					})
+					.then((res) => apiClient.handleResponse(res))
+					.catch(() => {});
+			}
+		};
+	}, [deckId]);
+
 	const handleKeyDown = useCallback(
 		(e: KeyboardEvent) => {
 			if (isSubmitting) return;
 
+			// Undo: Ctrl+Z / Cmd+Z anytime, or z when card is not flipped
+			if (
+				(e.key === "z" && (e.ctrlKey || e.metaKey)) ||
+				(e.key === "z" && !e.ctrlKey && !e.metaKey && !isFlipped)
+			) {
+				e.preventDefault();
+				handleUndo();
+				return;
+			}
+
 			if (!isFlipped) {
 				if (e.key === " " || e.key === "Enter") {
 					e.preventDefault();
@@ -119,7 +175,7 @@ function StudySession({ deckId }: { deckId: string }) {
 				}
 			}
 		},
-		[isFlipped, isSubmitting, handleFlip, handleRating],
+		[isFlipped, isSubmitting, handleFlip, handleRating, handleUndo],
 	);
 
 	useEffect(() => {
@@ -185,6 +241,28 @@ function StudySession({ deckId }: { deckId: string }) {
 				)}
 			</div>
 
+			{/* Undo Button */}
+			{pendingReview && !isFlipped && !isSessionComplete && (
+				<div className="flex justify-end mb-4">
+					<button
+						type="button"
+						data-testid="undo-button"
+						onClick={handleUndo}
+						className="inline-flex items-center gap-1.5 text-muted hover:text-slate text-sm transition-colors"
+					>
+						<FontAwesomeIcon
+							icon={faRotateLeft}
+							className="w-3.5 h-3.5"
+							aria-hidden="true"
+						/>
+						Undo
+						<kbd className="ml-1 px-1.5 py-0.5 bg-ivory rounded text-xs font-mono">
+							Z
+						</kbd>
+					</button>
+				</div>
+			)}
+
 			{/* No Cards State */}
 			{hasNoCards && (
 				<div
@@ -240,6 +318,21 @@ function StudySession({ deckId }: { deckId: string }) {
 							card{completedCount !== 1 ? "s" : ""}
 						</p>
 						<div className="flex flex-col sm:flex-row gap-3 justify-center">
+							{pendingReview && (
+								<button
+									type="button"
+									data-testid="undo-button"
+									onClick={handleUndo}
+									className="inline-flex items-center justify-center gap-2 bg-ivory hover:bg-border text-slate font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+								>
+									<FontAwesomeIcon
+										icon={faRotateLeft}
+										className="w-4 h-4"
+										aria-hidden="true"
+									/>
+									Undo
+								</button>
+							)}
 							<Link
 								href={`/decks/${deckId}`}
 								className="inline-flex items-center justify-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
-- 
cgit v1.3-1-g0d28

