'use client' import { memo, useEffect, useState } from 'react' import type { TrumpPost } from '@/types' /** * Was this post actually AI-scored, or filtered as off-topic noise before any * AI call? Single source of truth — used both by the card (to de-emphasise * noise) and by list views (to offer a "collapse off-topic" toggle), so the * two can never disagree about what counts as noise. */ export function isAiScored(post: Pick): boolean { return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning } function fmtPct(n: number | null | undefined) { if (n == null || isNaN(n)) return '—' // null = window not yet closed const s = n.toFixed(2) + '%' return n >= 0 ? '+' + s : s } function fmtImpactPct(v: number | null | undefined) { if (v == null || isNaN(v)) return '—' const s = Math.abs(v).toFixed(2) + '%' return v >= 0 ? '+' + s : '-' + s } function timeAgo(iso: string) { const diff = Date.now() - new Date(iso).getTime() const m = Math.floor(diff / 60000) if (m < 1) return 'just now' if (m < 60) return m + 'm' const h = Math.floor(m / 60) if (h < 24) return h + 'h' return Math.floor(h / 24) + 'd' } /** * Hydration-safe wrapper for relative time. Returns an empty placeholder on * SSR / first client render, then the real relative time after mount. Prevents * the SSR/CSR "5m vs 6m" mismatch error. */ function TimeAgo({ iso, suffix = '' }: { iso: string; suffix?: string }) { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) return return {timeAgo(iso)}{suffix} } /** * Hydration-safe absolute date formatter. Uses fixed 'en-US' locale + options * so the server/client outputs match once mounted; renders a placeholder before * mount to avoid timezone mismatches. */ function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatOptions }) { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) return return {new Date(iso).toLocaleString('en-US', opts)} } // Visual identity per signal source. The post stream now mixes Trump posts // with scanner-emitted technical signals — operators need to tell them // apart at a glance without reading the text. // // When adding a new scanner source, register it here too — otherwise it // falls through to the generic "first letter" fallback which has no title // or accent colour. const SOURCE_DISPLAY: Record = { truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social' }, breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' }, vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' }, reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner' }, btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC · Macro Bottom Reversal' }, funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC · Funding Rate Reversal' }, kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL · Talks vs Trades Divergence' }, whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert' }, manual: { glyph: '✋', cls: 'manual', title: 'Manual entry' }, } function SourceIcon({ source }: { source: string }) { const d = SOURCE_DISPLAY[source.toLowerCase()] if (d) { return
{d.glyph}
} // Unknown source — show first letter so user can still tell it apart. return
{source.charAt(0).toUpperCase()}
} function SignalPill({ signal }: { signal: string | null }) { if (!signal || signal === 'hold') return HOLD if (signal === 'buy') return BUY if (signal === 'short') return SHORT if (signal === 'sell') return SELL return {signal.toUpperCase()} } interface PostRowProps { post: TrumpPost selected?: boolean onClick?: () => void } const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps) { const [expanded, setExpanded] = useState(false) const impact = post.price_impact function handleClick() { if (!onClick) setExpanded(e => !e) onClick?.() } // Off-topic Trump posts (most of them) are filtered before any AI call, so // they carry confidence 0 + no reasoning. Treat those as un-scored noise and // de-emphasise them. Shared helper so list-level "collapse off-topic" agrees. const aiScored = isAiScored(post) return (
{/* ── main row ── */}
{/* Author label depends on source — non-Trump signals come from technical scanners or external modules, not @realDonaldTrump. */} {post.source === 'truth' ? '@realDonaldTrump' : post.source} · · {aiScored ? ( {post.sentiment} ) : ( off-topic · not crypto )}

{expanded ? post.text : (post.text.slice(0, aiScored ? 180 : 90) + (post.text.length > (aiScored ? 180 : 90) ? '…' : ''))}

{/* Aside (signal pill / target / impact / AI%) only for scored posts. Noise posts render as a compact two-column row (no aside) — the 'off-topic · not crypto' tag in the meta line is enough. */} {aiScored && (
{/* Target asset chip for actionable signals */} {post.target_asset && (post.signal === 'buy' || post.signal === 'short') && ( {post.target_asset} {post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''} )}
{impact ? ( <> 1h peak = 0 ? 'up' : 'down'}`}> {impact.m1h == null ? '…' : fmtPct(impact.m1h)} ) : ( no data )}
AI {post.ai_confidence}%
)}
{/* ── expanded detail ── */} {expanded && (
e.stopPropagation()} > {/* AI confidence bar — only when the post was actually AI-scored. Off-topic/noise posts (relevant=false, conf 0, no reasoning) show a neutral note instead of a misleading empty 0% bar. */} {aiScored ? (
AI confidence {post.ai_confidence}%
) : (
Not crypto-relevant — skipped AI scoring.
)} {/* Trade routing — target asset + expected move */} {post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
Trade {post.signal === 'buy' ? `↑ LONG ${post.target_asset}` : `↓ SHORT ${post.target_asset}`} {post.expected_move_pct != null && ( {`AI expects ~${post.expected_move_pct}% in 1h`} )} {post.category && ( {post.category.replace(/_/g, ' ')} )}
)} {/* AI reasoning */} {post.ai_reasoning && (
AI reasoning
{post.ai_reasoning}
)} {/* Price impact — peak move in signal direction per window */} {impact && (
{`Peak move · ${impact.asset}`}
{(['m5', 'm15', 'm1h'] as const).map(key => { const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h' const v = impact[key] // null = window still open const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h'] const pending = v == null return (
{label}
= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600, color: pending ? 'var(--ink-3)' : undefined }} > {pending ? '…' : fmtImpactPct(v)}
{!pending && correct != null && (
{correct ? '✓' : '✗'}
)} {pending && (
live
)}
) })}
)}
)}
) }) export default PostRow export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }