'use client' import { useState, useEffect, useMemo, useRef } from 'react' import type { TrumpPost } from '@/types' import { getPosts, getPostsPage, type PostListResponse } from '@/lib/api' import { hasCached, swrFetch } from '@/lib/cache' import PostRow, { isAiScored } from '@/components/dashboard/PostCards' import SystemControl from '@/components/signals/SystemControl' import PageHint from '@/components/ui/PageHint' import InfoTip from '@/components/ui/InfoTip' import Pagination from '@/components/ui/Pagination' const PAGE_SIZE = 30 const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const type SentimentFilter = (typeof SENTIMENTS)[number] type SignalFilter = 'all' | 'actionable' | 'buy' | 'short' interface TrumpSignalPageProps { initialData?: PostListResponse | null } const EMPTY_COUNTS: PostListResponse['counts'] = { all: 0, actionable: 0, buy: 0, short: 0, off_topic: 0, } function matchesBaseFilters(post: TrumpPost, sentFilter: SentimentFilter, hideNoise: boolean) { if (hideNoise && !isAiScored(post)) return false if (sentFilter !== 'all' && post.sentiment !== sentFilter) return false return true } function matchesSignalFilter(post: TrumpPost, sigFilter: SignalFilter) { if (sigFilter === 'actionable') return post.signal === 'buy' || post.signal === 'short' if (sigFilter === 'buy') return post.signal === 'buy' if (sigFilter === 'short') return post.signal === 'short' return true } function buildLocalCounts( posts: TrumpPost[], sentFilter: SentimentFilter, hideNoise: boolean, ): PostListResponse['counts'] { const sentimentScoped = posts.filter(p => sentFilter === 'all' || p.sentiment === sentFilter) const base = sentimentScoped.filter(p => matchesBaseFilters(p, sentFilter, hideNoise)) return { all: base.length, actionable: base.filter(p => p.signal === 'buy' || p.signal === 'short').length, buy: base.filter(p => p.signal === 'buy').length, short: base.filter(p => p.signal === 'short').length, off_topic: sentimentScoped.filter(p => !isAiScored(p)).length, } } export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageProps) { const [posts, setPosts] = useState(initialData?.items ?? []) const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0) const [counts, setCounts] = useState(initialData?.counts ?? EMPTY_COUNTS) const [loading, setLoading] = useState(initialData === null) const [loadErr, setLoadErr] = useState('') const [sentFilter, setSentFilter] = useState('all') const [sigFilter, setSigFilter] = useState('all') const [hideNoise, setHideNoise] = useState(false) const [page, setPage] = useState(1) const [serverPaging, setServerPaging] = useState(true) // Tracks whether we already have any rows on screen, WITHOUT putting // posts.length in the effect deps (which would re-run the effect on every // setPosts and fire a redundant fetch). Updated after each load below. const hasRowsRef = useRef((initialData?.items?.length ?? 0) > 0) useEffect(() => { const filters = { // Don't apply the sentiment bias when a directional signal filter is // active — Buy/Short/Actionable already imply direction, and combining // them with a sentiment filter produces confusing empty results. // sentFilter is intentionally NOT reset when the user switches tabs so // their preference is preserved if they switch back to 'all'. sentiment: sentFilter === 'all' || sigFilter !== 'all' ? undefined : sentFilter, signal: sigFilter === 'all' ? undefined : sigFilter, aiScoredOnly: hideNoise, } as const // sentFilter only participates in the key when sigFilter is 'all' — it's // ignored by the query otherwise, so caching it in the key would create // phantom cache entries that never match on the way back. const effectiveSent = sigFilter === 'all' ? sentFilter : 'all' const key = `posts-truth-page-${page}-sent-${effectiveSent}-sig-${sigFilter}-noise-${hideNoise ? '1' : '0'}` const legacyKey = 'posts-truth-legacy-500' setLoadErr('') setLoading(!hasRowsRef.current && !hasCached(key) && !hasCached(legacyKey)) swrFetch( key, 3 * 60_000, () => getPostsPage(PAGE_SIZE, page, 'truth', filters), fresh => { setPosts(fresh.items) setTotalPosts(fresh.total) setCounts(fresh.counts) hasRowsRef.current = fresh.items.length > 0 }, ) .then(r => { setPosts(r.items) setTotalPosts(r.total) setCounts(r.counts) setServerPaging(true) setLoadErr('') hasRowsRef.current = r.items.length > 0 }) .catch(async e => { const detail = e instanceof Error ? e.message : 'Failed to load posts' if (!detail.includes('404')) { setLoadErr(detail) return } try { // Legacy fallback (old backend without /posts-paged). Pull the // largest window the /posts endpoint allows (le=500) so client-side // pagination below has the full set to slice — 200 silently dropped // older matching posts once the truth feed grew past one page. const legacyPosts = await swrFetch( legacyKey, 3 * 60_000, () => getPosts(500, 1, 'truth'), ) const localCounts = buildLocalCounts(legacyPosts, sentFilter, hideNoise) setPosts(legacyPosts) setTotalPosts(localCounts.all) setCounts(localCounts) setServerPaging(false) setLoadErr('') hasRowsRef.current = legacyPosts.length > 0 } catch (legacyErr) { setLoadErr(legacyErr instanceof Error ? legacyErr.message : detail) } }) .finally(() => setLoading(false)) }, [page, sentFilter, sigFilter, hideNoise]) const filtered = useMemo( () => serverPaging ? posts : posts.filter(p => matchesBaseFilters(p, sentFilter, hideNoise) && matchesSignalFilter(p, sigFilter)), [hideNoise, posts, sentFilter, serverPaging, sigFilter], ) const noiseCount = counts.off_topic const totalPages = Math.max(1, Math.ceil(totalPosts / PAGE_SIZE)) const safePage = Math.min(page, totalPages) const pageItems = serverPaging ? posts : filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE) const signalLabels: Record = { all: 'All', actionable: 'Actionable', buy: 'Buy', short: 'Short', } const sentimentLabels: Record = { all: 'All', bullish: 'Bullish', bearish: 'Bearish', neutral: 'Neutral', } return (

