feat(mana/web/news): workbench ListView + dashboard widget

Surfaces News in two extra entry points beyond the dedicated /news
route. The workbench ListView is a compact ranked-feed view designed
for the AppPage carousel slot — it boots the same feed-cache poll, runs
the same scoreArticle pipeline, but renders smaller cards and skips the
onboarding wizard (un-onboarded users get a CTA pointing them at /news
instead). The NewsUnreadWidget shows the top three ranked unread
articles on the dashboard, sharing the exact same engine inputs so the
ordering matches the main feed. WidgetType + WIDGET_REGISTRY get the
new 'news-unread' entry, and dashboard.widgets.news_unread is added to
all five locale files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 15:54:57 +02:00
parent 8167d265a7
commit e579e292cc
9 changed files with 521 additions and 1 deletions

View file

@ -31,6 +31,7 @@ import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelt
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
import CyclesWidget from '$lib/modules/core/widgets/CyclesWidget.svelte';
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
@ -58,4 +59,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
'day-timeline': DayTimelineWidget,
'activity-feed': ActivityFeedWidget,
cycles: CyclesWidget,
'news-unread': NewsUnreadWidget,
};

View file

@ -150,6 +150,10 @@
"description": "Aktuelle Phase und Countdown zur nächsten Periode",
"empty": "Noch kein Zyklus erfasst.",
"open": "Öffnen"
},
"news_unread": {
"title": "News",
"description": "Top-Artikel aus deinem kuratierten Feed"
}
}
}

View file

@ -150,6 +150,10 @@
"description": "Current phase and countdown to next period",
"empty": "No cycle logged yet.",
"open": "Open"
},
"news_unread": {
"title": "News",
"description": "Top articles from your curated feed"
}
}
}

View file

@ -145,6 +145,10 @@
"description": "Fase actual y cuenta regresiva hasta el próximo período",
"empty": "Ningún ciclo registrado.",
"open": "Abrir"
},
"news_unread": {
"title": "Noticias",
"description": "Artículos destacados de tu feed curado"
}
}
}

View file

@ -145,6 +145,10 @@
"description": "Phase actuelle et compte à rebours jusqu'aux prochaines règles",
"empty": "Aucun cycle enregistré.",
"open": "Ouvrir"
},
"news_unread": {
"title": "Actualités",
"description": "Articles phares de ton fil personnalisé"
}
}
}

View file

@ -145,6 +145,10 @@
"description": "Fase attuale e conto alla rovescia per il prossimo ciclo",
"empty": "Nessun ciclo registrato.",
"open": "Apri"
},
"news_unread": {
"title": "Notizie",
"description": "Articoli in evidenza dal tuo feed curato"
}
}
}

View file

