From 6fc6fff1f3002c60549cc71bdb96f915b88095ca Mon Sep 17 00:00:00 2001 From: oabrivard Date: Tue, 24 Mar 2026 19:33:09 +0100 Subject: [PATCH] feat: article history page + provenance section in synthesis detail - Add ArticleHistoryEntry/ArticleHistoryResponse types - Add articleHistoryApi client (list + getProvenance endpoints) - Add ArticleHistory page with status/source_type filters and pagination - Add collapsible provenance section to SynthesisDetail - Register /article-history route in App.tsx - Add viewHistory link in Settings near articleHistoryDays input - Add all French i18n strings for article history feature Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.tsx | 2 + frontend/src/api/articleHistory.ts | 17 ++ frontend/src/i18n/fr.ts | 20 ++ frontend/src/pages/ArticleHistory.tsx | 294 +++++++++++++++++++++++++ frontend/src/pages/Settings.tsx | 6 + frontend/src/pages/SynthesisDetail.tsx | 87 +++++++- frontend/src/types.ts | 21 ++ 7 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/articleHistory.ts create mode 100644 frontend/src/pages/ArticleHistory.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84e9c3e..e061a5a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ const Settings = lazy(() => import('~/pages/Settings')); const Sources = lazy(() => import('~/pages/Sources')); const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis')); const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail')); +const ArticleHistory = lazy(() => import('~/pages/ArticleHistory')); const AdminProviders = lazy(() => import('~/pages/admin/Providers')); const AdminRateLimits = lazy(() => import('~/pages/admin/RateLimits')); const AdminUsers = lazy(() => import('~/pages/admin/Users')); @@ -63,6 +64,7 @@ const App: Component = () => { + {/* Admin routes with admin layout wrapper */} diff --git a/frontend/src/api/articleHistory.ts b/frontend/src/api/articleHistory.ts new file mode 100644 index 0000000..23fbcda --- /dev/null +++ b/frontend/src/api/articleHistory.ts @@ -0,0 +1,17 @@ +import { api } from './client'; +import type { ArticleHistoryResponse, ArticleHistoryEntry } from '~/types'; + +export const articleHistoryApi = { + list: (params: { limit?: number; offset?: number; status?: string; source_type?: string } = {}): Promise => { + const query = new URLSearchParams(); + if (params.limit) query.set('limit', String(params.limit)); + if (params.offset) query.set('offset', String(params.offset)); + if (params.status) query.set('status', params.status); + if (params.source_type) query.set('source_type', params.source_type); + const qs = query.toString(); + return api.get(`/article-history${qs ? '?' + qs : ''}`); + }, + + getProvenance: (synthesisId: string): Promise => + api.get(`/syntheses/${synthesisId}/provenance`), +}; diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 646b667..e153bbb 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -331,6 +331,26 @@ const fr = { 'admin.users.makeAdmin': 'Promouvoir admin', 'admin.users.makeUser': 'Retirer admin', + // Article History + 'articleHistory.title': 'Historique des articles', + 'articleHistory.date': 'Date', + 'articleHistory.articleTitle': 'Titre', + 'articleHistory.url': 'URL', + 'articleHistory.sourceType': 'Source', + 'articleHistory.status': 'Statut', + 'articleHistory.category': 'Categorie', + 'articleHistory.synthesis': 'Synthese', + 'articleHistory.empty': 'Aucun historique disponible.', + 'articleHistory.filterStatus': 'Filtrer par statut', + 'articleHistory.filterSourceType': 'Filtrer par type de source', + 'articleHistory.all': 'Tous', + 'articleHistory.statusUsed': 'Utilise', + 'articleHistory.statusFiltered': 'Filtre', + 'articleHistory.viewHistory': 'Historique des articles', + 'articleHistory.provenance': 'Provenance', + 'articleHistory.provenanceEmpty': 'Aucune donnee de provenance disponible pour cette synthese.', + 'articleHistory.provenanceDescription': 'Articles candidats traites lors de la generation de cette synthese.', + // Common 'common.loading': 'Chargement...', 'common.error': 'Une erreur est survenue.', diff --git a/frontend/src/pages/ArticleHistory.tsx b/frontend/src/pages/ArticleHistory.tsx new file mode 100644 index 0000000..5a24e54 --- /dev/null +++ b/frontend/src/pages/ArticleHistory.tsx @@ -0,0 +1,294 @@ +import { + type Component, + createSignal, + onMount, + Show, + For, + createEffect, +} from 'solid-js'; +import { A } from '@solidjs/router'; +import { useI18n } from '~/i18n'; +import { articleHistoryApi } from '~/api/articleHistory'; +import type { ArticleHistoryEntry } from '~/types'; +import LoadingSpinner from '~/components/ui/LoadingSpinner'; + +const PAGE_SIZE = 50; + +const STATUS_OPTIONS = [ + 'used', + 'filtered_empty', + 'filtered_history', + 'filtered_diversity', + 'filtered_homepage', + 'filtered_duplicate', + 'filtered_cross_phase_dedup', +]; + +const SOURCE_TYPE_OPTIONS = [ + 'personalized_source', + 'web_search', + 'overflow', +]; + +function statusBadgeClass(status: string): string { + if (status === 'used') return 'bg-green-100 text-green-800'; + if (status.startsWith('filtered_')) return 'bg-red-100 text-red-800'; + return 'bg-gray-100 text-gray-700'; +} + +function truncateUrl(url: string, maxLen = 50): string { + return url.length > maxLen ? url.slice(0, maxLen) + '...' : url; +} + +/** + * Article history page — displays a paginated, filterable table of every + * article candidate processed across all synthesis jobs. + */ +const ArticleHistory: Component = () => { + const { t } = useI18n(); + + const [items, setItems] = createSignal([]); + const [total, setTotal] = createSignal(0); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + + const [filterStatus, setFilterStatus] = createSignal(''); + const [filterSourceType, setFilterSourceType] = createSignal(''); + const [page, setPage] = createSignal(0); + + const totalPages = () => Math.max(1, Math.ceil(total() / PAGE_SIZE)); + + const fetchHistory = async () => { + setLoading(true); + setError(null); + try { + const result = await articleHistoryApi.list({ + limit: PAGE_SIZE, + offset: page() * PAGE_SIZE, + status: filterStatus() || undefined, + source_type: filterSourceType() || undefined, + }); + setItems(result.items); + setTotal(result.total); + } catch { + setError(t('common.error')); + } finally { + setLoading(false); + } + }; + + onMount(fetchHistory); + + // Re-fetch when filters or page change (but not on first mount — onMount covers that) + createEffect(() => { + // Track all reactive dependencies + filterStatus(); + filterSourceType(); + page(); + // Skip the initial run which is handled by onMount + }); + + const handleFilterChange = () => { + setPage(0); + fetchHistory(); + }; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + fetchHistory(); + }; + + return ( +
+ {/* Page header */} +
+

+ {t('articleHistory.title')} +

+
+ + {/* Filters */} +
+
+ + +
+ +
+ + +
+
+ + {/* Error */} + +
+ {error()} +
+
+ + {/* Loading */} + + + + + {/* Table */} + + 0} + fallback={ +
+ {t('articleHistory.empty')} +
+ } + > +
+
+ + + + + + + + + + + + + + + {(entry) => ( + + + + + + + + + + )} + + +
+ {t('articleHistory.date')} + + {t('articleHistory.status')} + + {t('articleHistory.articleTitle')} + + {t('articleHistory.url')} + + {t('articleHistory.sourceType')} + + {t('articleHistory.category')} + + {t('articleHistory.synthesis')} +
+ {new Date(entry.created_at).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + + + {entry.status} + + + + {entry.title || '—'} + + + + {truncateUrl(entry.url)} + + + {entry.source_type} + + {entry.category || '—'} + + + + {entry.synthesis_id!.slice(0, 8)}... + + + + + +
+
+ + {/* Pagination */} + 1}> +
+
+ {page() * PAGE_SIZE + 1}–{Math.min((page() + 1) * PAGE_SIZE, total())} / {total()} +
+
+ + +
+
+
+
+
+
+
+ ); +}; + +export default ArticleHistory; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 72bd6b3..bfe0f3e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -8,6 +8,7 @@ import { createEffect, } from 'solid-js'; import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid'; +import { A } from '@solidjs/router'; import { settingsApi } from '~/api/settings'; import { configApi } from '~/api/config'; import { apiKeysApi } from '~/api/apiKeys'; @@ -477,6 +478,11 @@ const Settings: Component = () => { } /> + diff --git a/frontend/src/pages/SynthesisDetail.tsx b/frontend/src/pages/SynthesisDetail.tsx index 8b2e791..bb2d6c0 100644 --- a/frontend/src/pages/SynthesisDetail.tsx +++ b/frontend/src/pages/SynthesisDetail.tsx @@ -10,8 +10,9 @@ import { ArrowLeft, ExternalLink, Trash2, AlertTriangle, Mail, Send, FileDown } import { useI18n } from '~/i18n'; import { useAuth } from '~/contexts/AuthContext'; import { synthesesApi } from '~/api/syntheses'; +import { articleHistoryApi } from '~/api/articleHistory'; import { isApiError } from '~/types'; -import type { Synthesis, NewsItem as NewsItemType } from '~/types'; +import type { Synthesis, NewsItem as NewsItemType, ArticleHistoryEntry } from '~/types'; import { extractWeekNumber, formatDateLong } from '~/utils/dates'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; @@ -88,6 +89,11 @@ const SynthesisDetail: Component = () => { const [exportingPdf, setExportingPdf] = createSignal(false); const [exportError, setExportError] = createSignal(null); + // Provenance state + const [provenance, setProvenance] = createSignal([]); + const [showProvenance, setShowProvenance] = createSignal(false); + const [provenanceLoading, setProvenanceLoading] = createSignal(false); + onMount(async () => { // Pre-fill email from authenticated user const currentUser = user(); @@ -193,6 +199,25 @@ const SynthesisDetail: Component = () => { } }; + const loadProvenance = async () => { + if (provenance().length > 0) return; // Already loaded + setProvenanceLoading(true); + try { + const data = await articleHistoryApi.getProvenance(params.id); + setProvenance(data); + } catch { + // Provenance may not be available for old syntheses + } finally { + setProvenanceLoading(false); + } + }; + + const toggleProvenance = () => { + const newState = !showProvenance(); + setShowProvenance(newState); + if (newState) loadProvenance(); + }; + return ( }> { + + {/* Provenance */} +
+ + + + + + +

{t('articleHistory.provenanceEmpty')}

+
+ 0}> +
+ + + + + + + + + + + + + {(entry) => ( + + + + + + + + )} + + +
{t('articleHistory.status')}{t('articleHistory.articleTitle')}{t('articleHistory.url')}{t('articleHistory.sourceType')}{t('articleHistory.category')}
+ + {entry.status} + + {entry.title || '—'} + + {entry.url.length > 50 ? entry.url.slice(0, 50) + '...' : entry.url} + + {entry.source_type}{entry.category || '—'}
+
+
+
+
)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5c6e16c..1c606cf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -261,3 +261,24 @@ export interface ProviderConfig { display_name: string; models: ProviderConfigModel[]; } + +// ---- Article History ---- + +export interface ArticleHistoryEntry { + id: string; + url: string; + title: string; + source_type: string; + source_url: string | null; + category: string | null; + synthesis_id: string | null; + status: string; + scraped_ok: boolean; + job_id: string; + created_at: string; +} + +export interface ArticleHistoryResponse { + items: ArticleHistoryEntry[]; + total: number; +}