From 72738917948b0fa4009655c22c1e1e9a2e0ebae5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 07:52:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20CLAUDE.md=EF=BC=8C?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=AD=BB=20import=EF=BC=8C=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=20API=20=E7=BB=86=E8=8A=82=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 257 ++++----- frontend/app/api/city/[name]/detail/route.ts | 64 ++- .../dashboard/ScanTerminalDashboard.tsx | 10 +- .../LiveTemperatureThresholdChart.tsx | 509 +++++++++++++++--- web/services/city_api.py | 13 +- 5 files changed, 615 insertions(+), 238 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15e094ee..8f8365c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,168 +4,133 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, and serves both a Next.js dashboard (Vercel) and a Telegram bot. +PolyWeather Pro — a paid institutional weather-intelligence terminal for temperature settlement markets. 50 monitored cities, DEB multi-model temperature blending, Mu probability calibration, Polymarket CLOB/WS price integration. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot. -## Environment & Preferences (ALWAYS follow) +**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription. -### Working Directory -- All commands run from the repo root -- Python virtual env: `venv\Scripts\activate` (Windows) / `source venv/bin/activate` (Linux/macOS) -- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000 -- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000 -- When I say "start the server", assume the working directory is the repo root +## Environment & Preferences -### Git Conventions -- **Commit language: Chinese (简体中文) ONLY** -- Format: Lore Commit Protocol — intent line in Chinese, trailers in English -- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径` -- **NEVER** use English for commit subject lines - -### Tooling -- Package manager: **npm** (not yarn/pnpm) +- Working directory: repo root - Python: `python` (not python3), venv at `venv/` -- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript) -- NEVER ask me about these preferences again — commit to memory +- Frontend: `cd frontend && npm run dev` → localhost:3000 +- Backend: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` +- Package manager: **npm** (not yarn/pnpm) +- **Commit language: Chinese (简体中文) ONLY** +- **NEVER start commit messages with `@`** — Chinese directly, no prefix + +## Commands + +```bash +# Frontend +cd frontend +npm run dev # dev server :3000 +npm run build # production build +npm run typecheck # tsc --noEmit +npm run test:business # 21 business state tests + +# Backend +uvicorn web.app:app --reload --host 0.0.0.0 --port 8000 +python bot_listener.py # Telegram bot + +# Python tests +python -m pytest tests/ +python -m pytest tests/test_supabase_entitlement.py + +# Lint +ruff check . +ruff format . + +# Docker (VPS) +docker compose down && docker compose up -d --build +``` ## Architecture ``` -Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py - ↓ - Weather Collector (METAR, TAF, Open-Meteo, country networks) - ↓ - Analysis (DEB + Trend + Probability + Market Scan) - ↓ - Payment Layer (Intent + Event + Confirm Loop) +Users → Next.js (Vercel) → FastAPI :8000 (VPS) + /terminal (paid gate) Weather Collector + / (landing page) Analysis (DEB + Mu + Polymarket scan) + Payment Layer (USDC on Polygon) + Telegram Bot → bot_listener.py ``` -- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/app_factory.py` → `web/routers/` (8 route modules: `system`, `city`, `auth`, `analytics`, `scan`, `payments`, `ops`, `routes` (legacy)) + `web/services/` (14 service modules) + `web/core.py`) -- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev) -- **Bot**: Telegram bot via `bot_listener.py` → `src/bot/` -- **Shared analysis core** in `src/` is used by both web API and bot -- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`) -- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views -- **Market Monitor** (`MonitorPanel`): Real-time temperature monitoring board for 22 trading cities. Uses a temperature resolution chain (AMOS runway → AMOS → `airport_primary` → `airport_current` → `current`) defined in `frontend/components/dashboard/monitoring/monitor-temperature.ts`. Per-city refresh decisions driven by source-aware freshness (`source-freshness.ts`) instead of uniform `obs_age_min`. Seoul/Busan display runway surface temperature from AMOS; US cities get 5-min MADIS HFMETAR via `airport_primary`; others fall back to METAR. -- **High-Freq Airport Pipeline**: 19 of 22 monitor cities have dedicated realtime sources (AMOS, MADIS, JMA, MGM, FMI, KNMI, AROME). Data flows: `weather_sources.py` (fetch) → `country_networks.py` (`_airport_primary_from_raw`, per-country providers) → API `airport_primary` field. Plain METAR stays in `airport_current`. Documented in `docs/AIRPORT_REALTIME_SOURCES.md`. -- **Country Network Providers**: `country_networks.py` routes per-city to the right provider (Turkey→MGM, Korea→KMA, Japan→JMA, etc.) via `get_country_network_provider()`. Each provider controls `airport_primary_current`, `official_nearby_current`, and `official_network_status`. US cities use the default `GlobalMetarNetworkProvider` but get MADIS overrides injected via `results["madis_hfmetar_current"]`. +### Frontend Structure -## Commands +| Path | Purpose | +|------|---------| +| `app/page.tsx` | Landing page (`InstitutionalLandingPage`) | +| `app/terminal/page.tsx` | Paid terminal (`ScanTerminalDashboard`) | +| `app/account/` | Account center with payment/subscription | +| `app/auth/` | Supabase login/signup | +| `components/dashboard/scan-terminal/` | Terminal sub-components | +| `components/account/` | Account + payment hooks | +| `components/landing/` | Institutional landing page | +| `components/subscription/` | `UnlockProOverlay` payment overlay | +| `lib/dashboard-types.ts` | All TypeScript types | -### Frontend (dev on port 3000) -```bash -cd frontend -npm ci -npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first) -npm run build # Production build (runs sync-next-server-chunks.mjs after) -npm run start # Production server -npm run lint # ESLint via next lint -npm run typecheck # tsc --noEmit -npm run test:business # Business state tests via scripts/run-business-state-tests.mjs (also runs in CI) +### Terminal Component Map + +- `ScanTerminalDashboard.tsx` — entry, auth gate, `ProductAccessRequired` +- `PolyWeatherTerminal` — main layout: sidebar + region tabs + 2-column grid +- `CityRegionList` — city list panel (left top) +- `CityContractDetail` — contract table panel (left bottom) +- `LiveTemperatureThresholdChart` — temperature trend + market thresholds (right) +- `TrainingDashboard` — DEB + Mu accuracy charts (sidebar tab) +- `MarketOverviewView` — regional heat + top opportunities (sidebar tab) +- `GroupedMarketTable` — contract comparison table +- `continent-grouping.ts` — 7 trading regions, city-to-region mapping, timezone detection + +### Account Module + +- `AccountCenter.tsx` (~1280 lines) — main component +- `useAccountPayment.ts` — master payment hook, composes sub-hooks +- `useWalletBind.ts` — EVM/WalletConnect binding +- `usePaymentFlow.ts` — intent creation, payment, confirmation +- `useBilling.ts` — subscription recovery, billing computation + +### Backend Key Files + +| Path | Purpose | +|------|---------| +| `web/routers/city.py` | City detail/summary/market-scan endpoints | +| `web/routers/scan.py` | Scan terminal aggregation | +| `web/services/city_payloads.py` | Market scan with Polymarket integration | +| `web/scan_terminal_city_row.py` | Builds terminal rows from analysis data | +| `src/data_collection/city_registry.py` | 50-city registry with tz_offset | +| `src/data_collection/polymarket_readonly.py` | Market discovery, CLOB prices, WS cache | +| `src/data_collection/polymarket_ws_cache.py` | WebSocket quote cache | +| `src/analysis/deb_algorithm.py` | DEB prediction + Mu calibration + accuracy | + +## Auth Gating + +Middleware (`middleware.ts`) handles two layers: +1. **Terminal gate** (`handleTerminalGate`): `/terminal/*` → redirect to `/auth/login` if no Supabase session +2. **Global auth** (`handleSupabaseAuthGate`): enforced when `POLYWEATHER_AUTH_REQUIRED=true` + +Client-side gate (`ProductAccessRequired`): `/terminal` checks auth + subscription via `/api/auth/me`, shows paywall if needed. + +Local dev bypass: set `NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false` to test auth locally. + +## Polymarket Integration + +Price pipeline: +``` +Gamma API (slug discovery) → CLOB REST + WS cache → market_scan → terminal rows ``` -### Backend (dev on port 8000) -```bash -uvicorn web.app:app --reload --host 0.0.0.0 --port 8000 -``` +- `resolve_city_clob_tokens()` — timezone-aware market discovery +- `collect_all_clob_token_ids()` — all YES/NO tokens for WS subscription +- `PolymarketWsQuoteCache` — WebSocket quote cache (daemon thread) +- Prices flow to terminal via `**row` spread in `_build_terminal_row` -### Telegram Bot -```bash -python bot_listener.py -# or via wrapper: -python run.py -``` +## Trading Regions -### Docker (production-like stack) -```bash -docker compose up -d --build # bot + web API (polyweather + polyweather_web) -``` -The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0. - -### Python tests -```bash -python -m pytest tests/ # all tests -python -m pytest tests/test_web_observability.py # single test file -``` - -### Version bump (see RELEASE.md) -```bash -python scripts/bump_version.py patch # or minor / major / 1.5.0 -python scripts/sync_version.py # verify sync across files -``` -`VERSION` file is the single source of truth; frontend `package.json` and docs sync from it. - -### Lint & Format -```bash -ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88) -ruff format . # Python format (Black-compatible, double quotes) -``` - -### Health & Ops checks -```bash -curl http://127.0.0.1:8000/healthz -curl http://127.0.0.1:8000/api/system/status -curl http://127.0.0.1:8000/metrics -``` - -## Key Directories - -| Directory | Purpose | -|-----------|---------| -| `src/analysis/` | DEB algorithm, trend engine, market alert engine, settlement rounding | -| `src/auth/` | Supabase entitlement checks, Telegram group pricing | -| `src/bot/` | Telegram bot handlers and orchestrator | -| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories | -| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) | -| `src/data_mining/` | Historical data fetch utilities | -| `src/onchain/` | Polygon wallet watcher | -| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit | -| `src/strategy/` | Trading strategy modules | -| `src/trading/` | Trading execution modules | -| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers | -| `web/` | FastAPI app (`app.py` → `app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) | -| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) | -| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. | -| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils | -| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper | -| `scripts/` | Operational scripts: backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. | -| `config/` | YAML config (city list, weather settings, logging) | -| `docs/` | Bilingual product & technical docs | - -## Key Technical Details - -- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules -- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined -- **Frontend package manager**: npm -- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only. -- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts -- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets. -- **Auth gating** (frontend middleware): Three-tier priority in `middleware.ts` — (1) local dev hosts (localhost / 127.0.0.1 / ::1) bypass auth entirely, (2) Supabase session-based when `POLYWEATHER_AUTH_ENABLED=true` via `handleSupabaseAuthGate` or `handleSupabaseOptionalSession`, (3) legacy token fallback via `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` cookie/query-param. Public pages (`/`, `/docs`, `/auth/*`, `/entitlement-required`) and public API routes are always accessible. -- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app) -- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts` - -## Commit Convention - -This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what). - -- Always write git commit messages in **Chinese (简体中文)**. +7 regions: east_asia, southeast_asia, central_asia, west_asia, europe_africa, south_america, north_america. Mappings in `continent-grouping.ts` (`CITY_REGION_FALLBACK` — all 50 cities hardcoded) and `scan_terminal_filters.py` (`market_region_from_tz_offset`). Default region auto-detected from browser timezone. ## Code Style -- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding. -- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch. -- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`. -- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity. -- **Monitoring CSS note**: `MonitorPanel.module.css` scopes its light-theme overrides to `.scan-terminal.light` (the terminal's built-in toggle), NOT `html.light`. When adding light styles for monitoring components, match this scoping. -- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`. - -## Quality Gates (MANDATORY) - -Before marking any task as complete, you MUST: - -1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files -2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix -3. **Dual-theme CSS** — For any UI change, confirm BOTH dark and light styles. Most components need `ScanTerminalLightTheme.module.css` updated; monitoring components (`MonitorPanel.module.css`) contain their own `.scan-terminal.light` blocks inline. -4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values -5. **Show the diff** — Output `git diff --stat` and test results before declaring success - -If any gate fails, fix it BEFORE reporting success. +- No `\uXXXX` escapes — write characters directly in UTF-8 +- Use `var(--color-*)` CSS tokens, not hardcoded hex +- Minimum font size: 10px (`text-[10px]`) +- Avoid `!important` except Leaflet map overrides +- Remove dead code immediately when features are removed diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index bd7526c1..cb928cec 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -1,9 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; -import { proxyBackendJsonGet } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; +import { buildCachedJsonResponse } from "@/lib/http-cache"; import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +function normalizeCityDetailPayload(data: unknown) { + if (!data || typeof data !== "object") return data; + const payload = data as Record; + + // Backend v2 nests hourly under timeseries; chart expects it at top level. + if (!payload.hourly && payload.timeseries?.hourly) { + payload.hourly = payload.timeseries.hourly; + } + + if (!payload.market_scan && payload.market_scan_payload) { + return { + ...payload, + market_scan: payload.market_scan_payload, + }; + } + return payload; +} + export async function GET( req: NextRequest, context: { params: Promise<{ name: string }> }, @@ -36,12 +62,32 @@ export async function GET( } const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; - return proxyBackendJsonGet(req, { - cacheControl: cachePolicy.responseCacheControl, - fetchCache: - cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, - publicMessage: "Failed to fetch city detail aggregate", - revalidateSeconds: cachePolicy.revalidateSeconds, - url, - }); + try { + const auth = await buildBackendRequestHeaders(req, { + includeSupabaseIdentity: false, + }); + const res = await fetch(url, { + headers: auth.headers, + ...(cachePolicy.fetchMode === "no-store" + ? { cache: "no-store" as const } + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), + }); + if (!res.ok) { + const raw = await res.text(); + const response = buildUpstreamErrorResponse(res.status, raw); + return applyAuthResponseCookies(response, auth.response); + } + const data = normalizeCityDetailPayload(await res.json()); + const response = buildCachedJsonResponse( + req, + data, + cachePolicy.responseCacheControl, + ); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch city detail aggregate", + }); + return response; + } } diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index b03b15c9..187b2192 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -13,7 +13,7 @@ import { Table2, UserRound, } from "lucide-react"; -import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; @@ -44,7 +44,6 @@ import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/ import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { useRelativeTime } from "@/hooks/useRelativeTime"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; -import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable"; import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard"; import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { MarketOverviewView } from "@/components/dashboard/scan-terminal/MarketOverviewView"; @@ -862,7 +861,7 @@ function PolyWeatherTerminal({
- +
@@ -1000,6 +999,9 @@ function ScanTerminalScreen() { () => filteredRows.find((row) => row.id === selectedId) || filteredRows[0] || null, [filteredRows, selectedId], ); + const handleSelectRow = useCallback((row: ScanOpportunityRow) => { + setSelectedId(row.id); + }, []); const generatedText = useRelativeTime(terminalData?.generated_at ?? null); if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) { @@ -1032,7 +1034,7 @@ function ScanTerminalScreen() { refreshing={scanLoading} rows={filteredRows} selectedRow={selectedRow} - setSelectedRow={(row) => setSelectedId(row.id)} + setSelectedRow={handleSelectRow} toggleLocale={toggleLocale} userLocalTime={userLocalTime} searchQuery={searchQuery} diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 982de188..04b5034f 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -14,11 +14,117 @@ import { XAxis, YAxis, } from "recharts"; -import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; +import type { AmosData, AirportCurrentConditions, CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { rowName, temp } from "@/components/dashboard/scan-terminal/utils"; +const SETTLEMENT_RUNWAY_PAIRS: Record> = { + shanghai: [["17L", "35R"]], + beijing: [["01", "19"]], + guangzhou: [["02L", "20R"]], + chengdu: [["02L", "20R"]], + chongqing: [["02L", "20R"]], + wuhan: [["04", "22"]], + seoul: [["15R", "33L"]], +}; + +function normalizeRunwayLabel(value?: string | null) { + return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); +} + +function normalizeCityKey(value?: string | null) { + return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, ""); +} + +function pairKey(pair: [string, string]) { + return pair.map(normalizeRunwayLabel).sort().join("/"); +} + +function buildRunwayPlates( + amos: AmosData | null | undefined, + row: ScanOpportunityRow | null, + settlementObs?: Array<{ ts: number; value: number }>, +) { + if (!amos) return []; + const runwayObs = amos.runway_obs || {}; + const runwayPairs = runwayObs.runway_pairs || []; + const runwayTemps = runwayObs.temperatures || []; + const pointTemps = runwayObs.point_temperatures || []; + + const cityKey = normalizeCityKey(row?.city); + const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; + const settlementKeys = new Set(settlementPairs.map(pairKey)); + + const list: Array<{ + rwy: string; + isSettlement: boolean; + tdzTemp: number | null; + midTemp: number | null; + endTemp: number | null; + maxTemp: number | null; + dailyHigh: number | null; + trend_15m: number | null; + }> = []; + + runwayPairs.forEach((rawPair: any, index: number) => { + const pair = rawPair as [string, string]; + if (!Array.isArray(pair) || pair.length < 2) return; + const isSettlement = settlementKeys.has(pairKey(pair)); + + const tdz = validNumber(pointTemps[index]?.tdz_temp); + const mid = validNumber(pointTemps[index]?.mid_temp); + const end = validNumber(pointTemps[index]?.end_temp); + + const historyVals = Array.isArray(runwayTemps[index]) + ? (runwayTemps[index] as Array).map(validNumber).filter((v): v is number => v !== null) + : []; + + const tdzVal = tdz !== null ? [tdz] : []; + const midVal = mid !== null ? [mid] : []; + const endVal = end !== null ? [end] : []; + const allVals = [...historyVals, ...tdzVal, ...midVal, ...endVal]; + + const maxTemp = allVals.length ? Math.max(...allVals) : null; + const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp; + + // Calculate 15-minute trend + const latest = historyVals.length > 0 ? historyVals[historyVals.length - 1] : (tdz ?? mid ?? end ?? null); + const val15 = historyVals.length > 15 ? historyVals[historyVals.length - 16] : (historyVals.length > 0 ? historyVals[0] : null); + let trend_15m = (latest !== null && val15 !== null) ? latest - val15 : null; + + if (isSettlement && settlementObs && settlementObs.length >= 2) { + const latestObs = settlementObs[settlementObs.length - 1]; + const targetTs = latestObs.ts - 15 * 60 * 1000; + let closestPoint = settlementObs[0]; + let minDiff = Math.abs(closestPoint.ts - targetTs); + for (let i = 1; i < settlementObs.length; i++) { + const diff = Math.abs(settlementObs[i].ts - targetTs); + if (diff < minDiff) { + minDiff = diff; + closestPoint = settlementObs[i]; + } + } + if (Math.abs(closestPoint.ts - targetTs) < 5 * 60 * 1000) { + trend_15m = latestObs.value - closestPoint.value; + } + } + + list.push({ + rwy: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`, + isSettlement, + tdzTemp: tdz, + midTemp: mid, + endTemp: end, + maxTemp, + dailyHigh, + trend_15m, + }); + }); + + return list; +} + type ObsPoint = { time?: string | null; temp?: number | null }; type EvidenceSeries = { @@ -41,39 +147,80 @@ function validNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function toTimestamp(value?: string | null): number | null { - const raw = String(value || "").trim(); +function getCityLocalUtcTimestamp( + value: string | number | null | undefined, + tzOffsetSeconds: number, + referenceLocalDate?: string | null +): number | null { + if (value == null) return null; + + if (typeof value === "number") { + const d = new Date(value + tzOffsetSeconds * 1000); + return Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes() + ); + } + + const raw = String(value).trim(); if (!raw) return null; - const d = new Date(raw); - if (!Number.isNaN(d.getTime())) return d.getTime(); - // HH:MM or HH:MM:SS — treat as today, but handle cross-midnight: - // if parsed time is >2h ahead of now, assume yesterday + + if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) { + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) { + const localMs = d.getTime() + tzOffsetSeconds * 1000; + const localDate = new Date(localMs); + return Date.UTC( + localDate.getUTCFullYear(), + localDate.getUTCMonth(), + localDate.getUTCDate(), + localDate.getUTCHours(), + localDate.getUTCMinutes() + ); + } + } + const m = raw.match(/(\d{1,2}):(\d{2})/); if (m) { - const now = new Date(); - const h = +m[1], min = +m[2]; - const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, min); - if (candidate.getTime() - now.getTime() > 2 * 60 * 60 * 1000) { - candidate.setDate(candidate.getDate() - 1); + const h = +m[1]; + const min = +m[2]; + + let year = new Date().getUTCFullYear(); + let month = new Date().getUTCMonth(); + let date = new Date().getUTCDate(); + + if (referenceLocalDate) { + const dateParts = referenceLocalDate.split("-"); + if (dateParts.length === 3) { + year = parseInt(dateParts[0]); + month = parseInt(dateParts[1]) - 1; + date = parseInt(dateParts[2]); + } } - return candidate.getTime(); + + return Date.UTC(year, month, date, h, min); } + return null; } function formatTimestamp(ts: number): string { const d = new Date(ts); - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; } -function normObs(points?: ObsPoint[] | null, limit = MAX_OBS_POINTS) { +function normObs(points: ObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { return (points || []) - .filter((p) => validNumber(p.temp) !== null && toTimestamp(p.time) !== null) - .slice(-limit) + .filter((p) => validNumber(p.temp) !== null && p.time) .map((p) => ({ - ts: toTimestamp(p.time)!, + ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!, value: Number(p.temp), - })); + })) + .filter((p) => p.ts !== null) + .slice(-limit); } function seriesStats(values: Array) { @@ -91,6 +238,9 @@ type HourlyForecast = { times: string[]; temps: Array; modelCurves?: Record>; + amos?: AmosData | null; + airportCurrent?: AirportCurrentConditions | null; + airportPrimary?: AirportCurrentConditions | null; } | null; // ── Build aligned data rows for the sliding-window chart ──────────────── @@ -99,8 +249,11 @@ function buildSlidingChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, ) { - const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs); - const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs); + const tzOffset = row?.tz_offset_seconds ?? 0; + const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + + const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); + const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); // Collect all timestamps from observations + forecasts const allTimes = new Set(); @@ -115,7 +268,7 @@ function buildSlidingChartData( const forecastTimes: number[] = []; if (hourly?.times?.length && hourly?.temps?.length) { hourly.times.forEach((t, i) => { - const ts = toTimestamp(t); + const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); if (ts !== null && i < hourly.temps.length) { allTimes.add(ts); forecastTimes.push(ts); @@ -143,9 +296,27 @@ function buildSlidingChartData( if (idx !== undefined) sVals[idx] = o.value; }); if (sVals.some((v) => v !== null)) { + const cityKey = String(row?.city || "").toLowerCase().trim(); + const runwaySensorCities = new Set([ + 'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao', + 'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors + 'seoul', 'busan', // AMOS runway sensors + ]); + const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau'); + const isTokyo = cityKey === 'tokyo'; + const isSingapore = cityKey === 'singapore'; + const isWeatherStation = !runwaySensorCities.has(cityKey) + && !isHKO && !isTokyo && !isSingapore; + + const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)' + : isTokyo ? '机场气象站 (10分钟)' + : isSingapore ? '航站楼温度' + : isWeatherStation ? '气象站实测' + : '跑道实测 (1分钟)'; + series.push({ key: "settlement", - label: row?.metar_context?.station_label || row?.metar_context?.station || "Settlement", + label: runwayHeaderLabel, source: row?.metar_context?.station || row?.airport || "Settlement", color: "#009688", featured: true, @@ -181,7 +352,7 @@ function buildSlidingChartData( ); const debVals = na(); hourly.times.forEach((t, i) => { - const ts = toTimestamp(t); + const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); const idx = ts !== null ? tsToIdx.get(ts) : undefined; if (idx !== undefined && i < debPath.debTemps.length) { debVals[idx] = validNumber(debPath.debTemps[i]); @@ -207,7 +378,7 @@ function buildSlidingChartData( if (!modelTemps?.length) return; const vals = na(); hourly.times.forEach((t, i) => { - const ts = toTimestamp(t); + const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); const x = ts !== null ? tsToIdx.get(ts) : undefined; if (x !== undefined && i < modelTemps.length) vals[x] = validNumber(modelTemps[i]); }); @@ -320,9 +491,11 @@ function buildChartDomain( export function LiveTemperatureThresholdChart({ isEn, row, + allRows = [], }: { isEn: boolean; row: ScanOpportunityRow | null; + allRows?: ScanOpportunityRow[]; }) { const [hourly, setHourly] = useState(null); const city = String(row?.city || "").toLowerCase().trim(); @@ -352,6 +525,9 @@ export function LiveTemperatureThresholdChart({ times: hourlySource.times || [], temps: hourlySource.temps || [], modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined, + amos: json.amos || null, + airportCurrent: json.airport_current || null, + airportPrimary: json.airport_primary || null, }; _hourlyCache.set(city, { ts: Date.now(), data }); setHourly(data); @@ -362,15 +538,99 @@ export function LiveTemperatureThresholdChart({ const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]); const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value); - const modelSummaryCards = useMemo(() => { - const cards = buildModelSummaryCards(row); - if (!hourly?.modelCurves) return cards; - const curveKeys = new Set(Object.keys(hourly.modelCurves)); - return cards.filter((c) => !curveKeys.has(c.label)); - }, [row, hourly]); - const tableRows = [...series, ...modelSummaryCards] - .slice(0, 5) - .map((item) => ({ ...item, ...seriesStats(item.values) })); + + const tzOffset = row?.tz_offset_seconds ?? 0; + const settlementObs = useMemo(() => { + return normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); + }, [row, tzOffset]); + + const runwayPlates = useMemo(() => buildRunwayPlates(hourly?.amos, row, settlementObs), [hourly?.amos, row, settlementObs]); + const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]); + + const cityKey = String(row?.city || "").toLowerCase().trim(); + const runwaySensorCities = new Set([ + 'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao', + 'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors + 'seoul', 'busan', // AMOS runway sensors + ]); + const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau'); + const isTokyo = cityKey === 'tokyo'; + const isSingapore = cityKey === 'singapore'; + const isWeatherStation = !runwaySensorCities.has(cityKey) + && !isHKO && !isTokyo && !isSingapore; + + const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)' + : isTokyo ? '机场气象站 (10分钟)' + : isSingapore ? '航站楼温度' + : isWeatherStation ? '气象站实测' + : '跑道实测 (1分钟)'; + + const metarHeaderLabel = isHKO ? '天文台实测 (10分钟)' + : 'METAR 结算 (30分钟)'; + + const runwayHighLabel = isHKO ? '参考站点' + : isTokyo ? '机场气象站' + : isSingapore ? '航站楼' + : isWeatherStation ? '气象站' + : '跑道实测'; + + const metarHighLabel = isHKO ? '天文台' + : 'METAR 官方'; + + const currentRunwayTemp = validNumber(hourly?.amos?.temp_c) ?? validNumber(row?.current_temp) ?? settlementPlate?.maxTemp ?? null; + const observedHighMetar = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far) ?? null; + const observedHighRunway = validNumber(row?.current_max_so_far) ?? settlementPlate?.maxTemp ?? currentRunwayTemp ?? null; + const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null; + + const modelValues = Object.values(row?.model_cluster_sources || {}) + .map(validNumber) + .filter((v): v is number => v !== null); + const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? null); + const modelMax = modelValues.length ? Math.max(...modelValues) : (row?.cluster_core_high ?? null); + const debVal = validNumber(row?.deb_prediction) ?? null; + + const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null; + const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧")); + const spreadLabelEn = spread === null ? "" : (spread <= 2.0 ? "Low" : (spread <= 4.0 ? "Medium" : "High")); + + const formattedUpdateTime = useMemo(() => { + if (row?.local_date && row?.local_time) { + return `${row.local_date} ${row.local_time.slice(0, 8)}`; + } + const d = new Date(); + return d.toISOString().replace('T', ' ').slice(0, 19); + }, [row]); + + const cityThresholds = useMemo(() => { + if (!row || !allRows || !allRows.length) return []; + const cityKey = String(row.city || "").toLowerCase().trim(); + const sameCityRows = allRows.filter( + (r) => String(r.city || "").toLowerCase().trim() === cityKey + ); + + const seen = new Set(); + const list: { threshold: number; label: string; isBreached: boolean; kind: "gte" | "lte" }[] = []; + sameCityRows.forEach((r) => { + const t = Number(r.target_threshold ?? r.target_value ?? r.target_lower ?? r.target_upper); + if (!Number.isFinite(t) || seen.has(t)) return; + seen.add(t); + + const maxTemp = Number(r.current_max_so_far ?? r.current_temp ?? 0); + const q = String(r.market_question || r.target_label || "").toLowerCase(); + const kind: "gte" | "lte" = q.includes("below") || q.includes("under") || q.includes("lte") ? "lte" : "gte"; + const isBreached = kind === "lte" ? maxTemp > t : maxTemp >= t; + + list.push({ + threshold: t, + label: r.target_label || `${t}°C`, + isBreached, + kind, + }); + }); + + return list.sort((a, b) => a.threshold - b.threshold); + }, [row, allRows]); + const marketTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]); const chartDomain = useMemo(() => buildChartDomain(marketTicks, series), [marketTicks, series]); @@ -378,49 +638,129 @@ export function LiveTemperatureThresholdChart({
{/* Stats bar */} -
-
-
-
- {isEn ? "Settlement live" : "跑道实测"} {temp(validNumber(row?.current_temp))} +
+ {/* Top Row: Large temperatures */} +
+
+
+ + {isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} + + + {temp(currentRunwayTemp)} +
-
- METAR {temp(validNumber(row?.metar_context?.airport_current_temp ?? row?.metar_context?.last_temp))} +
+ + {isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`} + + + {temp(observedHighMetar)} +
-
- {isEn ? "Threshold" : "当日阈值"} {temp(threshold)} + +
+ + {isEn ? "Daily Peak" : "当日最高气温"} + +
+ {isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway)} + | + {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar)} + {wundergroundDailyHigh !== null && ( + <> + | + WU: {temp(wundergroundDailyHigh)} + + )} +
-
- {tableRows.map((item) => ( -
-
- - {item.label} -
-
- {item.key.startsWith("model_summary_") ? ( - {temp(item.latest)} - ) : ( -
- now: {temp(item.latest)} - max: {temp(item.high)} - Δ15: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`} -
- )} -
-
- ))} + + {/* Bottom Row: Model Range Panel */} +
+
+ + {isEn ? "Model Range" : "模型区间"} + + + {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"} + +
+
+ + DEB + + + {temp(debVal)} + +
+
+ + {isEn ? "Spread" : "分歧"} + + + {spread !== null ? `${spread.toFixed(1)}°C` : "--"} + {spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`} + +
+
+ + {isEn ? "Updated" : "更新时间"} + + + {formattedUpdateTime} + +
+ {/* Runway observations */} + {runwayPlates.length > 0 && ( +
+
+ {isEn ? "Runway Observations" : "跑道观测"} + {runwayPlates.some((p) => p.trend_15m !== null && p.trend_15m > 0 && !p.isSettlement) && ( + + {isEn ? "Non-settlement Runway Warming Alert" : "非结算跑道升温提醒"} + + )} +
+
+ {runwayPlates.map((plate) => ( +
+
+ {plate.isSettlement && } + {plate.rwy} + {plate.isSettlement && ( + + {isEn ? "Settlement" : "结算"} + + )} +
+
TDZ: {plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}°C` : "--"}
+
MID: {plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}°C` : "--"}
+
END: {plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}°C` : "--"}
+
max: {plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}°C` : "--"}
+
high: {plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}°C` : "--"}
+
0 ? "text-orange-600 font-bold" : "text-slate-500")}> + 15m: {plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}°C` : "--"} +
+
+ ))} +
+
+ )} + {/* Chart */}
@@ -449,15 +789,28 @@ export function LiveTemperatureThresholdChart({ domain={chartDomain} ticks={marketTicks ?? undefined} /> - {threshold !== null && ( - - )} + {cityThresholds.map((t, idx) => { + const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold); + const labelText = isEn + ? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]` + : `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`; + + return ( + + ); + })} Dict[str, Any]: legacy_routes._assert_entitlement(request) city = legacy_routes._normalize_city_or_404(name) - data = await run_in_threadpool(legacy_routes._analyze, city, force_refresh, True) + if force_refresh: + data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, True) + else: + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city) + if cached_entry: + if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC): + data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False) + else: + data = cached_entry.get("payload") or {} + else: + data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False) + return await run_in_threadpool( legacy_routes._build_city_detail_payload, data,