feat(cards-web): PDF input for AI generator + study activity heatmap

PDF input:
  • lib/ai/pdf.ts wraps pdfjs-dist (Apache-2.0). Worker is bound via
    Vite's `?worker` suffix so the heavy parsing runs off-main-thread.
  • AiCardGen gains a "📄 PDF laden" button that pipes extracted text
    into the same textarea — the user can review/trim before
    generation. Reading state shows file name + page count + chars.

Heatmap:
  • queries.useStudyHeatmap(weeks=12) fills gaps with count=0 so the
    grid renders without holes.
  • StudyHeatmap.svelte: 7 rows × N columns (Monday-anchored), 5
    intensity buckets (neutral → emerald-300), tooltip per cell with
    date + count, legend strip.
  • Mounted on the dashboard between the deck list and the Anki import
    so the user lands on a quick visual progress receipt every visit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 13:37:01 +02:00
parent d8a35afd99
commit 1f2206f10b
7 changed files with 375 additions and 10 deletions

View file

@ -41,6 +41,7 @@
"@mana/shared-utils": "workspace:*",
"dexie": "^4.4.1",
"jszip": "^3.10.1",
"pdfjs-dist": "^5.7.284",
"sql.js": "^1.14.1"
}
}

View file

@ -0,0 +1,56 @@
/**
* PDF text extraction using pdfjs-dist.
*
* Loads each page, walks the text layer, joins items with spaces and
* pages with double newlines so the LLM gets a structured input. We
* don't try to preserve columns / tables the use case is "feed me
* the prose so I can make cards", not document fidelity.
*
* Worker is wired via Vite's `?worker` suffix so the heavy parsing
* happens off the main thread (PDF extraction is CPU-heavy).
*/
import * as pdfjs from 'pdfjs-dist';
import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker';
let workerWired = false;
function ensureWorker() {
if (workerWired) return;
pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker();
workerWired = true;
}
export interface PdfExtractResult {
text: string;
pageCount: number;
}
export async function extractTextFromPdf(file: File | Blob): Promise<PdfExtractResult> {
ensureWorker();
const buffer = await file.arrayBuffer();
const doc = await pdfjs.getDocument({ data: new Uint8Array(buffer) }).promise;
const pages: string[] = [];
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i);
const content = await page.getTextContent();
const pieces: string[] = [];
for (const item of content.items) {
if (typeof (item as { str?: string }).str === 'string') {
pieces.push((item as { str: string }).str);
}
}
pages.push(
pieces
.join(' ')
.replace(/[ \t]+/g, ' ')
.trim()
);
}
await doc.destroy();
return {
text: pages.filter(Boolean).join('\n\n'),
pageCount: doc.numPages,
};
}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { generateCardsFromText, type GeneratedCard } from '$lib/ai/generate';
import { extractTextFromPdf } from '$lib/ai/pdf';
import { cardStore } from '$lib/stores/cards.svelte';
interface Props {
@ -10,8 +11,12 @@
let { deckId, currentCardCount, onCreated }: Props = $props();
let stage = $state<'idle' | 'generating' | 'preview' | 'creating' | 'done' | 'error'>('idle');
let stage = $state<
'idle' | 'reading-pdf' | 'generating' | 'preview' | 'creating' | 'done' | 'error'
>('idle');
let source = $state('');
let pdfPicker = $state<HTMLInputElement | null>(null);
let pdfStatus = $state<string | null>(null);
let generated = $state<GeneratedCard[]>([]);
let selected = $state<boolean[]>([]);
let error = $state<string | null>(null);
@ -72,6 +77,27 @@
source = '';
error = null;
createdCount = 0;
pdfStatus = null;
}
async function handlePdfPick(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
error = null;
stage = 'reading-pdf';
pdfStatus = `Lese ${file.name}…`;
try {
const result = await extractTextFromPdf(file);
source = result.text;
pdfStatus = `${file.name} · ${result.pageCount} Seiten · ${result.text.length} Zeichen`;
stage = 'idle';
} catch (e: any) {
error = e?.message ?? 'PDF konnte nicht gelesen werden.';
stage = 'error';
pdfStatus = null;
}
}
</script>
@ -97,16 +123,36 @@
{#if stage === 'error' && error}
<p class="mt-2 text-sm text-red-400">{error}</p>
{/if}
<div class="mt-2 flex items-center justify-between text-xs text-neutral-500">
<span>{source.length} Zeichen</span>
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={handleGenerate}
disabled={!source.trim()}
>
Generieren
</button>
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-neutral-500">
<div class="flex items-center gap-3">
<span>{source.length} Zeichen</span>
{#if pdfStatus}<span class="text-indigo-300">📄 {pdfStatus}</span>{/if}
</div>
<div class="flex items-center gap-2">
<button
class="rounded-lg border border-neutral-700 px-3 py-1.5 text-neutral-300 hover:bg-neutral-800"
onclick={() => pdfPicker?.click()}
>
📄 PDF laden
</button>
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={handleGenerate}
disabled={!source.trim()}
>
Generieren
</button>
</div>
</div>
<input
bind:this={pdfPicker}
type="file"
accept="application/pdf,.pdf"
class="hidden"
onchange={handlePdfPick}
/>
{:else if stage === 'reading-pdf'}
<div class="py-6 text-center text-sm text-neutral-400">{pdfStatus ?? 'Lese PDF…'}</div>
{:else if stage === 'generating'}
<div class="py-6 text-center text-sm text-neutral-400">Modell denkt nach…</div>
{:else if stage === 'preview'}

View file

@ -0,0 +1,93 @@
<script lang="ts">
/**
* GitHub-style activity grid: 7 rows (weekdays) × N columns (weeks).
* Each cell encodes one day's review count via 5 color steps.
* Tooltips on hover show the date + count.
*
* Week-start convention: we group by ISO week starting Monday so the
* top row is always Mondays — matches the European calendar convention.
*/
import { useStudyHeatmap } from '$lib/queries';
interface Props {
weeks?: number;
}
let { weeks = 12 }: Props = $props();
const dataQuery = $derived(useStudyHeatmap(weeks));
const rawDays = $derived(($dataQuery as { date: string; count: number }[] | undefined) ?? []);
// Pad to align the first day to a Monday so columns are full weeks.
const grid = $derived.by(() => {
if (rawDays.length === 0) return [] as { date: string | null; count: number }[];
const first = new Date(rawDays[0].date);
const dow = (first.getDay() + 6) % 7; // 0=Mon, 6=Sun
const padded: { date: string | null; count: number }[] = [];
for (let i = 0; i < dow; i++) padded.push({ date: null, count: 0 });
padded.push(...rawDays);
return padded;
});
const columns = $derived.by(() => {
const cols: { date: string | null; count: number }[][] = [];
for (let i = 0; i < grid.length; i += 7) cols.push(grid.slice(i, i + 7));
return cols;
});
const max = $derived(rawDays.reduce((m, d) => Math.max(m, d.count), 0));
function bucket(count: number): string {
if (count === 0) return 'bg-neutral-800';
if (count <= Math.max(1, max * 0.25)) return 'bg-emerald-900';
if (count <= max * 0.5) return 'bg-emerald-700';
if (count <= max * 0.75) return 'bg-emerald-500';
return 'bg-emerald-300';
}
function fmt(date: string): string {
const d = new Date(date);
return d.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
}
const total = $derived(rawDays.reduce((sum, d) => sum + d.count, 0));
const activeDays = $derived(rawDays.filter((d) => d.count > 0).length);
</script>
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<div class="mb-3 flex items-center justify-between text-sm">
<span class="font-medium">Lernaktivität</span>
<span class="text-xs text-neutral-500">
{total} Karten · {activeDays} aktive {activeDays === 1 ? 'Tag' : 'Tage'} · letzte {weeks} Wochen
</span>
</div>
<div class="flex gap-1 overflow-x-auto">
{#each columns as col, ci (ci)}
<div class="flex flex-col gap-1">
{#each col as cell, ri (ri)}
{#if cell.date === null}
<div class="h-3 w-3"></div>
{:else}
<div
class="h-3 w-3 rounded-sm {bucket(cell.count)}"
title="{fmt(cell.date)}: {cell.count} {cell.count === 1 ? 'Karte' : 'Karten'}"
></div>
{/if}
{/each}
</div>
{/each}
</div>
<div class="mt-3 flex items-center gap-1 text-xs text-neutral-500">
<span>weniger</span>
<span class="ml-1 h-3 w-3 rounded-sm bg-neutral-800"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-900"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-700"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-500"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-300"></span>
<span class="ml-1">mehr</span>
</div>
</div>

View file

@ -185,6 +185,41 @@ export function useDueCountByDeck() {
});
}
/**
* Per-day review counts for the last `weeks * 7` days (default 12 weeks
* = 84 days). Used by the GitHub-style heatmap on the dashboard. Days
* with no row in cardStudyBlocks come back as count=0 so the renderer
* doesn't have to fill gaps itself.
*/
export function useStudyHeatmap(weeks: number = 12) {
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}`;
};
const days = weeks * 7;
const rows = await cardStudyBlockTable.toArray();
const byDate = new Map<string, number>();
for (const r of rows) {
if (r.deletedAt) continue;
byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.cardsReviewed);
}
const out: { date: string; count: number }[] = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = localKey(d);
out.push({ date: key, count: byDate.get(key) ?? 0 });
}
return out;
});
}
/**
* 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.

View file

@ -3,6 +3,7 @@
import { useAllDecks, useDueCountByDeck } from '$lib/queries';
import { deckStore } from '$lib/stores/decks.svelte';
import AnkiImport from '$lib/components/AnkiImport.svelte';
import StudyHeatmap from '$lib/components/StudyHeatmap.svelte';
import type { Deck } from '@mana/cards-core';
const decksQuery = $derived(useAllDecks());
@ -142,6 +143,10 @@
{/if}
<div class="mt-10">
<StudyHeatmap />
</div>
<div class="mt-6">
<AnkiImport />
</div>

129
pnpm-lock.yaml generated
View file

@ -232,6 +232,9 @@ importers:
jszip:
specifier: ^3.10.1
version: 3.10.1
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
sql.js:
specifier: ^1.14.1
version: 1.14.1
@ -6215,6 +6218,76 @@ packages:
cpu: [x64]
os: [win32]
'@napi-rs/canvas-android-arm64@0.1.100':
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.100':
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.100':
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.100':
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
engines: {node: '>= 10'}
'@nestjs/cli@10.4.9':
resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==}
engines: {node: '>= 16.14'}
@ -13921,6 +13994,10 @@ packages:
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pdfjs-dist@5.7.284:
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
engines: {node: '>=22.13.0 || >=24'}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@ -20733,6 +20810,54 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
'@napi-rs/canvas-android-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.100':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
optional: true
'@napi-rs/canvas@0.1.100':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.100
'@napi-rs/canvas-darwin-arm64': 0.1.100
'@napi-rs/canvas-darwin-x64': 0.1.100
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-musl': 0.1.100
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
optional: true
'@nestjs/cli@10.4.9':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@ -31164,6 +31289,10 @@ snapshots:
pako: 1.0.11
tslib: 1.14.1
pdfjs-dist@5.7.284:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
pend@1.2.0: {}
performance-now@2.1.0: