'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?.()
}
// Did this post actually get an AI score, or was it filtered as off-topic
// noise before any AI call? Trump posts mostly aren't crypto-related, so the
// entry filter / AI marks them relevant=false with confidence 0 and no
// reasoning. Showing "AI confidence 0%" + an empty bar for those is
// misleading — it reads as "AI looked and had zero confidence" when really
// "this was skipped as not crypto-relevant". Treat a post as AI-scored only
// when it has a real confidence or reasoning.
const aiScored = post.ai_confidence > 0 || !!post.ai_reasoning
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}
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 ? (