diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-14 22:48:56 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-14 22:48:56 +0900 |
| commit | 8883b0beb78b794d74fd5a1dad641b687b308dbd (patch) | |
| tree | 6625f49650618d7360030e2ddec1eee17e0d1fb8 /src/client/pages | |
| parent | 0dafc78fe522f519b632fbe5f2034c18bd45e2d5 (diff) | |
| download | kioku-8883b0beb78b794d74fd5a1dad641b687b308dbd.tar.gz kioku-8883b0beb78b794d74fd5a1dad641b687b308dbd.tar.zst kioku-8883b0beb78b794d74fd5a1dad641b687b308dbd.zip | |
fix(study): flush pending review before navigating away from session complete
Navigation links on the session complete screen were fire-and-forget,
causing the last card's review to not be reflected on the deck list.
Replace links with buttons that await the review flush and invalidate
the deck query cache before navigating.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages')
| -rw-r--r-- | src/client/pages/StudyPage.test.tsx | 114 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 58 |
2 files changed, 161 insertions, 11 deletions
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index 6e605c9..9c48083 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -601,6 +601,120 @@ describe("StudyPage", () => { }); }); + describe("Navigation flushes pending review", () => { + function getSessionCompleteButton(name: string) { + const container = screen.getByTestId("session-complete"); + const buttons = container.querySelectorAll("button"); + for (const button of buttons) { + if (button.textContent?.includes(name)) return button; + } + throw new Error(`Button "${name}" not found in session-complete`); + } + + it("flushes pending review when clicking 'Back to Deck' on session complete", async () => { + const user = userEvent.setup(); + + mockStudyPost.mockResolvedValue({ + card: { ...mockFirstCard, reps: 1 }, + }); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, + }); + + // Review the only card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + + // API should NOT have been called yet (still pending) + expect(mockStudyPost).not.toHaveBeenCalled(); + + // Click "Back to Deck" in session complete area + await user.click(getSessionCompleteButton("Back to Deck")); + + // Now the pending review should have been flushed + await waitFor(() => { + expect(mockStudyPost).toHaveBeenCalledTimes(1); + }); + expect(mockStudyPost).toHaveBeenCalledWith( + expect.objectContaining({ + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 3 }), + }), + ); + }); + + it("flushes pending review when clicking 'All Decks' on session complete", async () => { + const user = userEvent.setup(); + + mockStudyPost.mockResolvedValue({ + card: { ...mockFirstCard, reps: 1 }, + }); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, + }); + + // Review the only card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + + expect(mockStudyPost).not.toHaveBeenCalled(); + + // Click "All Decks" + await user.click(getSessionCompleteButton("All Decks")); + + // Pending review should have been flushed + await waitFor(() => { + expect(mockStudyPost).toHaveBeenCalledTimes(1); + }); + expect(mockStudyPost).toHaveBeenCalledWith( + expect.objectContaining({ + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 3 }), + }), + ); + }); + + it("navigates even if flush fails", async () => { + const user = userEvent.setup(); + + mockStudyPost.mockRejectedValue(new Error("Network error")); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, + }); + + // Review the only card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + + // Click "Back to Deck" — flush will fail but navigation should still happen + await user.click(getSessionCompleteButton("Back to Deck")); + + await waitFor(() => { + expect(mockStudyPost).toHaveBeenCalledTimes(1); + }); + + // Buttons should be disabled during navigation (isNavigating = true) + await waitFor(() => { + expect(getSessionCompleteButton("Back to Deck").disabled).toBe(true); + }); + }); + }); + describe("Undo", () => { it("does not show undo button before any rating", () => { renderWithProviders({ diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 92d655e..33fd290 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -14,10 +14,11 @@ import { useRef, useState, } from "react"; -import { Link, useParams } from "wouter"; +import { Link, useLocation, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; import { studyDataAtomFamily } from "../atoms"; import { ErrorBoundary } from "../components/ErrorBoundary"; +import { queryClient } from "../queryClient"; import { renderCard } from "../utils/templateRenderer"; type Rating = 1 | 2 | 3 | 4; @@ -37,7 +38,13 @@ const RatingStyles: Record<Rating, string> = { 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30", }; -function StudySession({ deckId }: { deckId: string }) { +function StudySession({ + deckId, + onNavigate, +}: { + deckId: string; + onNavigate: (href: string) => void; +}) { const { data: { deck, cards }, } = useAtomValue(studyDataAtomFamily(deckId)); @@ -124,6 +131,27 @@ function StudySession({ deckId }: { deckId: string }) { setIsFlipped(false); }, [pendingReview]); + const [isNavigating, setIsNavigating] = useState(false); + + const handleNavigateAway = useCallback( + async (href: string) => { + if (isNavigating) return; + setIsNavigating(true); + const review = pendingReviewRef.current; + if (review) { + try { + await flushPendingReview(review); + setPendingReview(null); + } catch { + // Continue navigation even on error + } + } + await queryClient.invalidateQueries({ queryKey: ["decks"] }); + onNavigate(href); + }, + [isNavigating, flushPendingReview, onNavigate], + ); + // Flush pending review on unmount (fire-and-forget) useEffect(() => { return () => { @@ -135,7 +163,10 @@ function StudySession({ deckId }: { deckId: string }) { json: { rating: review.rating, durationMs: review.durationMs }, }) .then((res) => apiClient.handleResponse(res)) + .then(() => queryClient.invalidateQueries({ queryKey: ["decks"] })) .catch(() => {}); + } else { + queryClient.invalidateQueries({ queryKey: ["decks"] }); } }; }, [deckId]); @@ -325,18 +356,22 @@ function StudySession({ deckId }: { deckId: string }) { 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" + <button + type="button" + disabled={isNavigating} + onClick={() => handleNavigateAway(`/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 disabled:opacity-50" > Back to Deck - </Link> - <Link - href="/" - 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" + </button> + <button + type="button" + disabled={isNavigating} + onClick={() => handleNavigateAway("/")} + 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 disabled:opacity-50" > All Decks - </Link> + </button> </div> </div> </div> @@ -427,6 +462,7 @@ function StudySession({ deckId }: { deckId: string }) { export function StudyPage() { const { deckId } = useParams<{ deckId: string }>(); + const [, navigate] = useLocation(); if (!deckId) { return ( @@ -479,7 +515,7 @@ export function StudyPage() { </div> } > - <StudySession deckId={deckId} /> + <StudySession deckId={deckId} onNavigate={navigate} /> </Suspense> </ErrorBoundary> </main> |
