feat(articles): M8 highlights view + stats + dashboard widget

useStats() live-query aggregates total / per-status / savedThisWeek /
finishedThisWeek / topSites / totalHighlights in one scoped Dexie pass.
useAllHighlights() joins cross-article highlights with article-header
info (title, siteName, originalUrl) for rendering.

/articles/highlights — HighlightsView groups chronologically-sorted
highlights per article with color-accented stripes, click-to-reader
jumps, and two export actions:
  - Copy as Markdown (clipboard)
  - Download .md (file)
Export logic lives in lib/markdown-export.ts as a pure function
(renderHighlightsMarkdown) so future snapshot tests don't need the
render tree.

Dashboard widget: ArticlesUnreadWidget mirrors NewsUnreadWidget's
pattern — self-contained live query, top-3 unread/reading, stats
strip ("N ungelesen · M diese Woche gespeichert"), empty state
CTA to /articles/add. Registered in:
  - lib/types/dashboard.ts (WidgetType union + WIDGET_REGISTRY)
  - lib/components/dashboard/widget-registry.ts (component map)
  - lib/i18n/locales/dashboard/{de,en}.json (translations)
  fr/it/es intentionally left untranslated — consistent with how
  invoices_open and broadcasts are handled.

ListView gains a pencil button next to the settings gear linking
to /articles/highlights.

Also: plan doc marks M7 + M8 done with commit refs; M1–M8 scope is
now complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 14:12:18 +02:00
parent 8a991f7c39
commit 7611d109be
12 changed files with 631 additions and 2 deletions

View file

