From 7ca9941982a7d7a4c126d215770ce71ad2f7f427 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 11:11:53 +0900 Subject: 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. --- src/client/atoms/cards.ts | 51 ++++++-- src/client/atoms/decks.ts | 124 ++++++++++++++++--- src/client/atoms/noteTypes.ts | 49 +++++++- src/client/atoms/study.ts | 60 ++++----- src/client/atoms/sync.ts | 50 +++++++- src/client/components/CreateCardModal.tsx | 6 +- src/client/components/CreateDeckModal.tsx | 6 +- src/client/components/CreateNoteModal.tsx | 8 +- src/client/components/CreateNoteTypeModal.tsx | 6 +- src/client/components/DeleteCardModal.tsx | 6 +- src/client/components/DeleteDeckModal.tsx | 6 +- src/client/components/DeleteNoteModal.tsx | 6 +- src/client/components/DeleteNoteTypeModal.tsx | 6 +- src/client/components/EditCardModal.tsx | 6 +- src/client/components/EditDeckModal.tsx | 6 +- src/client/components/EditNoteModal.tsx | 8 +- src/client/components/EditNoteTypeModal.tsx | 6 +- src/client/components/ImportNotesModal.tsx | 6 +- src/client/components/SyncStatusIndicator.tsx | 6 +- src/client/db/study-builder.test.ts | 169 ++++++++++++++++++++++++++ src/client/db/study-builder.ts | 82 +++++++++++++ src/client/pages/DeckCardsPage.test.tsx | 16 +-- src/client/pages/HomePage.test.tsx | 115 ++---------------- src/client/pages/NoteTypesPage.test.tsx | 49 +------- src/client/pages/StudyPage.test.tsx | 19 --- src/client/sync/index.ts | 2 - src/client/sync/scheduler.test.ts | 75 +----------- src/client/sync/scheduler.ts | 65 ---------- 28 files changed, 614 insertions(+), 400 deletions(-) create mode 100644 src/client/db/study-builder.test.ts create mode 100644 src/client/db/study-builder.ts (limited to 'src/client') 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 { + 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 => { + 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 { + 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 { + 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 { + 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 => { + 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 { + 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 => { + 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 { + 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 { + 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 => { + 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; -} +export type StudyCard = StudyCardView; export interface StudyDeck { id: string; @@ -25,35 +18,36 @@ export interface StudyData { cards: StudyCard[]; } +async function loadStudyData(deckId: string): Promise { + 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 => { - // 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 @@ -181,6 +183,34 @@ const syncManager = createSyncManager({ conflictResolver, }); +// ===================== +// 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 | null = null; + +export function ensureBootstrap(): Promise { + 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(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const resetForm = () => { setFront(""); @@ -163,7 +166,8 @@ export function CreateCardModal({