aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/pages/StudyPage.test.tsx114
-rw-r--r--src/client/pages/StudyPage.tsx58
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>