@ -32,6 +32,7 @@ import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgress
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
import BroadcastsWidget from '$lib/modules/broadcast/widgets/BroadcastsWidget.svelte';
@ -63,6 +64,7 @@ export const widgetComponents: Record<WidgetType, Component> = {
'activity-feed': ActivityFeedWidget,
period: PeriodWidget,
'news-unread': NewsUnreadWidget,
'articles-unread': ArticlesUnreadWidget,
'body-stats': BodyStatsWidget,
'invoices-open': InvoicesOpenWidget,
broadcasts: BroadcastsWidget,

View file

@ -155,6 +155,10 @@
"title": "News",
"description": "Top-Artikel aus deinem kuratierten Feed"
},
"articles_unread": {
"title": "Artikel",
"description": "Ungelesene Artikel aus deiner Leseliste"
},
"body_stats": {
"title": "Body",
"description": "Aktuelles Gewicht und Trainings-Status"

View file

@ -155,6 +155,10 @@
"title": "News",
"description": "Top articles from your curated feed"
},
"articles_unread": {
"title": "Articles",
"description": "Unread articles from your reading list"
},
"body_stats": {
"title": "Body",
"description": "Latest weight and training status"

View file

@ -80,6 +80,15 @@
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
</div>
<div class="header-actions">
<button
type="button"
class="icon-btn"
title="Highlights — alle markierten Stellen"
aria-label="Highlights anzeigen"
onclick={() => goto('/articles/highlights')}
>
</button>
<button
type="button"
class="icon-btn"

View file

@ -10,14 +10,18 @@ export {
useAllArticles,
useArticle,
useArticleHighlights,
useAllHighlights,
useArticleTagIds,
useArticleTagMap,
useStats,
toArticle,
toHighlight,
filterByStatus,
searchArticles,
} from './queries';
export type { ArticlesStats, SiteCount, HighlightWithArticle } from './queries';
export { articleTable, articleHighlightTable, articleTagTable } from './collections';
export type {

View file

@ -0,0 +1,68 @@
/**
* Markdown export for the highlights collection view.
*
* Groups highlights by article, dumps them in the order
* `useAllHighlights` returned them (chronological), and wraps the whole
* thing in a small header with the export date so the user can paste
* the result into Obsidian, Notion, a Markdown note whatever.
*
* Kept in a standalone file so the export logic can be unit-tested
* without needing the Svelte render tree.
*/
import type { HighlightWithArticle } from '../queries';
/** Escape the minimum set of Markdown specials that show up in article
* titles and highlight text so pasted output doesn't accidentally
* format parts of the quote. We don't escape inside the quoted block
* itself the reader's expectation is "see what I highlighted". */
function escapeTitle(text: string): string {
return text.replace(/([\\*_`[\]<>])/g, '\\$1');
}
function formatDate(iso: string): string {
try {
return new Date(iso).toISOString().slice(0, 10);
} catch {
return iso.slice(0, 10);
}
}
export function renderHighlightsMarkdown(
rows: HighlightWithArticle[],
now: Date = new Date()
): string {
const header = `# Mana Highlights — ${now.toISOString().slice(0, 10)}\n`;
if (rows.length === 0) {
return `${header}\n_Keine Highlights._\n`;
}
// Preserve the incoming chronological order but group consecutive
// rows for the same article together. Using a manual walk instead of
// Map-groupBy keeps the per-section header below the most-recent row
// for that article, matching what the UI shows.
const blocks: string[] = [header];
let currentArticleId: string | null = null;
for (const row of rows) {
if (row.article.id !== currentArticleId) {
currentArticleId = row.article.id;
blocks.push('');
blocks.push(`## ${escapeTitle(row.article.title)}`);
const subtitle = [row.article.siteName, row.article.originalUrl]
.filter((s): s is string => !!s)
.join(' · ');
if (subtitle) blocks.push(`_${subtitle}_`);
blocks.push('');
}
const savedAt = row.highlight.createdAt ? ` _(${formatDate(row.highlight.createdAt)})_` : '';
blocks.push(`- > ${row.highlight.text.replace(/\n+/g, ' ')}${savedAt}`);
if (row.highlight.note) {
blocks.push(`${row.highlight.note.replace(/\n+/g, ' ')}`);
}
}
blocks.push('');
return blocks.join('\n');
}

View file

@ -106,6 +106,149 @@ export function useArticleTagMap(articleIds: string[]) {
);
}
export interface SiteCount {
siteName: string;
count: number;
}
export interface ArticlesStats {
total: number;
unread: number;
reading: number;
finished: number;
archived: number;
favorites: number;
savedThisWeek: number;
finishedThisWeek: number;
topSites: SiteCount[];
totalHighlights: number;
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Aggregate stats for the dashboard widget + stats section. One live
* query over scope-filtered articles + highlights; decrypts only the
* articles (needed for title-based top-sites grouping).
*/
export function useStats() {
return useLiveQueryWithDefault(
async () => {
const [articleRows, highlightRows] = await Promise.all([
scopedForModule<LocalArticle, string>('articles', 'articles').toArray(),
scopedForModule<LocalHighlight, string>('articles', 'articleHighlights').toArray(),
]);
const visible = articleRows.filter((a) => !a.deletedAt);
const decrypted = await decryptRecords('articles', visible);
const now = Date.now();
const weekAgo = now - WEEK_MS;
const byStatus: Record<ArticleStatus, number> = {
unread: 0,
reading: 0,
finished: 0,
archived: 0,
};
let favorites = 0;
let savedThisWeek = 0;
let finishedThisWeek = 0;
const siteCounts = new Map<string, number>();
for (const a of decrypted) {
byStatus[a.status] = (byStatus[a.status] ?? 0) + 1;
if (a.isFavorite) favorites++;
const savedTs = a.savedAt ? Date.parse(a.savedAt) : NaN;
if (Number.isFinite(savedTs) && savedTs >= weekAgo) savedThisWeek++;
const readTs = a.readAt ? Date.parse(a.readAt) : NaN;
if (Number.isFinite(readTs) && readTs >= weekAgo) finishedThisWeek++;
if (a.siteName) {
siteCounts.set(a.siteName, (siteCounts.get(a.siteName) ?? 0) + 1);
}
}
const topSites: SiteCount[] = [...siteCounts.entries()]
.map(([siteName, count]) => ({ siteName, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
const totalHighlights = highlightRows.filter((h) => !h.deletedAt).length;
return {
total: decrypted.length,
unread: byStatus.unread,
reading: byStatus.reading,
finished: byStatus.finished,
archived: byStatus.archived,
favorites,
savedThisWeek,
finishedThisWeek,
topSites,
totalHighlights,
} satisfies ArticlesStats;
},
{
total: 0,
unread: 0,
reading: 0,
finished: 0,
archived: 0,
favorites: 0,
savedThisWeek: 0,
finishedThisWeek: 0,
topSites: [],
totalHighlights: 0,
} as ArticlesStats
);
}
/**
* Cross-article highlights query for `/articles/highlights`. Fetches
* all articles + all highlights in the active scope, pairs them up,
* and returns rows shaped for rendering as a chronological collection.
* Articles without highlights are excluded.
*/
export interface HighlightWithArticle {
highlight: Highlight;
article: Pick<Article, 'id' | 'title' | 'siteName' | 'originalUrl'>;
}
export function useAllHighlights() {
return useLiveQueryWithDefault(async () => {
const [articleRows, highlightRows] = await Promise.all([
scopedForModule<LocalArticle, string>('articles', 'articles').toArray(),
scopedForModule<LocalHighlight, string>('articles', 'articleHighlights').toArray(),
]);
const liveArticles = articleRows.filter((a) => !a.deletedAt);
const liveHighlights = highlightRows.filter((h) => !h.deletedAt);
if (liveHighlights.length === 0) return [] as HighlightWithArticle[];
const [decArticles, decHighlights] = await Promise.all([
decryptRecords('articles', liveArticles),
decryptRecords('articleHighlights', liveHighlights),
]);
const byId = new Map(decArticles.map((a) => [a.id, toArticle(a)]));
return decHighlights
.map((h) => {
const art = byId.get(h.articleId);
if (!art) return null;
return {
highlight: toHighlight(h),
article: {
id: art.id,
title: art.title,
siteName: art.siteName,
originalUrl: art.originalUrl,
},
} satisfies HighlightWithArticle;
})
.filter((r): r is HighlightWithArticle => r !== null)
.sort((a, b) => (b.highlight.createdAt ?? '').localeCompare(a.highlight.createdAt ?? ''));
}, [] as HighlightWithArticle[]);
}
export function useArticleHighlights(articleId: string) {
return useLiveQueryWithDefault(async () => {
// scopedForModule returns the scope-filtered Collection; we narrow

View file

@ -0,0 +1,301 @@
<!--
HighlightsView — Sammelansicht über alle Highlights.
Gruppiert die chronologisch sortierten Highlights pro Artikel
(gleiche Reihenfolge, die useAllHighlights liefert) und rendert sie
mit Farb-Akzent + optionaler Notiz. "Export" kopiert die Sammlung
als Markdown in die Zwischenablage; "Download" speichert sie als
.md-Datei.
Klick auf ein Highlight oder auf den Artikel-Header springt zurück
in den Reader.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllHighlights, type HighlightWithArticle } from '../queries';
import { renderHighlightsMarkdown } from '../lib/markdown-export';
const rows$ = useAllHighlights();
const rows = $derived(rows$.value);
interface Group {
articleId: string;
article: HighlightWithArticle['article'];
highlights: HighlightWithArticle['highlight'][];
}
const groups = $derived.by<Group[]>(() => {
const out: Group[] = [];
let current: Group | null = null;
for (const row of rows) {
if (!current || current.articleId !== row.article.id) {
current = {
articleId: row.article.id,
article: row.article,
highlights: [row.highlight],
};
out.push(current);
} else {
current.highlights.push(row.highlight);
}
}
return out;
});
let exportLabel = $state('Als Markdown kopieren');
async function copyMarkdown() {
const md = renderHighlightsMarkdown(rows);
try {
await navigator.clipboard.writeText(md);
exportLabel = 'Kopiert ✓';
setTimeout(() => (exportLabel = 'Als Markdown kopieren'), 1500);
} catch {
exportLabel = 'Fehler — bitte manuell';
}
}
function downloadMarkdown() {
const md = renderHighlightsMarkdown(rows);
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mana-highlights-${new Date().toISOString().slice(0, 10)}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<svelte:head>
<title>Highlights — Artikel — Mana</title>
</svelte:head>
<div class="shell">
<header class="header">
<div class="header-row">
<div>
<h1>Highlights</h1>
<p class="subtitle">Alle markierten Stellen aus deinen gespeicherten Artikeln.</p>
</div>
<button type="button" class="back" onclick={() => goto('/articles')}> Zurück</button>
</div>
{#if rows.length > 0}
<div class="actions">
<button type="button" class="ghost" onclick={copyMarkdown}>{exportLabel}</button>
<button type="button" class="ghost" onclick={downloadMarkdown}>Als .md herunterladen</button
>
</div>
{/if}
</header>
{#if rows$.loading}
<p class="muted center">Lädt…</p>
{:else if groups.length === 0}
<div class="empty">
<p class="empty-headline">Noch keine Highlights.</p>
<p class="empty-sub">
Markier eine Textstelle in einem gespeicherten Artikel — sie erscheint hier automatisch.
</p>
<button type="button" class="cta" onclick={() => goto('/articles')}>Zur Leseliste</button>
</div>
{:else}
<div class="groups">
{#each groups as group (group.articleId)}
<section class="group">
<header class="group-header">
<button
type="button"
class="article-link"
onclick={() => goto(`/articles/${group.articleId}`)}
title="Artikel öffnen"
>
<span class="title">{group.article.title}</span>
{#if group.article.siteName}
<span class="site">{group.article.siteName}</span>
{/if}
</button>
</header>
<ul class="hl-list">
{#each group.highlights as h (h.id)}
<li class="hl hl-{h.color}">
<button
type="button"
class="hl-text"
onclick={() => goto(`/articles/${group.articleId}`)}
title="Im Artikel öffnen"
>
{h.text}"
</button>
{#if h.note}
<p class="hl-note">{h.note}</p>
{/if}
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
</div>
<style>
.shell {
max-width: 800px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.25rem;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
}
.subtitle {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.95rem;
}
.back,
.ghost,
.cta {
font: inherit;
padding: 0.45rem 0.85rem;
border-radius: 0.5rem;
cursor: pointer;
}
.back,
.ghost {
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
}
.back:hover,
.ghost:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.actions {
margin-top: 0.9rem;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.cta {
border: 1px solid #f97316;
background: #f97316;
color: white;
}
.cta:hover {
background: #ea580c;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.empty {
margin-top: 2.5rem;
padding: 2rem;
text-align: center;
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.15));
border-radius: 0.75rem;
}
.empty-headline {
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.empty-sub {
margin: 0 0 1.25rem 0;
color: var(--color-text-muted, #64748b);
}
.groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.group {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.article-link {
font: inherit;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
text-align: left;
padding: 0.2rem 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.article-link:hover .title {
color: #f97316;
}
.title {
font-weight: 600;
font-size: 1.05rem;
}
.site {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
}
.hl-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hl {
padding: 0.5rem 0.75rem;
border-radius: 0.45rem;
border-left: 3px solid transparent;
}
.hl-yellow {
background: color-mix(in srgb, #fde68a 60%, transparent);
border-left-color: #f59e0b;
}
.hl-green {
background: color-mix(in srgb, #bbf7d0 60%, transparent);
border-left-color: #10b981;
}
.hl-blue {
background: color-mix(in srgb, #bfdbfe 60%, transparent);
border-left-color: #3b82f6;
}
.hl-pink {
background: color-mix(in srgb, #fbcfe8 60%, transparent);
border-left-color: #ec4899;
}
.hl-text {
font: inherit;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
padding: 0;
line-height: 1.5;
}
.hl-note {
margin: 0.4rem 0 0 0;
font-size: 0.88rem;
color: var(--color-text-muted, #334155);
font-style: italic;
}
</style>

View file

@ -0,0 +1,78 @@
<script lang="ts">
/**
* ArticlesUnreadWidget — dashboard tile for the articles module.
*
* Shows up to three unread articles + a one-line stats strip (saved
* this week / total unread). Mirrors the NewsUnreadWidget pattern:
* self-contained liveQuery, no props, renders its own tile chrome.
*/
import { useAllArticles, useStats } from '../queries';
const articles$ = useAllArticles();
const stats$ = useStats();
const articles = $derived(articles$.value);
const stats = $derived(stats$.value);
const topUnread = $derived(
articles.filter((a) => a.status === 'unread' || a.status === 'reading').slice(0, 3)
);
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span aria-hidden="true">📚</span>
Artikel
</h3>
<a href="/articles" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
</div>
{#if articles$.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if articles.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Noch keine Artikel gespeichert.</p>
<a
href="/articles/add"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Erste URL speichern
</a>
</div>
{:else if topUnread.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Alles gelesen — stark.</p>
<a href="/articles" class="mt-3 inline-block text-xs text-primary hover:underline">
Leseliste öffnen
</a>
</div>
{:else}
<div class="space-y-1.5">
{#each topUnread as article (article.id)}
<a
href="/articles/{article.id}"
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
<div class="mt-0.5 flex gap-1.5 text-xs text-muted-foreground">
{#if article.siteName}
<span class="font-medium">{article.siteName}</span>
{/if}
{#if article.readingTimeMinutes}
<span>·</span>
<span>{article.readingTimeMinutes} min</span>
{/if}
</div>
</a>
{/each}
</div>
<div class="mt-3 border-t border-border/50 pt-2 text-xs text-muted-foreground">
{stats.unread} ungelesen · {stats.savedThisWeek} diese Woche gespeichert
</div>
{/if}
</div>

View file

@ -32,6 +32,7 @@ export type WidgetType =
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'period' // Period: current phase + days until next period
| 'news-unread' // News: latest unread curated articles
| 'articles-unread' // Articles: saved read-it-later articles
| 'body-stats' // Body: latest weight + active workout summary
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
| 'broadcasts'; // Broadcast: YTD counts + last sent + next scheduled
@ -355,6 +356,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
defaultSize: 'small',
allowMultiple: false,
},
{
type: 'articles-unread',
nameKey: 'dashboard.widgets.articles_unread.title',
descriptionKey: 'dashboard.widgets.articles_unread.description',
icon: '📚',
defaultSize: 'small',
allowMultiple: false,
},
{
type: 'body-stats',
nameKey: 'dashboard.widgets.body_stats.title',

View file

@ -0,0 +1,5 @@
<script lang="ts">
import HighlightsView from '$lib/modules/articles/views/HighlightsView.svelte';
</script>
<HighlightsView />

View file

@ -16,11 +16,13 @@
**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden.
**M7 Share-Target + Bookmarklet: DONE** (commit pending) — `@mana/shared-pwa` bekommt neue Types (`PWAShareTarget`, `PWAShareTargetParams`), `createPWAConfig` threadet `shareTarget` in den Manifest, `ManifestConfig.share_target?` ergänzt. Web-App: `vite.config.ts` setzt `shareTarget: { action: '/articles/add', method: 'GET', params: { title, text, url } }`; `AddUrlForm` liest Query-Params in `onMount` (inkl. URL-Regex-Fallback auf `text` weil Chrome Android / WhatsApp den Link dort reinstecken), triggert auto-Vorschau. Neue Route `/articles/settings` rendert Bookmarklet-Karte (Drag-to-Bookmark + Copy-Snippet + expandable Quellcode) und Share-Target-Erklärung. `ListView` bekommt Zahnrad-Button zum Settings-Aufruf.
**M7 Share-Target + Bookmarklet: DONE** (commit `8a991f7c3`) — `@mana/shared-pwa` bekommt neue Types (`PWAShareTarget`, `PWAShareTargetParams`), `createPWAConfig` threadet `shareTarget` in den Manifest, `ManifestConfig.share_target?` ergänzt. Web-App: `vite.config.ts` setzt `shareTarget: { action: '/articles/add', method: 'GET', params: { title, text, url } }`; `AddUrlForm` liest Query-Params in `onMount` (inkl. URL-Regex-Fallback auf `text` weil Chrome Android / WhatsApp den Link dort reinstecken), triggert auto-Vorschau. Neue Route `/articles/settings` rendert Bookmarklet-Karte (Drag-to-Bookmark + Copy-Snippet + expandable Quellcode) und Share-Target-Erklärung. `ListView` bekommt Zahnrad-Button zum Settings-Aufruf.
Nicht im Scope (bewusst ausgelassen): die „optional" im Plan markierte `_pendingUrls`-Offline-Queue. Kann als M7b nachgereicht werden wenn das Problem auftaucht.
Nächster Schritt: M8 (HighlightsView + Stats + Dashboard-Widget).
**M8 HighlightsView + Stats + Dashboard-Widget: DONE** (commit pending) — `queries.ts` bekommt `useStats()` (total, pro-Status, savedThisWeek, finishedThisWeek, topSites, totalHighlights) und `useAllHighlights()` (chronologisch, joined mit Artikel-Header-Info). Neue Route `/articles/highlights` mountet `HighlightsView.svelte`: Highlights pro Artikel gruppiert, farbige Akzent-Striche, Click-to-Reader, Copy-Markdown + Download-.md via `lib/markdown-export.ts` (pure Funktion, unit-testbar). Dashboard-Widget `ArticlesUnreadWidget` zeigt Top-3 unread + Stats-Strip; registriert in `widget-registry.ts`, `dashboard.ts` `WidgetType`-Union + `WIDGET_REGISTRY`; i18n-Keys in de.json + en.json (fr/it/es konsistent mit anderen Widgets weggelassen). ListView bekommt ✎-Button zum Highlights-Aufruf neben dem Settings-Zahnrad.
Damit ist der komplette M1M8-Scope aus diesem Plan umgesetzt. Phase-3-Kandidaten (Highlight→Note-Export mit Backlink, Full-Text-Search, Mercury/archive.org-Fallback, Jahresrückblick) bleiben offen für spätere Iterationen.
## Ziel