Files

127 lines
4.1 KiB
TypeScript

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<TrumpPost, 'ai_confidence' | 'ai_reasoning'>): boolean {
return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning
}
function buildSourceCounts(items: TrumpPost[]): PostListResponse['source_counts'] {
const counts = new Map<string, { count: number; latest: string | null }>()
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<PostListResponse | null>
},
): Promise<PostListResponse | null> {
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
})
}