Files

331 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<TrumpPost[]>(initialData?.items ?? [])
const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0)
const [counts, setCounts] = useState<PostListResponse['counts']>(initialData?.counts ?? EMPTY_COUNTS)
const [loading, setLoading] = useState(initialData === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [sigFilter, setSigFilter] = useState<SignalFilter>('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<SignalFilter, string> = {
all: 'All', actionable: 'Actionable', buy: 'Buy', short: 'Short',
}
const sentimentLabels: Record<SentimentFilter, string> = {
all: 'All', bullish: 'Bullish', bearish: 'Bearish', neutral: 'Neutral',
}
return (
<div className="page">
<div className="page-head">
<div>
{/* No "⚡ Auto-trade" badge — the SystemControl strip right below
always shows the Auto-Trade feature + its live state. */}
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
{/* No count slot — the filter tabs right below already show the
per-filter numbers (All / Actionable / Buy / Short). */}
<PageHint>
Every Truth Social post scored in &lt;3s. Trades only when conviction clears the threshold.
</PageHint>
</div>
<span className="chip"><span className="live-dot" />Live</span>
</div>
<SystemControl system="trump" />
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 12, margin: '16px 0 12px', flexWrap: 'wrap',
}}>
{/* Signal filter tabs */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([
{ 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 => (
<button
key={f.key}
className={`nav-tab ${sigFilter === f.key ? 'active' : ''}`}
onClick={() => {
setSigFilter(f.key)
setPage(1)
// sentFilter is intentionally NOT reset here — the user's
// bias preference is preserved so it's still active when
// they switch back to 'all'. The query layer ignores
// sentFilter whenever sigFilter is directional.
}}
>
{f.key === 'actionable' ? '🔥 ' : ''}
{signalLabels[f.key]}{' '}
<span style={{ color: 'var(--ink-4)' }}>{counts[f.key]}</span>
</button>
))}
</div>
{/* 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' && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* 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' && (
<button
onClick={() => { setHideNoise(v => !v); setPage(1) }}
title={`Show only crypto-relevant posts — hides ${noiseCount} off-topic Trump posts`}
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--line)',
background: hideNoise ? 'var(--ink)' : 'transparent',
color: hideNoise ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: 600,
marginRight: 4,
}}
>
{hideNoise ? 'Show all' : 'Signals only'}
</button>
)}
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
Bias
<InfoTip
text="Filters by the post's directional bias (bullish / bearish / neutral). Bias ≠ trade signal — a bearish post can still be a BUY if the market reads it as priced-in."
placement="left"
/>
</span>
{SENTIMENTS.map(f => (
<button
key={f}
onClick={() => { setSentFilter(f); setPage(1) }}
style={{
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
)}
</div>
{loading && <TrumpSkeleton />}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
{`Couldn't load signals — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}>Retry</button>
</div>
</div>
)}
{!loading && !loadErr && totalPosts === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No Trump signals match the current filter.
</div>
)}
{!loading && pageItems.length > 0 && (
<>
<div className="post-stream">
{pageItems.map(p => <PostRow key={p.id} post={p} />)}
</div>
<Pagination
page={safePage}
total={totalPages}
count={totalPosts}
pageSize={PAGE_SIZE}
onChange={setPage}
/>
</>
)}
</div>
)
}
function TrumpSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="skeleton sk-line sk-w-q" />
<div className="skeleton sk-line" style={{ width: 60 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line sk-w-half" />
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
<div className="skeleton sk-line-sm" style={{ width: 64 }} />
</div>
</div>
))}
</div>
)
}