aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 11:11:53 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 11:12:00 +0900
commit7ca9941982a7d7a4c126d215770ce71ad2f7f427 (patch)
tree0178b48094e9b7b143fd47c4d8479d3d588bb1d7 /src
parent8f1a08fefee3a8e928baec741c830a88a4cd7200 (diff)
downloadkioku-7ca9941982a7d7a4c126d215770ce71ad2f7f427.tar.gz
kioku-7ca9941982a7d7a4c126d215770ce71ad2f7f427.tar.zst
kioku-7ca9941982a7d7a4c126d215770ce71ad2f7f427.zip
feat(client): read decks/cards/study from IndexedDB first
Switch deckAtom, cardsByDeckAtomFamily, noteTypesAtom, and studyDataAtom to a stale-while-revalidate pattern: read from IndexedDB synchronously, trigger sync in the background, and refetch on sync_complete. When local is empty, await a single bootstrap pull before deciding there's no data. Add study-builder to assemble StudyCards from LocalCard + Note + NoteType + field values, replacing the server /study endpoint dependency. The study session can now run end-to-end offline. Disable submit on all write modals when offline since writes still require the server. Add a "Showing cached data" hint to the sync status indicator. Drop cacheStudyCards (cards arrive via regular sync pull now) and update page tests to reflect that lists no longer refresh by hitting the GET API.
Diffstat (limited to 'src')
-rw-r--r--src/client/atoms/cards.ts51
-rw-r--r--src/client/atoms/decks.ts124
-rw-r--r--src/client/atoms/noteTypes.ts49
-rw-r--r--src/client/atoms/study.ts60
-rw-r--r--src/client/atoms/sync.ts50
-rw-r--r--src/client/components/CreateCardModal.tsx6
-rw-r--r--src/client/components/CreateDeckModal.tsx6
-rw-r--r--src/client/components/CreateNoteModal.tsx8
-rw-r--r--src/client/components/CreateNoteTypeModal.tsx6
-rw-r--r--src/client/components/DeleteCardModal.tsx6
-rw-r--r--src/client/components/DeleteDeckModal.tsx6
-rw-r--r--src/client/components/DeleteNoteModal.tsx6
-rw-r--r--src/client/components/DeleteNoteTypeModal.tsx6
-rw-r--r--src/client/components/EditCardModal.tsx6
-rw-r--r--src/client/components/EditDeckModal.tsx6
-rw-r--r--src/client/components/EditNoteModal.tsx8
-rw-r--r--src/client/components/EditNoteTypeModal.tsx6
-rw-r--r--src/client/components/ImportNotesModal.tsx6
-rw-r--r--src/client/components/SyncStatusIndicator.tsx6
-rw-r--r--src/client/db/study-builder.test.ts169
-rw-r--r--src/client/db/study-builder.ts82
-rw-r--r--src/client/pages/DeckCardsPage.test.tsx16
-rw-r--r--src/client/pages/HomePage.test.tsx115
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx49
-rw-r--r--src/client/pages/StudyPage.test.tsx19
-rw-r--r--src/client/sync/index.ts2
-rw-r--r--src/client/sync/scheduler.test.ts75
-rw-r--r--src/client/sync/scheduler.ts65
28 files changed, 614 insertions, 400 deletions
diff --git a/src/client/atoms/cards.ts b/src/client/atoms/cards.ts
index 4a6e72e..7771cdb 100644
--- a/src/client/atoms/cards.ts
+++ b/src/client/atoms/cards.ts
@@ -1,7 +1,8 @@
import { atomFamily } from "jotai-family";
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
-import { apiClient } from "../api/client";
-import type { CardStateType } from "../db";
+import type { CardStateType, LocalCard } from "../db";
+import { localCardRepository } from "../db/repositories";
+import { ensureBootstrap } from "./sync";
export interface Card {
id: string;
@@ -25,19 +26,51 @@ export interface Card {
syncVersion: number;
}
+function localCardToView(card: LocalCard): Card {
+ return {
+ id: card.id,
+ deckId: card.deckId,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
+ front: card.front,
+ back: card.back,
+ state: card.state,
+ due: card.due.toISOString(),
+ stability: card.stability,
+ difficulty: card.difficulty,
+ elapsedDays: card.elapsedDays,
+ scheduledDays: card.scheduledDays,
+ reps: card.reps,
+ lapses: card.lapses,
+ lastReview: card.lastReview ? card.lastReview.toISOString() : null,
+ createdAt: card.createdAt.toISOString(),
+ updatedAt: card.updatedAt.toISOString(),
+ deletedAt: card.deletedAt ? card.deletedAt.toISOString() : null,
+ syncVersion: card.syncVersion,
+ };
+}
+
+async function loadCardsByDeck(deckId: string): Promise<Card[]> {
+ const cards = await localCardRepository.findByDeckId(deckId);
+ cards.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+ return cards.map(localCardToView);
+}
+
// =====================
-// Cards by Deck - Suspense-compatible
+// Cards by Deck - Suspense-compatible, IndexedDB-first
// =====================
export const cardsByDeckAtomFamily = atomFamily((deckId: string) =>
atomWithSuspenseQuery(() => ({
queryKey: ["decks", deckId, "cards"],
- queryFn: async () => {
- const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({
- param: { deckId },
- });
- const data = await apiClient.handleResponse<{ cards: Card[] }>(res);
- return data.cards;
+ queryFn: async (): Promise<Card[]> => {
+ const cards = await loadCardsByDeck(deckId);
+ if (cards.length > 0) {
+ ensureBootstrap();
+ return cards;
+ }
+ await ensureBootstrap();
+ return loadCardsByDeck(deckId);
},
})),
);
diff --git a/src/client/atoms/decks.ts b/src/client/atoms/decks.ts
index 0d20586..d62227c 100644
--- a/src/client/atoms/decks.ts
+++ b/src/client/atoms/decks.ts
@@ -1,6 +1,9 @@
import { atomFamily } from "jotai-family";
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
-import { apiClient } from "../api/client";
+import { getEndOfStudyDayBoundary } from "../../shared/date";
+import { CardState, db, type LocalDeck } from "../db";
+import { localDeckRepository } from "../db/repositories";
+import { ensureBootstrap } from "./sync";
export interface Deck {
id: string;
@@ -15,34 +18,125 @@ export interface Deck {
updatedAt: string;
}
+async function loadCurrentUserId(): Promise<string | null> {
+ const stored = localStorage.getItem("kioku_user");
+ if (!stored) return null;
+ try {
+ const user = JSON.parse(stored) as { id?: string } | null;
+ return user?.id ?? null;
+ } catch {
+ return null;
+ }
+}
+
+interface DeckCardCounts {
+ dueCardCount: number;
+ newCardCount: number;
+ totalCardCount: number;
+ reviewCardCount: number;
+}
+
+async function computeDeckCounts(
+ deckId: string,
+ dueBoundary: Date,
+): Promise<DeckCardCounts> {
+ const cards = await db.cards.where("deckId").equals(deckId).toArray();
+ let due = 0;
+ let news = 0;
+ let total = 0;
+ let review = 0;
+ for (const card of cards) {
+ if (card.deletedAt !== null) continue;
+ total++;
+ if (card.due < dueBoundary) due++;
+ if (card.state === CardState.New) news++;
+ if (card.state === CardState.Review) review++;
+ }
+ return {
+ dueCardCount: due,
+ newCardCount: news,
+ totalCardCount: total,
+ reviewCardCount: review,
+ };
+}
+
+function localDeckToView(deck: LocalDeck, counts: DeckCardCounts): Deck {
+ return {
+ id: deck.id,
+ name: deck.name,
+ description: deck.description,
+ defaultNoteTypeId: deck.defaultNoteTypeId,
+ dueCardCount: counts.dueCardCount,
+ newCardCount: counts.newCardCount,
+ totalCardCount: counts.totalCardCount,
+ reviewCardCount: counts.reviewCardCount,
+ createdAt: deck.createdAt.toISOString(),
+ updatedAt: deck.updatedAt.toISOString(),
+ };
+}
+
+async function loadDecksFromIndexedDb(): Promise<Deck[]> {
+ const userId = await loadCurrentUserId();
+ if (!userId) return [];
+ const decks = await localDeckRepository.findByUserId(userId);
+ const boundary = getEndOfStudyDayBoundary(new Date());
+ decks.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+ return Promise.all(
+ decks.map(async (deck) => {
+ const counts = await computeDeckCounts(deck.id, boundary);
+ return localDeckToView(deck, counts);
+ }),
+ );
+}
+
// =====================
-// Decks List - Suspense-compatible
+// Decks List - Suspense-compatible, IndexedDB-first
// =====================
export const decksAtom = atomWithSuspenseQuery(() => ({
queryKey: ["decks"],
- queryFn: async () => {
- const res = await apiClient.rpc.api.decks.$get(undefined, {
- headers: apiClient.getAuthHeader(),
- });
- const data = await apiClient.handleResponse<{ decks: Deck[] }>(res);
- return data.decks;
+ queryFn: async (): Promise<Deck[]> => {
+ const decks = await loadDecksFromIndexedDb();
+ if (decks.length > 0) {
+ // Stale-while-revalidate: kick a background pull so the next
+ // invalidation reflects upstream changes.
+ ensureBootstrap();
+ return decks;
+ }
+ // IndexedDB is empty — wait for the initial pull to populate it
+ // before deciding there really are no decks.
+ await ensureBootstrap();
+ return loadDecksFromIndexedDb();
},
}));
// =====================
-// Single Deck by ID - Suspense-compatible
+// Single Deck by ID - Suspense-compatible, IndexedDB-first
// =====================
+async function loadDeckById(deckId: string): Promise<Deck | null> {
+ const deck = await localDeckRepository.findById(deckId);
+ if (!deck || deck.deletedAt !== null) return null;
+ const boundary = getEndOfStudyDayBoundary(new Date());
+ const counts = await computeDeckCounts(deck.id, boundary);
+ return localDeckToView(deck, counts);
+}
+
export const deckByIdAtomFamily = atomFamily((deckId: string) =>
atomWithSuspenseQuery(() => ({
queryKey: ["decks", deckId],
- queryFn: async () => {
- const res = await apiClient.rpc.api.decks[":id"].$get({
- param: { id: deckId },
- });
- const data = await apiClient.handleResponse<{ deck: Deck }>(res);
- return data.deck;
+ queryFn: async (): Promise<Deck> => {
+ let deck = await loadDeckById(deckId);
+ if (deck) {
+ ensureBootstrap();
+ return deck;
+ }
+ await ensureBootstrap();
+ deck = await loadDeckById(deckId);
+ if (!deck) {
+ throw new Error(`Deck not found: ${deckId}`);
+ }
+ return deck;
},
})),
);
diff --git a/src/client/atoms/noteTypes.ts b/src/client/atoms/noteTypes.ts
index fb99b14..1fde8f5 100644
--- a/src/client/atoms/noteTypes.ts
+++ b/src/client/atoms/noteTypes.ts
@@ -1,5 +1,7 @@
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
-import { apiClient } from "../api/client";
+import type { LocalNoteType } from "../db";
+import { localNoteTypeRepository } from "../db/repositories";
+import { ensureBootstrap } from "./sync";
export interface NoteType {
id: string;
@@ -11,15 +13,50 @@ export interface NoteType {
updatedAt: string;
}
+async function loadCurrentUserId(): Promise<string | null> {
+ const stored = localStorage.getItem("kioku_user");
+ if (!stored) return null;
+ try {
+ const user = JSON.parse(stored) as { id?: string } | null;
+ return user?.id ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function localNoteTypeToView(noteType: LocalNoteType): NoteType {
+ return {
+ id: noteType.id,
+ name: noteType.name,
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ isReversible: noteType.isReversible,
+ createdAt: noteType.createdAt.toISOString(),
+ updatedAt: noteType.updatedAt.toISOString(),
+ };
+}
+
+async function loadNoteTypes(): Promise<NoteType[]> {
+ const userId = await loadCurrentUserId();
+ if (!userId) return [];
+ const noteTypes = await localNoteTypeRepository.findByUserId(userId);
+ noteTypes.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+ return noteTypes.map(localNoteTypeToView);
+}
+
// =====================
-// NoteTypes List - Suspense-compatible
+// NoteTypes List - Suspense-compatible, IndexedDB-first
// =====================
export const noteTypesAtom = atomWithSuspenseQuery(() => ({
queryKey: ["noteTypes"],
- queryFn: async () => {
- const res = await apiClient.rpc.api["note-types"].$get();
- const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>(res);
- return data.noteTypes;
+ queryFn: async (): Promise<NoteType[]> => {
+ const noteTypes = await loadNoteTypes();
+ if (noteTypes.length > 0) {
+ ensureBootstrap();
+ return noteTypes;
+ }
+ await ensureBootstrap();
+ return loadNoteTypes();
},
}));
diff --git a/src/client/atoms/study.ts b/src/client/atoms/study.ts
index 17519de..115bbcd 100644
--- a/src/client/atoms/study.ts
+++ b/src/client/atoms/study.ts
@@ -1,19 +1,12 @@
import { atomFamily } from "jotai-family";
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
import { getStartOfStudyDayBoundary } from "../../shared/date";
-import { apiClient } from "../api/client";
-import type { CardStateType } from "../db";
-import { cacheStudyCards, type ServerStudyCard } from "../sync";
+import { localDeckRepository } from "../db/repositories";
+import { buildStudyCards, type StudyCardView } from "../db/study-builder";
import { createSeededRandom, shuffle } from "../utils/random";
+import { ensureBootstrap } from "./sync";
-export interface StudyCard extends ServerStudyCard {
- state: CardStateType;
- noteType: {
- frontTemplate: string;
- backTemplate: string;
- };
- fieldValuesMap: Record<string, string>;
-}
+export type StudyCard = StudyCardView;
export interface StudyDeck {
id: string;
@@ -25,35 +18,36 @@ export interface StudyData {
cards: StudyCard[];
}
+async function loadStudyData(deckId: string): Promise<StudyData | null> {
+ const deck = await localDeckRepository.findById(deckId);
+ if (!deck || deck.deletedAt !== null) return null;
+ const cards = await buildStudyCards(deckId);
+ const seed = getStartOfStudyDayBoundary().getTime();
+ return {
+ deck: { id: deck.id, name: deck.name },
+ cards: shuffle(cards, createSeededRandom(seed)),
+ };
+}
+
// =====================
-// Study Session - Suspense-compatible
+// Study Session - Suspense-compatible, IndexedDB-first
// =====================
export const studyDataAtomFamily = atomFamily((deckId: string) =>
atomWithSuspenseQuery(() => ({
queryKey: ["decks", deckId, "study"],
queryFn: async (): Promise<StudyData> => {
- // Fetch deck and due cards in parallel
- const [deckRes, cardsRes] = await Promise.all([
- apiClient.rpc.api.decks[":id"].$get({ param: { id: deckId } }),
- apiClient.rpc.api.decks[":deckId"].study.$get({ param: { deckId } }),
- ]);
-
- const deckData = await apiClient.handleResponse<{ deck: StudyDeck }>(
- deckRes,
- );
- const cardsData = await apiClient.handleResponse<{
- cards: StudyCard[];
- }>(cardsRes);
-
- // Cache cards in IndexedDB so reviews can be submitted offline.
- await cacheStudyCards(cardsData.cards);
-
- const seed = getStartOfStudyDayBoundary().getTime();
- return {
- deck: deckData.deck,
- cards: shuffle(cardsData.cards, createSeededRandom(seed)),
- };
+ let data = await loadStudyData(deckId);
+ if (data) {
+ ensureBootstrap();
+ return data;
+ }
+ await ensureBootstrap();
+ data = await loadStudyData(deckId);
+ if (!data) {
+ throw new Error(`Deck not found: ${deckId}`);
+ }
+ return data;
},
})),
);
diff --git a/src/client/atoms/sync.ts b/src/client/atoms/sync.ts
index 91395d8..60c8fae 100644
--- a/src/client/atoms/sync.ts
+++ b/src/client/atoms/sync.ts
@@ -1,6 +1,7 @@
-import { atom, useSetAtom } from "jotai";
+import { atom, useAtomValue, useSetAtom } from "jotai";
import { useEffect } from "react";
import { apiClient } from "../api/client";
+import { queryClient } from "../queryClient";
import {
conflictResolver,
createPullService,
@@ -23,6 +24,7 @@ import type {
SyncPullResult,
} from "../sync/pull";
import type { SyncPushData, SyncPushResult } from "../sync/push";
+import { userAtom } from "./auth";
// =====================
// Sync Services Setup
@@ -182,6 +184,34 @@ const syncManager = createSyncManager({
});
// =====================
+// Bootstrap (initial sync) coordination
+// =====================
+//
+// The first sync after app load is responsible for populating IndexedDB
+// from the server. SWR-style atoms (decks, cards, noteTypes, study) check
+// whether bootstrap is in flight and await it if their local data is empty.
+// If we are offline or already bootstrapped, the promise resolves immediately.
+
+let bootstrapPromise: Promise<void> | null = null;
+
+export function ensureBootstrap(): Promise<void> {
+ if (bootstrapPromise) return bootstrapPromise;
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
+ bootstrapPromise = Promise.resolve();
+ return bootstrapPromise;
+ }
+ bootstrapPromise = syncManager
+ .sync()
+ .then(() => undefined)
+ .catch(() => undefined);
+ return bootstrapPromise;
+}
+
+function resetBootstrap(): void {
+ bootstrapPromise = null;
+}
+
+// =====================
// Sync State Atoms
// =====================
@@ -207,6 +237,17 @@ export function useSyncInit() {
const setLastSyncAt = useSetAtom(lastSyncAtAtom);
const setLastError = useSetAtom(lastErrorAtom);
const setStatus = useSetAtom(syncStatusAtom);
+ const user = useAtomValue(userAtom);
+
+ useEffect(() => {
+ // Bootstrap pulls user-scoped data, so wait for an authenticated user.
+ // Reset on logout so the next sign-in re-pulls.
+ if (user) {
+ ensureBootstrap();
+ } else {
+ resetBootstrap();
+ }
+ }, [user]);
useEffect(() => {
syncManager.start();
@@ -229,6 +270,13 @@ export function useSyncInit() {
setIsSyncing(false);
setLastSyncAt(new Date());
setStatus(SyncStatus.Idle);
+ // Refetch SWR atoms so the UI reflects the freshly pulled
+ // IndexedDB data. Suspense queries with cached data refetch
+ // in the background without re-suspending.
+ if (event.result.success) {
+ queryClient.invalidateQueries({ queryKey: ["decks"] });
+ queryClient.invalidateQueries({ queryKey: ["noteTypes"] });
+ }
break;
case "sync_error":
setIsSyncing(false);
diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx
index 3913e82..8dbaa79 100644
--- a/src/client/components/CreateCardModal.tsx
+++ b/src/client/components/CreateCardModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { type FormEvent, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface CreateCardModalProps {
isOpen: boolean;
@@ -18,6 +20,7 @@ export function CreateCardModal({
const [back, setBack] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const resetForm = () => {
setFront("");
@@ -163,7 +166,8 @@ export function CreateCardModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !isFormValid}
+ disabled={isSubmitting || !isFormValid || !isOnline}
+ title={!isOnline ? "Reconnect to create a card" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Creating..." : "Create Card"}
diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx
index 4541a68..34d46e7 100644
--- a/src/client/components/CreateDeckModal.tsx
+++ b/src/client/components/CreateDeckModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { type FormEvent, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface CreateDeckModalProps {
isOpen: boolean;
@@ -16,6 +18,7 @@ export function CreateDeckModal({
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const resetForm = () => {
setName("");
@@ -160,7 +163,8 @@ export function CreateDeckModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !name.trim()}
+ disabled={isSubmitting || !name.trim() || !isOnline}
+ title={!isOnline ? "Reconnect to create a deck" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Creating..." : "Create Deck"}
diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx
index cc39bf6..f3809ea 100644
--- a/src/client/components/CreateNoteModal.tsx
+++ b/src/client/components/CreateNoteModal.tsx
@@ -1,7 +1,9 @@
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useAtomValue } from "jotai";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface NoteField {
id: string;
@@ -49,6 +51,7 @@ export function CreateNoteModal({
const [isLoadingNoteType, setIsLoadingNoteType] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasLoadedNoteTypes, setHasLoadedNoteTypes] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const fetchNoteTypeDetails = useCallback(async (noteTypeId: string) => {
setIsLoadingNoteType(true);
@@ -346,7 +349,10 @@ export function CreateNoteModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !isFormValid || isLoading}
+ disabled={
+ isSubmitting || !isFormValid || isLoading || !isOnline
+ }
+ title={!isOnline ? "Reconnect to create a note" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Creating..." : "Create Note"}
diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx
index 4c3b232..bbd43a1 100644
--- a/src/client/components/CreateNoteTypeModal.tsx
+++ b/src/client/components/CreateNoteTypeModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { type FormEvent, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface CreateNoteTypeModalProps {
isOpen: boolean;
@@ -18,6 +20,7 @@ export function CreateNoteTypeModal({
const [isReversible, setIsReversible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const resetForm = () => {
setName("");
@@ -197,7 +200,8 @@ export function CreateNoteTypeModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !name.trim()}
+ disabled={isSubmitting || !name.trim() || !isOnline}
+ title={!isOnline ? "Reconnect to create" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Creating..." : "Create"}
diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx
index d9cf098..99514be 100644
--- a/src/client/components/DeleteCardModal.tsx
+++ b/src/client/components/DeleteCardModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface Card {
id: string;
@@ -23,6 +25,7 @@ export function DeleteCardModal({
}: DeleteCardModalProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const handleClose = () => {
setError(null);
@@ -138,7 +141,8 @@ export function DeleteCardModal({
<button
type="button"
onClick={handleDelete}
- disabled={isDeleting}
+ disabled={isDeleting || !isOnline}
+ title={!isOnline ? "Reconnect to delete" : undefined}
className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
>
{isDeleting ? "Deleting..." : "Delete"}
diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx
index edc6093..954431e 100644
--- a/src/client/components/DeleteDeckModal.tsx
+++ b/src/client/components/DeleteDeckModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface Deck {
id: string;
@@ -21,6 +23,7 @@ export function DeleteDeckModal({
}: DeleteDeckModalProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const handleClose = () => {
setError(null);
@@ -129,7 +132,8 @@ export function DeleteDeckModal({
<button
type="button"
onClick={handleDelete}
- disabled={isDeleting}
+ disabled={isDeleting || !isOnline}
+ title={!isOnline ? "Reconnect to delete" : undefined}
className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
>
{isDeleting ? "Deleting..." : "Delete"}
diff --git a/src/client/components/DeleteNoteModal.tsx b/src/client/components/DeleteNoteModal.tsx
index 5d81fdc..3ed22ec 100644
--- a/src/client/components/DeleteNoteModal.tsx
+++ b/src/client/components/DeleteNoteModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface DeleteNoteModalProps {
isOpen: boolean;
@@ -18,6 +20,7 @@ export function DeleteNoteModal({
}: DeleteNoteModalProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const handleClose = () => {
setError(null);
@@ -127,7 +130,8 @@ export function DeleteNoteModal({
<button
type="button"
onClick={handleDelete}
- disabled={isDeleting}
+ disabled={isDeleting || !isOnline}
+ title={!isOnline ? "Reconnect to delete" : undefined}
className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
>
{isDeleting ? "Deleting..." : "Delete"}
diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx
index db93482..2fbf808 100644
--- a/src/client/components/DeleteNoteTypeModal.tsx
+++ b/src/client/components/DeleteNoteTypeModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface NoteType {
id: string;
@@ -21,6 +23,7 @@ export function DeleteNoteTypeModal({
}: DeleteNoteTypeModalProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const handleClose = () => {
setError(null);
@@ -129,7 +132,8 @@ export function DeleteNoteTypeModal({
<button
type="button"
onClick={handleDelete}
- disabled={isDeleting}
+ disabled={isDeleting || !isOnline}
+ title={!isOnline ? "Reconnect to delete" : undefined}
className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
>
{isDeleting ? "Deleting..." : "Delete"}
diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx
index 726a003..288bfd6 100644
--- a/src/client/components/EditCardModal.tsx
+++ b/src/client/components/EditCardModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { type FormEvent, useEffect, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface Card {
id: string;
@@ -26,6 +28,7 @@ export function EditCardModal({
const [back, setBack] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
// Sync form state when card changes
useEffect(() => {
@@ -164,7 +167,8 @@ export function EditCardModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !isFormValid}
+ disabled={isSubmitting || !isFormValid || !isOnline}
+ title={!isOnline ? "Reconnect to save changes" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Saving..." : "Save Changes"}
diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx
index 9a79de8..e9c2b7b 100644
--- a/src/client/components/EditDeckModal.tsx
+++ b/src/client/components/EditDeckModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface Deck {
id: string;
@@ -35,6 +37,7 @@ export function EditDeckModal({
const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const fetchNoteTypes = useCallback(async () => {
setIsLoadingNoteTypes(true);
@@ -216,7 +219,8 @@ export function EditDeckModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !name.trim()}
+ disabled={isSubmitting || !name.trim() || !isOnline}
+ title={!isOnline ? "Reconnect to save changes" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Saving..." : "Save Changes"}
diff --git a/src/client/components/EditNoteModal.tsx b/src/client/components/EditNoteModal.tsx
index ac22332..cd2c58c 100644
--- a/src/client/components/EditNoteModal.tsx
+++ b/src/client/components/EditNoteModal.tsx
@@ -1,7 +1,9 @@
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useAtomValue } from "jotai";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface NoteField {
id: string;
@@ -54,6 +56,7 @@ export function EditNoteModal({
const [isLoadingNote, setIsLoadingNote] = useState(false);
const [isLoadingNoteType, setIsLoadingNoteType] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
const fetchNoteTypeDetails = useCallback(async (noteTypeId: string) => {
setIsLoadingNoteType(true);
@@ -297,7 +300,10 @@ export function EditNoteModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !isFormValid || isLoading}
+ disabled={
+ isSubmitting || !isFormValid || isLoading || !isOnline
+ }
+ title={!isOnline ? "Reconnect to save changes" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Saving..." : "Save Changes"}
diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx
index 27ef5d8..5916ff0 100644
--- a/src/client/components/EditNoteTypeModal.tsx
+++ b/src/client/components/EditNoteTypeModal.tsx
@@ -1,5 +1,7 @@
+import { useAtomValue } from "jotai";
import { type FormEvent, useEffect, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
interface NoteType {
id: string;
@@ -28,6 +30,7 @@ export function EditNoteTypeModal({
const [isReversible, setIsReversible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const isOnline = useAtomValue(isOnlineAtom);
// Sync form state when noteType changes
useEffect(() => {
@@ -208,7 +211,8 @@ export function EditNoteTypeModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !name.trim()}
+ disabled={isSubmitting || !name.trim() || !isOnline}
+ title={!isOnline ? "Reconnect to save changes" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Saving..." : "Save Changes"}
diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx
index d3a2c0c..a38ac8f 100644
--- a/src/client/components/ImportNotesModal.tsx
+++ b/src/client/components/ImportNotesModal.tsx
@@ -5,8 +5,10 @@ import {
faSpinner,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useAtomValue } from "jotai";
import { type ChangeEvent, useCallback, useEffect, useState } from "react";
import { ApiClientError, apiClient } from "../api";
+import { isOnlineAtom } from "../atoms";
import { parseCSV } from "../utils/csvParser";
interface NoteField {
@@ -64,6 +66,7 @@ export function ImportNotesModal({
}: ImportNotesModalProps) {
const [phase, setPhase] = useState<ImportPhase>("upload");
const [error, setError] = useState<string | null>(null);
+ const isOnline = useAtomValue(isOnlineAtom);
const [noteTypes, setNoteTypes] = useState<NoteType[]>([]);
const [validatedRows, setValidatedRows] = useState<ValidatedRow[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
@@ -490,7 +493,8 @@ export function ImportNotesModal({
<button
type="button"
onClick={handleImport}
- disabled={validatedRows.length === 0}
+ disabled={validatedRows.length === 0 || !isOnline}
+ title={!isOnline ? "Reconnect to import notes" : undefined}
className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Import {validatedRows.length} Note(s)
diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx
index 4bb3ff5..c517b76 100644
--- a/src/client/components/SyncStatusIndicator.tsx
+++ b/src/client/components/SyncStatusIndicator.tsx
@@ -101,11 +101,15 @@ export function SyncStatusIndicator() {
);
};
+ const titleText = !isOnline
+ ? "Showing cached data — changes will sync when you're back online"
+ : lastError || undefined;
+
return (
<div
data-testid="sync-status-indicator"
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${getStatusStyles()}`}
- title={lastError || undefined}
+ title={titleText}
>
{getStatusIcon()}
<span>{getStatusText()}</span>
diff --git a/src/client/db/study-builder.test.ts b/src/client/db/study-builder.test.ts
new file mode 100644
index 0000000..1b5beae
--- /dev/null
+++ b/src/client/db/study-builder.test.ts
@@ -0,0 +1,169 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { CardState, db, FieldType } from "./index";
+import {
+ localCardRepository,
+ localDeckRepository,
+ localNoteFieldTypeRepository,
+ localNoteFieldValueRepository,
+ localNoteRepository,
+ localNoteTypeRepository,
+} from "./repositories";
+import { buildStudyCards } from "./study-builder";
+
+async function clearDb() {
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ await db.noteTypes.clear();
+ await db.noteFieldTypes.clear();
+ await db.notes.clear();
+ await db.noteFieldValues.clear();
+}
+
+async function seedDeckWithDueCard() {
+ const deck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Vocab",
+ description: null,
+ defaultNoteTypeId: null,
+ });
+
+ const noteType = await localNoteTypeRepository.create({
+ userId: "user-1",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ });
+
+ const frontField = await localNoteFieldTypeRepository.create({
+ noteTypeId: noteType.id,
+ name: "Front",
+ order: 0,
+ });
+ const backField = await localNoteFieldTypeRepository.create({
+ noteTypeId: noteType.id,
+ name: "Back",
+ order: 1,
+ });
+
+ const note = await localNoteRepository.create({
+ deckId: deck.id,
+ noteTypeId: noteType.id,
+ });
+
+ await localNoteFieldValueRepository.create({
+ noteId: note.id,
+ noteFieldTypeId: frontField.id,
+ value: "Hello",
+ });
+ await localNoteFieldValueRepository.create({
+ noteId: note.id,
+ noteFieldTypeId: backField.id,
+ value: "こんにちは",
+ });
+
+ const card = await localCardRepository.create({
+ deckId: deck.id,
+ noteId: note.id,
+ isReversed: false,
+ front: "Hello",
+ back: "こんにちは",
+ });
+ // Cards default to state=New with due=now, which counts as due.
+
+ return { deck, noteType, note, card };
+}
+
+describe("buildStudyCards", () => {
+ beforeEach(async () => {
+ await clearDb();
+ });
+
+ afterEach(async () => {
+ await clearDb();
+ });
+
+ it("assembles a StudyCard from local note + note type + field values", async () => {
+ const { deck, card } = await seedDeckWithDueCard();
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(1);
+ expect(studyCards[0]).toMatchObject({
+ id: card.id,
+ deckId: deck.id,
+ isReversed: false,
+ state: CardState.New,
+ noteType: {
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ },
+ fieldValuesMap: {
+ Front: "Hello",
+ Back: "こんにちは",
+ },
+ });
+ });
+
+ it("skips cards whose note has been soft-deleted", async () => {
+ const { deck, note } = await seedDeckWithDueCard();
+ await localNoteRepository.delete(note.id);
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(0);
+ });
+
+ it("skips cards whose note type has been soft-deleted", async () => {
+ const { deck, noteType } = await seedDeckWithDueCard();
+ await localNoteTypeRepository.delete(noteType.id);
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(0);
+ });
+
+ it("skips cards whose due date is past the study-day boundary", async () => {
+ const { deck, card } = await seedDeckWithDueCard();
+ // Push due date a year into the future.
+ await db.cards.update(card.id, {
+ due: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
+ });
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(0);
+ });
+
+ it("returns the field type assignments without losing fields", async () => {
+ const { deck, noteType } = await seedDeckWithDueCard();
+
+ // Add a third unused field type to make sure the builder handles
+ // extra fields without value (gap).
+ await localNoteFieldTypeRepository.create({
+ noteTypeId: noteType.id,
+ name: "Notes",
+ order: 2,
+ });
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(1);
+ const studyCard = studyCards[0];
+ if (!studyCard) throw new Error("expected one study card");
+ expect(Object.keys(studyCard.fieldValuesMap).sort()).toEqual([
+ "Back",
+ "Front",
+ ]);
+ });
+
+ it("ignores text-type field values constant when building map", async () => {
+ // Sanity check that FieldType.Text is what's set on field types.
+ expect(FieldType.Text).toBe("text");
+ });
+});
diff --git a/src/client/db/study-builder.ts b/src/client/db/study-builder.ts
new file mode 100644
index 0000000..25d1652
--- /dev/null
+++ b/src/client/db/study-builder.ts
@@ -0,0 +1,82 @@
+import type { CardStateType } from "./index";
+import {
+ localCardRepository,
+ localNoteFieldTypeRepository,
+ localNoteFieldValueRepository,
+ localNoteRepository,
+ localNoteTypeRepository,
+} from "./repositories";
+
+export interface StudyCardView {
+ id: string;
+ deckId: string;
+ noteId: string;
+ isReversed: boolean;
+ state: CardStateType;
+ noteType: {
+ frontTemplate: string;
+ backTemplate: string;
+ };
+ fieldValuesMap: Record<string, string>;
+}
+
+/**
+ * Build study card views for all due cards in a deck from IndexedDB.
+ *
+ * Cards whose note or note type has been soft-deleted are skipped, mirroring
+ * the server-side enrichment in `enrichCardsForStudy`.
+ */
+export async function buildStudyCards(
+ deckId: string,
+): Promise<StudyCardView[]> {
+ const dueCards = await localCardRepository.findDueCards(deckId);
+ if (dueCards.length === 0) {
+ return [];
+ }
+
+ const noteTypeFieldsCache = new Map<string, Map<string, string>>();
+ const result: StudyCardView[] = [];
+
+ for (const card of dueCards) {
+ const note = await localNoteRepository.findById(card.noteId);
+ if (!note || note.deletedAt !== null) continue;
+
+ const noteType = await localNoteTypeRepository.findById(note.noteTypeId);
+ if (!noteType || noteType.deletedAt !== null) continue;
+
+ let fieldTypeIdToName = noteTypeFieldsCache.get(noteType.id);
+ if (!fieldTypeIdToName) {
+ const fieldTypes = await localNoteFieldTypeRepository.findByNoteTypeId(
+ noteType.id,
+ );
+ fieldTypeIdToName = new Map(fieldTypes.map((ft) => [ft.id, ft.name]));
+ noteTypeFieldsCache.set(noteType.id, fieldTypeIdToName);
+ }
+
+ const fieldValues = await localNoteFieldValueRepository.findByNoteId(
+ note.id,
+ );
+ const fieldValuesMap: Record<string, string> = {};
+ for (const fv of fieldValues) {
+ const name = fieldTypeIdToName.get(fv.noteFieldTypeId);
+ if (name) {
+ fieldValuesMap[name] = fv.value;
+ }
+ }
+
+ result.push({
+ id: card.id,
+ deckId: card.deckId,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
+ state: card.state,
+ noteType: {
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ },
+ fieldValuesMap,
+ });
+ }
+
+ return result;
+}
diff --git a/src/client/pages/DeckCardsPage.test.tsx b/src/client/pages/DeckCardsPage.test.tsx
index c498056..0ea9822 100644
--- a/src/client/pages/DeckCardsPage.test.tsx
+++ b/src/client/pages/DeckCardsPage.test.tsx
@@ -417,12 +417,9 @@ describe("DeckCardsPage", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- it("deletes note and refreshes list on confirmation", async () => {
+ it("submits the note delete via the delete endpoint", async () => {
const user = userEvent.setup();
- mockCardsGet.mockResolvedValue({
- cards: [mockCards[1]],
- });
mockNoteDelete.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
@@ -457,10 +454,6 @@ describe("DeckCardsPage", () => {
expect(mockNoteDelete).toHaveBeenCalledWith({
param: { deckId: "deck-1", noteId: "note-1" },
});
-
- await waitFor(() => {
- expect(screen.getByText("(1)")).toBeDefined();
- });
});
it("displays error when delete fails", async () => {
@@ -568,10 +561,9 @@ describe("DeckCardsPage", () => {
).toBeDefined();
});
- it("deletes note and refreshes list when confirmed", async () => {
+ it("submits the note delete via the delete endpoint", async () => {
const user = userEvent.setup();
- mockCardsGet.mockResolvedValue({ cards: [] });
mockNoteDelete.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
@@ -603,10 +595,6 @@ describe("DeckCardsPage", () => {
expect(mockNoteDelete).toHaveBeenCalledWith({
param: { deckId: "deck-1", noteId: "note-1" },
});
-
- await waitFor(() => {
- expect(screen.getByText("No cards yet")).toBeDefined();
- });
});
it("displays note preview from normal card content", () => {
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index a552c7f..3b053f0 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -64,17 +64,6 @@ const mockFetch = vi.fn();
global.fetch = mockFetch;
// Helper to create mock responses compatible with Hono's ClientResponse
-function mockResponse(data: {
- ok: boolean;
- status?: number;
- // biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
- json: () => Promise<any>;
-}) {
- return data as unknown as Awaited<
- ReturnType<typeof apiClient.rpc.api.decks.$get>
- >;
-}
-
function mockPostResponse(data: {
ok: boolean;
status?: number;
@@ -280,27 +269,10 @@ describe("HomePage", () => {
expect(deckCard?.querySelectorAll("p").length).toBe(0);
});
- it("passes auth header when fetching decks", async () => {
- testQueryClient = new QueryClient({
- defaultOptions: {
- queries: { staleTime: 0, retry: false },
- },
- });
-
- vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({ decks: [] }),
- }),
- );
-
- renderWithProviders();
-
- await waitFor(() => {
- expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledWith(undefined, {
- headers: { Authorization: "Bearer access-token" },
- });
- });
+ it.skip("passes auth header when fetching decks", async () => {
+ // Decks are now read from IndexedDB; the GET decks API is no longer
+ // invoked by the decksAtom queryFn. Auth headers for the underlying
+ // sync pull are exercised in sync-layer tests.
});
describe("Create Deck", () => {
@@ -335,7 +307,7 @@ describe("HomePage", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- it("creates deck and refreshes list", async () => {
+ it("submits the new deck via the create endpoint", async () => {
const user = userEvent.setup();
const newDeck = {
id: "deck-new",
@@ -350,14 +322,6 @@ describe("HomePage", () => {
updatedAt: "2024-01-03T00:00:00Z",
};
- // After mutation, the list will refetch
- vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({ decks: [newDeck] }),
- }),
- );
-
vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
mockPostResponse({
ok: true,
@@ -365,35 +329,21 @@ describe("HomePage", () => {
}),
);
- // Start with empty decks (hydrated)
renderWithProviders({ initialDecks: [] });
- // Open modal
await user.click(screen.getByRole("button", { name: /New Deck/i }));
-
- // Fill in form
await user.type(screen.getByLabelText("Name"), "New Deck");
await user.type(
screen.getByLabelText("Description (optional)"),
"A new deck",
);
-
- // Submit
await user.click(screen.getByRole("button", { name: "Create Deck" }));
- // Modal should close
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Deck list should be refreshed with new deck
- await waitFor(() => {
- expect(screen.getByRole("heading", { name: "New Deck" })).toBeDefined();
- });
- expect(screen.getByText("A new deck")).toBeDefined();
-
- // API should have been called once (refresh after creation)
- expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(1);
+ expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledTimes(1);
});
});
@@ -438,55 +388,34 @@ describe("HomePage", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- it("edits deck and refreshes list", async () => {
+ it("submits the edited deck via the update endpoint", async () => {
const user = userEvent.setup();
const updatedDeck = {
...mockDecks[0],
name: "Updated Japanese",
};
- // After mutation, the list will refetch
- vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({ decks: [updatedDeck, mockDecks[1]] }),
- }),
- );
-
mockDeckPut.mockResolvedValue({
ok: true,
json: async () => ({ deck: updatedDeck }),
});
- // Start with initial decks (hydrated)
renderWithProviders({ initialDecks: mockDecks });
- // Click Edit on first deck
const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
await user.click(editButtons.at(0) as HTMLElement);
- // Update name
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Updated Japanese");
- // Save
await user.click(screen.getByRole("button", { name: "Save Changes" }));
- // Modal should close
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Deck list should be refreshed with updated name
- await waitFor(() => {
- expect(
- screen.getByRole("heading", { name: "Updated Japanese" }),
- ).toBeDefined();
- });
-
- // API should have been called once (refresh after update)
- expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(1);
+ expect(mockDeckPut).toHaveBeenCalledTimes(1);
});
});
@@ -538,37 +467,25 @@ describe("HomePage", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- it("deletes deck and refreshes list", async () => {
+ it("submits the delete via the delete endpoint", async () => {
const user = userEvent.setup();
- // After mutation, the list will refetch
- vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({ decks: [mockDecks[1]] }),
- }),
- );
-
mockDeckDelete.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
- // Start with initial decks (hydrated)
renderWithProviders({ initialDecks: mockDecks });
- // Click Delete on first deck
const deleteButtons = screen.getAllByRole("button", {
name: "Delete deck",
});
await user.click(deleteButtons.at(0) as HTMLElement);
- // Wait for modal to appear
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeDefined();
});
- // Confirm deletion - get the Delete button inside the dialog
const dialog = screen.getByRole("dialog");
const dialogButtons = dialog.querySelectorAll("button");
const deleteButton = Array.from(dialogButtons).find(
@@ -576,23 +493,11 @@ describe("HomePage", () => {
);
await user.click(deleteButton as HTMLElement);
- // Modal should close
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Deck list should be refreshed without deleted deck
- await waitFor(() => {
- expect(
- screen.queryByRole("heading", { name: "Japanese Vocabulary" }),
- ).toBeNull();
- });
- expect(
- screen.getByRole("heading", { name: "Spanish Verbs" }),
- ).toBeDefined();
-
- // API should have been called once (refresh after deletion)
- expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(1);
+ expect(mockDeckDelete).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx
index 612cf16..1a41185 100644
--- a/src/client/pages/NoteTypesPage.test.tsx
+++ b/src/client/pages/NoteTypesPage.test.tsx
@@ -267,7 +267,7 @@ describe("NoteTypesPage", () => {
).toBeDefined();
});
- it("creates note type and refreshes list", async () => {
+ it("submits the new note type via the create endpoint", async () => {
const user = userEvent.setup();
const newNoteType = {
id: "note-type-new",
@@ -279,35 +279,22 @@ describe("NoteTypesPage", () => {
updatedAt: "2024-01-03T00:00:00Z",
};
- // Mock the POST response and subsequent GET after reload
mockNoteTypesPost.mockResolvedValue({
ok: true,
json: async () => ({ noteType: newNoteType }),
});
- mockNoteTypesGet.mockResolvedValue({ noteTypes: [newNoteType] });
renderWithProviders({ initialNoteTypes: [] });
- // Open modal
await user.click(screen.getByRole("button", { name: /New Note Type/i }));
-
- // Fill in form
await user.type(screen.getByLabelText("Name"), "New Note Type");
-
- // Submit
await user.click(screen.getByRole("button", { name: "Create" }));
- // Modal should close
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Note type list should be refreshed with new note type
- await waitFor(() => {
- expect(
- screen.getByRole("heading", { name: "New Note Type" }),
- ).toBeDefined();
- });
+ expect(mockNoteTypesPost).toHaveBeenCalledTimes(1);
});
});
@@ -364,7 +351,7 @@ describe("NoteTypesPage", () => {
});
});
- it("edits note type and refreshes list", async () => {
+ it("submits the edited note type via the update endpoint", async () => {
const user = userEvent.setup();
const mockNoteTypeWithFields = {
...mockNoteTypes[0],
@@ -395,42 +382,29 @@ describe("NoteTypesPage", () => {
ok: true,
json: async () => ({ noteType: updatedNoteType }),
});
- mockNoteTypesGet.mockResolvedValue({
- noteTypes: [updatedNoteType, mockNoteTypes[1]],
- });
renderWithProviders({ initialNoteTypes: mockNoteTypes });
- // Click Edit on first note type
const editButtons = screen.getAllByRole("button", {
name: "Edit note type",
});
await user.click(editButtons.at(0) as HTMLElement);
- // Wait for the editor to load
await waitFor(() => {
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
});
- // Update name
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Updated Basic");
- // Save
await user.click(screen.getByRole("button", { name: "Save Changes" }));
- // Modal should close
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Note type list should be refreshed with updated name
- await waitFor(() => {
- expect(
- screen.getByRole("heading", { name: "Updated Basic" }),
- ).toBeDefined();
- });
+ expect(mockNoteTypePut).toHaveBeenCalledTimes(1);
});
});
@@ -463,29 +437,25 @@ describe("NoteTypesPage", () => {
expect(dialog.textContent).toContain("Basic");
});
- it("deletes note type and refreshes list", async () => {
+ it("submits the note type delete via the delete endpoint", async () => {
const user = userEvent.setup();
mockNoteTypeDelete.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
- mockNoteTypesGet.mockResolvedValue({ noteTypes: [mockNoteTypes[1]] });
renderWithProviders({ initialNoteTypes: mockNoteTypes });
- // Click Delete on first note type
const deleteButtons = screen.getAllByRole("button", {
name: "Delete note type",
});
await user.click(deleteButtons.at(0) as HTMLElement);
- // Wait for modal to appear
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeDefined();
});
- // Confirm deletion
const dialog = screen.getByRole("dialog");
const dialogButtons = dialog.querySelectorAll("button");
const deleteButton = Array.from(dialogButtons).find(
@@ -493,18 +463,11 @@ describe("NoteTypesPage", () => {
);
await user.click(deleteButton as HTMLElement);
- // Modal should close
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Note type list should be refreshed without deleted note type
- await waitFor(() => {
- expect(screen.queryByRole("heading", { name: "Basic" })).toBeNull();
- });
- expect(
- screen.getByRole("heading", { name: "Basic (and reversed card)" }),
- ).toBeDefined();
+ expect(mockNoteTypeDelete).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx
index aa33260..1fa6e71 100644
--- a/src/client/pages/StudyPage.test.tsx
+++ b/src/client/pages/StudyPage.test.tsx
@@ -36,7 +36,6 @@ vi.mock(import("../sync"), async (importOriginal) => {
mockSubmitReview(args),
undoReviewLocal: (args: Parameters<typeof actual.undoReviewLocal>[0]) =>
mockUndoReview(args),
- cacheStudyCards: vi.fn().mockResolvedValue(undefined),
};
});
@@ -136,21 +135,7 @@ function makeStudyCard(overrides: Partial<StudyCard>): StudyCard {
deckId: "deck-1",
noteId: "note-1",
isReversed: false,
- front: "Hello",
- back: "こんにちは",
state: 0,
- due: "2024-01-01T00:00:00Z",
- stability: 0,
- difficulty: 0,
- elapsedDays: 0,
- scheduledDays: 0,
- reps: 0,
- lapses: 0,
- lastReview: null,
- createdAt: "2024-01-01T00:00:00Z",
- updatedAt: "2024-01-01T00:00:00Z",
- deletedAt: null,
- syncVersion: 0,
noteType: { frontTemplate: "{{Front}}", backTemplate: "{{Back}}" },
fieldValuesMap: { Front: "Hello", Back: "こんにちは" },
...overrides,
@@ -161,15 +146,11 @@ const mockFirstCard = makeStudyCard({});
const mockSecondCard = makeStudyCard({
id: "card-2",
noteId: "note-2",
- front: "Goodbye",
- back: "さようなら",
fieldValuesMap: { Front: "Goodbye", Back: "さようなら" },
});
const mockThirdCard = makeStudyCard({
id: "card-3",
noteId: "note-3",
- front: "Thank you",
- back: "ありがとう",
fieldValuesMap: { Front: "Thank you", Back: "ありがとう" },
});
diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts
index d565569..59ae15a 100644
--- a/src/client/sync/index.ts
+++ b/src/client/sync/index.ts
@@ -44,8 +44,6 @@ export {
syncQueue,
} from "./queue";
export {
- cacheStudyCards,
- type ServerStudyCard,
type SubmitReviewResult,
submitReviewLocal,
undoReviewLocal,
diff --git a/src/client/sync/scheduler.test.ts b/src/client/sync/scheduler.test.ts
index adee34e..d14b5ae 100644
--- a/src/client/sync/scheduler.test.ts
+++ b/src/client/sync/scheduler.test.ts
@@ -10,12 +10,7 @@ import {
localReviewLogRepository,
} from "../db/repositories";
import { syncQueue } from "./queue";
-import {
- cacheStudyCards,
- type ServerStudyCard,
- submitReviewLocal,
- undoReviewLocal,
-} from "./scheduler";
+import { submitReviewLocal, undoReviewLocal } from "./scheduler";
async function clearDb() {
await db.decks.clear();
@@ -182,71 +177,3 @@ describe("undoReviewLocal", () => {
expect(logs).toHaveLength(0);
});
});
-
-describe("cacheStudyCards", () => {
- beforeEach(async () => {
- await clearDb();
- localStorage.clear();
- });
-
- afterEach(async () => {
- await clearDb();
- localStorage.clear();
- });
-
- function makeServerCard(id: string): ServerStudyCard {
- return {
- id,
- deckId: "deck-1",
- noteId: `note-${id}`,
- isReversed: false,
- front: "front",
- back: "back",
- state: 0,
- due: "2026-05-02T00:00:00.000Z",
- stability: 0,
- difficulty: 0,
- elapsedDays: 0,
- scheduledDays: 0,
- reps: 0,
- lapses: 0,
- lastReview: null,
- createdAt: "2026-05-01T00:00:00.000Z",
- updatedAt: "2026-05-01T00:00:00.000Z",
- deletedAt: null,
- syncVersion: 1,
- };
- }
-
- it("upserts new cards into IndexedDB as synced", async () => {
- await cacheStudyCards([makeServerCard("card-1"), makeServerCard("card-2")]);
-
- const card1 = await localCardRepository.findById("card-1");
- expect(card1?._synced).toBe(true);
- expect(card1?.due).toBeInstanceOf(Date);
- expect(card1?.syncVersion).toBe(1);
-
- const card2 = await localCardRepository.findById("card-2");
- expect(card2).toBeDefined();
- });
-
- it("does not clobber unsynced local edits", async () => {
- const deck = await seedDeck();
- const card = await seedSyncedCard(deck.id);
- await submitReviewLocal({
- cardId: card.id,
- rating: Rating.Good,
- durationMs: 1000,
- });
-
- const before = await localCardRepository.findById(card.id);
- expect(before?._synced).toBe(false);
-
- // Simulate the server returning a stale view of this card.
- await cacheStudyCards([{ ...makeServerCard(card.id), reps: 0, state: 0 }]);
-
- const after = await localCardRepository.findById(card.id);
- expect(after?._synced).toBe(false);
- expect(after?.reps).toBe(1);
- });
-});
diff --git a/src/client/sync/scheduler.ts b/src/client/sync/scheduler.ts
index 72a6e25..9c8572a 100644
--- a/src/client/sync/scheduler.ts
+++ b/src/client/sync/scheduler.ts
@@ -88,68 +88,3 @@ export async function undoReviewLocal(params: {
await localReviewLogRepository.delete(params.reviewLogId);
await syncQueue.notifyChanged();
}
-
-/**
- * Server-shaped study card. Includes all FSRS fields needed to reconstruct
- * a LocalCard so we can submit reviews offline.
- */
-export interface ServerStudyCard {
- id: string;
- deckId: string;
- noteId: string;
- isReversed: boolean;
- front: string;
- back: string;
- state: number;
- due: string;
- stability: number;
- difficulty: number;
- elapsedDays: number;
- scheduledDays: number;
- reps: number;
- lapses: number;
- lastReview: string | null;
- createdAt: string;
- updatedAt: string;
- deletedAt: string | null;
- syncVersion: number;
-}
-
-/**
- * Cache study cards into IndexedDB so the scheduler can submit reviews
- * even when the network drops mid-session. Only cards are cached here —
- * note types / fields / values come through the regular sync pull.
- */
-export async function cacheStudyCards(cards: ServerStudyCard[]): Promise<void> {
- for (const c of cards) {
- const local: LocalCard = {
- id: c.id,
- deckId: c.deckId,
- noteId: c.noteId,
- isReversed: c.isReversed,
- front: c.front,
- back: c.back,
- state: c.state as LocalCard["state"],
- due: new Date(c.due),
- stability: c.stability,
- difficulty: c.difficulty,
- elapsedDays: c.elapsedDays,
- scheduledDays: c.scheduledDays,
- reps: c.reps,
- lapses: c.lapses,
- lastReview: c.lastReview ? new Date(c.lastReview) : null,
- createdAt: new Date(c.createdAt),
- updatedAt: new Date(c.updatedAt),
- deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
- syncVersion: c.syncVersion,
- _synced: true,
- };
-
- // Don't clobber pending local edits (e.g., a review that hasn't
- // been pushed yet). If the local copy has unsynced changes, skip.
- const existing = await localCardRepository.findById(c.id);
- if (existing && !existing._synced) continue;
-
- await localCardRepository.upsertFromServer(local);
- }
-}