diff --git a/apps/cards/apps/web/src/lib/queries.ts b/apps/cards/apps/web/src/lib/queries.ts index 5bff895a6..139d9dbb1 100644 --- a/apps/cards/apps/web/src/lib/queries.ts +++ b/apps/cards/apps/web/src/lib/queries.ts @@ -7,7 +7,13 @@ */ import { liveQuery } from 'dexie'; -import { db, cardDeckTable, cardTable, cardReviewTable } from './data/database'; +import { + db, + cardDeckTable, + cardTable, + cardReviewTable, + cardStudyBlockTable, +} from './data/database'; import { decryptRecord, decryptRecords } from './data/crypto'; import type { CardFields, @@ -152,3 +158,56 @@ export function useReview(reviewId: string) { return toCardReview(r); }); } + +/** + * Map of deckId → count of currently-due reviews. Used by the deck list + * so the user can see at a glance which deck wants attention without + * opening it. + */ +export function useDueCountByDeck() { + return liveQuery(async () => { + const nowIso = new Date().toISOString(); + const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray(); + const live = due.filter((r) => !r.deletedAt); + if (live.length === 0) return new Map(); + + const cardIds = [...new Set(live.map((r) => r.cardId))]; + const cards = await cardTable.where('id').anyOf(cardIds).toArray(); + const cardToDeck = new Map(cards.filter((c) => !c.deletedAt).map((c) => [c.id, c.deckId])); + + const counts = new Map(); + for (const r of live) { + const deckId = cardToDeck.get(r.cardId); + if (!deckId) continue; + counts.set(deckId, (counts.get(deckId) ?? 0) + 1); + } + return counts; + }); +} + +/** + * Days-in-a-row with at least one review. Walks back from today; the + * first day with no row (or a soft-deleted/empty one) ends the count. + * Capped at 365 to bound the worst-case scan. + */ +export function useStreak() { + return liveQuery(async () => { + const today = new Date(); + const localKey = (d: Date) => { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; + }; + + let streak = 0; + for (let i = 0; i < 365; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const row = await cardStudyBlockTable.where('date').equals(localKey(d)).first(); + if (!row || row.deletedAt || row.cardsReviewed <= 0) break; + streak++; + } + return streak; + }); +} diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte index a66745b96..8cc2b4242 100644 --- a/apps/cards/apps/web/src/routes/+layout.svelte +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { AuthGate } from '@mana/shared-auth-ui'; import { authStore } from '$lib/stores/auth.svelte'; import { startSync, stopSync } from '$lib/data/sync'; + import { useStreak } from '$lib/queries'; let { children }: { children: Snippet } = $props(); @@ -20,6 +21,11 @@ startSync(authStore); } + // Live streak — recomputed whenever cardStudyBlocks changes. Lives at + // the layout level so the count is visible from every gated page. + const streakQuery = $derived(useStreak()); + const streak = $derived(($streakQuery as number | undefined) ?? 0); + onDestroy(() => stopSync()); @@ -33,6 +39,14 @@ 🃏 Cards
+ {#if streak > 0} + + 🔥 {streak} + + {/if} {#if authStore.user?.email} {/if} diff --git a/apps/cards/apps/web/src/routes/+page.svelte b/apps/cards/apps/web/src/routes/+page.svelte index b23098055..34d602eeb 100644 --- a/apps/cards/apps/web/src/routes/+page.svelte +++ b/apps/cards/apps/web/src/routes/+page.svelte @@ -1,12 +1,18 @@