ui: tighten dashboard copy and fix layout issues

This commit is contained in:
k
2026-06-14 21:58:56 +08:00
parent 4c3c8c6f87
commit 8534d90589
19 changed files with 1036 additions and 345 deletions
+378
View File
@@ -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<T>` 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 `<html>`. 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-0106, 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<string, number | null>`; `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:0002: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.
+46 -3
View File
@@ -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:0002: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
+214 -60
View File
@@ -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<TrumpPost[]>(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<TrumpPost[]>([])
// 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<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([])
const [hideFiltered, setHideFiltered] = useState(false)
const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
const railRef = useRef<HTMLDivElement>(null)
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([])
const [kolDigest, setKolDigest] = useState<KolDigest | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(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.
</PageHint>
</div>
{degraded ? (
<span className="chip" title="Backend is running read-only (not the singleton leader) — no background tasks, values may be delayed."
style={{ color: 'var(--amber-ink)', borderColor: 'color-mix(in oklab, var(--amber) 35%, var(--line))' }}>
<span className="live-dot" style={{ background: 'var(--amber)' }} />Delayed
</span>
) : (
<span className="chip"><span className="live-dot" />Live</span>
)}
</div>
{/* Open positions — what's on the book right now. Renders only when
@@ -387,21 +528,6 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
<div className="overview-market-subtitle">{asset} · live signal context</div>
</div>
<div className="overview-controls">
<div className="asset-switch">
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
<span className="asset-dot btc" /> BTC
</button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div>
</div>
</div>
<div className={`overview-macro-card ${macroTone}`}>
@@ -488,17 +614,17 @@ export default function DashboardClient({ initialPosts }: Props) {
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
<div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div>
{([
{ 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 }) => (
<div key={head} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12 }}>
{ 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 }) => (
<Link key={head} href={href} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12, textDecoration: 'none', color: 'inherit' }}>
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>{icon}</span>
<div>
<div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head}</div>
<div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head} <span style={{ color: 'var(--ink-4)', fontWeight: 400 }}></span></div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4, marginTop: 1 }}>{sub}</div>
</div>
</div>
</Link>
))}
</section>
)}
@@ -532,7 +658,10 @@ export default function DashboardClient({ initialPosts }: Props) {
{' '}on <strong>{d.ticker}</strong>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
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`
: ''}
</div>
</div>
))}
@@ -565,20 +694,27 @@ export default function DashboardClient({ initialPosts }: Props) {
{/* Left: Chart + signal stream */}
<div className="stack gap-l">
<div className="card" style={{ padding: 24 }}>
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
<div>
<div className="tiny">Price · {asset}</div>
<div className="row gap-m" style={{ marginTop: 6 }}>
<AnimatedNumber
className="hero-value mono"
style={{ fontSize: 32 }}
value={displayPrice}
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
/>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
{/* 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. */}
<div className="row between" style={{ marginBottom: 14, alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div className="tiny">Live chart · {asset} · signal markers</div>
<div className="overview-controls">
<div className="asset-switch">
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
<span className="asset-dot btc" /> BTC
</button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div>
</div>
<div className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
</div>
{chartErr && (
@@ -605,7 +741,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
)}
<ChartPanel
posts={posts}
posts={chartPosts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={(id) => {
@@ -620,10 +756,16 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
<div className="chart-footnote">
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
<div className="item">
Markers
<InfoTip
placement="top"
width={280}
text={asset === 'BTC'
? 'Green dot = buy, red = short; a number is the signal count in that candle. Amber dashed line = macro reversal signal.'
: 'Green dot = buy, red = short; a number is the signal count in that candle.'}
/>
</div>
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
</div>
</div>
@@ -632,10 +774,22 @@ export default function DashboardClient({ initialPosts }: Props) {
<div>
<div className="section-title">
<h2>Recent signals</h2>
<span className="hint">
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
{`${totalPosts} tracked`}
</span>
<button
onClick={() => setHideFiltered(v => !v)}
title="Hide hold/filtered (gray) posts and show only buy/short signals"
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--line)',
background: hideFiltered ? 'var(--ink)' : 'transparent',
color: hideFiltered ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: 600,
marginLeft: 'auto', marginRight: 8,
}}
>
{hideFiltered ? 'Show all' : 'Signals only'}
</button>
{/* No count hint here — the page-head PageHint already shows
"X actionable · Y tracked" for the whole feed. */}
</div>
<div className="post-stream">
{recentPosts.map(p => (
@@ -655,7 +809,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
{/* Right rail */}
<div className="rail">
<div className="rail" ref={railRef}>
{/* Day-view: all posts from clicked day */}
{selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}>
+59 -33
View File
@@ -238,43 +238,59 @@ export default function AnalyticsPageClient() {
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span>
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
{/* Overall */}
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Overall</div>
{(['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)'
{(() => {
// 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 (
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct != null ? `${pct.toFixed(0)}%` : '—'}</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 12 }}>
{WINDOWS.map(w => {
const pct = accuracy.overall[w].accuracy_pct
const has = pct != null && !Number.isNaN(pct)
return (
<div key={w} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: '0.06em', display: 'flex', alignItems: 'center' }}>
{WINDOW_LABEL[w]}
<InfoTip text={`Share of signals where price had moved in the signalled direction ${w === 'm5' ? '5 minutes' : w === 'm15' ? '15 minutes' : '1 hour'} after the post. 50% = coin flip.`} placement="top" />
</div>
)
})}
<div className="mono" style={{ fontSize: 30, fontWeight: 600, letterSpacing: '-0.01em', marginTop: 8, color: has ? pctColor(pct) : 'var(--ink-4)', fontVariantNumeric: 'tabular-nums' }}>
{has ? `${pct.toFixed(0)}%` : '—'}
</div>
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
{/* hit-rate bar with a 50% coin-flip tick */}
<div style={{ position: 'relative', height: 6, background: 'var(--surface-3)', borderRadius: 999, marginTop: 10, overflow: 'hidden' }}>
{has && (
<div style={{ position: 'absolute', inset: 0, width: `${Math.min(100, Math.max(0, pct))}%`, background: pctColor(pct), borderRadius: 999, opacity: 0.75 }} />
)}
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1.5, background: 'var(--ink-4)', opacity: 0.55 }} />
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12, color: 'var(--ink-3)', flexWrap: 'wrap' }}>
{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 (
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div>
{(['m5','m15','m1h'] as const).map(w => {
const d = data[w]
if (d.checked < 2) return <div key={w} className="row between" style={{ marginBottom: 3 }}><span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span><span style={{ fontSize: 12, color: 'var(--ink-3)' }}></span></div>
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
<span key={sig} style={{ whiteSpace: 'nowrap' }}>
{label}{' '}
<strong style={{ color: sparse ? 'var(--ink-4)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums' }}>{v}</strong>
<span style={{ color: 'var(--ink-4)' }}> ({d.count})</span>
</span>
)
})}
</div>
</div>
)
})}
</div>
)
})}
</div>
})()}
</div>
)}
@@ -315,10 +331,21 @@ export default function AnalyticsPageClient() {
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
</div>
{/* 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 && (
<div className="row gap-s" style={{ marginTop: 8 }}>
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
<span className="chip">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
</div>
)}
{filteredTrades.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 12, lineHeight: 1.6, maxWidth: 520 }}>
No closed bot trades in this window. P&amp;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.
</div>
)}
</div>
</div>
</div>
@@ -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) && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}>
{!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.')}
</p>
</div>
+3 -4
View File
@@ -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
<div className="page-head">
<div>
<h1 className="page-title">Archive</h1>
<PageHint count={`${selectedCount} legacy posts`}>
{/* No count slot — the source tabs and pagination already show
per-source and total counts. */}
<PageHint>
Signals from retired scanner experiments
(rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only
the bot no longer acts on any of these.
+13 -2
View File
@@ -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<ReturnType<typeof getPosts>> = []
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
},
})
+52 -6
View File
@@ -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;
+12 -12
View File
@@ -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}
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}>
{/* 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. */}
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600 }}>
{row.talkLine}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row.speakers}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>
{row.conviction}
</div>
</div>
<div style={{ minWidth: 0 }}>
@@ -540,9 +538,9 @@ function WalletCheckWidget({
}
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
if (!tickers.length) {
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}></span>
}
// 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 (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tickers.map((t, i) => (
@@ -811,7 +809,9 @@ export default function KolPage({
<div className="page-head">
<div>
<h1 className="page-title">{'KOL Signals'}</h1>
<PageHint count={`${serverTotal} posts`}>
{/* No count slot — the digest meta line ("X posts · Y assets") and
the feed pagination already show the totals. */}
<PageHint>
Arthur Hayes, Delphi, Bankless, and 22 more their public calls vs what their wallets actually do.
</PageHint>
</div>
+12 -90
View File
@@ -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({
<div className="page">
<div className="page-head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{/* No "You open · bot manages" badge — the SystemControl strip right
below says the same thing verbatim ("You open · bot manages exit"). */}
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
background: 'var(--bg-sunk)',
color: 'var(--ink-3)', border: '1px solid var(--line)',
letterSpacing: '0.04em',
}}>
{isZh ? '手动开仓 · Bot 托管' : 'You open · bot manages'}
</span>
</div>
</div>
<span className="chip"><span className="live-dot" />Live</span>
</div>
@@ -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' && <MacroPanel />}
{/* 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' && (
<>
<FundingPanel
isZh={isZh}
initialSnapshot={initialFundingSnapshot}
/>
<SignalMonitor />
</>
)}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
@@ -317,14 +310,10 @@ function FundingPanel({
)}
</div>
{snap.history && snap.history.length > 1 && (
<FundingSparkline
history={snap.history}
cadenceHours={snap.cadence_hours ?? 8}
extremeCumPct={thr}
isZh={isZh}
/>
)}
{/* 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. */}
</div>
)
}
@@ -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 (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>{isZh ? '7 日资金费率(单周期 %' : '7-day funding (per-cycle %)'}</span>
<span>{isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: H, display: 'block' }}>
{/* danger zones — shaded above +thr and below -thr */}
<rect x={PAD} y={PAD} width={W - 2 * PAD} height={Math.max(0, posThrY - PAD)}
fill="var(--down)" opacity={0.07} />
<rect x={PAD} y={negThrY} width={W - 2 * PAD} height={Math.max(0, H - PAD - negThrY)}
fill="var(--up)" opacity={0.07} />
{/* zero line */}
<line x1={PAD} x2={W - PAD} y1={zeroY} y2={zeroY}
stroke="var(--line-2, var(--line))" strokeWidth={1} />
{/* positive / negative threshold lines */}
<line x1={PAD} x2={W - PAD} y1={posThrY} y2={posThrY}
stroke="var(--down)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
<line x1={PAD} x2={W - PAD} y1={negThrY} y2={negThrY}
stroke="var(--up)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
{/* funding curve */}
<path d={path} fill="none" stroke="var(--amber, #f59e0b)" strokeWidth={1.5} />
{/* current value dot */}
<circle cx={x(history.length - 1)} cy={y(last.rate_pct)} r={3.5} fill={dotColor} />
</svg>
</div>
)
}
function BtcSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
+17 -1
View File
@@ -151,12 +151,26 @@ export default function TradesPageClient() {
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
{/* Says what the page is, not where things sit — the section cards
below are self-labelled ("Open positions", KPI row, table). */}
<PageHint>
Open positions above · closed trade history with realized P&amp;L below.
What the bot holds right now, and every trade it has closed.
</PageHint>
</div>
</div>
{/* 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 ? (
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to see your trades</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
Positions and trade history are wallet-bound and private.
Use the Connect wallet button in the top-right corner.
</div>
</div>
) : (
<>
{needsSetup && (
<div className="card" style={{
padding: '14px 18px', marginBottom: 16,
@@ -210,6 +224,8 @@ export default function TradesPageClient() {
)}
<TradeTable trades={trades} loading={loading} locked={needsUnlock} />
</>
)}
</div>
)
}
+5 -17
View File
@@ -170,24 +170,12 @@ export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageP
<div className="page">
<div className="page-head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{/* No "⚡ Auto-trade" badge — the SystemControl strip right below
always shows the Auto-Trade feature + its live state. */}
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
background: 'color-mix(in oklab, var(--up) 12%, transparent)',
color: 'var(--up)', border: '1px solid color-mix(in oklab, var(--up) 25%, transparent)',
letterSpacing: '0.04em',
}}>
Auto-trade
</span>
</div>
<PageHint count={
sigFilter === 'buy' ? `${counts.buy} buy signals`
: sigFilter === 'short' ? `${counts.short} short signals`
: sigFilter === 'actionable' ? `${counts.actionable} actionable signals`
: `${counts.actionable} actionable / ${counts.all} posts`
}>
{/* No count slot — the filter tabs right below already show the
per-filter numbers (All / Actionable / Buy / Short). */}
<PageHint>
Every Truth Social post scored in &lt;3s. Trades only when conviction clears the threshold.
</PageHint>
</div>
+38 -11
View File
@@ -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,9 +146,11 @@ 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
}) {
return (
<article className={`macro-metric-card tone-${tone}`}>
const head = (
<div className="macro-metric-head">
<div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
@@ -154,9 +159,8 @@ function MetricCard({
</div>
<div className="macro-metric-value">{value}</div>
</div>
<div className="macro-summary">{summary}</div>
)
const chips = (
<div className="macro-thresholds">
{thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
@@ -164,10 +168,32 @@ function MetricCard({
</ThresholdChip>
))}
</div>
)
const action = (
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
)
if (hero) {
return (
<article className={`macro-metric-card tone-${tone} hero`}>
{head}
<div className="macro-hero-body">
<div className="macro-summary">{summary}</div>
{chips}
</div>
{action}
</article>
)
}
return (
<article className={`macro-metric-card tone-${tone}`}>
{head}
<div className="macro-summary">{summary}</div>
{chips}
{action}
</article>
)
}
@@ -402,11 +428,12 @@ export default function MacroPanel() {
<Section title="Valuation">
<div className="macro-grid one">
<MetricCard
hero
rank={1}
label="AHR999"
value={ind.ahr999?.toFixed(4) ?? '—'}
tone={toneAhr(ind.ahr999)}
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.450.75 = fair value leaning cheap, 0.751.2 = neutral / no edge, > 1.2 = expensive."
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. Lower = cheaper."
summary={ahrSay}
activeIndex={ahrActive}
thresholds={[
@@ -457,7 +484,7 @@ export default function MacroPanel() {
label="Altcoin Season"
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason."
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). Higher = broader alt strength."
summary={altSay}
activeIndex={altActive}
thresholds={[
@@ -474,7 +501,7 @@ export default function MacroPanel() {
label="BTC Dominance"
value={fmtPct(ind.btc_dominance_pct)}
tone={toneBtcDominance(ind.btc_dominance_pct)}
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 4555% = balanced, 5565% = BTC-led / defensive, > 65% = flight to safety."
hint="BTC's share of total crypto market cap. High dominance = defensive, capital hiding in BTC; low = alt rotation."
summary={domSay}
activeIndex={domActive}
thresholds={[
@@ -491,7 +518,7 @@ export default function MacroPanel() {
label="ETH / BTC"
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
tone={toneEthBtc(ind.eth_btc_ratio)}
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.0250.04 = range, > 0.04 = ETH and alts gaining leadership."
hint="Relative strength of ETH vs BTC — a proxy for broader crypto risk appetite."
summary={ebrSay}
activeIndex={ebrActive}
thresholds={[
@@ -547,7 +574,7 @@ export default function MacroPanel() {
label="ETF Net Flow (1d)"
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$200M = strong bid, $50M to +$50M = noise, < $200M = heavy outflow."
hint="Yesterday's total net flow into/out of US spot BTC ETFs — a direct read on institutional demand."
summary={etfSay}
activeIndex={etfActive}
thresholds={[
+50 -8
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
@@ -33,6 +33,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
// subscribeVisibleTimeRangeChange callback (when the user pans / zooms).
const repositionMacroRef = useRef<(() => 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<string, number> = {
'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<number, typeof visible>()
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 (
<div
ref={containerRef}
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
style={{ width: '100%', height: '100%', borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
/>
)
}
+12 -19
View File
@@ -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
</Link>
</div>
)
@@ -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. */}
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
{s.name} Auto-Trade
Auto-Trade
</span>
<span>·</span>
<span style={{ color: 'var(--ink-4)' }}>
@@ -236,10 +237,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
Settings
</span>
<span>{settingsLabel}</span>
<span>Settings</span>
<span aria-hidden="true"></span>
</Link>
</div>
@@ -297,23 +295,21 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
{/* Header */}
<div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}>
{/* Header is just "Auto-Trade" — the page title already names the
system, and the switch row below repeats "Auto-Trade" otherwise. */}
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.name} {isZh ? '控制面板' : 'Auto-Trade'}
{isZh ? '控制面板' : 'Auto-Trade'}
</div>
</div>
{/* Master Auto-Trade switch */}
<div style={{ padding: '16px' }}>
<div style={ROW}>
<div>
<div style={{ fontSize: 14, fontWeight: 700 }}>
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 事件交易' : '· event-driven'}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3 }}>
{/* The card header above already says "Auto-Trade" — this row only
carries the ON/OFF legend, not a second title. */}
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
<Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">OFF</Pill>
@@ -377,10 +373,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
Settings
</span>
<span>{settingsLabel}</span>
<span>Settings</span>
<span aria-hidden="true"></span>
</Link>
</div>
+3 -3
View File
@@ -835,7 +835,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Bot locks in profit when the position gains this much. Required.</span>
<span className="hint">Bot locks in profit when the position gains this much.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -851,7 +851,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span>
<span className="hint">Bot cuts the loss when the position falls this much.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -998,7 +998,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Daily trading cap
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span>
<span className="hint">Bot stops opening new trades once it has spent this amount in a day.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
+18 -14
View File
@@ -288,14 +288,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
<table className="table">
<thead>
<tr>
{/* 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. */}
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th>
<th>P&amp;L</th>
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
<th>P&amp;L</th>
</tr>
</thead>
<tbody>
@@ -346,20 +350,8 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
</span>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-end' }}>
<div className="stack" style={{ alignItems: 'flex-start' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span
style={{ fontSize: 12, color: 'var(--ink-4)' }}
@@ -379,6 +371,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
)}
</div>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
</tr>
)
})}
+30 -4
View File
@@ -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 `<table>` 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 `<table>` cell should add `overflow: visible` to that row/td.
*
* Usage:
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
@@ -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<HTMLSpanElement>(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<HTMLElement>('.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 (
<span
ref={rootRef}
className={`infotip infotip-${placement}`}
// role=button is a slight overstatement but it lets screen-reader users
// know there's a thing to tab to. The actual content is in data-tip and
@@ -56,6 +79,9 @@ export default function InfoTip({
tabIndex={0}
aria-label={text}
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
onMouseEnter={clampIntoViewport}
onFocus={clampIntoViewport}
onTouchStart={clampIntoViewport}
>
<span aria-hidden="true" className="infotip-icon">?</span>
<span className="infotip-bubble" role="tooltip">{text}</span>
+23 -18
View File
@@ -36,6 +36,19 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>
}
/** 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<BotTrade[]> {
const qs = [
`wallet=${wallet.toLowerCase()}`,
`ts=${env.timestamp}`,
`sig=${encodeURIComponent(env.signature)}`,
`limit=${limit}`,
`page=${page}`,
].join('&')
return fetchJson<BotTrade[]>(`/trades?${qs}`)
const qs = `wallet=${wallet.toLowerCase()}&limit=${limit}&page=${page}`
return fetchJson<BotTrade[]>(`/trades?${qs}`, signedReadInit(env))
}
export async function getPerformance(
wallet: string,
env: SignedEnvelope,
): Promise<BotPerformance> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<BotPerformance>(`/performance?${qs}`)
const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<BotPerformance>(`/performance?${qs}`, signedReadInit(env))
}
export async function subscribe(
@@ -272,16 +279,16 @@ export async function getOpenPositions(
wallet: string,
env: SignedEnvelope,
): Promise<OpenPositionsResponse> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`)
const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`, signedReadInit(env))
}
export async function getTodayStats(
wallet: string,
env: SignedEnvelope,
): Promise<TodayStats> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<TodayStats>(`/positions/today?${qs}`)
const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<TodayStats>(`/positions/today?${qs}`, signedReadInit(env))
}
// ─── Scanner control (module 2) ─────────────────────────────────────────────
@@ -378,8 +385,7 @@ export async function getUser(
wallet: string,
env: SignedEnvelope,
): Promise<UserData> {
const qs = `ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}?${qs}`)
return fetchJson<UserData>(`/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<TelegramStatus>(`/telegram/${wallet}/status?${qs}`)
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`, signedReadInit(env))
}
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`)
}
+16 -5
View File
@@ -18,6 +18,11 @@ interface CacheEntry<T> {
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<string, CacheEntry<unknown>>()
@@ -37,16 +42,22 @@ export async function swrFetch<T>(
// 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<T>(
// 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
}