feat(mana/web/news): client data layer + module library

Adds the local-first News module: 5 Dexie tables (newsArticles,
newsCategories, newsPreferences, newsReactions, newsCachedFeed) with
the cached pool intentionally outside the sync map, four mutation
stores (articles, categories, preferences, reactions, feed-cache),
typed DTOs + queries with decryption-aware liveQueries, the api.ts
client for /api/v1/news/{feed,extract}, and the pure feed-engine that
scores articles by recency × topicWeight × sourceWeight and applies
reaction-driven weight updates client-side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 15:53:52 +02:00
parent 9ef97a1877
commit de7e359580
14 changed files with 1331 additions and 0 deletions

View file

@ -277,6 +277,24 @@ db.version(1).stores({
'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]',
timeBlockTags: 'id, blockId, tagId, [blockId+tagId]',
// ─── News (appId: 'news') ───
// `newsArticles` is the user's personal reading list (saved articles
// from the curated pool plus user-pasted URLs). `newsCategories` is
// user-defined folders for the reading list. `newsPreferences` is a
// singleton row holding selected topics, blocklist, language and the
// learned topic/source weights. `newsReactions` records per-article
// feedback (interested / not_interested / source_blocked / hidden)
// and is what the feed engine uses to suppress already-rated items.
// `newsCachedFeed` is a local mirror of the latest curated pool from
// the server — capped to ~200 entries for offline reading. It is
// intentionally NOT in module.config.ts and therefore not synced.
newsArticles:
'id, type, isArchived, isRead, isFavorite, categoryId, originalUrl, sourceCuratedId, [type+isArchived], [categoryId+createdAt]',
newsCategories: 'id, sortOrder',
newsPreferences: 'id',
newsReactions: 'id, articleId, reaction, sourceSlug, topic, [reaction+createdAt]',
newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]',
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
tagGroups: 'id',

View file

@ -0,0 +1,101 @@
/**
* News API client talks to apps/api `/api/v1/news/*`.
*
* Two flavors of endpoints:
* - GET /feed pulls the curated pool, with topic/lang filters
* - POST /extract/* Mozilla Readability for ad-hoc URL saves
*
* The base URL is read from `PUBLIC_MANA_API_URL` if set (production
* docker setup), otherwise falls back to localhost dev. Auth is the
* unified Mana JWT, picked up by the same fetch wrapper the rest of
* the app uses (cookie + Authorization header set by SvelteKit `fetch`
* via the auth-provider middleware).
*/
import { env as publicEnv } from '$env/dynamic/public';
const API_BASE =
publicEnv.PUBLIC_MANA_API_URL || (typeof window !== 'undefined' ? '' : 'http://localhost:3060');
export interface FeedArticleDto {
id: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string;
sourceSlug: string;
imageUrl: string | null;
topic: string;
language: string;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
ingestedAt: string;
}
export interface FeedQuery {
topics?: string[];
lang?: 'de' | 'en' | 'all';
since?: string;
limit?: number;
offset?: number;
}
export async function fetchFeed(
query: FeedQuery = {},
fetchImpl: typeof fetch = fetch
): Promise<FeedArticleDto[]> {
const params = new URLSearchParams();
if (query.topics && query.topics.length > 0) {
params.set('topics', query.topics.join(','));
}
if (query.lang && query.lang !== 'all') params.set('lang', query.lang);
if (query.since) params.set('since', query.since);
if (query.limit != null) params.set('limit', String(query.limit));
if (query.offset != null) params.set('offset', String(query.offset));
const url = `${API_BASE}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`;
const response = await fetchImpl(url, { credentials: 'include' });
if (!response.ok) {
throw new Error(`fetchFeed failed: ${response.status}`);
}
return (await response.json()) as FeedArticleDto[];
}
// ─── Ad-hoc URL extraction ─────────────────────────────────
export interface ExtractedArticleDto {
id: string;
type: 'saved';
sourceOrigin: 'user_saved';
originalUrl: string;
title: string;
content: string;
htmlContent: string;
excerpt: string;
author: string | null;
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
isArchived: boolean;
}
export async function extractFromUrl(
url: string,
fetchImpl: typeof fetch = fetch
): Promise<ExtractedArticleDto> {
const response = await fetchImpl(`${API_BASE}/api/v1/news/extract/save`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`extractFromUrl failed: ${response.status} ${text}`);
}
return (await response.json()) as ExtractedArticleDto;
}

View file

@ -0,0 +1,34 @@
/**
* News module Dexie table accessors and seed data.
*/
import { db } from '$lib/data/database';
import type {
LocalArticle,
LocalCachedArticle,
LocalCategory,
LocalPreferences,
LocalReaction,
} from './types';
import { PREFERENCES_ID } from './types';
export const articleTable = db.table<LocalArticle>('newsArticles');
export const categoryTable = db.table<LocalCategory>('newsCategories');
export const preferencesTable = db.table<LocalPreferences>('newsPreferences');
export const reactionTable = db.table<LocalReaction>('newsReactions');
export const cachedFeedTable = db.table<LocalCachedArticle>('newsCachedFeed');
/**
* Default preferences row written on first launch (before the user runs
* the onboarding flow). `onboardingCompleted: false` is what triggers
* the onboarding view to render instead of the feed.
*/
export const DEFAULT_PREFERENCES: LocalPreferences = {
id: PREFERENCES_ID,
selectedTopics: [],
blockedSources: [],
preferredLanguages: ['de', 'en'],
topicWeights: {},
sourceWeights: {},
onboardingCompleted: false,
};