Trump Signal

⚡ Auto-trade
Every Truth Social post scored in <3s. Trades only when conviction clears the threshold.
Live
{/* Signal filter tabs */}
{([ { key: 'all', hint: 'Every post the scraper has captured.' }, { key: 'actionable', hint: 'Only posts the AI flagged as BUY or SHORT.' }, { key: 'buy', hint: 'Posts the AI scored as a long signal.' }, { key: 'short', hint: 'Posts the AI scored as a short signal.' }, ] as const).map(f => ( ))}
{/* Sentiment + noise filters — hidden when a directional filter is already active (actionable / buy / short), because: - "buy" already implies bullish direction - "short" already implies bearish direction - "actionable" = buy ∪ short — sentiment adds no further info Showing them would create confusing combinations (e.g. Buy + Bearish returns 0 results with no explanation). */} {sigFilter === 'all' && (
{/* One-click collapse of off-topic (non-crypto, un-scored) posts. Hidden when a sentiment filter is active — Bullish/Bearish/Neutral results are all AI-scored by definition, so there are no off-topic posts to hide and the button would be meaningless. */} {(noiseCount > 0 || hideNoise) && sentFilter === 'all' && ( )} Bias {SENTIMENTS.map(f => ( ))}
)}
{loading && } {!loading && loadErr && (
⚠️ {`Couldn't load signals — ${loadErr}`}
)} {!loading && !loadErr && totalPosts === 0 && (
No Trump signals match the current filter.
)} {!loading && pageItems.length > 0 && ( <>
{pageItems.map(p => )}
)}
) } function TrumpSkeleton() { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
) }