'use client' import { memo, useEffect, useState } from 'react' import type { TrumpPost } from '@/types' 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?.() } 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} · · {post.sentiment} {expanded ? post.text : (post.text.slice(0, 180) + (post.text.length > 180 ? '…' : ''))} {/* 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 */} AI confidence {post.ai_confidence}% {/* 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 }
{expanded ? post.text : (post.text.slice(0, 180) + (post.text.length > 180 ? '…' : ''))}