diff --git a/frontend/src/components/home/SpacedRepetitionWidget.css b/frontend/src/components/home/SpacedRepetitionWidget.css
index 6dadb279..8d6dd465 100644
--- a/frontend/src/components/home/SpacedRepetitionWidget.css
+++ b/frontend/src/components/home/SpacedRepetitionWidget.css
@@ -103,6 +103,45 @@
}
}
+/* ==============================================
+ Idle State (No Cards Due)
+ ============================================== */
+
+.spaced-repetition-widget__idle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-md);
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
+
+.spaced-repetition-widget__idle-message {
+ opacity: 0.8;
+}
+
+/* ==============================================
+ Entrance Animation
+ ============================================== */
+
+@keyframes widget-content-enter {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.spaced-repetition-widget__question,
+.spaced-repetition-widget__answer,
+.spaced-repetition-widget__complete,
+.spaced-repetition-widget__idle {
+ animation: widget-content-enter 0.2s ease backwards;
+}
+
/* ==============================================
Error State
============================================== */
@@ -558,3 +597,13 @@
background-color: var(--color-surface);
}
}
+
+/* Reduced motion preference for entrance animations */
+@media (prefers-reduced-motion: reduce) {
+ .spaced-repetition-widget__question,
+ .spaced-repetition-widget__answer,
+ .spaced-repetition-widget__complete,
+ .spaced-repetition-widget__idle {
+ animation: none;
+ }
+}
diff --git a/frontend/src/components/home/SpacedRepetitionWidget.tsx b/frontend/src/components/home/SpacedRepetitionWidget.tsx
index a92e646a..276c2220 100644
--- a/frontend/src/components/home/SpacedRepetitionWidget.tsx
+++ b/frontend/src/components/home/SpacedRepetitionWidget.tsx
@@ -33,8 +33,13 @@ export interface SpacedRepetitionWidgetProps {
/**
* Widget state machine phases.
+ * - loading: Initial fetch in progress
+ * - idle: No cards due today
+ * - question: Showing question, awaiting user input
+ * - revealed: Answer shown, awaiting self-assessment
+ * - complete: All cards reviewed for this session
*/
-type WidgetPhase = "loading" | "question" | "revealed" | "complete";
+type WidgetPhase = "loading" | "idle" | "question" | "revealed" | "complete";
/**
* Internal widget state.
@@ -53,6 +58,8 @@ interface WidgetState {
initialCount: number;
/** Whether we've started a review session */
sessionStarted: boolean;
+ /** Whether the initial fetch failed (hide widget on error) */
+ fetchFailed: boolean;
}
const INITIAL_STATE: WidgetState = {
@@ -63,6 +70,7 @@ const INITIAL_STATE: WidgetState = {
queue: [],
initialCount: 0,
sessionStarted: false,
+ fetchFailed: false,
};
/**
@@ -117,11 +125,23 @@ export function SpacedRepetitionWidget({
getDueCards()
.then((result) => {
- if (!result || result.count === 0) {
- setState(INITIAL_STATE);
+ // API error (returns null on failure) - hide widget
+ if (result === null) {
+ setState({ ...INITIAL_STATE, fetchFailed: true });
return;
}
+ // No cards due - show idle state
+ if (result.count === 0) {
+ setState({
+ ...INITIAL_STATE,
+ phase: "idle",
+ sessionStarted: true,
+ });
+ return;
+ }
+
+ // Cards available - show first question
const cards = result.cards;
setState({
phase: "question",
@@ -131,10 +151,12 @@ export function SpacedRepetitionWidget({
queue: cards.slice(1),
initialCount: result.count,
sessionStarted: true,
+ fetchFailed: false,
});
})
.catch(() => {
- setState(INITIAL_STATE);
+ // Unexpected error - hide widget
+ setState({ ...INITIAL_STATE, fetchFailed: true });
});
}, [vaultId, getDueCards]);
@@ -305,13 +327,16 @@ export function SpacedRepetitionWidget({
[handleShowAnswer]
);
- // Don't render if no vault or if we haven't loaded cards yet
+ // Don't render if no vault or if initial fetch failed
if (!vaultId) return null;
- if (!state.sessionStarted) return null;
+ if (state.fetchFailed) return null;
- // Calculate remaining cards for header
+ // Calculate remaining cards for header (0 during loading/idle)
const remainingCount = state.queue.length + (state.currentCard ? 1 : 0);
+ // Show loading state during initial fetch
+ const showLoading = state.phase === "loading";
+
return (
<>
Spaced Repetition
-
- {remainingCount} {remainingCount === 1 ? "card" : "cards"}
-
+ {!showLoading && state.phase !== "idle" && (
+
+ {remainingCount} {remainingCount === 1 ? "card" : "cards"}
+
+ )}
{/* Loading state */}
- {state.phase === "loading" && (
+ {showLoading && (
)}
+ {/* Idle state - no cards due */}
+ {state.phase === "idle" && (
+
+
+ No cards due today
+
+
+ )}
+
{/* Error state */}
{error && (
diff --git a/frontend/src/components/home/__tests__/HomeView.test.tsx b/frontend/src/components/home/__tests__/HomeView.test.tsx
index b1f7223f..6ef53193 100644
--- a/frontend/src/components/home/__tests__/HomeView.test.tsx
+++ b/frontend/src/components/home/__tests__/HomeView.test.tsx
@@ -52,10 +52,11 @@ describe("HomeView", () => {
});
describe("inspiration", () => {
- it("does not render InspirationCard when no inspiration data", () => {
+ it("renders InspirationCard with skeleton when no inspiration data", () => {
render(, { wrapper: Wrapper });
- expect(screen.queryByLabelText("Inspiration")).toBeNull();
+ // InspirationCard always renders, showing skeleton when loading or no data
+ expect(screen.getByLabelText("Inspiration")).toBeTruthy();
});
});
});
diff --git a/frontend/src/components/home/__tests__/SpacedRepetitionWidget.test.tsx b/frontend/src/components/home/__tests__/SpacedRepetitionWidget.test.tsx
index e34d587a..12e67921 100644
--- a/frontend/src/components/home/__tests__/SpacedRepetitionWidget.test.tsx
+++ b/frontend/src/components/home/__tests__/SpacedRepetitionWidget.test.tsx
@@ -168,15 +168,15 @@ describe("SpacedRepetitionWidget", () => {
expect(container.firstChild).toBeNull();
});
- it("renders nothing when no cards are due", async () => {
+ it("shows idle state when no cards are due", async () => {
const mockFetch = createMockFetch({ dueCards: { cards: [], count: 0 } });
- const { container } = renderWithSession(
+ renderWithSession(
);
- // Wait for the fetch to complete
+ // Wait for the fetch to complete and show idle state
await waitFor(() => {
- expect(container.firstChild).toBeNull();
+ expect(screen.getByText("No cards due today")).toBeDefined();
});
});