View file

@ -0,0 +1,157 @@
/**
* Pure feed-engine: takes the raw cached pool + the user's preferences
* and reactions, returns a sorted, filtered list of articles to show.
*
* No state, no I/O every input is passed in. The store layer wires
* this up against live Dexie data via $derived.
*
* Scoring formula (deterministic, no ML):
* score = recency × topicWeight × sourceWeight
*
* recency 1.0 for <1h old, decays linearly to 0 over 7 days
* topicWeight default 1.0, +0.1 per "interested" reaction in that
* topic, 0.05 per "not_interested" (clamped 0.1..3.0)
* sourceWeight same dynamics keyed on source slug
*
* Hard filters (applied before scoring):
* - article topic must be in preferences.selectedTopics
* - article source must NOT be in preferences.blockedSources
* - language must be in preferences.preferredLanguages
* - article must not have a prior reaction
* (interested moved to reading list, not_interested/hidden
* explicitly suppressed)
*/
import type { LocalCachedArticle, Preferences, Reaction, ReactionKind } from './types';
export const TOPIC_WEIGHT_DEFAULT = 1.0;
export const TOPIC_WEIGHT_MIN = 0.1;
export const TOPIC_WEIGHT_MAX = 3.0;
export const INTERESTED_DELTA = 0.1;
export const NOT_INTERESTED_DELTA = -0.05;
const RECENCY_WINDOW_HOURS = 168; // 7 days
function recencyScore(publishedAt: string | null): number {
if (!publishedAt) return 0.1;
const ageH = (Date.now() - new Date(publishedAt).getTime()) / 3.6e6;
if (ageH < 0) return 1.0;
return Math.max(0, 1 - ageH / RECENCY_WINDOW_HOURS);
}
export interface ScoreContext {
prefs: Preferences;
/** Set of curatedArticleIds that already have any reaction. */
reactedIds: ReadonlySet<string>;
}
/** Build the lookup set once and reuse across all scoreArticle calls. */
export function buildReactedIds(reactions: readonly Reaction[]): Set<string> {
const set = new Set<string>();
for (const r of reactions) set.add(r.articleId);
return set;
}
/**
* Returns a number 0 if the article passes filters, or `null` if it
* should be hidden entirely. Callers sort by descending score.
*/
export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): number | null {
const { prefs, reactedIds } = ctx;
if (prefs.selectedTopics.length > 0 && !prefs.selectedTopics.includes(article.topic as never)) {
return null;
}
if (prefs.blockedSources.includes(article.sourceSlug)) return null;
if (
prefs.preferredLanguages.length > 0 &&
!prefs.preferredLanguages.includes(article.language as never)
) {
return null;
}
if (reactedIds.has(article.id)) return null;
const topicW = prefs.topicWeights[article.topic] ?? TOPIC_WEIGHT_DEFAULT;
const sourceW = prefs.sourceWeights[article.sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
const recency = recencyScore(article.publishedAt);
// Floor recency at 0.05 so very old but highly-weighted sources still
// surface above brand-new but unweighted ones — keeps the feed from
// devolving into a pure recency stream.
const floored = Math.max(recency, 0.05);
return floored * topicW * sourceW;
}
export interface ScoredArticle {
article: LocalCachedArticle;
score: number;
}
/**
* Score the whole pool, drop the rejected ones, and return descending
* by score. Stable: ties broken by `publishedAt` desc.
*/
export function rankFeed(pool: readonly LocalCachedArticle[], ctx: ScoreContext): ScoredArticle[] {
const out: ScoredArticle[] = [];
for (const article of pool) {
const score = scoreArticle(article, ctx);
if (score == null) continue;
out.push({ article, score });
}
out.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ap = a.article.publishedAt ?? '';
const bp = b.article.publishedAt ?? '';
return bp.localeCompare(ap);
});
return out;
}
// ─── Weight updates (returned as a partial Preferences diff) ───
export interface WeightDiff {
topicWeights?: Record<string, number>;
sourceWeights?: Record<string, number>;
blockedSources?: string[];
}
function clamp(n: number): number {
return Math.max(TOPIC_WEIGHT_MIN, Math.min(TOPIC_WEIGHT_MAX, n));
}
/**
* Compute the preferences delta for a new reaction. The store layer
* merges this back onto the existing preferences row in a single
* `update()` call.
*/
export function applyReaction(
prefs: Preferences,
reaction: ReactionKind,
topic: string,
sourceSlug: string
): WeightDiff {
if (reaction === 'source_blocked') {
if (prefs.blockedSources.includes(sourceSlug)) return {};
return { blockedSources: [...prefs.blockedSources, sourceSlug] };
}
if (reaction === 'hidden') {
// "Hidden" is a per-article suppression — no weight change. The
// reaction row alone is enough for `reactedIds` to filter it.
return {};
}
const delta = reaction === 'interested' ? INTERESTED_DELTA : NOT_INTERESTED_DELTA;
const currentTopic = prefs.topicWeights[topic] ?? TOPIC_WEIGHT_DEFAULT;
const currentSource = prefs.sourceWeights[sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
return {
topicWeights: { ...prefs.topicWeights, [topic]: clamp(currentTopic + delta) },
sourceWeights: {
...prefs.sourceWeights,
[sourceSlug]: clamp(currentSource + delta),
},
};
}

View file

@ -0,0 +1,64 @@
/**
* News module barrel exports.
*/
export {
articleTable,
categoryTable,
preferencesTable,
reactionTable,
cachedFeedTable,
DEFAULT_PREFERENCES,
} from './collections';
export {
useSavedArticles,
useArticle,
useCategories,
usePreferences,
useReactions,
useCachedFeed,
toArticle,
toCategory,
toPreferences,
toReaction,
formatRelativeTime,
} from './queries';
export {
rankFeed,
scoreArticle,
buildReactedIds,
applyReaction,
TOPIC_WEIGHT_DEFAULT,
} from './feed-engine';
export type { ScoredArticle, ScoreContext, WeightDiff } from './feed-engine';
export { articlesStore } from './stores/articles.svelte';
export { categoriesStore } from './stores/categories.svelte';
export { preferencesStore } from './stores/preferences.svelte';
export { reactionsStore } from './stores/reactions.svelte';
export { feedCacheStore } from './stores/feed-cache.svelte';
export { fetchFeed, extractFromUrl } from './api';
export type { FeedArticleDto, FeedQuery } from './api';
export { SOURCES_META, SOURCE_META_BY_SLUG, sourcesForTopic, TOPIC_LABELS } from './sources-meta';
export type { SourceMeta } from './sources-meta';
export { ALL_TOPICS, PREFERENCES_ID } from './types';
export type {
Article,
Category,
LocalArticle,
LocalCachedArticle,
LocalCategory,
LocalPreferences,
LocalReaction,
Language,
Preferences,
Reaction,
ReactionKind,
Topic,
} from './types';

View file

@ -0,0 +1,19 @@
import type { ModuleConfig } from '$lib/data/module-registry';
/**
* News module five Dexie tables, four of them synced.
*
* `newsCachedFeed` is intentionally absent: it mirrors the public
* server pool, refreshes on a 10-minute poll, and would chew through
* sync bandwidth + storage quota for zero benefit (the same data is
* just an HTTP fetch away).
*/
export const newsModuleConfig: ModuleConfig = {
appId: 'news',
tables: [
{ name: 'newsArticles', syncName: 'articles' },
{ name: 'newsCategories', syncName: 'categories' },
{ name: 'newsPreferences', syncName: 'preferences' },
{ name: 'newsReactions', syncName: 'reactions' },
],
};

View file

@ -0,0 +1,171 @@
/**
* Reactive queries + type converters for News.
*
* Read-side only. Anything that mutates lives in stores/*.svelte.ts.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import {
articleTable,
cachedFeedTable,
categoryTable,
preferencesTable,
reactionTable,
DEFAULT_PREFERENCES,
} from './collections';
import type {
Article,
Category,
LocalArticle,
LocalCachedArticle,
LocalCategory,
LocalPreferences,
LocalReaction,
Preferences,
Reaction,
} from './types';
import { PREFERENCES_ID } from './types';
// ─── Type converters ───────────────────────────────────────
export function toArticle(local: LocalArticle): Article {
return {
id: local.id,
type: local.type,
sourceCuratedId: local.sourceCuratedId ?? undefined,
originalUrl: local.originalUrl,
title: local.title,
excerpt: local.excerpt,
content: local.content,
htmlContent: local.htmlContent,
author: local.author,
siteName: local.siteName,
sourceSlug: local.sourceSlug,
imageUrl: local.imageUrl,
categoryId: local.categoryId,
wordCount: local.wordCount,
readingTimeMinutes: local.readingTimeMinutes,
publishedAt: local.publishedAt,
isArchived: local.isArchived,
isRead: local.isRead,
isFavorite: local.isFavorite,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCategory(local: LocalCategory): Category {
return {
id: local.id,
name: local.name,
color: local.color,
icon: local.icon,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toPreferences(local: LocalPreferences): Preferences {
return {
id: local.id,
selectedTopics: local.selectedTopics ?? [],
blockedSources: local.blockedSources ?? [],
preferredLanguages: local.preferredLanguages ?? ['de', 'en'],
topicWeights: local.topicWeights ?? {},
sourceWeights: local.sourceWeights ?? {},
onboardingCompleted: local.onboardingCompleted ?? false,
};
}
export function toReaction(local: LocalReaction): Reaction {
return {
id: local.id,
articleId: local.articleId,
reaction: local.reaction,
sourceSlug: local.sourceSlug,
topic: local.topic,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live queries ──────────────────────────────────────────
/** Saved articles (the personal reading list). Encrypted on disk. */
export function useSavedArticles() {
return useLiveQueryWithDefault(async () => {
const visible = (await articleTable.toArray()).filter((a) => !a.deletedAt && !a.isArchived);
const decrypted = await decryptRecords('newsArticles', visible);
return decrypted.map(toArticle).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [] as Article[]);
}
export function useArticle(id: string) {
return useLiveQueryWithDefault(
async () => {
const local = await articleTable.get(id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('newsArticles', [local]);
return decrypted ? toArticle(decrypted) : null;
},
null as Article | null
);
}
export function useCategories() {
return useLiveQueryWithDefault(async () => {
const visible = (await categoryTable.toArray()).filter((c) => !c.deletedAt);
const decrypted = await decryptRecords('newsCategories', visible);
return decrypted.map(toCategory).sort((a, b) => a.sortOrder - b.sortOrder);
}, [] as Category[]);
}
/**
* Singleton preferences row. Returns the default-shape preferences if
* the user has never opened the module before onboardingCompleted
* starts as `false`, which the route layer uses to redirect into the
* onboarding view on first launch.
*/
export function usePreferences() {
return useLiveQueryWithDefault(async () => {
const local = await preferencesTable.get(PREFERENCES_ID);
if (!local) return toPreferences(DEFAULT_PREFERENCES);
const [decrypted] = await decryptRecords('newsPreferences', [local]);
return toPreferences(decrypted ?? DEFAULT_PREFERENCES);
}, toPreferences(DEFAULT_PREFERENCES));
}
export function useReactions() {
return useLiveQueryWithDefault(async () => {
const visible = (await reactionTable.toArray()).filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('newsReactions', visible);
return decrypted.map(toReaction);
}, [] as Reaction[]);
}
/** The local mirror of the server's curated pool — plaintext, not synced. */
export function useCachedFeed() {
return useLiveQueryWithDefault(async () => {
const all = await cachedFeedTable.toArray();
// Newest first, but the feed engine re-sorts by score so this is
// only a stable input order.
return all.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''));
}, [] as LocalCachedArticle[]);
}
// ─── Pure helpers ──────────────────────────────────────────
export function formatRelativeTime(iso: string | null): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'jetzt';
if (mins < 60) return `vor ${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `vor ${hours}h`;
const days = Math.floor(hours / 24);
if (days < 7) return `vor ${days}d`;
return new Date(iso).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}

View file

@ -0,0 +1,83 @@
/**
* Source metadata for the News onboarding picker and per-article badges.
*
* MUST stay in sync with `services/news-ingester/src/sources.ts`
* the `slug` is the cross-reference key (user blocklists store it
* verbatim, articles in the curated pool reference it). Adding or
* removing a source means editing both files.
*
* `language` and `topic` are duplicated from the ingester so the client
* doesn't need to fetch source metadata at runtime.
*/
import type { Topic, Language } from './types';
export interface SourceMeta {
slug: string;
name: string;
topic: Topic;
language: Language;
}
export const SOURCES_META: readonly SourceMeta[] = [
// tech
{ slug: 'hacker-news', name: 'Hacker News', topic: 'tech', language: 'en' },
{ slug: 'arstechnica', name: 'Ars Technica', topic: 'tech', language: 'en' },
{ slug: 'theverge', name: 'The Verge', topic: 'tech', language: 'en' },
{ slug: 'heise', name: 'heise online', topic: 'tech', language: 'de' },
// wissenschaft
{ slug: 'quanta-magazine', name: 'Quanta Magazine', topic: 'wissenschaft', language: 'en' },
{ slug: 'spektrum', name: 'Spektrum', topic: 'wissenschaft', language: 'de' },
{ slug: 'nature-news', name: 'Nature News', topic: 'wissenschaft', language: 'en' },
{ slug: 'phys-org', name: 'Phys.org', topic: 'wissenschaft', language: 'en' },
// weltgeschehen
{ slug: 'tagesschau', name: 'Tagesschau', topic: 'weltgeschehen', language: 'de' },
{ slug: 'bbc-world', name: 'BBC World', topic: 'weltgeschehen', language: 'en' },
{ slug: 'aljazeera', name: 'Al Jazeera', topic: 'weltgeschehen', language: 'en' },
{ slug: 'dw-top', name: 'Deutsche Welle', topic: 'weltgeschehen', language: 'en' },
// wirtschaft
{ slug: 'handelsblatt', name: 'Handelsblatt', topic: 'wirtschaft', language: 'de' },
{ slug: 'ft-world', name: 'Financial Times', topic: 'wirtschaft', language: 'en' },
{ slug: 'bloomberg-markets', name: 'Bloomberg Markets', topic: 'wirtschaft', language: 'en' },
{
slug: 'economist-finance',
name: 'The Economist — Finance',
topic: 'wirtschaft',
language: 'en',
},
// kultur
{ slug: 'guardian-culture', name: 'The Guardian Culture', topic: 'kultur', language: 'en' },
{ slug: 'guardian-books', name: 'The Guardian Books', topic: 'kultur', language: 'en' },
{ slug: 'npr-arts', name: 'NPR Arts', topic: 'kultur', language: 'en' },
// gesundheit
{ slug: 'stat-news', name: 'STAT News', topic: 'gesundheit', language: 'en' },
{ slug: 'bbc-health', name: 'BBC Health', topic: 'gesundheit', language: 'en' },
{ slug: 'sciencedaily-health', name: 'ScienceDaily Health', topic: 'gesundheit', language: 'en' },
// politik
{ slug: 'spiegel-politik', name: 'Spiegel Politik', topic: 'politik', language: 'de' },
{ slug: 'politico-eu', name: 'Politico EU', topic: 'politik', language: 'en' },
{
slug: 'atlantic-politics',
name: 'The Atlantic — Politics',
topic: 'politik',
language: 'en',
},
];
export const SOURCE_META_BY_SLUG: Record<string, SourceMeta> = Object.fromEntries(
SOURCES_META.map((s) => [s.slug, s])
);
export function sourcesForTopic(topic: Topic): readonly SourceMeta[] {
return SOURCES_META.filter((s) => s.topic === topic);
}
export const TOPIC_LABELS: Record<Topic, { de: string; en: string; emoji: string }> = {
tech: { de: 'Tech', en: 'Tech', emoji: '💻' },
wissenschaft: { de: 'Wissenschaft', en: 'Science', emoji: '🔬' },
weltgeschehen: { de: 'Weltgeschehen', en: 'World', emoji: '🌍' },
wirtschaft: { de: 'Wirtschaft', en: 'Business', emoji: '📈' },
kultur: { de: 'Kultur', en: 'Culture', emoji: '🎭' },
gesundheit: { de: 'Gesundheit', en: 'Health', emoji: '🩺' },
politik: { de: 'Politik', en: 'Politics', emoji: '🏛️' },
};

View file

@ -0,0 +1,121 @@
/**
* Articles store the user's saved reading list.
*
* Two paths in:
* - saveFromCurated(article) copies a row from the local pool
* mirror into the encrypted reading list. Used when the user
* hits "speichern" on a feed card.
* - saveFromUrl(url) hits POST /api/v1/news/extract/save and
* stores the result. Used by /news/add for ad-hoc URLs.
*
* All other operations (read/archive/favorite/delete) are plain
* updates against `newsArticles`.
*/
import { encryptRecord } from '$lib/data/crypto';
import { articleTable } from '../collections';
import { extractFromUrl } from '../api';
import { toArticle } from '../queries';
import type { Article, LocalArticle, LocalCachedArticle } from '../types';
export const articlesStore = {
async saveFromCurated(input: LocalCachedArticle): Promise<Article> {
// Dedupe: if the user has already saved this curated article,
// return the existing row instead of creating a duplicate. The
// `sourceCuratedId` index makes this O(1).
const existing = await articleTable.where('sourceCuratedId').equals(input.id).first();
if (existing) return toArticle(existing);
const newLocal: LocalArticle = {
id: crypto.randomUUID(),
type: 'curated',
sourceCuratedId: input.id,
originalUrl: input.originalUrl,
title: input.title,
excerpt: input.excerpt,
content: input.content,
htmlContent: input.htmlContent,
author: input.author,
siteName: input.siteName,
sourceSlug: input.sourceSlug,
imageUrl: input.imageUrl,
categoryId: null,
wordCount: input.wordCount,
readingTimeMinutes: input.readingTimeMinutes,
publishedAt: input.publishedAt,
isArchived: false,
isRead: false,
isFavorite: false,
};
const snapshot = toArticle(newLocal);
await encryptRecord('newsArticles', newLocal);
await articleTable.add(newLocal);
return snapshot;
},
async saveFromUrl(url: string): Promise<Article> {
const extracted = await extractFromUrl(url);
const newLocal: LocalArticle = {
id: crypto.randomUUID(),
type: 'saved',
sourceCuratedId: null,
originalUrl: extracted.originalUrl,
title: extracted.title,
excerpt: extracted.excerpt,
content: extracted.content,
htmlContent: extracted.htmlContent,
author: extracted.author,
siteName: extracted.siteName,
sourceSlug: null,
imageUrl: null,
categoryId: null,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
publishedAt: null,
isArchived: false,
isRead: false,
isFavorite: false,
};
const snapshot = toArticle(newLocal);
await encryptRecord('newsArticles', newLocal);
await articleTable.add(newLocal);
return snapshot;
},
async markRead(id: string, isRead = true): Promise<void> {
await articleTable.update(id, {
isRead,
updatedAt: new Date().toISOString(),
});
},
async toggleFavorite(id: string): Promise<void> {
const a = await articleTable.get(id);
if (!a) return;
await articleTable.update(id, {
isFavorite: !a.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async archive(id: string): Promise<void> {
await articleTable.update(id, {
isArchived: true,
updatedAt: new Date().toISOString(),
});
},
async setCategory(id: string, categoryId: string | null): Promise<void> {
await articleTable.update(id, {
categoryId,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string): Promise<void> {
await articleTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,93 @@
/**
* Categories store user-defined folders for the saved reading list.
*
* Categories live in `newsCategories`. The link from an article to its
* category is `LocalArticle.categoryId` (a plaintext FK index), set
* via `articlesStore.setCategory`.
*
* Default seeds (Lese später / Recherche) are NOT created here the
* user starts with zero categories and adds them on demand. Empty is
* a valid state and the saved-list view falls back to "Alle Artikel"
* when no category is selected.
*/
import { encryptRecord } from '$lib/data/crypto';
import { categoryTable } from '../collections';
import type { LocalCategory } from '../types';
const DEFAULT_COLORS = [
'#3b82f6',
'#22c55e',
'#f59e0b',
'#ec4899',
'#8b5cf6',
'#06b6d4',
'#f43f5e',
'#84cc16',
];
function pickColor(existingCount: number): string {
return DEFAULT_COLORS[existingCount % DEFAULT_COLORS.length];
}
export const categoriesStore = {
async create(input: { name: string; color?: string; icon?: string }): Promise<LocalCategory> {
const count = await categoryTable.count();
const newLocal: LocalCategory = {
id: crypto.randomUUID(),
name: input.name.trim() || 'Ohne Namen',
color: input.color ?? pickColor(count),
icon: input.icon ?? '📁',
sortOrder: count,
};
await encryptRecord('newsCategories', newLocal);
await categoryTable.add(newLocal);
return newLocal;
},
async rename(id: string, name: string): Promise<void> {
const trimmed = name.trim();
if (!trimmed) return;
const diff: Partial<LocalCategory> = {
name: trimmed,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsCategories', diff);
await categoryTable.update(id, diff);
},
async setColor(id: string, color: string): Promise<void> {
await categoryTable.update(id, {
color,
updatedAt: new Date().toISOString(),
});
},
async setIcon(id: string, icon: string): Promise<void> {
await categoryTable.update(id, {
icon,
updatedAt: new Date().toISOString(),
});
},
async reorder(ids: string[]): Promise<void> {
// Bulk update via individual writes — Dexie has no native bulkUpdate
// for partial diffs and the per-call cost is negligible at folder
// counts (typically <20).
const now = new Date().toISOString();
for (let i = 0; i < ids.length; i++) {
await categoryTable.update(ids[i], { sortOrder: i, updatedAt: now });
}
},
async delete(id: string): Promise<void> {
// Soft-delete the category itself. Articles that referenced it
// keep `categoryId` pointing at the tombstoned row — the saved
// view treats unknown categoryIds as "uncategorized" so they
// don't disappear. A subsequent re-categorize cleans them up.
await categoryTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,120 @@
/**
* Feed-cache store pulls the curated pool from /api/v1/news/feed
* into the local-only `newsCachedFeed` table.
*
* Why a local mirror at all? The feed engine (scoreArticle, rankFeed)
* runs against the cached pool every time the feed view re-renders.
* Hitting the network on every render would be silly; hitting the
* network on every preferences change would be worse. Caching also
* gives us offline reading for the cards the user already saw.
*
* The cache is bounded: we keep at most CACHE_LIMIT rows, and prune
* the oldest by ingestedAt before each refresh. The bounded size + the
* fact that the cache is plaintext + not synced is what justifies
* leaving it out of the encryption registry and the sync map.
*
* `start()` should be called once from the news +layout it kicks an
* immediate refresh and then polls on a 10-minute interval. The
* interval is held in module scope on purpose so multiple route entries
* can't accidentally double up.
*/
import { cachedFeedTable } from '../collections';
import { fetchFeed } from '../api';
import type { FeedArticleDto } from '../api';
import type { LocalCachedArticle } from '../types';
const POLL_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
const CACHE_LIMIT = 400;
let pollHandle: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let lastError: string | null = null;
let lastRefreshedAt: string | null = null;
function toLocal(dto: FeedArticleDto): LocalCachedArticle {
return {
id: dto.id,
originalUrl: dto.originalUrl,
title: dto.title,
excerpt: dto.excerpt,
content: dto.content,
htmlContent: dto.htmlContent,
author: dto.author,
siteName: dto.siteName,
sourceSlug: dto.sourceSlug,
imageUrl: dto.imageUrl,
topic: dto.topic,
language: dto.language,
wordCount: dto.wordCount,
readingTimeMinutes: dto.readingTimeMinutes,
publishedAt: dto.publishedAt,
ingestedAt: dto.ingestedAt,
cachedAt: new Date().toISOString(),
};
}
async function pruneToLimit(): Promise<void> {
const count = await cachedFeedTable.count();
if (count <= CACHE_LIMIT) return;
// Keep the newest CACHE_LIMIT rows by ingestedAt. Dexie has no
// LIMIT/OFFSET on plain table, so collect all PKs sorted and slice.
const all = await cachedFeedTable.toArray();
all.sort((a, b) => (b.ingestedAt ?? '').localeCompare(a.ingestedAt ?? ''));
const toDelete = all.slice(CACHE_LIMIT).map((a) => a.id);
if (toDelete.length > 0) await cachedFeedTable.bulkDelete(toDelete);
}
export const feedCacheStore = {
get lastError() {
return lastError;
},
get lastRefreshedAt() {
return lastRefreshedAt;
},
get inFlight() {
return inFlight;
},
async refresh(opts: { topics?: string[]; lang?: 'de' | 'en' | 'all' } = {}): Promise<void> {
if (inFlight) return;
inFlight = true;
lastError = null;
try {
const dtos = await fetchFeed({
limit: 200,
topics: opts.topics,
lang: opts.lang ?? 'all',
});
if (dtos.length > 0) {
// bulkPut keeps existing rows for the same id and updates
// them in place. New rows from the server replace the
// previous mirror, old rows that fell out of the server's
// 200-row window stay in the cache until pruneToLimit cuts
// them. That's the behavior we want — the cache should
// degrade gradually, not flush every refresh.
await cachedFeedTable.bulkPut(dtos.map(toLocal));
}
await pruneToLimit();
lastRefreshedAt = new Date().toISOString();
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
console.warn('[news] feed refresh failed:', lastError);
} finally {
inFlight = false;
}
},
start(opts: { topics?: string[]; lang?: 'de' | 'en' | 'all' } = {}): void {
if (pollHandle) return; // already started
void this.refresh(opts);
pollHandle = setInterval(() => void this.refresh(opts), POLL_INTERVAL_MS);
},
stop(): void {
if (pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
}
},
};

View file

@ -0,0 +1,107 @@
/**
* Preferences store singleton row keyed on `PREFERENCES_ID`.
*
* The first read of the preferences row is also the place that creates
* it on disk, so the rest of the codebase can assume it always exists.
* Onboarding then flips `onboardingCompleted` to true.
*/
import { encryptRecord } from '$lib/data/crypto';
import { preferencesTable, DEFAULT_PREFERENCES } from '../collections';
import { toPreferences } from '../queries';
import type { LocalPreferences, Preferences, Topic, Language } from '../types';
import { PREFERENCES_ID } from '../types';
async function ensureRow(): Promise<LocalPreferences> {
const existing = await preferencesTable.get(PREFERENCES_ID);
if (existing) return existing;
const fresh: LocalPreferences = { ...DEFAULT_PREFERENCES };
await encryptRecord('newsPreferences', fresh);
await preferencesTable.add(fresh);
return fresh;
}
export const preferencesStore = {
async get(): Promise<Preferences> {
const row = await ensureRow();
return toPreferences(row);
},
async completeOnboarding(input: {
topics: Topic[];
languages: Language[];
blockedSources?: string[];
}): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
selectedTopics: input.topics,
preferredLanguages: input.languages,
blockedSources: input.blockedSources ?? [],
onboardingCompleted: true,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async setTopics(topics: Topic[]): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
selectedTopics: topics,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async setLanguages(languages: Language[]): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
preferredLanguages: languages,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async toggleBlockedSource(slug: string): Promise<void> {
const row = await ensureRow();
const blocked = row.blockedSources ?? [];
const next = blocked.includes(slug) ? blocked.filter((s) => s !== slug) : [...blocked, slug];
const diff: Partial<LocalPreferences> = {
blockedSources: next,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
/**
* Apply a precomputed weight diff (from feed-engine.applyReaction).
* Merges with existing weights caller already did the math.
*/
async applyWeightDiff(diff: {
topicWeights?: Record<string, number>;
sourceWeights?: Record<string, number>;
blockedSources?: string[];
}): Promise<void> {
await ensureRow();
const update: Partial<LocalPreferences> = {
...diff,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', update);
await preferencesTable.update(PREFERENCES_ID, update);
},
async resetWeights(): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
topicWeights: {},
sourceWeights: {},
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
};

View file

@ -0,0 +1,62 @@
/**
* Reactions store records the user's per-article feedback and pipes
* the matching weight delta into the preferences store in the same
* call. Two writes, one logical operation:
*
* 1. add a `newsReactions` row (drops the article from `reactedIds`
* so the feed engine stops surfacing it)
* 2. apply the weight diff back to `newsPreferences`
*
* The reaction row stays around so undo / "show what I dismissed"
* stays cheap. The preferences diff is what makes the suppression
* persist across cache refreshes.
*/
import { encryptRecord } from '$lib/data/crypto';
import { reactionTable } from '../collections';
import { applyReaction as computeWeightDiff } from '../feed-engine';
import { preferencesStore } from './preferences.svelte';
import type { LocalReaction, ReactionKind } from '../types';
async function loadCurrentPrefs() {
return preferencesStore.get();
}
export const reactionsStore = {
async react(input: {
articleId: string;
reaction: ReactionKind;
topic: string;
sourceSlug: string;
}): Promise<void> {
const prefs = await loadCurrentPrefs();
// 1. Persist the reaction row.
const row: LocalReaction = {
id: crypto.randomUUID(),
articleId: input.articleId,
reaction: input.reaction,
topic: input.topic,
sourceSlug: input.sourceSlug,
};
await encryptRecord('newsReactions', row);
await reactionTable.add(row);
// 2. Update preferences (weight + blocklist) in lockstep.
const diff = computeWeightDiff(prefs, input.reaction, input.topic, input.sourceSlug);
if (Object.keys(diff).length > 0) {
await preferencesStore.applyWeightDiff(diff);
}
},
async undo(reactionId: string): Promise<void> {
// Soft-delete: tombstone the reaction so the article shows up
// again in the feed. Weights stay where they were — undoing a
// thumbs-down doesn't *boost* the source, it just stops further
// suppression.
await reactionTable.update(reactionId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,181 @@
/**
* News module types local-first reading hub backed by the curated
* pool from `news.curated_articles` (see services/news-ingester).
*
* Local data is split across five Dexie tables:
*
* newsArticles saved reading list (encrypted)
* newsCategories user-defined folders for the reading list
* newsPreferences singleton row: topics, blocklist, weights
* newsReactions per-article feedback signals
* newsCachedFeed local mirror of the server's curated pool
* (NOT synced, NOT encrypted)
*/
import type { BaseRecord } from '@mana/local-store';
export type Topic =
| 'tech'
| 'wissenschaft'
| 'weltgeschehen'
| 'wirtschaft'
| 'kultur'
| 'gesundheit'
| 'politik';
export const ALL_TOPICS: readonly Topic[] = [
'tech',
'wissenschaft',
'weltgeschehen',
'wirtschaft',
'kultur',
'gesundheit',
'politik',
];
export type Language = 'de' | 'en';
export type ReactionKind = 'interested' | 'not_interested' | 'source_blocked' | 'hidden';
// ─── Saved reading list ────────────────────────────────────
export interface LocalArticle extends BaseRecord {
/** 'curated' = saved from the server pool, 'saved' = ad-hoc URL extract. */
type: 'curated' | 'saved';
/** Foreign key into the server's curated pool when type='curated'. */
sourceCuratedId?: string | null;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string | null;
sourceSlug: string | null;
imageUrl: string | null;
categoryId: string | null;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
isArchived: boolean;
isRead: boolean;
isFavorite: boolean;
}
export interface LocalCategory extends BaseRecord {
name: string;
color: string;
icon: string;
sortOrder: number;
}
// ─── Preferences (singleton) ───────────────────────────────
/**
* The single row id for the preferences singleton there is exactly
* one preferences row per user, so we use a stable string instead of a
* uuid to make upserts idempotent.
*/
export const PREFERENCES_ID = 'singleton';
export interface LocalPreferences extends BaseRecord {
id: string;
selectedTopics: Topic[];
blockedSources: string[];
preferredLanguages: Language[];
/** topic slug → weight (default 1.0, range ~0.1 to 3.0). */
topicWeights: Record<string, number>;
/** source slug → weight (default 1.0, range ~0.1 to 3.0). */
sourceWeights: Record<string, number>;
onboardingCompleted: boolean;
}
// ─── Reactions ─────────────────────────────────────────────
export interface LocalReaction extends BaseRecord {
/** The curated article id (server-side uuid from the pool). */
articleId: string;
reaction: ReactionKind;
/** Denormalized for O(1) weight updates without a join. */
sourceSlug: string;
topic: string;
}
// ─── Cached pool mirror (local only) ───────────────────────
export interface LocalCachedArticle {
/** Server-side curated_articles.id. Used as the dedupe key. */
id: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string;
sourceSlug: string;
imageUrl: string | null;
topic: string;
language: string;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
ingestedAt: string;
/** Local timestamp when this row entered the cache. */
cachedAt: string;
}
// ─── Public DTOs (rendered by views) ───────────────────────
export interface Article {
id: string;
type: 'curated' | 'saved';
sourceCuratedId?: string;
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string | null;
author: string | null;
siteName: string | null;
sourceSlug: string | null;
imageUrl: string | null;
categoryId: string | null;
wordCount: number | null;
readingTimeMinutes: number | null;
publishedAt: string | null;
isArchived: boolean;
isRead: boolean;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
color: string;
icon: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface Preferences {
id: string;
selectedTopics: Topic[];
blockedSources: string[];
preferredLanguages: Language[];
topicWeights: Record<string, number>;
sourceWeights: Record<string, number>;
onboardingCompleted: boolean;
}
export interface Reaction {
id: string;
articleId: string;
reaction: ReactionKind;
sourceSlug: string;
topic: string;
createdAt: string;
}