diff options
Diffstat (limited to 'src/client/atoms')
| -rw-r--r-- | src/client/atoms/cards.ts | 51 | ||||
| -rw-r--r-- | src/client/atoms/decks.ts | 124 | ||||
| -rw-r--r-- | src/client/atoms/noteTypes.ts | 49 | ||||
| -rw-r--r-- | src/client/atoms/study.ts | 60 | ||||
| -rw-r--r-- | src/client/atoms/sync.ts | 50 |
5 files changed, 270 insertions, 64 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); |
