mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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>
93 lines
3.1 KiB
Svelte
93 lines
3.1 KiB
Svelte
<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>
|