ui: tighten dashboard copy and fix layout issues
This commit is contained in:
@@ -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-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<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: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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
<span className="chip"><span className="live-dot" />Live</span>
|
||||
{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 }}>
|
||||
|
||||
@@ -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)'
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
|
||||
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>
|
||||
{(() => {
|
||||
// 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 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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
{/* 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 (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -315,10 +331,21 @@ export default function AnalyticsPageClient() {
|
||||
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
|
||||
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
|
||||
</div>
|
||||
<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>
|
||||
{/* 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>
|
||||
</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&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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
<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>
|
||||
{/* 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>
|
||||
</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 />
|
||||
</>
|
||||
<FundingPanel
|
||||
isZh={isZh}
|
||||
initialSnapshot={initialFundingSnapshot}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 }}>
|
||||
|
||||
@@ -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&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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
<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 "⚡ 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>
|
||||
{/* 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 <3s. Trades only when conviction clears the threshold.
|
||||
</PageHint>
|
||||
</div>
|
||||
|
||||
@@ -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 = (
|
||||
<div className="macro-metric-head">
|
||||
<div className="macro-metric-title">
|
||||
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
|
||||
<span>{label}</span>
|
||||
<InfoTip text={hint} placement="top" width={280} />
|
||||
</div>
|
||||
<div className="macro-metric-value">{value}</div>
|
||||
</div>
|
||||
)
|
||||
const chips = (
|
||||
<div className="macro-thresholds">
|
||||
{thresholds.map((item, i) => (
|
||||
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
|
||||
{item.label}
|
||||
</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}`}>
|
||||
<div className="macro-metric-head">
|
||||
<div className="macro-metric-title">
|
||||
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
|
||||
<span>{label}</span>
|
||||
<InfoTip text={hint} placement="top" width={280} />
|
||||
</div>
|
||||
<div className="macro-metric-value">{value}</div>
|
||||
</div>
|
||||
|
||||
{head}
|
||||
<div className="macro-summary">{summary}</div>
|
||||
|
||||
<div className="macro-thresholds">
|
||||
{thresholds.map((item, i) => (
|
||||
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
|
||||
{item.label}
|
||||
</ThresholdChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="macro-actions">
|
||||
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
|
||||
</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.45–0.75 = fair value leaning cheap, 0.75–1.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, 25–60 = mixed, 60–75 = 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, 45–55% = balanced, 55–65% = 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.025–0.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={[
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,22 +295,20 @@ 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 }}>
|
||||
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
|
||||
</div>
|
||||
{/* 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 style={{ display: 'flex', gap: 6 }}>
|
||||
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</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>
|
||||
|
||||
@@ -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) }} />
|
||||
|
||||
@@ -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&L</th>
|
||||
<th>{isZh ? '开仓' : 'Entry'}</th>
|
||||
<th>{isZh ? '平仓' : 'Exit'}</th>
|
||||
<th>{isZh ? '持仓' : 'Hold'}</th>
|
||||
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
|
||||
<th>P&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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user