diff options
Diffstat (limited to 'src/client/atoms/decks.ts')
| -rw-r--r-- | src/client/atoms/decks.ts | 124 |
1 files changed, 109 insertions, 15 deletions
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; }, })), ); |
