mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
9ef97a1877
commit
de7e359580
14 changed files with 1331 additions and 0 deletions
|
|
@ -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',
|
||||
|
|
|
|||
101
apps/mana/apps/web/src/lib/modules/news/api.ts
Normal file
101
apps/mana/apps/web/src/lib/modules/news/api.ts
Normal 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;
|
||||
}
|
||||
34
apps/mana/apps/web/src/lib/modules/news/collections.ts
Normal file
34
apps/mana/apps/web/src/lib/modules/news/collections.ts
Normal 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,
|
||||
};
|
||||
157
apps/mana/apps/web/src/lib/modules/news/feed-engine.ts
Normal file
157
apps/mana/apps/web/src/lib/modules/news/feed-engine.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
64
apps/mana/apps/web/src/lib/modules/news/index.ts
Normal file
64
apps/mana/apps/web/src/lib/modules/news/index.ts
Normal 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';
|
||||
19
apps/mana/apps/web/src/lib/modules/news/module.config.ts
Normal file
19
apps/mana/apps/web/src/lib/modules/news/module.config.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
171
apps/mana/apps/web/src/lib/modules/news/queries.ts
Normal file
171
apps/mana/apps/web/src/lib/modules/news/queries.ts
Normal 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' });
|
||||
}
|
||||
83
apps/mana/apps/web/src/lib/modules/news/sources-meta.ts
Normal file
83
apps/mana/apps/web/src/lib/modules/news/sources-meta.ts
Normal 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: '🏛️' },
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
181
apps/mana/apps/web/src/lib/modules/news/types.ts
Normal file
181
apps/mana/apps/web/src/lib/modules/news/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue