Compare commits

...

95 Commits

Author SHA1 Message Date
2569718930@qq.com f32a8d990a Fix ops weekly leaderboard ranking 2026-06-08 23:27:13 +08:00
2569718930@qq.com 6c7f3ef67f Use fixed feedback reward amounts 2026-06-08 22:20:35 +08:00
2569718930@qq.com 6273921005 Add ops feedback reward grants 2026-06-08 20:54:20 +08:00
2569718930@qq.com 7d1bfea8da Add ops feedback reward guide 2026-06-08 18:54:43 +08:00
2569718930@qq.com 961c9bd37c Show feedback reward details in account 2026-06-08 16:25:32 +08:00
2569718930@qq.com b0fec20370 Document feedback reward account details 2026-06-08 16:15:22 +08:00
2569718930@qq.com 8f6925108c Reduce polling and batch observation writes 2026-06-08 15:12:41 +08:00
2569718930@qq.com 96dde3b50e Add observation collector health status 2026-06-08 14:39:49 +08:00
2569718930@qq.com 505b75d6ce Throttle chart stale retry badges 2026-06-08 13:59:38 +08:00
2569718930@qq.com 956cb8a8e3 Use usable recent DEB training rankings 2026-06-08 13:37:20 +08:00
2569718930@qq.com 721173aea3 Isolate observation collector from web API 2026-06-08 13:19:52 +08:00
2569718930@qq.com ec84243fb9 Show usable recent DEB accuracy 2026-06-08 12:27:20 +08:00
2569718930@qq.com e1054e684a Return stale cache for duplicate force refresh 2026-06-08 01:20:59 +08:00
2569718930@qq.com 58a2881fc8 Return stale city panel cache while refreshing 2026-06-08 01:11:11 +08:00
2569718930@qq.com 4f4617ba0a Guard DEB bucket calibration 2026-06-08 00:59:16 +08:00
2569718930@qq.com 26775693e7 Avoid blocking city force refresh 2026-06-08 00:33:57 +08:00
2569718930@qq.com 139e5dbb06 Fix DEB quality daily payload 2026-06-08 00:12:14 +08:00
2569718930@qq.com bbe3871b7c Expose DEB quality guidance 2026-06-07 23:57:47 +08:00
2569718930@qq.com 4389d4a40d Add DEB city trust tiers 2026-06-07 23:32:23 +08:00
2569718930@qq.com c3f9b974c3 Cap DEB training version backtest 2026-06-07 23:05:45 +08:00
2569718930@qq.com 39026344dc Retry CI for DEB calibration update 2026-06-07 22:55:43 +08:00
2569718930@qq.com 41e819735d Improve DEB calibration and training metrics 2026-06-07 22:51:18 +08:00
2569718930@qq.com 12935f502f Fix README terminal image reference 2026-06-07 21:29:49 +08:00
2569718930@qq.com c4b47a81a7 Update README product status 2026-06-07 21:22:29 +08:00
2569718930@qq.com 27e4f1efe0 Show full Gaussian probability tooltip 2026-06-07 13:02:16 +08:00
2569718930@qq.com f66127da44 Refine pricing display and Gaussian context 2026-06-07 01:08:13 +08:00
2569718930@qq.com fb4a144fcf Use static terminal update announcements 2026-06-07 00:32:11 +08:00
2569718930@qq.com 3951735259 Add terminal update announcements 2026-06-07 00:04:08 +08:00
2569718930@qq.com a40b1ba137 feat: add UserFeedbackModal component and supporting unit tests 2026-06-06 23:30:08 +08:00
2569718930@qq.com 555d3fa68f Show user feedback history in account 2026-06-06 23:15:55 +08:00
2569718930@qq.com f7625218ae Add user feedback status notifications 2026-06-06 22:55:15 +08:00
2569718930@qq.com a5de604d85 Refine landing and auth entry design 2026-06-06 22:21:42 +08:00
2569718930@qq.com 11fd482378 Refresh product docs and observation freshness 2026-06-06 21:46:23 +08:00
2569718930@qq.com 524ae7c8cf fix: refine landing and auth entry design 2026-06-06 20:43:31 +08:00
2569718930@qq.com 98b69b180a fix: improve docs page readability 2026-06-06 19:52:27 +08:00
2569718930@qq.com d354fb12dd docs: add codex project instructions 2026-06-06 19:37:03 +08:00
2569718930@qq.com 2f5e4a40dd ci: avoid buildx setup for image publish 2026-06-06 19:22:46 +08:00
2569718930@qq.com 7a38e3588a feat: productize landing and chart education 2026-06-06 19:17:24 +08:00
2569718930@qq.com c2f9bc5994 fix: hide probability bands on temperature chart 2026-06-06 18:00:28 +08:00
2569718930@qq.com 89f77b8db4 fix: align hong kong compact hko stat 2026-06-06 17:01:39 +08:00
2569718930@qq.com 2766b6463c fix: keep chart runway history current 2026-06-06 13:37:00 +08:00
2569718930@qq.com 2a6c826f50 fix: restrict telegram pricing to private group 2026-06-05 17:26:43 +08:00
2569718930@qq.com 09979f3bb5 fix: handle auth callback failures 2026-06-05 16:50:52 +08:00
2569718930@qq.com e6a1d14b09 fix: improve mobile terminal region tabs 2026-06-05 16:08:34 +08:00
2569718930@qq.com 2af1cf9cd8 feat: add observation collector and source gate 2026-06-05 14:38:58 +08:00
2569718930@qq.com c3fd0582ad fix: improve chart tooltip and metar refresh 2026-06-03 11:41:16 +08:00
2569718930@qq.com 00f96150c5 fix: prevent slow chart proxy 504s 2026-06-03 02:34:27 +08:00
2569718930@qq.com 92307a9f97 fix: force frontend proxy to internal backend 2026-06-03 02:13:02 +08:00
2569718930@qq.com a440f3d2b5 fix: hide upstream html payment errors 2026-06-03 01:58:19 +08:00
2569718930@qq.com afffacb529 fix: recheck public smoke before rollback 2026-06-02 21:54:18 +08:00
2569718930@qq.com 137b0d8b3a fix: apply telegram group member monthly price 2026-06-02 21:35:35 +08:00
2569718930@qq.com 876417c5ec fix: raise web container open file limit 2026-06-02 16:19:52 +08:00
2569718930@qq.com 35a93cbd9e fix: allow closing terminal chart slots 2026-06-02 08:45:25 +08:00
2569718930@qq.com 9d136a337c Speed up entitlement auth sync 2026-06-01 22:36:52 +08:00
2569718930@qq.com fc0a8b8ff5 Harden wallet challenge proxy 2026-06-01 22:15:38 +08:00
2569718930@qq.com 8fc9f3ae1a Add terminal feedback workflow 2026-06-01 21:10:37 +08:00
2569718930@qq.com f1e577ea8a 优化终端侧边栏展开性能 2026-06-01 16:15:20 +08:00
2569718930@qq.com 2fd8027a41 Fix deploy stdin token handling 2026-06-01 14:44:53 +08:00
2569718930@qq.com 6407d0a29d Pass deploy GHCR token over stdin 2026-06-01 14:30:41 +08:00
2569718930@qq.com 573353421b Retry deploy compose recreate races 2026-06-01 14:21:43 +08:00
2569718930@qq.com ac5b512a72 Reduce terminal chart update timeouts 2026-06-01 14:07:13 +08:00
2569718930@qq.com 31fb1b3f28 Fix ops payment incident grouping 2026-06-01 13:49:47 +08:00
2569718930@qq.com 49cb0bb42d Prevent terminal auth spinner deadlock 2026-06-01 12:36:29 +08:00
2569718930@qq.com fb6c354317 Avoid terminal full-detail fallback overload 2026-06-01 12:06:46 +08:00
2569718930@qq.com fee402145e Slim terminal chart detail batches 2026-06-01 11:37:16 +08:00
2569718930@qq.com d1633331bc Load terminal chart canvas eagerly 2026-06-01 07:05:08 +08:00
2569718930@qq.com 6e00160897 Version scan terminal cache key 2026-06-01 06:46:34 +08:00
2569718930@qq.com 6bc53c6e5d Slim deferred scan runway history 2026-06-01 06:25:27 +08:00
2569718930@qq.com 7625a99020 Cache terminal city registry fallback 2026-06-01 06:09:44 +08:00
2569718930@qq.com 3447a043ff Preload terminal chart canvas 2026-06-01 05:56:09 +08:00
2569718930@qq.com 10cf557b11 Batch deferred chart detail loads 2026-06-01 02:56:13 +08:00
2569718930@qq.com eaf17c7a32 Align scan prewarm observability test 2026-06-01 02:35:23 +08:00
2569718930@qq.com 14526d6ce8 Prewarm default scan terminal payload 2026-06-01 02:06:40 +08:00
2569718930@qq.com 9a0d7ae444 Persist scan terminal cache in Redis 2026-06-01 01:53:54 +08:00
2569718930@qq.com de5083ef18 Defer secondary chart detail loads 2026-06-01 01:27:53 +08:00
2569718930@qq.com 2424b68532 Compact scan runway history payload 2026-06-01 01:03:33 +08:00
2569718930@qq.com ff2a3da100 Use longer timeout for scan prewarm 2026-06-01 00:30:25 +08:00
2569718930@qq.com 7040598e26 Prewarm terminal scan payload 2026-06-01 00:14:12 +08:00
2569718930@qq.com 22f4ac1f26 Preserve scan cache on timeout 2026-05-31 23:54:35 +08:00
2569718930@qq.com 3e5ff4ea8f Isolate scan prewarm to web service 2026-05-31 23:31:32 +08:00
2569718930@qq.com 39c7c1903c Shorten cold scan terminal timeout 2026-05-31 22:54:43 +08:00
2569718930@qq.com eecaa48ec2 Limit city detail fallback to batch failures 2026-05-31 22:34:28 +08:00
2569718930@qq.com d911af1225 Reduce duplicate terminal detail loads 2026-05-31 22:14:17 +08:00
2569718930@qq.com 38c6cefb27 Direct resync stale SSE clients 2026-05-31 21:48:09 +08:00
2569718930@qq.com a3df2e00dc Clamp SSE replay window on backend 2026-05-31 21:33:35 +08:00
2569718930@qq.com fde69123cd Merge branch 'codex/signup-trial-reconcile' 2026-05-31 21:25:58 +08:00
2569718930@qq.com 2d963c16f3 Coalesce terminal chart detail loads 2026-05-31 21:17:38 +08:00
2569718930@qq.com 071d749ef0 Reduce terminal detail refresh traffic 2026-05-31 21:01:11 +08:00
2569718930@qq.com 8d552a9279 Scale SSE replay window by visible cities 2026-05-31 20:33:04 +08:00
2569718930@qq.com 9c5a08dc1e Reduce false signup trial risk alerts 2026-05-31 20:21:57 +08:00
2569718930@qq.com ada5f274d3 Debounce terminal SSE subscription reconnects 2026-05-31 20:15:39 +08:00
2569718930@qq.com 78ea0326a5 Return partial city detail batches 2026-05-31 19:51:28 +08:00
2569718930@qq.com d3f444dbf6 Bind PolyWeather services to loopback 2026-05-31 19:46:00 +08:00
2569718930@qq.com 8d26afdec0 Reduce terminal auth and analytics stalls 2026-05-31 19:21:53 +08:00
2569718930@qq.com 5083b7c433 Instrument API timing and reduce detail fallbacks 2026-05-31 18:58:57 +08:00
182 changed files with 16511 additions and 1834 deletions
+38 -38
View File
@@ -73,9 +73,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
@@ -83,40 +80,41 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend
if: matrix.image.name == 'backend'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.file }}
push: true
tags: |
${{ matrix.image.tag }}:latest
${{ matrix.image.tag }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push image
env:
IMAGE_NAME: ${{ matrix.image.name }}
IMAGE_CONTEXT: ${{ matrix.image.context }}
IMAGE_FILE: ${{ matrix.image.file }}
IMAGE_TAG: ${{ matrix.image.tag }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
run: |
set -euo pipefail
- name: Build and push frontend
if: matrix.image.name == 'frontend'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.file }}
push: true
tags: |
${{ matrix.image.tag }}:latest
${{ matrix.image.tag }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
build_args=()
if [ "${IMAGE_NAME}" = "frontend" ]; then
build_args=(
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
)
fi
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
docker push "${IMAGE_TAG}:latest"
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
deploy:
needs: [build-and-push]
@@ -130,11 +128,13 @@ jobs:
uses: actions/checkout@v4
- name: Deploy to VPS
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ github.sha }}'
"
+78
View File
@@ -0,0 +1,78 @@
# PolyWeather Agent Instructions
## 语言和沟通
- 默认用中文回复用户。
- 直接说明正在做什么、查到了什么、下一步是什么;不要写空泛客套话。
- 如果用户要求“提交推送”“部署”“看日志”,必须在本地验证后再提交、推送,并检查 GitHub Actions 或线上状态。
- 不要把多个不相关任务混在一个结论里;遇到新方向时,建议用户在同一个 Project 下开新 Thread。
## 项目和线程使用
- 一个 Project 对应 PolyWeather 这个共享代码库和长期方向。
- 每个具体任务使用一条独立 Thread / Chat,例如:
- 落地页与产品包装
- 观测数据采集与 SSE patch
- Telegram 推送
- 付款与会员
- 部署、CI、服务器状态
- 同一个 Project 下的 Thread 共享文件夹和 `AGENTS.md`,但上下文分开,避免旧问题影响新任务判断。
## 代码工作原则
- 先读现有代码和配置,再改动;优先沿用项目已有模式。
- 使用 `rg` / `rg --files` 查找文件和文本。
- 手动编辑文件使用 `apply_patch`
- 不要回滚用户或其他 agent 已经做过的无关改动。
- 只改和当前任务直接相关的文件,避免顺手重构。
- 新增复杂逻辑时补充聚焦测试;窄改动保持验证范围匹配风险。
## 前端约定
- 前端位于 `frontend/`,使用 Next.js、React、TypeScript。
- UI 修改必须关注移动端响应式、文本不重叠、按钮和标签不溢出。
- 图表、终端、详情面板等工作界面应保持信息密度和可扫描性,避免营销式装饰。
- 常用验证:
- `cd frontend && npm run test:business`
- `cd frontend && npm run typecheck`
- 必要时启动本地预览并用浏览器检查桌面和移动视口;检查完关闭本地端口。
## 后端和数据约定
- Python 代码主要位于 `src/``web/``tests/`
- 观测数据刷新应以数据源原生频率为准,避免 Web、collector、Telegram 同时强刷同一外部源。
- Telegram 默认只读最新缓存/DB;除非完全没有缓存,才允许兜底刷新。
- 对外部源调用要考虑 singleflight、冷却、缓存和失败降级,避免 502/408 或 Supabase/磁盘 IO 压力。
- 常用验证:
- `python -m ruff check .`
- `python -m pytest`
## CI、提交和部署
- `main` push 会触发 `.github/workflows/ci.yml`
- `python-quality`
- `frontend-quality`
- `build-and-push`
- `deploy`
- 提交前至少运行和改动相关的验证;推送前确认 `git status --short`
- 推送后检查 GitHub Actions 最新 run;如果失败,先定位失败 job 和 step,再修改。
- 线上 smoke check 优先检查:
- `https://api.polyweather.top/healthz`
- `https://polyweather.top/`
- 相关页面或 API 路径
## 产品方向备忘
- PolyWeather 当前重点不是售卖 API。
- 核心差异化是结算源优先、实时观测源、跑道/城市细粒度温度、SSE patch、Telegram 缓存读取和面向交易/预测市场的解释能力。
- 公开包装、教育内容、图表变量完整度和付费分层可以加强,但不要把产品定位改成通用天气 API。
## Memory 说明
- `AGENTS.md` 是项目内显式规则,跟随仓库和 Project。
- Memory 是用户账号级偏好设置,agent 不能代替用户开启。
- 建议在 Codex / ChatGPT 设置中开启 Memory,并保存长期偏好,例如:
- PolyWeather 项目默认中文回复。
- 每个任务开独立 Thread。
- 修改后优先验证、提交、推送并检查部署状态。
- 不要主动把 PolyWeather 包装成 API 售卖产品。
+16 -13
View File
@@ -5,13 +5,13 @@ Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather.top](https://polyweather.top/)
中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
## Product Screenshots
### Realtime Terminal
![PolyWeather realtime terminal](frontend/public/static/web.png)
![PolyWeather realtime terminal](frontend/public/static/web.webp)
### Telegram Runway Alerts
@@ -21,22 +21,21 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
[![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
## Product Status (2026-05-28)
## Product Status (2026-06-07)
- Subscription live: `Pro Monthly 10 USDC`.
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
- Legacy Gaussian probability is rendered as horizontal probability bands and a `mu` reference line on the chart, rather than as a fake time-series curve.
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
@@ -44,10 +43,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
- Terminal city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
- The card label “model-market difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
@@ -73,9 +72,11 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
- Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
- Reuses one analysis core across web dashboard and Telegram bot.
- Adds payment audit trails, replay tooling, and incident visibility in ops.
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
@@ -131,6 +132,8 @@ npm run dev
## Recent Highlights
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
@@ -240,4 +243,4 @@ Production payment routes are configured by the backend. Polygon remains the def
## Version
- Version: `v1.8.1`
- Last Updated: `2026-05-28`
- Last Updated: `2026-06-07`
+17 -10
View File
@@ -8,22 +8,22 @@
### 实时终端
![PolyWeather 实时终端](frontend/public/static/web.png)
![PolyWeather 实时终端](frontend/public/static/web.webp)
### Telegram 跑道推送
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-30
## 当前产品状态(2026-06-07
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认。
- 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
@@ -31,7 +31,7 @@
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
- DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
@@ -49,10 +49,10 @@
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
@@ -73,9 +73,11 @@
- DEBDynamic Error Balancing)融合多模型最高温。
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
- 地图城市决策把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示
- 天气决策把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
## 参考架构
@@ -126,6 +128,11 @@ npm ci
npm run dev
```
## 近期更新
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
## 运行数据目录(VPS 推荐)
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
@@ -242,7 +249,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- 模型栈与 DEB[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- DEB 模型家族去重规则[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
- 发布流程:[RELEASE.md](RELEASE.md)
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
@@ -250,4 +257,4 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
## 当前版本
- 版本:`v1.8.1`
- 文档最后更新:`2026-05-28`
- 文档最后更新:`2026-06-07`
+2 -2
View File
@@ -19,8 +19,8 @@ cities:
- id: new_york
city: New York
country: USA
latitude: 40.7128
longitude: -74.006
latitude: 40.7769
longitude: -73.874
- id: chicago
city: Chicago
country: USA
+107 -10
View File
@@ -1,12 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
GHCR_PAT="$1"
NEW_TAG="${2:-latest}"
NEW_TAG="${1:-latest}"
TAG_FILE="/var/lib/polyweather/.current_tag"
COMPOSE_DIR="/root/PolyWeather"
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
GHCR_PAT=""
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
echo "❌ GHCR token must be provided on stdin"
exit 1
fi
if [ -z "$GHCR_PAT" ]; then
echo "❌ GHCR token must be provided on stdin"
exit 1
fi
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
@@ -14,7 +23,8 @@ if ! flock -n 9; then
exit 1
fi
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
unset GHCR_PAT
cd "$COMPOSE_DIR"
git fetch origin main && git reset --hard origin/main
@@ -30,14 +40,40 @@ rollback_to_previous() {
echo "Rolling back to $PREVIOUS_TAG..."
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
docker compose up -d
compose_up_retry "rollback" -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
}
compose_up_retry() {
local name="$1"
shift
local output=""
for attempt in $(seq 1 6); do
if output=$(docker compose up "$@" 2>&1); then
echo "$output"
return 0
fi
echo "$output"
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
sleep 5
continue
fi
return 1
done
echo "❌ docker compose up failed for ${name} after retries"
return 1
}
export IMAGE_TAG="$NEW_TAG"
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
pull_ok=0
for pull_attempt in $(seq 1 6); do
docker compose pull && pull_ok=1 && break
@@ -116,11 +152,64 @@ warm_public_route() {
return 0
}
read_env_file_value() {
local key="$1"
if [ ! -f ".env" ]; then
return 0
fi
awk -F= -v key="$key" '
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
value=$0
sub("^[^=]*=", "", value)
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
gsub(/^["'"'"']|["'"'"']$/, "", value)
print value
}
' .env | tail -n 1
}
validate_frontend_api_base_url() {
local api_base="${POLYWEATHER_API_BASE_URL:-}"
if [ -z "$api_base" ]; then
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
fi
local normalized
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
case "$normalized" in
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
exit 1
;;
esac
}
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
run_public_smoke_checks() {
local phase="${1:-initial}"
local failed=0
if [ "$phase" = "recheck" ]; then
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
else
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
fi
return "$failed"
}
validate_frontend_api_base_url
echo "Updating Redis dependency..."
docker compose up -d polyweather_redis
compose_up_retry "redis" -d polyweather_redis
echo "Updating backend services..."
docker compose up -d --no-deps polyweather_web polyweather
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
echo "Waiting for backend..."
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
@@ -131,8 +220,11 @@ if [ "$FAILED_BACKEND" = "1" ]; then
exit 1
fi
echo "Updating observation collector..."
compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating frontend..."
docker compose up -d --no-deps polyweather_frontend
compose_up_retry "frontend" -d --no-deps polyweather_frontend
echo "Waiting for frontend..."
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
@@ -150,9 +242,14 @@ warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
run_public_smoke_checks || FAILED=1
if [ "$FAILED" = "1" ]; then
echo "⚠️ Initial public smoke failed; retrying before rollback..."
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
FAILED=0
run_public_smoke_checks "recheck" || FAILED=1
fi
if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..."
+76 -3
View File
@@ -28,6 +28,11 @@ services:
env_file: &id001
- .env
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: bot
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
healthcheck:
@@ -66,7 +71,7 @@ services:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
@@ -79,7 +84,7 @@ services:
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports:
- 3001:3000
- "127.0.0.1:3001:3000"
restart: unless-stopped
polyweather_web:
command: python web/app.py
@@ -93,6 +98,23 @@ services:
max-file: "3"
container_name: polyweather_web
env_file: *id001
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-3000}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: web
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
healthcheck:
interval: 30s
retries: 3
@@ -104,7 +126,58 @@ services:
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
ports:
- 8000:8000
- "127.0.0.1:8000:8000"
restart: unless-stopped
ulimits:
nofile:
soft: 65535
hard: 65535
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_collector:
command: python -m web.observation_collector_worker
container_name: polyweather_collector
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
env_file: *id001
environment:
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: collector
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_COLLECTOR_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_COLLECTOR_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped
user: ${UID:-1000}:${GID:-1000}
volumes:
+19 -10
View File
@@ -11,13 +11,13 @@
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
@@ -56,11 +56,20 @@
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
## 推送机制
## 独立观测采集器
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
## Telegram 推送机制
- 每城按原生频率独立推送,不捆绑
- 首尔/釜山 60s,其余 600s
- 首尔/釜山 60s中国 AMSC 城市 180s其余 600s
- 循环轮询 60s 以匹配最快频率
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
- 确认过峰值后自动停止
@@ -69,7 +78,7 @@
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
### 1. 数据推送链路 (Data Pipeline)
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
1. **Observation Collector 采集端触发**`web.observation_collector_service` 按源频率调用采集层;`weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
2. **标准化事件**`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`
3. **事件存储**:生产环境写入 Redis Stream`stream:city_observation`)并生成全局递增 `revision`SQLite `observation_patch_events` 保留为本地/兜底 replay。
4. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
+9 -8
View File
@@ -13,13 +13,13 @@
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
### Tier 2 — 5 分钟高频 (MADIS)
@@ -123,7 +123,8 @@ ankara, istanbul, helsinki, amsterdam, paris
频率取决于源头:
- AMSC / AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
- MADIS:源头约 5 分钟。
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
@@ -0,0 +1,345 @@
# Feedback Reward Account Detail Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
---
## File Structure
- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
---
### Task 1: Backend Feedback Reward Metadata
**Files:**
- Modify: `tests/test_user_feedback.py`
- Modify: `src/database/db_manager.py`
- [ ] **Step 1: Write the failing backend test**
Add this test to `tests/test_user_feedback.py`:
```python
def test_user_feedback_reward_metadata_round_trip(tmp_path):
db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
created = db.append_user_feedback(
category="data",
message="Hong Kong COWIN reading was stale.",
user_id="user-reward",
user_email="reward@example.com",
)
assert created["reward_points"] == 0
assert created["reward_reason"] == ""
assert created["reward_status"] == ""
assert created["rewarded_at"] is None
rewarded = db.update_user_feedback_reward(
created["id"],
points=300,
reason="Valid data freshness report",
status="granted",
)
assert rewarded is not None
assert rewarded["reward_points"] == 300
assert rewarded["reward_reason"] == "Valid data freshness report"
assert rewarded["reward_status"] == "granted"
assert rewarded["rewarded_at"]
row = db.list_user_feedback(
limit=10,
user_id="user-reward",
user_email="reward@example.com",
)[0]
assert row["id"] == created["id"]
assert row["reward_points"] == 300
assert row["reward_reason"] == "Valid data freshness report"
assert row["reward_status"] == "granted"
assert row["rewarded_at"] == rewarded["rewarded_at"]
```
- [ ] **Step 2: Run the backend test and verify it fails**
Run:
```powershell
python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
```
Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
- [ ] **Step 3: Add reward columns and serialization**
In `src/database/db_manager.py`, update `user_feedback` schema and migration:
```python
reward_points INTEGER DEFAULT 0,
reward_reason TEXT DEFAULT '',
rewarded_at TIMESTAMP,
reward_status TEXT DEFAULT ''
```
Add `_ensure_column()` calls for the same columns in the existing migration block.
Update every feedback `SELECT` to include:
```sql
reward_points, reward_reason, rewarded_at, reward_status
```
Update `_feedback_row_to_dict()` to return:
```python
"reward_points": max(0, int(row["reward_points"] or 0)),
"reward_reason": str(row["reward_reason"] or ""),
"rewarded_at": row["rewarded_at"],
"reward_status": str(row["reward_status"] or ""),
```
- [ ] **Step 4: Add reward update method**
Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
```python
def update_user_feedback_reward(
self,
feedback_id: int,
*,
points: int,
reason: str = "",
status: str = "granted",
) -> Optional[Dict[str, Any]]:
safe_points = max(0, int(points or 0))
normalized_reason = str(reason or "").strip()[:500]
normalized_status = str(status or "").strip().lower()[:40]
if not normalized_status:
normalized_status = "granted" if safe_points > 0 else "skipped"
now = datetime.now().isoformat()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
conn.execute(
"""
UPDATE user_feedback
SET reward_points = ?,
reward_reason = ?,
reward_status = ?,
rewarded_at = ?,
updated_at = ?
WHERE id = ?
""",
(
safe_points,
normalized_reason,
normalized_status,
now,
now,
int(feedback_id),
),
)
row = conn.execute(
"""
SELECT id, category, message, source, status, contact, user_id,
user_email, context_json, reward_points, reward_reason,
rewarded_at, reward_status, created_at, updated_at
FROM user_feedback
WHERE id = ?
""",
(int(feedback_id),),
).fetchone()
conn.commit()
return self._feedback_row_to_dict(row) if row else None
```
- [ ] **Step 5: Run backend tests**
Run:
```powershell
python -m pytest tests/test_user_feedback.py -q
```
Expected: all `test_user_feedback.py` tests pass.
---
### Task 2: Account Feedback Reward Display
**Files:**
- Modify: `frontend/types/ops.ts`
- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
- [ ] **Step 1: Write the failing frontend test**
Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
```ts
assert(
feedbackPanelSource.includes("reward_points") &&
feedbackPanelSource.includes("reward_reason") &&
feedbackPanelSource.includes("reward_status") &&
feedbackPanelSource.includes("formatRewardPoints") &&
feedbackPanelSource.includes("renderFeedbackReward") &&
feedbackPanelSource.includes("奖励原因"),
"account feedback panel must show per-feedback reward points and reward reasons",
);
```
- [ ] **Step 2: Run the frontend business test and verify it fails**
Run:
```powershell
cd frontend; npm run test:business -- accountFeedbackPanel
```
Expected: failure because the panel does not yet reference the reward fields.
- [ ] **Step 3: Extend frontend type**
Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
```ts
reward_points?: number;
reward_reason?: string;
rewarded_at?: string | null;
reward_status?: string;
```
- [ ] **Step 4: Add account reward rendering helpers**
In `AccountFeedbackPanel.tsx`, add helpers:
```tsx
function formatRewardPoints(points?: number) {
const value = Math.max(0, Number(points || 0));
return `+${value.toLocaleString()} points`;
}
function rewardStatusText(status?: string, isEn = false) {
const key = String(status || "").toLowerCase();
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
return isEn ? "Feedback reward" : "反馈奖励";
}
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
const points = Math.max(0, Number(entry.reward_points || 0));
const rewardStatus = String(entry.reward_status || "").toLowerCase();
const reason = String(entry.reward_reason || "").trim();
if (points <= 0 && !rewardStatus && !reason) return null;
const granted = points > 0 || rewardStatus === "granted";
return (
<div className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
granted
? "border-amber-200 bg-amber-50 text-amber-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
{points > 0 ? <span className="font-mono font-black">{formatRewardPoints(points)}</span> : null}
</div>
{reason ? (
<div className="mt-1 leading-5 text-slate-600">
{isEn ? "Reason" : "奖励原因"}: {reason}
</div>
) : null}
{entry.rewarded_at ? (
<div className="mt-1 font-mono text-[11px] text-slate-400">
{compactDate(entry.rewarded_at)}
</div>
) : null}
</div>
);
}
```
- [ ] **Step 5: Render reward detail in each feedback row**
Inside each feedback row, after the feedback message paragraph, add:
```tsx
{renderFeedbackReward(entry, isEn)}
```
- [ ] **Step 6: Run frontend business test**
Run:
```powershell
cd frontend; npm run test:business -- accountFeedbackPanel
```
Expected: `accountFeedbackPanel` passes.
---
### Task 3: Verification and Commit
**Files:**
- Verify all touched files.
- [ ] **Step 1: Run targeted backend verification**
Run:
```powershell
python -m pytest tests/test_user_feedback.py -q
```
Expected: all tests pass.
- [ ] **Step 2: Run targeted frontend verification**
Run:
```powershell
cd frontend; npm run test:business -- accountFeedbackPanel
```
Expected: `accountFeedbackPanel` test passes.
- [ ] **Step 3: Run broader low-cost verification**
Run:
```powershell
python -m ruff check .
cd frontend; npm run typecheck
```
Expected: both commands pass.
- [ ] **Step 4: Check status and commit**
Run:
```powershell
git status --short
git diff --check
git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
git commit -m "Show feedback reward details in account"
```
Expected: commit succeeds with only planned files staged.
---
## Self-Review
- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
- Scope control: the plan does not implement automatic reward issuance or external notifications.
- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
@@ -0,0 +1,124 @@
# Feedback Reward Account Detail Design
## Goal
Show users which submitted feedback earned points, how many points were awarded, and why.
The first implementation focuses on account-page visibility. The later ops workflow for automatic reward issuance should reuse the same fields and display contract.
## Current Context
- `user_feedback` already stores each submitted feedback item with `status`, `message`, `user_id`, `user_email`, timestamps, and context JSON.
- `/api/feedback` already returns the current user's feedback list.
- `AccountFeedbackPanel` already renders the current user's feedback in the account page.
- Current points balance is shown through `/api/auth/me`, but account UI has no per-feedback reward source detail.
- Existing points grant helpers update `users.points`; feedback-specific rewards are not yet linked back to a feedback row.
## Scope
In scope:
- Add feedback reward metadata to feedback rows.
- Return reward metadata through the existing current-user feedback API.
- Display feedback reward details in the account page next to the matching feedback item.
- Keep the data contract ready for future ops automation: a processed feedback item can carry reward points and a human-readable reason.
Out of scope for this phase:
- Building the ops action that decides and grants points.
- Sending push/email/Telegram notifications after ops processing.
- Rebuilding a general points ledger for all point sources.
- Merging historical Supabase referral ledger entries into the account page.
## Data Model
Extend `user_feedback` with nullable columns:
- `reward_points INTEGER DEFAULT 0`
- `reward_reason TEXT DEFAULT ''`
- `rewarded_at TIMESTAMP`
- `reward_status TEXT DEFAULT ''`
Recommended `reward_status` values:
- empty string: no reward decision recorded
- `granted`: points were awarded for this feedback
- `skipped`: reviewed but no points awarded
- `pending`: reward decision is queued or awaiting processing
The display should treat `reward_points > 0` as the strongest signal that the user earned points.
## API Contract
`DBManager._feedback_row_to_dict()` should include:
- `reward_points`
- `reward_reason`
- `rewarded_at`
- `reward_status`
`GET /api/feedback` should continue using the same response shape:
```json
{
"feedback": [
{
"id": 123,
"status": "resolved",
"message": "Hong Kong COWIN reading looked stale",
"reward_points": 300,
"reward_reason": "Valid data freshness report",
"rewarded_at": "2026-06-08T12:00:00",
"reward_status": "granted"
}
]
}
```
The frontend proxy does not need a new route if it transparently forwards the existing payload.
## Account UI
`AccountFeedbackPanel` should show reward detail inside each feedback row:
- Granted reward: `+300 points` plus reward reason.
- Skipped reward: a muted note that no points were awarded, with reason if available.
- Pending reward: a small pending label.
- No reward metadata: render nothing extra to avoid noise.
The account page should remain dense and scannable. Reward content belongs inside the existing feedback row, not in a separate marketing-style card.
## Future Ops Workflow
The later feedback-processing feature can use a single backend operation:
1. Update feedback status.
2. Grant points to the matching user.
3. Write `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status = granted` back to the same feedback row.
If the grant fails, the operation should not mark the feedback as granted. It should either keep `reward_status = pending` or return a clear ops error.
## Error Handling
- Missing reward fields should default to zero or empty strings in API responses.
- If old SQLite databases do not have the new columns, `DBManager` migration should add them with `_ensure_column`.
- Account UI should tolerate partial payloads without crashing.
- Reward reason should be bounded in storage and display to avoid oversized rows.
## Testing
Backend tests:
- Existing feedback rows serialize reward fields with defaults.
- Updating reward metadata for a feedback row returns the fields in `/api/feedback`.
- Old rows without rewards still list normally.
Frontend tests:
- Account feedback panel displays `+N points` and reason for rewarded feedback.
- Skipped and pending reward states render without implying points were granted.
- Rows without reward metadata do not show an empty reward block.
## Rollout
This is a backward-compatible schema and UI change. Existing feedback rows keep working with empty reward metadata. The account page can ship before the ops reward action; it will simply show reward details once rows carry those fields.
+11 -22
View File
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
- Next.js App Router
- React + Tailwind
- Leaflet + Recharts
- 自研温度图表 + Recharts 运营图表
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
@@ -21,30 +21,19 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 今日日内分析支持:
- `锚点状态`
- `当前节奏`
- `校准模型概率`
- `模型区间与分歧`
- `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
- `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss
@@ -191,8 +180,8 @@ Ops
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=500`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 RedisRedis Stream / SQLite event log 都由后端统一封装
## Vercel 节流建议
+80 -24
View File
@@ -7,25 +7,46 @@ import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
250,
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
);
export async function POST(req: NextRequest) {
const timer = createProxyTimer(req, "analytics_events");
if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
new NextResponse(null, { status: 204 }),
timer,
"disabled",
);
}
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
const body = await timer.measure("request_read", () => req.json());
const payload =
body && typeof body.payload === "object" && body.payload != null
? body.payload
@@ -42,30 +63,65 @@ export async function POST(req: NextRequest) {
referer_header: req.headers.get("referer") || "",
},
};
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(enrichedBody),
cache: "no-store",
});
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(enrichedBody),
cache: "no-store",
signal: controller.signal,
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await res.json();
const data = await timer.measure("backend_read", () => res.json());
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const timedOut = controller.signal.aborted;
const response = timedOut
? NextResponse.json(
{
ok: false,
accepted: true,
dropped: true,
reason: "timeout",
},
{ status: 202 },
)
: buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return finishProxyTimedResponse(
withCookies,
timer,
timedOut ? "timeout_accepted" : "exception",
);
} finally {
clearTimeout(timeoutId);
}
}
+10 -1
View File
@@ -92,6 +92,14 @@ function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
.join(", ");
}
function buildBackendAuthMeUrl(req: NextRequest) {
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
url.searchParams.set("scope", "entitlement");
}
return url.toString();
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
@@ -101,6 +109,7 @@ function finishAuthMeResponse(
const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set("Cache-Control", "no-store");
response.headers.set(
"Server-Timing",
backendServerTiming
@@ -486,7 +495,7 @@ export async function GET(req: NextRequest) {
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(`${API_BASE}/api/auth/me`, {
await fetch(buildBackendAuthMeUrl(req), {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
+60 -7
View File
@@ -1,28 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "12000",
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
);
function parseRequestedCities(req: NextRequest) {
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
const limit = Number.isFinite(requestedLimit)
? Math.max(1, Math.min(24, requestedLimit))
: 12;
const seen = new Set<string>();
const requestedCities: string[] = [];
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
",",
)) {
const city = item.trim();
if (!city || seen.has(city)) continue;
seen.add(city);
requestedCities.push(city);
if (requestedCities.length >= limit) break;
}
return requestedCities;
}
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
return {
cities: requestedCities,
details: {},
errors: {},
missing: requestedCities,
partial: true,
timeout: true,
};
}
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const requestedCities = parseRequestedCities(req);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12",
});
for (const key of ["market_slug", "target_date", "resolution"]) {
for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value);
}
@@ -33,12 +75,23 @@ export async function GET(req: NextRequest) {
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
fetchCache:
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
cacheControlForData: (data) =>
data &&
typeof data === "object" &&
(data as { partial?: unknown }).partial === true
? "no-store, max-age=0"
: cachePolicy.responseCacheControl,
fetchCache: "no-store",
publicMessage: "Failed to fetch city detail batch",
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutResponse: () =>
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
headers: { "Cache-Control": "no-store, max-age=0" },
status: 200,
}),
timeoutPublicMessage: "City detail batch request timed out",
timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
});
} finally {
+38 -16
View File
@@ -9,6 +9,10 @@ import {
} from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,15 +38,16 @@ export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
const timer = createProxyTimer(req, "city_detail");
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return finishProxyTimedResponse(response, timer, "missing_api_base");
}
const { name } = await context.params;
const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const depth = req.nextUrl.searchParams.get("depth");
@@ -67,31 +72,48 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
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 } }),
});
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const res = await timer.measure("backend_fetch", () =>
fetch(url, {
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = normalizeCityDetailPayload(await res.json());
const data = normalizeCityDetailPayload(
await timer.measure("backend_read", () => res.json()),
);
const response = buildCachedJsonResponse(
req,
data,
cachePolicy.responseCacheControl,
);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate",
});
return response;
return finishProxyTimedResponse(response, timer, "exception");
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/feedback`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) upstream.searchParams.set("limit", limit);
const res = await fetch(upstream, {
method: "GET",
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback status request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch feedback status",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/feedback`, {
method: "POST",
headers,
body: JSON.stringify(body),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to submit feedback",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
{
method: "POST",
cache: "no-store",
headers: {
...Object.fromEntries(new Headers(auth.headers).entries()),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to grant feedback reward",
});
}
}
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
{
method: "POST",
cache: "no-store",
headers: {
...Object.fromEntries(new Headers(auth.headers).entries()),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to update feedback status",
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/feedback`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops feedback",
});
}
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/observation-collector-status`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Observation collector status check failed",
});
}
}
+42 -15
View File
@@ -5,27 +5,45 @@ import {
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "ops_online_users");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req),
);
const authError = timer.measureSync("ops_auth", () =>
requireOpsProxyAuth(req, auth),
);
if (authError) {
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
}
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
const raw = await timer.measure("backend_read", () => res.text());
const response = new NextResponse(raw, {
status: res.status,
headers: {
@@ -33,10 +51,19 @@ export async function GET(req: NextRequest) {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
res.ok ? "ok" : `upstream_${res.status}`,
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
});
return finishProxyTimedResponse(
buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
}),
timer,
"exception",
);
}
}
@@ -11,6 +11,35 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function submitErrorMessage(raw: string) {
try {
const parsed = JSON.parse(String(raw || "")) as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const message = [parsed.detail, parsed.error, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof message === "string") {
const trimmed = message.trim();
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
}
} catch {
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
}
return "Payment submit upstream failed";
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
@@ -40,14 +69,9 @@ export async function POST(
);
if (!res.ok) {
const raw = await res.text();
let detail = raw.slice(0, 350);
try {
const parsed = JSON.parse(raw);
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
} catch {}
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
error: detail || undefined,
error: submitErrorMessage(raw),
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -10,6 +10,42 @@ import {
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND_RETRY_DELAY_MS = 250;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWalletChallengeWithRetry(
url: string,
init: RequestInit,
attempts = 2,
) {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fetch(url, init);
} catch (error) {
lastError = error;
if (attempt >= attempts) break;
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
function logWalletChallengeProxyError(
error: unknown,
authDebug: Record<string, unknown>,
) {
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
console.error("[payment-wallet-challenge-proxy-exception]", {
error_name: String(source?.name || "Error"),
error_message: String(source?.message || error || "unknown"),
error_cause: source?.cause ? String(source.cause) : "",
...authDebug,
});
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
{ status: 500 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid wallet challenge request" },
{ status: 400 },
);
}
let authDebug: Record<string, unknown> = {};
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
authDebug = {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
};
const res = await fetchWalletChallengeWithRetry(
`${API_BASE}/api/payments/wallets/challenge`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
extraDebug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
extraDebug: authDebug,
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
logWalletChallengeProxyError(error, authDebug);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create wallet challenge",
status: 502,
publicMessage:
"Wallet challenge service is temporarily unavailable. Please retry.",
extra: { retryable: true },
});
}
}
+14 -4
View File
@@ -2,19 +2,28 @@ import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "40000",
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
);
export const maxDuration = 45;
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "scan_terminal");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
@@ -61,6 +70,7 @@ export async function GET(req: NextRequest) {
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out",
timing: timer,
url,
});
} finally {
+51 -1
View File
@@ -14,6 +14,31 @@ function normalizeNextPath(input: string | null) {
return raw;
}
function normalizeAuthError(input: string | null) {
const fallback = "Authentication failed. Please sign in again.";
const raw = String(input || "")
.replace(/[\r\n]+/g, " ")
.trim();
if (!raw) return fallback;
return raw.slice(0, 240);
}
function redirectToLoginWithError({
baseUrl,
error,
nextPath,
}: {
baseUrl: string;
error: string | null;
nextPath: string;
}) {
const loginUrl = new URL("/auth/login", baseUrl);
loginUrl.searchParams.set("next", nextPath);
loginUrl.searchParams.set("auth_error", "1");
loginUrl.searchParams.set("error", normalizeAuthError(error));
return NextResponse.redirect(loginUrl);
}
async function warmSignupTrial(accessToken: string) {
const token = String(accessToken || "").trim();
if (!API_BASE || !token) return;
@@ -30,7 +55,7 @@ async function warmSignupTrial(accessToken: string) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
try {
await fetch(`${API_BASE}/api/auth/me`, {
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
cache: "no-store",
headers,
signal: controller.signal,
@@ -60,6 +85,16 @@ export async function GET(request: NextRequest) {
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const baseUrl = siteUrl || request.nextUrl.origin;
const redirectUrl = new URL(nextPath, baseUrl);
const callbackError =
request.nextUrl.searchParams.get("error_description") ||
request.nextUrl.searchParams.get("error");
if (callbackError) {
return redirectToLoginWithError({
baseUrl,
error: callbackError,
nextPath,
});
}
if (!hasSupabaseServerEnv()) {
return NextResponse.redirect(redirectUrl);
@@ -71,7 +106,22 @@ export async function GET(request: NextRequest) {
if (code) {
const {
data: { session },
error: exchangeError,
} = await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) {
return redirectToLoginWithError({
baseUrl,
error: exchangeError.message,
nextPath,
});
}
if (!session?.access_token) {
return redirectToLoginWithError({
baseUrl,
error: "Authentication session was not created. Please sign in again.",
nextPath,
});
}
await warmSignupTrial(session?.access_token || "");
}
+15 -2
View File
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string; mode?: string }>;
searchParams?: Promise<{ error?: string; mode?: string; next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
return "login";
}
function normalizeAuthError(input: string | undefined) {
const raw = String(input || "")
.replace(/[\r\n]+/g, " ")
.trim();
if (!raw) return "";
return raw.slice(0, 240);
}
export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
const initialMode = normalizeMode(params.mode);
const initialError = normalizeAuthError(params.error);
return (
<I18nProvider>
<LoginClient nextPath={nextPath} initialMode={initialMode} />
<LoginClient
nextPath={nextPath}
initialMode={initialMode}
initialError={initialError}
/>
</I18nProvider>
);
}
+127
View File
@@ -285,6 +285,133 @@
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
@keyframes landingRise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes landingFloat {
0%,
100% {
translate: 0 0;
}
50% {
translate: 0 -10px;
}
}
@keyframes landingScan {
0% {
transform: translateY(-120%);
opacity: 0;
}
18%,
72% {
opacity: 0.48;
}
100% {
transform: translateY(120%);
opacity: 0;
}
}
@keyframes landingPulseDot {
0%,
100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.22);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 7px rgba(37, 99, 235, 0);
transform: scale(1.08);
}
}
.landing-rise {
opacity: 0;
animation: landingRise 720ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.landing-delay-1 {
animation-delay: 100ms;
}
.landing-delay-2 {
animation-delay: 190ms;
}
.landing-delay-3 {
animation-delay: 280ms;
}
.landing-float {
animation: landingFloat 7s ease-in-out infinite;
}
.landing-float-slow {
animation: landingFloat 9s ease-in-out infinite;
}
.landing-hover-lift {
transition:
transform 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
.landing-hover-lift:hover {
transform: translateY(-4px);
border-color: rgba(148, 163, 184, 0.78);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.1);
}
.landing-screen-glow {
position: relative;
}
.landing-screen-glow::after {
content: "";
pointer-events: none;
position: absolute;
inset: 44px 8px 8px;
border-radius: 6px;
background: linear-gradient(
180deg,
transparent 0%,
rgba(37, 99, 235, 0.12) 48%,
transparent 100%
);
mix-blend-mode: multiply;
opacity: 0;
animation: landingScan 5.6s ease-in-out infinite;
}
.landing-pulse-dot {
animation: landingPulseDot 2.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.landing-rise,
.landing-float,
.landing-float-slow,
.landing-screen-glow::after,
.landing-pulse-dot {
opacity: 1;
animation: none;
}
.landing-hover-lift,
.landing-hover-lift:hover {
transform: none;
}
}
}
/* ── Reduced motion: disable all animations and transitions ── */
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { requireOpsAdmin } from "@/lib/ops-admin";
import { FeedbackPageClient } from "@/components/ops/feedback/FeedbackPageClient";
export const metadata: Metadata = { title: "用户反馈 — PolyWeather Ops" };
export default async function OpsFeedbackPage() {
await requireOpsAdmin("/ops/feedback");
return <FeedbackPageClient />;
}
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
{
q_zh: "Pro 包含哪些功能?",
q_en: "What features does Pro include?",
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
},
{
q_zh: "当前订阅价格是多少?",
+1 -1
View File
@@ -15,7 +15,7 @@ const ScanTerminalDashboard = dynamic(
export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product",
description:
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
"Paid PolyWeather decision terminal for weather-signal analysis and multi-city chart monitoring.",
};
export default function TerminalPage() {
+22 -1
View File
@@ -52,6 +52,7 @@ import {
WALLETCONNECT_PROJECT_ID,
} from "./constants";
import { InfoRow, PlusIcon } from "./AccountInfoRow";
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
import {
chainIdToDisplayName,
clearStoredPaymentRecovery,
@@ -432,6 +433,14 @@ export function AccountCenter() {
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
const overlayPlanLabel =
selectedPlanCode === "pro_quarterly"
? copy.quarterlyPlan
: copy.monthlyPlan;
const overlayPeriodLabel = isEn
? `/ ${selectedPlanDurationDays} days`
: `/ ${selectedPlanDurationDays}`;
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
@@ -904,6 +913,8 @@ export function AccountCenter() {
<UnlockProOverlay
points={totalPoints}
planPriceUsd={billing.planAmount}
planLabel={overlayPlanLabel}
periodLabel={overlayPeriodLabel}
usePoints={usePoints}
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
billing={{
@@ -921,6 +932,7 @@ export function AccountCenter() {
payBusy={paymentBusy}
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
manualPayLabel="手动转账"
locale={locale}
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
@@ -933,6 +945,13 @@ export function AccountCenter() {
)}
</div>
<AccountFeedbackPanel
isEn={isEn}
title={copy.accountFeedbackTitle}
description={copy.accountFeedbackDescription}
refreshLabel={copy.refresh}
/>
{/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? (
<div
@@ -1108,7 +1127,9 @@ export function AccountCenter() {
>
<div className="flex items-center justify-between gap-2 text-xs font-bold">
<span>
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
{isQuarterly
? copy.quarterlyPlan
: copy.monthlyPlan}
</span>
<span>{plan.amount_usdc} USDC</span>
</div>
@@ -0,0 +1,199 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MessageSquare, RefreshCw } from "lucide-react";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
import {
feedbackStatusLabel,
feedbackStatusTone,
} from "@/components/dashboard/scan-terminal/feedback-status";
function compactDate(value?: string) {
if (!value) return "--";
return value.slice(0, 16).replace("T", " ");
}
function categoryLabel(value?: string, isEn = false) {
const key = String(value || "").toLowerCase();
if (key === "bug") return "Bug";
if (key === "data") return isEn ? "Data" : "数据";
if (key === "idea") return isEn ? "Suggestion" : "建议";
if (key === "payment") return isEn ? "Payment" : "支付";
if (key === "account") return isEn ? "Account" : "账号";
return isEn ? "Other" : "其他";
}
function formatRewardPoints(points?: number, isEn = false) {
const raw = Number(points || 0);
const value = Number.isFinite(raw) ? Math.max(0, raw) : 0;
return isEn
? `+${value.toLocaleString()} points`
: `+${value.toLocaleString()} 积分`;
}
function rewardStatusText(status?: string, isEn = false) {
const key = String(status || "").toLowerCase();
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
return isEn ? "Feedback reward" : "反馈奖励";
}
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
const rawPoints = Number(entry.reward_points || 0);
const points = Number.isFinite(rawPoints) ? Math.max(0, rawPoints) : 0;
const rewardStatus = String(entry.reward_status || "").toLowerCase();
const reason = String(entry.reward_reason || "").trim();
if (points <= 0 && !rewardStatus && !reason) return null;
const granted = points > 0 || rewardStatus === "granted";
return (
<div
className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
granted
? "border-amber-200 bg-amber-50 text-amber-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
{points > 0 ? (
<span className="font-mono font-black">
{formatRewardPoints(points, isEn)}
</span>
) : null}
</div>
{reason ? (
<div className="mt-1 leading-5 text-slate-600">
{isEn ? "Reason" : "奖励原因"}: {reason}
</div>
) : null}
{entry.rewarded_at ? (
<div className="mt-1 font-mono text-[11px] text-slate-400">
{compactDate(entry.rewarded_at)}
</div>
) : null}
</div>
);
}
export function AccountFeedbackPanel({
isEn,
title,
description,
refreshLabel,
}: {
isEn: boolean;
title: string;
description: string;
refreshLabel: string;
}) {
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [available, setAvailable] = useState(true);
const load = useCallback(async (signal?: AbortSignal) => {
if (typeof fetch !== "function") return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/feedback?limit=10", {
cache: "no-store",
headers: { Accept: "application/json" },
signal,
});
if (res.status === 401 || res.status === 403) {
setAvailable(false);
setEntries([]);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = (await res.json()) as UserFeedbackPayload;
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
setAvailable(true);
} catch (err) {
if (signal?.aborted) return;
setError(String(err).slice(0, 140));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
const emptyText = useMemo(() => {
if (loading) return isEn ? "Loading feedback..." : "正在加载反馈...";
return isEn ? "No submitted feedback yet." : "暂无已提交反馈。";
}, [isEn, loading]);
if (!available) return null;
return (
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-12">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="flex items-center gap-2 text-sm font-bold uppercase text-slate-700">
<MessageSquare size={18} className="text-blue-500" />
{title}
</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-600 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
>
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
{refreshLabel}
</button>
</div>
{error && (
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
{isEn ? "Failed to load feedback: " : "反馈加载失败:"}{error}
</div>
)}
{entries.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
{emptyText}
</div>
) : (
<div className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200">
{entries.map((entry) => (
<div key={entry.id} className="grid gap-3 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded border px-2 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
{feedbackStatusLabel(entry.status, isEn)}
</span>
<span className="text-[11px] font-bold text-slate-500">
{categoryLabel(entry.category, isEn)}
</span>
<span className="text-[11px] text-slate-400">
{compactDate(entry.created_at)}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm font-semibold leading-5 text-slate-900">
{entry.message || (isEn ? "Feedback" : "反馈")}
</p>
{renderFeedbackReward(entry, isEn)}
</div>
<div className="text-xs text-slate-500 md:text-right">
<div className="font-mono">{compactDate(entry.updated_at)}</div>
<div className="mt-1 text-[11px] text-slate-400">
{isEn ? "Updated" : "最近更新"}
</div>
</div>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const accountCenterSource = fs.readFileSync(
path.join(accountDir, "AccountCenter.tsx"),
"utf8",
);
const feedbackPanelPath = path.join(accountDir, "AccountFeedbackPanel.tsx");
assert(fs.existsSync(feedbackPanelPath), "account center must ship a My Feedback panel component");
const feedbackPanelSource = fs.readFileSync(feedbackPanelPath, "utf8");
assert(
accountCenterSource.includes('import { AccountFeedbackPanel } from "./AccountFeedbackPanel";') &&
accountCenterSource.includes("<AccountFeedbackPanel") &&
accountCenterSource.includes("accountFeedbackTitle"),
"account center must mount the My Feedback panel with localized account copy",
);
assert(
feedbackPanelSource.includes("/api/feedback?limit=10") &&
feedbackPanelSource.includes("feedbackStatusLabel") &&
feedbackPanelSource.includes("RefreshCw") &&
feedbackPanelSource.includes("useEffect") &&
!feedbackPanelSource.includes("setInterval"),
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
);
assert(
feedbackPanelSource.includes("reward_points") &&
feedbackPanelSource.includes("reward_reason") &&
feedbackPanelSource.includes("reward_status") &&
feedbackPanelSource.includes("formatRewardPoints") &&
feedbackPanelSource.includes("renderFeedbackReward") &&
feedbackPanelSource.includes("奖励原因"),
"account feedback panel must show per-feedback reward points and reward reasons",
);
}
@@ -0,0 +1,67 @@
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
} from "@/lib/auth-snapshot";
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
assert(
buildAuthMePath({ scope: "entitlement" }) ===
"/api/auth/me?scope=entitlement",
"account entitlement probe must use the lightweight auth/me scope",
);
assert(
buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) ===
"/api/auth/me?prefer_snapshot=1&scope=entitlement",
"terminal auth probe must combine snapshot preference with lightweight entitlement scope",
);
const active: AuthSnapshotLike & { referral?: { code?: string } } = {
authenticated: true,
user_id: "user-1",
subscription_active: true,
subscription_plan_code: "pro_monthly",
subscription_expires_at: "2026-07-01T00:00:00Z",
subscription_total_expires_at: "2026-07-01T00:00:00Z",
subscription_queued_days: 0,
points: 120,
referral: { code: "PW-ABC" },
};
const degraded = mergeAccountAuthSnapshot(active, {
authenticated: true,
user_id: "user-1",
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
points: 0,
degraded_auth_profile: true,
});
assert(
degraded.subscription_active === true &&
degraded.subscription_plan_code === "pro_monthly" &&
degraded.points === 120 &&
degraded.referral?.code === "PW-ABC",
"account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded",
);
const inactive = mergeAccountAuthSnapshot(active, {
authenticated: true,
user_id: "user-1",
subscription_active: false,
subscription_plan_code: null,
points: 0,
});
assert(
inactive.subscription_active === false,
"account snapshot merge must still accept a confirmed inactive subscription response",
);
}
@@ -23,6 +23,18 @@ export function runTests() {
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8",
);
const useBilling = fs.readFileSync(
path.join(projectRoot, "components", "account", "useBilling.ts"),
"utf8",
);
const unlockProOverlay = fs.readFileSync(
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
"utf8",
);
const telegramPricing = fs.readFileSync(
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
"utf8",
);
const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"),
"utf8",
@@ -59,11 +71,46 @@ export function runTests() {
!usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only",
);
assert(
useAccountPayment.includes("applyTelegramGroupPricingToPlanList") &&
useAccountPayment.includes("backend?.telegram_pricing") &&
useAccountPayment.includes("isTelegramPrivateGroupPriceEligible") &&
telegramPricing.includes("is_private_group_member") &&
telegramPricing.includes("telegram_private_group_member") &&
!telegramPricing.includes("is_group_member") &&
useAccountPayment.includes('=== "pro_monthly"') &&
useAccountPayment.includes("amount_usdc: telegramAmountUsdc"),
"account payment plan cards must only display the 5 USDC discounted monthly price after verified /bind eligibility",
);
assert(
useBilling.includes("telegramGroupPriceApplies") &&
useBilling.includes("isTelegramPrivateGroupPriceEligible") &&
useBilling.includes("backend?.telegram_pricing") &&
useBilling.includes("!telegramGroupPriceApplies"),
"billing must not let referral first-month pricing override the lower verified monthly price",
);
assert(
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
!accountCopy.includes(["私", "密", "群", "月", "付"].join("")),
"account plan card and checkout overlay should not expose a separate discounted monthly label",
);
assert(
accountCenter.includes("overlayPlanLabel") &&
accountCenter.includes("overlayPeriodLabel") &&
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
unlockProOverlay.includes("planLabel") &&
unlockProOverlay.includes("USDC") &&
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
);
assert(
types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") &&
types.includes("is_private_group_member?: boolean") &&
types.includes("duration_days: number") &&
types.includes("max_discount_usdc_by_plan"),
"account auth and payment types must include referral summary and plan durations",
"account auth and payment types must include referral summary, private Telegram pricing, and plan durations",
);
}
@@ -44,6 +44,10 @@ export function runTests() {
);
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
const walletBindSource = fs.readFileSync(
path.join(projectRoot, "components", "account", "useWalletBind.ts"),
"utf8",
);
const hookPath = path.join(
projectRoot,
"components",
@@ -62,6 +66,10 @@ export function runTests() {
const paymentFlowSource = fs.existsSync(paymentFlowPath)
? fs.readFileSync(paymentFlowPath, "utf8")
: "";
const paymentUtilsSource = fs.readFileSync(
path.join(projectRoot, "components", "account", "payment-utils.ts"),
"utf8",
);
// The receiver validation now lives in the extracted hook file (called
// from createManualPaymentIntent and createIntentAndPay).
@@ -221,4 +229,55 @@ export function runTests() {
`${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`,
);
}
const walletChallengeRouteSource = fs.readFileSync(
path.join(projectRoot, "app/api/payments/wallets/challenge/route.ts"),
"utf8",
);
assert(
walletChallengeRouteSource.includes("fetchWalletChallengeWithRetry"),
"wallet challenge proxy must retry a transient backend connection failure before blocking payment",
);
assert(
walletChallengeRouteSource.includes("Invalid wallet challenge request"),
"wallet challenge proxy must return a client error for malformed JSON instead of a generic payment failure",
);
assert(
walletChallengeRouteSource.includes("retryable: true"),
"wallet challenge proxy exception response must mark transient failures as retryable",
);
assert(
walletBindSource.includes("readPaymentApiErrorMessage"),
"wallet binding errors must show the API error message instead of raw JSON",
);
assert(
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
paymentUtilsSource.includes("支付服务暂时不可用"),
"payment API error parsing must collapse upstream HTML/Cloudflare 50x pages into a user-safe message",
);
assert(
paymentFlowSource.includes("readPaymentApiErrorMessage") &&
!paymentFlowSource.includes("(await submitRes.text()).slice(0, 350)") &&
!paymentFlowSource.includes("(await confirmRes.text()).slice(0, 350)"),
"payment submit/confirm failures must use sanitized API error messages instead of raw response text",
);
const submitRouteSource = fs.readFileSync(
path.join(
projectRoot,
"app",
"api",
"payments",
"intents",
"[intentId]",
"submit",
"route.ts",
),
"utf8",
);
assert(
submitRouteSource.includes("Payment submit upstream failed") &&
!submitRouteSource.includes("error: detail || undefined"),
"payment submit proxy must not copy raw upstream HTML into the public error field",
);
}
@@ -299,9 +299,14 @@ export function runTests() {
authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
authMeRouteSource.indexOf("await fetch(buildBackendAuthMeUrl(req)"),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
);
assert(
authMeRouteSource.includes("function buildBackendAuthMeUrl") &&
authMeRouteSource.includes('url.searchParams.set("scope", "entitlement")'),
"auth profile proxy must forward the lightweight entitlement scope to the backend auth/me endpoint",
);
assert(
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
+8 -4
View File
@@ -15,12 +15,16 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
identityStatus: isEn ? "Identity Status" : "身份状态",
accountFeedbackTitle: isEn ? "My Feedback" : "我的反馈",
accountFeedbackDescription: isEn
? "Recent reports you submitted and their current handling status."
: "你最近提交过的反馈及当前处理状态。",
authMode: isEn ? "Auth Mode" : "鉴权模式",
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
historyFuture: isEn
? "Future-date + Decision Card Analysis"
: "未来日期分析 + 城市决策卡",
? "Future-date + Multi-city Chart Monitoring"
: "未来日期分析 + 城市图表巡检",
smartPush: isEn
? "Cross-platform Smart Weather Push"
: "全平台智能气象查询",
@@ -271,8 +275,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ────────────────────────────────────────
telegramVerifySuccess: isEn
? "Telegram group membership verified. Checkout follows the selected Pro plan."
: "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
? "Telegram group membership verified. Member monthly price {amount} USDC is active."
: "Telegram 群成员验证成功,群友月付价 {amount} USDC 已生效。",
telegramBindClickHint: isEn
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
@@ -68,6 +68,54 @@ export type NormalizedPaymentError = {
userRejected: boolean;
};
export function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function containsCjk(value: string) {
return /[\u3400-\u9fff]/.test(value);
}
function safePaymentServiceUnavailableMessage(fallback: string) {
return containsCjk(fallback)
? "支付服务暂时不可用,请稍后重试;如果已经付款,请保存 Tx Hash 联系管理员。"
: "Payment service is temporarily unavailable. Please retry shortly; if you already paid, keep the Tx Hash and contact support.";
}
export async function readPaymentApiErrorMessage(
response: Response,
fallback = "Request failed",
limit = 300,
) {
const raw = await response.text();
const trimmed = String(raw || "").trim();
if (!trimmed) return fallback;
let message = "";
try {
const parsed = JSON.parse(trimmed) as {
error?: unknown;
detail?: unknown;
message?: unknown;
};
const parsedMessage = [parsed.error, parsed.detail, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof parsedMessage === "string") message = parsedMessage.trim();
} catch {
message = trimmed;
}
const candidate = message || trimmed;
if (looksLikeHtmlDocument(candidate)) {
return safePaymentServiceUnavailableMessage(fallback);
}
return candidate.slice(0, limit);
}
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
const source = error as any;
const code = Number(
@@ -0,0 +1,19 @@
import type { TelegramPricing } from "./types";
export function isTelegramPrivateGroupPriceEligible(
pricing?: TelegramPricing | null,
) {
return Boolean(
pricing?.is_private_group_member ||
pricing?.pricing_source === "telegram_private_group_member",
);
}
export function telegramPrivateGroupAmountUsdc(
pricing?: TelegramPricing | null,
) {
if (!isTelegramPrivateGroupPriceEligible(pricing)) return "";
const amount = String(pricing?.amount_usdc || "").trim();
const numeric = Number(amount);
return Number.isFinite(numeric) && numeric > 0 ? amount : "";
}
+5
View File
@@ -19,6 +19,10 @@ export type AuthMeResponse = {
subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
degraded_auth_profile?: boolean | null;
degraded_reason?: string | null;
entitlement_snapshot?: boolean | null;
entitlement_snapshot_reason?: string | null;
};
export type ReferralSummary = {
@@ -42,6 +46,7 @@ export type TelegramPricing = {
telegram_id?: number | null;
telegram_status?: string | null;
is_group_member?: boolean;
is_private_group_member?: boolean;
amount_usdc?: string;
pricing_source?: string;
};
@@ -1,20 +1,45 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { User } from "@supabase/supabase-js";
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
} from "@/lib/auth-snapshot";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import type {
AuthMeResponse,
BoundWallet,
PaymentConfig,
PaymentPlan,
ProviderMode,
InjectedProviderOption,
TelegramPricing,
} from "./types";
import { usePaymentState } from "./usePaymentState";
import { useWalletBind } from "./useWalletBind";
import { usePaymentFlow } from "./usePaymentFlow";
import { useBilling } from "./useBilling";
import {
isTelegramPrivateGroupPriceEligible,
telegramPrivateGroupAmountUsdc,
} from "./telegram-pricing";
function applyTelegramGroupPricingToPlanList(
plans: PaymentPlan[],
pricing?: TelegramPricing | null,
): PaymentPlan[] {
if (!isTelegramPrivateGroupPriceEligible(pricing)) return plans;
const telegramAmountUsdc = telegramPrivateGroupAmountUsdc(pricing);
if (!telegramAmountUsdc) return plans;
return plans.map((plan) =>
String(plan.plan_code || "").toLowerCase() === "pro_monthly"
? { ...plan, amount_usdc: telegramAmountUsdc }
: plan,
);
}
// ============================================================
export interface UseAccountPaymentParams {
@@ -25,7 +50,7 @@ export interface UseAccountPaymentParams {
backend: AuthMeResponse | null;
user: User | null;
setUser: (user: User | null) => void;
setBackend: (backend: AuthMeResponse | null) => void;
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
setErrorText: (text: string) => void;
setUpdatedAt: (text: string) => void;
showOverlay: boolean;
@@ -155,21 +180,45 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
.then(({ data: { session } }) => session?.user ?? null)
.catch(() => null as User | null)
: Promise.resolve(null as User | null);
const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", { cache: "no-store", headers }),
);
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
const [localUser, authHeaders] = await Promise.all([
userPromise,
buildAuthedHeaders(false),
]);
setUser(localUser);
if (!backendResult.ok) {
if (retry && backendResult.status === 401) {
const readAuthSnapshot = async (
headers: Record<string, string>,
options: Parameters<typeof buildAuthMePath>[0] = {},
) => {
const response = await fetch(buildAuthMePath(options), {
cache: "no-store",
headers,
});
if (!response.ok) {
const raw = (await response.text()).slice(0, 260);
const message = copy.httpError
.replace("{status}", String(response.status))
.replace("{raw}", raw);
const error = new Error(message) as Error & { status?: number };
error.status = response.status;
throw error;
}
return (await response.json()) as AuthMeResponse;
};
let latestHeaders = authHeaders;
let backendJson: AuthMeResponse;
try {
backendJson = await readAuthSnapshot(authHeaders, {
scope: "entitlement",
});
} catch (error) {
if (retry && (error as { status?: number }).status === 401) {
await new Promise((r) => setTimeout(r, 1200));
return fetchAuthSnapshot(false);
}
const raw = (await backendResult.text()).slice(0, 260);
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
throw error;
}
let backendJson = (await backendResult.json()) as AuthMeResponse;
if (
retry &&
supabaseReady &&
@@ -184,23 +233,31 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
refreshedSession?.access_token || "",
).trim();
if (refreshedToken) {
const retriedBackendResult = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${refreshedToken}` },
latestHeaders = { Authorization: `Bearer ${refreshedToken}` };
const retriedBackendJson = await readAuthSnapshot(latestHeaders, {
scope: "entitlement",
});
if (retriedBackendResult.ok) {
const retriedBackendJson =
(await retriedBackendResult.json()) as AuthMeResponse;
backendJson = retriedBackendJson;
}
backendJson = retriedBackendJson;
}
} catch {
// Keep the first response; the UI treats a logged-in local user with
// an unauthenticated backend snapshot as a temporary sync state.
}
}
setBackend(backendJson);
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
setUpdatedAt(new Date().toISOString());
if (backendJson.authenticated !== false) {
void readAuthSnapshot(latestHeaders)
.then((fullJson) => {
setBackend((previous) =>
mergeAccountAuthSnapshot(previous, fullJson),
);
setUpdatedAt(new Date().toISOString());
})
.catch(() => {
// The lightweight entitlement snapshot already resolved the UI.
});
}
return backendJson;
},
[
@@ -350,7 +407,10 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
]);
// ── Selected plan (derived, shared across sub-hooks) ───
const effectivePlanList = paymentConfig?.plans || [];
const effectivePlanList = applyTelegramGroupPricingToPlanList(
paymentConfig?.plans || [],
backend?.telegram_pricing,
);
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
// ── useWalletBind ──────────────────────────────────────
+10 -2
View File
@@ -19,6 +19,7 @@ import {
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { normalizePaymentError } from "./payment-utils";
import { isTelegramPrivateGroupPriceEligible } from "./telegram-pricing";
import { trackAppEvent } from "@/lib/app-analytics";
// ============================================================
@@ -122,6 +123,10 @@ export function useBilling(params: UseBillingParams) {
const listAmount =
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
const telegramGroupPriceApplies = Boolean(
selectedPlanCode === "pro_monthly" &&
isTelegramPrivateGroupPriceEligible(backend?.telegram_pricing),
);
const referral = backend?.referral;
const referralPending = Boolean(
referral?.applied_code ||
@@ -137,6 +142,7 @@ export function useBilling(params: UseBillingParams) {
const referralApplies =
selectedPlanCode === "pro_monthly" &&
referralPending &&
!telegramGroupPriceApplies &&
backend?.subscription_active !== true;
const planAmount = referralApplies
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
@@ -187,6 +193,7 @@ export function useBilling(params: UseBillingParams) {
paymentConfig?.points_redemption,
backend?.referral,
backend?.subscription_active,
backend?.telegram_pricing,
selectedPlan?.plan_code,
selectedPlan?.amount_usdc,
totalPoints,
@@ -399,8 +406,9 @@ export function useBilling(params: UseBillingParams) {
throw new Error(copy.bindFailed.replace("{raw}", raw));
}
const data = (await res.json()) as { telegram_pricing?: TelegramPricing | null };
if (data.telegram_pricing?.is_group_member) {
const amount = data.telegram_pricing.amount_usdc || "10";
const telegramPricing = data.telegram_pricing;
if (isTelegramPrivateGroupPriceEligible(telegramPricing)) {
const amount = telegramPricing?.amount_usdc || "10";
setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount));
}
await loadSnapshot();
+43 -9
View File
@@ -24,9 +24,11 @@ import {
buildBalanceOfCalldata,
formatTokenUnits,
normalizePaymentError,
readPaymentApiErrorMessage,
requestWalletWithTimeout,
} from "./payment-utils";
import { trackAppEvent } from "@/lib/app-analytics";
import { buildAuthMePath } from "@/lib/auth-snapshot";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
@@ -189,7 +191,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const authRes = await fetch("/api/auth/me", {
const authRes = await fetch(buildAuthMePath({ scope: "entitlement" }), {
cache: "no-store",
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
@@ -303,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const headers = authHeaders || (await buildAuthedHeaders(false));
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
if (!configRes.ok) {
const raw = (await configRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
configRes,
isEn ? "Payment config request failed." : "支付配置请求失败。",
350,
);
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
}
const configJson = (await configRes.json()) as PaymentConfig;
@@ -372,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
const raw = await readPaymentApiErrorMessage(
statusRes,
isEn ? "Payment status request failed." : "支付状态请求失败。",
260,
);
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
}
const statusJson = (await statusRes.json()) as IntentStatusResponse;
@@ -504,7 +514,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
createRes,
isEn ? "Payment order request failed." : "支付订单请求失败。",
350,
);
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
}
@@ -583,7 +597,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
submitRes,
isEn ? "Payment submit request failed." : "支付提交请求失败。",
350,
);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
return;
@@ -595,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
confirmRes,
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
350,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
@@ -681,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
createRes,
isEn ? "Manual payment order request failed." : "手动转账订单请求失败。",
350,
);
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
}
@@ -739,7 +765,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
submitRes,
isEn ? "Payment submit request failed." : "支付提交请求失败。",
350,
);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
return;
@@ -750,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
confirmRes,
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
350,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
+9 -5
View File
@@ -16,7 +16,11 @@ import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { shortAddress } from "./formatters";
import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils";
import {
normalizePaymentError,
readPaymentApiErrorMessage,
requestWalletWithTimeout,
} from "./payment-utils";
import {
eip6963Providers,
getEvmProvider,
@@ -300,8 +304,8 @@ export function useWalletBind(params: UseWalletBindParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ address }),
});
if (!challengeRes.ok) {
const raw = (await challengeRes.text()).slice(0, 300);
throw new Error(copy.challengeFailed.replace("{raw}", raw));
const message = await readPaymentApiErrorMessage(challengeRes);
throw new Error(copy.challengeFailed.replace("{raw}", message));
}
const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string };
@@ -314,8 +318,8 @@ export function useWalletBind(params: UseWalletBindParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }),
});
if (!verifyRes.ok) {
const raw = (await verifyRes.text()).slice(0, 300);
throw new Error(copy.verifyFailedRaw.replace("{raw}", raw));
const message = await readPaymentApiErrorMessage(verifyRes);
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
}
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
+187 -208
View File
@@ -7,11 +7,8 @@ import {
ArrowRight,
ChevronLeft,
Chrome,
CloudRain,
CloudSun,
Lock,
Mail,
Sun,
Eye,
EyeOff,
} from "lucide-react";
@@ -26,17 +23,18 @@ type Mode = "login" | "signup";
type LoginClientProps = {
nextPath: string;
initialError?: string;
initialMode?: Mode;
};
export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
const router = useRouter();
const { locale } = useI18n();
const [mode, setMode] = useState<Mode>(initialMode ?? "login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [errorText, setErrorText] = useState(initialError || "");
const [infoText, setInfoText] = useState("");
const [resetSent, setResetSent] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -50,22 +48,25 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
subtitle: isEn
? "Explore weather details from every corner of the world"
: "探索世界每一个角落的气象细节",
loginSubtitle: isEn
? "Sign in to continue to your weather decision terminal."
: "登录后进入你的天气决策终端。",
signupSubtitle: isEn
? "Create an account and get a one-time 3-day trial. No payment required first."
: "创建账号后自动开启一次 3 天试用,无需先付款。",
googleOneClick: isEn
? "Continue with Google"
: "使用 Google 账号一键登录",
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
orGoogle: isEn ? "Or continue with Google" : "或使用 Google",
login: isEn ? "Sign In" : "登录",
signup: isEn ? "Sign Up" : "注册",
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
passwordSignupPlaceholder: isEn
? "Set at least 6 characters"
: "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
loginSubmit: isEn ? "Enter PolyWeather Terminal" : "进入 PolyWeather 终端",
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
signupSubmit: isEn ? "Create account now" : "立即创建账号",
signupSubmit: isEn ? "Create account and start trial" : "创建账号并领取试用",
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
loginHint: isEn
@@ -96,24 +97,37 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
loginFailedHint: isEn
? "If you just signed up, please verify your email first. Check your inbox or spam folder."
: "如果刚注册,请先点击邮箱中的验证链接。检查收件箱或垃圾邮件。",
// New translations for Koyfin-style layouts
workEmail: isEn ? "Work email" : "工作邮箱",
password: isEn ? "Password" : "密码",
welcomeBack: isEn ? "Welcome Back" : "欢迎回来",
signUpTitle: isEn ? "Sign up for your PolyWeather account" : "注册您的 PolyWeather 账",
welcomeBack: isEn ? "Sign in to PolyWeather" : "登录 PolyWeather",
signUpTitle: isEn ? "Create your PolyWeather account" : "创建 PolyWeather 账",
newToPoly: isEn ? "New to PolyWeather?" : "还没有 PolyWeather 账号?",
alreadyHave: isEn ? "Already have an account?" : "已经有账号了?",
termsAgreement: isEn
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
: "继续操作即代表您同意隐私政策与服务条款。",
desc: isEn
? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
? "Use the same terminal palette as the product: live temperature evidence, DEB paths, and settlement-source context in one calm workspace."
: "沿用终端界面的配色和信息密度:实时温度证据、DEB 路径和结算源背景放在一个安静工作台里。",
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
} as const;
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
const googleSubmittingLabel = copy.googleSubmitting;
const formSubtitle = isLogin ? copy.loginSubtitle : copy.signupSubtitle;
const accessHighlights = isEn
? ["Live temperature charts", "DEB forecast path", "Runway and settlement alerts"]
: ["实时温度图表", "DEB 预测路径", "跑道与结算提醒"];
const sideStats = isEn
? [
{ label: "Trial", value: "3 days" },
{ label: "Access", value: "Terminal" },
{ label: "Signals", value: "Runway" },
]
: [
{ label: "试用", value: "3 天" },
{ label: "入口", value: "终端" },
{ label: "提醒", value: "跑道" },
];
const loadingSpinner = (
<span
aria-hidden="true"
@@ -121,6 +135,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
/>
);
useEffect(() => {
setErrorText(initialError || "");
}, [initialError]);
const onResetPassword = async () => {
setErrorText("");
setInfoText("");
@@ -247,167 +265,112 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
};
return (
<div className="flex min-h-screen w-full bg-[#f8fafc] font-sans text-slate-900">
{/* Left Column (Shared Dark Column with Illustrative Widget) */}
<div className="relative hidden lg:flex lg:w-[460px] xl:w-[500px] 2xl:w-[560px] flex-col justify-between bg-gradient-to-br from-[#060913] via-[#0f1527] to-[#040815] p-10 text-white shrink-0 overflow-hidden border-r border-white/5">
{/* Ambient Glows */}
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-blue-600/10 blur-[130px] pointer-events-none" />
<div className="absolute -right-20 -bottom-20 h-[360px] w-[360px] rounded-full bg-purple-500/10 blur-[120px] pointer-events-none" />
<div className="absolute top-1/2 right-0 h-[240px] w-[240px] -translate-y-1/2 rounded-full bg-amber-500/5 blur-[100px] pointer-events-none" />
{/* Grid overlay */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.015)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.015)_1px,transparent_1px)] bg-[size:32px_32px] pointer-events-none" />
<div className="flex min-h-screen w-full bg-[#e9edf3] font-sans text-slate-950">
<aside className="hidden w-[56px] shrink-0 flex-col items-center justify-between bg-[#171d24] py-5 text-white lg:flex">
<Link
href="/"
aria-label="PolyWeather"
className="grid h-9 w-9 place-items-center rounded-lg border border-white/10 bg-white/8 text-[11px] font-black text-sky-200"
>
PW
</Link>
<div className="flex flex-col items-center gap-3">
<span className="h-2 w-2 rounded-full bg-[#00897b]" />
<span className="h-2 w-2 rounded-full bg-[#2563eb]" />
<span className="h-2 w-2 rounded-full bg-slate-500" />
</div>
<Link
href="/"
className="grid h-9 w-9 place-items-center rounded-lg text-slate-400 transition hover:bg-white/8 hover:text-white"
aria-label={copy.backHome}
>
<ChevronLeft size={18} />
</Link>
</aside>
<div className="relative z-10 flex flex-col gap-14">
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity">
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain brightness-0 invert" />
<section className="relative hidden min-h-screen w-[44vw] max-w-[620px] shrink-0 overflow-hidden border-r border-[#d8e0ec] bg-[#171d24] p-6 text-white lg:flex lg:flex-col">
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:34px_34px]" />
<div className="relative z-10 flex items-center justify-between">
<Link
href="/"
className="inline-flex items-center gap-2 text-base font-black tracking-tight text-white transition hover:text-sky-100"
>
<span>PolyWeather</span>
</Link>
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-emerald-200">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
Terminal
</span>
</div>
<div className="relative z-10 mt-12 max-w-md">
<p className="text-xs font-black uppercase tracking-[0.18em] text-sky-200">
{isEn ? "Access Gate" : "终端入口"}
</p>
<h2 className="mt-4 text-3xl font-black leading-tight tracking-tight text-white">
{isEn
? "Sign in where the terminal starts."
: "从这里进入天气决策终端。"}
</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">
{copy.desc}
</p>
</div>
<div className="relative z-10 mt-10 overflow-hidden rounded-xl border border-white/10 bg-[#0b1220] shadow-[0_24px_60px_rgba(0,0,0,0.28)]">
<div className="flex h-11 items-center gap-2 border-b border-white/10 bg-[#111827] px-4">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
<span className="ml-2 font-mono text-[10px] text-slate-400">
polyweather.top/terminal
</span>
</div>
<img
src="/static/web.webp"
width="680"
height="340"
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
className="aspect-[16/9] w-full object-cover object-top"
decoding="async"
/>
</div>
<div className="relative z-10 mt-5 grid grid-cols-3 gap-2">
{sideStats.map((item) => (
<div
key={item.label}
className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-3"
>
<div className="font-mono text-sm font-black text-white">{item.value}</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">{item.label}</div>
</div>
))}
</div>
<div className="relative z-10 mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-[11px] text-slate-400">
<span>{isEn ? "Live charts" : "实时图表"}</span>
<span>{isEn ? "DEB path" : "DEB 路径"}</span>
<span>{isEn ? "Runway alerts" : "跑道提醒"}</span>
</div>
</section>
<div className="relative flex min-h-screen flex-1 flex-col justify-between overflow-hidden bg-[#e9edf3] p-4 sm:p-6 lg:p-8">
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.045)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.035)_1px,transparent_1px)] bg-[size:32px_32px]" />
<div className="relative z-10 flex items-center justify-between gap-3 lg:justify-end">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm font-black tracking-tight text-slate-950 transition-opacity hover:opacity-80 lg:hidden"
>
<span className="grid h-8 w-8 place-items-center rounded-lg border border-slate-300 bg-white text-[10px] font-black text-blue-700 shadow-sm">
PW
</span>
<span className="hidden min-[360px]:inline">PolyWeather</span>
</Link>
<div className="space-y-6">
<h2 className="text-3xl font-black leading-[1.25] tracking-tight text-white animate-fade-up [animation-delay:150ms] opacity-0">
{isEn ? (
<>
Weather intelligence and risk management{" "}
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
simplified.
</span>
</>
) : (
<>
{" "}
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
</span>
</>
)}
</h2>
<p className="text-sm leading-7 text-slate-400 max-w-md animate-fade-up [animation-delay:300ms] opacity-0">
{copy.desc}
</p>
</div>
</div>
{/* High-Fidelity Mock Terminal Preview Widget */}
<div className="relative z-10 my-auto p-[1px] bg-gradient-to-b from-white/15 to-transparent rounded-2xl shadow-2xl overflow-hidden hover:scale-[1.01] hover:shadow-blue-500/10 transition-all duration-500 max-w-[420px] w-full animate-fade-up [animation-delay:450ms] opacity-0">
<div className="bg-[#0b0f19]/80 backdrop-blur-xl rounded-2xl p-6">
{/* Terminal Top Window Controls */}
<div className="flex items-center gap-1.5 mb-5">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
<span className="ml-2 font-mono text-[9px] text-slate-500 tracking-wider">POLYWEATHER_CONSOLE_v1.7</span>
</div>
<div className="flex items-center justify-between border-b border-white/5 pb-3.5 mb-4">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-300">
{isEn ? "Runway 02L Consensus" : "跑道 02L 实测校验"}
</span>
</div>
<span className="font-mono text-[9px] font-black text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 tracking-wider">LIVE</span>
</div>
{/* Terminal metrics grid */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Current Temp" : "当前温度"}</span>
<span className="font-mono text-base font-bold text-white tracking-tight">28.8°C</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Target Threshold" : "监控阈值"}</span>
<span className="font-mono text-base font-bold text-rose-400 tracking-tight">30.0°C</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Model Blend" : "模型融合"}</span>
<span className="font-mono text-base font-bold text-blue-400 tracking-tight">88.5%</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Observed Peak" : "今日最高"}</span>
<span className="font-mono text-base font-bold text-emerald-400 tracking-tight">29.2°C</span>
</div>
</div>
{/* SVG Interactive Line Chart Preview */}
<div className="mb-2">
<div className="flex items-center justify-between text-[9px] text-slate-400 mb-2 font-mono">
<span>{isEn ? "TEMP TREND (24H)" : "气温趋势 (24小时)"}</span>
<span className="text-blue-400 font-bold">Blend vs Obs</span>
</div>
<div className="relative h-28 w-full bg-slate-950/60 rounded-xl p-2 border border-white/5">
<svg className="w-full h-full" viewBox="0 0 340 100" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Grid Lines */}
<line x1="0" y1="20" x2="340" y2="20" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="0" y1="50" x2="340" y2="50" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="0" y1="80" x2="340" y2="80" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="85" y1="0" x2="85" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="170" y1="0" x2="170" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="255" y1="0" x2="255" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
{/* Threshold Line (30.0°C) */}
<line x1="0" y1="40" x2="340" y2="40" stroke="#f43f5e" strokeWidth="1" strokeDasharray="3 3" opacity="0.8" />
<text x="5" y="36" fill="#f43f5e" className="text-[8px] font-mono font-semibold">30.0°C Target</text>
{/* Gradient Area under Forecast */}
<path
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55 L 340 100 L 0 100 Z"
fill="url(#chartGradient)"
opacity="0.15"
/>
{/* Forecast Line (Blue) */}
<path
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55"
stroke="#3b82f6"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Observation Line (Green Solid, ending at current 170px) */}
<path
d="M 0 87 Q 40 78 85 63 T 170 33"
stroke="#10b981"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Current Temp Point */}
<circle cx="170" cy="33" r="4" fill="#10b981" />
<circle cx="170" cy="33" r="8" stroke="#10b981" strokeWidth="1.5" className="animate-ping" opacity="0.5" />
{/* Definitions for Gradients */}
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="relative z-10 text-[10px] text-slate-500 font-mono">
{isEn ? "PolyWeather institutional analytics suite" : "PolyWeather 机构版天气决策系统"}
</div>
</div>
{/* Right Column (Forms) */}
<div className="flex flex-1 flex-col justify-between p-6 sm:p-10 bg-gradient-to-br from-[#f8fafc] via-[#ffffff] to-[#eff4f9] min-h-screen relative overflow-hidden">
{/* Subtle mesh background */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.01)_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none animate-pulse" style={{ animationDuration: '4s' }} />
{/* Top Header Switch */}
<div className="relative z-10 flex justify-between lg:justify-end items-center gap-3">
{/* Logo on top-left for mobile only */}
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity lg:hidden">
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
</Link>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">
<span className="hidden text-xs text-slate-500 sm:inline">
{isLogin ? copy.newToPoly : copy.alreadyHave}
</span>
<button
@@ -417,28 +380,40 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
setInfoText("");
setMode(isLogin ? "signup" : "login");
}}
className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 active:scale-[0.98]"
className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.98]"
>
{isLogin ? copy.signup : copy.login}
</button>
</div>
</div>
{/* Center Form Card */}
<div className="relative z-10 flex flex-1 items-center justify-center my-10">
<div className="w-full max-w-[440px] bg-white/90 backdrop-blur-xl border border-slate-200/50 rounded-2xl p-6 sm:p-10 shadow-[0_24px_60px_rgba(8,16,36,0.06)] animate-fade-up [animation-delay:200ms] opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_32px_80px_rgba(8,16,36,0.08)] duration-500">
<div className="relative z-10 flex flex-1 items-start justify-center pt-10 sm:pt-12 lg:my-10 lg:items-center lg:pt-0">
<div className="w-full max-w-[440px] rounded-xl border border-[#d8e0ec] bg-white p-6 shadow-[0_18px_50px_rgba(15,23,42,0.08)] sm:p-8">
<div className="mb-6">
<h1 className="text-2xl font-black tracking-tight text-slate-900 mb-2">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black uppercase tracking-[0.12em] text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
{isLogin ? copy.loginSubmit : isEn ? "3-day trial" : "3 天试用"}
</div>
<h1 className="mb-2 text-2xl font-black tracking-tight text-slate-950">
{isLogin ? copy.welcomeBack : copy.signUpTitle}
</h1>
<p className="text-xs text-slate-500 leading-relaxed">
{copy.subtitle}
<p className="text-sm leading-6 text-slate-600">
{formSubtitle}
</p>
</div>
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-5">
<div className="space-y-2 animate-fade-up [animation-delay:350ms] opacity-0">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
<div className="mb-6 grid gap-2">
{accessHighlights.map((item) => (
<div key={item} className="flex items-center gap-2 text-xs font-semibold text-slate-600">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600" />
{item}
</div>
))}
</div>
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-4">
<div className="space-y-2">
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
{copy.workEmail}
</label>
<div className="relative">
@@ -449,14 +424,14 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="yourname@email.com"
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-4 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-4 text-sm text-slate-950 placeholder:text-slate-400 transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
/>
</div>
</div>
<div className="space-y-2 animate-fade-up [animation-delay:450ms] opacity-0">
<div className="flex justify-between items-center">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
{copy.password}
</label>
{isLogin && !resetSent ? (
@@ -464,7 +439,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
type="button"
onClick={() => void onResetPassword()}
disabled={loading}
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
className="text-xs font-bold text-blue-600 transition hover:text-blue-700"
>
{copy.reset}
</button>
@@ -479,42 +454,43 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={isLogin ? copy.passwordLoginPlaceholder : copy.passwordSignupPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-11 text-sm text-slate-950 placeholder:text-slate-400 transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-700"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{!isLogin && (
<p className="text-[11px] leading-relaxed text-slate-400">
{!isLogin ? (
<p className="text-[11px] leading-relaxed text-slate-500">
{copy.termsAgreement}
</p>
)}
) : null}
<button
type="submit"
disabled={loading}
aria-busy={loading}
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0"
className="group mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-3.5 text-sm font-black text-white shadow-[0_10px_22px_rgba(37,99,235,0.22)] transition hover:bg-blue-700 active:scale-[0.99] disabled:opacity-50"
>
{loading ? loadingSpinner : null}
<span>{loading ? submittingLabel : (isLogin ? copy.loginSubmit : copy.signupSubmit)}</span>
{!loading && <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />}
{!loading ? <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" /> : null}
</button>
</form>
<div className="my-6 flex items-center">
<div className="h-px flex-grow bg-slate-200/60" />
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-slate-400">
{isEn ? "or" : "或"}
<div className="h-px flex-grow bg-slate-200" />
<span className="px-3 text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">
{copy.orGoogle}
</span>
<div className="h-px flex-grow bg-slate-200/60" />
<div className="h-px flex-grow bg-slate-200" />
</div>
<button
@@ -522,26 +498,29 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
onClick={() => void onGoogleSignIn()}
disabled={loading}
aria-busy={loading}
className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50"
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.99] disabled:opacity-50"
>
{loading ? loadingSpinner : <Chrome className="h-4 w-4 text-blue-600" />}
<span>{loading ? googleSubmittingLabel : copy.googleOneClick}</span>
</button>
{errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null}
{infoText ? <p className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs text-emerald-700 leading-normal">{infoText}</p> : null}
{errorText ? (
<p className="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-normal text-rose-700">{errorText}</p>
) : null}
{infoText ? (
<p className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-normal text-emerald-700">{infoText}</p>
) : null}
{errorText && isLogin && errorText.includes("Invalid login") ? (
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.loginFailedHint}</p>
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.loginFailedHint}</p>
) : null}
{infoText === copy.signupCheckEmail ? (
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.resendVerify}</p>
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.resendVerify}</p>
) : null}
</div>
</div>
{/* Footer */}
<div className="relative z-10 text-[10px] text-slate-400 text-center font-mono">
© {new Date().getFullYear()} PolyWeather. All rights reserved.
<div className="relative z-10 text-center font-mono text-[10px] text-slate-500">
© {new Date().getFullYear()} PolyWeather
</div>
</div>
</div>
@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const source = fs.readFileSync(
path.join(process.cwd(), "components", "auth", "LoginClient.tsx"),
"utf8",
);
assert(
source.includes("登录 PolyWeather") &&
source.includes("创建 PolyWeather 账号") &&
source.includes("创建账号并领取试用"),
"auth entry copy must be product-specific and explain the signup trial path",
);
assert(
source.includes("PW") && source.includes("PolyWeather"),
"auth pages must show a readable PolyWeather brand mark",
);
assert(
!source.includes("brightness-0 invert"),
"auth dark panel must not invert the square logo image into a white block",
);
assert(
source.includes("items-start justify-center pt-10") &&
source.includes("lg:items-center lg:pt-0"),
"auth form must sit higher on mobile while staying centered on desktop",
);
assert(
!source.includes("hover:-translate-y-1"),
"auth form card must not shift position on hover",
);
assert(
source.includes("hidden text-xs text-slate-500 sm:inline") &&
source.includes("whitespace-nowrap rounded-xl"),
"auth mode switch prompt must not crowd or wrap the mobile header",
);
}
@@ -33,8 +33,16 @@ export function runTests() {
"callback",
"route.ts",
);
const loginPagePath = path.join(
projectRoot,
"app",
"auth",
"login",
"page.tsx",
);
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
const loginPageSource = fs.readFileSync(loginPagePath, "utf8");
assert(
loginClientSource.includes("/auth/reset-password") &&
@@ -80,4 +88,22 @@ export function runTests() {
authCallbackSource.includes("Bearer"),
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately",
);
assert(
loginPageSource.includes("error?: string") &&
loginPageSource.includes("normalizeAuthError") &&
loginPageSource.includes("params.error") &&
loginPageSource.includes("initialError={initialError}") &&
loginClientSource.includes("initialError?: string") &&
loginClientSource.includes("useState(initialError || \"\")"),
"login page must surface auth callback errors in the login form instead of silently returning users to the terminal",
);
assert(
authCallbackSource.includes("redirectToLoginWithError") &&
authCallbackSource.includes('request.nextUrl.searchParams.get("error_description")') &&
authCallbackSource.includes('request.nextUrl.searchParams.get("error")') &&
authCallbackSource.includes("exchangeError") &&
authCallbackSource.includes("exchangeCodeForSession(code)") &&
authCallbackSource.includes("auth_error"),
"auth callback must redirect failed OAuth/session exchanges back to login with an error message",
);
}
@@ -11,11 +11,12 @@ import {
ChevronRight,
GraduationCap,
Menu,
MessageSquare,
Search,
UserRound,
Users,
} from "lucide-react";
import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
@@ -46,19 +47,35 @@ import { scanRootClass } from "@/components/dashboard/scan-root-styles";
import { useRelativeTime } from "@/hooks/useRelativeTime";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import {
LiveTemperatureThresholdChart,
clearCityDetailCache,
preloadTemperatureChartCanvas,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown";
import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector";
import {
UserFeedbackModal,
type FeedbackDraft,
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
import { UpdateAnnouncementButton } from "@/components/dashboard/scan-terminal/UpdateAnnouncementButton";
import {
mergeAccessStateWithAuthPayload,
type AuthProfilePayload,
} from "@/components/dashboard/scan-terminal/terminal-access-state";
import { loadTerminalAuthProfile } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
import {
createAuthProfileRequestCache,
loadTerminalAuthProfile,
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
import { buildAuthMePath } from "@/lib/auth-snapshot";
import {
cityListItemsToScanRows,
mergeScanRowsWithCityFallbackRows,
readCachedCityList,
writeCachedCityList,
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
@@ -78,6 +95,17 @@ const TrainingDashboard = dynamic(
);
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
const TERMINAL_NAV_ITEMS = [
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
] as const;
const AUTH_PROFILE_REQUEST_TIMEOUT_MS = 4500;
const AUTH_DECISION_RECOVERY_MS = 10_000;
const AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000;
const AUTH_PROFILE_RETRY_POLL_MS = 30_000;
const ACTIVE_ACCESS_CACHE_KEY = "polyweather_terminal_active_access_v1";
const ACTIVE_ACCESS_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
function createEmptyAccess(loading = true): ProAccessState {
return {
@@ -109,6 +137,67 @@ function createLocalAccess(): ProAccessState {
};
}
function isFutureAccessExpiry(value: string | null | undefined, now = Date.now()) {
if (!value) return true;
const ts = Date.parse(value);
return Number.isFinite(ts) && ts > now;
}
function readCachedActiveAccess(now = Date.now()): ProAccessState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(ACTIVE_ACCESS_CACHE_KEY);
if (!raw) return null;
const cached = JSON.parse(raw);
const access = cached?.access as Partial<ProAccessState> | undefined;
const ts = Number(cached?.ts || 0);
if (!access?.authenticated || !access.subscriptionActive) return null;
if (!ts || now - ts < 0 || now - ts > ACTIVE_ACCESS_CACHE_TTL_MS) return null;
if (!isFutureAccessExpiry(access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt, now)) {
return null;
}
return {
loading: false,
authenticated: true,
userId: access.userId ?? null,
subscriptionActive: true,
subscriptionPlanCode: access.subscriptionPlanCode ?? null,
subscriptionExpiresAt: access.subscriptionExpiresAt ?? null,
subscriptionTotalExpiresAt:
access.subscriptionTotalExpiresAt ?? access.subscriptionExpiresAt ?? null,
subscriptionQueuedDays: Number(access.subscriptionQueuedDays ?? 0),
points: Number(access.points ?? 0),
error: null,
};
} catch {
return null;
}
}
function writeCachedActiveAccess(access: ProAccessState) {
if (typeof window === "undefined") return;
if (!access.authenticated || !access.subscriptionActive) return;
if (!isFutureAccessExpiry(access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt)) return;
try {
window.localStorage.setItem(
ACTIVE_ACCESS_CACHE_KEY,
JSON.stringify({
ts: Date.now(),
access: {
authenticated: access.authenticated,
userId: access.userId,
subscriptionActive: access.subscriptionActive,
subscriptionPlanCode: access.subscriptionPlanCode,
subscriptionExpiresAt: access.subscriptionExpiresAt,
subscriptionTotalExpiresAt: access.subscriptionTotalExpiresAt,
subscriptionQueuedDays: access.subscriptionQueuedDays,
points: access.points,
},
}),
);
} catch {}
}
function createTransientAccess(error: unknown): ProAccessState {
return {
...createEmptyAccess(true),
@@ -184,6 +273,7 @@ const MAX_TERMINAL_GRID_SIDE = 3;
const MAX_TERMINAL_CHARTS = 9;
const MOBILE_TERMINAL_CHARTS = 1;
const DEFAULT_TERMINAL_GRID_SIDE = 2;
const TERMINAL_SLOTS_STORAGE_KEY = "polyweather_terminal_slots";
function clampGridSide(value: number) {
if (!Number.isFinite(value)) return DEFAULT_TERMINAL_GRID_SIDE;
@@ -210,6 +300,45 @@ function normalizeSlotList(slots: Array<string | null>, totalSlots: number) {
return [...slots, ...Array(totalSlots - slots.length).fill(null)];
}
function readStoredTerminalSlots(totalSlots = MAX_TERMINAL_CHARTS) {
const emptySlots = Array(totalSlots).fill(null) as Array<string | null>;
if (typeof window === "undefined") {
return { slots: emptySlots, hasPersistedSlots: false };
}
try {
const stored = localStorage.getItem(TERMINAL_SLOTS_STORAGE_KEY);
if (stored === null) return { slots: emptySlots, hasPersistedSlots: false };
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) return { slots: emptySlots, hasPersistedSlots: true };
const normalized = parsed.map((value) => {
const text = String(value || "").toLowerCase().trim();
return text || null;
});
return {
slots: normalizeSlotList(normalized, totalSlots),
hasPersistedSlots: true,
};
} catch {
return { slots: emptySlots, hasPersistedSlots: false };
}
}
function shouldAutofillInitialSlots({
filteredRegionRows,
hasPersistedSlots,
slots,
}: {
filteredRegionRows: ScanOpportunityRow[];
hasPersistedSlots: boolean;
slots: Array<string | null>;
}) {
return (
!hasPersistedSlots &&
filteredRegionRows.length > 0 &&
slots.every((slot) => slot === null)
);
}
function t(key: keyof typeof TERM, isEn: boolean) {
return isEn ? TERM[key].en : TERM[key].zh;
}
@@ -359,6 +488,120 @@ function EmptySlotCard({
);
}
const TerminalSidebar = memo(function TerminalSidebar({
activeNavKey,
isEn,
onFeedbackClick,
onSelectNav,
}: {
activeNavKey: string;
isEn: boolean;
onFeedbackClick: () => void;
onSelectNav: (key: string) => void;
}) {
const [navExpanded, setNavExpanded] = useState(false);
return (
<aside
className={clsx(
"flex shrink-0 flex-col bg-white border-r border-[#d2d9e2] py-3 text-slate-500 transition-colors duration-150",
navExpanded ? "w-[172px] items-start px-3" : "w-[52px] items-center gap-2",
)}
>
<div className={clsx(
"flex items-center w-full",
navExpanded ? "gap-3 mb-3 px-1" : "justify-center mb-2",
)}>
<Link
href="/"
className="block h-7 w-7 shrink-0 overflow-hidden rounded transition hover:opacity-90"
title="PolyWeather"
>
<img src="/apple-touch-icon.png" alt="PolyWeather" className="h-full w-full object-cover" />
</Link>
{navExpanded && (
<span className="text-sm font-black text-slate-800 tracking-tight truncate">
PolyWeather
</span>
)}
</div>
<button
type="button"
onClick={() => setNavExpanded((prev) => !prev)}
className={clsx(
"flex items-center gap-3 transition-colors hover:text-slate-800",
navExpanded
? "w-full h-8 px-1 mb-2"
: "grid h-9 w-full place-items-center mb-2",
)}
>
{navExpanded ? (
<>
<ChevronLeft size={14} />
<span className="text-[11px] font-bold uppercase tracking-wide text-slate-400">
{isEn ? "Collapse" : "收起"}
</span>
</>
) : (
<Menu size={18} />
)}
</button>
{TERMINAL_NAV_ITEMS.map(({ key, Icon, labelEn, labelZh }) => {
const isActive = activeNavKey === key;
return (
<button
key={key}
type="button"
onClick={() => onSelectNav(key)}
className={clsx(
"flex items-center gap-3 transition-colors rounded",
navExpanded
? "w-full h-9 px-2 text-left"
: "grid h-9 w-full place-items-center border-l-4",
isActive
? navExpanded
? "bg-blue-50 text-blue-600 font-bold"
: "border-blue-500 bg-blue-50/50 text-blue-600"
: navExpanded
? "hover:bg-slate-50 hover:text-slate-900"
: "border-transparent hover:bg-slate-50 hover:text-slate-700",
)}
title={isEn ? labelEn : labelZh}
>
<Icon size={16} className="shrink-0" />
{navExpanded && (
<span className="text-xs font-semibold whitespace-nowrap">
{isEn ? labelEn : labelZh}
</span>
)}
</button>
);
})}
<div className={clsx("mt-auto w-full border-t border-slate-200 pt-2", navExpanded ? "" : "px-0")}>
<button
type="button"
onClick={onFeedbackClick}
className={clsx(
"flex items-center gap-3 rounded text-slate-500 transition-colors hover:bg-slate-50 hover:text-blue-700",
navExpanded ? "h-9 w-full px-2 text-left" : "grid h-9 w-full place-items-center border-l-4 border-transparent",
)}
title={isEn ? "Feedback / Bug" : "反馈 / Bug"}
>
<MessageSquare size={16} className="shrink-0" />
{navExpanded && (
<span className="text-xs font-semibold whitespace-nowrap">
{isEn ? "Feedback" : "反馈 / Bug"}
</span>
)}
</button>
</div>
</aside>
);
});
function PolyWeatherTerminal({
generatedText,
isEn,
@@ -421,9 +664,18 @@ function PolyWeatherTerminal({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [searchInputRef, setSearchQuery]);
const [navExpanded, setNavExpanded] = useState(false);
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
const [onlineCount, setOnlineCount] = useState<number | null>(null);
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
const handleSelectNav = useCallback((key: string) => {
setActiveNavKey(key);
}, []);
useEffect(() => {
if (activeNavKey !== "thresholds") return;
void preloadTemperatureChartCanvas();
}, [activeNavKey]);
useEffect(() => {
const fetchOnline = () => {
@@ -455,25 +707,62 @@ function PolyWeatherTerminal({
const totalSlots = getSlotCount(gridCols, gridRows);
const hasPersistedSlots = useRef(false);
const [slots, setSlots] = useState<Array<string | null>>(() => {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("polyweather_terminal_slots");
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return normalizeSlotList(parsed, MAX_TERMINAL_CHARTS);
}
}
} catch {}
}
return Array(MAX_TERMINAL_CHARTS).fill(null);
const stored = readStoredTerminalSlots();
hasPersistedSlots.current = stored.hasPersistedSlots;
return stored.slots;
});
const [activeSlotIndex, setActiveSlotIndex] = useState<number>(0);
const [maximizedSlotIndex, setMaximizedSlotIndex] = useState<number | null>(null);
const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState<number | null>(null);
const visibleSlots = useMemo(() => slots.slice(0, totalSlots), [slots, totalSlots]);
const openTerminalFeedback = useCallback(() => {
setFeedbackDraft({
category: "bug",
source: "terminal",
context: {
source: "terminal_sidebar",
active_nav: activeNavKey,
locale,
grid: `${gridCols}x${gridRows}`,
active_slot_index: activeSlotIndex,
maximized_slot_index: maximizedSlotIndex,
visible_slots: visibleSlots.filter(Boolean),
selected_city: selectedCity || "",
selected_region: selectedRegionKey,
},
});
}, [
activeNavKey,
activeSlotIndex,
gridCols,
gridRows,
locale,
maximizedSlotIndex,
selectedCity,
selectedRegionKey,
visibleSlots,
]);
const openChartFeedback = useCallback((context: Record<string, unknown>) => {
setFeedbackDraft({
category: "bug",
source: "chart",
context: {
...context,
active_nav: activeNavKey,
locale,
grid: `${gridCols}x${gridRows}`,
},
});
}, [activeNavKey, gridCols, gridRows, locale]);
const handleFeedbackSubmitted = useCallback(() => {
setFeedbackRefreshKey((value) => value + 1);
}, []);
const handleSetGridSize = (cols: number, rows: number) => {
const safeCols = clampGridSide(cols);
const safeRows = clampGridSide(rows);
@@ -498,12 +787,6 @@ function PolyWeatherTerminal({
}
};
const NAV_ITEMS = [
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
];
useEffect(() => {
setSelectedCity(null);
}, [selectedRegionKey, setSelectedCity]);
@@ -516,13 +799,20 @@ function PolyWeatherTerminal({
}, [rows, selectedRegionKey]);
useEffect(() => {
if (filteredRegionRows.length && slots.every((s) => s === null)) {
if (
shouldAutofillInitialSlots({
filteredRegionRows,
hasPersistedSlots: hasPersistedSlots.current,
slots,
})
) {
const next = Array(MAX_TERMINAL_CHARTS)
.fill(null)
.map((_, idx) => filteredRegionRows[idx]?.city || null);
setSlots(next);
hasPersistedSlots.current = true;
try {
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
localStorage.setItem(TERMINAL_SLOTS_STORAGE_KEY, JSON.stringify(next));
} catch {}
}
}, [filteredRegionRows, slots]);
@@ -532,8 +822,9 @@ function PolyWeatherTerminal({
const next = [...slots];
next[index] = city;
setSlots(next);
hasPersistedSlots.current = true;
try {
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
localStorage.setItem(TERMINAL_SLOTS_STORAGE_KEY, JSON.stringify(next));
} catch {}
};
@@ -579,24 +870,46 @@ function PolyWeatherTerminal({
() => buildContinentGroups(filteredRegionRows, isEn),
[filteredRegionRows, isEn]
);
const mobileGroups = useMemo(
() => buildContinentGroups(rows, isEn),
[rows, isEn],
);
const [mobileTab, setMobileTab] = useState<string>("active_signals");
const mobileActiveGroup = useMemo(
() => continentGroups.find((g) => g.key === mobileTab) || continentGroups[0],
[continentGroups, mobileTab]
() => mobileGroups.find((g) => g.key === mobileTab) || mobileGroups[0],
[mobileGroups, mobileTab],
);
useEffect(() => {
if (continentGroups.length > 0 && !continentGroups.find((g) => g.key === mobileTab)) {
setMobileTab(continentGroups[0].key);
if (mobileGroups.length > 0 && !mobileGroups.find((g) => g.key === mobileTab)) {
setMobileTab(mobileGroups[0].key);
}
}, [continentGroups, mobileTab]);
}, [mobileGroups, mobileTab]);
const mobileSelectedRowInActiveGroup = useMemo(
() =>
selectedRow && mobileActiveGroup?.rows.some((row) => row.id === selectedRow.id)
? selectedRow
: null,
[mobileActiveGroup?.rows, selectedRow],
);
const mobileChartRow = useMemo(
() =>
selectedRow ||
mobileActiveGroup?.rows.slice(0, MOBILE_TERMINAL_CHARTS)[0] ||
mobileSelectedRowInActiveGroup ||
mobileActiveGroup?.rows[0] ||
filteredRegionRows[0] ||
null,
[filteredRegionRows, mobileActiveGroup?.rows, selectedRow],
[filteredRegionRows, mobileActiveGroup?.rows, mobileSelectedRowInActiveGroup],
);
const handleMobileSelectTab = useCallback((key: string) => {
setMobileTab(key);
if (selectedRegionKey !== "all") {
setSelectedRegionKey("all");
}
const nextGroup = mobileGroups.find((group) => group.key === key);
const nextRow = nextGroup?.rows[0];
if (nextRow) {
setSelectedRow(nextRow);
}
}, [mobileGroups, selectedRegionKey, setSelectedRegionKey, setSelectedRow]);
useEffect(() => {
if (!filteredRegionRows.length) return;
if (!selectedRow || !filteredRegionRows.some((row) => row.id === selectedRow.id)) {
@@ -627,87 +940,12 @@ function PolyWeatherTerminal({
return (
<div className="flex h-screen w-full overflow-hidden bg-[#e9edf3] text-[#202833]">
<aside
className={clsx(
"flex shrink-0 flex-col bg-white border-r border-[#d2d9e2] py-3 text-slate-500 transition-all duration-200",
navExpanded ? "w-[172px] items-start px-3" : "w-[52px] items-center gap-2",
)}
>
{/* Logo row */}
<div className={clsx(
"flex items-center w-full",
navExpanded ? "gap-3 mb-3 px-1" : "justify-center mb-2",
)}>
<Link
href="/"
className="block h-7 w-7 shrink-0 overflow-hidden rounded transition hover:opacity-90"
title="PolyWeather"
>
<img src="/apple-touch-icon.png" alt="PolyWeather" className="h-full w-full object-cover" />
</Link>
{navExpanded && (
<span className="text-sm font-black text-slate-800 tracking-tight truncate">
PolyWeather
</span>
)}
</div>
{/* Toggle button */}
<button
type="button"
onClick={() => setNavExpanded((prev) => !prev)}
className={clsx(
"flex items-center gap-3 transition-colors hover:text-slate-800",
navExpanded
? "w-full h-8 px-1 mb-2"
: "grid h-9 w-full place-items-center mb-2",
)}
>
{navExpanded ? (
<>
<ChevronLeft size={14} />
<span className="text-[11px] font-bold uppercase tracking-wide text-slate-400">
{isEn ? "Collapse" : "收起"}
</span>
</>
) : (
<Menu size={18} />
)}
</button>
{/* Nav items */}
{NAV_ITEMS.map(({ key, Icon, labelEn, labelZh }) => {
const isActive = activeNavKey === key;
return (
<button
key={key}
type="button"
onClick={() => { setActiveNavKey(key); }}
className={clsx(
"flex items-center gap-3 transition-colors rounded",
navExpanded
? "w-full h-9 px-2 text-left"
: "grid h-9 w-full place-items-center border-l-4",
isActive
? navExpanded
? "bg-blue-50 text-blue-600 font-bold"
: "border-blue-500 bg-blue-50/50 text-blue-600"
: navExpanded
? "hover:bg-slate-50 hover:text-slate-900"
: "border-transparent hover:bg-slate-50 hover:text-slate-700",
)}
title={isEn ? labelEn : labelZh}
>
<Icon size={16} className="shrink-0" />
{navExpanded && (
<span className="text-xs font-semibold whitespace-nowrap">
{isEn ? labelEn : labelZh}
</span>
)}
</button>
);
})}
</aside>
<TerminalSidebar
activeNavKey={activeNavKey}
isEn={isEn}
onFeedbackClick={openTerminalFeedback}
onSelectNav={handleSelectNav}
/>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-[#d2d9e2] bg-white px-4 text-slate-800">
@@ -716,6 +954,9 @@ function PolyWeatherTerminal({
<Activity size={13} />
{t("dashboard", isEn)}
</div>
<div className="hidden lg:block">
<UpdateAnnouncementButton isEn={isEn} />
</div>
{onlineCount != null && (
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
<Users size={12} />
@@ -741,6 +982,7 @@ function PolyWeatherTerminal({
>
{isEn ? "中文" : "EN"}
</button>
<UserFeedbackStatusButton isEn={isEn} refreshKey={feedbackRefreshKey} />
<Link
href="/account"
className="grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
@@ -760,16 +1002,18 @@ function PolyWeatherTerminal({
<>
{/* Mobile layout */}
<div className="flex flex-col gap-2 lg:hidden overflow-auto flex-1 pb-6">
<MobileRegionTabs
activeTab={mobileTab}
groups={continentGroups}
isEn={isEn}
onSelectTab={setMobileTab}
/>
<div className="rounded border border-blue-200 bg-blue-50 px-3 py-2 text-[11px] font-semibold leading-4 text-blue-700">
{isEn
? "Mobile renders one chart. Rotate to landscape to inspect the full terminal grid."
: "手机端仅渲染 1 个图表。建议横屏查看完整终端网格。"}
<div className="mobile-region-tabs-shell sticky top-0 z-30 -mx-2 bg-[#eef2f6] pb-2 shadow-sm">
<MobileRegionTabs
activeTab={mobileTab}
groups={mobileGroups}
isEn={isEn}
onSelectTab={handleMobileSelectTab}
/>
<div className="mx-2 mt-1 rounded border border-blue-200 bg-blue-50 px-2 py-1 text-[10px] font-semibold leading-3 text-blue-700">
{isEn
? "Mobile renders one chart. Rotate to landscape for the full grid."
: "手机端仅渲染 1 个图表。建议横屏查看完整终端网格。"}
</div>
</div>
{mobileChartRow && (
<div className="h-[420px] min-h-[420px] overflow-hidden rounded border border-[#d2d9e2] bg-white">
@@ -779,6 +1023,7 @@ function PolyWeatherTerminal({
allRows={filteredRegionRows}
compact={false}
disableClose={true}
onReportIssue={openChartFeedback}
/>
</div>
)}
@@ -793,16 +1038,16 @@ function PolyWeatherTerminal({
))}
</div>
{/* Mobile Selected Row Detail */}
{selectedRow && (
{mobileChartRow && (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h3 className="text-sm font-black text-slate-900 mb-2">{rowName(selectedRow)}</h3>
<h3 className="text-sm font-black text-slate-900 mb-2">{rowName(mobileChartRow)}</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
["Obs", temp(selectedRow.current_temp, selectedRow.temp_symbol)],
["High", temp(selectedRow.current_max_so_far, selectedRow.temp_symbol)],
["DEB", temp(selectedRow.deb_prediction, selectedRow.temp_symbol)],
["Gap", temp(selectedRow.signed_gap ?? selectedRow.gap_to_target, selectedRow.temp_symbol)],
["Edge", pct(selectedRow.edge_percent)],
["Obs", temp(mobileChartRow.current_temp, mobileChartRow.temp_symbol)],
["High", temp(mobileChartRow.current_max_so_far, mobileChartRow.temp_symbol)],
["DEB", temp(mobileChartRow.deb_prediction, mobileChartRow.temp_symbol)],
["Gap", temp(mobileChartRow.signed_gap ?? mobileChartRow.gap_to_target, mobileChartRow.temp_symbol)],
["Edge", pct(mobileChartRow.edge_percent)],
["Market", "--"],
].map(([label, value]) => (
<div key={label} className="rounded border border-slate-200 bg-slate-50 p-2">
@@ -838,8 +1083,8 @@ function PolyWeatherTerminal({
handleSelectCityForSlot(maximizedSlotIndex, null);
setMaximizedSlotIndex(null);
}}
onReportIssue={openChartFeedback}
isMaximized={true}
disableClose={visibleSlots.filter(Boolean).length <= 1}
/>
{activeSearchSlotIndex === maximizedSlotIndex && (
@@ -926,8 +1171,8 @@ function PolyWeatherTerminal({
onClose={() => {
handleSelectCityForSlot(slotIndex, null);
}}
onReportIssue={openChartFeedback}
isMaximized={false}
disableClose={visibleSlots.filter(Boolean).length <= 1}
/>
{activeSearchSlotIndex === slotIndex && (
@@ -953,6 +1198,79 @@ function PolyWeatherTerminal({
)}
</main>
</div>
<UserFeedbackModal
draft={feedbackDraft}
isEn={isEn}
onClose={() => setFeedbackDraft(null)}
onSubmitted={handleFeedbackSubmitted}
/>
</div>
);
}
function AuthSyncRecoveryScreen({
isEn,
onRetry,
rootClassName,
retrying,
themeMode,
userLocalTime,
}: {
isEn: boolean;
onRetry: () => void;
rootClassName: string;
retrying: boolean;
themeMode: "dark" | "light";
userLocalTime: string;
}) {
return (
<div className={rootClassName}>
<div className={clsx("flex h-screen w-full bg-[#e9edf3] text-slate-950", themeMode === "light" && "light")}>
<aside className="w-[52px] bg-[#171d24]" />
<main className="flex flex-1 flex-col">
<header className="flex h-[52px] items-center justify-between border-b border-slate-200 bg-white px-4">
<Link href="/" className="flex items-center gap-2 hover:opacity-90">
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
<span className="text-sm font-semibold tracking-tight text-slate-900">Terminal</span>
</Link>
<div className="font-mono text-sm text-slate-500">{userLocalTime}</div>
</header>
<section className="grid flex-1 place-items-center p-6">
<div className="w-full max-w-md rounded-[6px] border border-amber-200 bg-white p-6 shadow-sm">
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] font-black text-amber-700">
{isEn ? "Access sync timeout" : "权限同步超时"}
</div>
<h1 className="text-xl font-black text-slate-950">
{isEn ? "We could not confirm your subscription yet" : "暂时未能确认你的订阅状态"}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-600">
{isEn
? "The session or entitlement service is taking too long. Retry access sync; if you just paid, wait a few seconds and try again."
: "当前会话或会员服务响应过慢。请先重新同步权限;如果刚完成支付,等待几秒后再试。"}
</p>
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
<button
type="button"
onClick={onRetry}
disabled={retrying}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-[4px] bg-blue-600 px-4 py-2.5 text-sm font-black text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70"
>
{retrying && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" />
)}
{isEn ? "Retry access sync" : "重新同步权限"}
</button>
<Link
href="/account"
className="inline-flex flex-1 items-center justify-center rounded-[4px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-black text-slate-700 transition hover:bg-slate-50"
>
{isEn ? "Open account" : "打开账户页"}
</Link>
</div>
</div>
</section>
</main>
</div>
</div>
);
}
@@ -962,7 +1280,7 @@ function ScanTerminalScreen() {
createEmptyAccess(true),
);
const loadAuthProfile = useCallback(
const rawLoadAuthProfile = useCallback(
async (
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
@@ -970,20 +1288,52 @@ function ScanTerminalScreen() {
const headers: Record<string, string> = { Accept: "application/json" };
const token = String(accessToken || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(
options?.preferSnapshot
? "/api/auth/me?prefer_snapshot=1"
: "/api/auth/me",
{
cache: "no-store",
headers,
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AuthProfilePayload>;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeoutId = controller
? globalThis.setTimeout(() => controller.abort(), AUTH_PROFILE_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await fetch(
buildAuthMePath({
preferSnapshot: options?.preferSnapshot,
scope: "entitlement",
}),
{
cache: "no-store",
headers,
signal: controller?.signal,
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AuthProfilePayload>;
} finally {
if (timeoutId !== null) globalThis.clearTimeout(timeoutId);
}
},
[],
);
const authProfileRequestCacheRef = useRef<{
load: typeof rawLoadAuthProfile;
cached: ReturnType<typeof createAuthProfileRequestCache>;
} | null>(null);
const loadAuthProfile = useCallback(
(
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
): Promise<AuthProfilePayload> => {
const current = authProfileRequestCacheRef.current;
if (current?.load === rawLoadAuthProfile) {
return current.cached(accessToken, options);
}
const next = {
load: rawLoadAuthProfile,
cached: createAuthProfileRequestCache(rawLoadAuthProfile),
};
authProfileRequestCacheRef.current = next;
return next.cached(accessToken, options);
},
[rawLoadAuthProfile],
);
const refreshLiveAuthProfile = useCallback(async () => {
const supabaseEnabled = hasSupabasePublicEnv();
@@ -995,6 +1345,7 @@ function ScanTerminalScreen() {
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile: (accessToken) =>
loadAuthProfile(accessToken, { preferSnapshot: false }),
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
});
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
}, [loadAuthProfile]);
@@ -1057,13 +1408,17 @@ function ScanTerminalScreen() {
setLocale((prev) => (prev === "zh-CN" ? "en-US" : "zh-CN"));
const [hydrated, setHydrated] = useState(false);
const [localFullAccess, setLocalFullAccess] = useState(false);
const [authWaitExpired, setAuthWaitExpired] = useState(false);
const [authRetrying, setAuthRetrying] = useState(false);
const canUseLocalFullAccess = hydrated && localFullAccess;
const isAuthenticated =
hydrated && (proAccess.authenticated || canUseLocalFullAccess);
const isPro =
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
const accessDecisionPending =
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
!hydrated || (proAccess.loading && !canUseLocalFullAccess && !authWaitExpired);
const authSyncRecoveryNeeded =
hydrated && proAccess.loading && !canUseLocalFullAccess && authWaitExpired;
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
const userLocalTime = useUserLocalClock();
const { themeMode } = useScanTerminalTheme();
@@ -1103,6 +1458,10 @@ function ScanTerminalScreen() {
cancelled = true;
};
}
const cachedActiveAccess = readCachedActiveAccess();
if (cachedActiveAccess) {
setProAccess(cachedActiveAccess);
}
if (typeof fetch !== "function") {
setProAccess(createEmptyAccess(false));
return () => {
@@ -1117,6 +1476,7 @@ function ScanTerminalScreen() {
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile,
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
})
.then((payload) => {
if (cancelled) return;
@@ -1140,6 +1500,23 @@ function ScanTerminalScreen() {
};
}, [loadAuthProfile, refreshLiveAuthProfile]);
useEffect(() => {
if (!hydrated || !proAccess.loading || canUseLocalFullAccess) {
setAuthWaitExpired(false);
return;
}
const timer = window.setTimeout(() => {
setAuthWaitExpired(true);
}, AUTH_DECISION_RECOVERY_MS);
return () => window.clearTimeout(timer);
}, [canUseLocalFullAccess, hydrated, proAccess.loading]);
useEffect(() => {
if (hydrated && proAccess.authenticated && proAccess.subscriptionActive) {
writeCachedActiveAccess(proAccess);
}
}, [hydrated, proAccess]);
useEffect(() => {
if (
!hydrated ||
@@ -1163,6 +1540,7 @@ function ScanTerminalScreen() {
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile,
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
});
if (cancelled) return;
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
@@ -1178,10 +1556,10 @@ function ScanTerminalScreen() {
const firstRetry = window.setTimeout(() => {
void retryAuthProfile();
}, 1500);
}, AUTH_PROFILE_RETRY_INITIAL_DELAY_MS);
const interval = window.setInterval(() => {
void retryAuthProfile();
}, 5000);
}, AUTH_PROFILE_RETRY_POLL_MS);
return () => {
cancelled = true;
window.clearTimeout(firstRetry);
@@ -1228,9 +1606,20 @@ function ScanTerminalScreen() {
clearCityDetailCache();
refreshScanTerminalManually();
}, [refreshScanTerminalManually]);
const handleRetryAuthSync = useCallback(() => {
setAuthRetrying(true);
setAuthWaitExpired(false);
void refreshLiveAuthProfile()
.catch((error) => {
setProAccess((prev) => ({ ...prev, error: String(error) }));
})
.finally(() => {
setAuthRetrying(false);
});
}, [refreshLiveAuthProfile]);
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
cityListItemsToScanRows(STATIC_CITY_LIST),
cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST),
);
const rows = useMemo(
() => {
@@ -1246,9 +1635,15 @@ function ScanTerminalScreen() {
useEffect(() => {
if (!isPro || typeof fetch !== "function") return;
if (fallbackFetchedRef.current) return;
const cachedCities = readCachedCityList();
if (cachedCities) {
fallbackFetchedRef.current = true;
setCityFallbackRows(cityListItemsToScanRows(cachedCities));
return;
}
const controller = new AbortController();
fetch("/api/cities", {
cache: "no-store",
cache: "default",
headers: { Accept: "application/json" },
signal: controller.signal,
})
@@ -1259,6 +1654,7 @@ function ScanTerminalScreen() {
.then((payload) => {
if (!payload || !Array.isArray(payload.cities)) return;
fallbackFetchedRef.current = true;
writeCachedCityList(payload.cities);
setCityFallbackRows(cityListItemsToScanRows(payload.cities));
})
.catch(() => {});
@@ -1313,6 +1709,19 @@ function ScanTerminalScreen() {
);
}
if (authSyncRecoveryNeeded) {
return (
<AuthSyncRecoveryScreen
isEn={isEn}
onRetry={handleRetryAuthSync}
rootClassName={scanRootClass}
retrying={authRetrying}
themeMode={themeMode}
userLocalTime={userLocalTime}
/>
);
}
if (shouldShowPaywall) {
return (
<ProductAccessRequired
@@ -1,16 +1,17 @@
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import { Bug, ChevronDown, ChevronUp } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary";
import { TemperatureChartCanvasFallback } from "@/components/dashboard/scan-terminal/TemperatureChartCanvasFallback";
import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";
import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails";
import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { rowName } from "@/components/dashboard/scan-terminal/utils";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import {
HOURLY_CACHE_TTL_MS,
@@ -31,6 +32,7 @@ import {
normObs,
prefersHighFrequencyRunwayResolution,
readSessionCache,
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
seedHourlyForecastFromRow,
shouldPollLiveChart,
@@ -56,17 +58,17 @@ const PEAK_GLOW_BADGE_CLASS = {
} as const;
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
const NO_PATCH_FULL_DETAIL_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
const INITIAL_DETAIL_LOAD_SLOTS = 3;
const DEFERRED_DETAIL_LOAD_DELAY_MS = 1_200;
const DEFERRED_DETAIL_LOAD_GROUP_SIZE = 3;
const DEFERRED_DETAIL_LOAD_WAVE_STEP_MS = 900;
const TemperatureChartCanvas = dynamic(
() =>
import("@/components/dashboard/scan-terminal/TemperatureChartCanvas").then(
(mod) => mod.TemperatureChartCanvas,
),
{
ssr: false,
loading: () => <TemperatureChartCanvasFallback />,
},
);
export function preloadTemperatureChartCanvas() {
return Promise.resolve();
}
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
if (state === "watch") return isEn ? "Watch" : "关注";
@@ -109,16 +111,283 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
}
type AdvancedWeatherVariableItem = {
key: "wind_dir" | "wind_speed" | "dewpoint" | "humidity" | "pressure";
label: string;
value: string;
};
type SourceCadenceSummary = {
label: string;
cadence: string;
status: string | null;
};
function formatCompactNumber(value: number, decimals = 1) {
const rounded = Number(value.toFixed(decimals));
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(decimals);
}
function fallbackCadenceSeconds(sourceText: string) {
const value = sourceText.toLowerCase();
if (value.includes("amos")) return 60;
if (value.includes("amsc")) return 180;
if (value.includes("madis") || value.includes("hfmetar")) return 300;
if (value.includes("cowin")) return 60;
if (value.includes("hko")) return 600;
if (value.includes("cwa") || value.includes("jma") || value.includes("fmi") || value.includes("knmi")) return 600;
if (value.includes("mgm")) return 900;
if (value.includes("mss") || value.includes("singapore")) return 60;
return null;
}
function formatCadence(seconds: number) {
return `${Math.round(seconds)}s`;
}
function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
if (!status) return null;
const normalized = status.toLowerCase();
if (normalized === "fresh") return isEn ? "fresh" : "新鲜";
if (normalized === "expected_wait") return isEn ? "waiting for source" : "等待源头";
if (normalized === "delayed") return isEn ? "delayed" : "延迟";
if (normalized === "stale") return isEn ? "stale" : "过旧";
if (normalized === "offline") return isEn ? "offline" : "离线";
return status;
}
function buildSourceCadenceSummary(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
isEn: boolean,
): SourceCadenceSummary | null {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
const sourceParts = [
(primary as any)?.source,
primary?.source_code,
primary?.source_label,
primary?.station_code,
row?.metar_context?.source,
row?.metar_context?.station_label,
row?.airport,
]
.map((item) => String(item || "").trim())
.filter(Boolean);
const sourceText = sourceParts.join(" ");
const nativeCadence = validNumber(primary?.freshness?.native_update_interval_sec);
const cadenceSeconds = nativeCadence ?? fallbackCadenceSeconds(sourceText);
if (cadenceSeconds === null) return null;
const label =
primary?.source_label ||
primary?.source_code ||
(primary as any)?.source ||
row?.metar_context?.station_label ||
row?.airport ||
(isEn ? "Source" : "数据源");
return {
label,
cadence: formatCadence(cadenceSeconds),
status: sourceStatusLabel(primary?.freshness?.freshness_status, isEn),
};
}
function buildAdvancedWeatherVariableItems(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
isEn: boolean,
): AdvancedWeatherVariableItem[] {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
const current = hourly?.current || null;
const metarContext = row?.metar_context || null;
const tempSymbol = row?.temp_symbol || primary?.temp_symbol || "°C";
const windDir =
validNumber(primary?.wind_dir) ??
validNumber(current?.wind_dir) ??
validNumber(metarContext?.airport_wind_dir);
const windSpeed =
validNumber(primary?.wind_speed_kt) ??
validNumber(current?.wind_speed_kt) ??
validNumber(metarContext?.airport_wind_speed_kt);
const dewpoint =
validNumber(current?.dewpoint) ??
validNumber((current as any)?.dew_point) ??
validNumber((primary as any)?.dewpoint) ??
validNumber((primary as any)?.dew_point);
const humidity =
validNumber(primary?.humidity) ??
validNumber(current?.humidity) ??
validNumber(metarContext?.airport_humidity);
const pressure =
validNumber(primary?.pressure_hpa) ??
validNumber((current as any)?.pressure_hpa) ??
validNumber((current as any)?.pressure);
const items: AdvancedWeatherVariableItem[] = [];
if (windDir !== null) {
items.push({
key: "wind_dir",
label: isEn ? "Wind Dir" : "风向",
value: `${Math.round(windDir)}°`,
});
}
if (windSpeed !== null) {
items.push({
key: "wind_speed",
label: isEn ? "Wind Speed" : "风速",
value: `${formatCompactNumber(windSpeed)} kt`,
});
}
if (dewpoint !== null) {
items.push({
key: "dewpoint",
label: isEn ? "Dew Point" : "露点",
value: `${formatCompactNumber(dewpoint)}${tempSymbol}`,
});
}
if (humidity !== null) {
items.push({
key: "humidity",
label: isEn ? "Humidity" : "湿度",
value: `${formatCompactNumber(humidity)}%`,
});
}
if (pressure !== null) {
items.push({
key: "pressure",
label: isEn ? "Pressure" : "气压",
value: `${formatCompactNumber(pressure)} hPa`,
});
}
return items;
}
function SourceCadenceStrip({
isEn,
summary,
}: {
isEn: boolean;
summary: SourceCadenceSummary | null;
}) {
if (!summary) return null;
return (
<div className="shrink-0 border-b border-slate-200 bg-slate-50/70 px-4 py-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600">
<span className="font-bold text-slate-700">
{isEn ? "Source" : "数据源"}: {summary.label}
</span>
<span className="font-mono font-black text-slate-900">
{isEn ? "native cadence" : "源头频率"} {summary.cadence}
</span>
{summary.status && (
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 font-semibold text-slate-500">
{summary.status}
</span>
)}
</div>
</div>
);
}
function AdvancedWeatherVariablesStrip({
isEn,
items,
}: {
isEn: boolean;
items: AdvancedWeatherVariableItem[];
}) {
const [expanded, setExpanded] = useState(false);
if (!items.length) return null;
return (
<div className="shrink-0 border-b border-slate-200 bg-white px-4 py-2">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="inline-flex min-h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-900"
>
<span>{isEn ? "Advanced Variables" : "高级气象变量"}</span>
<span className="font-mono text-slate-400">{items.length}</span>
{expanded ? <ChevronUp size={13} aria-hidden="true" /> : <ChevronDown size={13} aria-hidden="true" />}
</button>
{expanded && (
<div className="mt-2 grid gap-2 sm:grid-cols-5">
{items.map((item) => (
<div
key={item.key}
className="rounded-md border border-slate-100 bg-slate-50 px-3 py-2"
>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{item.label}
</div>
<div className="mt-1 font-mono text-sm font-black text-slate-800">
{item.value}
</div>
</div>
))}
</div>
)}
{expanded && (
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{isEn
? "Context only. These variables help explain capping and boundary-layer structure; they are not settlement-temperature curves."
: "仅作上下文。它们用于解释压温和边界层结构,不是结算温度曲线。"}
</p>
)}
</div>
);
}
function shouldFetchCityDetailForChart({
city,
documentHidden,
isChartVisible,
compact = false,
isActive = false,
isMaximized = false,
slotIndex = 0,
detailLoadReady = true,
}: {
city: string;
documentHidden: boolean;
isChartVisible: boolean;
compact?: boolean;
isActive?: boolean;
isMaximized?: boolean;
slotIndex?: number;
detailLoadReady?: boolean;
}) {
return Boolean(city) && isChartVisible && !documentHidden;
if (!city || !isChartVisible || documentHidden) return false;
if (!compact || isActive || isMaximized) return true;
if (normalizeSlotIndex(slotIndex) < INITIAL_DETAIL_LOAD_SLOTS) return true;
return detailLoadReady;
}
function normalizeSlotIndex(slotIndex: number | null | undefined) {
return Number.isFinite(slotIndex) && Number(slotIndex) >= 0 ? Math.floor(Number(slotIndex)) : 0;
}
function getInitialDetailLoadDelayMs({
compact,
isActive,
isMaximized,
slotIndex,
}: {
compact?: boolean;
isActive?: boolean;
isMaximized?: boolean;
slotIndex?: number;
}) {
if (!compact || isActive || isMaximized) return 0;
const normalizedIndex = normalizeSlotIndex(slotIndex);
if (normalizedIndex < INITIAL_DETAIL_LOAD_SLOTS) return 0;
const deferredWave = Math.floor(
(normalizedIndex - INITIAL_DETAIL_LOAD_SLOTS) / DEFERRED_DETAIL_LOAD_GROUP_SIZE,
);
return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS;
}
// ── Main component ─────────────────────────────────────────────────────
@@ -131,6 +400,7 @@ export function LiveTemperatureThresholdChart({
onSearchClick,
onMaximize,
onClose,
onReportIssue,
isMaximized = false,
disableClose = false,
isActive = !compact,
@@ -143,6 +413,7 @@ export function LiveTemperatureThresholdChart({
onSearchClick?: () => void;
onMaximize?: () => void;
onClose?: () => void;
onReportIssue?: (context: Record<string, unknown>) => void;
isMaximized?: boolean;
disableClose?: boolean;
isActive?: boolean;
@@ -165,6 +436,7 @@ export function LiveTemperatureThresholdChart({
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0);
const lastForegroundRefreshAtRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined",
@@ -177,6 +449,11 @@ export function LiveTemperatureThresholdChart({
const [targetResolution, setTargetResolution] = useState<string>(() =>
prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m",
);
const detailLoadDelayMs = useMemo(
() => getInitialDetailLoadDelayMs({ compact, isActive, isMaximized, slotIndex }),
[compact, isActive, isMaximized, slotIndex],
);
const [detailLoadReady, setDetailLoadReady] = useState(() => detailLoadDelayMs === 0);
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
formatCityLocalDate(row?.tz_offset_seconds),
);
@@ -189,7 +466,7 @@ export function LiveTemperatureThresholdChart({
setTargetResolution(prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m");
setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null);
setIsHourlyLoading(Boolean(city));
setIsHourlyLoading(Boolean(city) && detailLoadDelayMs === 0);
setDetailError(null);
setDetailRetryNonce(0);
setShowingStaleDetail(false);
@@ -197,9 +474,25 @@ export function LiveTemperatureThresholdChart({
lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0;
lastProbabilityRefreshAtRef.current = 0;
lastForegroundRefreshAtRef.current = 0;
localDayRolloverFetchDateRef.current = "";
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]);
}, [city, detailLoadDelayMs]);
useEffect(() => {
if (!city) {
setDetailLoadReady(false);
return;
}
if (detailLoadDelayMs <= 0) {
setDetailLoadReady(true);
return;
}
setDetailLoadReady(false);
const id = setTimeout(() => setDetailLoadReady(true), detailLoadDelayMs);
return () => clearTimeout(id);
}, [city, detailLoadDelayMs]);
useEffect(() => {
const node = chartVisibilityRef.current;
@@ -232,17 +525,6 @@ export function LiveTemperatureThresholdChart({
return;
}
if (
!shouldFetchCityDetailForChart({
city,
documentHidden:
typeof document !== "undefined" && document.visibilityState === "hidden",
isChartVisible,
})
) {
return;
}
const cacheKey = `${city}:${targetResolution}`;
let cached = _hourlyCache.get(cacheKey);
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
@@ -267,6 +549,23 @@ export function LiveTemperatureThresholdChart({
return;
}
if (
!shouldFetchCityDetailForChart({
city,
documentHidden:
typeof document !== "undefined" && document.visibilityState === "hidden",
isChartVisible,
compact,
isActive,
isMaximized,
slotIndex,
detailLoadReady,
})
) {
setIsHourlyLoading(false);
return;
}
if (!cached && !hasLoadedHourlyDetailRef.current) {
setHourly(seedHourlyForecastFromRow(row));
setShowingStaleDetail(false);
@@ -274,9 +573,6 @@ export function LiveTemperatureThresholdChart({
setIsHourlyLoading(true);
let cancelled = false;
// Prioritize active slots, stagger/delay background slots to optimize load performance
const delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350);
const timer = setTimeout(() => {
fetchHourlyForecastForCity(city, { resolution: targetResolution })
.then((data) => {
@@ -296,13 +592,25 @@ export function LiveTemperatureThresholdChart({
.finally(() => {
if (!cancelled) setIsHourlyLoading(false);
});
}, delay);
}, DETAIL_LOAD_BATCH_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]);
}, [
city,
row,
targetResolution,
isChartVisible,
compact,
isActive,
isMaximized,
slotIndex,
detailLoadReady,
detailRetryNonce,
isEn,
]);
useEffect(() => {
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
@@ -357,7 +665,7 @@ export function LiveTemperatureThresholdChart({
};
}, [resyncVersion, city, targetResolution]);
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for 2 minutes ──
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for one METAR cadence ──
useEffect(() => {
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
let cancelled = false;
@@ -381,7 +689,7 @@ export function LiveTemperatureThresholdChart({
const checkFallback = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return;
if (Date.now() - lastPatchAtRef.current < NO_PATCH_FULL_DETAIL_FALLBACK_MS) return;
refreshFullDetail();
};
@@ -398,7 +706,19 @@ export function LiveTemperatureThresholdChart({
let cancelled = false;
const refreshForegroundFullDetail = () => {
lastPatchAtRef.current = Date.now();
const now = Date.now();
const cacheKey = `${city}:${targetResolution}`;
const cached = _hourlyCache.get(cacheKey);
const cacheAge = cached ? now - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
if (
now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS ||
(cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS)
) {
return;
}
lastForegroundRefreshAtRef.current = now;
lastPatchAtRef.current = now;
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => {
@@ -548,6 +868,7 @@ export function LiveTemperatureThresholdChart({
}, [chartSeries, userToggledKeys, city, showRunwayDetails]);
const {
isHKO,
isShenzhen,
metarHeaderLabel,
metarHighLabel,
@@ -555,11 +876,17 @@ export function LiveTemperatureThresholdChart({
runwayHighLabel,
} = useMemo(() => getLiveObservationLabels(row, chartHourly), [row, chartHourly]);
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
const { currentMetarTemp, currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
() => getObservationDisplayMetrics(row, chartHourly, settlementPlate),
[row, chartHourly, settlementPlate],
);
const displayRunwayTemp = selectDisplayRunwayTemp(liveTemp, currentRunwayTemp, hasRunwayData);
const displayMetarTemp = selectCompactSecondaryTemp({
isHKO,
isShenzhen,
displayMetarTemp: currentMetarTemp,
observedHighMetar,
});
const wundergroundDailyHigh = getWundergroundDailyHigh(chartHourly);
const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10);
@@ -577,6 +904,14 @@ export function LiveTemperatureThresholdChart({
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 sourceCadenceSummary = useMemo(
() => buildSourceCadenceSummary(row, chartHourly, isEn),
[chartHourly, isEn, row],
);
const advancedWeatherVariables = useMemo(
() => buildAdvancedWeatherVariableItems(row, chartHourly, isEn),
[chartHourly, isEn, row],
);
const formattedUpdateTime = useMemo(() => {
const nowUtc = Date.now();
@@ -633,6 +968,7 @@ export function LiveTemperatureThresholdChart({
);
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
const showDetailErrorBadge = !compact || isActive || isMaximized;
const handleZoomReset = useCallback(() => {
setZoomRange(null);
@@ -696,6 +1032,56 @@ export function LiveTemperatureThresholdChart({
setDetailRetryNonce((value) => value + 1);
}, []);
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onReportIssue?.({
source: "chart",
city,
display_city: row ? rowName(row) : "",
row_id: row?.id || "",
slot_index: slotIndex,
compact,
is_active: isActive,
is_maximized: isMaximized,
detail_error: detailError,
is_hourly_loading: isHourlyLoading,
showing_stale_detail: showingStaleDetail,
target_resolution: targetResolution,
view_mode: viewMode,
loaded_local_date: chartLocalDate || "",
has_runway_data: hasRunwayData,
series: chartSeries.map((item) => item.key),
visible_series: activeSeries.map((item) => item.key),
live_temp: liveTemp,
current_runway_temp: currentRunwayTemp,
current_metar_temp: currentMetarTemp,
observed_high_metar: observedHighMetar,
observed_high_runway: observedHighRunway,
});
}, [
activeSeries,
chartLocalDate,
chartSeries,
city,
compact,
currentMetarTemp,
currentRunwayTemp,
detailError,
hasRunwayData,
isActive,
isHourlyLoading,
isMaximized,
liveTemp,
observedHighMetar,
observedHighRunway,
onReportIssue,
row,
showingStaleDetail,
slotIndex,
targetResolution,
viewMode,
]);
const panelTitle = row ? (
<div className="flex items-center gap-1">
<button
@@ -758,8 +1144,18 @@ export function LiveTemperatureThresholdChart({
))}
</div>
{(onMaximize || onClose) && (
{(onMaximize || onClose || onReportIssue) && (
<div className="flex items-center gap-1">
{onReportIssue && (
<button
type="button"
onClick={handleReportIssue}
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-amber-600 transition-colors shadow-sm"
title={isEn ? "Report this chart" : "反馈此图表"}
>
<Bug size={13} />
</button>
)}
{onMaximize && (
<button
type="button"
@@ -815,10 +1211,12 @@ export function LiveTemperatureThresholdChart({
metarHighLabel={metarHighLabel}
isShenzhen={isShenzhen}
displayRunwayTemp={displayRunwayTemp}
displayMetarTemp={displayMetarTemp}
observedHighMetar={observedHighMetar}
observedHighRunway={observedHighRunway}
wundergroundDailyHigh={wundergroundDailyHigh}
debVal={debVal}
debQuality={chartHourly?.debQuality || null}
modelMin={modelMin}
modelMax={modelMax}
spread={spread}
@@ -835,6 +1233,18 @@ export function LiveTemperatureThresholdChart({
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
)}
{timeframe === "1D" && !compact && (
<SourceCadenceStrip isEn={isEn} summary={sourceCadenceSummary} />
)}
{timeframe === "1D" && !compact && (
<AdvancedWeatherVariablesStrip
key={city || "advanced-weather"}
isEn={isEn}
items={advancedWeatherVariables}
/>
)}
<TemperatureChartCanvas
isEn={isEn}
compact={compact}
@@ -852,6 +1262,7 @@ export function LiveTemperatureThresholdChart({
isHourlyLoading={isHourlyLoading}
detailError={detailError}
showingStaleDetail={showingStaleDetail}
showDetailErrorBadge={showDetailErrorBadge}
refAreaLeft={refAreaLeft}
refAreaRight={refAreaRight}
onMouseDown={handleMouseDown}
@@ -885,7 +1296,11 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
export const __getPeakGlowStateForTest = getPeakGlowState;
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
export const __buildAdvancedWeatherVariableItemsForTest = buildAdvancedWeatherVariableItems;
export const __buildSourceCadenceSummaryForTest = buildSourceCadenceSummary;
@@ -15,7 +15,7 @@ export function MobileRegionTabs({
onSelectTab: (key: string) => void;
}) {
return (
<div className="mobile-region-tabs flex overflow-x-auto border-b border-slate-200 bg-white px-2">
<div className="mobile-region-tabs flex min-h-11 snap-x gap-1 overflow-x-auto border-b border-slate-200 bg-white px-2 [-webkit-overflow-scrolling:touch] [scrollbar-width:none]">
{groups.map((g) => {
const label = isEn ? g.labelEn : g.labelZh;
const isActive = activeTab === g.key;
@@ -23,9 +23,10 @@ export function MobileRegionTabs({
<button
key={g.key}
type="button"
aria-pressed={isActive}
onClick={() => onSelectTab(g.key)}
className={clsx(
"shrink-0 px-3 py-2.5 text-xs font-bold whitespace-nowrap border-b-2 transition-colors",
"flex min-h-11 shrink-0 snap-start items-center border-b-2 px-3 text-xs font-bold whitespace-nowrap transition-colors",
isActive
? "border-blue-600 text-blue-700"
: "border-transparent text-slate-500 hover:text-slate-700"
@@ -41,7 +41,7 @@ function hasDrawableTemperatureChartContent({
probabilityOverlay: ProbabilityOverlay | null;
zoomedData: Array<Record<string, any>>;
}) {
if (probabilityOverlay?.muLine || probabilityOverlay?.bands.length) return true;
void probabilityOverlay;
return activeSeries.some((series) =>
zoomedData.some((point, index) => {
const value = point?.[series.key] ?? series.values[index];
@@ -112,6 +112,7 @@ function TemperatureChartCanvasComponent({
isHourlyLoading,
detailError,
showingStaleDetail,
showDetailErrorBadge = true,
refAreaLeft,
refAreaRight,
onMouseDown,
@@ -139,6 +140,7 @@ function TemperatureChartCanvasComponent({
isHourlyLoading: boolean;
detailError?: string | null;
showingStaleDetail?: boolean;
showDetailErrorBadge?: boolean;
refAreaLeft: number | null;
refAreaRight: number | null;
onMouseDown: (event: any) => void;
@@ -223,7 +225,7 @@ function TemperatureChartCanvasComponent({
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
const shouldShowBackgroundError =
Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
return (
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
@@ -261,29 +263,6 @@ function TemperatureChartCanvasComponent({
</label>
)}
{probabilityOverlay && (
<span
className={clsx(
"inline-flex items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700",
canToggleRunwayDetails ? "" : "ml-auto",
)}
title={
probabilityOverlay.muLine
? probabilityOverlay.muLine.label
: isEn
? "Legacy Gaussian probability bands"
: "Legacy 高斯概率温度带"
}
>
<span className="h-2 w-2 rounded-full bg-violet-500/70" />
<span>{isEn ? "Gaussian" : "高斯概率"}</span>
{probabilityOverlay.muLine && (
<span className="font-mono text-violet-600">
μ {probabilityOverlay.muLine.value.toFixed(1)}{tempSymbol}
</span>
)}
</span>
)}
</div>
<div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}>
{!shouldRenderChart && <TemperatureChartSkeleton compact={compact} />}
@@ -316,16 +295,6 @@ function TemperatureChartCanvasComponent({
domain={chartDomain}
ticks={intDegreeTicks ?? undefined}
/>
{timeframe === "1D" && probabilityOverlay?.bands.map((band) => (
<ReferenceArea
key={band.key}
y1={band.lower}
y2={band.upper}
strokeOpacity={0}
fill="#8b5cf6"
fillOpacity={band.opacity}
/>
))}
{timeframe === "1D" && cityThresholds.map((t, idx) => {
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
const labelText = isEn
@@ -348,20 +317,6 @@ function TemperatureChartCanvasComponent({
/>
);
})}
{timeframe === "1D" && probabilityOverlay?.muLine && (
<ReferenceLine
y={probabilityOverlay.muLine.value}
stroke="#7c3aed"
strokeDasharray="2 3"
strokeWidth={1.4}
label={{
value: compact ? undefined : probabilityOverlay.muLine.label,
fill: "#7c3aed",
fontSize: 9,
position: "insideTopLeft",
}}
/>
)}
<Tooltip
filterNull={false}
cursor={{ stroke: "#94a3b8", strokeWidth: 1 }}
@@ -378,7 +333,9 @@ function TemperatureChartCanvasComponent({
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
data={zoomedData}
series={activeSeries}
probabilityOverlay={probabilityOverlay}
tempSymbol={tempSymbol}
isEn={isEn}
/>
)}
formatter={(value: unknown) => {
@@ -1,35 +0,0 @@
export function TemperatureChartCanvasFallback({ compact }: { compact?: boolean }) {
const horizontalLines = compact ? 5 : 7;
const verticalLines = compact ? 5 : 8;
const minChartHeight = compact === false ? 220 : 120;
return (
<div
className="relative flex-1 overflow-hidden rounded-sm border border-slate-100 bg-white"
style={{ minHeight: minChartHeight }}
>
<div className="absolute inset-x-3 bottom-7 top-4 rounded-sm border border-slate-100">
{Array.from({ length: horizontalLines }).map((_, index) => (
<span
key={`h-${index}`}
className="absolute left-0 right-0 border-t border-dashed border-sky-100"
style={{ top: `${(index / Math.max(1, horizontalLines - 1)) * 100}%` }}
/>
))}
{Array.from({ length: verticalLines }).map((_, index) => (
<span
key={`v-${index}`}
className="absolute bottom-0 top-0 border-l border-dashed border-sky-100"
style={{ left: `${(index / Math.max(1, verticalLines - 1)) * 100}%` }}
/>
))}
</div>
<div className="absolute inset-0 grid place-items-center">
<div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-500 shadow-sm">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-500" />
</div>
</div>
</div>
);
}
@@ -12,6 +12,7 @@ const OBSERVATION_LABEL_EN: Record<string, string> = {
"CWA (10分钟)": "CWA (10m)",
"气象站实测": "Weather Station Live",
"跑道实测 (1分钟)": "Runway Live (1m)",
"跑道实测 (3分钟)": "Runway Live (3m)",
"机场报文": "Airport METAR",
"METAR 结算 (30分钟)": "METAR Settlement (30m)",
};
@@ -37,6 +38,50 @@ function highLabel(label: string, isEn: boolean) {
return isEn ? (HIGH_LABEL_EN[label] || label) : label;
}
type DebQuality = {
quality_tier?: string | null;
recommendation?: string | null;
recent_hit_rate?: number | null;
recent_samples?: number | null;
};
function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean) {
const recommendation = quality?.recommendation;
if (recommendation === "primary") return isEn ? "Primary" : "主用";
if (recommendation === "supporting") return isEn ? "Support" : "辅助";
if (recommendation === "context_only") return isEn ? "Context" : "参考";
if (recommendation === "insufficient") return isEn ? "Thin" : "样本少";
return "";
}
function debQualityClass(quality: DebQuality | null | undefined) {
const tier = quality?.quality_tier;
if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700";
return "border-slate-200 bg-slate-50 text-slate-500";
}
function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
const label = debQualityLabel(quality, isEn);
if (!label) return null;
const hitRate = quality?.recent_hit_rate;
const samples = quality?.recent_samples;
const titleParts = [
isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`,
hitRate == null ? null : `${hitRate.toFixed(0)}%`,
samples == null ? null : `n=${samples}`,
].filter(Boolean);
return (
<span
className={clsx("ml-1.5 inline-flex items-center rounded border px-1.5 py-0.5 text-[9px] font-black uppercase leading-none", debQualityClass(quality))}
title={titleParts.join(" · ")}
>
{label}
</span>
);
}
function buildStatsLabels({
isEn,
isShenzhen,
@@ -76,10 +121,12 @@ export function TemperatureStatsBars({
metarHighLabel,
isShenzhen,
displayRunwayTemp,
displayMetarTemp,
observedHighMetar,
observedHighRunway,
wundergroundDailyHigh,
debVal,
debQuality,
modelMin,
modelMax,
spread,
@@ -97,10 +144,12 @@ export function TemperatureStatsBars({
metarHighLabel: string;
isShenzhen: boolean;
displayRunwayTemp: number | null;
displayMetarTemp: number | null;
observedHighMetar: number | null;
observedHighRunway: number | null;
wundergroundDailyHigh: number | null;
debVal: number | null;
debQuality?: DebQuality | null;
modelMin: number | null;
modelMax: number | null;
spread: number | null;
@@ -129,13 +178,14 @@ export function TemperatureStatsBars({
<span className="text-slate-300">|</span>
<span className="font-semibold text-slate-500">
{labels.compactSecondary}:{" "}
<strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong>
<strong className="text-blue-600 font-mono">{temp(displayMetarTemp, tempSymbol)}</strong>
</span>
</div>
) : (
<div className="flex items-center gap-4 text-[11px]">
<span className="font-semibold text-slate-500">
DEB: <strong className="text-orange-600 font-mono">{temp(debVal, tempSymbol)}</strong>
<DebQualityBadge quality={debQuality} isEn={isEn} />
</span>
{modelMin !== null && modelMax !== null && (
<>
@@ -184,6 +234,7 @@ export function TemperatureStatsBars({
<div className="flex flex-col">
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
DEB Max
<DebQualityBadge quality={debQuality} isEn={isEn} />
</span>
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
{temp(debVal, tempSymbol)}
@@ -234,6 +285,7 @@ export function TemperatureStatsBars({
</span>
<strong className="text-blue-600 font-bold">
{temp(debVal, tempSymbol)}
<DebQualityBadge quality={debQuality} isEn={isEn} />
</strong>
</div>
<div className="flex flex-col gap-0.5">
@@ -260,3 +312,4 @@ export function TemperatureStatsBars({
}
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
export const __buildDebQualityLabelForTest = debQualityLabel;
@@ -1,9 +1,14 @@
"use client";
import { validNumber, type EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
import {
validNumber,
type EvidenceSeries,
type ProbabilityOverlay,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
type TooltipSeries = Pick<EvidenceSeries, "key" | "label" | "color">;
type TooltipRow = TooltipSeries & { value: number };
type TooltipProbabilityRow = { key: string; label: string; value: string; color: string };
function isRunwayTooltipSeries(seriesKey: string) {
return seriesKey.startsWith("runway_");
@@ -36,38 +41,66 @@ export function TemperatureTooltipContent({
payload,
data,
series,
probabilityOverlay,
tempSymbol = "°C",
isEn = false,
}: {
active?: boolean;
label?: string | number;
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
data: Array<Record<string, any>>;
series: TooltipSeries[];
probabilityOverlay?: ProbabilityOverlay | null;
tempSymbol?: string;
isEn?: boolean;
}) {
if (!active || !payload?.length || !series.length) return null;
const activePoint = payload[0]?.payload || {};
const rows = buildTooltipRows(activePoint, data, series);
if (!rows.length) return null;
if (!active) return null;
const activePoint = payload?.[0]?.payload || findTooltipPointByLabel(data, label) || {};
const rows = series.length ? buildTooltipRows(activePoint, data, series) : [];
const probabilityRows = buildTooltipProbabilityRows(probabilityOverlay, tempSymbol, isEn);
if (!rows.length && !probabilityRows.length) return null;
return (
<div className="rounded border border-slate-200 bg-white px-2.5 py-2 text-[11px] shadow-lg">
<div className="mb-1 font-mono text-slate-500">{label}</div>
<div className="grid gap-1">
{rows.slice(0, 8).map((item) => (
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
<span className="inline-flex items-center gap-1.5 text-slate-700">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-semibold">{item.label}</span>
</span>
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
</div>
))}
</div>
{rows.length > 0 && (
<div className="grid gap-1">
{rows.slice(0, 8).map((item) => (
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
<span className="inline-flex items-center gap-1.5 text-slate-700">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-semibold">{item.label}</span>
</span>
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
</div>
))}
</div>
)}
{probabilityRows.length > 0 && (
<div className={rows.length > 0 ? "mt-1.5 grid max-h-[260px] gap-1 overflow-y-auto border-t border-slate-100 pt-1.5 pr-1" : "grid max-h-[260px] gap-1 overflow-y-auto pr-1"}>
{probabilityRows.map((item) => (
<div key={item.key} className="flex min-w-[160px] items-center justify-between gap-4">
<span className="inline-flex items-center gap-1.5 text-violet-700">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-semibold">{item.label}</span>
</span>
<strong className="font-mono text-violet-900">{item.value}</strong>
</div>
))}
</div>
)}
</div>
);
}
function findTooltipPointByLabel(
data: Array<Record<string, any>>,
label?: string | number,
) {
if (label === undefined || label === null) return null;
return data.find((point) => String(point.label) === String(label)) || null;
}
function buildTooltipRows(
activePoint: Record<string, any>,
data: Array<Record<string, any>>,
@@ -85,4 +118,51 @@ function buildTooltipRows(
.filter((item): item is TooltipRow => item !== null);
}
function formatProbabilityRangeValue(value: number) {
return Number(value.toFixed(1)).toString();
}
function formatProbabilityRangeLabel(
lower: number,
upper: number,
tempSymbol: string,
) {
return `${formatProbabilityRangeValue(lower)}-${formatProbabilityRangeValue(upper)}${tempSymbol}`;
}
function buildTooltipProbabilityRows(
probabilityOverlay: ProbabilityOverlay | null | undefined,
tempSymbol: string,
isEn: boolean,
): TooltipProbabilityRow[] {
if (!probabilityOverlay) return [];
const rows: TooltipProbabilityRow[] = [];
const mu = validNumber(probabilityOverlay.muLine?.value);
if (mu !== null) {
rows.push({
key: "legacy_probability_mu",
label: isEn ? "Gaussian μ" : "高斯 μ",
value: `${mu.toFixed(1)}${tempSymbol}`,
color: "#8b5cf6",
});
}
rows.push(
...[...probabilityOverlay.bands]
.filter((band) => validNumber(band.probability) !== null && band.probability > 0)
.sort((a, b) => a.lower - b.lower)
.map((band) => {
const probabilityPct = Math.round(band.probability * 100);
return {
key: band.key,
label: formatProbabilityRangeLabel(band.lower, band.upper, tempSymbol),
value: `${probabilityPct}%`,
color: "#a78bfa",
};
}),
);
return rows;
}
export const __buildTemperatureTooltipRowsForTest = buildTooltipRows;
export const __buildTemperatureTooltipProbabilityRowsForTest = buildTooltipProbabilityRows;
@@ -11,6 +11,7 @@ import {
YAxis,
} from "recharts";
import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react";
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
type MetricPayload = {
hit_rate: number;
@@ -19,13 +20,79 @@ type MetricPayload = {
brier_score?: number;
};
type DebWindowSummary = {
start_date?: string | null;
end_date?: string | null;
samples?: number;
hits?: number;
hit_rate?: number | null;
mae?: number | null;
bias?: number | null;
city_count?: number;
};
type DebHistoricalSummary = {
city_count?: number;
avg_hit_rate?: number | null;
weighted_hit_rate?: number | null;
avg_mae?: number | null;
avg_days_per_city?: number;
sample_days?: number;
hits?: number;
};
type DebUsableRecentSummary = {
window?: "recent_7d" | "recent_14d" | string;
city_count?: number;
samples?: number;
hits?: number;
hit_rate?: number | null;
avg_mae?: number | null;
recommendations?: {
primary?: number;
supporting?: number;
};
};
type DebVersionSummary = {
version?: string;
samples?: number;
mae?: number | null;
rmse?: number | null;
bias?: number | null;
bucket_hit_rate?: number | null;
};
type DebSummaryPayload = {
historical?: DebHistoricalSummary;
usable_recent?: DebUsableRecentSummary;
recent_7d?: DebWindowSummary;
recent_14d?: DebWindowSummary;
versions?: Record<string, DebVersionSummary>;
};
type DebRecentStrategy = {
recent_7d?: DebWindowSummary;
recent_14d?: DebWindowSummary;
trust_tier?: "high" | "medium" | "low" | "insufficient" | string;
recommendation?: "primary" | "supporting" | "context_only" | "insufficient" | string;
bias_direction?: "under" | "over" | "neutral" | "unknown" | string;
reason?: string;
};
type TrainingCity = {
city_id: string;
name: string;
deb?: MetricPayload;
deb_recent?: DebRecentStrategy | null;
mu?: MetricPayload;
};
type TrainingAccuracyPayload = {
accuracy: TrainingCity[];
deb_summary?: DebSummaryPayload;
};
const STAT_CARD_CLASSES: Record<string, string> = {
blue: "bg-blue-50 border-blue-200",
emerald: "bg-emerald-50 border-emerald-200",
@@ -45,48 +112,78 @@ function barColor(hr: number) {
return "#dc2626";
}
function trustBadgeClass(tier?: string) {
if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700";
return "border-slate-200 bg-slate-50 text-slate-500";
}
function trustLabel(tier: string | undefined, isEn: boolean) {
if (tier === "high") return isEn ? "High" : "高";
if (tier === "medium") return isEn ? "Medium" : "中";
if (tier === "low") return isEn ? "Low" : "低";
return isEn ? "Thin" : "少";
}
function recommendationLabel(value: string | undefined, isEn: boolean) {
if (value === "primary") return isEn ? "Primary" : "主用";
if (value === "supporting") return isEn ? "Support" : "辅助";
if (value === "context_only") return isEn ? "Context" : "参考";
return isEn ? "Insufficient" : "样本少";
}
const TRAINING_CACHE_KEY = "polyweather_training_accuracy_v1";
const TRAINING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
function readTrainingCache(): TrainingCity[] | null {
function readTrainingCache(): TrainingAccuracyPayload | null {
try {
const raw = localStorage.getItem(TRAINING_CACHE_KEY);
if (!raw) return null;
const cached = JSON.parse(raw);
if (cached.ts && Date.now() - cached.ts < TRAINING_CACHE_TTL_MS && Array.isArray(cached.data)) {
return cached.data;
if (cached.ts && Date.now() - cached.ts < TRAINING_CACHE_TTL_MS) {
if (Array.isArray(cached.data)) return { accuracy: cached.data };
if (cached.data && Array.isArray(cached.data.accuracy)) return cached.data;
}
} catch { /* ignore */ }
return null;
}
function writeTrainingCache(data: TrainingCity[]) {
function writeTrainingCache(data: TrainingAccuracyPayload) {
try {
localStorage.setItem(TRAINING_CACHE_KEY, JSON.stringify({ ts: Date.now(), data }));
} catch { /* ignore */ }
}
export function TrainingDashboard({ isEn }: { isEn: boolean }) {
const [data, setData] = useState<TrainingCity[] | null>(() => readTrainingCache());
const [payload, setPayload] = useState<TrainingAccuracyPayload | null>(() => readTrainingCache());
useEffect(() => {
let cancelled = false;
fetch("/api/ops/training/accuracy", { cache: "no-store", headers: { Accept: "application/json" } })
.then(async (res) => {
if (!res.ok) return null;
return res.json() as Promise<{ accuracy: TrainingCity[] }>;
return res.json() as Promise<TrainingAccuracyPayload>;
})
.then((payload) => {
if (cancelled || !payload?.accuracy) return;
const filtered = payload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5);
setData(filtered);
writeTrainingCache(filtered);
.then((nextPayload) => {
if (cancelled || !nextPayload?.accuracy) return;
const filtered = nextPayload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5);
const next = { ...nextPayload, accuracy: filtered };
setPayload(next);
writeTrainingCache(next);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const data = payload?.accuracy ?? null;
const debSummary = payload?.deb_summary;
const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]);
const debRecentRanked = useMemo(() => buildDebRecentRankingRows(data || []), [data]);
const debRecentRankIndex = useMemo(
() => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
[debRecentRanked],
);
const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]);
const debStats = useMemo(() => {
@@ -94,8 +191,16 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length;
const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length;
const avgDays = Math.round(debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0) / Math.max(debSorted.length, 1));
return { avgHit, avgMae, avgDays, cities: debSorted.length };
}, [debSorted]);
return {
avgHit: debSummary?.historical?.avg_hit_rate ?? avgHit,
avgMae: debSummary?.historical?.avg_mae ?? avgMae,
avgDays: debSummary?.historical?.avg_days_per_city ?? avgDays,
cities: debSummary?.historical?.city_count ?? debSorted.length,
sampleDays: debSummary?.historical?.sample_days,
weightedHit: debSummary?.historical?.weighted_hit_rate,
usableRecent: debSummary?.usable_recent,
};
}, [debSorted, debSummary]);
const muStats = useMemo(() => {
if (!muSorted.length) return null;
@@ -107,12 +212,19 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
}, [muSorted]);
const debHitChart = useMemo(
() => debSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.hit_rate ?? 0).toFixed(1)) })),
[debSorted],
() => debRecentRanked.slice(0, 18).map((c) => ({ name: c.name, value: c.hitRate })),
[debRecentRanked],
);
const debMaeChart = useMemo(
() => [...debSorted].sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.mae ?? 0).toFixed(2)) })),
[debSorted],
() => [...debRecentRanked]
.sort((a, b) => {
if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
return a.mae - b.mae;
})
.slice(0, 18)
.map((c) => ({ name: c.name, value: c.mae })),
[debRecentRanked],
);
const muHitChart = useMemo(
() => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })),
@@ -122,6 +234,22 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
() => [...muSorted].sort((a, b) => (a.mu?.brier_score ?? 99) - (b.mu?.brier_score ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.brier_score ?? 0).toFixed(3)) })),
[muSorted],
);
const debVersionRows = useMemo(() => {
const versions = debSummary?.versions || {};
return [
{ key: "deb_v1_raw", label: isEn ? "Raw DEB" : "原始 DEB" },
{ key: "deb_v1_recent_bias_corrected", label: isEn ? "Mean Bias" : "均值偏差" },
{ key: "deb_v2_bucket_calibrated", label: isEn ? "Bucket v2" : "桶校准 v2" },
{ key: "deb_v3_guarded_calibrated", label: isEn ? "Guarded v3" : "保护 v3" },
].map(({ key, label }) => ({ key, label, value: versions[key] })).filter((row) => row.value);
}, [debSummary?.versions, isEn]);
const formatPct = (value: number | null | undefined) => value == null ? "--" : `${value.toFixed(1)}%`;
const formatMaybeDeg = (value: number | null | undefined, digits = 1) => value == null ? "--" : `${value.toFixed(digits)}°`;
const usableWindowLabel = (window?: string) => {
if (window === "recent_14d") return isEn ? "Usable 14d" : "可用近14天";
return isEn ? "Usable 7d" : "可用近7天";
};
return (
<div className="h-full overflow-auto bg-[#f5f7fa]">
@@ -143,12 +271,14 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
<Thermometer size={14} className="text-amber-600" />
{isEn ? "DEB Temperature Forecast" : "DEB 气温预报"}
</h2>
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="grid grid-cols-2 gap-2 mb-3 md:grid-cols-3 xl:grid-cols-6">
{[
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" },
{ icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${debStats.avgHit.toFixed(1)}%`, tone: "emerald" },
{ icon: Target, label: usableWindowLabel(debStats.usableRecent?.window), value: formatPct(debStats.usableRecent?.hit_rate), tone: "emerald" },
{ icon: TrendingUp, label: isEn ? "Recent 7d" : "近7天", value: formatPct(debSummary?.recent_7d?.hit_rate), tone: "emerald" },
{ icon: TrendingUp, label: isEn ? "Recent 14d" : "近14天", value: formatPct(debSummary?.recent_14d?.hit_rate), tone: "purple" },
{ icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" },
{ icon: TrendingUp, label: isEn ? "Avg Days/City" : "每城平均天数", value: debStats.avgDays.toLocaleString(), tone: "purple" },
{ icon: Hash, label: isEn ? "Samples" : "样本天数", value: (debStats.sampleDays ?? debStats.avgDays).toLocaleString(), tone: "blue" },
].map(({ icon: Icon, label, value, tone }) => (
<div key={label} className={`flex items-center gap-3 rounded-lg border ${STAT_CARD_CLASSES[tone]} p-3`}>
<Icon size={20} className={STAT_ICON_CLASSES[tone]} />
@@ -159,8 +289,27 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
</div>
))}
</div>
{debVersionRows.length ? (
<div className="mb-3 grid grid-cols-1 gap-2 md:grid-cols-3">
{debVersionRows.map((row) => {
const bucketRate = row.value?.bucket_hit_rate == null ? null : row.value.bucket_hit_rate * 100;
return (
<div key={row.key} className="rounded-lg border border-slate-200 bg-white px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-black uppercase text-slate-500">{row.label}</span>
<span className="font-mono text-sm font-black text-slate-900">{formatPct(bucketRate)}</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{isEn ? "MAE" : "误差"} {formatMaybeDeg(row.value?.mae, 2)}</span>
<span>{isEn ? "Samples" : "样本"} {row.value?.samples ?? 0}</span>
</div>
</div>
);
})}
</div>
) : null}
<div className="grid grid-cols-2 gap-3 mb-4">
<ChartCard title={isEn ? "Forecast Hit Rate by City" : "预报命中率 by 城市"}>
<ChartCard title={isEn ? "Usable Recent Hit Rate by City" : "可用近期命中率 by 城市"}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={debHitChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
@@ -171,7 +320,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title={isEn ? "Forecast Error by City (lower = better)" : "预报误差 by 城市(越低越好)"}>
<ChartCard title={isEn ? "Usable Recent Error by City (lower = better)" : "可用近期误差 by 城市(越低越好)"}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={debMaeChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
@@ -237,12 +386,15 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
)}
{/* ── Combined Table ── */}
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
<table className="w-full text-[12px] border-collapse">
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-[960px] w-full text-[12px] border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left">
<th className="w-10 px-3 py-2 text-center text-[11px] font-black text-slate-400">#</th>
<th className="px-3 py-2 text-[11px] font-black uppercase text-slate-500">{isEn ? "City" : "城市"}</th>
<th className="px-2 py-2 text-left text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Trust" : "DEB 信任"}</th>
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "7d" : "7天"}</th>
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "14d" : "14天"}</th>
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Hit" : "DEB 命中"}</th>
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Error" : "DEB 误差"}</th>
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "μ Hit" : "μ 命中"}</th>
@@ -253,28 +405,47 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
<tbody className="divide-y divide-slate-100">
{debSorted.length || muSorted.length ? (
(() => {
const cities = new Map<string, { deb?: MetricPayload; mu?: MetricPayload; name: string }>();
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
const cities = new Map<string, { deb?: MetricPayload; debRecent?: DebRecentStrategy | null; mu?: MetricPayload; name: string }>();
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name });
for (const c of muSorted) {
const existing = cities.get(c.city_id);
if (existing) existing.mu = c.mu;
else cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
else cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name });
}
const merged = [...cities.entries()]
.sort((a, b) => {
const aDebRank = debRecentRankIndex.get(a[0]) ?? 9999;
const bDebRank = debRecentRankIndex.get(b[0]) ?? 9999;
if (aDebRank !== bDebRank) return aDebRank - bDebRank;
const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0);
const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0);
return bMax - aMax;
})
.slice(0, 30);
return merged.map(([cityId, { deb, mu, name }], i) => {
return merged.map(([cityId, { deb, debRecent, mu, name }], i) => {
const debHit = deb?.hit_rate ?? 0;
const muHit = mu?.hit_rate ?? 0;
const brier = mu?.brier_score;
const recent7 = debRecent?.recent_7d?.hit_rate;
const recent14 = debRecent?.recent_14d?.hit_rate;
return (
<tr key={cityId} className="hover:bg-slate-50 transition-colors">
<td className="px-3 py-2 text-center text-[12px] font-mono text-slate-400">{i + 1}</td>
<td className="px-3 py-2 font-semibold text-slate-800 capitalize">{name}</td>
<td className="px-2 py-2">
{debRecent ? (
<span
className={`inline-flex min-w-[4rem] items-center justify-center rounded border px-2 py-0.5 text-[10px] font-black uppercase ${trustBadgeClass(debRecent.trust_tier)}`}
title={debRecent.reason}
>
{trustLabel(debRecent.trust_tier, isEn)} · {recommendationLabel(debRecent.recommendation, isEn)}
</span>
) : (
<span className="text-slate-400">--</span>
)}
</td>
<td className="px-2 py-2 text-right font-mono text-slate-600">{recent7 == null ? "--" : `${recent7.toFixed(0)}%`}</td>
<td className="px-2 py-2 text-right font-mono text-slate-600">{recent14 == null ? "--" : `${recent14.toFixed(0)}%`}</td>
<td className="px-2 py-2 text-right font-mono font-bold" style={{ color: barColor(debHit) }}>
{deb ? `${debHit.toFixed(0)}%` : "--"}
</td>
@@ -290,7 +461,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
})()
) : (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-slate-400">
<td colSpan={10} className="px-4 py-12 text-center text-slate-400">
{data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
</td>
</tr>
@@ -0,0 +1,150 @@
"use client";
import { Megaphone, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
type AnnouncementText = {
title: string;
body: string;
};
type StaticUpdateAnnouncement = {
id: string;
publishedAt: string;
expiresAt: string;
zh: AnnouncementText;
en: AnnouncementText;
};
type UpdateAnnouncementButtonProps = {
isEn: boolean;
};
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
{
id: "feedback-status-2026-06",
publishedAt: "2026-06-07T00:00:00+08:00",
expiresAt: "2026-07-15T00:00:00+08:00",
zh: {
title: "更新公告:终端新增公告与反馈状态",
body:
"PolyWeather 天气决策台新增“更新公告”入口,后续产品更新、数据源调整和重要说明会在这里同步。\n\n" +
"用户反馈系统也已升级:提交反馈时会自动附带相关图表上下文,用户可以在终端右上角通知入口和账户页查看自己反馈的处理状态,包括已收到、已确认、处理中、已解决和已关闭。\n\n" +
"我们也在考虑对真实、可复现、有建设性价值的反馈加入积分或 Pro 天数激励。",
},
en: {
title: "Update: announcements and feedback status are now live",
body:
"PolyWeather Terminal now has an update announcement entry. Future product updates, data-source changes, and important notes will be shared here.\n\n" +
"The feedback system has also been upgraded. When users submit feedback, PolyWeather automatically attaches the relevant chart context. Users can now track their own feedback status from the terminal notification entry and the account page: received, confirmed, in progress, resolved, or closed.\n\n" +
"We are also considering small rewards such as points or Pro days for real, reproducible, and constructive feedback.",
},
},
];
function isActiveAnnouncement(item: StaticUpdateAnnouncement, now = Date.now()) {
const expiresAt = Date.parse(item.expiresAt);
if (!Number.isFinite(expiresAt) || expiresAt <= now) return false;
const publishedAt = Date.parse(item.publishedAt);
return !Number.isFinite(publishedAt) || publishedAt <= now;
}
function pickAnnouncementText(payload: StaticUpdateAnnouncement, isEn: boolean) {
const primary = isEn ? payload.en : payload.zh;
const fallback = isEn ? payload.zh : payload.en;
return {
title: String(primary?.title || fallback?.title || "").trim(),
body: String(primary?.body || fallback?.body || "").trim(),
};
}
function formatUpdatedAt(value: string | undefined, isEn: boolean) {
if (!value) return "";
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return "";
return date.toLocaleString(isEn ? "en-US" : "zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
const [open, setOpen] = useState(false);
const shellRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!shellRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
const announcement = useMemo(
() => STATIC_UPDATE_ANNOUNCEMENTS.find((item) => isActiveAnnouncement(item)) ?? null,
[],
);
const text = useMemo(
() => (announcement ? pickAnnouncementText(announcement, isEn) : { title: "", body: "" }),
[announcement, isEn],
);
const updatedAt = useMemo(
() => formatUpdatedAt(announcement?.publishedAt, isEn),
[announcement?.publishedAt, isEn],
);
if (!announcement || (!text.title && !text.body)) return null;
return (
<div ref={shellRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
title={isEn ? "Update announcement" : "更新公告"}
aria-expanded={open}
>
<Megaphone size={12} />
{isEn ? "Updates" : "更新公告"}
</button>
{open && (
<div className="absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg">
<div className="flex items-start gap-3">
<div className="grid h-7 w-7 shrink-0 place-items-center rounded border border-blue-100 bg-blue-50 text-blue-600">
<Megaphone size={14} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold leading-5 text-slate-900">
{text.title || (isEn ? "PolyWeather update" : "PolyWeather 更新")}
</div>
{updatedAt && (
<div className="mt-0.5 font-mono text-[10px] text-slate-400">
{isEn ? "Updated" : "更新"} {updatedAt}
</div>
)}
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="grid h-6 w-6 shrink-0 place-items-center rounded border border-slate-200 text-slate-400 hover:bg-slate-50 hover:text-slate-700"
title={isEn ? "Close" : "关闭"}
>
<X size={12} />
</button>
</div>
{text.body && (
<p className="mt-3 whitespace-pre-line text-[12px] font-medium leading-5 text-slate-600">
{text.body}
</p>
)}
</div>
)}
</div>
);
}
@@ -38,8 +38,8 @@ const quickStart: Record<"zh" | "en", GuideCopy[]> = {
body: "橙色 DEB Forecast 是融合模型和日内修正后的路径,用来判断后续升温或降温空间。",
},
{
title: "最后看概率",
body: "紫色区域和虚线表示高概率温度带,适合判断当前实测是否偏离主预期。",
title: "最后看图层",
body: "按需打开模型线或跑道明细,用来确认当前实测是否偏离 DEB 主路径。",
},
],
en: [
@@ -56,8 +56,8 @@ const quickStart: Record<"zh" | "en", GuideCopy[]> = {
body: "The orange DEB Forecast blends model context with intraday correction to frame the remaining move.",
},
{
title: "Check probability",
body: "The purple band and dotted line show the high-probability temperature zone for fast deviation checks.",
title: "Check layers",
body: "Use model lines or runway details only when needed to confirm whether live observations are drifting away from the DEB path.",
},
],
};
@@ -66,7 +66,7 @@ const legendItems: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{ title: "实测 / 结算线", body: "优先展示结算跑道、官方站或城市核心实况,用于判断已兑现温度。" },
{ title: "DEB Forecast", body: "橙色预测路径,重点看它和实测线在峰值窗口前后的分歧。" },
{ title: "概率", body: "紫色带表示当前概率分布的主要落点,虚线是概率均值附近。" },
{ title: "市场概率", body: "概率仍用于市场判断和后台分析,但默认不再占用温度主图空间。" },
{ title: "机场报文", body: "METAR / MGM 作为机场站参考,默认只在适合的城市自动显示。" },
{ title: "模型线", body: "ECMWF、GFS、ICON、GEM 等提供背景,默认弱化为辅助判断。" },
{ title: "跑道明细", body: "打开后可查看各跑道传感器,关闭后仍保留结算跑道温度。" },
@@ -74,7 +74,7 @@ const legendItems: Record<"zh" | "en", GuideCopy[]> = {
en: [
{ title: "Live / settlement", body: "Settlement runway, official station, or core live observation used as the realized anchor." },
{ title: "DEB Forecast", body: "Orange forecast path; focus on its gap versus live observations near the peak window." },
{ title: "Probability band", body: "Purple band marks the main probability zone, with the dotted line near the probability mean." },
{ title: "Market probability", body: "Probability remains available for market analysis, but no longer occupies the main temperature chart by default." },
{ title: "Airport reports", body: "METAR / MGM are airport references and are auto-shown only where they are useful by default." },
{ title: "Model lines", body: "ECMWF, GFS, ICON, GEM, and related model layers provide background context." },
{ title: "Runway details", body: "When disabled, the chart still keeps the settlement runway temperature visible." },
@@ -160,8 +160,8 @@ export function UsageGuideDashboard({ isEn }: { isEn: boolean }) {
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
{isEn
? "Start from live evidence, compare DEB, then use probability and layer toggles to confirm whether the city is moving away from the main path."
: "先看实况锚点,再对照 DEB 路径,最后用概率带和图层显隐确认城市是否偏离主预期。"}
? "Start from live evidence, compare DEB, then use layer toggles to confirm whether the city is moving away from the main path."
: "先看实况锚点,再对照 DEB 路径,最后用图层显隐确认城市是否偏离主预期。"}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-[11px] font-bold text-slate-600">
@@ -0,0 +1,283 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { Bug, CheckCircle2, Lightbulb, MessageSquare, WalletCards, X } from "lucide-react";
import {
getAnalyticsClientId,
getAnalyticsSessionId,
} from "@/lib/app-analytics";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import type { UserFeedbackEntry } from "@/types/ops";
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
export type FeedbackDraft = {
category?: FeedbackCategory;
source?: string;
context?: Record<string, unknown>;
};
const CATEGORY_OPTIONS: Array<{
key: FeedbackCategory;
labelZh: string;
labelEn: string;
Icon: typeof Bug;
}> = [
{ key: "bug", labelZh: "Bug", labelEn: "Bug", Icon: Bug },
{ key: "data", labelZh: "数据问题", labelEn: "Data issue", Icon: MessageSquare },
{ key: "idea", labelZh: "功能建议", labelEn: "Suggestion", Icon: Lightbulb },
{ key: "payment", labelZh: "支付问题", labelEn: "Payment", Icon: WalletCards },
];
function buildRuntimeContext(extra: Record<string, unknown>) {
if (typeof window === "undefined") return extra;
return {
...extra,
client_id: getAnalyticsClientId() || "",
session_id: getAnalyticsSessionId() || "",
path: window.location.pathname,
href: window.location.href,
language: navigator.language || "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
user_agent: navigator.userAgent || "",
viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`,
captured_at: new Date().toISOString(),
};
}
export function UserFeedbackModal({
draft,
isEn,
onClose,
onSubmitted,
}: {
draft: FeedbackDraft | null;
isEn: boolean;
onClose: () => void;
onSubmitted?: (entry?: UserFeedbackEntry) => void;
}) {
const open = Boolean(draft);
const [category, setCategory] = useState<FeedbackCategory>("bug");
const [message, setMessage] = useState("");
const [contact, setContact] = useState("");
const [loginEmailContact, setLoginEmailContact] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (!open) return;
setCategory(draft?.category || "bug");
setMessage("");
setContact("");
setLoginEmailContact("");
setError("");
setSubmitted(false);
}, [open, draft?.category]);
useEffect(() => {
if (!open || !hasSupabasePublicEnv()) return;
let cancelled = false;
const loadLoginEmail = async () => {
try {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const email = String(session?.user?.email || "").trim();
if (cancelled || !email) return;
setLoginEmailContact(email);
setContact(email);
} catch {
// Feedback can still be submitted; the backend will attach auth email when present.
}
};
void loadLoginEmail();
return () => {
cancelled = true;
};
}, [open]);
const contextPreview = useMemo(() => {
const context = draft?.context || {};
const city = String(context.city || context.display_city || "").trim();
const source = String(draft?.source || context.source || "terminal").trim();
if (city) {
return isEn ? `Context: ${city} · ${source}` : `上下文:${city} · ${source}`;
}
return isEn ? `Context: ${source}` : `上下文:${source}`;
}, [draft, isEn]);
if (!open) return null;
const canSubmit = message.trim().length >= 3 && !submitting;
const submit = async () => {
if (!canSubmit) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
message: message.trim(),
contact: contact.trim() || undefined,
source: draft?.source || "terminal",
context: buildRuntimeContext(draft?.context || {}),
}),
});
if (!res.ok) {
const detail = (await res.text().catch(() => "")).slice(0, 200);
throw new Error(detail || `HTTP ${res.status}`);
}
const payload = (await res.json().catch(() => null)) as {
feedback?: UserFeedbackEntry;
} | null;
setSubmitted(true);
onSubmitted?.(payload?.feedback);
} catch (err) {
setError(String(err).slice(0, 220));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-[2px]">
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-slate-200 bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<div className="text-sm font-black text-slate-950">
{isEn ? "Send feedback" : "提交反馈"}
</div>
<div className="mt-0.5 text-xs text-slate-500">{contextPreview}</div>
</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 hover:text-slate-900"
aria-label={isEn ? "Close feedback" : "关闭反馈"}
>
<X size={16} />
</button>
</div>
{submitted ? (
<div className="px-5 py-8 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<div className="mt-3 text-sm font-black text-slate-950">
{isEn ? "Feedback received" : "反馈已收到"}
</div>
<p className="mx-auto mt-2 max-w-sm text-xs leading-5 text-slate-500">
{isEn
? "We saved the report with the current terminal context. The bell icon will show handling updates."
: "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}
</p>
<button
type="button"
onClick={onClose}
className="mt-5 rounded bg-blue-600 px-4 py-2 text-xs font-black text-white transition hover:bg-blue-700"
>
{isEn ? "Done" : "完成"}
</button>
</div>
) : (
<div className="space-y-4 p-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{CATEGORY_OPTIONS.map(({ key, labelZh, labelEn, Icon }) => (
<button
key={key}
type="button"
onClick={() => setCategory(key)}
className={clsx(
"flex h-11 items-center justify-center gap-2 rounded border px-2 text-xs font-bold transition",
category === key
? "border-blue-300 bg-blue-50 text-blue-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
)}
>
<Icon size={14} />
<span>{isEn ? labelEn : labelZh}</span>
</button>
))}
</div>
<label className="block">
<span className="text-xs font-bold text-slate-600">
{isEn ? "What happened?" : "问题或建议"}
</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
rows={5}
className="mt-1 w-full resize-none rounded border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
placeholder={
isEn
? "Example: This chart keeps loading after I switch to Helsinki."
: "例如:切到 Helsinki 后图表一直加载。"
}
/>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-600">
{loginEmailContact
? isEn ? "Contact (login email)" : "联系方式(登录邮箱)"
: isEn ? "Contact, optional" : "联系方式,可选"}
</span>
<input
value={contact}
onChange={(event) => {
if (!loginEmailContact) setContact(event.target.value);
}}
readOnly={Boolean(loginEmailContact)}
className={clsx(
"mt-1 h-9 w-full rounded border border-slate-200 px-3 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100",
loginEmailContact ? "bg-slate-50 text-slate-600" : "bg-white",
)}
placeholder={isEn ? "Email or Telegram" : "邮箱或 Telegram"}
/>
</label>
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-4 text-slate-500">
{isEn
? "Terminal context is attached automatically: city, slot, source state, browser and session diagnostics."
: "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"}
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{isEn ? "Submit failed: " : "提交失败:"}{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="h-9 rounded border border-slate-200 px-4 text-xs font-bold text-slate-600 transition hover:bg-slate-50"
>
{isEn ? "Cancel" : "取消"}
</button>
<button
type="button"
onClick={submit}
disabled={!canSubmit}
className="h-9 rounded bg-blue-600 px-4 text-xs font-black text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? (isEn ? "Sending..." : "提交中...") : (isEn ? "Send" : "提交")}
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,284 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bell, RefreshCcw } from "lucide-react";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
FEEDBACK_STATUS_CACHE_TTL_MS,
FEEDBACK_STATUS_POLL_MS,
feedbackStatusLabel,
feedbackStatusTone,
} from "./feedback-status";
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
const FEEDBACK_STATUS_CACHE_KEY = "polyweather_feedback_status_cache_v1";
function loadSeenKeys() {
if (typeof window === "undefined") return new Set<string>();
try {
const raw = window.localStorage.getItem(FEEDBACK_STATUS_SEEN_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
} catch {
return new Set<string>();
}
}
function saveSeenKeys(keys: Set<string>) {
if (typeof window === "undefined") return;
try {
const trimmed = Array.from(keys).slice(-200);
window.localStorage.setItem(FEEDBACK_STATUS_SEEN_KEY, JSON.stringify(trimmed));
} catch {
// Ignore storage failures; the badge can reappear without breaking feedback status.
}
}
function readFeedbackStatusCache(): { entries: UserFeedbackEntry[]; ts: number } | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(FEEDBACK_STATUS_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const ts = Number(parsed?.ts || 0);
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
if (!ts || Date.now() - ts > FEEDBACK_STATUS_CACHE_TTL_MS) return null;
return { entries, ts };
} catch {
return null;
}
}
function writeFeedbackStatusCache(entries: UserFeedbackEntry[]) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
FEEDBACK_STATUS_CACHE_KEY,
JSON.stringify({ entries: entries.slice(0, 20), ts: Date.now() }),
);
} catch {
// Ignore storage failures; status will still refresh over the network.
}
}
function compactDate(value?: string) {
if (!value) return "";
return value.slice(0, 16).replace("T", " ");
}
function categoryLabel(value?: string, isEn = false) {
const key = String(value || "").toLowerCase();
if (key === "bug") return "Bug";
if (key === "data") return isEn ? "Data" : "数据";
if (key === "idea") return isEn ? "Suggestion" : "建议";
if (key === "payment") return isEn ? "Payment" : "支付";
if (key === "account") return isEn ? "Account" : "账号";
return isEn ? "Other" : "其他";
}
export function UserFeedbackStatusButton({
isEn,
refreshKey = 0,
}: {
isEn: boolean;
refreshKey?: number;
}) {
const [available, setAvailable] = useState(true);
const [entries, setEntries] = useState<UserFeedbackEntry[]>(() => readFeedbackStatusCache()?.entries || []);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
const lastLoadedAtRef = useRef<number>(readFeedbackStatusCache()?.ts || 0);
const unseenCount = useMemo(
() => countUnseenFeedbackUpdates(entries, seenKeys),
[entries, seenKeys],
);
const emptyStateText = loading
? isEn
? "Loading..."
: "加载中..."
: isEn
? "No submitted feedback yet."
: "暂无已提交反馈。";
const load = useCallback(async (
signal?: AbortSignal,
options?: { force?: boolean },
) => {
if (typeof fetch !== "function") return;
const cached = readFeedbackStatusCache();
if (!options?.force && cached) {
lastLoadedAtRef.current = cached.ts;
setEntries(cached.entries);
setAvailable(true);
return;
}
const now = Date.now();
if (
!options?.force &&
lastLoadedAtRef.current &&
now - lastLoadedAtRef.current < FEEDBACK_STATUS_CACHE_TTL_MS
) {
return;
}
setLoading(true);
try {
const res = await fetch("/api/feedback?limit=12", {
cache: "no-store",
headers: { Accept: "application/json" },
signal,
});
if (res.status === 401 || res.status === 403) {
setAvailable(false);
setEntries([]);
return;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const payload = (await res.json()) as UserFeedbackPayload;
const nextEntries = Array.isArray(payload.feedback) ? payload.feedback : [];
setEntries(nextEntries);
writeFeedbackStatusCache(nextEntries);
lastLoadedAtRef.current = Date.now();
setAvailable(true);
setError("");
} catch (err) {
if (signal?.aborted) return;
setError(String(err).slice(0, 120));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
const markVisibleAsSeen = useCallback((visibleEntries: UserFeedbackEntry[]) => {
setSeenKeys((current) => {
const next = new Set(current);
visibleEntries.forEach((entry) => next.add(buildFeedbackNotificationKey(entry)));
saveSeenKeys(next);
return next;
});
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal, { force: refreshKey > 0 });
const handleVisibilityChange = () => {
if (
document.visibilityState === "visible" &&
Date.now() - lastLoadedAtRef.current >= FEEDBACK_STATUS_CACHE_TTL_MS
) {
void load();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const id = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
}, FEEDBACK_STATUS_POLL_MS);
return () => {
controller.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.clearInterval(id);
};
}, [load, refreshKey]);
useEffect(() => {
if (open) markVisibleAsSeen(entries);
}, [entries, markVisibleAsSeen, open]);
if (!available) return null;
return (
<div className="relative">
<button
type="button"
onClick={() => {
setOpen((value) => {
const next = !value;
if (next) markVisibleAsSeen(entries);
return next;
});
}}
className="relative grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
title={isEn ? "Feedback status" : "反馈处理状态"}
aria-label={isEn ? "Feedback status" : "反馈处理状态"}
>
<Bell size={13} />
{unseenCount > 0 && (
<span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-blue-600 px-1 text-[9px] font-black leading-none text-white ring-2 ring-white">
{unseenCount > 9 ? "9+" : unseenCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-9 z-[70] w-[340px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-800 shadow-xl">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<div>
<div className="text-xs font-black text-slate-950">
{isEn ? "Feedback updates" : "反馈处理动态"}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"}
</div>
</div>
<button
type="button"
onClick={() => void load(undefined, { force: true })}
disabled={loading}
className="grid h-7 w-7 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
title={isEn ? "Refresh" : "刷新"}
aria-label={isEn ? "Refresh feedback status" : "刷新反馈状态"}
>
<RefreshCcw size={13} className={loading ? "animate-spin" : ""} />
</button>
</div>
{error && (
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
{isEn ? "Status refresh failed: " : "状态刷新失败:"}{error}
</div>
)}
{entries.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-slate-500">
{emptyStateText}
</div>
) : (
<div className="max-h-[360px] overflow-y-auto">
{entries.map((entry) => (
<div key={entry.id} className="border-b border-slate-100 px-3 py-2 last:border-b-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-bold text-slate-900">
{entry.message || (isEn ? "Feedback" : "反馈")}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
<span>{categoryLabel(entry.category, isEn)}</span>
{entry.created_at && <span>{compactDate(entry.created_at)}</span>}
</div>
</div>
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
{feedbackStatusLabel(entry.status, isEn)}
</span>
</div>
{entry.updated_at && entry.updated_at !== entry.created_at && (
<div className="mt-1 text-[11px] text-slate-500">
{isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import * as Chart from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const buildItems = (Chart as any).__buildAdvancedWeatherVariableItemsForTest;
assert(typeof buildItems === "function", "advanced weather variable item builder should be exported for tests");
const buildCadence = (Chart as any).__buildSourceCadenceSummaryForTest;
assert(typeof buildCadence === "function", "source cadence summary builder should be exported for tests");
const items = buildItems(
{
metar_context: {
airport_wind_speed_kt: 7,
airport_wind_dir: 220,
airport_humidity: 68,
},
},
{
current: { dewpoint: 18.2 },
airportPrimary: {
wind_speed_kt: 9,
wind_dir: 240,
humidity: 64,
pressure_hpa: 1009.4,
source_label: "MADIS HFMETAR",
},
},
false,
);
assert(items.length === 5, `expected five advanced variable items, got ${items.length}`);
assert(items.some((item: any) => item.key === "wind_dir" && item.value === "240°"), "wind direction should prefer airport-primary detail data");
assert(items.some((item: any) => item.key === "wind_speed" && item.value === "9 kt"), "wind speed should include kt units");
assert(items.some((item: any) => item.key === "dewpoint" && item.value === "18.2°C"), "dew point should render from current conditions");
assert(items.some((item: any) => item.key === "humidity" && item.value === "64%"), "humidity should render as a percentage");
assert(items.some((item: any) => item.key === "pressure" && item.value === "1009.4 hPa"), "pressure should render as hPa");
const emptyItems = buildItems({}, {}, true);
assert(emptyItems.length === 0, "advanced variables should stay hidden when no source fields exist");
const backendCadence = buildCadence(
{},
{
airportPrimary: {
source_code: "custom_source",
source_label: "Custom Feed",
freshness: { native_update_interval_sec: 420, freshness_status: "fresh" },
},
},
true,
);
assert(backendCadence?.cadence === "420s", "source cadence should prefer backend native_update_interval_sec");
assert(backendCadence?.label.includes("Custom Feed"), "source cadence should include the source label");
const amscCadence = buildCadence({}, { airportPrimary: { source: "amsc_awos" } }, false);
assert(amscCadence?.cadence === "180s", "AMSC AWOS should fall back to 180s source cadence");
const amosCadence = buildCadence({}, { airportPrimary: { source_label: "AMOS runway" } }, false);
assert(amosCadence?.cadence === "60s", "AMOS should fall back to 60s source cadence");
const madisCadence = buildCadence({}, { airportPrimary: { source_code: "madis_hfmetar" } }, false);
assert(madisCadence?.cadence === "300s", "MADIS should fall back to 300s source cadence");
}
@@ -1,6 +1,9 @@
import {
CITY_LIST_CACHE_TTL_MS,
cityListItemsToScanRows,
mergeScanRowsWithCityFallbackRows,
readCachedCityList,
writeCachedCityList,
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
import type { CityListItem } from "@/lib/dashboard-types";
@@ -47,6 +50,15 @@ export function runTests() {
];
const rows = cityListItemsToScanRows(cities);
const cacheStorage = new Map<string, string>();
const memoryStorage = {
getItem: (key: string) => cacheStorage.get(key) ?? null,
removeItem: (key: string) => { cacheStorage.delete(key); },
setItem: (key: string, value: string) => { cacheStorage.set(key, value); },
};
writeCachedCityList(cities, memoryStorage, 1_000);
const cachedCities = readCachedCityList(memoryStorage, 1_000 + 60_000);
assert(rows.length === 2, "fallback rows should preserve every city");
assert(rows[0].id === "city-fallback:taipei", "fallback row id should be stable");
@@ -57,6 +69,12 @@ export function runTests() {
assert(rows[0].temp_symbol === "°C", "celsius cities should use °C");
assert(rows[1].trading_region === "north_america", "known cities should keep their configured product region");
assert(rows[1].temp_symbol === "°F", "fahrenheit cities should use °F");
assert(cachedCities?.length === 2, "fresh city registry cache should return the stored city list");
assert(cachedCities?.[0]?.name === "taipei", "city registry cache should preserve city payloads");
assert(
readCachedCityList(memoryStorage, 1_000 + CITY_LIST_CACHE_TTL_MS + 1) === null,
"expired city registry cache should be ignored so the dashboard can revalidate",
);
const scanRows = [
{
@@ -122,10 +140,13 @@ export function runTests() {
assert(
dashboardSource.includes("cityListItemsToScanRows") &&
dashboardSource.includes("STATIC_CITY_LIST") &&
dashboardSource.includes("readCachedCityList") &&
dashboardSource.includes("writeCachedCityList") &&
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
dashboardSource.includes("/api/cities") &&
dashboardSource.includes('cache: "default"') &&
dashboardSource.includes("cityFallbackRows"),
"terminal dashboard should seed fallback rows from the static city snapshot before refreshing /api/cities",
"terminal dashboard should seed fallback rows from cached/static city snapshots and avoid no-store /api/cities fetches when possible",
);
assert(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback");
assert(
@@ -34,4 +34,11 @@ export function runTests() {
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
"deploy script must warm terminal and auth snapshot routes after container replacement",
);
assert(
deployScript.includes("validate_frontend_api_base_url") &&
deployScript.includes("POLYWEATHER_API_BASE_URL must not point at the frontend site") &&
deployScript.indexOf("validate_frontend_api_base_url") <
deployScript.indexOf('echo "Updating Redis dependency..."'),
"deploy script must reject frontend POLYWEATHER_API_BASE_URL values that point at polyweather.top and would recurse through the frontend proxy",
);
}
@@ -0,0 +1,61 @@
import fs from "node:fs";
import path from "node:path";
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
FEEDBACK_STATUS_CACHE_TTL_MS,
FEEDBACK_STATUS_POLL_MS,
feedbackStatusLabel,
} from "@/components/dashboard/scan-terminal/feedback-status";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const statusButtonSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx"),
"utf8",
);
assert(FEEDBACK_STATUS_POLL_MS === 30 * 60 * 1000, "feedback bell background polling should run every 30 minutes");
assert(FEEDBACK_STATUS_CACHE_TTL_MS === 10 * 60 * 1000, "feedback bell should reuse a 10 minute local status cache");
assert(
statusButtonSource.includes("FEEDBACK_STATUS_CACHE_KEY") &&
statusButtonSource.includes("readFeedbackStatusCache") &&
statusButtonSource.includes("writeFeedbackStatusCache") &&
statusButtonSource.includes("lastLoadedAtRef") &&
statusButtonSource.includes("FEEDBACK_STATUS_CACHE_TTL_MS"),
"feedback status button must use a local cache and avoid refetching on every visibility resume",
);
assert(feedbackStatusLabel("open", false) === "已收到", "open feedback should read as received to users");
assert(feedbackStatusLabel("triaged", false) === "已确认", "triaged feedback should read as confirmed to users");
assert(feedbackStatusLabel("investigating", false) === "处理中", "investigating feedback should read as in progress");
assert(feedbackStatusLabel("resolved", false) === "已解决", "resolved feedback should read as handled");
assert(feedbackStatusLabel("closed", true) === "Closed", "closed feedback should have English copy");
const firstVersion = {
id: 7,
status: "open",
updated_at: "2026-06-06T10:00:00",
};
const handledVersion = {
...firstVersion,
status: "resolved",
updated_at: "2026-06-06T10:30:00",
};
const firstKey = buildFeedbackNotificationKey(firstVersion);
const handledKey = buildFeedbackNotificationKey(handledVersion);
assert(firstKey !== handledKey, "status and updated_at changes should create a new notification key");
assert(
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey])) === 1,
"already seen feedback versions should not count, but later handled versions should",
);
assert(
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey, handledKey])) === 0,
"opening the status panel should be able to clear all visible feedback update badges",
);
}
@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
const chartSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8",
);
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.tsx");
const statusButtonPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx");
const opsSidebarSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
"utf8",
);
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const feedbackRouteSource = fs.readFileSync(path.join(projectRoot, "app", "api", "feedback", "route.ts"), "utf8");
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
assert(fs.existsSync(statusButtonPath), "terminal must ship a user-facing feedback status notification component");
const modalSource = fs.readFileSync(modalPath, "utf8");
const statusButtonSource = fs.readFileSync(statusButtonPath, "utf8");
const statusHelperSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "feedback-status.ts"),
"utf8",
);
assert(
dashboardSource.includes("onFeedbackClick") &&
dashboardSource.includes("<UserFeedbackModal") &&
dashboardSource.includes("setFeedbackDraft"),
"terminal sidebar must expose a feedback entry that opens the shared modal",
);
assert(
chartSource.includes("onReportIssue") &&
chartSource.includes("Bug") &&
chartSource.includes("detailError"),
"each chart must expose a report-this-chart action with chart loading/error context",
);
assert(
modalSource.includes("/api/feedback") &&
modalSource.includes("getAnalyticsClientId") &&
modalSource.includes("getSupabaseBrowserClient") &&
modalSource.includes("hasSupabasePublicEnv") &&
modalSource.includes(".auth.getSession()") &&
modalSource.includes("readOnly={Boolean(loginEmailContact)}") &&
modalSource.includes("onSubmitted") &&
modalSource.includes("navigator.userAgent") &&
modalSource.includes("type=\"textarea\"") === false,
"feedback modal must submit to the feedback API, lock contact to the login email when available, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
);
assert(
feedbackRouteSource.includes("export async function GET") &&
feedbackRouteSource.includes("method: \"GET\"") &&
feedbackRouteSource.includes("limit"),
"feedback API proxy must expose a GET endpoint for the current user's feedback status list",
);
assert(
dashboardSource.includes("<UserFeedbackStatusButton") &&
dashboardSource.includes("feedbackRefreshKey") &&
dashboardSource.includes("setFeedbackRefreshKey"),
"terminal header must expose a small feedback status notification icon and refresh it after submissions",
);
assert(
statusButtonSource.includes("/api/feedback") &&
statusButtonSource.includes("localStorage") &&
statusButtonSource.includes("Bell") &&
statusHelperSource.includes("triaged") &&
statusHelperSource.includes("resolved"),
"feedback status notification must poll the user feedback API, badge unseen status changes, and translate handled states",
);
assert(
opsSidebarSource.includes("/ops/feedback") &&
opsApiSource.includes("feedback(") &&
opsApiSource.includes("updateFeedbackStatus"),
"ops must expose a feedback inbox in navigation and API client",
);
}
@@ -0,0 +1,86 @@
import { getDocsPage } from "@/content/docs/docs";
import { DOCS_PAGES } from "@/content/docs/docs";
import { DOCS_GROUPS } from "@/content/docs/docs.config";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function pageText(slug: string, locale: "zh-CN" | "en-US") {
const page = getDocsPage(slug);
assert(page, `${slug} docs page should exist`);
return [
page.content[locale].title,
page.content[locale].description,
...page.content[locale].sections.flatMap((section) => [
section.title,
...section.blocks.flatMap((block) => {
if (block.type === "paragraph" || block.type === "callout") return [block.text];
if (block.type === "bullets" || block.type === "steps") return block.items;
if (block.type === "link") return [block.label, block.caption || ""];
if (block.type === "image") return [block.alt, block.caption || ""];
return [];
}),
]),
].join("\n");
}
export function runTests() {
const publicDocSlugs = DOCS_PAGES.map((page) => page.slug);
assert(
publicDocSlugs.join(",") === "intro,chart-guide,realtime-sources,settlement-sources,extension",
"public docs navigation should only expose current shipped user-facing surfaces",
);
for (const group of DOCS_GROUPS) {
assert(
DOCS_PAGES.some((page) => page.group === group.id),
`docs navigation group should not be empty: ${group.id}`,
);
}
const allDocsZh = publicDocSlugs.map((slug) => pageText(slug, "zh-CN")).join("\n");
const allDocsEn = publicDocSlugs.map((slug) => pageText(slug, "en-US")).join("\n");
for (const staleTerm of ["从地图进入", "机会榜", "日历", "EMOS", "LGBM", "城市决策卡", "付费判断台", "刷新锁"]) {
assert(!allDocsZh.includes(staleTerm), `public Chinese docs should not expose stale term: ${staleTerm}`);
}
for (const staleTerm of ["map-launched", "opportunity board", "calendar", "EMOS", "LGBM", "city decision card", "paid decision workspace", "refresh lock"]) {
assert(!allDocsEn.includes(staleTerm), `public English docs should not expose stale term: ${staleTerm}`);
}
const introZh = pageText("intro", "zh-CN");
assert(
introZh.includes("结算源优先") && introZh.includes("天气决策台"),
"intro should position PolyWeather as a settlement-source-first decision terminal",
);
assert(
introZh.includes("1-9 个图表槽位") && introZh.includes("天气决策 / 训练数据 / 使用指南"),
"intro should match the current terminal navigation and chart-slot workflow",
);
const chartGuideZh = pageText("chart-guide", "zh-CN");
assert(chartGuideZh.includes("如何读 PolyWeather 图表"), "chart guide title should be present");
assert(chartGuideZh.includes("高级气象变量") && chartGuideZh.includes("默认隐藏"), "chart guide should explain advanced variables as hidden-by-default context");
assert(chartGuideZh.includes("不要把概率温度带当成实测曲线"), "chart guide should warn against reading probability as observation");
assert(chartGuideZh.includes("高温 / 全天"), "chart guide should document the current chart view modes");
const realtimeSourcesZh = pageText("realtime-sources", "zh-CN");
assert(realtimeSourcesZh.includes("AMSC 180s"), "realtime sources should document the AMSC 180s cadence");
assert(realtimeSourcesZh.includes("AMOS 60s"), "realtime sources should document the AMOS 60s cadence");
assert(realtimeSourcesZh.includes("SSE patch"), "realtime sources should document the SSE patch path");
const settlementSourcesZh = pageText("settlement-sources", "zh-CN");
assert(settlementSourcesZh.includes("结算站点"), "settlement source guide should exist");
assert(settlementSourcesZh.includes("机场 METAR") && settlementSourcesZh.includes("官方结算站点"), "settlement source guide should distinguish airport and official station settlement");
assert(
getDocsPage("chart-guide")?.group === "getting-started" &&
getDocsPage("realtime-sources")?.group === "settlement" &&
getDocsPage("settlement-sources")?.group === "settlement",
"docs navigation should expose chart, realtime source, and settlement source guides in the right groups",
);
const chartGuideEn = pageText("chart-guide", "en-US");
assert(chartGuideEn.includes("How To Read PolyWeather Charts"), "English chart guide should exist");
assert(chartGuideEn.includes("hidden by default"), "English chart guide should describe hidden-by-default advanced variables");
assert(chartGuideEn.includes("Peak / All Day"), "English chart guide should document the current chart view modes");
}
@@ -20,6 +20,7 @@ export function runTests() {
const cached = buildCityDetailProxyCachePolicy("false", 15);
assert.equal(cached.fetchMode, "revalidate");
assert.equal(cached.revalidateSeconds, 15);
assert.match(cached.responseCacheControl, /max-age=15/);
assert.match(cached.responseCacheControl, /s-maxage=15/);
const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
@@ -55,6 +56,16 @@ export function runTests() {
/hasDirectBackendApiBaseUrl/,
"public scan terminal requests should only attach user auth in direct-backend mode so CDN caches can be shared through the Next proxy",
);
assert.match(
scanTerminalClientSource,
/SCAN_TERMINAL_PAYLOAD_VERSION/,
"scan terminal client should include a stable payload version in the URL so CDN caches roll forward after response-shape optimizations",
);
assert.match(
scanTerminalClientSource,
/params\.set\("_v",\s*SCAN_TERMINAL_PAYLOAD_VERSION\)/,
"scan terminal client should vary the CDN cache key without changing backend scan filters",
);
const overviewProxySource = fs.readFileSync(
path.join(
@@ -6,12 +6,14 @@ import {
} from "@/lib/refresh-policy";
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
__getInitialDetailLoadDelayMsForTest,
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_CACHE_TTL_MS,
__resolveCityDetailFromBatchForTest,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
@@ -67,14 +69,47 @@ export async function runTests() {
assert(
chartSource.includes("useLatestPatch") &&
chartSource.includes("latestPatch") &&
chartSource.includes("2 * 60_000"),
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("2 * 60_000"),
"selected city chart should consume SSE patches and use a METAR-cadence no-patch fallback instead of a 2-minute forced refresh",
);
assert(
chartSource.includes("preloadTemperatureChartCanvas"),
"terminal chart canvas should expose a preload hook for first-paint optimization",
);
assert(
chartSource.includes('from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"') &&
!chartSource.includes("next/dynamic") &&
!chartSource.includes("TemperatureChartCanvasFallback"),
"terminal chart canvas should be loaded with the terminal route instead of showing a second-stage dynamic chunk fallback",
);
assert(
dashboardSource.includes("preloadTemperatureChartCanvas") &&
dashboardSource.includes("void preloadTemperatureChartCanvas()") &&
dashboardSource.includes('activeNavKey !== "thresholds"'),
"terminal screen should preload the chart chunk once access is confirmed on the chart tab",
);
assert(
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") &&
chartSource.includes("setHourly(data)"),
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop",
);
assert(
chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") &&
chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") &&
chartLogicSource.includes("recentlyRefreshed.data"),
"forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly",
);
assert(
chartLogicSource.includes("forceRefresh: boolean") &&
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
"ignoreCache chart detail refreshes must send force_refresh=true so fresh METAR observations bypass proxy and backend chart caches",
);
assert(
chartLogicSource.includes("cityDetailBatchQueueKey") &&
chartLogicSource.includes('forceRefresh ? "force" : "cached"'),
"forced and cached chart detail requests must use separate batch queues so a normal first-paint request cannot downgrade a live refresh",
);
assert(
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
"compact grid slots are visible charts and should run the no-patch fallback guard",
@@ -95,29 +130,73 @@ export async function runTests() {
chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
);
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\n}\n\nfunction fetchCityDetailWithTimeout/)?.[0] || "";
assert(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") &&
chartLogicSource.includes('scope: "chart"') &&
chartLogicSource.includes("params.toString()"),
"terminal chart detail batches should request the slim chart scope instead of the full city detail payload",
);
assert(
chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"),
"visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames",
);
assert(
chartLogicSource.includes("partial?: boolean") &&
chartLogicSource.includes("missing?: string[]"),
"frontend city detail batch payload should understand partial responses and missing city markers",
);
const flushCityDetailBatchBlock = chartLogicSource.match(/async function flushCityDetailBatch[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailBatchWithTimeout/)?.[0] || "";
assert(
flushCityDetailBatchBlock.includes("partialMissingCities") &&
flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") &&
flushCityDetailBatchBlock.includes("payload?.partial === true"),
"partial detail-batch misses should resolve without immediately issuing single-city fallback requests",
);
assert(
flushCityDetailBatchBlock.includes("if (!payload)") &&
flushCityDetailBatchBlock.includes("resolveAllBatchWaitersAsNull") &&
!flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"),
"whole-batch failures should stop at the chart batch layer instead of fanning out into single-city full-detail requests",
);
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || "";
assert(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") &&
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
"first-paint and background city detail refreshes should both enter the batch queue before falling back to single requests",
"first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests",
);
assert(
dashboardSource.includes("ONLINE_USERS_REFRESH_MS = 5 * 60_000") &&
dashboardSource.includes('document.visibilityState === "hidden"'),
"terminal online-user presence should refresh slowly and pause while the browser tab is hidden",
);
assert(
dashboardSource.includes("AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000") &&
dashboardSource.includes("AUTH_PROFILE_RETRY_POLL_MS = 30_000") &&
!dashboardSource.includes("}, 5000);"),
"terminal auth subscription retry should back off after the first retry instead of polling auth/me every 5 seconds",
);
assert(
chartSource.includes("IntersectionObserver") &&
chartSource.includes("shouldFetchCityDetailForChart") &&
chartSource.includes("isChartVisible"),
"city detail prefetch should be gated to visible chart cards instead of every mounted slot",
);
assert(
chartSource.includes("DETAIL_LOAD_BATCH_DELAY_MS") &&
!chartSource.includes("slotIndex ? 300 + slotIndex * 250"),
"visible chart detail loads should enter the batch queue together instead of being staggered into single-city requests",
);
assert(
chartSource.includes("allowStale: true") &&
chartCanvasSourceIncludes(chartSource, "数据暂不可用") &&
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
"city detail charts should show stale cache first and expose a retryable unavailable state",
);
assert(
chartSource.includes("const showDetailErrorBadge = !compact || isActive || isMaximized") &&
chartSource.includes("showDetailErrorBadge={showDetailErrorBadge}") &&
chartCanvasSourceIncludes(chartSource, "showDetailErrorBadge"),
"compact grid charts should only show the stale-cache retry badge on the active slot or expanded chart",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true,
"visible chart cards should fetch city detail",
@@ -130,6 +209,71 @@ export async function runTests() {
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false,
"hidden browser tabs should not prefetch city detail",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 0 }) === 0 &&
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 2 }) === 0,
"first visible grid charts should fetch full detail immediately",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 3 }) > 0,
"later non-active grid charts should defer full-detail loading after first paint",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 3 }) ===
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 5 }),
"deferred grid charts should load in same-wave groups so detail-batch can coalesce them",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 6 }) ===
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 8 }) &&
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 6 }) >
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 5 }),
"later deferred grid charts should form a second batch wave instead of one request per slot",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: true, isMaximized: false, slotIndex: 8 }) === 0 &&
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: true, slotIndex: 8 }) === 0,
"active or maximized charts should bypass deferred detail loading",
);
assert(
__shouldFetchCityDetailForChartForTest({
city: "paris",
documentHidden: false,
isChartVisible: true,
compact: true,
isActive: false,
isMaximized: false,
slotIndex: 5,
detailLoadReady: false,
}) === false,
"deferred grid charts should not enter the detail request queue before their delay is ready",
);
assert(
__shouldFetchCityDetailForChartForTest({
city: "paris",
documentHidden: false,
isChartVisible: true,
compact: true,
isActive: false,
isMaximized: false,
slotIndex: 5,
detailLoadReady: true,
}) === true,
"deferred grid charts should fetch detail after their delay is ready",
);
const normalizedBatchDetail = __resolveCityDetailFromBatchForTest(
{
"hong kong": {
city: "hong kong",
timeseries: { hourly: { times: ["00:00"], temps: [32] } },
},
} as any,
"Hong Kong",
) as any;
assert(
normalizedBatchDetail?.city === "hong kong",
"frontend detail batch lookup should accept backend-normalized city keys before falling back to single-city requests",
);
__resetHourlyDetailRequestQueueForTest();
let activeRequests = 0;
@@ -28,6 +28,7 @@ export function runTests() {
const now = new Date("2026-05-14T12:00:00Z");
assert.equal(getObservationSourceProfile("amos").nativeUpdateIntervalSec, 60);
assert.equal(getObservationSourceProfile("amsc_awos").nativeUpdateIntervalSec, 180);
assert.equal(getObservationSourceProfile("metar").nativeUpdateIntervalSec, 900);
const amosFresh = buildObservationFreshness({
@@ -109,4 +110,28 @@ export function runTests() {
},
});
assert.equal(getObservationFreshness(amosDetail)?.source_code, "amos");
const amscDetail = detail({
amos: {
observation_time: "2026-05-14T11:57:20Z",
runway_obs: {
runway_pairs: [["35R", "17L"]],
temperatures: [[25.2, null]],
},
source: "amsc_awos",
source_label: "AMSC AWOS",
},
});
const amscFreshness = getObservationFreshness(amscDetail);
assert.equal(amscFreshness?.source_code, "amsc_awos");
assert.equal(amscFreshness?.native_update_interval_sec, 180);
const amscExpectedWait = buildObservationFreshness({
now,
observedAt: "2026-05-14T11:56:40Z",
sourceCode: "amsc_awos",
sourceLabel: "AMSC AWOS",
});
assert.equal(amscExpectedWait.native_update_interval_sec, 180);
assert.equal(amscExpectedWait.freshness_status, "expected_wait");
}
@@ -94,11 +94,30 @@ export function runTests() {
assert(hook.includes("subscribedCities"), "frontend patch hook must track the visible city subscription set");
assert(hook.includes("since_revision"), "frontend patch hook must reconnect with since_revision");
assert(hook.includes("resync_required"), "frontend patch hook must react to server resync_required events");
assert(
hook.includes("SSE_REPLAY_EVENTS_PER_CITY") &&
hook.includes("resolveSseReplayLimit") &&
hook.includes('params.set("replay_limit", String(resolveSseReplayLimit(cities.length)))') &&
!hook.includes('params.set("replay_limit", "500")'),
"frontend patch hook should size SSE replay_limit by visible city count instead of always asking for 500 events",
);
assert(hook.includes("lastRevision"), "frontend patch hook must track the global last processed revision");
assert(hook.includes("Map<"), "frontend patch hook must keep latest patches in a Map");
assert(hook.includes("useLatestPatch"), "frontend patch hook must export useLatestPatch(city)");
assert(hook.includes("revision"), "frontend patch hook must track revisions and skip stale patches");
assert(hook.includes("setTimeout"), "frontend patch hook must implement explicit reconnect backoff");
assert(
hook.includes("SSE_SUBSCRIPTION_RECONNECT_DELAY_MS") &&
hook.includes("scheduleSubscriptionReconnect") &&
hook.includes("clearSubscriptionReconnectTimer"),
"frontend patch hook must debounce visible-city subscription changes before reopening SSE",
);
const subscriptionBlock = hook.match(/function registerCitySubscription[\s\S]*?\n}\r?\n\r?\nfunction normalizeLegacyPatch/)?.[0] || "";
assert(
subscriptionBlock.includes("scheduleSubscriptionReconnect()") &&
!subscriptionBlock.includes("ensureSsePatchConnection();"),
"city subscription mount/unmount should schedule one coalesced SSE reconnect instead of reconnecting per chart",
);
const bffEventsRoute = readFrontendFile("app", "api", "events", "route.ts");
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
@@ -129,7 +148,11 @@ export function runTests() {
assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches");
assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete");
assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback");
assert(
chart.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chart.includes("2 * 60_000"),
"temperature chart must wait one METAR cadence without patches before full-fetch fallback",
);
assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas");
assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars");
assert(chart.includes("TemperatureRunwayDetails"), "temperature chart shell must compose the extracted runway panel");
@@ -179,8 +202,9 @@ export function runTests() {
assert(
foregroundRefreshBlock.includes("ignoreCache: true") &&
foregroundRefreshBlock.includes("fetchHourlyForecastForCity") &&
foregroundRefreshBlock.includes("FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS") &&
!foregroundRefreshBlock.includes("setIsHourlyLoading(true)"),
"foreground resume refresh should update full detail immediately in the background without showing the loading overlay",
"foreground resume refresh should update full detail in the background without showing the loading overlay or refetching fresh detail",
);
assert(
!chart.includes("/api/city/${encodeURIComponent(city)}/summary"),
@@ -238,8 +262,8 @@ export function runTests() {
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant",
);
assert(
chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000") &&
chartLogic.includes("fetchCityDetailWithTimeout") &&
chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000") &&
chartLogic.includes("fetchCityDetailBatchWithTimeout") &&
chartLogic.includes("signal: controller.signal") &&
chartLogic.includes("controller.abort()"),
"city detail chart fetches must have a frontend timeout so panels cannot stay on 加载图表 forever",
@@ -9,6 +9,7 @@ import {
__getVisibleTemperatureSeriesForTest,
__isTemperatureSeriesVisibleByDefaultForTest,
__mergePatchIntoHourlyForTest,
__selectCompactSecondaryTempForTest,
__selectDisplayRunwayTempForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
@@ -976,6 +977,68 @@ export function runTests() {
"runway header should prefer runway-history current temp even when the latest detail payload lacks runway_obs snapshot",
);
const hongKongMetrics = __getObservationDisplayMetricsForTest(
{
city: "hong kong",
local_date: "2026-06-06",
local_time: "16:48",
tz_offset_seconds: 8 * 60 * 60,
current_temp: 30.4,
current_max_so_far: 30.4,
temp_symbol: "°C",
} as any,
{
localTime: "16:48",
times: ["00:00", "12:00", "18:00", "23:00"],
temps: [28.0, 31.0, 29.0, 28.0],
settlementTodayObs: [
{ time: "14:00", temp: 30.4 },
{ time: "16:40", temp: 28.1 },
],
airportPrimary: {
temp: 28.7,
obs_time: "2026-06-06T16:48:00+08:00",
source_code: "cowin_obs",
source_label: "CoWIN 6087",
},
airportPrimaryTodayObs: [
{ time: "16:47", temp: 28.6 },
{ time: "16:48", temp: 28.7 },
],
} as any,
null,
);
assert(
hongKongMetrics.currentRunwayTemp === 28.7,
"Hong Kong primary compact stat should stay on the latest CoWIN reference-station point",
);
assert(
(hongKongMetrics as any).currentMetarTemp === 28.1,
"Hong Kong secondary compact stat should have the latest HKO point separate from the HKO daily high",
);
assert(
hongKongMetrics.observedHighMetar === 30.4,
"Hong Kong HKO daily high should still be available for expanded daily-high summaries",
);
assert(
__selectCompactSecondaryTempForTest({
isHKO: true,
isShenzhen: false,
displayMetarTemp: (hongKongMetrics as any).currentMetarTemp,
observedHighMetar: hongKongMetrics.observedHighMetar,
}) === 28.1,
"Hong Kong compact HKO stat should render the latest HKO point, not the HKO daily high",
);
assert(
__selectCompactSecondaryTempForTest({
isHKO: false,
isShenzhen: false,
displayMetarTemp: 72.0,
observedHighMetar: 73.9,
}) === 73.9,
"non-HKO compact secondary stat should keep the existing daily-high behavior",
);
const wuhanRunwayChart = __buildTemperatureChartDataForTest(
{
city: "wuhan",
@@ -1107,6 +1170,29 @@ export function runTests() {
"Panama City high label should use airport METAR report wording, not weather-station or runway wording",
);
const shanghaiLabels = __getLiveObservationLabelsForTest(
{
city: "shanghai",
local_date: "2026-06-06",
local_time: "21:03",
tz_offset_seconds: 8 * 60 * 60,
} as any,
{
amos: {
source: "amsc_awos",
source_label: "AMSC AWOS",
},
airportPrimary: {
source_code: "amsc_awos",
source_label: "AMSC AWOS",
},
} as any,
);
assert(
shanghaiLabels.runwayHeaderLabel === "跑道实测 (3分钟)",
"AMSC runway cities should advertise the 3-minute source cadence instead of the AMOS 1-minute cadence",
);
const newYorkWithMadis = __buildTemperatureChartDataForTest(
{
city: "new york",
@@ -1574,7 +1660,7 @@ export function runTests() {
const firstBand = bandPoints[0].runway_band;
assert(Array.isArray(firstBand) && firstBand[0] === 24.0 && firstBand[1] === 26.0, "runway_band tuple values should match input limits");
// ── Legacy Gaussian probability overlay test ──
// ── Legacy Gaussian probability data should be available as compact tooltip context only ──
const gaussianOverlayChart = __buildTemperatureChartDataForTest(
{
city: "toronto",
@@ -1602,16 +1688,13 @@ export function runTests() {
) as any;
const gaussianOverlay = gaussianOverlayChart.probabilityOverlay;
assert(gaussianOverlay, "legacy Gaussian probabilities should be exposed as a chart overlay");
assert(gaussianOverlay.muLine?.value === 27.4, "legacy Gaussian μ should become a reference line");
assert(
gaussianOverlay.bands.some(
(band: any) => band.value === 27 && band.lower === 26.5 && band.upper === 27.5 && band.probability === 0.42,
),
"legacy Gaussian buckets should become horizontal probability temperature bands",
gaussianOverlay?.muLine?.label === "Gaussian μ 27.4°C" &&
gaussianOverlay.bands.length === 3,
"legacy Gaussian probabilities should remain available for compact tooltip context",
);
assert(
!gaussianOverlayChart.series.some((series: any) => String(series.key || "").includes("probability")),
"legacy Gaussian probability distribution should not be rendered as a time-series line",
"legacy Gaussian probability distribution should not be rendered as a time-series line on the main chart",
);
}
@@ -1,4 +1,4 @@
import { __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { __buildDebQualityLabelForTest, __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { temp } from "@/components/dashboard/scan-terminal/utils";
function assert(condition: unknown, message: string) {
@@ -36,6 +36,18 @@ export function runTests() {
assert(shenzhen.runwayHigh === "HKO Live", "Shenzhen high summary should translate 天文台实测");
assert(shenzhen.metarHigh === "HKO", "Shenzhen high summary should translate 天文台");
const shanghai = __buildTemperatureStatsLabelsForTest({
isEn: true,
isShenzhen: false,
runwayHeaderLabel: "跑道实测 (3分钟)",
metarHeaderLabel: "METAR 结算 (30分钟)",
runwayHighLabel: "跑道实测",
metarHighLabel: "METAR 官方",
});
assert(shanghai.primary === "Runway Live (3m)", "AMSC English primary label should match 跑道实测 (3分钟)");
assert(shanghai.runwayHigh === "Runway", "AMSC runway high label should remain Runway");
const zh = __buildTemperatureStatsLabelsForTest({
isEn: false,
isShenzhen: true,
@@ -51,4 +63,12 @@ export function runTests() {
assert(temp(null, "°C") === "--", "empty temperature values should not render as 0.0°C while city detail is loading");
assert(temp(undefined, "°C") === "--", "undefined temperature values should not render as 0.0°C while city detail is loading");
assert(temp("", "°C") === "--", "blank temperature values should not render as 0.0°C while city detail is loading");
assert(
__buildDebQualityLabelForTest({ recommendation: "context_only" }, true) === "Context",
"low-confidence DEB should render as context-only guidance in English",
);
assert(
__buildDebQualityLabelForTest({ recommendation: "insufficient" }, false) === "样本少",
"thin-sample DEB should render a Chinese low-sample label",
);
}
@@ -1,4 +1,11 @@
import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
import {
readFileSync,
} from "node:fs";
import path from "node:path";
import {
__buildTemperatureTooltipProbabilityRowsForTest,
__buildTemperatureTooltipRowsForTest,
} from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
@@ -48,4 +55,65 @@ export function runTests() {
rows.some((row) => row.key === "gfs" && row.value === 24.2),
"non-runway sparse series should keep nearest-value fallback",
);
const probabilityRows = __buildTemperatureTooltipProbabilityRowsForTest(
{
engine: "legacy",
muLine: { value: 27.4, label: "Gaussian μ 27.4°C" },
bands: [
{
key: "legacy_probability_26_0",
value: 26,
lower: 25.5,
upper: 26.5,
probability: 0.18,
label: "26°C 18%",
opacity: 0.08,
},
{
key: "legacy_probability_27_0",
value: 27,
lower: 26.5,
upper: 27.5,
probability: 0.42,
label: "27°C 42%",
opacity: 0.13,
},
{
key: "legacy_probability_28_0",
value: 28,
lower: 27.5,
upper: 28.5,
probability: 0.31,
label: "28°C 31%",
opacity: 0.11,
},
],
},
"°C",
true,
);
assert(
probabilityRows.length === 4,
"temperature tooltip should show Gaussian μ and every available probability bucket as compact context",
);
assert(
probabilityRows.some((row) => row.key === "legacy_probability_mu" && row.value === "27.4°C") &&
probabilityRows.some((row) => row.key === "legacy_probability_26_0" && row.label === "25.5-26.5°C" && row.value === "18%") &&
probabilityRows.some((row) => row.key === "legacy_probability_27_0" && row.label === "26.5-27.5°C" && row.value === "42%") &&
probabilityRows.some((row) => row.key === "legacy_probability_28_0" && row.label === "27.5-28.5°C" && row.value === "31%"),
"temperature tooltip should format the full Gaussian probability distribution by temperature range without drawing it on the main chart",
);
const projectRoot = process.cwd();
const chartCanvasSource = readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8",
);
assert(
chartCanvasSource.includes("probabilityOverlay={probabilityOverlay}") &&
!chartCanvasSource.includes("probabilityOverlay={null}"),
"temperature chart canvas must pass probability overlay data into the tooltip instead of hiding Gaussian μ",
);
}
@@ -1,4 +1,5 @@
import {
createAuthProfileRequestCache,
loadTerminalAuthProfile,
type TerminalAuthProfilePayload,
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
@@ -27,6 +28,41 @@ async function flushMicrotasks() {
}
export async function runTests() {
const pendingProfile = deferred<TerminalAuthProfilePayload>();
let underlyingProfileLoads = 0;
const cachedLoadAuthProfile = createAuthProfileRequestCache((
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
) => {
underlyingProfileLoads += 1;
assert(
accessToken === "shared-token" && options?.preferSnapshot === true,
"auth profile request cache should pass through the original token and snapshot preference",
);
return pendingProfile.promise;
});
const firstCachedProfile = cachedLoadAuthProfile("shared-token", {
preferSnapshot: true,
});
const secondCachedProfile = cachedLoadAuthProfile("shared-token", {
preferSnapshot: true,
});
assert(
firstCachedProfile === secondCachedProfile && underlyingProfileLoads === 1,
"auth profile request cache should dedupe identical in-flight profile loads",
);
pendingProfile.resolve({
authenticated: true,
user_id: "shared-user",
subscription_active: true,
});
await firstCachedProfile;
await cachedLoadAuthProfile("shared-token", { preferSnapshot: true });
assert(
underlyingProfileLoads === 2,
"auth profile request cache should clear a key after the in-flight load settles",
);
const slowCookieProfile = deferred<TerminalAuthProfilePayload>();
const fastSession = deferred<{ data: { session: { access_token: string } } }>();
const calls: string[] = [];
@@ -88,6 +124,45 @@ export async function runTests() {
"terminal auth bootstrap should accept an authenticated cookie profile immediately",
);
const slowDegradedSession = deferred<{ data: { session: { access_token: string } | null } }>();
const timedCookieResult = await loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () => slowDegradedSession.promise,
timeoutMs: 1,
loadAuthProfile: (accessToken) => {
assert(!accessToken, "timeout fallback should use the settled cookie profile when bearer session is still pending");
return Promise.resolve({
authenticated: true,
user_id: "cookie-user",
subscription_active: null,
degraded_auth_profile: true,
});
},
});
assert(
timedCookieResult.user_id === "cookie-user" &&
timedCookieResult.subscription_active === null,
"terminal auth bootstrap timeout must resolve with a known degraded cookie profile instead of spinning forever",
);
const neverCookie = deferred<TerminalAuthProfilePayload>();
const neverSession = deferred<{ data: { session: { access_token: string } | null } }>();
let timeoutRejected = false;
try {
await loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () => neverSession.promise,
timeoutMs: 1,
loadAuthProfile: () => neverCookie.promise,
});
} catch (error) {
timeoutRejected = String(error).includes("Terminal auth bootstrap timeout");
}
assert(
timeoutRejected,
"terminal auth bootstrap must reject instead of leaving the loading screen unresolved when no profile request settles",
);
const delayedBearerSession = deferred<{ data: { session: { access_token: string } } }>();
let coldStartSettled = false;
const coldStartResultPromise = loadTerminalAuthProfile({
@@ -36,6 +36,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
"utf8",
);
const mobileRegionTabsSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "MobileRegionTabs.tsx"),
"utf8",
);
assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -57,6 +61,26 @@ export function runTests() {
dashboardSource.includes("disableClose={true}"),
"mobile terminal should render one selected chart and suggest landscape for the full grid",
);
assert(
dashboardSource.includes("mobile-region-tabs-shell") &&
dashboardSource.includes("handleMobileSelectTab") &&
dashboardSource.includes("mobileSelectedRowInActiveGroup") &&
dashboardSource.includes("mobileActiveGroup?.rows[0]") &&
mobileRegionTabsSource.includes("min-h-11") &&
mobileRegionTabsSource.includes("snap-x") &&
mobileRegionTabsSource.includes("aria-pressed={isActive}"),
"mobile region navigation must stay visible above the landscape hint, remain touch-friendly, and switch the visible chart with the active region",
);
assert(
!dashboardSource.includes("disableClose={visibleSlots.filter(Boolean).length <= 1}"),
"desktop chart slots must allow clearing the last visible city so users can close a 1x1 chart",
);
assert(
dashboardSource.includes("readStoredTerminalSlots") &&
dashboardSource.includes("hasPersistedSlots") &&
dashboardSource.includes("shouldAutofillInitialSlots"),
"terminal slots must distinguish first-load default fill from a user-cleared all-empty layout",
);
assert(
selectorSource.includes("[1, 2, 3].map") &&
selectorSource.includes("grid grid-cols-3"),
@@ -97,11 +121,10 @@ export function runTests() {
"terminal dashboard must lazy-load the training analytics tab so Recharts stays out of the default terminal path",
);
assert(
chartSource.includes('from "next/dynamic"') &&
chartSource.includes("TemperatureChartCanvasFallback") &&
chartSource.includes("import(\"@/components/dashboard/scan-terminal/TemperatureChartCanvas\")") &&
!chartSource.includes('import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";'),
"terminal temperature charts must lazy-load the Recharts canvas behind a lightweight fallback",
chartSource.includes('import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";') &&
!chartSource.includes("TemperatureChartCanvasFallback") &&
!chartSource.includes("import(\"@/components/dashboard/scan-terminal/TemperatureChartCanvas\")"),
"terminal temperature charts must load the Recharts canvas with the terminal route so visible cards do not sit behind a second-stage fallback",
);
assert(
chartSource.includes("setLiveTemp(null);") &&
@@ -166,6 +189,13 @@ export function runTests() {
chartCanvasSource.includes("TemperatureChartCanvasComponent"),
"temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts",
);
assert(
dashboardSource.includes("const TerminalSidebar = memo(function TerminalSidebar") &&
dashboardSource.includes("const [navExpanded, setNavExpanded] = useState(false);") &&
dashboardSource.includes("transition-colors duration-150") &&
!dashboardSource.includes("transition-all duration-200"),
"terminal sidebar expand state must be isolated from the chart grid and must not animate width through transition-all",
);
assert(
__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
@@ -0,0 +1,49 @@
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const rows = buildDebRecentRankingRows([
{
city_id: "legacy",
name: "Legacy Good",
deb: { hit_rate: 95, mae: 0.9, total_days: 40 },
deb_recent: {
recent_7d: { hit_rate: 0, mae: 2.5, samples: 4 },
recent_14d: { hit_rate: 15, mae: 2.1, samples: 8 },
trust_tier: "low",
recommendation: "context_only",
},
},
{
city_id: "usable",
name: "Usable Recent",
deb: { hit_rate: 40, mae: 1.4, total_days: 20 },
deb_recent: {
recent_7d: { hit_rate: 75, mae: 0.7, samples: 4 },
recent_14d: { hit_rate: 70, mae: 0.8, samples: 8 },
trust_tier: "high",
recommendation: "primary",
},
},
{
city_id: "support",
name: "Support Recent",
deb: { hit_rate: 35, mae: 1.5, total_days: 20 },
deb_recent: {
recent_7d: { hit_rate: 50, mae: 1.2, samples: 4 },
recent_14d: { hit_rate: 50, mae: 1.2, samples: 8 },
trust_tier: "medium",
recommendation: "supporting",
},
},
] as any);
assert(rows[0].cityId === "usable", "high-trust recent DEB city should rank first");
assert(rows[1].cityId === "support", "supporting recent DEB city should rank before low-trust legacy hit rate");
assert(rows[2].cityId === "legacy", "low-trust historical performer should rank after usable recent cities");
assert(rows[0].hitRate === 75, "ranking rows should use recent hit rate for chart value");
assert(rows[0].mae === 0.7, "ranking rows should use recent MAE for chart value");
}
@@ -0,0 +1,89 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const repoRoot = path.resolve(projectRoot, "..");
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
const opsConfigSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "config", "ConfigPageClient.tsx"),
"utf8",
);
const nextRoutePath = path.join(
projectRoot,
"app",
"api",
"system",
"update-announcement",
"route.ts",
);
const componentPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"UpdateAnnouncementButton.tsx",
);
const opsApiSource = fs.readFileSync(path.join(repoRoot, "web", "services", "ops_api.py"), "utf8");
const systemApiSource = fs.readFileSync(path.join(repoRoot, "web", "services", "system_api.py"), "utf8");
const systemRouterSource = fs.readFileSync(path.join(repoRoot, "web", "routers", "system.py"), "utf8");
const dbSource = fs.readFileSync(path.join(repoRoot, "src", "database", "db_manager.py"), "utf8");
const middlewareSource = fs.readFileSync(path.join(projectRoot, "middleware.ts"), "utf8");
assert(fs.existsSync(componentPath), "terminal must have a compact update announcement component");
assert(!fs.existsSync(nextRoutePath), "update announcements should not depend on an admin-managed API proxy");
const componentSource = fs.readFileSync(componentPath, "utf8");
assert(
dashboardSource.includes("UpdateAnnouncementButton") &&
dashboardSource.includes("<UpdateAnnouncementButton") &&
dashboardSource.includes("isEn={isEn}"),
"terminal header must render a bilingual update announcement entry beside the dashboard title",
);
assert(
componentSource.includes("STATIC_UPDATE_ANNOUNCEMENTS") &&
componentSource.includes("expiresAt") &&
componentSource.includes("Date.now()") &&
componentSource.includes("Megaphone") &&
componentSource.includes("zh") &&
componentSource.includes("en") &&
!componentSource.includes("fetch(") &&
!componentSource.includes("/api/system/update-announcement") &&
!componentSource.includes("setInterval("),
"announcement component must use hardcoded zh/en release notes with an expiry time and no backend polling",
);
assert(
!middlewareSource.includes("/api/system/update-announcement"),
"middleware should not keep a public announcement API entry after announcements move into frontend code",
);
assert(
!opsConfigSource.includes("公告类配置") &&
!opsConfigSource.includes("multiline") &&
!opsConfigSource.includes("<textarea"),
"ops config page should not expose update announcement editing controls",
);
assert(
!opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT") &&
!opsApiSource.includes("_RUNTIME_CONFIG_KEYS"),
"ops API must not expose editable update announcement keys",
);
assert(
!systemApiSource.includes("get_public_update_announcement") &&
!systemRouterSource.includes("/api/system/update-announcement"),
"backend must not expose a runtime update announcement endpoint",
);
assert(
!dbSource.includes("CREATE TABLE IF NOT EXISTS runtime_config") &&
!dbSource.includes("set_runtime_config") &&
!dbSource.includes("get_runtime_config_value"),
"database manager should not keep a runtime_config table only for update announcements",
);
}
@@ -5,10 +5,61 @@ import {
type RegionKey,
} from "@/components/dashboard/scan-terminal/continent-grouping";
const CITY_LIST_CACHE_KEY = "polyweather_city_list_v1";
export const CITY_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
type CityListCacheStorage = Pick<Storage, "getItem" | "removeItem" | "setItem">;
function normalizeCityKey(value: string) {
return String(value || "").trim().toLowerCase();
}
function getCityListCacheStorage(storage?: CityListCacheStorage): CityListCacheStorage | null {
if (storage) return storage;
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function readCachedCityList(
storage?: CityListCacheStorage,
now = Date.now(),
): CityListItem[] | null {
const cacheStorage = getCityListCacheStorage(storage);
if (!cacheStorage) return null;
try {
const raw = cacheStorage.getItem(CITY_LIST_CACHE_KEY);
if (!raw) return null;
const cached = JSON.parse(raw);
const age = now - Number(cached.ts || 0);
if (age < 0 || age >= CITY_LIST_CACHE_TTL_MS || !Array.isArray(cached.cities)) {
return null;
}
return cached.cities as CityListItem[];
} catch {
try { cacheStorage.removeItem(CITY_LIST_CACHE_KEY); } catch {}
}
return null;
}
export function writeCachedCityList(
cities: CityListItem[],
storage?: CityListCacheStorage,
now = Date.now(),
) {
const cacheStorage = getCityListCacheStorage(storage);
if (!cacheStorage || !Array.isArray(cities) || !cities.length) return;
try {
cacheStorage.setItem(
CITY_LIST_CACHE_KEY,
JSON.stringify({ ts: now, cities }),
);
} catch {}
}
function tempSymbolForCity(city: CityListItem) {
return city.temp_unit === "fahrenheit" ? "°F" : "°C";
}
@@ -0,0 +1,43 @@
import type { UserFeedbackEntry } from "@/types/ops";
export const FEEDBACK_STATUS_POLL_MS = 30 * 60 * 1000;
export const FEEDBACK_STATUS_CACHE_TTL_MS = 10 * 60 * 1000;
export function feedbackStatusLabel(status: string | undefined, isEn: boolean) {
const key = String(status || "open").toLowerCase();
if (key === "triaged") return isEn ? "Confirmed" : "已确认";
if (key === "investigating") return isEn ? "In progress" : "处理中";
if (key === "resolved") return isEn ? "Resolved" : "已解决";
if (key === "closed") return isEn ? "Closed" : "已关闭";
return isEn ? "Received" : "已收到";
}
export function feedbackStatusTone(status: string | undefined) {
const key = String(status || "open").toLowerCase();
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
if (key === "closed") return "border-slate-200 bg-slate-50 text-slate-500";
return "border-red-200 bg-red-50 text-red-700";
}
export function buildFeedbackNotificationKey(
entry: Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">,
) {
return [
Number(entry.id || 0),
String(entry.status || "open").toLowerCase(),
String(entry.updated_at || entry.created_at || ""),
].join(":");
}
export function countUnseenFeedbackUpdates(
entries: Array<
Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">
>,
seenKeys: Set<string>,
) {
return entries.reduce((count, entry) => {
return count + (seenKeys.has(buildFeedbackNotificationKey(entry)) ? 0 : 1);
}, 0);
}
@@ -22,6 +22,7 @@ export const scanTerminalQueryPolicy = {
autoRefreshMs: null,
manualForceRefreshCooldownMs: 2 * 60_000,
} as const;
const SCAN_TERMINAL_PAYLOAD_VERSION = "runway-slim-v1";
type TerminalQueryOptions = {
forceRefresh?: boolean;
@@ -142,6 +143,7 @@ async function getTerminal({
if (Number.isFinite(timezoneOffsetSeconds)) {
params.set("timezone_offset_seconds", String(Math.trunc(Number(timezoneOffsetSeconds))));
}
params.set("_v", SCAN_TERMINAL_PAYLOAD_VERSION);
if (forceRefresh) {
params.set("_ts", String(Date.now()));
}
@@ -2,9 +2,11 @@ import type {
AmosData,
AirportCurrentConditions,
CityDetail,
CurrentConditions,
ScanOpportunityRow,
ForecastDay,
DailyModelForecast,
DebForecast,
DebHourlyPath,
ProbabilityBucket,
} from "@/lib/dashboard-types";
@@ -15,6 +17,10 @@ const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000;
const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000;
const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const AMSC_RUNWAY_CITIES = new Set([
"beijing", "shanghai", "guangzhou", "qingdao",
"chengdu", "chongqing", "wuhan",
]);
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
shanghai: [["17L", "35R"]],
@@ -353,10 +359,11 @@ type LocalDayBounds = { start: number; end: number };
const MAX_OBS_POINTS = 1440;
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000;
let _hourlyActiveDetailRequests = 0;
const _hourlyDetailRequestQueue: Array<() => void> = [];
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
@@ -366,13 +373,16 @@ const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
type HourlyCacheEntry = { ts: number; data: HourlyForecast };
function isFreshHourlyCacheEntry(entry: HourlyCacheEntry | null | undefined) {
return Boolean(entry && Date.now() - Number(entry.ts || 0) < SESSION_CACHE_TTL_MS);
function isFreshHourlyCacheEntry(
entry: HourlyCacheEntry | null | undefined,
maxAgeMs = SESSION_CACHE_TTL_MS,
) {
return Boolean(entry && Date.now() - Number(entry.ts || 0) < maxAgeMs);
}
function readSessionCache(
city: string,
options: { allowStale?: boolean } = {},
options: { allowStale?: boolean; maxAgeMs?: number } = {},
): HourlyCacheEntry | null {
if (typeof window === "undefined") return null;
try {
@@ -382,7 +392,7 @@ function readSessionCache(
if (
item &&
item.ts &&
(options.allowStale || Date.now() - item.ts < SESSION_CACHE_TTL_MS)
(options.allowStale || Date.now() - item.ts < (options.maxAgeMs ?? SESSION_CACHE_TTL_MS))
) {
return item;
}
@@ -392,10 +402,10 @@ function readSessionCache(
function readHourlyCacheEntry(
cacheKey: string,
options: { allowStale?: boolean } = {},
options: { allowStale?: boolean; maxAgeMs?: number } = {},
): HourlyCacheEntry | null {
const cached = _hourlyCache.get(cacheKey);
if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached))) {
if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached, options.maxAgeMs))) {
return cached;
}
@@ -841,9 +851,14 @@ function getObservationDisplayMetrics(
null;
}
const currentMetarTemp =
latestSettlement ??
latestMetar ??
airportCurrentTemp ??
null;
const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null;
return { currentRunwayTemp, observedHighMetar, observedHighRunway };
return { currentMetarTemp, currentRunwayTemp, observedHighMetar, observedHighRunway };
}
function selectDisplayRunwayTemp(
@@ -855,6 +870,23 @@ function selectDisplayRunwayTemp(
return liveTemp;
}
function selectCompactSecondaryTemp({
isHKO,
isShenzhen,
displayMetarTemp,
observedHighMetar,
}: {
isHKO: boolean;
isShenzhen: boolean;
displayMetarTemp: number | null;
observedHighMetar: number | null;
}) {
if (isHKO && !isShenzhen && displayMetarTemp !== null) {
return displayMetarTemp;
}
return observedHighMetar;
}
function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) {
const cityKey = normalizeCityKey(row?.city);
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
@@ -909,6 +941,7 @@ function runwayPatchPointsFromRunwayObs(runwayObs: any) {
type HourlyForecast = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
debQuality?: Pick<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | null;
debHourlyPath?: DebHourlyPath | null;
localDate?: string | null;
localTime?: string | null;
@@ -918,6 +951,7 @@ type HourlyForecast = {
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>;
amos?: AmosData | null;
current?: CurrentConditions | null;
airportCurrent?: AirportCurrentConditions | null;
airportPrimary?: AirportCurrentConditions | null;
wundergroundCurrent?: AirportCurrentConditions | null;
@@ -935,6 +969,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
return {
forecastTodayHigh: null,
debPrediction: validNumber(row.deb_prediction),
debQuality: null,
debHourlyPath: null,
localDate: row.local_date || null,
localTime: row.local_time || null,
@@ -944,6 +979,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
runwayBandHistory: undefined,
amos: null,
current: null,
airportCurrent: null,
airportPrimary: null,
wundergroundCurrent: (row as any)?.wunderground_current || null,
@@ -966,8 +1002,11 @@ type HourlyForecastFetchOptions = {
};
type CityDetailBatchPayload = {
cities?: string[];
details?: Record<string, CityDetail | null | undefined>;
errors?: Record<string, string>;
missing?: string[];
partial?: boolean;
};
type CityDetailBatchWaiter = {
@@ -979,9 +1018,11 @@ type CityDetailBatchQueue = {
cities: Set<string>;
waiters: Map<string, CityDetailBatchWaiter[]>;
timer: ReturnType<typeof setTimeout> | null;
resolution: string;
forceRefresh: boolean;
};
const CITY_DETAIL_BATCH_WINDOW_MS = 25;
const CITY_DETAIL_BATCH_WINDOW_MS = 100;
const CITY_DETAIL_BATCH_MAX_CITIES = 12;
const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>();
@@ -991,6 +1032,14 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
return {
forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
debQuality: json.deb ? {
quality_tier: json.deb.quality_tier,
recommendation: json.deb.recommendation,
recent_hit_rate: json.deb.recent_hit_rate,
recent_samples: json.deb.recent_samples,
recent_hits: json.deb.recent_hits,
recent_mae: json.deb.recent_mae,
} : null,
debHourlyPath: json.deb?.hourly_path || null,
localDate: json.local_date || (json as any)?.overview?.local_date || null,
localTime: json.local_time || null,
@@ -1000,6 +1049,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
runwayBandHistory: (json as any)?.runway_band_history || undefined,
amos: json.amos || null,
current: json.current || null,
airportCurrent: json.airport_current || null,
airportPrimary: json.airport_primary || null,
wundergroundCurrent: (json as any).wunderground_current || (json as any)?.official?.wunderground_current || null,
@@ -1026,24 +1076,25 @@ function primeCityDetailCache(
return data;
}
async function fetchSingleHourlyForecastForCity(
city: string,
resolution: string,
): Promise<HourlyForecast> {
const res = await fetchCityDetailWithTimeout(city, resolution);
if (!res || !res.ok) return null;
const json = await res.json() as CityDetail;
return primeCityDetailCache(city, resolution, json);
function cityDetailBatchQueueKey(resolution: string, forceRefresh: boolean) {
return `${resolution}:${forceRefresh ? "force" : "cached"}`;
}
function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyForecast> {
function queueCityDetailBatch(
city: string,
resolution: string,
forceRefresh: boolean,
): Promise<HourlyForecast> {
return new Promise<HourlyForecast>((resolve, reject) => {
const queue = _cityDetailBatchQueues.get(resolution) || {
const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh);
const queue = _cityDetailBatchQueues.get(queueKey) || {
cities: new Set<string>(),
waiters: new Map<string, CityDetailBatchWaiter[]>(),
timer: null,
resolution,
forceRefresh,
};
_cityDetailBatchQueues.set(resolution, queue);
_cityDetailBatchQueues.set(queueKey, queue);
const cityWaiters = queue.waiters.get(city) || [];
cityWaiters.push({ resolve, reject });
@@ -1051,10 +1102,10 @@ function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyF
queue.cities.add(city);
if (queue.timer === null) {
queue.timer = setTimeout(() => flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS);
queue.timer = setTimeout(() => flushCityDetailBatch(queueKey), CITY_DETAIL_BATCH_WINDOW_MS);
}
if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) {
flushCityDetailBatch(resolution);
flushCityDetailBatch(queueKey);
}
});
}
@@ -1066,17 +1117,34 @@ function resolveBatchWaiters(
(waiters || []).forEach((waiter) => waiter.resolve(value));
}
function rejectBatchWaiters(
waiters: CityDetailBatchWaiter[] | undefined,
reason: unknown,
function resolveCityDetailFromBatch(
details: Record<string, CityDetail | null | undefined> | undefined,
city: string,
) {
(waiters || []).forEach((waiter) => waiter.reject(reason));
if (!details) return undefined;
const trimmed = String(city || "").trim();
const direct =
details[city] ||
details[trimmed] ||
details[trimmed.toLowerCase()] ||
details[normalizeCityKey(trimmed)];
if (direct) return direct;
const requestedKey = normalizeCityKey(trimmed);
if (!requestedKey) return undefined;
for (const [key, detail] of Object.entries(details)) {
if (!detail) continue;
if (normalizeCityKey(key) === requestedKey) return detail;
const detailCity = (detail as any).city || detail.name || detail.display_name;
if (normalizeCityKey(detailCity) === requestedKey) return detail;
}
return undefined;
}
async function flushCityDetailBatch(resolution: string) {
const queue = _cityDetailBatchQueues.get(resolution);
async function flushCityDetailBatch(queueKey: string) {
const queue = _cityDetailBatchQueues.get(queueKey);
if (!queue) return;
_cityDetailBatchQueues.delete(resolution);
_cityDetailBatchQueues.delete(queueKey);
if (queue.timer !== null) {
clearTimeout(queue.timer);
queue.timer = null;
@@ -1086,53 +1154,65 @@ async function flushCityDetailBatch(resolution: string) {
if (!cities.length) return;
try {
const payload = await fetchCityDetailBatchWithTimeout(cities, resolution);
const payload = await fetchCityDetailBatchWithTimeout(
cities,
queue.resolution,
queue.forceRefresh,
);
if (!payload) {
resolveAllBatchWaitersAsNull(cities, queue);
return;
}
const details = payload?.details || {};
const partialMissingCities =
payload?.partial === true
? new Set((payload.missing || []).map((city) => normalizeCityKey(city)))
: new Set<string>();
await Promise.all(
cities.map(async (city) => {
const waiters = queue.waiters.get(city);
const detail = details[city];
const data = primeCityDetailCache(city, resolution, detail);
const detail = resolveCityDetailFromBatch(details, city);
const data = primeCityDetailCache(city, queue.resolution, detail);
if (data) {
resolveBatchWaiters(waiters, data);
return;
}
try {
resolveBatchWaiters(
waiters,
await runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resolution)),
);
} catch (error) {
rejectBatchWaiters(waiters, error);
if (partialMissingCities.has(normalizeCityKey(city))) {
resolveBatchWaiters(waiters, null);
return;
}
resolveBatchWaiters(waiters, null);
}),
);
} catch (error) {
await Promise.all(
cities.map(async (city) => {
const waiters = queue.waiters.get(city);
try {
resolveBatchWaiters(
waiters,
await runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resolution)),
);
} catch (fallbackError) {
rejectBatchWaiters(waiters, fallbackError || error);
}
}),
);
resolveAllBatchWaitersAsNull(cities, queue);
}
}
function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) {
function resolveAllBatchWaitersAsNull(
cities: string[],
queue: CityDetailBatchQueue,
) {
cities.forEach((city) => {
resolveBatchWaiters(queue.waiters.get(city), null);
});
}
function fetchCityDetailBatchWithTimeout(
cities: string[],
resolution: string,
forceRefresh: boolean,
) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
const params = new URLSearchParams({
cities: cities.join(","),
depth: "full",
force_refresh: "false",
force_refresh: forceRefresh ? "true" : "false",
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
resolution,
scope: "chart",
});
return fetch(`/api/cities/detail-batch?${params.toString()}`, {
headers: { Accept: "application/json" },
@@ -1152,19 +1232,27 @@ async function fetchHourlyForecastForCity(
): Promise<HourlyForecast> {
const resParam = options.resolution || "10m";
const cacheKey = `${city}:${resParam}`;
const forceRefresh = Boolean(options.ignoreCache);
if (!options.ignoreCache) {
if (!forceRefresh) {
const cached = readHourlyCacheEntry(cacheKey);
if (cached) {
return cached.data;
}
} else {
const recentlyRefreshed = readHourlyCacheEntry(cacheKey, {
maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS,
});
if (recentlyRefreshed) {
return recentlyRefreshed.data;
}
}
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
const pending = _hourlyRequestCache.get(requestKey);
if (pending) return pending;
const request = queueCityDetailBatch(city, resParam)
const request = queueCityDetailBatch(city, resParam, forceRefresh)
.finally(() => {
_hourlyRequestCache.delete(requestKey);
});
@@ -1173,17 +1261,6 @@ async function fetchHourlyForecastForCity(
return request;
}
function fetchCityDetailWithTimeout(city: string, resolution: string) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
return fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resolution}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
})
.catch(() => null)
.finally(() => globalThis.clearTimeout(timeoutId));
}
function shouldPollLiveChart({
city,
compact,
@@ -1236,6 +1313,9 @@ function getLiveObservationLabels(
weatherStationCities.has(normalizedKey) ||
/\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens);
const isRunwaySensorCity = runwaySensorCities.has(normalizedKey);
const isAmscRunwayCity =
AMSC_RUNWAY_CITIES.has(normalizedKey) ||
/\bamsc(?:_awos)?\b|\bawos\b/.test(sourceTokens);
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei
&& hasRealStationNetwork;
@@ -1247,7 +1327,7 @@ function getLiveObservationLabels(
: isParis ? "官方机场观测 (15分钟)"
: isTaipei ? "CWA (10分钟)"
: isWeatherStation ? "气象站实测"
: isRunwaySensorCity ? "跑道实测 (1分钟)"
: isRunwaySensorCity ? `跑道实测 (${isAmscRunwayCity ? "3分钟" : "1分钟"})`
: "机场报文";
const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)"
@@ -1297,6 +1377,7 @@ function mergePatchIntoHourly(
...(prev || {
forecastTodayHigh: null,
debPrediction: null,
debQuality: null,
localDate: null,
localTime: null,
times: [],
@@ -2110,6 +2191,8 @@ function buildFullDayChartData(
return point;
});
// Keep legacy Gaussian data as compact tooltip context. It is not rendered
// as a time-series line on the live temperature chart.
const probabilityOverlay = buildLegacyGaussianProbabilityOverlay(row, hourly);
return { data, series, probabilityOverlay };
@@ -2424,8 +2507,10 @@ export {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
HOURLY_CACHE_TTL_MS,
HOURLY_FORCE_REFRESH_DEDUP_MS,
_hourlyCache,
__readHourlyCacheEntryForTest,
resolveCityDetailFromBatch as __resolveCityDetailFromBatchForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
buildChartDomain,
@@ -2447,6 +2532,7 @@ export {
normalizeCityKey,
prefersHighFrequencyRunwayResolution,
readSessionCache,
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
seedHourlyForecastFromRow,
seriesStats,
@@ -17,6 +17,7 @@ type LoadTerminalAuthProfileOptions = {
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
) => Promise<TerminalAuthProfilePayload>;
timeoutMs?: number;
};
type SettledProfile =
@@ -57,15 +58,44 @@ function canResolveProfileImmediately(
return payload?.authenticated === true && payload.subscription_active === true;
}
function authProfileRequestCacheKey(
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
) {
const token = String(accessToken || "").trim();
const scope = token ? `bearer:${token}` : "cookie";
const mode = options?.preferSnapshot ? "snapshot" : "live";
return `${mode}:${scope}`;
}
export function createAuthProfileRequestCache(
loadAuthProfile: LoadTerminalAuthProfileOptions["loadAuthProfile"],
): LoadTerminalAuthProfileOptions["loadAuthProfile"] {
const pending = new Map<string, Promise<TerminalAuthProfilePayload>>();
return (accessToken, options) => {
const key = authProfileRequestCacheKey(accessToken, options);
const existing = pending.get(key);
if (existing) return existing;
const request = loadAuthProfile(accessToken, options).finally(() => {
pending.delete(key);
});
pending.set(key, request);
return request;
};
}
export async function loadTerminalAuthProfile({
getSession,
hasSupabasePublicEnv,
loadAuthProfile,
timeoutMs = 6500,
}: LoadTerminalAuthProfileOptions) {
let resolvedAuthenticated = false;
let resolveAuthenticated:
| ((payload: TerminalAuthProfilePayload) => void)
| null = null;
let latestCookiePayload: TerminalAuthProfilePayload | null = null;
let latestBearerPayload: TerminalAuthProfilePayload | null = null;
const authenticatedProfile = new Promise<TerminalAuthProfilePayload>((resolve) => {
resolveAuthenticated = resolve;
@@ -79,6 +109,7 @@ export async function loadTerminalAuthProfile({
const cookieProfile = settleProfile(
loadAuthProfile(null, { preferSnapshot: true }).then((payload) => {
latestCookiePayload = payload;
resolveIfAuthenticated(payload);
return payload;
}),
@@ -94,6 +125,7 @@ export async function loadTerminalAuthProfile({
).trim();
if (!accessToken) return null;
const payload = await loadAuthProfile(accessToken, { preferSnapshot: true });
latestBearerPayload = payload;
resolveIfAuthenticated(payload);
return payload;
})(),
@@ -103,5 +135,20 @@ export async function loadTerminalAuthProfile({
([cookieResult, bearerResult]) => firstKnownProfile(cookieResult, bearerResult),
);
return Promise.race([authenticatedProfile, fallbackProfile]);
const timeoutProfile = new Promise<TerminalAuthProfilePayload>((resolve, reject) => {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
globalThis.setTimeout(() => {
if (latestBearerPayload) {
resolve(latestBearerPayload);
return;
}
if (latestCookiePayload) {
resolve(latestCookiePayload);
return;
}
reject(new Error("Terminal auth bootstrap timeout"));
}, timeoutMs);
});
return Promise.race([authenticatedProfile, fallbackProfile, timeoutProfile]);
}
+388 -117
View File
@@ -1,6 +1,10 @@
.docsShell {
min-height: 100vh;
color: rgba(226, 232, 240, 0.94);
background:
linear-gradient(90deg, rgba(216, 224, 236, 0.38) 1px, transparent 1px),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 54%, #f5f8fc 100%);
background-size: 44px 44px, auto;
color: var(--color-text-primary, #0f172a);
}
.docsHeader {
@@ -10,10 +14,12 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
background: rgba(2, 6, 23, 0.82);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
gap: 18px;
min-height: 58px;
padding: 10px 24px;
border-bottom: 1px solid rgba(184, 196, 214, 0.74);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
backdrop-filter: blur(16px);
}
@@ -21,23 +27,31 @@
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.brandLink {
color: #67e8f9;
font-size: 1.9rem;
font-weight: 800;
color: #0f172a;
font-size: 1.24rem;
font-weight: 850;
letter-spacing: 0;
text-decoration: none;
}
.brandLink::first-letter {
color: var(--color-accent-primary, #2563eb);
}
.brandSubtitle {
color: rgba(148, 163, 184, 0.88);
font-size: 0.95rem;
color: #64748b;
font-size: 0.88rem;
white-space: nowrap;
}
.headerActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
@@ -46,87 +60,133 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.75);
color: rgba(226, 232, 240, 0.95);
padding: 0 13px;
border: 1px solid var(--color-border-default, #d8e0ec);
border-radius: 10px;
background: var(--color-bg-raised, #ffffff);
color: #1e293b;
text-decoration: none;
font-size: 0.92rem;
font-weight: 600;
font-size: 0.9rem;
font-weight: 800;
cursor: pointer;
transition:
border-color 160ms ease,
color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
}
.headerGhost {
background: transparent;
.headerButton:hover,
.headerGhost:hover {
border-color: #93c5fd;
color: #1d4ed8;
background: #eff6ff;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.1);
}
.headerGhost svg,
.mobileMenuButton svg {
flex: 0 0 auto;
}
.langSwitch {
display: flex;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
border: 1px solid var(--color-border-default, #d8e0ec);
border-radius: 10px;
background: #e8edf5;
}
.langButton {
min-width: 54px;
min-height: 38px;
min-width: 48px;
min-height: 32px;
padding: 0 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgba(148, 163, 184, 0.92);
font-weight: 700;
color: #64748b;
font-size: 0.82rem;
font-weight: 850;
cursor: pointer;
}
.langButtonActive {
background: rgba(34, 211, 238, 0.16);
color: #67e8f9;
background: #ffffff;
color: #0f172a;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.docsFrame {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 220px;
gap: 28px;
padding: 24px;
grid-template-columns: 268px minmax(0, 850px) 226px;
justify-content: center;
gap: 24px;
width: 100%;
max-width: 1480px;
margin: 0 auto;
padding: 28px 24px 68px;
}
.sidebar {
position: sticky;
top: 86px;
top: 82px;
align-self: start;
max-height: calc(100vh - 110px);
max-height: calc(100vh - 106px);
overflow: auto;
padding: 20px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 20px;
background: rgba(15, 23, 42, 0.72);
padding: 14px;
border: 1px solid rgba(184, 196, 214, 0.72);
border-radius: 10px;
background: #ffffff;
box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06));
}
.sidebarGroup + .sidebarGroup {
margin-top: 24px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid #e8edf5;
}
.sidebarTitle {
margin: 0 0 10px;
color: rgba(148, 163, 184, 0.92);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 8px;
padding: 0 8px;
color: #64748b;
font-size: 0.74rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sidebarLink {
position: relative;
display: block;
margin-bottom: 6px;
padding: 10px 12px;
border-radius: 12px;
color: rgba(226, 232, 240, 0.88);
margin-bottom: 3px;
padding: 9px 10px 9px 14px;
border-radius: 8px;
color: #334155;
text-decoration: none;
font-size: 0.95rem;
font-size: 0.92rem;
font-weight: 650;
line-height: 1.35;
transition:
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.sidebarLink:hover {
background: #f1f5f9;
color: #0f172a;
}
.sidebarLinkActive {
background: rgba(34, 211, 238, 0.12);
color: #67e8f9;
background: #eff6ff;
color: #1d4ed8;
font-weight: 850;
box-shadow: inset 3px 0 0 var(--color-accent-primary, #2563eb);
}
.content {
@@ -134,84 +194,215 @@
}
.contentInner {
padding: 12px 0 48px;
padding: 0 0 52px;
}
.pageIntro {
padding: 28px 30px;
border: 1px solid rgba(184, 196, 214, 0.72);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.96));
box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06));
}
.introKicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
color: #047857;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.introKicker svg {
color: #00897b;
}
.pageTitle {
margin: 0;
font-size: clamp(2.1rem, 4vw, 3.2rem);
font-weight: 800;
letter-spacing: -0.03em;
color: #0f172a;
font-size: clamp(2rem, 3vw, 2.72rem);
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
}
.pageDescription {
max-width: 820px;
max-width: 760px;
margin: 14px 0 0;
color: rgba(191, 219, 254, 0.9);
font-size: 1.03rem;
color: #475569;
font-size: 1.02rem;
line-height: 1.75;
}
.pageMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.pageMeta span {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
font-size: 0.78rem;
font-weight: 850;
}
.section {
margin-top: 40px;
scroll-margin-top: 110px;
margin-top: 0;
padding: 34px 2px 30px;
border-bottom: 1px solid rgba(216, 224, 236, 0.78);
scroll-margin-top: 92px;
}
.section:last-child {
border-bottom: 0;
}
.sectionTitle {
position: relative;
margin: 0 0 16px;
font-size: 1.65rem;
font-weight: 700;
padding-left: 14px;
color: #172033;
font-size: 1.34rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.3;
}
.sectionTitle::before {
content: "";
position: absolute;
left: 0;
top: 0.16em;
bottom: 0.16em;
width: 4px;
border-radius: 4px;
background: linear-gradient(180deg, #2563eb, #00897b);
}
.paragraph {
margin: 0 0 16px;
color: rgba(226, 232, 240, 0.94);
line-height: 1.9;
max-width: 780px;
margin: 0 0 15px;
color: #334155;
font-size: 1rem;
line-height: 1.85;
}
.callout {
margin: 18px 0;
padding: 18px 20px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 18px;
background: rgba(15, 23, 42, 0.72);
position: relative;
max-width: 790px;
margin: 20px 0;
padding: 17px 18px 17px 20px;
border: 1px solid #d8e0ec;
border-left-width: 4px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05);
}
.calloutInfo {
border-color: rgba(34, 211, 238, 0.24);
background: rgba(8, 47, 73, 0.26);
border-left-color: var(--color-accent-primary, #2563eb);
background: #f5f9ff;
}
.calloutWarning {
border-color: rgba(251, 191, 36, 0.24);
background: rgba(120, 53, 15, 0.2);
border-left-color: #d97706;
background: #fffaf0;
}
.calloutSuccess {
border-color: rgba(52, 211, 153, 0.24);
background: rgba(6, 78, 59, 0.22);
border-left-color: #00897b;
background: #f0fdfa;
}
.calloutTitle {
margin: 0 0 8px;
margin: 0 0 7px;
color: #0f172a;
font-size: 0.98rem;
font-weight: 700;
font-weight: 850;
}
.calloutText {
margin: 0;
line-height: 1.8;
color: #334155;
line-height: 1.75;
}
.list {
margin: 0;
padding-left: 20px;
color: rgba(226, 232, 240, 0.94);
line-height: 1.85;
max-width: 800px;
margin: 0 0 18px;
padding: 0;
color: #334155;
line-height: 1.75;
}
.list li + li {
margin-top: 8px;
margin-top: 9px;
}
.bulletList {
list-style: none;
}
.bulletList li {
position: relative;
padding-left: 22px;
}
.bulletList li::before {
content: "";
position: absolute;
left: 3px;
top: 0.72em;
width: 7px;
height: 7px;
border-radius: 999px;
background: #00897b;
box-shadow: 0 0 0 4px rgba(0, 137, 123, 0.1);
}
.stepList {
list-style: none;
counter-reset: docs-step;
}
.stepList li {
counter-increment: docs-step;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 12px 14px;
border: 1px solid rgba(216, 224, 236, 0.88);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
}
.stepList li::before {
content: counter(docs-step);
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #0f172a;
color: #ffffff;
font-size: 0.76rem;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.linkCard {
@@ -219,54 +410,91 @@
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 16px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.34);
background: rgba(8, 47, 73, 0.3);
color: #67e8f9;
padding: 0 15px;
border: 1px solid #93c5fd;
border-radius: 10px;
background: #eff6ff;
color: #1d4ed8;
text-decoration: none;
font-weight: 700;
font-weight: 850;
}
.linkCard::after {
content: "->";
margin-left: 8px;
font-family: var(--font-mono, monospace);
}
.linkCard:hover {
background: rgba(34, 211, 238, 0.12);
border-color: #60a5fa;
background: #dbeafe;
}
.linkCaption {
margin: 10px 0 0;
color: rgba(148, 163, 184, 0.92);
line-height: 1.75;
color: #64748b;
line-height: 1.7;
}
.content figure {
max-width: 800px;
margin: 22px 0;
}
.content img {
display: block;
width: 100%;
height: auto;
border: 1px solid #d8e0ec;
border-radius: 10px;
background: #ffffff;
}
.content figcaption {
margin-top: 9px;
color: #64748b;
font-size: 0.9rem;
line-height: 1.6;
}
.toc {
position: sticky;
top: 86px;
top: 82px;
align-self: start;
padding: 20px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 20px;
background: rgba(15, 23, 42, 0.64);
padding: 14px;
border: 1px solid rgba(184, 196, 214, 0.72);
border-radius: 10px;
background: #ffffff;
box-shadow: var(--shadow-elevation-1, 0 1px 3px rgba(15, 23, 42, 0.05));
}
.tocTitle {
margin: 0 0 12px;
color: rgba(148, 163, 184, 0.92);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
color: #64748b;
font-size: 0.74rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tocLink {
display: block;
color: rgba(226, 232, 240, 0.86);
padding: 7px 0 7px 12px;
border-left: 2px solid #e8edf5;
color: #475569;
text-decoration: none;
line-height: 1.7;
font-size: 0.92rem;
line-height: 1.45;
font-size: 0.88rem;
}
.tocLink:hover {
border-left-color: #2563eb;
color: #1d4ed8;
}
.mobileMenuButton {
display: none;
margin-bottom: 14px;
}
.mobileSidebarBackdrop {
@@ -275,7 +503,8 @@
@media (max-width: 1200px) {
.docsFrame {
grid-template-columns: 260px minmax(0, 1fr);
grid-template-columns: 248px minmax(0, 850px);
justify-content: start;
}
.toc {
@@ -287,10 +516,12 @@
.docsHeader {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.docsFrame {
grid-template-columns: minmax(0, 1fr);
padding-top: 18px;
}
.mobileMenuButton {
@@ -302,10 +533,12 @@
top: 0;
left: 0;
z-index: 60;
width: min(320px, 88vw);
width: min(326px, 88vw);
height: 100vh;
height: 100dvh;
max-height: none;
border-radius: 0 20px 20px 0;
padding: 18px 14px;
border-radius: 0 10px 10px 0;
transform: translateX(-100%);
transition: transform 180ms ease-out;
}
@@ -318,27 +551,65 @@
position: fixed;
inset: 0;
z-index: 50;
background: rgba(2, 6, 23, 0.52);
display: block;
border: 0;
background: rgba(15, 23, 42, 0.48);
}
}
@media (max-width: 640px) {
.docsHeader {
padding: 16px;
}
.docsFrame {
padding: 16px;
padding: 12px 14px;
}
.brandWrap {
flex-direction: column;
align-items: flex-start;
gap: 4px;
gap: 2px;
}
.pageDescription {
.brandSubtitle {
white-space: normal;
}
.headerActions {
width: 100%;
align-items: stretch;
justify-content: space-between;
}
.headerGhost,
.langSwitch {
flex: 1 1 auto;
}
.langSwitch {
justify-content: center;
}
.docsFrame {
padding: 14px 14px 48px;
}
.pageIntro {
padding: 22px 18px;
}
.pageTitle {
font-size: 1.9rem;
}
.pageDescription,
.paragraph {
font-size: 0.98rem;
}
.section {
padding: 28px 0 26px;
}
.stepList li {
grid-template-columns: 30px minmax(0, 1fr);
padding: 11px 12px;
}
}
+31 -6
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { ArrowLeft, BookOpen, Menu } from "lucide-react";
import styles from "./DocsLayout.module.css";
import {
DocsLocale,
@@ -30,6 +31,7 @@ function DocsHeader() {
<div className={styles.headerActions}>
<Link href="/" className={styles.headerGhost}>
<ArrowLeft size={16} aria-hidden="true" />
{locale === "zh-CN" ? "返回主站" : "Back to App"}
</Link>
<div className={styles.langSwitch} role="group" aria-label="Language switch">
@@ -81,6 +83,7 @@ function DocsSidebar({
key={page.slug}
href={href}
className={clsx(styles.sidebarLink, currentSlug === page.slug && styles.sidebarLinkActive)}
aria-current={currentSlug === page.slug ? "page" : undefined}
onClick={onClose}
>
{title}
@@ -114,20 +117,30 @@ function BlockRenderer({ block }: { block: DocsPageContent["sections"][number]["
return <p className={styles.paragraph}>{block.text}</p>;
case "callout":
return (
<div className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}>
<div
className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}
role="note"
>
{block.title ? <div className={styles.calloutTitle}>{block.title}</div> : null}
<p className={styles.calloutText}>{block.text}</p>
</div>
);
case "bullets":
case "steps":
return (
<ul className={styles.list}>
<ul className={clsx(styles.list, styles.bulletList)}>
{block.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
case "steps":
return (
<ol className={clsx(styles.list, styles.stepList)}>
{block.items.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
);
case "link":
return (
<div>
@@ -175,10 +188,22 @@ export function DocsScreen({ page }: { page: DocsPage }) {
<main className={styles.content}>
<div className={styles.contentInner}>
<button type="button" className={clsx(styles.headerButton, styles.mobileMenuButton)} onClick={() => setMobileSidebarOpen(true)}>
{locale === "zh-CN" ? "打开导航" : "Open navigation"}
<Menu size={16} aria-hidden="true" />
{locale === "zh-CN" ? "目录" : "Contents"}
</button>
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
<p className={styles.pageDescription}>{localizedPage.description}</p>
<div className={styles.pageIntro}>
<div className={styles.introKicker}>
<BookOpen size={16} aria-hidden="true" />
{locale === "zh-CN" ? "PolyWeather 产品手册" : "PolyWeather Product Manual"}
</div>
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
<p className={styles.pageDescription}>{localizedPage.description}</p>
<div className={styles.pageMeta} aria-label={locale === "zh-CN" ? "文档范围" : "Document scope"}>
<span>{locale === "zh-CN" ? "当前工作台" : "Current terminal"}</span>
<span>{locale === "zh-CN" ? `${DOCS_PAGES.length} 篇文档` : `${DOCS_PAGES.length} docs`}</span>
<span>{locale === "zh-CN" ? `${localizedPage.sections.length} 个章节` : `${localizedPage.sections.length} sections`}</span>
</div>
</div>
{localizedPage.sections.map((section) => (
<section key={section.id} id={section.id} className={styles.section}>
<h2 className={styles.sectionTitle}>{section.title}</h2>
@@ -0,0 +1,21 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const cssPath = path.join(process.cwd(), "components", "docs", "DocsLayout.module.css");
const source = fs.readFileSync(cssPath, "utf8");
assert(
source.includes("background:") && source.includes("#f8fbff") && source.includes("#eef4fb"),
"docs shell must define a light page background instead of inheriting mixed app styles",
);
assert(source.includes(".pageTitle") && source.includes("color: #0f172a"), "docs title must use high-contrast dark text");
assert(source.includes(".paragraph") && source.includes("color: #334155"), "docs body copy must use readable dark text");
assert(source.includes(".sidebar") && source.includes("background: #ffffff"), "docs sidebar must use a solid light surface");
assert(source.includes(".toc") && source.includes("background: #ffffff"), "docs table of contents must use a solid light surface");
assert(!source.includes("color: rgba(226, 232, 240"), "docs layout must not use dark-theme pale text on a light page");
}
@@ -12,25 +12,25 @@ import {
} from "@/components/landing/landingLocale";
const COVERAGE_EN = [
"Live airport observations",
"DEB blend forecast",
"Model-implied distribution",
"Intraday observation windows",
"Deviation checks and risk thresholds",
"Paid Telegram alerts",
"AMOS 60s runway sensors",
"AMSC 180s runway endpoints",
"MADIS 300s airport observations",
"CoWIN 60s + HKO 600s",
"Live chart updates",
"Short Telegram alerts",
];
const COVERAGE_ZH = [
"机场实况观测数据",
"DEB 智能融合预报",
"模型隐含分布预测",
"日内分段观测窗口",
"偏差校验与风控阈值",
"付费 Telegram 实时通知",
"AMOS 60s 跑道传感器",
"AMSC 180s 跑道端点",
"MADIS 300s 机场观测",
"CoWIN 60s + HKO 600s",
"网页图表实时更新",
"Telegram 简短提醒",
];
const PRO_FEATURES_EN = [
"METAR airport observations and runway-level reference data",
"Settlement-source-first airport, official-station, and runway observations",
"DEB blend forecast with model-spread context",
"Model-implied distribution and probability estimates",
"Intraday windows, deviation metrics, and settlement context",
@@ -39,7 +39,7 @@ const PRO_FEATURES_EN = [
];
const PRO_FEATURES_ZH = [
"METAR 机场实测与跑道级参考数据",
"结算源优先的机场、官方站与跑道实测",
"DEB 智能融合预报与模型分歧背景",
"模型隐含分布预测与概率估算",
"日内观测窗口、偏差度量与结算背景",
@@ -170,8 +170,8 @@ function WeatherWorkflowIllustration() {
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-16 z-0 mx-auto hidden h-[240px] max-w-6xl overflow-hidden md:block"
>
<div className="absolute left-8 top-14 h-24 w-24 rotate-[-7deg] rounded-lg border-2 border-slate-900 bg-[#fff3b0] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
<div className="absolute right-14 top-10 h-20 w-28 rotate-[6deg] rounded-lg border-2 border-slate-900 bg-[#dff8ea] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
<div className="landing-float absolute left-8 top-14 h-24 w-24 rotate-[-7deg] rounded-lg border-2 border-slate-900 bg-[#fff3b0] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
<div className="landing-float-slow absolute right-14 top-10 h-20 w-28 rotate-[6deg] rounded-lg border-2 border-slate-900 bg-[#dff8ea] shadow-[6px_6px_0_rgba(15,23,42,0.12)]" />
</div>
);
}
@@ -249,8 +249,11 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<LandingAnalytics />
<header className="sticky top-0 z-30 border-b border-slate-200 bg-[#fbfbfa]/90 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2 transition-opacity hover:opacity-80">
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
<Link
href="/"
className="text-base font-black tracking-tight text-slate-950 transition-opacity hover:opacity-80 sm:text-lg"
>
PolyWeather
</Link>
<nav className="hidden items-center gap-7 text-sm font-medium text-slate-500 md:flex">
@@ -260,6 +263,12 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<a href="#coverage" className="hover:text-slate-950">
{isEn ? "Data" : "数据"}
</a>
<a href="#screenshots" className="hover:text-slate-950">
{isEn ? "Screens" : "截图"}
</a>
<Link href="/docs/chart-guide" className="hover:text-slate-950">
{isEn ? "Guide" : "读图"}
</Link>
<a href="#pricing" className="hover:text-slate-950">
{isEn ? "Pricing" : "定价"}
</a>
@@ -274,27 +283,29 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<WeatherWorkflowIllustration />
<div className="relative z-10 mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<h1 className="text-5xl font-black leading-[1.04] tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
<h1 className="landing-rise text-5xl font-black leading-[1.04] tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
PolyWeather
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600 sm:text-xl">
<p className="landing-rise landing-delay-1 mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600 sm:text-xl">
{isEn
? "A calmer way to read airport weather, model forecasts, and intraday risk before the market moves."
: "用更轻松的方式阅读机场天气、模型预报和日内风险,在市场变化前完成判断。"}
? "A settlement-source-first terminal for temperature markets: live airport/runway observations, DEB, market buckets, and alerts in one workflow."
: "面向温度市场的结算源优先终端:机场/跑道实测、DEB、市场温度桶和提醒放在同一个工作流里。"}
</p>
<LandingHeroActions locale={locale} />
<p className="mt-4 text-sm text-slate-500">
<div className="landing-rise landing-delay-2">
<LandingHeroActions locale={locale} />
</div>
<p className="landing-rise landing-delay-3 mt-4 text-sm text-slate-500">
{isEn
? "Start with a one-time 3-day trial. Trial access matches Pro except for the paid Telegram group link."
: "新用户可先领一次 3 天试用。试用期权益和 Pro 一致,除了不显示付费 Telegram 群链接。"}
</p>
</div>
<div className="mx-auto mt-14 max-w-5xl rounded-lg border border-slate-200 bg-white p-2 shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="landing-float-slow landing-screen-glow mx-auto mt-14 max-w-5xl rounded-lg border border-slate-200 bg-white p-2 shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="flex h-9 items-center gap-2 border-b border-slate-200 px-3">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff6b6b]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#ffd166]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
<span className="landing-pulse-dot h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
<span className="ml-2 text-xs font-semibold text-slate-400">
polyweather.app/terminal
</span>
@@ -318,7 +329,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
{heroStats.map((item) => (
<div
key={item.label}
className="rounded-lg border border-slate-200 bg-white px-4 py-4 shadow-sm"
className="landing-hover-lift rounded-lg border border-slate-200 bg-white px-4 py-4 shadow-sm"
>
<div className="font-mono text-lg font-black text-slate-950">
{item.value}
@@ -338,14 +349,14 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
{isEn
? "Like a tidy workspace for weather decisions."
: "像整理好的工作区一样阅读天气决策。"}
? "Built around the station that actually matters."
: "围绕真正会影响结算的站点构建。"}
</h2>
</div>
<div className="mt-12 grid gap-4 md:grid-cols-3">
{platformCards.map(({ body, icon, title }) => (
<article key={title} className="rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<article key={title} className="landing-hover-lift rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-800">
<LandingIcon name={icon} size={19} />
</div>
@@ -354,6 +365,100 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</article>
))}
</div>
<div className="mt-10 rounded-lg border border-slate-200 bg-[#fbfbfa] p-5">
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">
{isEn ? "Differentiation" : "差异化卖点"}
</p>
<h3 className="mt-3 text-2xl font-black tracking-tight text-slate-950">
{isEn ? "Settlement-source first, not generic weather." : "结算源优先,不做泛天气看板。"}
</h3>
<p className="mt-3 text-sm leading-7 text-slate-600">
{isEn
? "The product is built around the station, runway, and update cadence that affect settlement, then layers DEB, market buckets, and alerting on top."
: "产品围绕真正影响结算的站点、跑道和源头频率构建,再叠加 DEB、市场温度桶和提醒工作流。"}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(isEn
? [
"Runway-level China and Korea observation context",
"Hong Kong CoWIN + HKO dual-source reading",
"Live pages update as new source readings arrive",
"Telegram sends concise alerts without noisy refresh loops",
]
: [
"中国和韩国跑道级实测上下文",
"香港 CoWIN + HKO 双源读数",
"源头有新读数时,网页自动补上变化",
"Telegram 只发简短提醒,避免噪音刷屏",
]
).map((item) => (
<div key={item} className="landing-hover-lift rounded-md border border-slate-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-700">
{item}
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section id="screenshots" className="border-b border-slate-200 bg-[#fbfbfa] px-4 py-20 sm:px-6">
<div className="mx-auto max-w-6xl">
<div className="max-w-2xl">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400">
{isEn ? "Product Screenshots" : "产品截图"}
</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
{isEn ? "The terminal and alerts are the product." : "终端和提醒就是核心产品。"}
</h2>
<p className="mt-4 text-sm leading-7 text-slate-600">
{isEn
? "See what you will use before subscribing: a browser terminal for live temperature evidence, plus short Telegram alerts when runway or settlement signals change."
: "订阅前先看清你会用到什么:网页上看实时温度证据;跑道或结算源有变化时,在 Telegram 收到简短提醒。"}
</p>
</div>
<div className="mt-10 grid gap-5 lg:grid-cols-[1.4fr_0.8fr]">
<figure className="landing-hover-lift rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
<div className="aspect-[16/9] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
<img
src="/static/web.webp"
width="680"
height="340"
alt={isEn ? "Realtime terminal screenshot" : "实时终端截图"}
className="h-full w-full object-cover object-top transition duration-500 hover:scale-[1.015]"
decoding="async"
loading="lazy"
sizes="(min-width: 1024px) 760px, calc(100vw - 48px)"
/>
</div>
<figcaption className="px-2 py-3 text-xs font-semibold text-slate-500">
{isEn ? "Browser terminal: settlement observations, DEB, source cadence, and market context." : "浏览器终端:结算实测、DEB、源头频率和市场上下文。"}
</figcaption>
</figure>
<figure className="landing-hover-lift rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
<div className="aspect-[9/16] max-h-[520px] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
<img
src="/static/tel.png"
width="420"
height="640"
alt={isEn ? "Telegram runway alert screenshot" : "Telegram 跑道提醒截图"}
className="h-full w-full object-cover object-top transition duration-500 hover:scale-[1.015]"
decoding="async"
loading="lazy"
sizes="(min-width: 1024px) 340px, calc(100vw - 48px)"
/>
</div>
<figcaption className="px-2 py-3 text-xs font-semibold text-slate-500">
{isEn ? "Telegram alerts: concise runway and settlement-source updates for paid users." : "Telegram 提醒:为付费用户提供简洁的跑道与结算源更新。"}
</figcaption>
</figure>
</div>
</div>
</section>
@@ -364,8 +469,27 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
{isEn ? "Data Coverage" : "数据覆盖"}
</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
{isEn ? "Keep the signal clear and the page approachable." : "信号清楚,页面也可以亲和。"}
{isEn ? "Refresh cadence follows the source, not a fake timer." : "刷新频率跟随源头,不伪装成统一定时器。"}
</h2>
<p className="mt-4 text-sm leading-7 text-slate-600">
{isEn
? "PolyWeather follows each source's real update rhythm. The website refreshes the visible charts as new readings arrive, while Telegram keeps alerts short and readable."
: "PolyWeather 跟随每个数据源自己的更新节奏。网页图表会补上最新读数,Telegram 只保留短提醒,让你快速知道哪里变了。"}
</p>
<div className="mt-6 flex flex-wrap gap-2">
<Link
href="/docs/chart-guide"
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Read chart guide" : "查看读图指南"}
</Link>
<Link
href="/docs/realtime-sources"
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Source cadence" : "数据源频率"}
</Link>
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm">
@@ -373,7 +497,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
{coverage.map((item, index) => (
<div
key={item}
className="flex items-center gap-3 rounded-md border border-slate-100 bg-[#fbfbfa] px-4 py-3"
className="landing-hover-lift flex items-center gap-3 rounded-md border border-slate-100 bg-[#fbfbfa] px-4 py-3"
>
<span
className={`grid h-8 w-8 place-items-center rounded-md ${
@@ -408,7 +532,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
</p>
</div>
<div className="mt-12 grid gap-4 md:grid-cols-3">
<div className="mt-12 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="flex items-center gap-2 text-sm font-bold text-emerald-700">
<LandingIcon name="clock" />
@@ -440,20 +564,31 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
Pro
</div>
<h3 className="mt-5 text-2xl font-black text-slate-950">
{isEn ? "Pro Monthly" : "Pro 月付"}
Pro
</h3>
<p className="mt-3 text-sm leading-7 text-slate-600">
{isEn
? "Full Pro access for 30 days, including paid Telegram group eligibility."
: "完整 Pro 权限 30 天,包含付费 Telegram 群准入资格。"}
? "Full terminal access, chart guides, advanced context, and paid Telegram group eligibility."
: "完整终端权限、读图指南、高级上下文和付费 Telegram 群准入资格。"}
</p>
<div className="mt-7 flex items-baseline gap-2">
<span className="font-mono text-5xl font-black text-slate-950">29.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 30 </span>
<div className="mt-7 space-y-2">
<div className="flex items-baseline gap-2">
<span className="font-mono text-4xl font-black text-slate-950">29.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 30 </span>
</div>
<div className="flex items-baseline gap-2">
<span className="font-mono text-2xl font-black text-slate-950">79.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 90 </span>
</div>
</div>
<p className="mt-2 text-xs font-semibold text-slate-500">
{isEn ? "Referral first month: 20 USDC" : "使用邀请码首月 20 USDC"}
</p>
<div className="mt-4 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold text-slate-600">
{isEn
? "Invite reward: referrer gets +3500 points when invitee subscribes."
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
</div>
<ul className="mt-7 space-y-3 border-t border-slate-200 pt-6">
{(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => (
<li key={feature} className="flex items-start gap-3">
@@ -478,30 +613,51 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="flex items-center gap-2 text-sm font-bold text-amber-700">
<LandingIcon name="lineChart" />
{isEn ? "Quarterly" : "季度"}
API
</div>
<h3 className="mt-5 text-2xl font-black text-slate-950">
{isEn ? "Pro Quarterly" : "Pro 季度"}
{isEn ? "API" : "API"}
</h3>
<p className="mt-3 flex-1 text-sm leading-7 text-slate-600">
{isEn
? "90 days of Pro access for users with steady usage. Lower cost per month."
: "90 天 Pro 权限,适合稳定使用的个人和团队,折算月成本更低。"}
? "Not sold as a public product right now. The public site and Telegram workflow remain the supported product surface."
: "目前不作为公开产品售卖。当前支持的产品形态仍是网站终端和 Telegram 工作流。"}
</p>
<div className="mt-7 flex items-baseline gap-2">
<span className="font-mono text-5xl font-black text-slate-950">79.9</span>
<span className="text-sm font-semibold text-slate-500">USDC / 90 </span>
</div>
<div className="mt-5 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
{isEn
? "Invite reward: referrer gets +3500 points when invitee subscribes."
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
<div className="mt-7 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
{isEn ? "Not for sale. We will revisit API packaging only after endpoint docs, keys, limits, and support boundaries are ready." : "暂不售卖。只有接口文档、key、限额和支持边界准备好后,才重新评估 API 产品化。"}
</div>
<Link
href="/account"
href="/docs/realtime-sources"
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Choose quarterly" : "选择季度 Pro"}
{isEn ? "Read data guide" : "查看数据指南"}
<LandingIcon name="arrow" size={15} />
</Link>
</div>
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
<div className="flex items-center gap-2 text-sm font-bold text-violet-700">
<LandingIcon name="shield" />
Team
</div>
<h3 className="mt-5 text-2xl font-black text-slate-950">
{isEn ? "Team" : "团队"}
</h3>
<p className="mt-3 flex-1 text-sm leading-7 text-slate-600">
{isEn
? "For teams that need shared access, Telegram workflow support, and manual onboarding."
: "面向需要共享权限、Telegram 工作流支持和人工开通的团队。"}
</p>
<div className="mt-7 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
{isEn
? "Custom seat and group setup. Best for teams already using the terminal together."
: "自定义席位和群组配置,适合已经一起使用终端的团队。"}
</div>
<Link
href="/subscription-help"
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Talk to us" : "联系开通"}
<LandingIcon name="arrow" size={15} />
</Link>
</div>
@@ -49,6 +49,12 @@ export function runTests() {
assert(!source.includes("高频刷新与 API 仍为 Pro 权益"), "landing page must not incorrectly exclude high-frequency refresh or API from trial access");
assert(source.includes("bg-[#fbfbfa]"), "landing page must use a light Notion-style background");
assert(source.includes("WeatherWorkflowIllustration"), "landing page must include a friendly illustration surface");
assert(
/className="text-base font-black tracking-tight text-slate-950[\s\S]*?>\s*PolyWeather\s*<\/Link>/.test(
source,
),
"landing header must expose a readable PolyWeather wordmark instead of a tiny square logo image",
);
assert(!fs.existsSync(publicPngPath), "heavy PNG preview must not remain in public static assets");
assert(fs.existsSync(fixturePngPath), "PNG preview may only remain as a test fixture");
assert(fs.existsSync(webpPath), "landing page must ship a WebP preview image for the LCP product screenshot");
@@ -57,6 +63,9 @@ export function runTests() {
"WebP preview must be materially smaller than the PNG LCP image",
);
assert(source.includes("/static/web.webp"), "landing page must load the lighter WebP product preview image");
assert(source.includes("/static/tel.png"), "landing page must include the Telegram alert screenshot");
assert(source.includes("#screenshots"), "landing navigation must expose the product screenshot section");
assert(source.includes("结算源优先") && source.includes("差异化卖点"), "landing page must explain the differentiated settlement-source positioning");
assert(!source.includes('src="/static/web.png"'), "landing hero must not use the heavy PNG as its primary LCP image");
assert(
source.includes('width="680"') &&
@@ -73,6 +82,10 @@ export function runTests() {
);
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
assert(source.includes("API") && source.includes("暂不售卖"), "landing page must describe API as not currently for sale");
assert(!source.includes("Request API") && !source.includes("申请 API"), "landing page must not invite users to buy or request API access");
assert(source.includes("Team") && source.includes("团队"), "landing page must describe the Team tier");
assert(source.includes("Trial") && source.includes("Pro") && source.includes("API") && source.includes("Team"), "landing pricing ladder must clearly name Trial / Pro / API / Team");
assert(source.includes("20 USDC") && source.includes("+3500 积分"), "landing page must describe referral discount and reward");
assert(!source.includes("AI 气象证据链解读"), "legacy AI evidence-chain wording must be removed");
assert(!source.includes("AI weather evidence"), "legacy AI evidence wording must be removed");
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
function readFrontend(...parts: string[]) {
return fs.readFileSync(path.join(process.cwd(), ...parts), "utf8");
}
export function runTests() {
const timingSource = readFrontend("lib", "proxy-timing.ts");
assert.match(
timingSource,
/createProxyTimer/,
"shared proxy timing helper should create timers for slow API proxies",
);
assert.match(
timingSource,
/Server-Timing/,
"shared proxy timing helper should write Server-Timing headers for HAR inspection",
);
assert.doesNotMatch(
timingSource,
/authUserId|authEmail|userId|email/,
"proxy timing logs must avoid raw user ids or emails",
);
const apiProxySource = readFrontend("lib", "api-proxy.ts");
assert.match(
apiProxySource,
/timing\?: ProxyTimer/,
"generic backend JSON proxy should accept an optional timer",
);
for (const stage of ["auth_headers", "backend_fetch", "backend_read"]) {
assert.match(
apiProxySource,
new RegExp(stage),
`generic backend JSON proxy should measure ${stage}`,
);
}
const detailBatchProxy = readFrontend("app", "api", "cities", "detail-batch", "route.ts");
assert.match(detailBatchProxy, /createProxyTimer\(req,\s*"city_detail_batch"\)/);
assert.match(detailBatchProxy, /timing:\s*timer/);
assert.match(
detailBatchProxy,
/POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS\s*\|\|\s*"15000"/,
"city detail batch proxy should leave room for backend partial responses instead of aborting at the chart fetch deadline",
);
assert.match(
detailBatchProxy,
/fetchCache:\s*"no-store"/,
"city detail batch proxy should avoid caching partial backend fetches in the Next data cache",
);
assert.match(
detailBatchProxy,
/cacheControlForData/,
"city detail batch proxy should be able to suppress response caching for partial payloads",
);
assert.match(
detailBatchProxy,
/buildCityDetailBatchTimeoutPayload/,
"city detail batch proxy should degrade proxy timeouts into the same partial payload shape the chart client already tolerates",
);
assert.match(
detailBatchProxy,
/missing:\s*requestedCities/,
"city detail batch proxy timeout fallback should mark requested cities as missing instead of surfacing a browser 504",
);
assert.match(
detailBatchProxy,
/partial:\s*true/,
"city detail batch proxy timeout fallback should preserve partial response semantics",
);
assert.match(
detailBatchProxy,
/status:\s*200/,
"city detail batch proxy timeout fallback should avoid red 504 fetch failures for optional chart enrichment",
);
assert.match(
apiProxySource,
/cacheControlForData\?:/,
"generic backend JSON proxy should allow response cache policy to depend on parsed data",
);
const scanTerminalProxy = readFrontend("app", "api", "scan", "terminal", "route.ts");
assert.match(scanTerminalProxy, /createProxyTimer\(req,\s*"scan_terminal"\)/);
assert.match(scanTerminalProxy, /timing:\s*timer/);
assert.match(
scanTerminalProxy,
/POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"35000"/,
"scan terminal proxy should allow the production backend enough time to return before the 45 second route cap",
);
assert.match(
scanTerminalProxy,
/export const maxDuration = 45/,
"scan terminal proxy timeout budget should remain below the Next route execution cap",
);
const cityDetailProxy = readFrontend("app", "api", "city", "[name]", "detail", "route.ts");
assert.match(cityDetailProxy, /createProxyTimer\(req,\s*"city_detail"\)/);
for (const stage of ["auth_headers", "backend_fetch", "backend_read"]) {
assert.match(cityDetailProxy, new RegExp(stage));
}
const onlineUsersProxy = readFrontend("app", "api", "ops", "online-users", "route.ts");
assert.match(onlineUsersProxy, /createProxyTimer\(req,\s*"ops_online_users"\)/);
for (const stage of ["auth_headers", "ops_auth", "backend_fetch", "backend_read"]) {
assert.match(onlineUsersProxy, new RegExp(stage));
}
const analyticsProxy = readFrontend("app", "api", "analytics", "events", "route.ts");
assert.match(
analyticsProxy,
/ANALYTICS_PROXY_TIMEOUT_MS/,
"analytics event proxy should use a short dedicated timeout instead of waiting for long backend stalls",
);
assert.match(
analyticsProxy,
/createProxyTimer\(req,\s*"analytics_events"\)/,
"analytics event proxy should expose Server-Timing for HAR inspection",
);
assert.match(
analyticsProxy,
/AbortController/,
"analytics event proxy should abort slow upstream tracking requests",
);
assert.match(
analyticsProxy,
/status:\s*202/,
"analytics event proxy timeout should stay non-blocking for the fire-and-forget client event",
);
}
@@ -24,6 +24,12 @@ export function runTests() {
authMeRouteSource.includes("total"),
"/api/auth/me proxy must expose stage durations through Server-Timing for HAR inspection",
);
assert(
authMeRouteSource.includes('response.headers.set("Cache-Control", "no-store")') &&
authMeRouteSource.indexOf('response.headers.set("Cache-Control", "no-store")') >
authMeRouteSource.indexOf("function finishAuthMeResponse"),
"/api/auth/me proxy must mark every auth profile response no-store so anonymous state cannot be reused after login",
);
const finishStart = authMeRouteSource.indexOf("function finishAuthMeResponse");
const finishEnd = authMeRouteSource.indexOf("async function trackAuthDiagnosticEvent");
const finishSource =
@@ -33,7 +33,9 @@ export function runTests() {
paymentsPage.includes("Intent 卡住") &&
paymentsPage.includes("积分异常") &&
paymentsPage.includes("推荐奖励结算") &&
paymentsPage.includes("月度邀请封顶"),
paymentsPage.includes("月度邀请封顶") &&
paymentsPage.includes("occurrence_count") &&
paymentsPage.includes("支付事件不匹配"),
"ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals",
);
assert(
@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const feedbackPagePath = path.join(
projectRoot,
"components",
"ops",
"feedback",
"FeedbackPageClient.tsx",
);
const source = fs.readFileSync(feedbackPagePath, "utf8");
assert(
source.includes("<select") &&
source.includes("onChange={(event) =>") &&
source.includes("changeStatus(row, event.target.value)") &&
source.includes("disabled={updatingId === row.id}") &&
source.includes("STATUS_UPDATE_OPTIONS.map"),
"ops feedback action column must use a status dropdown",
);
assert(
!source.includes("标为{feedbackActionLabel(row.status)}") &&
!source.includes("advanceStatus(row)"),
"ops feedback action column must not use one-step status buttons",
);
assert(
source.includes("积分奖励标准") &&
source.includes("REWARD_GUIDELINES") &&
source.includes("100") &&
source.includes("300") &&
source.includes("500") &&
source.includes("1000") &&
source.includes("1500"),
"ops feedback page must document fixed reward point guidelines",
);
}
@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const feedbackPageSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "feedback", "FeedbackPageClient.tsx"),
"utf8",
);
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const rewardRoutePath = path.join(
projectRoot,
"app",
"api",
"ops",
"feedback",
"[feedbackId]",
"reward",
"route.ts",
);
assert(fs.existsSync(rewardRoutePath), "ops feedback reward proxy route must exist");
assert(
opsApiSource.includes("grantFeedbackReward") &&
opsApiSource.includes("/api/ops/feedback/${feedbackId}/reward") &&
opsApiSource.includes("JSON.stringify({ points })"),
"ops API client must expose grantFeedbackReward with fixed points only",
);
assert(
feedbackPageSource.includes("REWARD_POINT_OPTIONS") &&
feedbackPageSource.includes("handleRewardGrant") &&
feedbackPageSource.includes("发放奖励") &&
feedbackPageSource.includes("opsApi.grantFeedbackReward"),
"ops feedback page must provide fixed-point reward grant controls",
);
assert(
feedbackPageSource.includes('value: 100') &&
feedbackPageSource.includes('value: 300') &&
feedbackPageSource.includes('value: 500') &&
feedbackPageSource.includes('value: 1000') &&
feedbackPageSource.includes('value: 1500'),
"ops feedback page must use fixed reward point options",
);
assert(
!feedbackPageSource.includes("奖励原因") &&
!feedbackPageSource.includes("rewardDrafts") &&
!feedbackPageSource.includes("reason:"),
"ops feedback page must not ask operators for reward reasons",
);
assert(
feedbackPageSource.includes("已发放") &&
feedbackPageSource.includes("reward_points"),
"ops feedback page must show existing feedback reward details",
);
}
@@ -16,6 +16,10 @@ export function runTests() {
path.join(projectRoot, "app", "api", "ops", "source-health", "route.ts"),
"utf8",
);
const collectorRoute = fs.readFileSync(
path.join(projectRoot, "app", "api", "ops", "observation-collector-status", "route.ts"),
"utf8",
);
assert(
opsApi.includes("sourceHealth") &&
@@ -40,4 +44,23 @@ export function runTests() {
nextRoute.includes("no-store"),
"source health proxy must stay ops-admin protected and uncached",
);
assert(
opsApi.includes("observationCollectorStatus") &&
opsApi.includes("/api/ops/observation-collector-status"),
"ops client must expose observation collector status endpoint",
);
assert(
systemPage.includes("观测采集器") &&
systemPage.includes("collectorStatus") &&
systemPage.includes("failure_count") &&
systemPage.includes("last_latency_ms") &&
systemPage.includes("冷却"),
"ops system page must show observation collector failures, latency, and cooldown status",
);
assert(
collectorRoute.includes("requireOpsProxyAuth") &&
collectorRoute.includes("/api/ops/observation-collector-status") &&
collectorRoute.includes("no-store"),
"observation collector proxy must stay ops-admin protected and uncached",
);
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
function readFrontend(...parts: string[]) {
return fs.readFileSync(path.join(process.cwd(), ...parts), "utf8");
}
export function runTests() {
const trainingPage = readFrontend("components", "ops", "training", "TrainingPageClient.tsx");
const charts = readFrontend("components", "ops", "training", "TrainingAccuracyCharts.tsx");
assert.match(
trainingPage,
/buildDebRecentRankingRows/,
"ops training page should share the DEB usable-recent ranking helper with the terminal dashboard",
);
assert.match(
trainingPage,
/debRecentRanked/,
"ops training page should build chart rows from the usable-recent DEB ranking",
);
assert.match(
trainingPage,
/debRecentRankIndex/,
"ops training detail table should follow the same usable-recent DEB order before falling back to historical scores",
);
assert.doesNotMatch(
trainingPage,
/sort\(\s*\(a,\s*b\)\s*=>\s*\(\(b\.deb\?\.hit_rate/,
"ops training DEB chart must not regress to sorting by long-term historical hit rate",
);
assert.match(
charts,
/DEB 可用近期命中率 by 城市/,
"ops DEB hit-rate chart should label the metric as usable recent accuracy",
);
assert.match(
charts,
/DEB 可用近期 MAE by 城市/,
"ops DEB MAE chart should label the metric as usable recent MAE",
);
assert.match(
charts,
/可用近期命中率/,
"ops DEB hit-rate tooltip should describe the usable recent metric",
);
}
@@ -0,0 +1,27 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const usersPageSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "users", "UsersPageClient.tsx"),
"utf8",
);
assert(
usersPageSource.includes("leaderboardName") &&
usersPageSource.includes("String(entry.username || \"\").trim()") &&
usersPageSource.includes("TG${entry.telegram_id}"),
"ops users leaderboard must fall back when username is blank",
);
assert(
usersPageSource.includes("本周暂无积分记录") &&
usersPageSource.includes("本周积分") &&
!usersPageSource.includes("entry.username ?? `TG${entry.telegram_id}`"),
"ops users leaderboard must show weekly points clearly and avoid zero-point fake rankings",
);
}
@@ -0,0 +1,391 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Bug, CheckCircle2, Coins, MessageSquare, RefreshCcw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
const STATUS_OPTIONS = [
{ key: "", label: "全部" },
{ key: "open", label: "新建" },
{ key: "triaged", label: "已确认" },
{ key: "investigating", label: "处理中" },
{ key: "resolved", label: "已解决" },
{ key: "closed", label: "关闭" },
] as const;
const STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter((item) => item.key);
const REWARD_POINT_OPTIONS = [
{ value: 100, label: "100 分", title: "轻量提醒" },
{ value: 300, label: "300 分", title: "可复现 Bug" },
{ value: 500, label: "500 分", title: "有效数据问题" },
{ value: 1000, label: "1000 分", title: "高影响问题" },
{ value: 1500, label: "1500 分", title: "重大事故" },
] as const;
const REWARD_GUIDELINES = [
{ points: "0", title: "无效/重复", detail: "重复反馈、无法复现、非问题" },
{ points: "100", title: "轻量提醒", detail: "文案、体验、小范围提示" },
{ points: "300", title: "可复现 Bug", detail: "加载失败、操作异常、局部影响" },
{ points: "500", title: "有效数据问题", detail: "城市数据、图表、关键变量异常" },
{ points: "1000", title: "高影响问题", detail: "支付、账号、订阅、核心终端异常" },
{ points: "1500", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" },
] as const;
function compactDate(value?: string) {
if (!value) return "—";
return value.slice(0, 19).replace("T", " ");
}
function categoryLabel(value?: string) {
const key = String(value || "").toLowerCase();
if (key === "bug") return "Bug";
if (key === "data") return "数据";
if (key === "idea") return "建议";
if (key === "payment") return "支付";
if (key === "account") return "账号";
return "其他";
}
function statusLabel(value?: string) {
const key = String(value || "open").toLowerCase();
if (key === "open") return "新建";
if (key === "triaged") return "已确认";
if (key === "investigating") return "处理中";
if (key === "resolved") return "已解决";
if (key === "closed") return "关闭";
return key;
}
function statusTone(value?: string) {
const key = String(value || "open").toLowerCase();
if (key === "open") return "border-red-200 bg-red-50 text-red-700";
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
return "border-slate-200 bg-slate-50 text-slate-600";
}
function contextSummary(context?: Record<string, unknown>) {
if (!context) return "—";
const city = String(context.city || context.display_city || "").trim();
const slot = context.slot_index != null ? `slot ${context.slot_index}` : "";
const source = String(context.source || "").trim();
const pieces = [city, slot, source].filter(Boolean);
return pieces.length ? pieces.join(" · ") : "terminal";
}
export function FeedbackPageClient() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filter, setFilter] = useState("");
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
const [updatingId, setUpdatingId] = useState<number | null>(null);
const [rewardingId, setRewardingId] = useState<number | null>(null);
const [rewardPointsById, setRewardPointsById] = useState<Record<number, string>>({});
const load = async () => {
setLoading(true);
setError("");
try {
const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload;
setPayload(data);
} catch (err) {
setError(String(err).slice(0, 220));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [filter]);
const rows = payload?.feedback || [];
const counts = payload?.status_counts || {};
const openCount = Number(counts.open || 0);
const activeCount = Number(counts.open || 0) + Number(counts.triaged || 0) + Number(counts.investigating || 0);
const categoryCounts = useMemo(() => {
const acc: Record<string, number> = {};
rows.forEach((row) => {
const key = String(row.category || "other");
acc[key] = (acc[key] || 0) + 1;
});
return acc;
}, [rows]);
const changeStatus = async (row: UserFeedbackEntry, next: string) => {
const current = String(row.status || "open").toLowerCase();
if (!next || next === current) return;
setUpdatingId(row.id);
try {
await opsApi.updateFeedbackStatus(row.id, next);
await load();
} finally {
setUpdatingId(null);
}
};
const updateRewardPoints = (rowId: number, points: string) => {
setRewardPointsById((prev) => ({
...prev,
[rowId]: points,
}));
};
const handleRewardGrant = async (row: UserFeedbackEntry) => {
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
const points = Number.parseInt(selectedPoints, 10);
if (!row.user_email) {
setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
return;
}
if (!Number.isFinite(points) || points <= 0) {
setError("请输入有效的奖励积分。");
return;
}
setRewardingId(row.id);
setError("");
try {
await opsApi.grantFeedbackReward(row.id, points);
setRewardPointsById((prev) => {
const next = { ...prev };
delete next[row.id];
return next;
});
await load();
} catch (err) {
setError(String(err).slice(0, 220));
} finally {
setRewardingId(null);
}
};
if (loading && !payload) {
return <div className="text-slate-400 animate-pulse">...</div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Bug className="h-5 w-5 text-red-500" />
<div>
<div className="text-xs text-slate-500"></div>
<div className="text-2xl font-black text-slate-950">{openCount}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<MessageSquare className="h-5 w-5 text-blue-500" />
<div>
<div className="text-xs text-slate-500"></div>
<div className="text-2xl font-black text-slate-950">{activeCount}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
<div>
<div className="text-xs text-slate-500"></div>
<div className="text-2xl font-black text-slate-950">{Number(counts.resolved || 0)}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-2xl font-black text-slate-950">{rows.length}</div>
<div className="mt-1 text-xs text-slate-500">
Bug {categoryCounts.bug || 0} · {categoryCounts.data || 0} · {categoryCounts.idea || 0}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
{REWARD_GUIDELINES.map((item) => (
<div
key={item.points}
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
>
<div className="font-mono text-sm font-black text-blue-700">
{item.points}
</div>
<div className="mt-1 text-xs font-bold text-slate-900">
{item.title}
</div>
<div className="mt-1 text-[11px] leading-4 text-slate-500">
{item.detail}
</div>
</div>
))}
</div>
<p className="mt-2 text-xs text-slate-500">
/
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="flex flex-wrap gap-1.5">
{STATUS_OPTIONS.map((item) => (
<button
key={item.key || "all"}
type="button"
onClick={() => setFilter(item.key)}
className={
"rounded border px-2.5 py-1 text-xs font-bold transition " +
(filter === item.key
? "border-blue-300 bg-blue-50 text-blue-700"
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50")
}
>
{item.label}
</button>
))}
</div>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-slate-500">
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
const rewardPoints = Number(row.reward_points || 0);
const rewardStatus = String(row.reward_status || "").toLowerCase();
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
return (
<tr key={row.id} className="border-b border-slate-100 align-top">
<td className="py-3 pr-4">
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-bold ${statusTone(row.status)}`}>
{statusLabel(row.status)}
</span>
</td>
<td className="py-3 pr-4 text-slate-500">{categoryLabel(row.category)}</td>
<td className="max-w-xl py-3 pr-4">
<div className="font-semibold leading-5 text-slate-900">{row.message || "—"}</div>
{row.contact && <div className="mt-1 text-xs text-slate-500">{row.contact}</div>}
</td>
<td className="py-3 pr-4">
<div className="font-mono text-xs text-blue-700">{contextSummary(row.context)}</div>
{Boolean(row.context?.detail_error) && (
<div className="mt-1 max-w-xs text-xs text-amber-700">
{String(row.context?.detail_error || "").slice(0, 120)}
</div>
)}
</td>
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
{row.user_email || row.user_id || "—"}
</td>
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
<td className="min-w-[170px] py-3 pr-4">
{hasReward ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
<div className="font-black text-emerald-700">
+{rewardPoints.toLocaleString()}
</div>
</div>
) : (
<div className="space-y-1.5">
<select
value={selectedPoints}
onChange={(event) => updateRewardPoints(row.id, event.target.value)}
disabled={rewardingId === row.id || !row.user_email}
className="h-8 w-full rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
aria-label="奖励积分"
>
{REWARD_POINT_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label} · {item.title}
</option>
))}
</select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleRewardGrant(row)}
disabled={rewardingId === row.id || !row.user_email}
className="h-8 gap-1.5"
>
<Coins className="h-3.5 w-3.5" />
</Button>
{!row.user_email && (
<div className="text-[11px] text-amber-700"></div>
)}
</div>
)}
</td>
<td className="py-3 pr-4">
<select
value={String(row.status || "open").toLowerCase()}
onChange={(event) => changeStatus(row, event.target.value)}
disabled={updatingId === row.id}
className="h-8 min-w-[108px] rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-600 outline-none transition hover:bg-slate-50 focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-wait disabled:opacity-60"
aria-label="更新反馈状态"
>
{STATUS_UPDATE_OPTIONS.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -15,6 +15,7 @@ import {
ScrollText,
Activity,
ShieldAlert,
MessageSquare,
} from "lucide-react";
const navGroups = [
@@ -33,6 +34,7 @@ const navGroups = [
items: [
{ href: "/ops/payments", icon: CreditCard, label: "支付管理" },
{ href: "/ops/memberships", icon: UserCheck, label: "会员订阅" },
{ href: "/ops/feedback", icon: MessageSquare, label: "用户反馈" },
{ href: "/ops/telegram-audit", icon: ShieldAlert, label: "电报清理" },
{ href: "/ops/users", icon: Users, label: "用户积分" },
],
@@ -58,6 +58,8 @@ function paymentReasonLabel(reason?: string) {
if (key === "tx_not_found") return "链上交易未找到";
if (key === "tx_reverted") return "链上交易失败";
if (key === "expired") return "订单已过期";
if (key === "event_mismatch") return "支付事件不匹配";
if (key === "direct_transfer_mismatch") return "直接转账不匹配";
if (key === "unknown") return "未知原因";
return key || "未知原因";
}
@@ -134,7 +136,7 @@ export function PaymentsPageClient() {
const reasonCounts: Record<string, number> = {};
incidents.forEach((inc) => {
const r = inc.reason || "unknown";
reasonCounts[r] = (reasonCounts[r] || 0) + 1;
reasonCounts[r] = (reasonCounts[r] || 0) + Math.max(1, Number(inc.occurrence_count ?? 1));
});
const incidentPieData = Object.entries(reasonCounts).map(([name, value]) => ({
@@ -318,6 +320,11 @@ export function PaymentsPageClient() {
<td className="py-2 pr-4 text-slate-500 font-mono">{inc.id}</td>
<td className="py-2 pr-4">
<div className="font-bold text-amber-600">{paymentReasonLabel(inc.reason)}</div>
{Number(inc.occurrence_count ?? 1) > 1 ? (
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
{Number(inc.occurrence_count).toLocaleString()} · {compactDate(inc.first_seen_at)}
</div>
) : null}
<div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}>
{inc.detail || inc.reason || "—"}
</div>
@@ -1,12 +1,17 @@
"use client";
import { useEffect, useState } from "react";
import { AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
import { Activity, AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { SourceHealthPayload, SystemStatusPayload, HealthPayload } from "@/types/ops";
import type {
ObservationCollectorStatusPayload,
SourceHealthPayload,
SystemStatusPayload,
HealthPayload,
} from "@/types/ops";
function sourceStatusTone(status?: string) {
if (status === "fresh") return "text-emerald-500";
@@ -25,12 +30,48 @@ function sourceStatusLabel(status?: string) {
return "未知";
}
function collectorStatusTone(status?: string) {
if (status === "ok") return "text-emerald-500";
if (status === "due") return "text-blue-500";
if (status === "cooldown") return "text-amber-500";
if (status === "failed" || status === "never_run") return "text-red-500";
return "text-slate-500";
}
function collectorStatusLabel(status?: string) {
if (status === "ok") return "正常";
if (status === "due") return "到期";
if (status === "cooldown") return "冷却";
if (status === "failed") return "失败";
if (status === "never_run") return "未采集";
return "未知";
}
function formatAge(ageMin?: number | null) {
if (ageMin == null) return "—";
if (ageMin < 60) return `${Math.round(ageMin)}m`;
return `${(ageMin / 60).toFixed(1)}h`;
}
function formatSeconds(seconds?: number | null) {
if (seconds == null) return "—";
if (seconds <= 0) return "已到期";
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
return `${(seconds / 3600).toFixed(1)}h`;
}
function formatLatency(ms?: number | null) {
if (ms == null) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatTimestamp(value?: string | null) {
if (!value) return "—";
return value.replace("T", " ").replace("Z", "").slice(0, 19);
}
function sourceReasonLabel(reason?: string | null) {
const key = String(reason || "").trim().toLowerCase();
if (key === "observation_time_missing") return "观测时间缺失";
@@ -65,20 +106,23 @@ export function SystemPageClient() {
const [health, setHealth] = useState<HealthPayload | null>(null);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
const [collectorStatus, setCollectorStatus] = useState<ObservationCollectorStatusPayload | null>(null);
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const [h, s, sh] = await Promise.all([
const [h, s, sh, cs] = await Promise.all([
opsApi.health(),
opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
opsApi.observationCollectorStatus(200) as Promise<ObservationCollectorStatusPayload>,
]);
setHealth(h);
setStatus(s);
setSourceHealth(sh);
setCollectorStatus(cs);
} catch (e) {
setError(String(e).slice(0, 200));
} finally {
@@ -100,6 +144,12 @@ export function SystemPageClient() {
const dbOk = status?.db?.ok ?? health?.db?.ok;
const cacheAnalysis = status?.cache?.analysis;
const collectorIssues = (collectorStatus?.entries || [])
.filter((entry) => {
const state = String(entry.status || "");
return ["failed", "cooldown", "never_run", "due"].includes(state) || (entry.failure_count ?? 0) > 0;
})
.slice(0, 12);
const sourceIssues = (sourceHealth?.cities || [])
.flatMap((city) =>
(city.sources || [])
@@ -240,6 +290,96 @@ export function SystemPageClient() {
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4 text-cyan-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
{["ok", "due", "cooldown", "failed", "never_run"].map((key) => (
<div key={key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<div className="text-[11px] text-slate-500">{collectorStatusLabel(key)}</div>
<div className={`text-lg font-black ${collectorStatusTone(key)}`}>
{collectorStatus?.status_counts?.[key] ?? 0}
</div>
</div>
))}
</div>
{(collectorStatus?.sources || []).length ? (
<div className="mb-4 grid grid-cols-1 gap-3 text-xs md:grid-cols-2 xl:grid-cols-3">
{(collectorStatus?.sources || []).map((source) => (
<div key={source.source} className="rounded-lg border border-slate-200 bg-white px-3 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-mono text-sm font-black text-slate-800">{source.source}</span>
<span className={`font-bold ${collectorStatusTone(source.worst_status)}`}>
{collectorStatusLabel(source.worst_status)}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-slate-600">
<span> {source.city_count ?? 0}</span>
<span> {source.min_interval_sec ?? source.interval_sec ?? "—"}s</span>
<span> {source.failure_count ?? 0}</span>
<span> {source.cooldown_count ?? 0}</span>
<span> {formatLatency(source.avg_latency_ms)}</span>
<span title={source.last_success_at || ""}> {formatTimestamp(source.last_success_at)}</span>
</div>
</div>
))}
</div>
) : (
<div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500">
collector source/city
</div>
)}
{collectorIssues.length ? (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[860px] text-left text-xs">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2">Source</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{collectorIssues.map((entry) => (
<tr key={`${entry.source}-${entry.city}`} className="border-t border-slate-100">
<td className="px-3 py-2 font-mono font-bold text-slate-800">{entry.source}</td>
<td className="px-3 py-2 font-mono text-slate-700">{entry.city}</td>
<td className={`px-3 py-2 font-bold ${collectorStatusTone(entry.status)}`}>
{collectorStatusLabel(entry.status)}
</td>
<td className="px-3 py-2 font-mono text-slate-600">{formatTimestamp(entry.last_success_at)}</td>
<td className="px-3 py-2 font-mono text-slate-600">{entry.failure_count ?? 0}</td>
<td className="px-3 py-2 font-mono text-slate-600">{formatLatency(entry.last_latency_ms)}</td>
<td className="px-3 py-2 font-mono text-slate-600">{formatSeconds(entry.due_in_sec)}</td>
<td className="max-w-[260px] truncate px-3 py-2 text-slate-500" title={entry.last_error || ""}>
{entry.last_error || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
<ShieldCheck className="h-4 w-4" />
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">

Some files were not shown because too many files have changed in this diff Show More