@ -0,0 +1,360 @@
<!--
News — Workbench ListView.
This is the version that renders inside an AppPage carousel slot, not
the dedicated /news route. Two important differences from the route:
1. We boot the feed-cache poll loop here too — a user might add the
News card to a workbench scene without ever opening the /news route,
and we don't want them to stare at an empty card.
2. The onboarding wizard lives only on the /news route. Inside the
compact workbench frame there's no room for a 3-step picker, so
un-onboarded users get a CTA card pointing them at /news.
Header is intentionally bare — the workbench AppPage already supplies
the title bar and close/move/minimize controls.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import type { ViewProps } from '$lib/app-registry';
import {
usePreferences,
useCachedFeed,
useReactions,
formatRelativeTime,
} from '$lib/modules/news/queries';
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
import type { LocalCachedArticle } from '$lib/modules/news/types';
// We accept ViewProps for protocol compatibility but the workbench
// view doesn't navigate within itself — every "open" jumps to the
// dedicated /news routes.
let _props: ViewProps = $props();
void _props;
const prefs$ = usePreferences();
const pool$ = useCachedFeed();
const reactions$ = useReactions();
const prefs = $derived(prefs$.value);
const pool = $derived(pool$.value);
const reactions = $derived(reactions$.value);
const reactedIds = $derived(buildReactedIds(reactions));
const ranked = $derived(prefs.onboardingCompleted ? rankFeed(pool, { prefs, reactedIds }) : []);
onMount(() => {
feedCacheStore.start();
});
onDestroy(() => {
// Don't stop the poll — the /news layout uses it too and the
// store dedupes via inFlight. Stopping here would race with a
// concurrently-mounted /news route.
});
$effect(() => {
if (!prefs.onboardingCompleted) return;
void feedCacheStore.refresh({
topics: prefs.selectedTopics,
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
});
});
async function react(
article: LocalCachedArticle,
kind: 'interested' | 'not_interested' | 'source_blocked'
) {
await reactionsStore.react({
articleId: article.id,
reaction: kind,
topic: article.topic,
sourceSlug: article.sourceSlug,
});
if (kind === 'interested') {
await articlesStore.saveFromCurated(article);
}
}
function open(article: LocalCachedArticle) {
goto(`/news/${article.id}`);
}
async function refresh() {
await feedCacheStore.refresh({
topics: prefs.selectedTopics,
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
});
}
</script>
<div class="wb-news">
{#if !prefs.onboardingCompleted}
<div class="cta">
<p class="cta-title">News Hub einrichten</p>
<p class="cta-hint">
Wähle Themen, Sprachen und Quellen — danach erscheinen hier deine Artikel.
</p>
<a class="cta-btn" href="/news">Jetzt einrichten</a>
</div>
{:else}
<div class="toolbar">
<div class="counts">
{ranked.length} Artikel
{#if feedCacheStore.lastError}
· <span class="err">Fehler</span>
{/if}
</div>
<div class="tools">
<button
type="button"
class="tool"
onclick={refresh}
disabled={feedCacheStore.inFlight}
title="Neu laden"
>
{feedCacheStore.inFlight ? '…' : '↻'}
</button>
<a class="tool" href="/news/saved" title="Gespeichert">📑</a>
<a class="tool" href="/news/preferences" title="Einstellungen"></a>
</div>
</div>
{#if ranked.length === 0}
<div class="empty">
{#if pool.length === 0}
<p>Lade Artikel…</p>
{:else}
<p>Keine neuen Artikel.</p>
<button type="button" class="link" onclick={refresh}>Neu laden</button>
{/if}
</div>
{:else}
<ul class="list">
{#each ranked.slice(0, 30) as { article } (article.id)}
<li class="item">
{#if article.imageUrl}
<button type="button" class="thumb" onclick={() => open(article)} aria-label="Öffnen">
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="body">
<div class="meta">
<span class="site">{article.siteName}</span>
<span>·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
</div>
<button type="button" class="title" onclick={() => open(article)}>
{article.title}
</button>
<div class="actions">
<button
type="button"
class="rxn"
onclick={() => react(article, 'interested')}
title="Interessiert"
>
❤️
</button>
<button
type="button"
class="rxn"
onclick={() => react(article, 'not_interested')}
title="Nicht für mich"
>
👎
</button>
<button
type="button"
class="rxn"
onclick={() => react(article, 'source_blocked')}
title="Quelle ausblenden"
>
🚫
</button>
</div>
</div>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
<style>
.wb-news {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.5rem 0.25rem;
height: 100%;
overflow: hidden;
}
.cta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
text-align: center;
}
.cta-title {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.cta-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.cta-btn {
margin-top: 0.5rem;
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
text-decoration: none;
font-size: 0.8125rem;
font-weight: 500;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.25rem;
}
.counts {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.counts .err {
color: hsl(var(--color-destructive));
}
.tools {
display: flex;
gap: 0.25rem;
}
.tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
text-decoration: none;
}
.tool:disabled {
opacity: 0.5;
}
.empty {
text-align: center;
padding: 1.5rem 0;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.link {
background: none;
border: none;
color: hsl(var(--color-primary));
cursor: pointer;
text-decoration: underline;
font-size: 0.8125rem;
margin-top: 0.25rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
flex: 1;
}
.item {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.625rem;
padding: 0.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.thumb {
width: 64px;
height: 48px;
border: none;
padding: 0;
background: hsl(var(--color-background));
border-radius: 0.375rem;
overflow: hidden;
cursor: pointer;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.body {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.meta {
display: flex;
gap: 0.3rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.meta .site {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.title {
text-align: left;
background: none;
border: none;
padding: 0;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.3;
color: hsl(var(--color-foreground));
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title:hover {
color: hsl(var(--color-primary));
}
.actions {
display: flex;
gap: 0.25rem;
margin-top: 0.125rem;
}
.rxn {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
font-size: 0.75rem;
}
</style>

View file

@ -0,0 +1,129 @@
<script lang="ts">
/**
* NewsUnreadWidget — three top-ranked unread articles from the user's
* curated feed, surfaced on the dashboard.
*
* Reads:
* - newsCachedFeed (the local pool mirror — plaintext, no decrypt)
* - newsPreferences singleton (decrypts to apply topic/lang filters)
* - newsReactions (decrypts to skip already-rated articles)
*
* The widget intentionally does NOT trigger a feed refresh — that's
* the news layout's job. If the user has never opened /news, the
* pool is empty and the widget shows the empty state with a CTA.
*/
import { liveQuery } from 'dexie';
import {
cachedFeedTable,
preferencesTable,
reactionTable,
DEFAULT_PREFERENCES,
} from '$lib/modules/news/collections';
import { decryptRecords } from '$lib/data/crypto';
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
import { toPreferences, toReaction, formatRelativeTime } from '$lib/modules/news/queries';
import { PREFERENCES_ID, type LocalCachedArticle } from '$lib/modules/news/types';
let topThree = $state<LocalCachedArticle[]>([]);
let loading = $state(true);
let onboardingDone = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const [pool, prefsRow, reactionsRows] = await Promise.all([
cachedFeedTable.toArray(),
preferencesTable.get(PREFERENCES_ID),
reactionTable.toArray(),
]);
// Decrypt prefs + reactions (cache stays plaintext).
const prefs = prefsRow
? toPreferences(
(await decryptRecords('newsPreferences', [prefsRow]))[0] ?? DEFAULT_PREFERENCES
)
: toPreferences(DEFAULT_PREFERENCES);
const visibleReactions = reactionsRows.filter((r) => !r.deletedAt);
const reactions = (await decryptRecords('newsReactions', visibleReactions)).map(toReaction);
return {
prefs,
ranked: rankFeed(pool, { prefs, reactedIds: buildReactedIds(reactions) }),
};
}).subscribe({
next: ({ prefs, ranked }) => {
onboardingDone = prefs.onboardingCompleted;
topThree = ranked.slice(0, 3).map((s) => s.article);
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
</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>
News
</h3>
<a href="/news" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
</div>
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-12 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if !onboardingDone}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Richte deinen Newsfeed ein.</p>
<a
href="/news"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Jetzt starten
</a>
</div>
{:else if topThree.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Keine neuen Artikel.</p>
<a href="/news" class="mt-3 inline-block text-xs text-primary hover:underline">
Feed öffnen
</a>
</div>
{:else}
<div class="space-y-2">
{#each topThree as article (article.id)}
<a
href="/news/{article.id}"
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div class="flex items-start gap-3">
{#if article.imageUrl}
<img
src={article.imageUrl}
alt=""
class="h-12 w-16 flex-shrink-0 rounded object-cover"
loading="lazy"
/>
{/if}
<div class="min-w-0 flex-1">
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
<div class="mt-0.5 flex gap-1 text-xs text-muted-foreground">
<span class="font-medium">{article.siteName}</span>
<span>·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -30,7 +30,8 @@ export type WidgetType =
| 'plant-watering' // Planta: plants due for watering
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'cycles'; // Cycles: current phase + days until next period
| 'cycles' // Cycles: current phase + days until next period
| 'news-unread'; // News: latest unread curated articles
/**
* Widget size - maps to CSS Grid columns
@ -342,6 +343,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'cycles',
},
{
type: 'news-unread',
nameKey: 'dashboard.widgets.news_unread.title',
descriptionKey: 'dashboard.widgets.news_unread.description',
icon: '📰',
defaultSize: 'small',
allowMultiple: false,
},
];
/**