mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
585bee42be
commit
009fb3589e
3 changed files with 90 additions and 3 deletions
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue