From de7e359580b18a913fc58dc86041f37c01c02faf Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 15:53:52 +0200 Subject: [PATCH] feat(mana/web/news): client data layer + module library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/mana/apps/web/src/lib/data/database.ts | 18 ++ .../mana/apps/web/src/lib/modules/news/api.ts | 101 ++++++++++ .../web/src/lib/modules/news/collections.ts | 34 ++++ .../web/src/lib/modules/news/feed-engine.ts | 157 +++++++++++++++ .../apps/web/src/lib/modules/news/index.ts | 64 +++++++ .../web/src/lib/modules/news/module.config.ts | 19 ++ .../apps/web/src/lib/modules/news/queries.ts | 171 +++++++++++++++++ .../web/src/lib/modules/news/sources-meta.ts | 83 ++++++++ .../modules/news/stores/articles.svelte.ts | 121 ++++++++++++ .../modules/news/stores/categories.svelte.ts | 93 +++++++++ .../modules/news/stores/feed-cache.svelte.ts | 120 ++++++++++++ .../modules/news/stores/preferences.svelte.ts | 107 +++++++++++ .../modules/news/stores/reactions.svelte.ts | 62 ++++++ .../apps/web/src/lib/modules/news/types.ts | 181 ++++++++++++++++++ 14 files changed, 1331 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/news/api.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/feed-engine.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/sources-meta.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/stores/categories.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/stores/feed-cache.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/stores/reactions.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/news/types.ts diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index c8cb50d0b..7afeacf39 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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', diff --git a/apps/mana/apps/web/src/lib/modules/news/api.ts b/apps/mana/apps/web/src/lib/modules/news/api.ts new file mode 100644 index 000000000..72508e2e2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/api.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/mana/apps/web/src/lib/modules/news/collections.ts b/apps/mana/apps/web/src/lib/modules/news/collections.ts new file mode 100644 index 000000000..15399d242 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/collections.ts @@ -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('newsArticles'); +export const categoryTable = db.table('newsCategories'); +export const preferencesTable = db.table('newsPreferences'); +export const reactionTable = db.table('newsReactions'); +export const cachedFeedTable = db.table('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, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts b/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts new file mode 100644 index 000000000..8f2f68245 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts @@ -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; +} + +/** Build the lookup set once and reuse across all scoreArticle calls. */ +export function buildReactedIds(reactions: readonly Reaction[]): Set { + const set = new Set(); + 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; + sourceWeights?: Record; + 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), + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/news/index.ts b/apps/mana/apps/web/src/lib/modules/news/index.ts new file mode 100644 index 000000000..5a14f29b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/news/module.config.ts b/apps/mana/apps/web/src/lib/modules/news/module.config.ts new file mode 100644 index 000000000..990130948 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/module.config.ts @@ -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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/queries.ts b/apps/mana/apps/web/src/lib/modules/news/queries.ts new file mode 100644 index 000000000..580e05838 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/queries.ts @@ -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' }); +} diff --git a/apps/mana/apps/web/src/lib/modules/news/sources-meta.ts b/apps/mana/apps/web/src/lib/modules/news/sources-meta.ts new file mode 100644 index 000000000..670045dec --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/sources-meta.ts @@ -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 = 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 = { + 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: '🏛️' }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts new file mode 100644 index 000000000..914bf5e18 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/stores/articles.svelte.ts @@ -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
{ + // 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
{ + 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 { + await articleTable.update(id, { + isRead, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string): Promise { + 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 { + await articleTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, + + async setCategory(id: string, categoryId: string | null): Promise { + await articleTable.update(id, { + categoryId, + updatedAt: new Date().toISOString(), + }); + }, + + async delete(id: string): Promise { + await articleTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/categories.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/categories.svelte.ts new file mode 100644 index 000000000..7aab6532f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/stores/categories.svelte.ts @@ -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 { + 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 { + const trimmed = name.trim(); + if (!trimmed) return; + const diff: Partial = { + name: trimmed, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsCategories', diff); + await categoryTable.update(id, diff); + }, + + async setColor(id: string, color: string): Promise { + await categoryTable.update(id, { + color, + updatedAt: new Date().toISOString(), + }); + }, + + async setIcon(id: string, icon: string): Promise { + await categoryTable.update(id, { + icon, + updatedAt: new Date().toISOString(), + }); + }, + + async reorder(ids: string[]): Promise { + // 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 { + // 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(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/feed-cache.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/feed-cache.svelte.ts new file mode 100644 index 000000000..ae9680986 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/stores/feed-cache.svelte.ts @@ -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 | 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 { + 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 { + 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; + } + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts new file mode 100644 index 000000000..6bd952e2a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts @@ -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 { + 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 { + const row = await ensureRow(); + return toPreferences(row); + }, + + async completeOnboarding(input: { + topics: Topic[]; + languages: Language[]; + blockedSources?: string[]; + }): Promise { + await ensureRow(); + const diff: Partial = { + 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 { + await ensureRow(); + const diff: Partial = { + selectedTopics: topics, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsPreferences', diff); + await preferencesTable.update(PREFERENCES_ID, diff); + }, + + async setLanguages(languages: Language[]): Promise { + await ensureRow(); + const diff: Partial = { + preferredLanguages: languages, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsPreferences', diff); + await preferencesTable.update(PREFERENCES_ID, diff); + }, + + async toggleBlockedSource(slug: string): Promise { + const row = await ensureRow(); + const blocked = row.blockedSources ?? []; + const next = blocked.includes(slug) ? blocked.filter((s) => s !== slug) : [...blocked, slug]; + const diff: Partial = { + 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; + sourceWeights?: Record; + blockedSources?: string[]; + }): Promise { + await ensureRow(); + const update: Partial = { + ...diff, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsPreferences', update); + await preferencesTable.update(PREFERENCES_ID, update); + }, + + async resetWeights(): Promise { + await ensureRow(); + const diff: Partial = { + topicWeights: {}, + sourceWeights: {}, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsPreferences', diff); + await preferencesTable.update(PREFERENCES_ID, diff); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/reactions.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/reactions.svelte.ts new file mode 100644 index 000000000..4ab79efb6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/stores/reactions.svelte.ts @@ -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 { + 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 { + // 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(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/news/types.ts b/apps/mana/apps/web/src/lib/modules/news/types.ts new file mode 100644 index 000000000..23d949b42 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news/types.ts @@ -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; + /** source slug → weight (default 1.0, range ~0.1 to 3.0). */ + sourceWeights: Record; + 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; + sourceWeights: Record; + onboardingCompleted: boolean; +} + +export interface Reaction { + id: string; + articleId: string; + reaction: ReactionKind; + sourceSlug: string; + topic: string; + createdAt: string; +}