import type { TrumpPost } from '@/types' import { getPosts, getPostsPage, type PostListResponse } from './api' const LIVE_ARCHIVE_SOURCES = new Set([ 'truth', 'btc_bottom_reversal', 'funding_reversal', 'kol_divergence', ]) function isAiScored(post: Pick): boolean { return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning } function buildSourceCounts(items: TrumpPost[]): PostListResponse['source_counts'] { const counts = new Map() for (const post of items) { const hit = counts.get(post.source) if (!hit) { counts.set(post.source, { count: 1, latest: post.published_at }) continue } hit.count += 1 if (!hit.latest || post.published_at > hit.latest) hit.latest = post.published_at } return Array.from(counts.entries()) .map(([source, meta]) => ({ source, count: meta.count, latest: meta.latest })) .sort((a, b) => b.count - a.count || a.source.localeCompare(b.source)) } export function buildPostListFallbackResponse( items: TrumpPost[], source: string, limit: number, ): PostListResponse { return { items, total: items.length, page: 1, limit, counts: { all: items.length, actionable: items.filter(p => p.signal === 'buy' || p.signal === 'short').length, buy: items.filter(p => p.signal === 'buy').length, short: items.filter(p => p.signal === 'short').length, off_topic: items.filter(p => !isAiScored(p)).length, }, source_counts: [{ source, count: items.length, latest: items[0]?.published_at ?? null }], } } export function buildArchiveFallbackResponse( allPosts: TrumpPost[], page: number, limit: number, source = 'all', ): PostListResponse { const archivePosts = allPosts.filter(post => !LIVE_ARCHIVE_SOURCES.has(post.source)) const sourceCounts = buildSourceCounts(archivePosts) const filtered = source === 'all' ? archivePosts : archivePosts.filter(post => post.source === source) const offset = (page - 1) * limit const items = filtered.slice(offset, offset + limit) return { items, total: filtered.length, page, limit, counts: { all: filtered.length, actionable: filtered.filter(p => p.signal === 'buy' || p.signal === 'short').length, buy: filtered.filter(p => p.signal === 'buy').length, short: filtered.filter(p => p.signal === 'short').length, off_topic: filtered.filter(p => !isAiScored(p)).length, }, source_counts: sourceCounts, } } export async function getInitialPostPage( limit: number, page: number, options: { source?: string filters?: { sourceIn?: string[] sourceNotIn?: string[] archiveOnly?: boolean sentiment?: 'bullish' | 'bearish' | 'neutral' signal?: 'buy' | 'short' | 'actionable' aiScoredOnly?: boolean } legacyFallbackSource?: string legacyFallback?: () => Promise }, ): Promise { return getPostsPage(limit, page, options.source, options.filters).catch(async (e) => { // Only fall back to legacy /posts on 404 (new endpoint not yet deployed). // Non-404 errors (500, network) should NOT silently mask as empty data — // a server-side fallback on 500 produces initial HTML that the client // cannot reproduce on re-fetch (it shows an error instead), causing a // hydration mismatch. Return null so the page renders empty and the // client fetches cleanly on mount. const detail = e instanceof Error ? e.message : '' if (!detail.includes('404')) return null if (options.legacyFallback) { return options.legacyFallback() } if (!options.legacyFallbackSource || options.filters?.archiveOnly || options.filters?.sourceIn || options.filters?.sourceNotIn) { return null } const fallbackItems = await getPosts(limit, page, options.legacyFallbackSource).catch(() => null) return fallbackItems ? buildPostListFallbackResponse(fallbackItems, options.legacyFallbackSource, limit) : null }) }