diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b04ac9c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,378 @@ +# Trump Alpha — Frontend (trumpsignal.com) + +> Next.js 16 dashboard for the Trump Alpha backend. Real-time signals, +> Hyperliquid position management UI, KOL talks-vs-trades view, Macro Vibes +> regime panel. Read-only for the public; wallet-bound features (Pro) for +> auto-trade subscribers. + +This is the AI-readable entry doc. Read this first on entering the repo. + +--- + +## What this repo is + +- **Pure frontend** — Next.js 16 App Router, TypeScript, server components + + client components, deployed to Vercel. +- **Talks to a Python backend** at `https://api.trumpsignal.com` (or + `localhost:8000` in dev). All trading state, signals, AI scoring, + Hyperliquid integration lives in the sibling **`../backend`** + repo. See its AGENTS.md. +- **No server-side trading logic** lives here. Frontend is a thin layer + over the API + WebSocket — even "open a position" routes to a backend + endpoint that does the signed-request verification + HL call. + +--- + +## Stack quirks (Next.js 16 specific) + +- **`proxy.ts` replaces `middleware.ts`** in Next.js 16. Routes that need + CORS / locale rewriting go through `proxy.ts` at the root. +- **`params` is async (`Promise<{...}>`)** in all dynamic routes. ALL page + components await it: + ```tsx + export default async function Page({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + ... + } + ``` + Forgetting the `await` redirects to `/undefined/...` silently. Several + pages were broken this way pre-v2.0 — be alert if you copy older patterns. +- **i18n is shelved.** The `[locale]` segment exists, `messages/zh.json` + exists, but Chinese branches are kept as dead code with a comment + `// i18n shelved — see messages/zh.json`. Don't activate without an ADR. + Currently `isZh = false` is hardcoded in metadata generators. +- **`runtime = 'edge'`** on a few routes (`app/icon.tsx`, OG image generator). + Edge runtime has no fs / no Node APIs — don't import server-side libs there. + +--- + +## Route map + +``` +app/ +├── layout.tsx Root layout: JSON-LD schema.org, fonts, meta +├── page.tsx / (root) → marketing / landing page +├── icon.tsx Dynamic favicon (α on dark bg, edge runtime) +├── apple-icon.tsx PWA install icon +├── opengraph-image.tsx OG card generator (1200x630) +├── manifest.ts PWA manifest +├── sitemap.ts SEO sitemap.xml +├── robots.ts robots.txt +└── api/ + └── proxy/[...path]/ Catch-all proxy to backend (CORS workaround) + +app/[locale]/ +├── page.tsx Landing — pinned signals, live ticker, Trump feed +├── DashboardClient.tsx Main 'use client' wrapper for the home dashboard +├── trump/page.tsx /en/trump — Trump Truth Social signal stream +├── macro/page.tsx /en/macro — Macro Vibes regime panel ★ renamed from /btc +├── kol/page.tsx /en/kol — KOL talks-vs-trades +├── trades/page.tsx /en/trades — closed trade history +├── posts/page.tsx /en/posts — all Posts (signal + Trump) +├── archive/page.tsx /en/archive — historical / paginated +├── settings/page.tsx /en/settings — wallet connect, HL key, prefs +├── analytics/page.tsx /en/analytics — performance metrics +├── case-studies/page.tsx /en/case-studies — documented event walkthroughs +├── methodology/page.tsx /en/methodology — how signals work +├── glossary/page.tsx /en/glossary — AHR999, MVRV-Z, KOL, etc. +├── privacy/page.tsx +├── terms/page.tsx +├── contact/page.tsx +└── globals.css Design system tokens + .macro-* classes +``` + +--- + +## Components map (the meaningful ones) + +``` +components/ +├── nav/Navbar.tsx Top nav + tabs (Trump | Macro Vibes | KOL ...) +├── signals/ +│ ├── SourceChips.tsx Filter chips per source +│ └── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch +├── dashboard/ +│ ├── PostCards.tsx Trump post cards +│ ├── SignalMonitor.tsx Breakout Monitor (ETH/LINK 5m, WS-driven). +│ │ UNMOUNTED 2026-06-12 — backend scanner is +│ │ disabled and its 5-min poll job unscheduled. +│ │ Kept for revival; see Open known issues. +│ └── ChartPanel.tsx Price chart panel +├── btc/MacroPanel.tsx ★ 8-indicator Macro Vibes layout (4 sections, +│ composite needle, threshold chips, peak-trail viz) +├── positions/ +│ └── OpenPositions.tsx Polls /positions/open + /positions/today every 15s +│ Includes grow toggle + manual close button +├── trades/ +│ ├── TradeTable.tsx Closed trade history table (dynamic asset filter) +│ └── BotConfigPanel.tsx Bot configuration (SL, TP, leverage, paper mode). +│ Includes 3-step onboarding stepper + inline Auto-Trade toggle. +├── telegram/ +│ └── TelegramCard.tsx Settings — connect via 6-char code +├── wallet/ +│ └── SignConfirmSheet.tsx EIP-191 signed request preview +├── seo/Breadcrumbs.tsx Structured breadcrumb nav for SEO +├── ui/ +│ ├── InfoTip.tsx CSS-only tooltip with `?` icon +│ ├── PageHint.tsx Strong page subtitle +│ ├── Pagination.tsx Reusable pagination control +│ └── TradeAlertBanner.tsx Fixed-position WS alert banner (trade_alert events). +│ Auto-dismisses after 10s. Filtered by wallet address. +└── lib/wsContext.tsx WebSocket singleton context (replaces WsProvider) +``` + +--- + +## API layer + +All API calls go through **`lib/api.ts`**. Single `fetchJson` wrapper: + +- Reads base URL from env (`NEXT_PUBLIC_API_URL`) or proxies through + `/api/proxy/[...path]/` for same-origin CORS-free calls. +- Wallet-scoped reads (`/user/.../public`, `/positions/open`) pass wallet + as query param + signed ts/sig. +- Mutations (subscribe, manual_close, set_auto_trade, manual-window) build + an EIP-191 signed payload via wagmi → POST signed envelope. + +**Auth model**: no sessions, no cookies. Wallet address + signed timestamp ++ signature on every mutating call. Verification lives in the backend +(`signed_request.verify_signed_request`). The same wallet that signed the +HL API key envelope at subscribe time is the only one that can later +mutate the subscription. + +--- + +## State management + +- **One small global store** — `store/dashboard.ts` (Zustand, `useDashboardStore`). + Holds cross-page UI/session state: selected post, chart asset/timeframe, + wallet address, subscription + bot-readiness flags, masked HL key hint, and + the live-price map. Everything else stays tree-local (state + props) — don't + grow this store into a catch-all; page-specific data belongs in the page. +- **SWR-style cache** in `lib/cache.ts` for the API-heavy pages. +- **WebSocket** singleton via `WsProvider` — used by `SignalMonitor` and + live price tickers. +- **localStorage** holds wallet address only (for re-connect UX). HL API + keys NEVER touch the client — they're submitted once at subscribe, sent + encrypted, and decrypted only inside the backend. + +--- + +## Design system + +Tokens in `app/[locale]/globals.css`: + +- **Colors** — `oklch()` color space throughout. Brand orange = `#f5a524`, + brand dark = `#0a0907`. `var(--up)` green / `var(--down)` red / `var(--ink-1..3)` + text tiers. +- **Dark mode** via `[data-theme="dark"]` attribute on ``. Most + components use `oklch(L% c h)` so dark/light switching is automatic. +- **Macro Vibes specific** — extensive `.macro-*` classes for the 8-indicator + layout. Threshold chips have `:not(.active) { opacity: 0.45 }` and `.active` + is tone-filled (green/red/amber). + +--- + +## Things that LOOK like bugs but aren't + +- **Old code references "/en/btc"** — Macro Vibes was renamed from "BTC + Signal" in v2.0. All routes are now `/en/macro`. `app/[locale]/btc/page.tsx` + EXISTS intentionally as a permanent redirect to `/en/macro` — that's fine. + If you find a stale `/en/btc` hardcoded link anywhere else, it's a real + bug — fix it. +- **`isZh = false` in metadata generators** — i18n was shelved pre-launch. + See "Stack quirks" above. +- **Multiple `proxy.ts` patterns** — Next.js 16 split middleware behaviour. + Keep proxy logic in the single `proxy.ts` at root. +- **`'use client'` directives on many pages** — necessary for client-side + WS / wallet interactions. Don't try to convert without verifying the WS + + wagmi imports work in server components. +- **`telegram.send_message` accepting `int | str` for chat_id** — the backend + uses string form for the public channel (`"@trumpalpha"`) and integer for + private user chats. Both are valid; no frontend change needed. +- **`x-forwarded-for` is deleted in the proxy** — KNOWN BUG (see below). + Don't "fix" it by just removing the delete; the correct fix is to relay the + real client IP. See Open Known Issues. + +--- + +## Open known issues (frontend) + +- ~~**Rate limit bypass via proxy** (FIXED 2026-05-29):~~ `route.ts` no longer + deletes `x-forwarded-for`. It now reads the real client IP from the incoming + `x-forwarded-for` or `x-real-ip` header (set by Vercel/CDN) and sets it on + the upstream request so `slowapi` can distinguish individual clients. + +- ~~**`signMessageAsync` in `OpenPositions` polling deps** (MEDIUM, FIXED 2026-05-29):~~ + Removed `signMessageAsync` and `isZh` from the `useEffect` dependency array + in `components/positions/OpenPositions.tsx`. Neither is used inside the effect; + including them caused wagmi reference churn to restart the 15s poll timer. + +- ~~**Stale types after BUG-08/BUG-14** (FE-01–06, FIXED 2026-05-29):~~ + - `types/index.ts`: `price_impact.asset` widened from `'BTC' | 'ETH'` to + `string` (BUG-14 now tracks the actually-traded asset which may be + SOL/TRUMP/etc.). + - `lib/useRealtimeData.ts`: `PriceMsg.asset` and `Handlers.onPrice` widened + to `string` (BUG-08 expanded the WS price stream to all ASSET_MAP entries). + - `store/dashboard.ts`: `livePrices` changed from `{ BTC; ETH }` to + `Record`; `setLivePrice` param widened to `string`. + - `lib/api.ts`: `getPrices` asset param widened to `string`. + - `components/trades/TradeTable.tsx`: hardcoded `ASSETS = ['all','BTC','ETH','SOL']` + replaced by a dynamic `useMemo` that builds the asset list from the actual + trade set — new perps (TRUMP, BNB, etc.) appear in the filter automatically. + - `DashboardClient.tsx` + `TradesPageClient.tsx`: removed `isZh` from + `useEffect` deps (compile-time constant; including it restarted effects + on every render). + +- ~~**Interaction/button bugs sweep** (UI-A..G, FIXED 2026-05-29):~~ + - **A** `OpenPositions.tsx` close-modal backdrop now dismisses in `'err'` + state too (previously stuck — only `'idle'` dismissed). State is reset on + dismiss so the next open is clean. + - **B** `confirmCloseTrade` distinguishes user-cancelled signature + (`isUserRejection`) from real failure: cancellation closes the modal + silently; real errors stay open in `'err'` state with the message. + - **C** `SystemControl.flipAuto` claims `busy=true` **before** `await + confirmSign(...)`. Rapid double-click on the ON/OFF pill no longer slips + past the guard. The slot is released if the user cancels the sheet. + - **D** `BotConfigPanel` "Rotate" API-key button resets `keyState`/`keyErr` + so a previously-failed save's red error doesn't bleed into the new edit. + - **E** Paper/Live `Switch` clears `subErr` when toggled so a stale + subscribe-error doesn't sit next to the new choice. + - **F** `confirmSign` keeps a module-scope `_activeCancel`; a second call + while a sheet is mounted resolves the first promise with `false` instead + of leaving its caller hanging forever. All 9 call sites verified to + cleanly handle the `false` return (`setBusy(false)` / `setLoadState('idle')`). + - **G** `OpenPositions.toggleGrow` errors now write to a dedicated + `growErr` state with a 4-second auto-clear, instead of squatting in the + panel-header `err` banner shared with the 15-s poll error. + +- ~~**H2 Chart data race** (FIXED 2026-06-01):~~ `cancelled` flag added to the + candles `useEffect` — stale BTC response can no longer overwrite ETH candles + when asset is switched mid-fetch. + +- ~~**M8 Duplicate React keys** (FIXED 2026-06-01):~~ `onNewPost` now deduplicates + by `post.id` before prepending to the posts list. + +- ~~**L2 fittedRef not reset on asset change** (FIXED 2026-06-01):~~ Chart view + is re-fitted when `asset` changes, not only on `timeframe` change. + +- ~~**L4 invalid ARIA role** (FIXED 2026-06-01):~~ `Ticker.tsx` `role="marquee"` + replaced with `role="region"`. + +- ~~**L5 ws:// mixed-content block** (FIXED 2026-06-01):~~ `wsContext.tsx` now + auto-upgrades to `wss://` when served over HTTPS, instead of hard-coding `ws://`. + +- ~~**Data-consistency / UI sweep** (FIXED 2026-06-09):~~ + - **Overview headline counts** (`DashboardClient`) now come from server-side + `/posts-paged` totals (`getPostsPage(1,1,...)` for global / truth / macro), + not the 80-post first-paint slice. The local slice is only a pre-load + fallback. Without this a 1108-post feed showed "80 tracked / —". + - **"Live" chip downgrades to "Delayed"** when `/api/health/deep` reports + `is_leader === false` (follower process runs no background tasks → data is + frozen). Keyed ONLY on is_leader, not `status:degraded`, to avoid flapping + on transient single-feed staleness. + - **KOL pages default to a 30d window** (was 7d) everywhere — `kol/page.tsx` + SSR, `KolPageClient` `kolDays`, and the Overview KOL-intel card. KOL + ingestion is daily/sparse so 7d was frequently empty (7d=0 / 30d=196). + - **KOL divergence "Xd ago"** uses `post_at`, not `created_at` (a backfill + set created_at≈now and made old events look fresh). + - **Macro tone** is derived from the backend `regime_label`, not a + re-thresholded score (±15 on the client vs ±20 on the server disagreed). + - **Trump page header count** follows the active signal filter. + - **Source labels**: `blog` (KOL), `sma_reclaim`/`rsi_reversal`/`breakout` + (TradeTable + PostCards), `adopted` (TradeTable) registered — no more raw + technical strings. + - **TradeTable** resets source/asset filters that don't apply to the new + wallet's trade set (clamp effect), so a stuck filter + vanished breakdown + card can't hide the new history. + - **OpenPositions** has an in-page "Unlock positions" button (signs + `view_user`) — no forced detour to Settings. + - **BotConfigPanel**: overnight schedules (22:00–02:00) are allowed (backend + already supports wrap-around); readiness reports NOT-ready for a Macro-only + wallet with no Telegram bound (since `/adopt` needs Telegram). + - **Cross-page selection**: clicking a Recent-signals row clears + `selectedDayPosts` so the single-post detail actually shows. + +**Open / deferred (need interface change or DB migration):** +- **C3** Signed READ endpoints (`allow_replay=True`) put `ts`/`sig` in URL → access logs. Fix requires changing read endpoints to POST body (interface change + frontend update). +- **H4** KEK derived with single SHA-256, no salt/KDF. Fix requires re-encrypting all HL API keys (DB migration). +- **M5** `GET /telegram/{wallet}/status` is unauthenticated — exposes `chat_id`/`tg_username`. +- **M7** Rate limit bypassable via spoofed XFF — infrastructure-level fix (nginx/Cloudflare). +- ~~**M9**~~ **FIXED 2026-06-12**: `lib/cache.ts` keeps a per-key `subscribers` + list — every caller that hits a stale key during an in-flight revalidation + registers its `onUpdate`, and ALL are notified when the fetch resolves + (previously only the caller that started the revalidation got fresh data). +- ~~**Funding Reversal tab Breakout Monitor**~~ **RESOLVED 2026-06-12 (removed)**: + `SignalMonitor` unmounted from the Funding tab — the backend scanner has been + disabled for months (`funding_signal._enabled=False`, operator-only toggle) so + the panel only ever showed "Paused / No signals yet". The backend's 5-min poll + job was also unscheduled (`backend/app/main.py`); the `/signal/*` API routes + remain. To revive: re-add the `add_job()` AND remount `SignalMonitor` in + `MacroVibesPageClient`. + +## How to verify changes locally + +```bash +# Type check (must pass with 0 errors) +npx tsc --noEmit + +# Production build (catches missing pages, stale imports) +rm -rf .next && npx next build + +# Dev server — NOTE: this app runs on port 3001 (see package.json: +# `next dev -p 3001` / `next start -p 3001`), NOT the Next.js default 3000. +# Any external doc, script, or healthcheck must target 3001. +npm run dev + +# Pages-200 sweep (every locale page returns 200) — port 3001 +for p in /en /en/trump /en/macro /en/kol /en/trades /en/posts /en/settings \ + /en/methodology /en/glossary /en/case-studies /en/privacy /en/terms; do + echo "$p $(curl -so /dev/null -w '%{http_code}' http://localhost:3001$p)" +done +``` + +--- + +## SEO / GEO surface (don't break) + +- **`app/layout.tsx`** has JSON-LD schema.org markup (Organization, + SoftwareApplication, FAQ). Search engines + AI crawlers read this. +- **`public/llms.txt`** — canonical doc for AI crawlers (Perplexity, ChatGPT + browse, Google AI Overviews). Edit BOTH this file AND `layout.tsx` when + renaming product features. +- **`app/sitemap.ts`** — sitemap.xml. Add new routes here. +- **OG image** — `app/opengraph-image.tsx` dynamically renders the social + card. Keep the brand pill list in sync with current features. + +--- + +## Coupling to backend + +- **API contract lives in TypeScript types under `lib/api.ts`** (e.g. + `OpenPositionsResponse`, `TodayStats`, `BotTrade`). These must match the + backend Pydantic models in `app/api/*/positions.py` etc. +- When backend adds a field (e.g. `BotTrade.released_at`), this repo's + `lib/api.ts` types may need updating to surface it in the UI. Currently + released trades are FILTERED OUT by the backend's `/positions/open` — + they don't appear here, so no UI for "managed vs released" yet. +- When backend renames a route (e.g. `/btc/page.tsx` → `/macro/page.tsx`), + search for ALL references (including telegram deep-link map in + `backend/app/services/telegram.py`). + +--- + +## Deploy + +Vercel auto-deploys `main` branch pushes. No special build flags. Env vars +in Vercel dashboard: +- `NEXT_PUBLIC_API_URL` — backend HTTPS origin +- `NEXT_PUBLIC_SITE_URL` — canonical site URL (used for OG, sitemap) +- `NEXT_PUBLIC_WS_URL` — WebSocket endpoint (usually `wss://api...`) + +--- + +## Sibling repo + +- **`/Users/k/Public/trumpsignal/backend`** — Python/FastAPI backend, includes + Hyperliquid integration, Telegram bot, AI scoring (DeepSeek), all + signal scanners, the adoption/release flow. See its AGENTS.md. diff --git a/CLAUDE.md b/CLAUDE.md index a0f755a..6ff4663 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,10 @@ components/ │ └── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch ├── dashboard/ │ ├── PostCards.tsx Trump post cards -│ ├── SignalMonitor.tsx Live signal stream (WS-driven) +│ ├── SignalMonitor.tsx Breakout Monitor (ETH/LINK 5m, WS-driven). +│ │ UNMOUNTED 2026-06-12 — backend scanner is +│ │ disabled and its 5-min poll job unscheduled. +│ │ Kept for revival; see Open known issues. │ └── ChartPanel.tsx Price chart panel ├── btc/MacroPanel.tsx ★ 8-indicator Macro Vibes layout (4 sections, │ composite needle, threshold chips, peak-trail viz) @@ -259,13 +262,53 @@ Tokens in `app/[locale]/globals.css`: - ~~**L5 ws:// mixed-content block** (FIXED 2026-06-01):~~ `wsContext.tsx` now auto-upgrades to `wss://` when served over HTTPS, instead of hard-coding `ws://`. +- ~~**Data-consistency / UI sweep** (FIXED 2026-06-09):~~ + - **Overview headline counts** (`DashboardClient`) now come from server-side + `/posts-paged` totals (`getPostsPage(1,1,...)` for global / truth / macro), + not the 80-post first-paint slice. The local slice is only a pre-load + fallback. Without this a 1108-post feed showed "80 tracked / —". + - **"Live" chip downgrades to "Delayed"** when `/api/health/deep` reports + `is_leader === false` (follower process runs no background tasks → data is + frozen). Keyed ONLY on is_leader, not `status:degraded`, to avoid flapping + on transient single-feed staleness. + - **KOL pages default to a 30d window** (was 7d) everywhere — `kol/page.tsx` + SSR, `KolPageClient` `kolDays`, and the Overview KOL-intel card. KOL + ingestion is daily/sparse so 7d was frequently empty (7d=0 / 30d=196). + - **KOL divergence "Xd ago"** uses `post_at`, not `created_at` (a backfill + set created_at≈now and made old events look fresh). + - **Macro tone** is derived from the backend `regime_label`, not a + re-thresholded score (±15 on the client vs ±20 on the server disagreed). + - **Trump page header count** follows the active signal filter. + - **Source labels**: `blog` (KOL), `sma_reclaim`/`rsi_reversal`/`breakout` + (TradeTable + PostCards), `adopted` (TradeTable) registered — no more raw + technical strings. + - **TradeTable** resets source/asset filters that don't apply to the new + wallet's trade set (clamp effect), so a stuck filter + vanished breakdown + card can't hide the new history. + - **OpenPositions** has an in-page "Unlock positions" button (signs + `view_user`) — no forced detour to Settings. + - **BotConfigPanel**: overnight schedules (22:00–02:00) are allowed (backend + already supports wrap-around); readiness reports NOT-ready for a Macro-only + wallet with no Telegram bound (since `/adopt` needs Telegram). + - **Cross-page selection**: clicking a Recent-signals row clears + `selectedDayPosts` so the single-post detail actually shows. + **Open / deferred (need interface change or DB migration):** - **C3** Signed READ endpoints (`allow_replay=True`) put `ts`/`sig` in URL → access logs. Fix requires changing read endpoints to POST body (interface change + frontend update). - **H4** KEK derived with single SHA-256, no salt/KDF. Fix requires re-encrypting all HL API keys (DB migration). -- **M1** Adopted (sys2) positions incorrectly counted against sys1 daily budget. - **M5** `GET /telegram/{wallet}/status` is unauthenticated — exposes `chat_id`/`tg_username`. - **M7** Rate limit bypassable via spoofed XFF — infrastructure-level fix (nginx/Cloudflare). -- **M9** SWR cache only notifies the first caller on background refresh — others get stale data. +- ~~**M9**~~ **FIXED 2026-06-12**: `lib/cache.ts` keeps a per-key `subscribers` + list — every caller that hits a stale key during an in-flight revalidation + registers its `onUpdate`, and ALL are notified when the fetch resolves + (previously only the caller that started the revalidation got fresh data). +- ~~**Funding Reversal tab Breakout Monitor**~~ **RESOLVED 2026-06-12 (removed)**: + `SignalMonitor` unmounted from the Funding tab — the backend scanner has been + disabled for months (`funding_signal._enabled=False`, operator-only toggle) so + the panel only ever showed "Paused / No signals yet". The backend's 5-min poll + job was also unscheduled (`backend/app/main.py`); the `/signal/*` API routes + remain. To revive: re-add the `add_job()` AND remount `SignalMonitor` in + `MacroVibesPageClient`. ## How to verify changes locally diff --git a/app/[locale]/DashboardClient.tsx b/app/[locale]/DashboardClient.tsx index 40f3760..360f3c4 100644 --- a/app/[locale]/DashboardClient.tsx +++ b/app/[locale]/DashboardClient.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useParams } from 'next/navigation' @@ -9,13 +9,14 @@ import { useAccount } from 'wagmi' import type { TrumpPost, BotPerformance, Candle } from '@/types' import { useDashboardStore } from '@/store/dashboard' import { usePriceSocket } from '@/lib/useRealtimeData' -import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, type MacroSnapshot } from '@/lib/api' +import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, getPostsPage, type MacroSnapshot } from '@/lib/api' import type { KolDivergence, KolDigest } from '@/types' import { getCachedViewEnvelope } from '@/lib/signedRequest' import { swrFetch, invalidate as invalidateCache } from '@/lib/cache' import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards' import OpenPositions from '@/components/positions/OpenPositions' import PageHint from '@/components/ui/PageHint' +import InfoTip from '@/components/ui/InfoTip' import AnimatedNumber from '@/components/ui/AnimatedNumber' // Heavy components — lazy-loaded so they don't bloat the initial JS bundle. @@ -159,17 +160,42 @@ export default function DashboardClient({ initialPosts }: Props) { const params = useParams() const locale = (typeof params?.locale === 'string' ? params.locale : 'en') const [posts, setPosts] = useState(initialPosts) + // Chart markers need actionable (buy/short) posts, but the 80-post + // first-paint slice is usually ALL hold/filtered — Trump posts are mostly + // off-topic, so the chart would render zero markers. Fetch signal posts + // separately and merge them in for the chart. + const [signalPosts, setSignalPosts] = useState([]) + // Distinguishes "actionable fetch not done yet" from "loaded, zero + // actionable" — the auto-select effect waits on it before falling back. + const [signalPostsLoaded, setSignalPostsLoaded] = useState(false) const [performance, setPerformance] = useState(undefined) const [candles, setCandles] = useState([]) + const [hideFiltered, setHideFiltered] = useState(false) const [chartErr, setChartErr] = useState('') const [chartReload, setChartReload] = useState(0) const [selectedPostId, setSelectedPostId] = useState(null) + const railRef = useRef(null) const [macro, setMacro] = useState(null) const [kolDivergences, setKolDivergences] = useState([]) const [kolDigest, setKolDigest] = useState(null) // For 1D: show all posts from selected day const [selectedDayPosts, setSelectedDayPosts] = useState(null) + // Server-side aggregate counts. The Overview headline numbers (total tracked, + // actionable, Trump/Macro signals) must reflect the WHOLE feed, not the + // 80-post first-paint slice — otherwise a site with 1108 posts / 25 actionable + // Trump signals showed "80 tracked" and "—". Loaded once from /posts-paged + // which returns server-computed totals + counts without shipping the rows. + const [serverCounts, setServerCounts] = useState<{ + total: number; actionable: number; trump: number; macro: number + } | null>(null) + + // Backend degraded state. When the process isn't the singleton leader it + // runs NO background tasks (no scrapers, price feeds, scheduler) — the data + // is frozen and "Live" would be a lie. /api/health/deep returns 503 + a body + // with { status, is_leader } in that case. + const [degraded, setDegraded] = useState(false) + useEffect(() => { // B42: cancel the in-flight getUserPublic if the wallet switches again // before it resolves — prevents old wallet's data polluting new wallet's state. @@ -199,7 +225,7 @@ export default function DashboardClient({ initialPosts }: Props) { }) .catch(() => {}) return () => { cancelled = true } - }, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness]) + }, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness, setPaperMode]) useEffect(() => { if (!isConnected || !address) { @@ -255,9 +281,63 @@ export default function DashboardClient({ initialPosts }: Props) { .then(c => { if (!cancelled) { setCandles(c); setChartErr('') } }) .catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') }) return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [asset, timeframe, chartReload]) + // Auto-select the latest actionable signal once on load so the detail rail + // isn't an empty column until the user clicks something. Runs once; never + // re-selects after the user picks or closes a post. + const autoSelectedRef = useRef(false) + const skipRailScrollRef = useRef(false) + useEffect(() => { + if (autoSelectedRef.current) return + if (selectedPostId != null || selectedDayPosts) { autoSelectedRef.current = true; return } + const pool = [...posts, ...signalPosts] + const actionable = pool.filter(p => p.signal === 'buy' || p.signal === 'short') + // Prefer the latest actionable signal — but the actionable fetch is async, + // so wait for it before falling back to a (likely off-topic) recent post. + if (!actionable.length && !signalPostsLoaded) return + const pick = (actionable.length ? actionable : pool) + .slice() + .sort((a, b) => +new Date(b.published_at) - +new Date(a.published_at))[0] + if (!pick) return + autoSelectedRef.current = true + skipRailScrollRef.current = true // a load-time selection must not scroll the page + setSelectedPostId(pick.id) + }, [posts, signalPosts, signalPostsLoaded, selectedPostId, selectedDayPosts]) + + // On narrow viewports the layout stacks and the detail rail sits ABOVE the + // chart — a click on a post down in the list renders the detail card out of + // view and the selection looks like a no-op. Scroll it into view, but only + // when it's actually outside the viewport (no jump on desktop, where the + // rail is a sticky side column). + useEffect(() => { + if (selectedPostId == null && !selectedDayPosts) return + if (skipRailScrollRef.current) { skipRailScrollRef.current = false; return } + const el = railRef.current + if (!el) return + const r = el.getBoundingClientRect() + // The detail card renders at the TOP of the rail, so "visible" means the + // rail's top edge is on screen — a rail whose tail end pokes into the + // viewport still hides the card. + if (r.top < 0 || r.top > window.innerHeight - 120) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, [selectedPostId, selectedDayPosts]) + + // Actionable posts for the chart markers (see signalPosts above). + useEffect(() => { + let alive = true + swrFetch( + 'posts-actionable-chart', + 90_000, + () => getPostsPage(100, 1, undefined, { signal: 'actionable' }), + fresh => { if (alive) setSignalPosts(fresh.items) }, + ) + .then(r => { if (alive) { setSignalPosts(r.items); setSignalPostsLoaded(true) } }) + .catch(() => { if (alive) setSignalPostsLoaded(true) }) + return () => { alive = false } + }, []) + useEffect(() => { let alive = true function load() { @@ -275,6 +355,50 @@ export default function DashboardClient({ initialPosts }: Props) { return () => { alive = false; clearInterval(id) } }, []) + // Poll backend health so the "Live" chip can downgrade to "Delayed" when the + // process is a non-leader follower (read-only, no background tasks → data is + // frozen). The body is present on both 200 and 503, so read it regardless of + // status. Key ONLY on is_leader === false: a `degraded` status can also mean + // a single price feed briefly lagged, which would flap the chip even though + // the feed self-heals in seconds — that's noisier than it is useful. + useEffect(() => { + let alive = true + async function check() { + try { + const res = await fetch('/api/proxy/api/health/deep') + const body = await res.json().catch(() => null) + if (!alive || !body) return + setDegraded(body.is_leader === false) + } catch { /* network error — leave chip as-is */ } + } + check() + const id = setInterval(check, 60_000) + return () => { alive = false; clearInterval(id) } + }, []) + + // Server-side aggregate counts for the Overview headline numbers. limit=1 + // keeps the payload tiny — we only consume `total` and `counts`. + useEffect(() => { + let alive = true + ;(async () => { + try { + const [global, trump, macro] = await Promise.all([ + getPostsPage(1, 1), + getPostsPage(1, 1, 'truth'), + getPostsPage(1, 1, undefined, { sourceIn: ['btc_bottom_reversal', 'funding_reversal'] }), + ]) + if (!alive) return + setServerCounts({ + total: global.total, + actionable: global.counts.actionable, + trump: trump.counts.actionable, + macro: macro.counts.actionable, + }) + } catch { /* fall back to local-slice counts */ } + })() + return () => { alive = false } + }, []) + // KOL divergence + digest — loaded once, 30 min cache (changes daily) useEffect(() => { let alive = true @@ -290,12 +414,21 @@ export default function DashboardClient({ initialPosts }: Props) { return () => { alive = false } }, []) - const selectedPost = posts.find(p => p.id === selectedPostId) ?? null + // Union of the recent-posts slice and the separately-fetched actionable + // posts (deduped) — feeds the chart so signal markers always render, and + // the detail lookup so clicking one of those markers resolves. + const chartPosts = (() => { + const seen = new Set(posts.map(p => p.id)) + return [...posts, ...signalPosts.filter(p => !seen.has(p.id))] + })() + + const selectedPost = chartPosts.find(p => p.id === selectedPostId) ?? null // Show actionable signals first (buy/short), then most recent hold/neutral. - // Cap at 8 total so the list doesn't get too long. - const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4) - const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4) + // Cap at 8 total so the list doesn't get too long. "Signals only" hides the + // hold/filtered (gray) posts entirely — same toggle as the Trump page. + const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, hideFiltered ? 8 : 4) + const recentOthers = hideFiltered ? [] : posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4) const recentPosts = [...actionable, ...recentOthers].slice(0, 8) const lastCandle = candles[candles.length - 1] @@ -322,12 +455,14 @@ export default function DashboardClient({ initialPosts }: Props) { ? ((displayPrice - baseline24h.open) / baseline24h.open) * 100 : 0 - const totalPosts = posts.length + // Prefer server-computed totals (whole feed); fall back to the local 80-post + // slice only until serverCounts loads (or if that fetch failed). + const totalPosts = serverCounts?.total ?? posts.length const todayKey = new Date().toISOString().slice(0, 10) const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length - const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length - const trumpActionable = posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length - const macroActionable = posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length + const actionablePosts = serverCounts?.actionable ?? posts.filter(p => p.signal === 'buy' || p.signal === 'short').length + const trumpActionable = serverCounts?.trump ?? posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length + const macroActionable = serverCounts?.macro ?? posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length // Use actual KOL divergence count (divergence-flagged items from the API), // not a source==='kol' post count (which never matches — source is 'substack'/'blog'/etc.). const kolMentions = kolDivergences.filter(d => d.signal_type === 'divergence').length @@ -343,10 +478,9 @@ export default function DashboardClient({ initialPosts }: Props) { // a score of e.g. 15.2 read "Supportive" in the card while the same card's // regime label said NEUTRAL. Trusting regime_label keeps them consistent. const macroTone = - macroRegime == null ? (macroScore == null ? 'neutral' : 'neutral') - : macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull' + macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull' : macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear' - : 'neutral' + : 'neutral' // covers NEUTRAL and the null/not-loaded case const macroSummary = macroScore == null ? 'Daily macro composite not loaded yet.' : macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.' @@ -362,7 +496,14 @@ export default function DashboardClient({ initialPosts }: Props) { Trump · Macro · KOL divergence — live. - Live + {degraded ? ( + + Delayed + + ) : ( + Live + )} {/* Open positions — what's on the book right now. Renders only when @@ -387,21 +528,6 @@ export default function DashboardClient({ initialPosts }: Props) {
{asset} · live signal context
-
-
- - -
-
- {(['5m', '15m', '1H', '4H', '1D'] as const).map(t => ( - - ))} -
-
@@ -488,17 +614,17 @@ export default function DashboardClient({ initialPosts }: Props) {
Get started
{([ - { icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed' }, - { icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds' }, - { icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access' }, - ] as const).map(({ icon, head, sub }) => ( -
+ { icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed', href: `/${locale}/trump` }, + { icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds', href: `/${locale}/settings` }, + { icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access', href: `/${locale}/trades` }, + ] as const).map(({ icon, head, sub, href }) => ( + {icon}
-
{head}
+
{head}
{sub}
-
+ ))}
)} @@ -532,7 +658,10 @@ export default function DashboardClient({ initialPosts }: Props) { {' '}on {d.ticker}
- Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null ? ` → $${(d.usd_after / 1000).toFixed(0)}k on-chain` : ''} + {/* /1000+"k" alone printed "$2100k" for $2.1M positions */} + Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null + ? ` → ${d.usd_after >= 1e6 ? '$' + (d.usd_after / 1e6).toFixed(1) + 'M' : '$' + (d.usd_after / 1e3).toFixed(0) + 'K'} on-chain` + : ''}
))} @@ -565,20 +694,27 @@ export default function DashboardClient({ initialPosts }: Props) { {/* Left: Chart + signal stream */}
-
-
-
Price · {asset}
-
- - = 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h + {/* The big price lives in the market hero above — repeating it here + just duplicated the same number 32px tall. The chart's own price + axis label shows the live price; this header instead hosts the + asset/timeframe controls right next to the chart they drive. */} +
+
Live chart · {asset} · signal markers
+
+
+ + +
+
+ {(['5m', '15m', '1H', '4H', '1D'] as const).map(t => ( + + ))}
-
Live chart with signal markers
{chartErr && ( @@ -605,7 +741,7 @@ export default function DashboardClient({ initialPosts }: Props) {
)} { @@ -620,10 +756,16 @@ export default function DashboardClient({ initialPosts }: Props) {
-
Buy signal
-
Short signal
-
Hold / filtered
- {asset === 'BTC' &&
Macro reversal highlight
} +
+ Markers + +
Binance candles via backend API · click marker to inspect
@@ -632,10 +774,22 @@ export default function DashboardClient({ initialPosts }: Props) {

Recent signals

- - {actionable.length > 0 ? `${actionable.length} actionable · ` : ''} - {`${totalPosts} tracked`} - + + {/* No count hint here — the page-head PageHint already shows + "X actionable · Y tracked" for the whole feed. */}
{recentPosts.map(p => ( @@ -655,7 +809,7 @@ export default function DashboardClient({ initialPosts }: Props) {
{/* Right rail */} -
+
{/* Day-view: all posts from clicked day */} {selectedDayPosts ? (
diff --git a/app/[locale]/analytics/AnalyticsPageClient.tsx b/app/[locale]/analytics/AnalyticsPageClient.tsx index f52db49..6091140 100644 --- a/app/[locale]/analytics/AnalyticsPageClient.tsx +++ b/app/[locale]/analytics/AnalyticsPageClient.tsx @@ -238,43 +238,59 @@ export default function AnalyticsPageClient() { {accuracy.total_directional_signals} directional signals measured Public · no login needed
-
- {/* Overall */} -
-
Overall
- {(['m5','m15','m1h'] as const).map(w => { - const d = accuracy.overall[w] - const pct = d.accuracy_pct - const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' - return ( -
- {w.replace('m1','1').replace('m','')} - {pct != null ? `${pct.toFixed(0)}%` : '—'} -
- ) - })} -
- {Object.entries(accuracy.by_signal).map(([sig, data]) => { - const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell' - return ( -
-
{label} ({data.count})
- {(['m5','m15','m1h'] as const).map(w => { - const d = data[w] - if (d.checked < 2) return
{w.replace('m1','1').replace('m','')}
- const pct = d.accuracy_pct - const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' - return ( -
- {w.replace('m1','1').replace('m','')} - {pct.toFixed(0)}% + {(() => { + // One full-width block per measurement window. Big overall number + // + a hit-rate bar (50% tick = coin-flip baseline) + per-direction + // breakdown. `repeat(3, 1fr)` stretches across the card so no + // whitespace is stranded on wide screens. + const WINDOWS = ['m5', 'm15', 'm1h'] as const + const WINDOW_LABEL = { m5: 'After 5 min', m15: 'After 15 min', m1h: 'After 1 hour' } as const + const pctColor = (pct: number) => pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' + const fmtBreakdown = (pct: number | null, sparse: boolean) => + sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%` + // auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the + // viewport on phones and forced the whole page to scroll sideways. + return ( +
+ {WINDOWS.map(w => { + const pct = accuracy.overall[w].accuracy_pct + const has = pct != null && !Number.isNaN(pct) + return ( +
+
+ {WINDOW_LABEL[w]} +
- ) - })} -
- ) - })} -
+
+ {has ? `${pct.toFixed(0)}%` : '—'} +
+ {/* hit-rate bar with a 50% coin-flip tick */} +
+ {has && ( +
+ )} +
+
+
+ {Object.entries(accuracy.by_signal).map(([sig, d]) => { + const sparse = d[w].checked < 2 + const v = fmtBreakdown(d[w].accuracy_pct, sparse) + const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell' + return ( + + {label}{' '} + {v} + ({d.count}) + + ) + })} +
+
+ ) + })} +
+ ) + })()}
)} @@ -315,10 +331,21 @@ export default function AnalyticsPageClient() {
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
-
- = 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'} - {isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`} -
+ {/* Win-rate chip only when there are trades to rate — a red + "0.0% win rate" next to an em-dash P&L misreads as a losing + record when the wallet simply has no data. */} + {pricedTrades.length > 0 && ( +
+ = 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'} +
+ )} + {filteredTrades.length === 0 && ( +
+ No closed bot trades in this window. P&L, win rate and the + per-trade metrics below fill in once auto-trade executions close + — or pick a longer window on the top right. +
+ )}
@@ -339,15 +366,14 @@ export default function AnalyticsPageClient() { {/* Signal accuracy is now shown at the top of the page (public, no login needed). Removed duplicate block here to avoid showing the same data twice. */} - {!filteredTrades.length && ( + {/* Empty-state card renders only for states no other element explains: + - "window empty but trades exist" → Performance hero's inline note + - privateLocked → the unlock card at the top of the page */} + {!filteredTrades.length && !privateLocked && (!isConnected || !address || !trades.length) && (

{!isConnected || !address ? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.') - : privateLocked - ? (isZh ? '签名解锁后可查看你的真实业绩和交易历史。' : 'Sign once above to unlock your personal P&L and trade history.') - : trades.length - ? (isZh ? `${period} 时间窗内还没有已平仓交易。` : `No trades closed in the ${period} window yet.`) : (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')}

diff --git a/app/[locale]/archive/ArchivePageClient.tsx b/app/[locale]/archive/ArchivePageClient.tsx index d398d24..1b67d27 100644 --- a/app/[locale]/archive/ArchivePageClient.tsx +++ b/app/[locale]/archive/ArchivePageClient.tsx @@ -79,9 +79,6 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE)) const archiveSafePage = Math.min(archivePage, archiveTotalPages) const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0) - const selectedCount = src === 'all' - ? totalPosts - : sourceCounts.find(s => s.source === src)?.count ?? totalPosts const sourceTabs: [string, number][] = [ ['all', allSourcesCount], ...sourceCounts.map(item => [item.source, item.count] as [string, number]), @@ -92,7 +89,9 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli

Archive

- + {/* No count slot — the source tabs and pagination already show + per-source and total counts. */} + Signals from retired scanner experiments (rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only — the bot no longer acts on any of these. diff --git a/app/[locale]/archive/page.tsx b/app/[locale]/archive/page.tsx index a1764ff..02bf552 100644 --- a/app/[locale]/archive/page.tsx +++ b/app/[locale]/archive/page.tsx @@ -16,8 +16,19 @@ export default async function ArchivePage() { const initialData: PostListResponse | null = await getInitialPostPage(30, 1, { filters: { archiveOnly: true }, legacyFallback: async () => { - const legacyItems = await getPosts(500, 1).catch(() => null) - return legacyItems ? buildArchiveFallbackResponse(legacyItems, 1, 30) : null + // Old backend (no /posts-paged archive_only): /posts caps at 500 per + // page, so a single page only sees the latest 500 posts globally — older + // archive rows get buried under fresh truth posts and silently dropped. + // Page through a few windows so archive coverage isn't truncated. + const MAX_PAGES = 4 // up to 2000 most-recent posts scanned for archive rows + const collected: Awaited> = [] + for (let p = 1; p <= MAX_PAGES; p++) { + const batch = await getPosts(500, p).catch(() => null) + if (!batch || batch.length === 0) break + collected.push(...batch) + if (batch.length < 500) break // last page reached + } + return collected.length ? buildArchiveFallbackResponse(collected, 1, 30) : null }, }) diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css index c8ef483..f74c01a 100644 --- a/app/[locale]/globals.css +++ b/app/[locale]/globals.css @@ -723,6 +723,34 @@ html[data-theme="dark"] .infotip-bubble { min-height: 66px; } +/* Hero variant — for a section with a SINGLE headline card (Valuation/AHR999). + The stacked card layout stretched full-width left a huge dead middle; the + hero lays out horizontally: value block left, summary + chips filling the + middle, chart button right. Wraps back to stacked on narrow screens. */ +.macro-metric-card.hero { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + gap: clamp(16px, 3vw, 40px); + min-height: 0; +} +.macro-metric-card.hero .macro-hero-body { + flex: 1 1 300px; + min-width: 0; +} +.macro-metric-card.hero .macro-summary { + margin-top: 0; + min-height: 0; +} +.macro-metric-card.hero .macro-thresholds { + margin-top: 10px; +} +.macro-metric-card.hero .macro-actions { + margin-top: 0; + padding-top: 0; + flex-shrink: 0; +} + .macro-thresholds { display: flex; flex-wrap: wrap; @@ -1066,22 +1094,24 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active { opacity: 1; transform: translateY(0); } -/* Placement variants (default = top) */ +/* Placement variants (default = top). + --tip-shift is set by InfoTip.tsx on reveal: it nudges a bubble that would + hang past the viewport edge back on-screen (icons near the screen margin). */ .infotip-top .infotip-bubble { bottom: calc(100% + 8px); - left: 50%; transform: translate(-50%, 2px); + left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), 2px); } .infotip-top:hover .infotip-bubble, .infotip-top:focus-visible .infotip-bubble { - transform: translate(-50%, 0); + transform: translate(calc(-50% + var(--tip-shift, 0px)), 0); } .infotip-bottom .infotip-bubble { top: calc(100% + 8px); - left: 50%; transform: translate(-50%, -2px); + left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), -2px); } .infotip-bottom:hover .infotip-bubble, .infotip-bottom:focus-visible .infotip-bubble { - transform: translate(-50%, 0); + transform: translate(calc(-50% + var(--tip-shift, 0px)), 0); } .infotip-left .infotip-bubble { right: calc(100% + 8px); @@ -1562,6 +1592,10 @@ html[data-theme="dark"] .chip.down { color: oklch(80% 0.16 27); } gap: 20px; align-items: start; } +/* Grid items default to min-width:auto, so an intrinsically-wide child (the + lightweight-charts canvas, a long URL) blows the column past the viewport + on phones — the whole page then scrolls sideways. Let columns shrink. */ +.dash-grid > * { min-width: 0; } .hero-value { font-size: 44px; @@ -2308,9 +2342,13 @@ html[data-theme="dark"] .post-row.signal-short { .src-ico.whale { background: oklch(94% 0.08 150); color: oklch(40% 0.15 150); font-size: 13px; } .src-ico.manual { background: oklch(94% 0.05 60); color: oklch(45% 0.15 60); font-size: 13px; } .src-ico.external { background: var(--bg-sunk); color: var(--ink-2); } +/* min-width:0 — without it a long unbreakable URL in the text widens the 1fr + grid column past the card and the right edge (meta tag included) is clipped. */ +.post-body { min-width: 0; } .post-body .meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; margin-bottom: 6px; font-size: 12px; @@ -2321,6 +2359,7 @@ html[data-theme="dark"] .post-row.signal-short { line-height: 1.5; color: var(--ink); margin: 0 0 10px; + overflow-wrap: anywhere; } .post-aside { display: flex; @@ -2812,8 +2851,15 @@ html[data-theme="dark"] .post-row.signal-short { CSS-hover-only tooltips require). */ .infotip:active .infotip-bubble { opacity: 1; - transform: none; } + /* Keep the viewport-clamp shift on tap — `transform: none` here used to + discard it and re-centre the bubble off-screen. */ + .infotip-top:active .infotip-bubble, + .infotip-bottom:active .infotip-bubble { + transform: translate(calc(-50% + var(--tip-shift, 0px)), 0); + } + .infotip-left:active .infotip-bubble { transform: translate(0, -50%); } + .infotip-right:active .infotip-bubble { transform: translate(0, -50%); } .macro-action-label { font-size: 12px; diff --git a/app/[locale]/kol/KolPageClient.tsx b/app/[locale]/kol/KolPageClient.tsx index c21da3f..55abda3 100644 --- a/app/[locale]/kol/KolPageClient.tsx +++ b/app/[locale]/kol/KolPageClient.tsx @@ -230,7 +230,8 @@ function DigestTickerChip({ border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`, boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none', cursor: 'pointer', textAlign: 'left', - minHeight: 108, + // No fixed minHeight — content is 3 short lines; 108px left the bottom + // ~40% of every chip empty (worst on phones, 2 chips per row). width: '100%', transform: active ? 'translateY(-2px)' : 'none', opacity: hasActive && !active ? 0.6 : 1, @@ -479,15 +480,12 @@ function WalletCheckWidget({ {row.verdict.label}
-
+ {/* Speakers + conviction dropped — the digest chip for the same + ticker directly above already shows the handles and max + conviction %. This column keeps only the one-line talk summary. */} +
{row.talkLine}
-
- {row.speakers} -
-
- {row.conviction} -
@@ -540,9 +538,9 @@ function WalletCheckWidget({ } function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) { - if (!tickers.length) { - return - } + // No tickers → render nothing. A lone "—" dangled under every card whose + // post had no extracted assets (most of the feed). + if (!tickers.length) return null return (
{tickers.map((t, i) => ( @@ -811,7 +809,9 @@ export default function KolPage({

{'KOL Signals'}

- + {/* No count slot — the digest meta line ("X posts · Y assets") and + the feed pagination already show the totals. */} + Arthur Hayes, Delphi, Bankless, and 22 more — their public calls vs what their wallets actually do.
diff --git a/app/[locale]/macro/MacroVibesPageClient.tsx b/app/[locale]/macro/MacroVibesPageClient.tsx index 97c377b..12a66ee 100644 --- a/app/[locale]/macro/MacroVibesPageClient.tsx +++ b/app/[locale]/macro/MacroVibesPageClient.tsx @@ -9,7 +9,6 @@ import { swrFetch } from '@/lib/cache' import PostRow from '@/components/dashboard/PostCards' import SystemControl from '@/components/signals/SystemControl' import InfoTip from '@/components/ui/InfoTip' -import SignalMonitor from '@/components/dashboard/SignalMonitor' // MacroPanel is 631 lines with heavy indicator math — split it out. const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), { @@ -84,18 +83,9 @@ export default function MacroVibesPage({
-
-

{isZh ? '宏观氛围' : 'Macro Vibes'}

- - {isZh ? '手动开仓 · Bot 托管' : 'You open · bot manages'} - -
+ {/* No "You open · bot manages" badge — the SystemControl strip right + below says the same thing verbatim ("You open · bot manages exit"). */} +

{isZh ? '宏观氛围' : 'Macro Vibes'}

Live
@@ -133,14 +123,17 @@ export default function MacroVibesPage({ users are reasoning about the broader risk regime. The Funding tab has its own live funding panel below. */} {tab === 'bottom' && } + {/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the + backend scanner is disabled (services/funding_signal.py _enabled=False, + operator-only toggle), so the panel only ever showed "Paused / No + signals yet" — and breakout scanning is unrelated to funding reversal + anyway. Component kept at components/dashboard/SignalMonitor.tsx; + remount here if the scanner is ever re-enabled. */} {tab === 'funding' && ( - <> - - - + )}
@@ -317,14 +310,10 @@ function FundingPanel({ )}
- {snap.history && snap.history.length > 1 && ( - - )} + {/* 7-day sparkline removed: the y-range was forced to include the ±threshold + lines, so in normal regimes the curve rendered as a flat line on the zero + axis with ~80% of the plot being empty danger-zone shading. The stat boxes + above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
) } @@ -352,73 +341,6 @@ function StatBox({ label, value, color, sub, hint }: { ) } -// Tiny inline SVG sparkline of the last 7 days of funding cycles. -// `extremeCumPct` is the 30d cumulative threshold (e.g. 3.0). To project it as -// a per-cycle reference line we divide by the number of cycles in 30 days at -// the venue's cadence (24h × 30d / cadence_h). This is the per-cycle rate that -// — sustained for 30 days — would hit the extreme bucket. -function FundingSparkline({ history, cadenceHours, extremeCumPct, isZh }: { - history: { t: number; rate_pct: number }[] - cadenceHours: number - extremeCumPct: number - isZh: boolean -}) { - const W = 600, H = 90, PAD = 6 - const cyclesIn30d = Math.max(1, (30 * 24) / Math.max(cadenceHours, 0.5)) - const perCycleThr = extremeCumPct / cyclesIn30d // e.g. 3% / 90 ≈ 0.033% - - const rates = history.map(h => h.rate_pct) - // Ensure threshold lines are always visible in the y-range - const minR = Math.min(...rates, -perCycleThr * 1.2) - const maxR = Math.max(...rates, perCycleThr * 1.2) - const span = maxR - minR || 1 - const x = (i: number) => PAD + (i / (history.length - 1)) * (W - 2 * PAD) - const y = (r: number) => PAD + (1 - (r - minR) / span) * (H - 2 * PAD) - const zeroY = y(0) - const posThrY = y(perCycleThr) - const negThrY = y(-perCycleThr) - const path = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(h.rate_pct)}`).join(' ') - const last = history[history.length - 1] - // Color the dot by whether the latest cycle is inside the danger band - const inDanger = Math.abs(last.rate_pct) >= perCycleThr - const dotColor = inDanger - ? (last.rate_pct > 0 ? 'var(--down)' : 'var(--up)') - : 'var(--amber, #f59e0b)' - - return ( -
-
- {isZh ? '7 日资金费率(单周期 %)' : '7-day funding (per-cycle %)'} - {isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`} -
- - {/* danger zones — shaded above +thr and below -thr */} - - - - {/* zero line */} - - {/* positive / negative threshold lines */} - - - - {/* funding curve */} - - {/* current value dot */} - - -
- ) -} - function BtcSkeleton() { return (
diff --git a/app/[locale]/trades/TradesPageClient.tsx b/app/[locale]/trades/TradesPageClient.tsx index 53dd343..ac028b7 100644 --- a/app/[locale]/trades/TradesPageClient.tsx +++ b/app/[locale]/trades/TradesPageClient.tsx @@ -151,12 +151,26 @@ export default function TradesPageClient() {

{isZh ? '交易执行' : 'Trades'}

+ {/* Says what the page is, not where things sit — the section cards + below are self-labelled ("Open positions", KPI row, table). */} - Open positions above · closed trade history with realized P&L below. + What the bot holds right now, and every trade it has closed.
+ {/* Guest view: a zero-filled KPI row + empty table reads like a broken + dashboard. Show one connect prompt instead and skip the data UI. */} + {mounted && !isConnected ? ( +
+
Connect your wallet to see your trades
+
+ Positions and trade history are wallet-bound and private. + Use the Connect wallet button in the top-right corner. +
+
+ ) : ( + <> {needsSetup && (
+ + )}
) } diff --git a/app/[locale]/trump/TrumpPageClient.tsx b/app/[locale]/trump/TrumpPageClient.tsx index e5dde32..c82be47 100644 --- a/app/[locale]/trump/TrumpPageClient.tsx +++ b/app/[locale]/trump/TrumpPageClient.tsx @@ -170,24 +170,12 @@ export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageP
-
-

Trump Signal

- - ⚡ Auto-trade - -
- + {/* No "⚡ Auto-trade" badge — the SystemControl strip right below + always shows the Auto-Trade feature + its live state. */} +

Trump Signal

+ {/* No count slot — the filter tabs right below already show the + per-filter numbers (All / Actionable / Buy / Short). */} + Every Truth Social post scored in <3s. Trades only when conviction clears the threshold.
diff --git a/components/btc/MacroPanel.tsx b/components/btc/MacroPanel.tsx index 951a497..677026a 100644 --- a/components/btc/MacroPanel.tsx +++ b/components/btc/MacroPanel.tsx @@ -128,11 +128,14 @@ function MetricCard({ activeIndex, chartHref, chartLabel = 'CoinGlass', + hero = false, }: { rank: number label: string value: string tone?: Tone + /** What the indicator IS, one sentence. Do NOT enumerate threshold bands + * here — the chips below each card already list them. */ hint: string summary: string thresholds: Array<{ label: string; tone?: Tone }> @@ -143,31 +146,54 @@ function MetricCard({ * the dual "Data source + Chart" pattern confused users about which to click. */ chartHref: string chartLabel?: string + /** Horizontal headline layout for a single full-width card (Valuation). + * The stacked layout stretched across the panel left a dead middle. */ + hero?: boolean }) { + const head = ( +
+
+ {String(rank).padStart(2, '0')} + {label} + +
+
{value}
+
+ ) + const chips = ( +
+ {thresholds.map((item, i) => ( + + {item.label} + + ))} +
+ ) + const action = ( +
+ {chartLabel} +
+ ) + + if (hero) { + return ( +
+ {head} +
+
{summary}
+ {chips} +
+ {action} +
+ ) + } + return (
-
-
- {String(rank).padStart(2, '0')} - {label} - -
-
{value}
-
- + {head}
{summary}
- -
- {thresholds.map((item, i) => ( - - {item.label} - - ))} -
- -
- {chartLabel} -
+ {chips} + {action}
) } @@ -402,11 +428,12 @@ export default function MacroPanel() {
void) | null>(null) const fittedRef = useRef(false) + // Chart creation is async (dynamic import). If the candles fetch wins the + // race (common — SWR serves them instantly from cache), the data effect + // runs while seriesRef is still null and bails; nothing re-triggers it + // afterwards, so the chart stays EMPTY and the 2s live-tick effect then + // paints a single lone candle via series.update(). This state flips when + // the chart actually exists so the data effect re-runs. + const [chartReady, setChartReady] = useState(false) + // The live-tick effect must never run before setData() — updating an empty + // series creates a one-candle chart. + const dataLoadedRef = useRef(false) const postsRef = useRef(posts) postsRef.current = posts const selectedPostIdRef = useRef(externalSelectedId) @@ -111,7 +121,11 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI const chart = createChart(containerRef.current, { width: containerRef.current.clientWidth, - height: 360, + // Follow the container, don't hardcode: .chart-wrap is 360px on + // desktop but 300/240px behind !important media queries. A fixed + // 360 overflows those and overflow:hidden cuts off the bottom of + // the chart — lowest candles, markers, and the whole time axis. + height: containerRef.current.clientHeight || 360, layout: { background: { color: colors.background }, textColor: colors.textColor, @@ -145,6 +159,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI seriesRef.current = series + // Breathing room above/below the candles. Markers render ~10px outside + // the bar's high/low; with the default tight bottom margin a crash + // candle at the bottom of the autoscaled range gets its marker dots + // clipped by the pane edge. + series.priceScale().applyOptions({ + scaleMargins: { top: 0.12, bottom: 0.16 }, + }) + + setChartReady(true) + // Re-project macro signal time → pixel X on every visible-range // change (pan / zoom / data update / resize). Without this the line // is set ONCE in the data effect and then stays stuck at a fixed @@ -226,7 +250,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI ro = new ResizeObserver(() => { if (containerRef.current && !destroyed) { - chart.applyOptions({ width: containerRef.current.clientWidth }) + chart.applyOptions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight || 360, + }) // Width change → new pixel coordinates → re-project the macro // marker too, otherwise it'd drift relative to the candles on // window resize / sidebar toggle. @@ -269,6 +296,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI ro?.disconnect() themeObserver?.disconnect() fittedRef.current = false + dataLoadedRef.current = false + setChartReady(false) if (chartRef.current) { // @ts-expect-error lightweight-charts type chartRef.current.remove() @@ -291,7 +320,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI useEffect(() => { const series = seriesRef.current const chart = chartRef.current - if (!series || !chart || candles.length === 0) return + if (!series || !chart || candles.length === 0) { + dataLoadedRef.current = false + return + } const sorted = [...candles].sort((a, b) => a.time - b.time) @@ -303,6 +335,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI low: c.low, close: c.close, }))) + dataLoadedRef.current = true const minTime = sorted[0].time const maxTime = sorted[sorted.length - 1].time @@ -313,6 +346,13 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI return t >= minTime && t <= maxTime }) + // Markers show actionable signals only. Hold/filtered posts vastly + // outnumber signals and their gray dots pile into unreadable clusters; + // they stay browsable in the Recent-signals list instead. + const markerPosts = visible.filter( + (p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell', + ) + const bucketByTf: Record = { '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800, } @@ -320,7 +360,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing const bucketMap = new Map() - for (const p of visible) { + for (const p of markerPosts) { const pt = Math.floor(new Date(p.published_at).getTime() / 1000) const bucket = Math.floor(pt / bucketSecs) * bucketSecs if (!bucketMap.has(bucket)) bucketMap.set(bucket, []) @@ -415,7 +455,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI chart.timeScale().fitContent() fittedRef.current = true } - }, [candles, posts, externalSelectedId, asset]) + }, [candles, posts, externalSelectedId, asset, chartReady]) useEffect(() => { fittedRef.current = false }, [timeframe, asset]) @@ -426,7 +466,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI // the live tick exceeds them. useEffect(() => { const series = seriesRef.current as any - if (!series) return + // dataLoadedRef: update() on a series that hasn't had setData() yet + // creates a chart with a single lone candle. + if (!series || !dataLoadedRef.current) return const live = livePrices[asset] if (live == null || !candles.length) return const last = candles[candles.length - 1] @@ -442,7 +484,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI return (
) } diff --git a/components/signals/SystemControl.tsx b/components/signals/SystemControl.tsx index 9f2fd0c..40f6b41 100644 --- a/components/signals/SystemControl.tsx +++ b/components/signals/SystemControl.tsx @@ -149,7 +149,6 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { if (!mounted) return null const settingsHref = `/${locale}/settings#bot-config` - const settingsLabel = system === 'trump' ? 'Trump Signal' : 'Macro Vibes' // Macro Vibes (System 2) is MANAGE-ONLY by design — it NEVER auto-opens a // trade. bot_engine.process_post early-returns for sys2 sources, so the @@ -184,7 +183,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { opacity: 0.8, }} > - {settingsLabel} settings ↗ + Settings ↗
) @@ -211,8 +210,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { width: 6, height: 6, borderRadius: '50%', background: 'var(--ink-4)', flexShrink: 0, }} /> + {/* No "{s.name}" prefix — the page title right above already names + the system; repeating it here read "Trump Signal" twice per screen. */} - {s.name} Auto-Trade + Auto-Trade · @@ -236,10 +237,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { boxShadow: 'var(--shadow-1)', }} > - - Settings - - {settingsLabel} + Settings
@@ -297,22 +295,20 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { {/* Header */}
+ {/* Header is just "Auto-Trade" — the page title already names the + system, and the switch row below repeats "Auto-Trade" otherwise. */}
- {s.name} {isZh ? '控制面板' : '— Auto-Trade'} + {isZh ? '控制面板' : 'Auto-Trade'}
{/* Master Auto-Trade switch */}
-
-
- Auto-Trade {isZh ? '· 事件交易' : '· event-driven'} -
-
- {isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'} -
+ {/* The card header above already says "Auto-Trade" — this row only + carries the ON/OFF legend, not a second title. */} +
+ {isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
flipAuto(true)} tone="green">ON @@ -377,10 +373,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { boxShadow: 'var(--shadow-1)', }} > - - Settings - - {settingsLabel} + Settings
diff --git a/components/trades/BotConfigPanel.tsx b/components/trades/BotConfigPanel.tsx index c5e6962..2ec0cf9 100644 --- a/components/trades/BotConfigPanel.tsx +++ b/components/trades/BotConfigPanel.tsx @@ -835,7 +835,7 @@ export default function BotConfigPanel() {
Take profit * - Bot locks in profit when the position gains this much. Required. + Bot locks in profit when the position gains this much.
@@ -851,7 +851,7 @@ export default function BotConfigPanel() {
Stop loss * - Bot cuts the loss when the position falls this much. Required. + Bot cuts the loss when the position falls this much.
@@ -998,7 +998,7 @@ export default function BotConfigPanel() {
Daily trading cap - Daily spending cap — bot stops opening new trades once it hits this amount. + Bot stops opening new trades once it has spent this amount in a day.
{ setUseBudget(v); setDirty(true) }} /> diff --git a/components/trades/TradeTable.tsx b/components/trades/TradeTable.tsx index eb78c8f..82367fe 100644 --- a/components/trades/TradeTable.tsx +++ b/components/trades/TradeTable.tsx @@ -288,14 +288,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) { + {/* P&L right after Side — it's the column this page exists for. + As the last of 8 columns it sat off-screen on phones (the + table scrolls inside its card) until the user swiped past + entry/exit/hold/trigger to find it. */} + - @@ -346,20 +350,8 @@ export default function TradeTable({ trades, loading, locked = false }: Props) { {t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')} - - - - + + + + ) })} diff --git a/components/ui/InfoTip.tsx b/components/ui/InfoTip.tsx index 9f85c29..4296ce9 100644 --- a/components/ui/InfoTip.tsx +++ b/components/ui/InfoTip.tsx @@ -13,10 +13,12 @@ * If you need a paragraph, you're explaining the wrong thing. * * The icon is intentionally subtle (12 px circle, ink-4 colour). It * should disappear visually until the user actively scans for help. - * * The tooltip uses CSS hover only — no JS positioning, no portals. - * Trade-off: it's clipped by the nearest `overflow:hidden` ancestor. - * Pages that need tooltips inside a `
{isZh ? '来源' : 'Source'} {isZh ? '资产' : 'Asset'} {isZh ? '方向' : 'Side'}P&L {isZh ? '开仓' : 'Entry'} {isZh ? '平仓' : 'Exit'} {isZh ? '持仓' : 'Hold'} {isZh ? '触发信号' : 'What triggered it'}P&L
{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}{fmtHold(t.hold_seconds)} - {t.trigger_post_text ? ( - - {t.trigger_post_text.slice(0, 60)}… - - ) : ( - - )} - -
+
{t.pnl_usd === null || t.pnl_usd === undefined ? (
{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}{fmtHold(t.hold_seconds)} + {t.trigger_post_text ? ( + + {t.trigger_post_text.slice(0, 60)}… + + ) : ( + + )} +
` cell should add - * `overflow: visible` to that row/td. + * * The tooltip uses CSS hover positioning — no portals. The only JS is + * an on-reveal measurement that clamps the bubble inside the viewport + * (a centred bubble on an icon near the screen edge used to hang + * off-screen, worst on phones). Trade-off: it's still clipped by the + * nearest `overflow:hidden` ancestor. Pages that need tooltips inside + * a `
` cell should add `overflow: visible` to that row/td. * * Usage: * Latest cycle @@ -27,6 +29,7 @@ * /> */ +import { useRef } from 'react' import type { CSSProperties } from 'react' type Placement = 'top' | 'bottom' | 'left' | 'right' @@ -46,8 +49,28 @@ export default function InfoTip({ width = 240, iconStyle, }: Props) { + const rootRef = useRef(null) + + // Clamp the bubble inside the viewport when it's revealed. The bubble is + // CSS-centred on the icon, so near a screen edge it overflowed off-screen. + // Measured at shift=0, then the needed horizontal offset is written to the + // --tip-shift var consumed by the .infotip-top/-bottom transforms. + function clampIntoViewport() { + const bubble = rootRef.current?.querySelector('.infotip-bubble') + if (!bubble) return + bubble.style.setProperty('--tip-shift', '0px') + const r = bubble.getBoundingClientRect() + const vw = document.documentElement.clientWidth + const margin = 8 + let shift = 0 + if (r.left < margin) shift = margin - r.left + else if (r.right > vw - margin) shift = (vw - margin) - r.right + if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`) + } + return ( {text} diff --git a/lib/api.ts b/lib/api.ts index b8432a1..d6cdce6 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -36,6 +36,19 @@ async function fetchJson(path: string, init?: RequestInit): Promise { return res.json() as Promise } +/** Signed-read credentials as headers (C3): keeps EIP-191 signatures out of + * URLs, access logs, and browser history. Backend still accepts the legacy + * ?ts=&sig= query params for old clients. */ +function signedReadInit(env: SignedEnvelope): RequestInit { + return { + headers: { + 'Content-Type': 'application/json', + 'X-Sig-Ts': String(env.timestamp), + 'X-Sig-Sig': env.signature, + }, + } +} + export interface PostListResponse { items: TrumpPost[] total: number @@ -104,22 +117,16 @@ export async function getTrades( limit = 20, page = 1, ): Promise { - const qs = [ - `wallet=${wallet.toLowerCase()}`, - `ts=${env.timestamp}`, - `sig=${encodeURIComponent(env.signature)}`, - `limit=${limit}`, - `page=${page}`, - ].join('&') - return fetchJson(`/trades?${qs}`) + const qs = `wallet=${wallet.toLowerCase()}&limit=${limit}&page=${page}` + return fetchJson(`/trades?${qs}`, signedReadInit(env)) } export async function getPerformance( wallet: string, env: SignedEnvelope, ): Promise { - const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` - return fetchJson(`/performance?${qs}`) + const qs = `wallet=${wallet.toLowerCase()}` + return fetchJson(`/performance?${qs}`, signedReadInit(env)) } export async function subscribe( @@ -272,16 +279,16 @@ export async function getOpenPositions( wallet: string, env: SignedEnvelope, ): Promise { - const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` - return fetchJson(`/positions/open?${qs}`) + const qs = `wallet=${wallet.toLowerCase()}` + return fetchJson(`/positions/open?${qs}`, signedReadInit(env)) } export async function getTodayStats( wallet: string, env: SignedEnvelope, ): Promise { - const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` - return fetchJson(`/positions/today?${qs}`) + const qs = `wallet=${wallet.toLowerCase()}` + return fetchJson(`/positions/today?${qs}`, signedReadInit(env)) } // ─── Scanner control (module 2) ───────────────────────────────────────────── @@ -378,8 +385,7 @@ export async function getUser( wallet: string, env: SignedEnvelope, ): Promise { - const qs = `ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` - return fetchJson(`/user/${wallet.toLowerCase()}?${qs}`) + return fetchJson(`/user/${wallet.toLowerCase()}`, signedReadInit(env)) } export interface WindowAccuracy { @@ -586,8 +592,7 @@ export async function getTelegramStatus(wallet: string, env?: SignedEnvelope): P // Pass signature when available so the backend returns full binding details. // Without it the endpoint returns only `bound: boolean` to prevent third-party de-anonymisation. if (env) { - const qs = new URLSearchParams({ timestamp: String(env.timestamp), signature: env.signature }) - return fetchJson(`/telegram/${wallet}/status?${qs}`) + return fetchJson(`/telegram/${wallet}/status`, signedReadInit(env)) } return fetchJson(`/telegram/${wallet}/status`) } diff --git a/lib/cache.ts b/lib/cache.ts index 41d3d79..8d7a823 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -18,6 +18,11 @@ interface CacheEntry { data: T ts: number revalidating: boolean + /** onUpdate callbacks of every caller that hit this key while a background + * revalidation was in flight (M9 fix). All are notified when the fetch + * resolves — previously only the caller that STARTED the revalidation got + * fresh data; everyone else silently kept the stale copy. */ + subscribers: Array<(fresh: T) => void> } const store = new Map>() @@ -37,16 +42,22 @@ export async function swrFetch( // Still fresh — return immediately. return hit.data } - // Stale — return immediately AND revalidate in background. + // Stale — return immediately AND revalidate in background. Every caller's + // onUpdate joins the subscriber list; the in-flight fetch notifies all. + if (onUpdate) hit.subscribers.push(onUpdate) if (!hit.revalidating) { hit.revalidating = true fetcher() .then(fresh => { - store.set(key, { data: fresh, ts: Date.now(), revalidating: false }) - onUpdate?.(fresh) + const subs = hit.subscribers + store.set(key, { data: fresh, ts: Date.now(), revalidating: false, subscribers: [] }) + for (const notify of subs) { + try { notify(fresh) } catch { /* one bad subscriber must not starve the rest */ } + } }) .catch(() => { - if (hit) hit.revalidating = false + hit.revalidating = false + hit.subscribers = [] }) } return hit.data @@ -54,7 +65,7 @@ export async function swrFetch( // Cache miss — must wait for the first fetch. const data = await fetcher() - store.set(key, { data, ts: Date.now(), revalidating: false }) + store.set(key, { data, ts: Date.now(), revalidating: false, subscribers: [] }) return data }