feat(cards-web): streak indicator + per-deck due counts

Two small UI surfaces over data the backend already computes:
  • Header shows current streak (🔥 N) — useStreak() walks back through
    cardStudyBlocks until it finds a gap.
  • Decks list shows a "fällig"-pill per deck and a total in the header
    subline — useDueCountByDeck() joins cardReviews→cards once and
    groups by deckId.

Both queries live in lib/queries.ts and use Dexie liveQuery, so the
header refreshes automatically the moment a learn session ticks the
study block forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 12:45:04 +02:00
parent 585bee42be
commit 009fb3589e
3 changed files with 90 additions and 3 deletions

View file

@ -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<string, number>();
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<string, number>();
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;
});
}

View file

@ -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());
</script>
@ -33,6 +39,14 @@
<span class="text-base">🃏</span> Cards
</a>
<div class="flex items-center gap-3 text-xs text-neutral-500">
{#if streak > 0}
<span
class="inline-flex items-center gap-1 rounded-full bg-orange-500/15 px-2 py-0.5 text-orange-300"
title="{streak} {streak === 1 ? 'Tag' : 'Tage'} in Folge gelernt"
>
🔥 {streak}
</span>
{/if}
{#if authStore.user?.email}
<span class="hidden sm:inline">{authStore.user.email}</span>
{/if}

View file

@ -1,12 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllDecks } from '$lib/queries';
import { useAllDecks, useDueCountByDeck } from '$lib/queries';
import { deckStore } from '$lib/stores/decks.svelte';
import type { Deck } from '@mana/cards-core';
const decksQuery = $derived(useAllDecks());
const decks = $derived(($decksQuery as Deck[] | undefined) ?? []);
const dueByDeckQuery = $derived(useDueCountByDeck());
const dueByDeck = $derived(($dueByDeckQuery as Map<string, number> | undefined) ?? new Map());
const totalDue = $derived(
[...(dueByDeck as Map<string, number>).values()].reduce((a, b) => a + b, 0)
);
let showNew = $state(false);
let newTitle = $state('');
let newDesc = $state('');
@ -37,7 +43,9 @@
<h1 class="text-3xl font-semibold tracking-tight">Cards</h1>
<p class="text-sm text-neutral-400">
{decks.length}
{decks.length === 1 ? 'Deck' : 'Decks'}
{decks.length === 1 ? 'Deck' : 'Decks'}{#if totalDue > 0}
· <span class="text-amber-400">{totalDue} fällig</span>
{/if}
</p>
</div>
<button
@ -107,6 +115,7 @@
{:else}
<ul class="space-y-2">
{#each decks as deck (deck.id)}
{@const due = (dueByDeck as Map<string, number>).get(deck.id) ?? 0}
<li>
<a
href={`/decks/${deck.id}`}
@ -119,6 +128,11 @@
<span class="block truncate text-xs text-neutral-400">{deck.description}</span>
{/if}
</span>
{#if due > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-400">
{due} fällig
</span>
{/if}
<span class="text-xs text-neutral-500">{deck.cardCount}</span>
</a>
</li>