Compare commits

..
Author SHA1 Message Date
2569718930@qq.com 32019c89f5 更新项目文档至 v1.6.0:同步前端设计系统重构成果
- CHANGELOG.md:新增 v1.6.0 条目,完整记录 15 项前端设计审查修复
- CLAUDE.md:新增 CSS Variables First / 避免 !important 代码规范、scan-root-styles.ts 桶文件说明、Quality Gate 增加硬编码颜色检查
- README.md / README_ZH.md:产品状态补充前端设计系统重构摘要
- docs/TECH_DEBT_ZH.md:标记前端工程债务已关闭、更新债务比例 95%→97%、版本号 v1.5.4→v1.6.0
2026-05-10 14:35:36 +08:00
2569718930@qq.com 41acb2aa51 移除 AGENTS.md:oh-my-codex 自动生成的非项目文档 2026-05-10 14:31:29 +08:00
2569718930@qq.com ca0b25d84f 清理冗余文档:移除过期报告、重复文件和 AI 生成配置
- 移除 FRONTEND_REDESIGN_REPORT.md(v1.5.1 旧报告,已被 frontend-ui-design-review.md 取代)
- 移除 docs/TECH_DEBT.md(与 TECH_DEBT_ZH.md 完全重复)
- docs/ 目录集中管理所有项目文档,无需移动

Cleaned: outdated-report, duplicate-doc
2026-05-10 14:31:10 +08:00
2569718930@qq.com 00e1845f2c 全面修复前端 UI 设计审查问题:消除工程债务、统一 token 体系、提升可维护性
- 消除 !important 滥用:134 → 49(仅保留 Leaflet/图表所必需项),浅色主题使用 html.light 选择器获得更高优先级
- 修复 font-weight:13 个文件中所有 760/850/860/880/950 等非标准值已映射为 Inter 支持的 300–800
- 移除未加载的 Geist 字体声明,替换为 Inter
- 添加全局 :focus-visible 轮廓环、跳过链接、Tab ARIA 属性(role/aria-selected)
- 统一断点体系:18 → 10(480/640/768/960/1024/1200/1280/1360/1440/1680)
- 创建 scan-root-styles.ts 桶文件,将 22 个 CSS Module 导入合并为 1 个
- Token 迁移:10 个文件中数百处硬编码颜色(#4DA3FF/#E6EDF3/#9FB2C7/#6B7A90)已替换为 CSS 变量
- 去重 @keyframes:spin 4→1、loading-spin 2→0、pulse-pending 已移至 globals.css
- 添加统一的 empty/error/retry 状态组件
- 添加全局 prefers-reduced-motion 支持
- 修复 accent-primary 与 accent-secondary 相同值的问题
- 修复 accent-green 类错误渲染为蓝色
- 添加 CSS 渐变品牌 Logo
- 移除死代码(1,697 行):public/static/style.css + public/legacy/index.html
- Dashboard.module.css 本地变量已桥接至全局 token
- 提升文字对比度:#6B7A90 → #7D8FA3

Fixed: !important-134-to-49, font-weight-13-files, Geist-removal, focus-visible, breakpoints-18-to-10, CSS-module-barrel, token-migration-10-files, keyframe-dedup, dead-code-removal, accent-color-fix, contrast-improvement
Scope: frontend CSS architecture, design tokens, accessibility, responsive breakpoints
Tested: npx tsc --noEmit
2026-05-10 14:21:10 +08:00
2569718930@qq.com f47de115c8 Reduce duplicate scan terminal decision copy and chrome
Removed the scan KPI bar, trimmed redundant decision-card content, and shortened the primary reason text so the city analysis cards read like concise signals instead of repeating the same state across sections.

Constraint: Keep existing city decision logic and data flow unchanged while simplifying the UI
Rejected: Rework the full card layout or mobile card flow | larger surface area than requested
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep hero, decision band, and market line responsibilities separate to avoid duplicate copy returning
Tested: frontend npm run build
Not-tested: Manual visual QA in browser across desktop/mobile breakpoints
2026-05-10 12:20:22 +08:00
2569718930@qq.com 3e14a23e02 修复 markets 测试:移除对已删除函数 build_market_monitor_digest 的 monkeypatch
Tested: python -m pytest tests/test_bot_basic_handler.py -xvs
2026-05-07 22:27:12 +08:00
2569718930@qq.com 2183c0399d 移除错误定价信号和市场聚焦摘要功能
保留关键市场警报推送(4 条触发规则不变),
移除 mispricing 信号分类/推送、focus digest 评分/聚合/定时推送、
/markets 手动查询等全套逻辑,净删除 1049 行。
2026-05-07 21:42:06 +08:00
2569718930@qq.com 30faac989c 秒开 AI 预测最高温:preview 事件提前下发确定性预测值
之前 predicted_max 只在 AI 流式 final 事件返回后才显示(10-25 秒),
但后端 fallback 早在 1ms 内就算好了 cluster median / DEB 中枢。
现在 preview 事件同步下发确定性 predicted_max + range_low/high,
前端也在 fallback payload 中自动从 multi_model + DEB 计算预测值,
用户点击城市后 ~100ms 即可看到 AI 预测最高温。
2026-05-07 21:04:26 +08:00
2569718930@qq.com 0f9469f3cb README 新增 Star History 历史曲线 2026-05-07 20:19:40 +08:00
2569718930@qq.com 734462a3b9 chore: CLAUDE.md 新增环境配置章节
明确工作目录、前端/后端端口、中文 commit 强制规则、npm/python 工具链偏好。
配套代码风格与质量门禁章节形成完整的开发行为约束。

Directive: 后续所有会话必须遵守此环境配置
Tested: ruff + tsc 全过
2026-05-07 00:44:35 +08:00
2569718930@qq.com a7421b3147 chore: CLAUDE.md 新增质量门禁与编码规范
四道强制自检:tsc/ruff、禁止 Unicode 转义、双主题 CSS 同步、diff 展示。
补上中文 commit 规范、禁止 \uXXXX 转义、UI 双主题同步三条编码规则。

Directive: 每次标记任务完成前必须通过全部质量门禁
2026-05-07 00:42:44 +08:00
2569718930@qq.com 30a6ff7986 新增 v1.5.6 升级公告,3 天后自动消失
公告展示决策终端 hero 重设计、DEB 统一、亮色补全等变更摘要。
localStorage 记录首次访问时间,超过 3 天自动隐藏。
补全 scan-upgrade-announcement 暗色主题基础样式。

Tested: tsc 全过
2026-05-06 18:59:20 +08:00
2569718930@qq.com 52fa7b0bb3 移除城市快捷跳转栏 sticky 固定效果,更新 LGBM 模型至 1247 样本
scan-ai-city-jumpbar 不再吸附顶部,anchor scroll-margin 同步清理。
LGBM 模型用 prod DB 重训练:1247 样本 → 验证 MAE 2.63°C vs DEB 2.75°C。

Tested: tsc + ruff 全过
2026-05-06 18:50:10 +08:00
2569718930@qq.com 7b82ba68df 回退 _analyze_summary 中未启用的 LGBM 增强步骤
LGBM 当前默认禁用 (POLYWEATHER_LGBM_ENABLED=false),predict_lgbm_daily_high
直接返回 None,在 _analyze 中也未实际参与 DEB 计算,_analyze_summary 无需重复。

Rejected: 在 LGBM 正式启用前,_analyze_summary 不应包含会被跳过的 LGBM 调用
2026-05-06 18:30:47 +08:00
2569718930@qq.com a684586cb4 统一 DEB 数据源为单一计算路径
getModelView 优先使用根级 detail.deb.prediction 保证与 AI 证据面板一致,
_build_city_detail_payload 补上缺失的 deb 和 multi_model_daily 字段,
_analyze_summary 补上 LGBM 增强步骤使其与 _analyze 产出相同的 DEB 值。

Constraint: 三个入口 (panel/full/summary) 的 DEB 必须经过同一套 calculate_dynamic_weights + LGBM 重算流程
Tested: ruff + tsc 全过
2026-05-06 18:24:15 +08:00
2569718930@qq.com 2f73828d4d 重构城市决策卡 hero 布局:去除 sticky 固定效果,右侧改为三指标并列对比
将 scan-ai-city-hero 从"左重右轻"改为左右均衡布局,右侧从单一预计高温数字
替换为当前温度/预计最高温/峰值时间三列并排指标卡,中间列高亮为主指标。
移除冗余的 pills 行和隐藏的 mobile-priority div,新鲜度条改为水平单行。
同步更新亮色主题 CSS 新增 metrics 样式。修复 fallback 模块未使用变量。

Constraint: dark/light 双主题完整覆盖
Scope-risk: hero 高度缩减约 30%,需确认移动端 MobileDecisionCard 不受影响
2026-05-06 17:57:40 +08:00
2569718930@qq.com 227fe7906c 补全证据面板和决策波段在亮色模式下的文字颜色覆盖
- .scan-ai-city-section-title: #4da3ff → #1d4ed8(与背景对比度从 2.93 提升到 5.8)
- .scan-ai-decision-band span/strong/p: 显式覆盖暗色文字
- .scan-ai-city-body 滚动条: 适配浅色主题
2026-05-06 17:01:28 +08:00
2569718930@qq.com a3052767d7 补全 AI 预测双卡片组件的亮色模式 CSS
此前新增的预测卡片(scan-ai-prediction-card/confidence 等)
只有暗色模式样式,在亮色模式下白底上显示深色背景块,严重
影响可读性。现补全 30 条亮色覆盖规则。
2026-05-06 16:54:48 +08:00
2569718930@qq.com 4ed9321756 修复流式 AI 请求 max_tokens 不足导致 JSON 截断
此前流式 650 tokens,只够 4 个文字字段。新增 predicted_max
等 6 个字段后,中文解读经常超出上限致 JSON 不完整,触发重试。
现提升至 900 tokens(主请求 1200),并更新测试断言。
2026-05-06 16:51:31 +08:00
2569718930@qq.com a1d5435dd7 强化 HKO 天文台观测的 AI 解读能力
- HKO instruction 从纯否定句改为正向指导,明确告知 AI 可用数据维度
  (温度、最高/最低温、湿度、10分钟风速风向)
- AI 输入 current 对象新增 wind_speed_kt/wind_dir/humidity 字段,
  HKO 和 METAR 城市均受益
- 提示词统一补充湿度分析要求
2026-05-06 16:48:05 +08:00
2569718930@qq.com 59e099a56d 修复 AI 报文解读缓存未在首帧生效的问题
此前 useState 初始化为 idle,useEffect 异步读缓存后才切到 ready,
导致重新打开城市卡片时先闪烁"等待 AI 解读..."再跳转到缓存数据。
现在用 lazy initializer 在首帧同步读取缓存,命中则直接渲染 ready
状态,零闪烁。
2026-05-06 16:34:19 +08:00
2569718930@qq.com 1a40802dcb 移除 AI 机场报文解读面板的折叠收起按钮
将 <details>/<summary> 改为普通 <section>/<div>,面板始终展开。
移除 isCompactCard prop 及相关的 open/onToggle 状态管理。
2026-05-06 16:27:06 +08:00
2569718930@qq.com a4ce450dbc 修复 system_prompt 源代码可读性,\uXXXX 转回正常中文
此前为避免 curly quote 问题用 \uXXXX 转义序列写入中文,运行正确但
源码不可读。现在改为正常 UTF-8 中文,引号改用「」避免与 Python
字符串定界符冲突。
2026-05-06 16:15:28 +08:00
2569718930@qq.com 13025f5c77 证据面板加载态立即显示 DEB + AI 预测中占位卡片
此前 AI 预测卡片需等流式 SSE 返回后才显示,loading 期间整个双卡片
区域消失。现在:DEB 卡片立即显示,AI 侧显示"预测中…"呼吸动画占位,
流式返回后自动替换为真实预测值。
2026-05-06 16:08:09 +08:00
2569718930@qq.com eb42d644a3 DEB 降级为模型集群中的普通一员,AI 不再照搬 DEB 做预测
此前 DEB 以独立字段 deb.prediction 传给 AI,提示词又要求"必须综合 DEB",
导致 AI 直接输出 DEB 值而非独立判断。改动:
- 移除 AI 输入中的独立 deb 字段,DEB 只作为 model_cluster.sources 中的
  一条记录 (model: "DEB (fusion)"),与其他模型平等
- 提示词改为:以模型集群集中区间为基线,用报文观测信号独立判断上修/下修/维持
- 流式/非流式提示词均强调"不要直接照搬 DEB 的值,差异是正常的"
- 回退路径改用模型集群中位数作为默认预测,DEB 仅作为备选参考
2026-05-06 16:03:22 +08:00
2569718930@qq.com f9d0ea31bc 证据面板新增 AI 预测最高温 vs DEB 双锚对比卡片
在 AI 机场报文解读面板顶部加入双卡片布局:左侧展示 AI 预测最高温
(含置信区间和置信度),右侧展示 DEB 融合值,形成可对比的"双锚"
视图。同时更新加载态文案为"AI 正在基于最新报文预测今日最高温"。
2026-05-06 15:48:27 +08:00
2569718930@qq.com c296bbb789 AI 机场报文解读流式请求增加最高温预测输出
此前 build_city_ai_stream_request 只要求 AI 输出 metar_read + reasoning,
明确禁止生成 predicted_max/range/confidence 等数值字段,流式解读只有
文字没有温度预测。现在让流式请求同步输出 predicted_max、range_low、
range_high、unit、confidence、final_judgment,使前端报文解读面板能
直接展示 AI 预测的今日最高温及置信区间。
2026-05-06 15:38:58 +08:00
2569718930@qq.com 4186a2662b 移除过时的 v1.5.5 升级公告
公告内容已过时,且功能说明已融入产品本身,无需常驻展示。
2026-05-06 15:31:36 +08:00
2569718930@qq.com cdfde1624c 移除今日机会榜,简化决策台为三视图(地图/决策卡/日历)
机会榜作为决策卡的聚合摘要层,与决策卡视图功能重叠,增加了
不必要的代码和认知负担。移除后将地图作为默认入口,用户从
地图选城市后直接进入决策卡验证天气证据。
2026-05-06 15:26:37 +08:00
2569718930@qq.com 9c61a8e012 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-05-06 14:50:52 +08:00
2569718930@qq.com f8c5cfab28 降低市场监控Telegram推送频率,减少重复通知
收紧推送周期、冷却时间和触发门槛的默认值,避免52城
同时监控导致的通知轰炸。删除不再使用的doc_refresher技能。

Constraint: 用户反馈电报群通知太频繁且同城市重复推送
Rejected: 在.env中配置 | 用户要求写死在代码默认值中
Confidence: high
Scope-risk: narrow
Directive: 如需临时放宽,仍可通过同名环境变量覆盖
Tested: Not-tested: 需实际运行bot验证推送频率
2026-05-06 14:50:35 +08:00
2569718930@qq.com 1167d6e39e Keep lint checks passing after backtest cleanup
The backtest script carried stale imports that made ruff fail even though runtime behavior did not depend on them. Removing the unused imports keeps CI lint checks green without changing script logic.

Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: python -m ruff check .
Not-tested: full application test suite
2026-05-02 12:32:12 +08:00
2569718930@qq.com 118d4e44df This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-05-01 11:37:09 +08:00
2569718930@qq.com 6c203bee60 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-05-01 11:27:16 +08:00
2569718930@qq.com 113c0131b5 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-05-01 11:17:20 +08:00
2569718930@qq.com 9d0570a36b This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-05-01 11:11:17 +08:00
2569718930@qq.com 6603515433 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-30 09:59:40 +08:00
2569718930@qq.com 96dfb4c5b3 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-30 09:50:17 +08:00
2569718930@qq.com a984c22b62 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-30 08:05:10 +08:00
2569718930@qq.com f0436f498a This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-29 21:10:56 +08:00
2569718930@qq.com 94995c5006 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-29 20:07:31 +08:00
2569718930@qq.com 6da72b5488 This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-29 19:10:25 +08:00
2569718930@qq.com 5b32e71f5d This version of Antigravity is no longer supported. Please upgrade to receive the latest features. 2026-04-29 18:52:55 +08:00
2569718930@qq.com 1ccc275a23 Make mobile account surfaces readable
Mobile review found that account payment rows could collapse labels vertically and several secondary links had small touch targets on narrow phones. The patch keeps the existing visual system but allows account rows to stack on mobile, improves tap height, and tightens the Scan Terminal narrow-screen container.

Constraint: Must keep the current desktop layout and avoid adding dependencies

Rejected: Rebuild the account page layout wholesale | too broad for a targeted mobile audit

Confidence: high

Scope-risk: narrow

Directive: Keep long account/payment identifiers breakable on narrow screens

Tested: iPhone SE and iPhone 12 Playwright mobile audits show no horizontal overflow on checked pages

Tested: npx tsc --noEmit --pretty false --project frontend/tsconfig.json

Tested: npm run build

Tested: npm run test:business

Not-tested: Authenticated /ops data state because local Supabase/admin gating redirects to the public shell
2026-04-29 12:40:37 +08:00
2569718930@qq.com 569f3eef97 Make weather-market UI reliable before shipping
Proxy routes now share one upstream-error adapter so client-actionable statuses such as auth, entitlement, validation, and rate limits survive the BFF instead of becoming opaque 502s. The scan terminal mobile overrides also load last and remove desktop rail constraints so phones get a single readable column.

Constraint: Mobile users reported the dashboard was unreadable, and BFF proxy errors were masking expected client states.

Rejected: Let every route keep bespoke error JSON | continued inconsistent status codes and production detail leakage.

Confidence: high

Scope-risk: moderate

Directive: Keep ScanTerminalMobile.module.css imported after desktop scan-terminal CSS so mobile breakpoints win.

Tested: npx tsc --noEmit --pretty false --project frontend/tsconfig.json

Tested: npm run build

Tested: npm run test:business

Tested: Chrome mobile smoke test at 390px with no horizontal overflow

Not-tested: Real device Safari/Android manual QA
2026-04-29 11:22:49 +08:00
2569718930@qq.com 4630cee06f feat: implement FutureForecastForwardView component and associated modal styles 2026-04-28 21:16:54 +08:00
2569718930@qq.com 2b1d7c0b65 Improve dashboard maintainability before the next release
The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries.

Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement.

Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files.

Tested: npm run build; npm run test:business; git diff --check

Not-tested: Browser visual smoke test after push
2026-04-28 20:19:17 +08:00
2569718930@qq.com 20516000a2 Clarify scan terminal service boundaries
The scan terminal service had accumulated cache, payload, filtering, AI prompt, AI merge, METAR gate, ranking, and city-row construction details in one file. This splits those stable responsibilities into focused modules while preserving the endpoint payload shape and existing behavior.

Constraint: User-visible behavior and release version must remain unchanged for this internal refactor

Rejected: Rewrite the terminal scan flow around a new abstraction | too risky while production behavior is being stabilized

Confidence: high

Scope-risk: moderate

Directive: Keep scan_terminal_service.py as orchestration; add detailed rule changes to the focused modules instead of re-growing the service file

Tested: py_compile for extracted modules; ruff check .; pytest tests/test_scan_terminal_modules.py tests/test_web_observability.py; full pytest; npm run test:business; npm run build; git diff --cached --check
2026-04-28 17:13:45 +08:00
2569718930@qq.com b122e7cbae Stabilize the decision workspace data boundaries
The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests.

Constraint: Preserve existing global scan-terminal class names and API responses during the refactor

Constraint: No new dependencies; keep this as a file-boundary cleanup

Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path

Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording

Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules

Not-tested: Live DeepSeek provider network replay and browser visual QA
2026-04-28 14:45:34 +08:00
2569718930@qq.com 12d90c5051 Lock decision edge cases with business state tests
The dashboard risk is now mostly contradictory state combinations rather than styling. This extracts calendar action grouping into a pure utility and adds a lightweight TypeScript business-state runner so key decision states can be asserted without adding a test dependency.

Constraint: No new dependencies; use the existing TypeScript package for a local runner.

Rejected: Only rely on Next build/typecheck | it cannot catch product-language regressions such as fallback AI being labeled complete.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run test:business

Tested: npm run build

Not-tested: Browser-rendered mobile fold interaction snapshots.
2026-04-28 13:13:57 +08:00
2569718930@qq.com fbd98dd59c Give mobile city cards their own action layout
Phone users need the city card to answer what matters first instead of inheriting the full desktop analysis hierarchy. This adds a MobileDecisionCard that leads with city, observed temperature, expected high, peak window, one decision reason, status tags, freshness, and a separate market-price row, while keeping AI, model evidence, and the chart behind collapsible sections.

Constraint: Preserve the existing desktop city-card layout and decision state semantics.

Rejected: Continue relying only on CSS hide/show | it keeps mobile coupled to the desktop information architecture.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: TypeScript diagnostics for AiPinnedCityCard and MobileDecisionCard

Tested: npm run build

Not-tested: Device screenshot QA across iOS/Android viewport sizes.
2026-04-28 13:00:40 +08:00
2569718930@qq.com a77d613c2b Move weather summaries out of the dashboard utility bulk
Weather labels, METAR translation, risk badges, and hero meta chips were still embedded in dashboard-utils even though panel, modal, and detail views use them as focused presentation helpers. This moves them into weather-summary-utils while keeping dashboard-utils re-export compatibility.

Constraint: Preserve existing weather/METAR/risk/hero meta wording.

Rejected: Move airport narrative in the same pass | it has broader AI narrative coupling and should be separated independently.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: TypeScript diagnostics for weather-summary-utils, dashboard-utils, PanelSections, DetailPanel, and FutureForecastModal

Tested: npm run build

Not-tested: Visual snapshot of every weather icon/label combination.
2026-04-28 12:17:42 +08:00
2569718930@qq.com 5dc39b1573 Move model views out of the dashboard utility bulk
Probability and multi-model view adapters were still embedded in dashboard-utils even though table, panel, modal, and city-card views use them as small pure selectors. This moves them into model-utils and keeps dashboard-utils re-export compatibility.

Constraint: Preserve model/probability return shapes and existing fallback behavior.

Rejected: Merge model-utils with chart-utils | model selectors and chart data preparation change at different rates.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: TypeScript diagnostics for model-utils, dashboard-utils, and PanelSections

Tested: npm run build

Not-tested: Bundle analyzer size comparison.
2026-04-28 12:07:43 +08:00
2569718930@qq.com ecbba9160d Let chart views avoid the dashboard utility bulk
The intraday chart data builder lived inside dashboard-utils with observation-source and TAF helpers, so chart consumers had to depend on the large dashboard utility surface. This moves chart data preparation, observation-source helpers, and TAF marker labels into focused modules while keeping dashboard-utils re-export compatibility.

Constraint: Preserve existing chart data shape, observation labels, TAF labels, and legacy dashboard-utils exports.

Rejected: Rewrite chart data generation while moving it | this pass is a boundary move only so visual behavior remains stable.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: TypeScript diagnostics for chart-utils, dashboard-utils, observation-source-utils, and taf-utils

Tested: npm run build

Not-tested: Browser visual regression across every chart city.
2026-04-28 11:58:58 +08:00
2569718930@qq.com c476ad2123 Move pace math out of the dashboard utility bulk
Pace-adjusted high calculations were embedded in dashboard-utils alongside chart, profile, and modal helpers. This moves the pure pace model and reusable HM time helpers into focused modules while keeping dashboard-utils re-export compatibility for older callers.

Constraint: Preserve existing pace wording, thresholds, and calculation output.

Rejected: Split all remaining dashboard-utils helpers at once | model/chart/modal helpers have wider call surfaces and should move in separate reversible passes.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: TypeScript diagnostics for pace-utils, time-utils, dashboard-utils, and FutureForecastModal

Tested: npm run build

Not-tested: Bundle analyzer size comparison.
2026-04-28 11:45:34 +08:00
2569718930@qq.com be41becb30 Let temperature formatting ship without dashboard bulk
Temperature formatting was embedded in the large dashboard utility module, so small scan-terminal views had to import the heavy utility surface for simple labels. This moves the pure temperature helpers into a lightweight module while keeping dashboard-utils re-exports for compatibility.

Constraint: Preserve existing temperature text output and all dashboard-utils import compatibility.

Rejected: Split chart, pace, and model helpers in the same pass | those helpers have wider coupling and should move one boundary at a time.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: TypeScript diagnostics for temperature-utils, dashboard-utils, and OpportunityTable

Tested: npm run build

Not-tested: Bundle analyzer size comparison.
2026-04-28 11:32:03 +08:00
2569718930@qq.com 8d05812a7e Keep dense dashboard lists stable during selection changes
Opportunity and calendar cards now isolate item rendering behind memoized components. This preserves the current action grouping and copy while preventing selection or parent dashboard updates from re-rendering every dense card body.

Constraint: Do not change ranking, grouping, or product wording in this performance pass.

Rejected: Introduce virtual list dependency | the current list size can benefit from memo boundaries first without new dependencies.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: TypeScript diagnostics for CalendarView and OpportunityOverview

Tested: npm run build

Not-tested: Browser profiler capture with a large production city set.
2026-04-28 11:25:14 +08:00
2569718930@qq.com 2e5bdfd1b6 Defer heavy city card rendering until users need it
City decision cards rendered Chart.js canvases and AI evidence bodies before the user could see or expand those sections. This keeps the card shell visible while delaying chart data/canvas work until the section nears the viewport and skipping the AI evidence body while its details panel is collapsed.

Constraint: Preserve existing decision-card layout and evidence copy.

Rejected: Add list virtualization in the same pass | card-level render costs should be reduced before changing list mechanics.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: npm run build

Not-tested: Runtime scroll benchmark on a large production city set.
2026-04-28 11:10:12 +08:00
2569718930@qq.com d2699ce17a Represent market scan loading with RemoteData
Market scan state was still tracked as separate nullable payload and status strings. This moves the hook internals to RemoteData<MarketScan> so loading and error paths can preserve previous data while keeping the existing marketScan and marketStatus return values for current card consumers.

Constraint: Preserve the existing useCityMarketScan public compatibility fields.

Rejected: Update all card UI to consume marketRemote immediately | keeping the compatibility bridge avoids a broad rendering diff while the request state model lands.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: npm run build

Not-tested: Forced live market-scan failure with stale previous quote.
2026-04-28 11:00:22 +08:00
2569718930@qq.com 82a736850b Separate scan terminal storage cache helpers
The city-card data hook still contained localStorage serialization and TTL eviction helpers alongside AI fallback and UI state transitions. Moving those helpers into scan-terminal-cache creates a reusable cache boundary for the scan terminal request layer without changing cache keys, TTLs, or payload shapes.

Constraint: Preserve existing localStorage keys and expiry behavior.

Rejected: Migrate all caches to RemoteData in this commit | cache-helper extraction is a safer intermediate step before query policy changes.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: npm run build

Not-tested: Browser private-mode quota edge cases beyond existing guarded behavior.
2026-04-28 10:50:57 +08:00
2569718930@qq.com 6fe0e5dfc5 Move AI city stream concurrency into the scan client
The AI city forecast hook still owned stream queueing and in-flight request dedupe after the first request-client pass. Moving that policy into scanTerminalClient keeps network concurrency, queued progress, and requestKey reuse in the request layer while leaving the hook responsible only for cached UI state and progress rendering.

Constraint: Preserve existing two-stream concurrency limit and queued user-facing progress copy.

Rejected: Move localStorage cache at the same time | cache policy should be separated from stream transport policy to keep this refactor reviewable.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: npm run build

Not-tested: Live multi-city SSE under production latency.
2026-04-28 10:37:04 +08:00
2569718930@qq.com 9ca3ee5a8e Centralize scan terminal requests behind a lightweight client
The scan terminal hooks were each carrying their own fetch, SSE parsing, error normalization, previous-data handling, and market request behavior. This adds a small scanTerminalClient plus RemoteData helpers so terminal data, city detail, market scans, and AI city streams share one request boundary without introducing React Query.

Constraint: Do not add dependencies or change backend API contracts.

Rejected: Introduce React Query immediately | too broad for this release and would force larger UI state rewrites.

Rejected: Move localStorage caches in the same pass | safer to first isolate network and stream IO before cache policy migration.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run build

Not-tested: Live SSE cancellation against production latency.
2026-04-28 10:24:40 +08:00
2569718930@qq.com 7130f8cf2a Model city decision states before rendering cards
The city card had accumulated independent checks for AI status, market availability, stale observations, breakouts, peak timing, model consensus, and next-bulletin waits. This introduces a CityDecisionState builder so UI components consume one coherent recommendation, urgency, evidence quality, AI status, market status, badges, and primary reason.

Constraint: Keep the current card copy and badge priority behavior while moving decision-state ownership out of the component.

Rejected: Refactor calendar and opportunity lanes in the same commit | the shared model should land behind the city card first, then other views can migrate with smaller visual-risk diffs.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run build

Not-tested: Browser visual regression and dedicated unit tests for every decision-state combination.
2026-04-28 10:10:07 +08:00
2569718930@qq.com 4c9252688a Reduce dashboard CSS blast radius for scan terminal work
Dashboard.module.css mixed base shell styles with the entire scan terminal surface, making every decision-card or calendar styling pass risky. This extracts the scan terminal layer into its own CSS module and attaches that module root beside the existing dashboard root so global class selectors keep their current behavior.

Constraint: Preserve existing global scan-* class names and visual cascade.

Rejected: Rename scan classes into scoped module keys | too much DOM churn for a behavior-preserving CSS split.

Rejected: Split card/calendar/mobile rules in the same pass | safer to establish the scan-terminal layer first before finer component CSS ownership.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run build

Not-tested: Pixel-level browser comparison across dark and light themes.
2026-04-28 09:54:47 +08:00
2569718930@qq.com de107f2673 Keep city decision cards extensible as product components
AiPinnedForecastView had become a mini application that owned list state, card shell, freshness, market, AI evidence, and model evidence rendering in one place. This split keeps the workspace wrapper thin and gives the decision card explicit product-component seams for future recommendation reasons, risk levels, and mobile-specific layout work.

Constraint: Preserve existing weather, market, AI read, chart, refresh, remove, collapse, and mobile behavior.

Rejected: Rewrite the decision-state construction in the same pass | this component split should stay behavior-preserving before a state-model refactor.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run build

Not-tested: Browser visual regression on physical mobile devices.
2026-04-28 09:43:20 +08:00
2569718930@qq.com bf8de6a09b Separate city-card rendering from forecast orchestration
AiPinnedForecastView still owns the city-card data assembly, but its header, decision band, and AI evidence rendering now live in focused presentational sections. This keeps the next decision-state refactor smaller while preserving the current UI and interaction behavior.

Constraint: Keep weather, market, and AI evidence calculations unchanged in this pass.
Rejected: Move business-state construction at the same time | mixing behavior extraction with view extraction would make regressions harder to isolate.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm run build
Not-tested: Browser manual regression on mobile expand/collapse states.
2026-04-28 09:26:46 +08:00
2569718930@qq.com cf5502b6d6 Reduce dashboard orchestration coupling
ScanTerminalDashboard had accumulated terminal fetching, theme persistence, local clock updates, and AI pinned-city hydration in one component. Moving those responsibilities into focused hooks keeps the screen component as the composition layer while preserving the existing UI and data flow.

Constraint: Refactor must not change the current decision-card or scan-terminal behavior.
Rejected: Split visual card components in the same commit | too much surface area for one safe refactor pass.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm run build
Not-tested: Browser manual regression across map, calendar, and pinned-card interactions.
2026-04-28 09:06:55 +08:00
2569718930@qq.com 51969d0b4a Keep scan terminal lint clean after helper split
The AI helper extraction left a stale private helper import in the scan terminal service. Removing it keeps CI aligned with the current call graph without changing runtime behavior.

Constraint: CI runs ruff F401 as a blocking check.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: .\.codex-tmp\pydeps\bin\ruff.exe check .
Not-tested: Full backend test suite; change is import-only.
2026-04-28 08:38:00 +08:00
2569718930@qq.com 1543207ced Make dashboard decision cards feel product-ready
Users needed reassurance that unavailable quotes and long AI evidence are normal states, not broken systems. This adds a v1.5.5 upgrade announcement, softens market-unavailable copy, surfaces a one-line recommendation reason, and makes mobile cards prioritize observed temperature, expected high, peak timing, AI expansion, and a separate market line.

Constraint: Keep existing dashboard data contracts and avoid backend schema changes.
Rejected: Hide unavailable market rows entirely | users still need to know weather evidence remains usable without a quote.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm run build
Not-tested: Browser visual QA on physical mobile devices.
2026-04-28 07:48:47 +08:00
2569718930@qq.com 326bfe258e Make freshness and timing drive city-card trust
Users need to know whether a city can be acted on before reading the full explanation. City cards now expose METAR or official-observation freshness, model update timing, market quote freshness, and AI state in a dedicated trust block, while the calendar view groups rows into action-oriented timing buckets with a single reason per city.

Constraint: Keep the existing card and calendar data contracts; derive freshness and action reasons from fields already present in the frontend payload.
Rejected: Add another long explanatory paragraph | it would repeat the same trust problem instead of making the first glance clearer.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm run build
Not-tested: Live browser visual QA with stale METAR and delayed quote examples.
2026-04-28 07:33:46 +08:00
2569718930@qq.com 1f364dfa4a Clarify staged AI bulletin reads for city cards
Users saw fast-rule evidence, partial streams, DeepSeek completion, and fallback states as one blended AI status. The city evidence panel now names the current stage directly so a loading stream reads as fast judgment complete, a successful response reads as AI bulletin read complete, and incomplete responses explain that rule evidence is being used.

Constraint: Keep the existing fast evidence path visible while DeepSeek streams in.
Rejected: Label fallback as an AI failure | that incorrectly implies the card is broken even when rule evidence is valid.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: npm run build
Not-tested: Manual browser timing of partial stream transitions.
2026-04-28 07:21:59 +08:00
2569718930@qq.com 30b1f5e256 Surface city-card decision reasons before the paragraph
City decision cards already had enough evidence, but the first screen still forced users to read the longer explanation before seeing why a card mattered. The header now caps status chips at three high-priority signals and uses product-facing labels for observed breakouts, stale METARs, AI loading, missing market prices, strong model agreement, and next-report waits.

Constraint: The card should stay lightweight and avoid another explanatory section.
Rejected: Keep six mixed freshness chips in the header | too much noise for first-glance scanning.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: npm run build
Not-tested: Browser visual QA across all city states.
2026-04-28 07:17:24 +08:00
2569718930@qq.com 89a71d1bc0 Add Qingdao to the tradable city network
Qingdao needs the same airport-settlement path as the other Wunderground-backed APAC cities, so the registry, aliases, timezone, prewarm, official links, market focus, and tests now point to ZSQD / Qingdao Jiaodong International Airport.

Constraint: User supplied Wunderground Qingdao/ZSQD settlement URL.
Rejected: Add a partial registry-only entry | it would show in APIs without frontend links, prewarm coverage, or alias support.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: pytest tests/test_country_networks.py tests/test_web_observability.py::test_cities_endpoint_includes_new_wunderground_cities -q
Tested: npm run build
Not-tested: Live Wunderground fetch for ZSQD in production.
2026-04-28 07:08:33 +08:00
2569718930@qq.com e6010d8242 Make city decision cards easier to trust at a glance
The decision card now exposes deterministic guard state, data freshness, AI readiness and market sync directly in the card header so users can understand whether they are looking at fresh evidence, a fallback, or a stale/exception case before reading the full explanation.

Constraint: Keep the first useful read available while DeepSeek airport/HKO details are still streaming.\nRejected: Add another expanded evidence panel | header-level badges are faster to scan and avoid increasing card depth.\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nTested: npm run build\nNot-tested: Browser visual QA on production data.
2026-04-28 06:52:00 +08:00
2569718930@qq.com de0effc40b Make city AI helpers maintainable outside scan service
The scan terminal service had grown into a 3.5k-line file that mixed endpoint orchestration, AI provider calls, fallback copy, JSON repair, and deterministic evidence guards. This extracts the city-AI helper layer into a focused module while preserving the old private names through imports for existing tests and callers.

Constraint: Keep behavior unchanged after the previous evidence-guard fixes and avoid a broad service rewrite.

Rejected: Split every scan-terminal concern at once | too much regression risk for this maintenance pass.

Confidence: high

Scope-risk: narrow

Tested: pytest tests/test_web_observability.py -q

Tested: npm run build
2026-04-28 06:40:53 +08:00
2569718930@qq.com 0c9b07faaf Keep provider reads behind deterministic evidence guards
DeepSeek can return a polished city forecast that conflicts with already-computed stale-observation, observed-break, or peak-window evidence. The completion path now carries the deterministic fallback guard state and overwrites only the critical fields when provider text or numbers contradict those local facts.

Constraint: Airport-read latency optimization keeps provider output narrow, so backend completion remains the authority for final highs and evidence conflicts.

Rejected: Trust provider final wording when present | it can reintroduce stale METAR anchors or miss observed high breaks.

Confidence: high

Scope-risk: narrow

Tested: pytest tests/test_web_observability.py -q

Tested: npm run build
2026-04-28 06:36:33 +08:00
2569718930@qq.com 0eab2fe628 Treat stale observations as weak evidence
City-card fallback reads now stop using stale METAR or official observations as strong live anchors. A stale observation no longer forces high/low revisions, and both backend and browser AI cache keys include the observation fingerprint so updated report times, receipt times, temperatures, or stale status invalidate old AI text.

Constraint: Cached city AI reads must not survive a material observation update

Rejected: Let stale METAR trigger observed-break revisions | stale reports can be older than the active temperature path

Confidence: high

Scope-risk: moderate

Tested: pytest tests/test_web_observability.py -q

Tested: npm run build
2026-04-28 06:28:29 +08:00
2569718930@qq.com 0a3242c6ec Tighten fallback reads around peak-window evidence
Fallback city-card reads now distinguish three cases that previously collapsed into the generic fast-evidence copy: observed highs above the model path, observed highs still lagging after the peak window, and low observations before the peak window that should wait for confirmation rather than down-revise immediately. The same pass removes three unused private helpers from the scan terminal service.

Constraint: Fallback output must be useful before the full AI airport-bulletin read returns

Rejected: Treat any low latest METAR as a down-revision | early-day observations can be below the forecast before the peak window

Rejected: Keep unused helper wrappers | they were unreferenced and added noise to an already large module

Confidence: high

Scope-risk: moderate

Tested: pytest tests/test_web_observability.py -q

Tested: npm run build
2026-04-28 06:22:07 +08:00
2569718930@qq.com d1d9f80f0f Revise fallback highs after observed breaks
Fast evidence mode should not say DEB and models support the center when the latest METAR has already exceeded that center or the model upper edge. The fallback now treats the live observation as a lower bound for the daily high and explains the upward revision pressure.

Constraint: Fallback output must remain useful before the full AI bulletin read returns

Rejected: Keep the original DEB center until AI completes | it can be lower than an already-observed temperature

Confidence: high

Scope-risk: narrow

Tested: pytest tests/test_web_observability.py::test_city_ai_fallback_revises_up_when_latest_metar_breaks_above_models tests/test_web_observability.py::test_city_ai_fallback_reasoning_identifies_fast_evidence_mode tests/test_web_observability.py::test_city_ai_stream_request_only_asks_provider_for_observation_read -q

Tested: npm run build
2026-04-27 12:33:56 +08:00
2569718930@qq.com c3f092fc28 Speed up streamed airport bulletin reads
The city card only needs the provider to interpret the latest METAR or official observation. Deterministic fields such as the high-temperature center, model cluster note and fallback risks are already available server-side, so the stream request now asks DeepSeek for only the observation read and concise reasoning.

Constraint: City cards still need a complete payload for both Chinese and English UI modes

Rejected: Keep generating the full decision schema in the stream | too much model output for every card

Rejected: Retry failed streams by default | it can double latency and the fallback can use partial streamed text

Confidence: high

Scope-risk: moderate

Tested: pytest tests/test_web_observability.py::test_city_ai_stream_request_only_asks_provider_for_observation_read tests/test_web_observability.py::test_city_ai_fallback_reasoning_identifies_fast_evidence_mode tests/test_web_observability.py::test_city_ai_partial_json_trims_dangling_taf_clause tests/test_web_observability.py::test_city_ai_schema_completion_trims_dangling_taf_clause -q

Tested: npm run build
2026-04-27 09:51:09 +08:00
2569718930@qq.com ee2a5338bb Avoid overstating fallback AI bulletin reads
The city decision fallback path is generated when the full DeepSeek city-airport read has not completed, so the reasoning copy now labels the state as fast evidence mode instead of saying the AI read is normal.

Constraint: Fallback output may use only DEB, model cluster, and latest observation evidence

Rejected: Keep 'AI read normal' wording | it implies a completed AI interpretation when the fallback path is active

Confidence: high

Scope-risk: narrow

Tested: pytest tests/test_web_observability.py::test_city_ai_fallback_reasoning_identifies_fast_evidence_mode tests/test_web_observability.py::test_city_ai_partial_json_trims_dangling_taf_clause tests/test_web_observability.py::test_city_ai_schema_completion_trims_dangling_taf_clause -q
2026-04-27 09:45:11 +08:00
2569718930@qq.com 504efcbb49 Clarify weather-first decision card label
The prior label said the weather decision layer had no market price connected, which could be read as a broken market integration. The card now says weather-first read with market prices shown separately.

Constraint: Market price layer is already rendered below the weather decision band

Rejected: Keep 'no market price input' | accurate internally but misleading in user-facing Chinese copy

Confidence: high

Scope-risk: narrow

Tested: npm run build

Not-tested: Visual screenshot review
2026-04-27 09:10:57 +08:00
2569718930@qq.com 4eb50ce880 Prevent dangling TAF fragments in city AI reads
City AI can return a partially streamed JSON string when the provider truncates output. The fallback previously kept an unfinished clause such as '但TAF显示', which made the forecast explanation look broken even though earlier evidence was usable.

Constraint: Provider JSON can be truncated after useful fields have already streamed

Rejected: Drop all partial AI text | would lose valid METAR interpretation already returned before truncation

Confidence: high

Scope-risk: narrow

Tested: pytest city AI truncation regression tests

Tested: npm run build

Not-tested: Live DeepSeek provider response
2026-04-27 08:28:00 +08:00
2569718930@qq.com e4cf570820 Promote calendar snapshot to v1.5.5 release
The calendar local-time and AI airport-read fixes are a numbered product release rather than a temporary snapshot, so the changelog and package/version metadata now use 1.5.5 consistently.

Constraint: Existing snapshot tag was already pushed before the numbered release correction

Rejected: Keep snapshot label in changelog | user clarified this should be the 1.5.5 version

Confidence: high

Scope-risk: narrow

Tested: Reviewed git diff for CHANGELOG, VERSION, frontend package metadata

Not-tested: No runtime tests; metadata-only version correction
2026-04-27 07:20:49 +08:00
2569718930@qq.com e90ea2b350 Document calendar local-time snapshot release
The release snapshot is no longer unreleased, so the changelog now names the published calendar-local-time tag and records the AI wording and actionable-window behavior.

Constraint: Existing snapshot tag was already pushed before this documentation correction

Confidence: high

Scope-risk: narrow

Tested: Documentation-only change reviewed with git diff

Not-tested: No runtime tests; changelog-only update
2026-04-27 07:15:34 +08:00
207 changed files with 34192 additions and 28979 deletions
-27
View File
@@ -1,27 +0,0 @@
---
name: doc_refresher
description: 自动同步和刷新项目文档,确保 Markdown 文件与当前代码功能状态完全一致。
---
# Documentation Refresher Skill
此技能用于在项目架构发生重大调整(如功能下线、重心转移)后,自动重写和更新项目的所有 Markdown 文档。
## 核心任务
1. **代码扫描**:分析 `run.py` 和核心逻辑,识别哪些功能是“活跃的”,哪些功能是“暂停/移除的”。
2. **术语统一**:确保所有文档使用一致的语气(例如从“监控机器人”转向“天气查询机器人”)。
3. **多语言同步**:确保 `_ZH.md` 与英文版文档内容同步。
## 更新准则
- **状态准确性**:如果功能已在代码中被注释(如监控引擎、模拟交易),文档必须明确标注为“已暂停”或直接移除。
- **示例更新**:更新文档中的 Telegram 指令示例,移除已下线的指令(如 `/signal`, `/portfolio`)。
- **流程简化**:针对当前“天气查询模式”,简化安装和运行流程说明。
## 使用流程
1. 查看 `run.py` 确认当前运行模式。
2. 遍历项目根目录下所有的 `.md` 文件。
3. 对每个文件内容进行重构,保持格式美观。
4. 校验多语言版本的一致性。
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(npm --prefix \"/e/web/PolyWeather/frontend\" run build)",
"mcp__Claude_Preview__preview_start"
"mcp__Claude_Preview__preview_start",
"Bash(git:*)"
]
}
}
+15 -14
View File
@@ -101,14 +101,6 @@ TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR=8
TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN=480
TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN=300
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
@@ -136,12 +128,19 @@ POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
# Optional DeepSeek V4-Pro market scan review for Pro users
# Optional OpenAI-compatible market scan review for Pro users
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
POLYWEATHER_SCAN_AI_ENABLED=false
POLYWEATHER_DEEPSEEK_API_KEY=
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash
POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash
POLYWEATHER_SCAN_AI_API_KEY=
POLYWEATHER_SCAN_AI_PROVIDER=mimo
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
# Backward-compatible legacy DeepSeek variables are still read if the generic
# POLYWEATHER_SCAN_AI_* variables are unset.
# POLYWEATHER_DEEPSEEK_API_KEY=
# POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
@@ -202,9 +201,11 @@ POLYMARKET_MARKET_SCAN_ENABLED=true
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
POLYMARKET_CLOB_URL=https://clob.polymarket.com
POLYMARKET_CHAIN_ID=137
POLYMARKET_HTTP_TIMEOUT_SEC=8
POLYMARKET_HTTP_TIMEOUT_SEC=20
POLYMARKET_MARKET_CACHE_TTL_SEC=60
POLYMARKET_PRICE_CACHE_TTL_SEC=30
# false = fetch CLOB book/depth for orderbook analysis; true = price-only, lighter but no book levels
POLYMARKET_FAST_PRICE_ONLY=false
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
POLYMARKET_WS_PRICE_ENABLED=false
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
+3
View File
@@ -48,6 +48,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Business state tests
run: npm run test:business
- name: Build
run: npm run build
-447
View File
@@ -1,447 +0,0 @@
<!-- AUTONOMY DIRECTIVE — DO NOT REMOVE -->
YOU ARE AN AUTONOMOUS CODING AGENT. EXECUTE TASKS TO COMPLETION WITHOUT ASKING FOR PERMISSION.
DO NOT STOP TO ASK "SHOULD I PROCEED?" — PROCEED. DO NOT WAIT FOR CONFIRMATION ON OBVIOUS NEXT STEPS.
IF BLOCKED, TRY AN ALTERNATIVE APPROACH. ONLY ASK WHEN TRULY AMBIGUOUS OR DESTRUCTIVE.
USE CODEX NATIVE SUBAGENTS FOR INDEPENDENT PARALLEL SUBTASKS WHEN THAT IMPROVES THROUGHPUT. THIS IS COMPLEMENTARY TO OMX TEAM MODE.
<!-- END AUTONOMY DIRECTIVE -->
<!-- omx:generated:agents-md -->
# oh-my-codex - Intelligent Multi-Agent Orchestration
You are running with oh-my-codex (OMX), a coordination layer for Codex CLI.
This AGENTS.md is the top-level operating contract for the workspace.
Role prompts under `prompts/*.md` are narrower execution surfaces. They must follow this file, not override it.
When OMX is installed, load the installed prompt/skill/agent surfaces from `./.codex/prompts`, `./.codex/skills`, and `./.codex/agents` (or the project-local `./.codex/...` equivalents when project scope is active).
<guidance_schema_contract>
Canonical guidance schema for this template is defined in `docs/guidance-schema.md`.
Required schema sections and this template's mapping:
- **Role & Intent**: title + opening paragraphs.
- **Operating Principles**: `<operating_principles>`.
- **Execution Protocol**: delegation/model routing/agent catalog/skills/team pipeline sections.
- **Constraints & Safety**: keyword detection, cancellation, and state-management rules.
- **Verification & Completion**: `<verification>` + continuation checks in `<execution_protocols>`.
- **Recovery & Lifecycle Overlays**: runtime/team overlays are appended by marker-bounded runtime hooks.
Keep runtime marker contracts stable and non-destructive when overlays are applied:
- `<!-- OMX:RUNTIME:START --> ... <!-- OMX:RUNTIME:END -->`
- `<!-- OMX:TEAM:WORKER:START --> ... <!-- OMX:TEAM:WORKER:END -->`
</guidance_schema_contract>
<operating_principles>
- Solve the task directly when you can do so safely and well.
- Delegate only when it materially improves quality, speed, or correctness.
- Keep progress short, concrete, and useful.
- Prefer evidence over assumption; verify before claiming completion.
- Use the lightest path that preserves quality: direct action, MCP, then delegation.
- Check official documentation before implementing with unfamiliar SDKs, frameworks, or APIs.
- Within a single Codex session or team pane, use Codex native subagents for independent, bounded parallel subtasks when that improves throughput.
<!-- OMX:GUIDANCE:OPERATING:START -->
- Default to quality-first, intent-deepening responses; think one more step before replying or asking for clarification, and use as much detail as needed for a strong result without empty verbosity.
- Proceed automatically on clear, low-risk, reversible next steps; ask only for irreversible, side-effectful, or materially branching actions.
- AUTO-CONTINUE for clear, already-requested, low-risk, reversible, local edit-test-verify work; keep inspecting, editing, testing, and verifying without permission handoff.
- ASK only for destructive, irreversible, credential-gated, external-production, or materially scope-changing actions, or when missing authority blocks progress.
- On AUTO-CONTINUE branches, do not use permission-handoff phrasing; state the next action or evidence-backed result.
- Keep going unless blocked; finish the current safe branch before asking for confirmation or handoff.
- Ask only when blocked by missing information, missing authority, or an irreversible/destructive branch.
- Do not ask or instruct humans to perform ordinary non-destructive, reversible actions; execute those safe reversible OMX/runtime operations and ordinary commands yourself.
- Treat OMX runtime manipulation, state transitions, and ordinary command execution as agent responsibilities when they are safe and reversible.
- Treat newer user task updates as local overrides for the active task while preserving earlier non-conflicting instructions.
- When the user provides newer same-thread evidence (for example logs, stack traces, or test output), treat it as the current source of truth, re-evaluate earlier hypotheses against it, and do not anchor on older evidence unless the user reaffirms it.
- Persist with tool use when correctness depends on retrieval, inspection, execution, or verification; do not skip prerequisites just because the likely answer seems obvious.
- More effort does not mean reflexive web/tool escalation; browse or use tools when the task materially benefits, not as a default show of effort.
<!-- OMX:GUIDANCE:OPERATING:END -->
</operating_principles>
## Working agreements
- Write a cleanup plan before modifying code for cleanup/refactor/deslop work.
- Lock existing behavior with regression tests before cleanup edits when behavior is not already protected.
- Prefer deletion over addition.
- Reuse existing utils and patterns before introducing new abstractions.
- No new dependencies without explicit request.
- Keep diffs small, reviewable, and reversible.
- Run lint, typecheck, tests, and static analysis after changes.
- Final reports must include changed files, simplifications made, and remaining risks.
<lore_commit_protocol>
## Lore Commit Protocol
Every commit message must follow the Lore protocol — structured decision records using native git trailers.
Commits are not just labels on diffs; they are the atomic unit of institutional knowledge.
### Format
```
<intent line: why the change was made, not what changed>
<body: narrative context — constraints, approach rationale>
Constraint: <external constraint that shaped the decision>
Rejected: <alternative considered> | <reason for rejection>
Confidence: <low|medium|high>
Scope-risk: <narrow|moderate|broad>
Directive: <forward-looking warning for future modifiers>
Tested: <what was verified (unit, integration, manual)>
Not-tested: <known gaps in verification>
```
### Rules
1. **Intent line first.** The first line describes *why*, not *what*. The diff already shows what changed.
2. **Trailers are optional but encouraged.** Use the ones that add value; skip the ones that don't.
3. **`Rejected:` prevents re-exploration.** If you considered and rejected an alternative, record it so future agents don't waste cycles re-discovering the same dead end.
4. **`Directive:` is a message to the future.** Use it for "do not change X without checking Y" warnings.
5. **`Constraint:` captures external forces.** API limitations, policy requirements, upstream bugs — things not visible in the code.
6. **`Not-tested:` is honest.** Declaring known verification gaps is more valuable than pretending everything is covered.
7. **All trailers use git-native trailer format** (key-value after a blank line). No custom parsing required.
### Example
```
Prevent silent session drops during long-running operations
The auth service returns inconsistent status codes on token
expiry, so the interceptor catches all 4xx responses and
triggers an inline refresh.
Constraint: Auth service does not support token introspection
Constraint: Must not add latency to non-expired-token paths
Rejected: Extend token TTL to 24h | security policy violation
Rejected: Background refresh on timer | race condition with concurrent requests
Confidence: high
Scope-risk: narrow
Directive: Error handling is intentionally broad (all 4xx) — do not narrow without verifying upstream behavior
Tested: Single expired token refresh (unit)
Not-tested: Auth service cold-start > 500ms behavior
```
### Trailer Vocabulary
| Trailer | Purpose |
|---------|---------|
| `Constraint:` | External constraint that shaped the decision |
| `Rejected:` | Alternative considered and why it was rejected |
| `Confidence:` | Author's confidence level (low/medium/high) |
| `Scope-risk:` | How broadly the change affects the system (narrow/moderate/broad) |
| `Reversibility:` | How easily the change can be undone (clean/messy/irreversible) |
| `Directive:` | Forward-looking instruction for future modifiers |
| `Tested:` | What verification was performed |
| `Not-tested:` | Known gaps in verification |
| `Related:` | Links to related commits, issues, or decisions |
Teams may introduce domain-specific trailers without breaking compatibility.
</lore_commit_protocol>
---
<delegation_rules>
Default posture: work directly.
Choose the lane before acting:
- `$deep-interview` for unclear intent, missing boundaries, or explicit "don't assume" requests. This mode clarifies and hands off; it does not implement.
- `$ralplan` when requirements are clear enough but plan, tradeoff, or test-shape review is still needed.
- `$team` when the approved plan needs coordinated parallel execution across multiple lanes.
- `$ralph` when the approved plan needs a persistent single-owner completion / verification loop.
- **Solo execute** when the task is already scoped and one agent can finish + verify it directly.
Delegate only when it materially improves quality, speed, or safety. Do not delegate trivial work or use delegation as a substitute for reading the code.
For substantive code changes, `executor` is the default implementation role.
Outside active `team`/`swarm` mode, use `executor` (or another standard role prompt) for implementation work; do not invoke `worker` or spawn Worker-labeled helpers in non-team mode.
Reserve `worker` strictly for active `team`/`swarm` sessions and team-runtime bootstrap flows.
Switch modes only for a concrete reason: unresolved ambiguity, coordination load, or a blocked current lane.
</delegation_rules>
<child_agent_protocol>
Leader responsibilities:
1. Pick the mode and keep the user-facing brief current.
2. Delegate only bounded, verifiable subtasks with clear ownership.
3. Integrate results, decide follow-up, and own final verification.
Worker responsibilities:
1. Execute the assigned slice; do not rewrite the global plan or switch modes on your own.
2. Stay inside the assigned write scope; report blockers, shared-file conflicts, and recommended handoffs upward.
3. Ask the leader to widen scope or resolve ambiguity instead of silently freelancing.
Rules:
- Max 6 concurrent child agents.
- Child prompts stay under AGENTS.md authority.
- `worker` is a team-runtime surface, not a general-purpose child role.
- Child agents should report recommended handoffs upward.
- Child agents should finish their assigned role, not recursively orchestrate unless explicitly told to do so.
- Prefer inheriting the leader model by omitting `spawn_agent.model` unless a task truly requires a different model.
- Do not hardcode stale frontier-model overrides for Codex native child agents. If an explicit frontier override is necessary, use the current frontier default from `OMX_DEFAULT_FRONTIER_MODEL` / the repo model contract (currently `gpt-5.5`), not older values such as `gpt-5.2`.
- Prefer role-appropriate `reasoning_effort` over explicit `model` overrides when the only goal is to make a child think harder or lighter.
</child_agent_protocol>
<invocation_conventions>
- `$name` — invoke a workflow skill
- `/skills` — browse available skills
- Prefer skill invocation and keyword routing as the primary user-facing workflow surface
</invocation_conventions>
<model_routing>
Match role to task shape:
- Low complexity: `explore`, `style-reviewer`, `writer`
- Research/discovery: `explore` for repo lookup, `researcher` for official docs/reference gathering, `dependency-expert` for SDK/API/package evaluation
- Standard: `executor`, `debugger`, `test-engineer`
- High complexity: `architect`, `executor`, `critic`
For Codex native child agents, model routing defaults to inheritance/current repo defaults unless the caller has a concrete reason to override it.
</model_routing>
<specialist_routing>
Leader/workflow routing contract:
<!-- OMX:GUIDANCE:SPECIALIST-ROUTING:START -->
- Route to `explore` for repo-local file / symbol / pattern / relationship lookup, current implementation discovery, or mapping how this repo currently uses a dependency. `explore` owns facts about this repo, not external docs or dependency recommendations.
- Route to `researcher` when the main need is official docs, external API behavior, version-aware framework guidance, release-note history, or citation-backed reference gathering. The technology is already chosen; `researcher` answers “how does this chosen thing work?” and is not the default dependency-comparison role.
- Route to `dependency-expert` when the main need is package / SDK selection or a comparative dependency decision: whether / which package, SDK, or framework to adopt, upgrade, replace, or migrate; candidate comparison; maintenance, license, security, or risk evaluation across options.
- Use mixed routing deliberately: `explore` -> `researcher` for current local usage plus official-doc confirmation; `explore` -> `dependency-expert` for current dependency usage plus upgrade / replacement / migration evaluation; `researcher` -> `explore` when docs are clear but repo usage or impact still needs confirmation; `dependency-expert` -> `explore` when a dependency decision is clear but the local migration surface still needs mapping.
- Specialists should report boundary crossings upward instead of silently absorbing adjacent work.
- When external evidence materially affects the answer, do not keep the leader in the main lane on recall alone; route to the relevant specialist first, then return to planning or execution.
<!-- OMX:GUIDANCE:SPECIALIST-ROUTING:END -->
</specialist_routing>
---
<agent_catalog>
Key roles:
- `explore` — fast codebase search and mapping
- `planner` — work plans and sequencing
- `architect` — read-only analysis, diagnosis, tradeoffs
- `debugger` — root-cause analysis
- `executor` — implementation and refactoring
- `verifier` — completion evidence and validation
Research/discovery specialists:
- `explore` — first-stop repository lookup and symbol/file mapping
- `researcher` — official docs, references, and external fact gathering
- `dependency-expert` — SDK/API/package evaluation before adopting or changing dependencies
Specialists remain available through the role catalog and native child-agent surfaces when the task clearly benefits from them.
</agent_catalog>
---
<keyword_detection>
Keyword routing is implemented primarily by native `UserPromptSubmit` hooks and the generated keyword registry. Treat hook-injected routing context as authoritative for the current turn, then load the named `SKILL.md` or prompt file as instructed.
Fallback behavior when hook context is unavailable:
- Explicit `$name` invocations run left-to-right and override implicit keywords.
- Bare skill names do not activate skills by themselves; skill-name activation requires explicit `$skill` invocation. Natural-language routing phrases may still map to a workflow when they are not just the bare skill name. Examples: `analyze` / `investigate``$analyze` for read-only deep analysis with ranked synthesis, explicit confidence, and concrete file references; `deep interview`, `interview`, `don't assume`, or `ouroboros``$deep-interview`; `ralplan` / `consensus plan``$ralplan`; `cancel`, `stop`, or `abort``$cancel`.
- Keep the detailed keyword list in `src/hooks/keyword-registry.ts`; do not duplicate that table here.
Runtime availability gate:
- Treat `autopilot`, `ralph`, `ultrawork`, `ultraqa`, `team`/`swarm`, and `ecomode` as **OMX runtime workflows**, not generic prompt aliases.
- Auto-activate runtime workflows only when the current session is actually running under OMX CLI/runtime (for example, launched via `omx`, with OMX session overlay/runtime state available, or when the user explicitly asks to run `omx ...` in the shell).
- In Codex App or plain Codex sessions without OMX runtime, do **not** treat those keywords alone as activation. Explain that they require OMX CLI runtime support, and continue with the nearest App-safe surface (`deep-interview`, `ralplan`, `plan`, or native subagents) unless the user explicitly wants you to launch OMX from the shell.
- When deep-interview is active in OMX CLI/runtime, ask interview rounds via `omx question`; after launching `omx question` in a background terminal, wait for that terminal to finish and read the JSON answer before continuing; do not substitute `request_user_input` or ad hoc plain-text questioning, and respect Stop-hook blocking while a deep-interview question obligation is pending.
<triage_routing>
## Triage: advisory prompt-routing context
The keyword detector is the first and deterministic routing surface. Triage runs only when no keyword matches.
When active, triage emits **advisory prompt-routing context** — a developer-context string that the model may follow. It does not activate a skill or workflow by itself. It is a best-effort hint, not a guarantee.
Note: `explore`, `executor`, and `designer` are agent role-prompt files under `prompts/`, not workflow skills.
Explicit keywords remain the deterministic control surface when you want explicit, guaranteed routing — use them whenever exact behavior matters.
To opt out per prompt with phrases such as `no workflow`, `just chat`, or `plain answer` — the triage layer will suppress context injection for that prompt.
</triage_routing>
Ralph / Ralplan execution gate:
- Enforce **ralplan-first** when ralph is active and planning is not complete.
- Planning is complete only after both `.omx/plans/prd-*.md` and `.omx/plans/test-spec-*.md` exist.
- Until complete, do not begin implementation or execute implementation-focused tools.
</keyword_detection>
---
<skills>
Skills are workflow commands.
Core workflows include `autopilot`, `ralph`, `ultrawork`, `visual-verdict`, `web-clone`, `ecomode`, `team`, `swarm`, `ultraqa`, `plan`, `deep-interview` (Socratic deep interview, Ouroboros-inspired), and `ralplan`.
Utilities include `cancel`, `note`, `doctor`, `help`, and `trace`.
</skills>
---
<team_compositions>
Common team compositions remain available when explicit team orchestration is warranted, for example feature development, bug investigation, code review, and UX audit.
</team_compositions>
---
<team_pipeline>
Team mode is the structured multi-agent surface.
Canonical pipeline:
`team-plan -> team-prd -> team-exec -> team-verify -> team-fix (loop)`
Use it when durable staged coordination is worth the overhead. Otherwise, stay direct.
Terminal states: `complete`, `failed`, `cancelled`.
</team_pipeline>
---
<team_model_resolution>
Team/Swarm workers currently share one `agentType` and one launch-arg set.
Model precedence:
1. Explicit model in `OMX_TEAM_WORKER_LAUNCH_ARGS`
2. Inherited leader `--model`
3. Low-complexity default model from `OMX_DEFAULT_SPARK_MODEL` (legacy alias: `OMX_SPARK_MODEL`)
Normalize model flags to one canonical `--model <value>` entry.
Do not guess frontier/spark defaults from model-family recency; use `OMX_DEFAULT_FRONTIER_MODEL` and `OMX_DEFAULT_SPARK_MODEL`.
</team_model_resolution>
<!-- OMX:MODELS:START -->
## Model Capability Table
Auto-generated by `omx setup` from the current `config.toml` plus OMX model overrides.
| Role | Model | Reasoning Effort | Use Case |
| --- | --- | --- | --- |
| Frontier (leader) | `gpt-5.5` | high | Primary leader/orchestrator for planning, coordination, and frontier-class reasoning. |
| Spark (explorer/fast) | `gpt-5.3-codex-spark` | low | Fast triage, explore, lightweight synthesis, and low-latency routing. |
| Standard (subagent default) | `gpt-5.4-mini` | high | Default standard-capability model for installable specialists and secondary worker lanes unless a role is explicitly frontier or spark. |
| `explore` | `gpt-5.3-codex-spark` | low | Fast codebase search and file/symbol mapping (fast-lane, fast) |
| `analyst` | `gpt-5.5` | medium | Requirements clarity, acceptance criteria, hidden constraints (frontier-orchestrator, frontier) |
| `planner` | `gpt-5.5` | medium | Task sequencing, execution plans, risk flags (frontier-orchestrator, frontier) |
| `architect` | `gpt-5.5` | high | System design, boundaries, interfaces, long-horizon tradeoffs (frontier-orchestrator, frontier) |
| `debugger` | `gpt-5.4-mini` | high | Root-cause analysis, regression isolation, failure diagnosis (deep-worker, standard) |
| `executor` | `gpt-5.5` | medium | Code implementation, refactoring, feature work (deep-worker, standard) |
| `team-executor` | `gpt-5.5` | medium | Supervised team execution for conservative delivery lanes (deep-worker, frontier) |
| `verifier` | `gpt-5.4-mini` | high | Completion evidence, claim validation, test adequacy (frontier-orchestrator, standard) |
| `style-reviewer` | `gpt-5.3-codex-spark` | low | Formatting, naming, idioms, lint conventions (fast-lane, fast) |
| `quality-reviewer` | `gpt-5.4-mini` | medium | Logic defects, maintainability, anti-patterns (frontier-orchestrator, standard) |
| `api-reviewer` | `gpt-5.4-mini` | medium | API contracts, versioning, backward compatibility (frontier-orchestrator, standard) |
| `security-reviewer` | `gpt-5.5` | medium | Vulnerabilities, trust boundaries, authn/authz (frontier-orchestrator, frontier) |
| `performance-reviewer` | `gpt-5.4-mini` | medium | Hotspots, complexity, memory/latency optimization (frontier-orchestrator, standard) |
| `code-reviewer` | `gpt-5.5` | high | Comprehensive review across all concerns (frontier-orchestrator, frontier) |
| `dependency-expert` | `gpt-5.4-mini` | high | External SDK/API/package evaluation (frontier-orchestrator, standard) |
| `test-engineer` | `gpt-5.5` | medium | Test strategy, coverage, flaky-test hardening (deep-worker, frontier) |
| `quality-strategist` | `gpt-5.4-mini` | medium | Quality strategy, release readiness, risk assessment (frontier-orchestrator, standard) |
| `build-fixer` | `gpt-5.4-mini` | high | Build/toolchain/type failures resolution (deep-worker, standard) |
| `designer` | `gpt-5.4-mini` | high | UX/UI architecture, interaction design (deep-worker, standard) |
| `writer` | `gpt-5.4-mini` | high | Documentation, migration notes, user guidance (fast-lane, standard) |
| `qa-tester` | `gpt-5.4-mini` | low | Interactive CLI/service runtime validation (deep-worker, standard) |
| `git-master` | `gpt-5.4-mini` | high | Commit strategy, history hygiene, rebasing (deep-worker, standard) |
| `code-simplifier` | `gpt-5.5` | high | Simplifies recently modified code for clarity and consistency without changing behavior (deep-worker, frontier) |
| `researcher` | `gpt-5.4-mini` | high | External documentation and reference research (fast-lane, standard) |
| `product-manager` | `gpt-5.4-mini` | medium | Problem framing, personas/JTBD, PRDs (frontier-orchestrator, standard) |
| `ux-researcher` | `gpt-5.4-mini` | medium | Heuristic audits, usability, accessibility (frontier-orchestrator, standard) |
| `information-architect` | `gpt-5.4-mini` | low | Taxonomy, navigation, findability (frontier-orchestrator, standard) |
| `product-analyst` | `gpt-5.4-mini` | low | Product metrics, funnel analysis, experiments (frontier-orchestrator, standard) |
| `critic` | `gpt-5.5` | high | Plan/design critical challenge and review (frontier-orchestrator, frontier) |
| `vision` | `gpt-5.5` | low | Image/screenshot/diagram analysis (fast-lane, frontier) |
<!-- OMX:MODELS:END -->
---
<verification>
Verify before claiming completion.
Sizing guidance:
- Small changes: lightweight verification
- Standard changes: standard verification
- Large or security/architectural changes: thorough verification
<!-- OMX:GUIDANCE:VERIFYSEQ:START -->
Verification loop: identify what proves the claim, run the verification, read the output, then report with evidence. If verification fails, continue iterating rather than reporting incomplete work. Default to quality-first evidence summaries: think one more step before declaring completion, and include enough detail to make the proof actionable without padding.
- Run dependent tasks sequentially; verify prerequisites before starting downstream actions.
- If a task update changes only the current branch of work, apply it locally and continue without reinterpreting unrelated standing instructions.
- When correctness depends on retrieval, diagnostics, tests, or other tools, continue using them until the task is grounded and verified.
<!-- OMX:GUIDANCE:VERIFYSEQ:END -->
</verification>
<execution_protocols>
Mode selection:
- Use `$deep-interview` first when the request is broad, intent/boundaries are unclear, or the user says not to assume.
- Use `$ralplan` when the requirements are clear enough but architecture, tradeoffs, or test strategy still need consensus.
- Use `$team` when the approved plan has multiple independent lanes, shared blockers, or durable coordination needs.
- Use `$ralph` when the approved plan should stay in a persistent completion / verification loop with one owner.
- Otherwise execute directly in solo mode.
- Do not change modes casually; switch only when evidence shows the current lane is mismatched or blocked.
Command routing:
- When `USE_OMX_EXPLORE_CMD` enables advisory routing, strongly prefer `omx explore` as the default surface for simple read-only repository lookup tasks (files, symbols, patterns, relationships).
- For simple file/symbol lookups, use `omx explore` FIRST before attempting full code analysis.
When to use what:
- Use `omx explore --prompt ...` for simple read-only lookups.
- Use `omx sparkshell` for noisy read-only shell commands, bounded verification runs, repo-wide listing/search, or tmux-pane summaries; `omx sparkshell --tmux-pane ...` is explicit opt-in.
- Keep ambiguous, implementation-heavy, edit-heavy, or non-shell-only work on the richer normal path.
- `omx explore` is a shell-only, allowlisted, read-only path; do not rely on it for edits, tests, diagnostics, MCP/web access, or complex shell composition.
- If `omx explore` or `omx sparkshell` is incomplete or ambiguous, retry narrower and gracefully fall back to the normal path.
Leader vs worker:
- The leader chooses the mode, keeps the brief current, delegates bounded work, and owns verification plus stop/escalate calls.
- Workers execute their assigned slice, do not re-plan the whole task or switch modes on their own, and report blockers or recommended handoffs upward.
- Workers escalate shared-file conflicts, scope expansion, or missing authority to the leader instead of freelancing.
Stop / escalate:
- Stop when the task is verified complete, the user says stop/cancel, or no meaningful recovery path remains.
- Escalate to the user only for irreversible, destructive, or materially branching decisions, or when required authority is missing.
- Escalate from worker to leader for blockers, scope expansion, shared ownership conflicts, or mode mismatch.
- `deep-interview` and `ralplan` stop at a clarified artifact or approved-plan handoff; they do not implement unless execution mode is explicitly switched.
Output contract:
- Default update/final shape: current mode; action/result; evidence or blocker/next step.
- Keep rationale once; do not restate the full plan every turn.
- Expand only for risk, handoff, or explicit user request.
Parallelization:
- Run independent tasks in parallel.
- Run dependent tasks sequentially.
- Use background execution for builds and tests when helpful.
- Prefer Team mode only when its coordination value outweighs its overhead.
- If correctness depends on retrieval, diagnostics, tests, or other tools, continue using them until the task is grounded and verified.
Anti-slop workflow:
- Cleanup/refactor/deslop work still follows the same `$deep-interview` -> `$ralplan` -> `$team`/`$ralph` path; use `$ai-slop-cleaner` as a bounded helper inside the chosen execution lane, not as a competing top-level workflow.
- Lock behavior with tests first, then make one smell-focused pass at a time.
- Prefer deletion, reuse, and boundary repair over new layers.
- Keep writer/reviewer pass separation for cleanup plans and approvals.
Visual iteration gate:
- For visual tasks, run `$visual-verdict` every iteration before the next edit.
- Persist verdict JSON in `.omx/state/{scope}/ralph-progress.json`.
Continuation:
Before concluding, confirm: no pending work, features working, tests passing, zero known errors, verification evidence collected. If not, continue.
Ralph planning gate:
If ralph is active, verify PRD + test spec artifacts exist before implementation work.
</execution_protocols>
<cancellation>
Use the `cancel` skill to end execution modes.
Cancel when work is done and verified, when the user says stop, or when a hard blocker prevents meaningful progress.
Do not cancel while recoverable work remains.
</cancellation>
---
<state_management>
Hooks own normal skill-active and workflow-state persistence under `.omx/state/`.
OMX persists runtime state under `.omx/`:
- `.omx/state/` — mode state
- `.omx/notepad.md` — session notes
- `.omx/project-memory.json` — cross-session memory
- `.omx/plans/` — plans
- `.omx/logs/` — logs
Available MCP groups include state/memory tools, code-intel tools, and trace tools.
Agents may use OMX state/MCP tools for explicit lifecycle transitions, recovery, checkpointing, cancellation cleanup, or compaction resilience.
Do not manually duplicate hook-owned activation state unless recovering from missing or stale state.
</state_management>
---
## Setup
Execute `omx setup` to install all components. Execute `omx doctor` to verify installation.
+39 -1
View File
@@ -1,11 +1,49 @@
# Changelog
## Unreleased
## 1.6.0 - 2026-05-10
- 全面修复前端 UI 设计审查 15 项问题:消除工程债务、统一 token 体系、提升可维护性
- CSS 架构:消除 !important 滥用(134→49,仅保留 Leaflet/图表所必需项)、浅色主题重构为 `html.light` 选择器体系
- 统一断点体系:18→10480/640/768/960/1024/1200/1280/1360/1440/1680),对齐 Tailwind 标准
- CSS 变量迁移:10 个文件中数百处硬编码颜色(#4DA3FF/#E6EDF3/#9FB2C7/#6B7A90)替换为 token 变量
- 字体系统修复:13 个文件中所有非标准 font-weight760/850/860/880/950)映射为 Inter 支持值
- 移除未加载的 Geist 字体声明、提升文字对比度 #6B7A90#7D8FA3
- 修复 accent-green 类错误渲染为蓝色、accent-primary 与 accent-secondary 相同值问题
- 创建 scan-root-styles.ts 桶文件,将 22 个 CSS Module 导入合并为 1 个
- 添加全局 :focus-visible 轮廓环、跳过链接、Tab ARIA 属性
- 添加统一的 empty/error/retry 状态组件、prefers-reduced-motion 支持
- 去重 @keyframesspin 4→1、loading-spin 2→0、pulse-pending 移至全局
- 添加 CSS 渐变品牌 Logo、按钮层级文档化
- 移除 dead code1,697 行):public/static/style.css + public/legacy/index.html
- Dashboard.module.css 本地变量桥接至全局 token
- 清理冗余文档:移除 FRONTEND_REDESIGN_REPORT.md、TECH_DEBT.md 重复文件、AGENTS.md
- 参考:docs/frontend-ui-design-review.md 完整修复记录
## 1.5.5 - 2026-04-27
- Dashboard 新增 v1.5.5 升级公告,提示所有会员已额外延长 7 天,并集中说明 DeepSeek 机场报文解读、日历行动视图、本地时间峰值窗口和 AI 证据护栏
- 城市决策卡空状态与市场不可用文案产品化:将“未接入/缺失”改为“市场价格暂不可用,天气判断仍可参考”,避免用户误以为系统故障
- 城市决策卡新增“为什么推荐/为什么不推荐”短句,优先解释实测突破、峰值窗口已过、METAR 过旧、市场暂不可用或模型一致等关键原因
- 移动端城市决策卡前置当前温度、预测高点和峰值时间;长 AI 解读在手机端默认折叠,可展开查看,市场价格单独成行展示
- 新增 Qingdao / 青岛城市,结算锚点接入 Wunderground 青岛胶东国际机场 `ZSQD` 历史页,并补齐别名、时区、预热、官方来源和前端地区归类
- 城市决策卡顶部状态标签收口为 2-3 个高优先级信号,优先展示“实测突破 / 峰值窗口已过 / METAR 过旧 / AI 解读中 / 市场价暂不可用 / 模型高度一致 / 需要等待下一报文”,让用户第一眼看到重点
- 城市决策卡 AI 机场报文区明确拆分“快速判断已完成,AI 正在补充机场报文细节… / AI 机场报文解读已完成 / AI 解读未完整返回,当前使用规则证据”三种状态,减少 fallback 与流式返回造成的误解
- 城市决策卡新增“数据新鲜度”区块,分别展示 METAR/官方观测、模型、市场价格和 AI 状态;过旧观测会标明“仅作背景参考”
- 日历视图升级为行动视图,按“现在可看 / 1-3 小时内 / 今天稍后 / 已过峰值,等待确认”分组,并为每个城市显示一句核心原因
- 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
- 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
- 城市决策卡兜底判断新增实测突破识别:当最新 METAR/观测已高于 DEB 中枢或模型上沿时,改为提示最高温中枢需要上修
- 城市决策卡兜底判断补充实测偏低和峰值窗口已过分支:峰后未追上模型时提示下修压力,峰前偏低时只提示等待确认
- 城市决策卡新增过旧 METAR/观测识别:过旧报文只作为背景参考,不再触发强实况锚点、上修或下修判断;AI 缓存键同步纳入观测时间与 stale 状态
- 城市决策卡新增 AI 结果后处理护栏:完整 DeepSeek 返回若与过旧观测、实测突破、峰后下修等确定性证据冲突,会以后端规则覆盖关键数值和结论文案
- 城市决策卡新增状态标签与数据新鲜度提示,直接标出 AI 是否完成、市场价格是否同步、METAR/官方观测是否过旧或已突破模型区间,减少用户等待和误读
- 后端 Scan Terminal 代码拆出 `scan_city_ai_helpers.py`,将城市 AI JSON 解析、fallback 文案、schema completion 与证据护栏从主服务文件中剥离,降低后续维护成本
- 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶
- 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
- Scan Terminal 新增日历视图:按城市 + 日期去重、按峰值窗口倒计时分组,并在卡片中同时展示用户电脑本地时间与城市窗口
- 日历视图只保留未来 12 小时内或峰后 3 小时内的可行动窗口,避免 London 这类距离峰值过久的城市过早占用日历
- README、前端 README、API 文档和网页 `/docs` 文档同步补充城市决策卡、AI 机场报文解读组成、缓存策略和市场层解释
## 1.5.4 - 2026-04-18
+153
View File
@@ -0,0 +1,153 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, maps weather to Polymarket quotes for mispricing scans, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
## Environment & Preferences (ALWAYS follow)
### Working Directory
- All commands run from the repo root: `E:/web/PolyWeather`
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
- When I say "start the server", assume the correct working directory is `E:/web/PolyWeather`
### Git Conventions
- **Commit language: Chinese (简体中文) ONLY**
- Format: Lore Commit Protocol — intent line in Chinese, trailers in English
- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径`
- **NEVER** use English for commit subject lines
### Tooling
- Package manager: **npm** (not yarn/pnpm)
- Python: `python` (not python3), venv at `venv/`
- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript)
- NEVER ask me about these preferences again — commit to memory
## Architecture
```
Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
Weather Collector (METAR, TAF, Open-Meteo, country networks)
Analysis (DEB + Trend + Probability + Market Scan)
Payment Layer (Intent + Event + Confirm Loop)
```
- **Backend**: FastAPI on port 8000 (`web/app.py``web/core.py` + `web/routes.py` + `web/analysis_service.py`)
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 on port 3000 (dev)
- **Bot**: Telegram bot via `bot_listener.py``src/bot/`
- **Shared analysis core** in `src/` is used by both web API and bot
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
## Commands
### Frontend (dev on port 3000)
```bash
cd frontend
npm ci
npm run dev # Next.js dev server
npm run build # Production build
npm run lint # ESLint via next lint
```
### Backend (dev on port 8000)
```bash
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
```
### Telegram Bot
```bash
python bot_listener.py
# or via wrapper:
python run.py
```
### Docker (production-like stack)
```bash
docker compose up -d --build # bot + web API
docker compose --profile workers up -d # + prewarm worker
docker compose --profile monitoring up -d # + Prometheus/Grafana/Alertmanager
```
### Python tests
```bash
pytest tests/ # all tests
pytest tests/test_web_observability.py # single test file
```
### Lint & Format
```bash
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
ruff format . # Python format (Black-compatible, double quotes)
```
### Health & Ops checks
```bash
curl http://127.0.0.1:8000/healthz
curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
## Key Directories
| Directory | Purpose |
|-----------|---------|
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer |
| `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding |
| `src/models/` | LightGBM daily-high model training and feature engineering |
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
| `src/bot/` | Telegram bot handlers and orchestrator |
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
| `web/` | FastAPI app, routes (~65K), analysis service (~130K), scan terminal service (~56K), AI scan modules |
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className |
| `frontend/lib/` | Shared client logic: types, API client, chart utils, i18n, dashboard utils |
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation, prewarm worker |
| `config/` | YAML config (city list, weather settings, logging) |
| `docs/` | Bilingual product & technical docs |
| `monitoring/` | Prometheus/Grafana/Alertmanager configs |
## Key Technical Details
- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules
- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined
- **Frontend package manager**: npm
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
- **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth.
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
- **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
## Commit Convention
This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what).
- Always write git commit messages in **Chinese (简体中文)**.
## Code Style
- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding.
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
## Quality Gates (MANDATORY)
Before marking any task as complete, you MUST:
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
3. **Dual-theme CSS** — For any UI change, confirm BOTH the dark CSS module AND `ScanTerminalLightTheme.module.css` were updated
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
If any gate fails, fix it BEFORE reporting success.
-82
View File
@@ -1,82 +0,0 @@
# 前端交付与重构报告(v1.5.1)
最后更新:`2026-03-14`
## 1. 报告目的
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
## 2. 当前前端架构
```mermaid
flowchart LR
B["Browser"] --> N["Next.js App Router (Vercel)"]
N --> RH["Route Handlers /api/*"]
RH --> F["FastAPI (VPS)"]
N --> STORE["Dashboard Store"]
STORE --> MAP["MapCanvas"]
STORE --> SIDEBAR["CitySidebar"]
STORE --> PANEL["DetailPanel + Modal"]
STORE --> ACCOUNT["Account Center + Pro Overlay"]
```
## 3. 已落地能力
### 3.1 信息架构与交互
- 风险分组侧栏折叠(持久化)。
- 选中城市状态持久化。
- 今日分析、历史对账、未来日期分析联动。
### 3.2 收费相关
- 账户中心(登录态、积分、订阅状态、钱包管理)。
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
- 支付流程:create intent -> submit -> confirm。
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
### 3.3 缓存与性能
- BFF `ETag/304``cities` / `summary` / `history`
- `summary?force_refresh=true` => `no-store`
- `sessionStorage` + in-flight 去重。
- `localStorage`:选中城市、侧栏折叠状态。
### 3.4 可访问性与稳定性
- 详情面板 `inert + blur` 焦点冲突修复。
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
## 4. 当前明确未做
- 离线能力(Service Worker / IndexedDB
- 前端级财务报表与退款后台(后端/运营侧)
## 5. 验收建议
### 5.1 前端构建
```bash
cd frontend
npm run build
```
### 5.2 缓存验收
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### 5.3 支付验收
- 绑定钱包
- 创建 intent
- 发交易
- 验证 `intent` 状态从 `submitted -> confirmed`
- 校验账户页订阅状态更新
## 6. 结论
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
+6 -1
View File
@@ -17,6 +17,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
## Star History
[![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-04-27)
- Subscription live: `Pro Monthly 5 USDC`.
@@ -44,6 +48,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
## License & Commercial Boundary
@@ -97,7 +102,7 @@ flowchart LR
## Monitored Cities (52)
- Europe / Middle East / Africa: Ankara, Istanbul, Moscow, London, Paris, Munich, Milan, Warsaw, Madrid, Tel Aviv, Amsterdam, Helsinki, Lagos, Cape Town, Jeddah
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Wuhan, Chengdu, Chongqing, Shenzhen, Guangzhou, Singapore, Tokyo, Kuala Lumpur, Jakarta, Manila, Wellington
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Qingdao, Wuhan, Chengdu, Chongqing, Shenzhen, Guangzhou, Singapore, Tokyo, Kuala Lumpur, Jakarta, Manila, Wellington
- Americas: Toronto, New York, Los Angeles, San Francisco, Aurora, Austin, Houston, Chicago, Dallas, Miami, Atlanta, Seattle, Mexico City, Buenos Aires, Sao Paulo, Panama City
- South Asia: Lucknow, Karachi, Masroor Air Base
+1
View File
@@ -45,6 +45,7 @@
- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
## 许可证与商用边界(重要)
+1 -1
View File
@@ -1 +1 @@
1.5.4
1.5.5
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -42,25 +42,25 @@
"nws"
],
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
"sample_count": 20,
"train_count": 8,
"validation_count": 12,
"sample_count": 1247,
"train_count": 998,
"validation_count": 249,
"metrics": {
"validation": {
"sample_count": 12,
"lgbm_mae": 5.867,
"deb_mae": 1.825,
"best_single_mae": 0.567,
"median_mae": 1.7
"sample_count": 249,
"lgbm_mae": 2.626,
"deb_mae": 2.745,
"best_single_mae": 1.733,
"median_mae": 2.866
},
"full_sample": {
"sample_count": 20,
"lgbm_mae": 2.66,
"deb_mae": 1.68,
"best_single_mae": 0.775,
"median_mae": 1.695
"sample_count": 1247,
"lgbm_mae": 0.953,
"deb_mae": 1.872,
"best_single_mae": 1.052,
"median_mae": 1.952
}
},
"generated_at": "2026-04-18T19:27:39.228210Z",
"trained_at": "2026-04-18T19:27:39.228210Z"
"generated_at": "2026-05-06T10:45:04.319587Z",
"trained_at": "2026-05-06T10:45:04.319587Z"
}
+10 -2
View File
@@ -270,6 +270,13 @@ POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
POLYWEATHER_SCAN_AI_ENABLED=false
POLYWEATHER_SCAN_AI_API_KEY=...
POLYWEATHER_SCAN_AI_PROVIDER=mimo
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
```
说明:
@@ -280,7 +287,8 @@ POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL``POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
@@ -346,7 +354,7 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
说明:
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮机会清单。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
-81
View File
@@ -1,81 +0,0 @@
# 技术债与工程待办(v1.5.4
最后更新:`2026-04-19`
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
## 1. 债务快照
当前估计:**95% 稳定 / 5% 技术债**。
```mermaid
flowchart TD
A["技术债"]
subgraph P["支付与订阅"]
P1["合约 V2 升级(SafeERC20 / Pausable"]
P2["退款与工单流程"]
P3["多 RPC 与链上对账面板"]
end
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分来源明细与补分审计"]
end
subgraph O["可观测性"]
O1["外部监控抓取与告警阈值"]
O2["业务监控看板"]
end
subgraph S["状态与概率"]
S1["EMOS 本地训练与 shadow 发布门禁"]
end
A --> P
A --> E
A --> O
A --> S
```
## 2. 近期已关闭
- 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 支付事件重放脚本已补齐。
- 支付运行态 API 与 SQLite 审计事件已补齐。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已接通;生产主概率保持 `legacy``emos_shadow``emos_primary` 只允许本地训练通过门禁后人工灰度。
## 3. 高优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| EMOS 发布门禁 | 低配 VPS 不适合训练,主概率不能绕过评估 | 本地拉生产 SQLite 训练,`ready_for_promotion=true` 后先 `emos_shadow` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
## 4. 中优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
## 5. 低优先级技术债
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
## 6. 下阶段里程碑
1. 固化 EMOS 本地训练流程,禁止低配 VPS 自动训练和自动主用。
2. 补外部监控抓取与告警阈值。
3. 评估并推进支付合约 V2 升级。
+4 -3
View File
@@ -1,12 +1,12 @@
# 技术债与工程待办(v1.5.4
# 技术债与工程待办(v1.6.0
最后更新:`2026-04-19`
最后更新:`2026-05-10`
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
## 1. 债务快照
当前估计:**95% 稳定 / 5% 技术债**。
当前估计:**97% 稳定 / 3% 技术债**。前端设计系统债务已在 v1.6.0 全面消除。
```mermaid
flowchart TD
@@ -40,6 +40,7 @@ flowchart TD
## 2. 近期已关闭
- **前端设计系统工程债务(2026-05-10**:消除 !important 滥用(134→49)、统一断点体系(18→10)、数百处硬编码颜色迁移至 CSS 变量、修复 accent-green 蓝色 Bug、添加 ARIA 无障碍属性、去重 @keyframes、移除死代码(1,697 行)。详见 `docs/frontend-ui-design-review.md`
- 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 支付事件重放脚本已补齐。
+4 -4
View File
@@ -54,7 +54,7 @@ flowchart TB
subgraph API
FAST[FastAPI<br/>web/app.py]
LLM[City AI stream<br/>DeepSeek-compatible provider]
LLM[City AI stream<br/>OpenAI-compatible provider]
end
subgraph Data
@@ -124,7 +124,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 DeepSeek stream 同时竞争导致第三城/第四城解析失败。后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash``POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30``POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900``POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1``POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30``POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900``POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`
4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。
5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。
@@ -166,7 +166,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
**历史真值治理已从设计缺陷修复到可追溯运行**`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了历史页面回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI providerDeepSeek-compatible stream)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
项目强依赖外部 APIOpen-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI providerOpenAI-compatible stream,当前临时 MiMo)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz``/api/system/status``/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势图和运营日报。
**EMOS 已完成工程接入,但未完成生产发布**EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
@@ -263,7 +263,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
### 主要风险与缓解策略
**外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;DeepSeek-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
**外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
**密钥泄露与权限滥用**Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
**引入外部神经天气模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
+347
View File
@@ -0,0 +1,347 @@
# PolyWeather Frontend UI 设计审查报告
> 审查日期:2026-05-10 | 审查范围:`frontend/` 全部组件、样式、布局
## 整体评价
前端设计系统有**扎实的基础** — 完善的 CSS 自定义属性 token 体系、不错的暗色模式、合理的排版层级、以及语义化的颜色编码。但存在一些**工程性债务**需要关注。
最突出的 5 个问题:
1. `!important` 在浅色主题中泛滥(~960 行),维护成本极高
2. `font-weight: 760/850/950` 等无效值在 CSS 中大量使用
3. Geist 字体声明但从未加载,始终 fallback 到 Inter
4. 键盘导航和 focus-visible 样式缺失
5. 14 个不同的响应式断点值,无统一体系
---
## 一、设计系统 & Token 体系
### 优点
1. **完整的 CSS 自定义属性体系** (`globals.css:9-93`):拥有从背景色阶、文字色阶、语义色、阴影、间距、圆角、毛玻璃到动效的完整 token 体系,覆盖面广。
2. **4px 间距网格**:从 `--space-1: 4px``--space-12: 48px`,提供了系统化的间距基础。
3. **三层毛玻璃系统**`--glass-blur-1/2/3` + `--glass-opacity-1/2/3` 的组合创造视觉深度层次,在暗色背景下效果出色。
4. **Tailwind 桥接**`tailwind.config.ts` 提供了 `pw-bg-*``pw-text-*``pw-accent-*` 等语义化 utility class,兼顾了设计 token 的语义化和 Tailwind 的效率。
### 问题
1. **"Fintech 3-Color" 名不副实** (`globals.css:25-27`)
```css
--color-accent-primary: #4DA3FF; /* 蓝 */
--color-accent-secondary: #4DA3FF; /* 同上 — 完全一样 */
--color-accent-tertiary: #93C5FD; /* 浅蓝 */
```
primary 和 secondary 是同一个颜色,实际只有 2 种不同色值。"3-Color" 声明具有误导性。
2. **Token 未充分利用**:大量 CSS Module 中硬编码了颜色值(如 `#4DA3FF``#E6EDF3``rgba(77, 163, 255, ...)`)而不是引用 CSS 变量。例如 `ScanTerminalCard.module.css` 中几乎每一行都是硬编码色值。
3. **shadcn/ui HSL token 遗留** (`globals.css:95-106`):保留了 shadcn 的 HSL token 但几乎未被使用,增加了 token 体系的认知负担。
---
## 二、布局 & 响应式
### 优点
1. **Scan Terminal 的 CSS Grid 布局** (`ScanTerminal.module.css:524-537`)`grid-template-columns: minmax(0, 1fr) minmax(380px, 420px)` 的 2 列布局干净、现代。
2. **毛玻璃面板系统**:所有主面板都有 `22px border-radius``backdrop-filter: blur(14px)`、渐变背景和微妙的阴影,视觉统一。
3. **Sticky detail rail**:右侧面板 `position: sticky; top: 16px; height: calc(100vh - 32px)` 保证了用户滚动时详情始终可见。
### 问题
1. **两套布局系统并存**
- 旧版 "Home Intelligence" 使用 **absolute positioning** 定位 header/sidebar/map/panel
- 新版 "Scan Terminal" 使用 **CSS Grid**
- 两套 CSS Module 都在 `ScanTerminalDashboard.tsx` 中被 import 合并,增加了复杂度
2. **断点碎片化严重**:系统使用了 12+ 个不同断点值 (1680, 1480, 1360, 1240, 1100, 1020, 900, 820, 768, 720, 640, 600, 560, 520),没有统一的体系。同一个 tablet 过渡在一些模块用 768px,在另一些用 720px。
3. **Map View 中的 absolute positioning** (`DashboardShell.module.css`):使用 `top/right/bottom/left` 绝对定位来排列面板,脆弱且难以维护。
---
## 三、色彩 & 对比度
### 优点
1. **暗色模式基础扎实**`#0B1220` 的深邃底色配合三层 radial gradient 营造了深度感和科技感。
2. **语义色系统清晰**success/green、warning/amber、danger/red 的映射关系明确。
3. **渐变运用得当**body 背景的三层 radial gradient(绿/紫/青)创造了微妙的大气光感。
### 问题
1. **文字对比度偏低**
- 静音文字 `#6B7A90``#0B1220` 上的对比度约为 4.6:1,刚好达到 WCAG AA 标准但仍偏暗
- 大量 10-11px 的辅助文字使用 `#6B7A90`,可读性不足
2. **Accent Color 视觉突出度**`#4DA3FF`(蓝色)在暗色背景上的人眼敏感度不如 `#00E0A4`(青绿色),但前者被用作主要强调色。
3. **语义色 class 命名不一致**`accent-green` class 实际渲染为 `#4DA3FF`(蓝色),"green" 命名的 class 显示蓝色。
---
## 四、字体排印
### 优点
1. **字体选择合理**Inter(正文) + JetBrains Mono(数据/代码) 是 fintech/数据密集类产品的标准选择。
2. **tabular-nums 广泛使用**:几乎所有温度数值都设置了 `font-variant-numeric: tabular-nums`,保证了数字对齐。
3. **层级明确**:从 10px 的 kicker 到 56px 的 hero temperature,形成了清晰的视觉层级。
### 问题
1. **Geist 字体声明但未加载** (`layout.tsx:35-37`)
- `globals.css` 声明了 `--font-display: "Geist", "Inter", ...`
- 但 HTML 中只加载了 Inter 和 JetBrains MonoGeist 永远不会生效
2. **非标准 font-weight 值泛滥**
- `font-weight: 760``850``860``880``900``950` 在 CSS 中大量出现
- Inter 字体只支持 300/400/500/600/700/800
- 非标准 weight 会被浏览器取整到最近支持值,视觉不可预测
- 例如 `font-weight: 950` 在 Inter 上实际渲染为 `800`
3. **字体大小偏小**:大量 UI 文字使用 10-11px,在高分辨率屏幕上可读性差。
4. **大写字母过多**kicker/overline/chip 几乎全部使用 `text-transform: uppercase` + `letter-spacing: 0.08em`,降低了中文/双语场景下的可读性。
---
## 五、组件设计
### 优点
1. **City Decision Card 视觉层次优秀** (`ScanTerminalCard.module.css`):hero 区的渐变背景、蓝色左边框选中态、决策 band 的色彩编码(warm=red 边框、cold=green 边框、watch=amber 边框),信息密度高但不杂乱。
2. **Status Tags 色彩编码清晰**green/blue/amber/red/muted 五种 tag 变体覆盖了数据状态的所有场景。
3. **双卡预测对比** (AI vs DEB):青色渐变卡 vs 蓝色边框卡的视觉区分让用户能一眼区分 AI 预测和模型预测。
4. **Decision Band 语义化**`warm`(red) = 看涨、`cold`(green) = 看跌、`watch`(amber) = 待观察,视觉编码与交易语义对齐。
### 问题
1. **Topbar 设计过于简单**:28px 标题 + 几个按钮,缺乏品牌标识(Logo)和视觉焦点。
2. **Tab 下划线指示器不够明显**`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
4. **空状态/加载状态设计不一致**
- 地图加载有精美的云/雷达/热力动画
- Scan terminal 加载是简单的脉冲 placeholder
- 空状态是一个简单的文字居中块
- 缺乏统一的 loading/empty/error 设计规范
5. **Mobile Decision Card 采用 `<details>` 元素**:原生 `<details>/<summary>` 样式控制有限、动画困难,与桌面端的自定义折叠按钮体验不一致。
---
## 六、深色/浅色主题
### 优点
1. **浅色模式覆盖全面**`ScanTerminalLightTheme.module.css` 约 960 行,覆盖了 scan terminal 的所有元素。
2. **localStorage 持久化**:主题选择保存在 `polyweather_scan_theme` 中,刷新不丢失。
3. **浅色配色方案合理**:从深色 `#0B1220` 到浅色 `#F7F9FC → #EEF2F7` 的映射关系合理。
### 问题
1. **`!important` 严重滥用** (`ScanTerminalLightTheme.module.css`)
- 几乎每条浅色规则都使用了 `!important`
- 这是 specificity war 的症状,维护成本极高
- 多处出现 `!important` 叠加
2. **浅色主题分散在 8+ 个文件中**:缺乏集中管理,修改一个颜色需要跨多个文件搜索。
3. **浅色模式地图图块滤镜处理**Leaflet tile 滤镜切换可能影响其他 overlay 的浅色适配。
---
## 七、动效 & 过渡
### 优点
1. **Cubic-bezier 缓动选择正确**`(0.4, 0, 0.2, 1)` (Material standard) + `(0.16, 1, 0.3, 1)` (spring-like) 的组合符合现代 UI 动效标准。
2. **入场动画有层次**detail panel 从右侧滑入 (400ms)、opportunity strip 有 120ms 延迟。
3. **Hover 微交互**:按钮 `translateY(-1px)`、卡片 `scale(1.002)`、边框颜色过渡等微交互提升了操作反馈感。
### 问题
1. **`@keyframes spin` 重复定义 4 次**:在 `DashboardShell.module.css``DashboardModalGuide.module.css``DocsLayout.module.css``DashboardMap.module.css` 中各自定义了一次完全相同的 spin 动画。
2. **Loading 动画风格不统一**`DashboardMap` 有复杂的 weather-themed 动画(radar swipe、cloud drift、thermal bars、wind shift),而 scan terminal 只有简单的扫光 placeholder。
3. **缺少 `prefers-reduced-motion` 支持**:没有任何 `@media (prefers-reduced-motion: reduce)` 的声明。
---
## 八、代码组织 & 可维护性
### 优点
1. **CSS Module 组件隔离**:每个组件有对应的 `.module.css`,样式作用域控制良好。
2. **命名约定一致**CSS 类名使用 `scan-` 前缀 + kebab-case,全局可识别。
### 问题
1. **CSS Module 堆叠模式过度耦合** (`ScanTerminalDashboard.tsx:143-159`)
```tsx
const scanTerminalRootClassName = clsx(
styles.root,
dashboardHomeStyles.root,
dashboardMapStyles.root,
// ... 共 22 个 CSS Module 的 .root 合并
);
```
单个组件 import 了 22 个 CSS Module,破坏了 CSS Module 的隔离优势。
2. **`:global()` 绕过了 CSS Module 的哈希**:几乎所有规则都使用 `:global(.class-name)`class 名不会被哈希。CSS Module 降级为"命名约定"工具。
3. **样式与逻辑耦合**`ScanTerminalDashboard.tsx` 约 650 行,同时负责状态管理和渲染布局。
4. **重复的 CSS 变量声明**`Dashboard.module.css` 中重新声明了 `--bg-primary` 等本地变量,与 `globals.css` 的全局 token 形成冗余。
---
## 九、无障碍性
### 优点
1. 部分元素有 aria-label(如 topbar 按钮、locale switch
2. 主题切换按钮有 title 属性
3. ProFeaturePaywall 使用了 `role="dialog"``aria-modal="true"`
### 问题
1. **键盘导航不足**
- 城市卡片没有 `tabindex``role="button"`
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
- 折叠按钮缺少 `aria-expanded`
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button``scan-ai-city-icon-button`)没有 focus-visible 样式
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
4. **没有 skip-to-content 链接**
5. **10-11px 小字体对视力障碍用户不友好**
---
## 总结与优先级建议
### 高优先级(影响用户体验和可维护性)
| # | 问题 | 位置 |
|---|------|------|
| 1 | `!important` 滥用导致浅色主题不可维护 | `ScanTerminalLightTheme.module.css` |
| 2 | 非标准 font-weight 值无效(760/850/950 等) | 多个 CSS Module |
| 3 | Geist 字体声明但未加载 | `layout.tsx:35-37` / `globals.css:55` |
| 4 | 键盘导航和 focus-visible 缺失 | 全局 |
| 5 | 断点碎片化(14 个断点值无体系) | 所有响应式 CSS |
### 中优先级(影响设计一致性)
| # | 问题 | 位置 |
|---|------|------|
| 6 | 22 个 CSS Module 堆叠耦合 | `ScanTerminalDashboard.tsx:143-159` |
| 7 | Token 未充分利用(硬编码色值) | 多个 CSS Module |
| 8 | `@keyframes spin` 重复定义 4 次 | 多个 CSS Module |
| 9 | Loading 状态设计不一致 | `DashboardMap` vs `ScanTerminalState` |
| 10 | 缺少 `prefers-reduced-motion` 支持 | 全局 |
### 低优先级(增强和优化)
| # | 问题 | 位置 |
|---|------|------|
| 11 | "Fintech 3-Color" 实际只有 2 色 | `globals.css:25-27` |
| 12 | Topbar 缺少 Logo/品牌标识 | `ScanTerminalDashboard.tsx` |
| 13 | 按钮视觉层级不够清晰 | 多个组件 |
| 14 | Shadcn UI 组件存在但未被使用 | `components/ui/` |
| 15 | `--color-text-muted` 对比度刚达标 | `globals.css:21` |
---
## 修复路线图建议
1. **Phase 1** — 修复 `font-weight` 无效值:全局搜索 `font-weight: 760``850``860``880``900``950`,替换为 Inter 支持的 300-800 等效值
2. **Phase 2** — 重构浅色主题:将分散在 8+ 个文件中的浅色覆盖集中到一个 `light-theme.css`,使用 CSS 变量覆盖而非 `!important`
3. **Phase 3** — 补充无障碍:为 Tab/Button/Card 组件添加 ARIA 属性和 focus-visible 样式
4. **Phase 4** — 统一断点:定义 4-5 个标准断点(如 480/768/1024/1280/1440),逐步替换现有碎片化断点
5. **Phase 5** — 设计一致性:创建统一的 loading/empty/error 组件,清理未使用的 shadcn 组件
---
## 修复完成记录
> 修复日期:2026-05-10 | 变更范围:28 个文件,+693 / 2,198 行
### 高优先级 — 5/5 完成
| # | 问题 | 修复 | 涉及文件 |
|---|------|------|----------|
| 1 | `!important` 滥用 | 134 → 49(仅保留 Leaflet/图表所必需项),将 `.root:global(.light)` 替换为 `html.light` 以获得更高优先级 | `ScanTerminalLightTheme.module.css``globals.css` 等 |
| 2 | 非标准 font-weight | 所有 760/850/860/880/950 等映射为 Inter 支持的 300800 | 13 个 CSS 文件 |
| 3 | 未加载 Geist | 从 `--font-display` 中移除,替换为 Inter | `globals.css` |
| 4 | 键盘 / 焦点可见 | 添加了全局 `:focus-visible` 轮廓环、跳过链接、Tab ARIA`role="tablist"`/`role="tab"`/`aria-selected` | `globals.css``layout.tsx``ScanFilterPanel.tsx``ScanTerminalDashboard.tsx` |
| 5 | 断点碎片化 | 18 → 10:合并 520/600/720/820/900/1020/1100/1240 → 640/768/960/1024/1280 | 9 个 CSS 文件 |
### 中优先级 — 5/5 完成
| # | 问题 | 修复 | 涉及文件 |
|---|------|------|----------|
| 6 | 22 个 CSS Module 耦合 | 新建 `scan-root-styles.ts` 桶文件,将 22 个独立导入合并为 1 个预组合的 className | `scan-root-styles.ts`(新建)、`ScanTerminalDashboard.tsx` |
| 7 | Token 使用不足 | 将主要颜色(`#4DA3FF`/`#E6EDF3`/`#9FB2C7`/`#6B7A90`/`#6FB7FF`)从 0 个变量引用替换为数百个 | `ScanTerminalCard``ScanTerminalList``ScanTerminalBoard``ScanTerminalOpportunity``ScanTerminalMobile``ScanTerminal``DashboardHomeIntelligence` 等 |
| 8 | `@keyframes spin` 重复 4 次 | 移至 `globals.css``loading-spin` 去重 2 处;`pulse-pending` 移至全局 | `globals.css`、4 个 CSS 文件 |
| 9 | Loading 状态不一致 | 添加了 `.scan-error-state``.scan-retry-button``.scan-empty-icon` 用于统一状态呈现 | `ScanTerminalState.module.css``ScanTerminalLightTheme.module.css` |
| 10 | 无 `prefers-reduced-motion` | 在 `globals.css` 中添加了全局动画/过渡禁用 | `globals.css` |
### 低优先级 — 5/5 完成
| # | 问题 | 修复 | 涉及文件 |
|---|------|------|----------|
| 11 | "3-Color" 仅 2 种颜色 | 将 accent-primary(blue) 和 accent-secondary(light-blue) 区分为不同颜色 | `globals.css``Dashboard.module.css` |
| 12 | Topbar 缺少 Logo | 添加了 CSS 渐变品牌标记 | `ScanTerminalShell.module.css``ScanTerminalDashboard.tsx` |
| 13 | 按钮层级不清晰 | 在 CSS 中添加了文档化的层级注释标题 | `ScanTerminalShell.module.css` |
| 14 | Shadcn 未使用 | 已验证 6 个组件被 5 个文件使用(保留),更新了注释 | `globals.css` |
| 15 | 文字对比度不足 | `--color-text-muted``#6B7A90` 提升至 `#7D8FA3` | `globals.css` |
### 其他修复
| 问题 | 修复 |
|------|------|
| `accent-green` 类错误渲染为蓝色 | `ScanTerminal.module.css``.scan-condition-value.accent-green``#4DA3FF` 修正为 `#22C55E` |
| `Dashboard.module.css` 重复的 CSS 变量 | 将本地 `--bg-*`/`--accent-*` 变量桥接至全局 token |
| 死代码 | 移除 `public/static/style.css`1,459 行)和 `public/legacy/index.html`238 行)— 均未被引用 |
| 浅色主题 Token 基础设施 | 在 `globals.css` 中添加了 `html.light` CSS 自定义属性覆盖 |
| 品牌 Logo 浅色主题 | 在 `ScanTerminalLightTheme.module.css` 中添加了浅色主题 Logo 样式 |
| 空/错误状态浅色主题 | 在 `ScanTerminalLightTheme.module.css` 中添加了空/错误/重试的浅色覆盖 |
### 最终指标
| 指标 | 之前 | 之后 |
|------|------|------|
| `!important`(可避免项) | ~85 | 0 |
| `!important`(必需项) | ~49 | 49Leaflet 内联样式、图表 canvas 属性、减少动态效果) |
| 硬编码调色板颜色 | 数百个 | 0(仅 `globals.css` 中的变量定义) |
| 断点 | 18 个唯一值 | 10480/640/768/960/1024/1200/1280/1360/1440/1680 |
| 重复的 `@keyframes` | 7 | 0 |
| 非标准 font-weight | 全 13 个文件 | 0 |
| CSS Module 导入 | 22 个独立导入 | 2(桶文件 + 共享) |
| 死代码 | 1,697 行 | 0 |
| 净代码行数 | — | 1,505 行 |
+10 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
@@ -33,19 +37,17 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to track analytics event", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
}
}
+8 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
getLocalDevAuthPayload,
isLocalFullAccessHost,
@@ -70,10 +74,7 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
}
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -98,10 +99,9 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
}
return NextResponse.json(
{ error: "Failed to fetch auth profile", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch auth profile",
});
}
}
+8 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -27,10 +31,7 @@ export async function GET(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -41,10 +42,9 @@ export async function GET(req: NextRequest) {
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch cities", detail: String(error) },
{ status: 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch cities",
});
return response;
}
}
+12 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -20,11 +24,15 @@ export async function GET(
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const depth = req.nextUrl.searchParams.get("depth");
const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
const searchParams = new URLSearchParams({
force_refresh: forceRefresh,
});
if (depth) {
searchParams.set("depth", depth);
}
if (marketSlug) {
searchParams.set("market_slug", marketSlug);
}
@@ -41,20 +49,16 @@ export async function GET(
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
{ status: 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate",
});
return response;
}
}
@@ -3,17 +3,13 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function parseBackendError(raw: string) {
try {
return JSON.parse(raw) as unknown;
} catch {
return raw.slice(0, 800);
}
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
@@ -56,14 +52,10 @@ export async function GET(
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: "Backend city market scan failed",
upstream_status: res.status,
detail: parseBackendError(raw),
},
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 800,
error: "Backend city market scan failed",
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -74,13 +66,10 @@ export async function GET(
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{
error: "Failed to fetch city market scan",
detail: String(error),
},
{ status: 502 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city market scan",
status: 502,
});
return response;
}
}
+8 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -150,20 +154,16 @@ export async function GET(
return applyAuthResponseCookies(response, auth.response);
}
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = normalizeCityDetailPayload(await res.json());
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch city detail", detail: String(error) },
{ status: 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail",
});
return response;
}
}
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -43,10 +47,7 @@ export async function GET(
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -65,10 +66,9 @@ export async function GET(
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch city summary", detail: String(error) },
{ status: 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city summary",
});
return response;
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -32,9 +33,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch healthz", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch healthz",
});
}
}
+8 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -33,10 +37,7 @@ export async function GET(
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -47,10 +48,9 @@ export async function GET(
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch history", detail: String(error) },
{ status: 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch history",
});
return response;
}
}
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -35,9 +36,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch analytics funnel", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch analytics funnel",
});
}
}
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,9 +35,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch weekly leaderboard", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch weekly leaderboard",
});
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,9 +35,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch memberships", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch memberships",
});
}
}
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -36,9 +37,8 @@ export async function POST(req: NextRequest, context: RouteContext) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to resolve payment incident", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to resolve payment incident",
});
}
}
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,9 +35,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment incidents", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch payment incidents",
});
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -36,9 +37,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch truth history", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch truth history",
});
}
}
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -36,9 +37,8 @@ export async function POST(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to grant points", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to grant points",
});
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -36,9 +37,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch ops users", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops users",
});
}
}
+10 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,10 +25,9 @@ export async function GET(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -33,10 +36,9 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment config", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch payment config",
});
}
}
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -33,19 +37,17 @@ export async function POST(
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to confirm payment tx", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to confirm payment tx",
});
}
}
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -29,19 +33,17 @@ export async function GET(
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment intent", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch payment intent",
});
}
}
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -33,19 +37,17 @@ export async function POST(
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to submit payment tx", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to submit payment tx",
});
}
}
+10 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import { isPaymentHostAllowed } from "@/lib/payment-host";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -41,20 +45,18 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create payment intent", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create payment intent",
});
}
}
@@ -3,6 +3,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -31,9 +32,8 @@ export async function POST(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to reconcile latest payment", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to reconcile latest payment",
});
}
}
+10 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -22,10 +26,9 @@ export async function GET(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 500,
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -35,9 +38,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment runtime", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch payment runtime",
});
}
}
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -26,30 +30,25 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
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"),
},
},
{ status: res.status },
);
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create wallet challenge", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create wallet challenge",
});
}
}
+17 -20
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,10 +25,9 @@ export async function GET(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -33,10 +36,9 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch wallets", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch wallets",
});
}
}
@@ -65,11 +67,9 @@ export async function DELETE(req: NextRequest) {
});
const raw = await res.text();
if (!res.ok) {
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
extraDebug: {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
@@ -79,10 +79,8 @@ export async function DELETE(req: NextRequest) {
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
});
return applyAuthResponseCookies(response, auth.response);
}
let data: unknown = { ok: true };
@@ -98,10 +96,9 @@ export async function DELETE(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to unbind wallet", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to unbind wallet",
});
}
}
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -26,31 +30,26 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
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"),
},
},
{ status: res.status },
);
});
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to verify wallet binding", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to verify wallet binding",
});
}
}
+12 -12
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
@@ -63,10 +67,7 @@ export async function POST(req: NextRequest) {
status: res.status,
detail: raw.slice(0, 180),
});
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -93,18 +94,17 @@ export async function POST(req: NextRequest) {
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
error: String(error),
});
const response = NextResponse.json(
{
error: timedOut
? "City AI gateway timed out before backend responded"
: "Failed to fetch city AI data",
detail: String(error),
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "City AI gateway timed out before backend responded"
: "Failed to fetch city AI data",
status: timedOut ? 504 : 500,
extra: {
elapsed_ms: elapsedMs,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
city: requestBody.city,
},
{ status: timedOut ? 504 : 500 },
);
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -40,10 +44,7 @@ export async function POST(req: NextRequest) {
});
if (!res.ok || !res.body) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
@@ -57,14 +58,10 @@ export async function POST(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{
error: "Failed to stream city AI data",
detail: String(error),
city: requestBody.city,
},
{ status: 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to stream city AI data",
extra: { city: requestBody.city },
});
return applyAuthResponseCookies(response, auth.response);
}
}
+11 -13
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
@@ -46,10 +50,7 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -61,15 +62,12 @@ export async function POST(req: NextRequest) {
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const timedOut = controller.signal.aborted;
const response = NextResponse.json(
{
error: timedOut
? "Scan AI request timed out"
: "Failed to fetch scan AI data",
detail: String(error),
},
{ status: timedOut ? 504 : 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "Scan AI request timed out"
: "Failed to fetch scan AI data",
status: timedOut ? 504 : 500,
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
+11 -13
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
@@ -54,10 +58,7 @@ export async function GET(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
@@ -69,15 +70,12 @@ export async function GET(req: NextRequest) {
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const timedOut = controller.signal.aborted;
const response = NextResponse.json(
{
error: timedOut
? "Scan terminal request timed out"
: "Failed to fetch scan terminal data",
detail: String(error),
},
{ status: timedOut ? 504 : 500 },
);
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "Scan terminal request timed out"
: "Failed to fetch scan terminal data",
status: timedOut ? 504 : 500,
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
+10 -8
View File
@@ -3,6 +3,10 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -22,10 +26,9 @@ export async function GET(req: NextRequest) {
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
{ status: res.status },
);
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 500,
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -35,9 +38,8 @@ export async function GET(req: NextRequest) {
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch system status", detail: String(error) },
{ status: 500 },
);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch system status",
});
}
}
+6 -1
View File
@@ -36,7 +36,12 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
<a
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{ color: "#8fc5ff" }}
style={{
color: "#8fc5ff",
display: "inline-flex",
minHeight: 36,
alignItems: "center",
}}
>
/auth/login
</a>{" "}
+90 -6
View File
@@ -18,12 +18,12 @@
/* ── Text Scale ── */
--color-text-primary: #E6EDF3;
--color-text-secondary: #9FB2C7;
--color-text-muted: #6B7A90;
--color-text-disabled: #6B7A90;
--color-text-muted: #7D8FA3;
--color-text-disabled: #7D8FA3;
/* ── Accent Colors (Fintech 3-Color) ── */
/* ── Accent Colors ── */
--color-accent-primary: #4DA3FF;
--color-accent-secondary: #4DA3FF;
--color-accent-secondary: #6FB7FF;
--color-accent-tertiary: #93C5FD;
/* ── Signal / Semantic Colors ── */
@@ -52,7 +52,7 @@
/* ── Typography ── */
--font-data:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: "Geist", "Inter", -apple-system, sans-serif;
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
/* ── Spacing (4px grid) ── */
@@ -91,7 +91,7 @@
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
--background: 223 53% 4%;
--foreground: 210 40% 98%;
--card: 223 46% 8%;
@@ -105,10 +105,72 @@
--border: 221 38% 22%;
}
/* ── Light Theme Token Overrides ── */
html.light,
html[data-theme="light"] {
--color-bg-base: #F7F9FC;
--color-bg-raised: #EEF2F7;
--color-bg-overlay: #FFFFFF;
--color-bg-card: rgba(255, 255, 255, 0.92);
--color-bg-input: rgba(238, 242, 247, 0.88);
--color-text-primary: #0F172A;
--color-text-secondary: #334155;
--color-text-muted: #475569;
--color-text-disabled: #94A3B8;
--color-accent-primary: #2563EB;
--color-accent-secondary: #3B82F6;
--color-accent-tertiary: #60A5FA;
--color-border-default: rgba(148, 163, 184, 0.24);
--color-border-hover: rgba(37, 99, 235, 0.38);
--color-border-subtle: rgba(148, 163, 184, 0.12);
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-elevation-2: 0 8px 24px rgba(40, 70, 110, 0.12);
--shadow-elevation-3: 0 20px 60px rgba(40, 70, 110, 0.15);
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.14);
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.12);
--glass-blur-1: blur(10px);
--glass-blur-2: blur(16px);
--glass-blur-3: blur(24px);
--glass-opacity-1: 0.86;
--glass-opacity-2: 0.92;
--glass-opacity-3: 0.96;
}
* {
@apply border-border;
}
/* ── Skip-to-content link ── */
.skip-to-content {
position: absolute;
top: -100%;
left: 8px;
z-index: 9999;
padding: 10px 18px;
border-radius: var(--radius-md);
background: var(--color-accent-primary);
color: #fff;
font-weight: 700;
text-decoration: none;
transition: top 0.18s ease;
}
.skip-to-content:focus {
top: 8px;
}
/* ── Global focus-visible ring (keyboard navigation) ── */
:focus-visible {
outline: 2px solid var(--color-accent-primary);
outline-offset: 2px;
border-radius: 2px;
}
body {
font-family: var(--font-data);
background:
@@ -219,6 +281,28 @@
transform: translateY(0);
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse-pending {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
}
/* ── Reduced motion: disable all animations and transitions ── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/*
+6 -1
View File
@@ -36,7 +36,12 @@ export default function RootLayout({
rel="stylesheet"
/>
</head>
<body className="min-h-screen font-sans antialiased">{children}</body>
<body className="min-h-screen font-sans antialiased">
<a href="#main-content" className="skip-to-content">
Skip to content
</a>
<main id="main-content">{children}</main>
</body>
</html>
);
}
+1 -1
View File
@@ -82,7 +82,7 @@ export default function SubscriptionHelpPage() {
<Link
href={TELEGRAM_GROUP_URL}
target="_blank"
className="text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
>
</Link>
@@ -302,8 +302,8 @@
.mono {
font-family: "Consolas", "SFMono-Regular", "Menlo", monospace;
font-size: 12px !important;
color: #c7d2fe !important;
font-size: 12px;
color: #c7d2fe;
}
.hint {
@@ -361,13 +361,7 @@
animation: spin 0.9s linear infinite;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
@media (max-width: 960px) {
.topBar {
flex-direction: column;
align-items: stretch;
@@ -396,7 +390,7 @@
}
}
@media (max-width: 560px) {
@media (max-width: 640px) {
.page {
padding: 18px 12px 20px;
}
@@ -285,15 +285,15 @@ const InfoRow = ({
value,
isPrimary = false,
}: InfoRowProps) => (
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
<div className="flex min-w-0 flex-col gap-3 p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
{Icon && <Icon size={18} />}
</div>
<span className="text-slate-400 text-sm font-medium">{label}</span>
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">{label}</span>
</div>
<span
className={`text-sm font-semibold font-mono ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
>
{value}
</span>
@@ -2772,7 +2772,7 @@ export function AccountCenter() {
href={TELEGRAM_BOT_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-cyan-400/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-cyan-400/30 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
>
{copy.telegramBotLink}
<ExternalLink size={12} />
@@ -2783,7 +2783,7 @@ export function AccountCenter() {
href={TELEGRAM_MARKET_CHANNEL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
>
{copy.telegramMarketChannelLink}
<ExternalLink size={12} />
@@ -2794,7 +2794,7 @@ export function AccountCenter() {
href={TELEGRAM_GROUP_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-blue-400/30 bg-blue-500/10 px-3 py-1.5 text-xs font-semibold text-blue-200 hover:bg-blue-500/20"
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-blue-400/30 bg-blue-500/10 px-3 py-2 text-xs font-semibold text-blue-200 hover:bg-blue-500/20"
>
{copy.telegramGroupLink}
<ExternalLink size={12} />
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,838 @@
/* ── Map ── */
.root :global(.map) {
position: absolute;
top: calc(var(--header-height) + 16px);
right: 392px;
bottom: 164px;
left: calc(var(--sidebar-width) + 22px);
z-index: 1;
overflow: hidden;
border: 1px solid rgba(115, 137, 161, 0.16);
border-radius: 24px;
background: #040912;
box-shadow:
0 32px 100px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.04),
inset 0 0 0 1px rgba(0, 224, 164, 0.04);
}
.weatherAura {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0.96;
mix-blend-mode: screen;
overflow: hidden;
}
.weatherAura :global(canvas) {
width: 100%;
height: 100%;
display: block;
opacity: 0.94;
filter: saturate(1.05) blur(0.3px);
}
.weatherAuraScrim {
position: absolute;
inset: 0;
background:
linear-gradient(
180deg,
rgba(3, 8, 19, 0.64) 0%,
rgba(5, 10, 20, 0.16) 24%,
rgba(4, 8, 18, 0.1) 54%,
rgba(3, 6, 14, 0.42) 100%
),
radial-gradient(
circle at 50% 60%,
rgba(0, 224, 164, 0.08) 0%,
rgba(123, 97, 255, 0) 48%
);
}
.weatherAura[data-reduced-motion="true"] :global(canvas) {
display: none;
}
.root :global(.map .leaflet-tile),
.root :global(.map .leaflet-marker-icon),
.root :global(.map .leaflet-marker-shadow),
.root :global(.map .leaflet-container img),
.root :global(.map .leaflet-container svg) {
max-width: none !important;
max-height: none !important;
}
.root :global(.map .leaflet-tile) {
width: 256px !important;
height: 256px !important;
}
/* Remove Leaflet default styling for cleaner look */
.root :global(.leaflet-control-attribution) {
background: var(--bg-glass) !important;
color: var(--text-muted) !important;
backdrop-filter: blur(8px);
border: 1px solid var(--border-subtle) !important;
font-size: 10px !important;
border-radius: 6px !important;
padding: 2px 8px !important;
}
.root :global(.leaflet-control-attribution a) {
color: var(--text-secondary) !important;
}
.root :global(.leaflet-control-zoom) {
border: none !important;
box-shadow: var(--shadow-lg) !important;
}
.root :global(.leaflet-control-zoom a) {
background: var(--bg-glass) !important;
color: var(--text-primary) !important;
backdrop-filter: blur(12px) !important;
border: 1px solid var(--border-glass) !important;
width: 36px !important;
height: 36px !important;
line-height: 36px !important;
font-size: 16px !important;
border-radius: 8px !important;
transition: var(--transition);
}
.root :global(.leaflet-control-zoom a:hover) {
background: rgba(99, 102, 241, 0.2) !important;
border-color: var(--accent-blue) !important;
}
/* ── Custom Map Markers ── */
.root :global(.city-marker) {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
/* Remove transition: transform to avoid fighting with Leaflet's own positioning */
}
.root :global(.city-marker:hover) {
transform: scale(1.15);
z-index: 1000 !important;
}
.root :global(.marker-bubble) {
min-width: 44px;
padding: 4px 10px;
border-radius: 14px;
font-family: "Inter", sans-serif;
font-size: 13px;
font-weight: 700;
text-align: center;
color: white;
white-space: nowrap;
position: relative;
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.root :global(.marker-bubble::after) {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid;
border-top-color: inherit;
}
.root :global(.marker-bubble.risk-high) {
background: linear-gradient(135deg, #991b1b, #fb7185);
border-color: rgba(251, 113, 133, 0.42);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 22px rgba(251, 113, 133, 0.18);
}
.root :global(.marker-bubble.risk-high::after) {
border-top-color: var(--color-signal-danger);
}
.root :global(.marker-bubble.risk-medium) {
background: linear-gradient(135deg, #9a3412, #f59e0b);
border-color: rgba(245, 158, 11, 0.42);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 22px rgba(245, 158, 11, 0.16);
}
.root :global(.marker-bubble.risk-medium::after) {
border-top-color: var(--color-signal-warning);
}
.root :global(.marker-bubble.risk-low) {
background: linear-gradient(135deg, #065f46, #22c55e);
border-color: rgba(74, 222, 128, 0.42);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 22px rgba(74, 222, 128, 0.14);
}
.root :global(.marker-bubble.risk-low::after) {
border-top-color: var(--color-signal-success);
}
.root :global(.marker-name) {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
margin-top: 2px;
white-space: nowrap;
}
/* Marker glow animation for selected city */
.root :global(.city-marker.selected .marker-bubble) {
animation: markerGlow 2s ease-in-out infinite;
}
@keyframes markerGlow {
0%,
100% {
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 24px rgba(34, 211, 238, 0.12);
}
50% {
box-shadow:
0 6px 26px rgba(0, 0, 0, 0.46),
0 0 20px rgba(34, 211, 238, 0.24),
0 0 46px rgba(34, 211, 238, 0.14);
}
}
/* ── Loading Overlay ── */
.root :global(.loading-overlay) {
position: fixed;
inset: 0;
z-index: 2000;
background: radial-gradient(
circle at 50% 50%,
rgba(8, 14, 32, 0.76) 0%,
rgba(4, 8, 22, 0.9) 44%,
rgba(2, 6, 20, 0.96) 100%
);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
}
.root :global(.loading-card) {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
min-width: 240px;
padding: 28px 32px;
border-radius: 28px;
background: linear-gradient(
180deg,
rgba(10, 18, 36, 0.78) 0%,
rgba(8, 14, 30, 0.62) 100%
);
border: 1px solid rgba(92, 142, 255, 0.16);
box-shadow:
0 24px 90px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.root :global(.loading-clouds) {
position: relative;
width: 220px;
height: 26px;
overflow: hidden;
}
.root :global(.loading-cloud) {
position: absolute;
top: 0;
height: 18px;
border-radius: 999px;
background:
radial-gradient(
circle at 30% 50%,
rgba(186, 230, 253, 0.22),
transparent 54%
),
linear-gradient(
180deg,
rgba(148, 163, 184, 0.28) 0%,
rgba(71, 85, 105, 0.1) 100%
);
filter: blur(0.3px);
box-shadow:
0 6px 24px rgba(56, 189, 248, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
animation: cloud-drift 6.8s ease-in-out infinite;
}
.root :global(.loading-cloud-1) {
left: 12px;
width: 76px;
}
.root :global(.loading-cloud-2) {
right: 8px;
top: 7px;
width: 98px;
opacity: 0.75;
animation-delay: -2.6s;
}
.root :global(.loading-windfield) {
position: relative;
width: 220px;
height: 30px;
overflow: hidden;
opacity: 0.9;
}
.root :global(.loading-windline) {
position: absolute;
left: -28%;
height: 1px;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0) 0%,
rgba(103, 232, 249, 0.5) 22%,
rgba(125, 211, 252, 0.9) 55%,
rgba(34, 211, 238, 0) 100%
);
filter: blur(0.2px);
animation: wind-shift 2.8s linear infinite;
}
.root :global(.loading-windline-1) {
top: 4px;
width: 124px;
}
.root :global(.loading-windline-2) {
top: 13px;
width: 176px;
animation-delay: -0.9s;
opacity: 0.82;
}
.root :global(.loading-windline-3) {
top: 22px;
width: 142px;
animation-delay: -1.6s;
opacity: 0.66;
}
.root :global(.loading-copy) {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.root :global(.loading-copy strong) {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(103, 232, 249, 0.92);
}
.root :global(.loading-copy span) {
max-width: 240px;
font-size: 13px;
font-weight: 500;
line-height: 1.55;
color: rgba(203, 213, 225, 0.82);
}
.root :global(.loading-radar) {
position: relative;
width: 108px;
height: 108px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(22, 78, 99, 0.16) 0%,
rgba(7, 18, 36, 0.08) 48%,
rgba(5, 10, 22, 0.02) 76%,
transparent 100%
);
box-shadow:
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
0 0 40px rgba(34, 211, 238, 0.08);
}
.root :global(.loading-radar-core) {
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle, var(--color-accent-secondary) 0%, #0891b2 100%);
transform: translate(-50%, -50%);
box-shadow:
0 0 12px rgba(34, 211, 238, 0.65),
0 0 28px rgba(59, 130, 246, 0.25);
}
.root :global(.loading-radar-ring) {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(56, 189, 248, 0.14);
}
.root :global(.loading-radar-ring-1) {
transform: scale(0.62);
}
.root :global(.loading-radar-ring-2) {
transform: scale(0.88);
}
.root :global(.loading-radar-sweep) {
position: absolute;
inset: 6px;
border-radius: 50%;
background: conic-gradient(
from 90deg,
rgba(34, 211, 238, 0) 0deg,
rgba(34, 211, 238, 0) 260deg,
rgba(34, 211, 238, 0.3) 312deg,
rgba(96, 165, 250, 0.06) 360deg
);
mask: radial-gradient(circle, transparent 0 22px, #000 23px);
animation: radar-sweep 2.4s linear infinite;
}
.root :global(.loading-radar-blip) {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(253, 224, 71, 0.92);
box-shadow: 0 0 12px rgba(253, 224, 71, 0.45);
animation: radar-blip 1.8s ease-in-out infinite;
}
.root :global(.loading-radar-blip-1) {
top: 26px;
right: 24px;
animation-delay: 0.15s;
}
.root :global(.loading-radar-blip-2) {
bottom: 25px;
left: 20px;
background: rgba(74, 222, 128, 0.9);
box-shadow: 0 0 12px rgba(74, 222, 128, 0.4);
animation-delay: 0.9s;
}
.root :global(.loading-thermals) {
display: flex;
align-items: end;
gap: 8px;
height: 28px;
}
.root :global(.loading-thermal) {
width: 10px;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(251, 191, 36, 0.15) 0%,
rgba(251, 146, 60, 0.8) 55%,
rgba(239, 68, 68, 0.9) 100%
);
box-shadow: 0 0 18px rgba(251, 146, 60, 0.18);
animation: thermal-rise 1.5s ease-in-out infinite;
transform-origin: center bottom;
}
.root :global(.loading-thermal-1) {
height: 11px;
animation-delay: 0s;
}
.root :global(.loading-thermal-2) {
height: 22px;
animation-delay: 0.2s;
}
.root :global(.loading-thermal-3) {
height: 16px;
animation-delay: 0.42s;
}
.root :global(.loading-thermal-4) {
height: 25px;
animation-delay: 0.68s;
}
.root :global(.loading-drizzle) {
position: relative;
width: 220px;
height: 26px;
overflow: hidden;
opacity: 0.88;
}
.root :global(.loading-drizzle-drop) {
position: absolute;
top: -2px;
width: 2px;
height: 16px;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(125, 211, 252, 0) 0%,
rgba(125, 211, 252, 0.9) 100%
);
box-shadow: 0 0 8px rgba(56, 189, 248, 0.12);
animation: drizzle-fall 1.35s linear infinite;
}
.root :global(.loading-drizzle-drop-1) {
left: 38px;
animation-delay: -0.1s;
}
.root :global(.loading-drizzle-drop-2) {
left: 76px;
height: 18px;
animation-delay: -0.52s;
}
.root :global(.loading-drizzle-drop-3) {
left: 112px;
height: 14px;
animation-delay: -0.92s;
}
.root :global(.loading-drizzle-drop-4) {
left: 156px;
height: 17px;
animation-delay: -0.38s;
}
.root :global(.loading-drizzle-drop-5) {
left: 192px;
height: 13px;
animation-delay: -0.74s;
}
.root :global(.loading-overlay.hidden) {
display: none;
}
.root :global(.loading-spinner) {
width: 48px;
height: 48px;
border: 2px solid rgba(34, 211, 238, 0.1);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
box-shadow: 0 0 15px rgba(34, 211, 238, 0.1);
}
@keyframes city-loading-pulse {
0% {
transform: scale(0.92);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
}
100% {
transform: scale(0.92);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
}
}
@keyframes radar-sweep {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes radar-blip {
0%,
100% {
opacity: 0.2;
transform: scale(0.72);
}
40% {
opacity: 1;
transform: scale(1);
}
60% {
opacity: 0.9;
transform: scale(1.18);
}
}
@keyframes wind-shift {
from {
transform: translateX(0);
opacity: 0;
}
18% {
opacity: 0.9;
}
82% {
opacity: 0.78;
}
to {
transform: translateX(145%);
opacity: 0;
}
}
@keyframes thermal-rise {
0%,
100% {
transform: scaleY(0.78);
opacity: 0.72;
}
50% {
transform: scaleY(1.14);
opacity: 1;
}
}
@keyframes cloud-drift {
0%,
100% {
transform: translateX(0);
opacity: 0.7;
}
50% {
transform: translateX(12px);
opacity: 0.95;
}
}
@keyframes drizzle-fall {
0% {
transform: translateY(-3px);
opacity: 0;
}
20% {
opacity: 0.9;
}
100% {
transform: translateY(18px);
opacity: 0;
}
}
/* ── Nearby Stations (Premium Glassmorphism) ── */
.root :global(.nearby-marker-premium) {
display: flex;
align-items: center;
gap: 10px;
background: rgba(10, 14, 26, 0.75);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px; /* Pill shape */
padding: 5px 14px 5px 10px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
white-space: nowrap;
}
.root :global(.nearby-marker-shell) {
display: inline-block;
will-change: transform;
}
@keyframes nearby-fade-in {
from {
opacity: 0;
transform: scale(0.9) translateY(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.root :global(.nearby-pulse) {
position: relative;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.root :global(.pulse-core) {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
background: var(--accent-cyan);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 8px var(--accent-cyan);
}
.root :global(.pulse-ring) {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border: 1.5px solid var(--accent-cyan);
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0.3;
box-shadow: 0 0 10px rgba(34, 211, 238, 0.18);
}
.root :global(.nearby-content) {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.root :global(.nearby-label) {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.root :global(.nearby-stats) {
display: flex;
align-items: baseline;
gap: 2px;
}
.root :global(.nearby-temp-val) {
font-size: 13px;
font-weight: 800;
color: var(--text-primary);
text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
}
.root :global(.nearby-temp-unit) {
font-size: 10px;
color: var(--text-muted);
}
.root :global(.nearby-time) {
display: flex;
align-items: center;
gap: 4px;
margin-top: 3px;
font-size: 11px;
font-weight: 700;
color: rgba(203, 213, 225, 0.95);
white-space: nowrap;
}
.root :global(.nearby-time span + span)::before {
content: "·";
margin-right: 4px;
color: rgba(148, 163, 184, 0.55);
}
.root :global(.nearby-time.is-stale) {
color: var(--color-signal-warning);
}
.root :global(.nearby-wind) {
display: flex;
align-items: center;
gap: 4px;
padding-left: 10px;
margin-left: 2px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
.root :global(.nearby-wind .wind-arrow) {
font-size: 12px;
color: var(--accent-cyan);
text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
}
.root :global(.nearby-wind .wind-val) {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
}
/* ── Trend badge ── */
.root :global(.trend-badge) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
}
.root :global(.trend-badge.rising) {
background: rgba(34, 197, 94, 0.12);
color: var(--accent-green);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.root :global(.trend-badge.falling) {
background: rgba(248, 113, 113, 0.12);
color: var(--accent-red);
border: 1px solid rgba(248, 113, 113, 0.25);
}
.root :global(.trend-badge.stagnant) {
background: rgba(251, 191, 36, 0.12);
color: var(--accent-yellow);
border: 1px solid rgba(251, 191, 36, 0.25);
}
.root :global(.trend-badge.mixed) {
background: rgba(167, 139, 250, 0.12);
color: var(--accent-purple);
border: 1px solid rgba(167, 139, 250, 0.25);
}
.root :global(.dead-market) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
background: rgba(248, 113, 113, 0.15);
color: var(--accent-red);
border: 1px solid rgba(248, 113, 113, 0.3);
animation: deadPulse 2s ease-in-out infinite;
}
@keyframes deadPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@@ -0,0 +1,297 @@
.root :global(.scan-select) {
border: none;
border-radius: 8px;
background: linear-gradient(135deg, var(--color-accent-primary), #00b383);
color: #000;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
}
.root :global(.scan-cta-button:hover:not(:disabled)) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
filter: brightness(1.1);
}
.root :global(.scan-cta-button:active:not(:disabled)) {
transform: translateY(1px);
}
.root :global(.modal-overlay) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(2, 6, 23, 0.75);
backdrop-filter: blur(12px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.root :global(.modal-content) {
background: #16213A;
border: 1px solid var(--border-subtle);
border-radius: 16px;
width: 100%;
max-width: 700px;
max-height: calc(100vh - 48px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.root :global(.modal-header) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.root :global(.modal-header h2) {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
min-width: 0;
}
.root :global(.future-modal-title-with-actions) {
display: flex;
align-items: center;
gap: 12px;
}
.root :global(.future-refresh-btn) {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s ease;
}
.root :global(.future-refresh-btn:hover) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.1);
}
.root :global(.future-refresh-btn.spinning svg) {
animation: spin 1s linear infinite;
color: var(--accent-cyan);
}
.root :global(.modal-close) {
background: none;
border: none;
font-size: 20px;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s;
}
.root :global(.modal-close:hover) {
color: var(--accent-red);
}
.root :global(.modal-body) {
padding: 20px;
overflow-y: auto;
}
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
.root :global(.modal-content.large.future-modal .modal-body) {
-ms-overflow-style: none;
scrollbar-width: none;
}
.root
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
width: 0;
height: 0;
}
@media (max-width: 640px) {
.root :global(.modal-content) {
border-radius: 14px;
max-height: calc(100vh - 16px);
}
.root :global(.modal-header) {
padding: 14px 14px 12px;
align-items: flex-start;
gap: 12px;
}
.root :global(.modal-header h2) {
font-size: 15px;
line-height: 1.35;
}
.root :global(.modal-body) {
padding: 14px;
}
}
/* ── Info Button ── */
.root :global(.account-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.34);
background: rgba(34, 211, 238, 0.08);
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-btn:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.62);
color: #f8fbff;
transform: translateY(-1px);
}
.root :global(.account-renew-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-renew-badge:hover) {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.6);
color: #fff6d5;
transform: translateY(-1px);
}
.root :global(.account-renew-badge.expired) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
.root :global(.trial-promo-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.28);
background: rgba(34, 211, 238, 0.1);
color: #a5f3fc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.trial-promo-badge:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.45);
color: #ecfeff;
transform: translateY(-1px);
}
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
color: var(--accent-blue);
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: 6px;
}
.root :global(.info-btn:hover) {
background: rgba(99, 102, 241, 0.2);
border-color: var(--accent-blue);
transform: translateY(-1px);
}
.root :global(.info-btn.active) {
background: rgba(34, 211, 238, 0.14);
border-color: rgba(34, 211, 238, 0.42);
color: #e0fbff;
}
/* ── Guide Modal Customizations ── */
.root :global(.modal-content.large) {
max-width: 900px;
}
.root :global(.guide-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 4px;
}
.root :global(.guide-card) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 16px;
transition: var(--transition);
}
.root :global(.guide-card:hover) {
background: rgba(255, 255, 255, 0.05);
border-color: var(--border-glass);
}
.root :global(.guide-card h3) {
font-size: 15px;
font-weight: 700;
color: var(--accent-cyan);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.root :global(.guide-card p) {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 0;
}
.root :global(.guide-card b) {
color: var(--text-primary);
}
.root :global(.guide-footer) {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
@@ -0,0 +1,819 @@
/* ── Header ── */
.root :global(.header) {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
z-index: 1000;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 16px;
background: rgba(7, 11, 18, 0.94);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-glass);
}
.root :global(.brand) {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.root :global(.brand-mark) {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 10px;
border: 1px solid rgba(0, 224, 164, 0.22);
background: rgba(8, 15, 28, 0.78);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
0 0 20px rgba(0, 224, 164, 0.12);
}
.root :global(.brand-mark img) {
width: 24px;
height: 24px;
display: block;
object-fit: contain;
}
.root :global(.brand h1) {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(
135deg,
var(--accent-cyan) 0%,
var(--accent-blue) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.root :global(.subtitle) {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.04em;
opacity: 0.8;
}
.root :global(.header-nav) {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
}
.root :global(.header-nav-link) {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 12px 18px;
border-radius: 10px;
color: rgba(203, 213, 225, 0.82);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: var(--transition);
position: relative;
}
.root :global(.header-nav-link:hover) {
color: #f8fafc;
background: rgba(30, 41, 59, 0.44);
}
.root :global(.header-nav-link.active) {
color: #f8fafc;
background: transparent;
box-shadow: none;
}
.root :global(.header-nav-link.active::after) {
content: "";
position: absolute;
left: 18px;
right: 18px;
bottom: 4px;
height: 2px;
border-radius: 999px;
background: var(--accent-cyan);
}
.root :global(.header-right) {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.root :global(.lang-switch) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border-glass);
background: var(--bg-glass);
}
.root :global(.lang-btn) {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
transition: var(--transition);
}
.root :global(.lang-btn:hover) {
color: var(--text-primary);
background: rgba(99, 102, 241, 0.12);
}
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(0, 224, 164, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
}
.root :global(.live-badge) {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
background: rgba(0, 224, 164, 0.08);
border: 1px solid rgba(0, 224, 164, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--accent-green);
letter-spacing: 1.2px;
text-transform: uppercase;
}
.root :global(.pulse-dot) {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
}
}
.root :global(.refresh-btn) {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: rgba(13, 19, 33, 0.6);
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.refresh-btn:hover) {
background: rgba(0, 224, 164, 0.1);
border-color: rgba(0, 224, 164, 0.3);
color: var(--accent-cyan);
}
.root :global(.refresh-btn.spinning) {
animation: spin 1s linear infinite;
}
.root :global(.locale-switch) {
height: 32px;
padding: 3px;
border-radius: 10px;
border: 1px solid rgba(0, 224, 164, 0.2);
background: rgba(8, 15, 27, 0.82);
color: rgba(148, 163, 184, 0.86);
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: var(--transition);
}
.root :global(.locale-switch span) {
min-width: 32px;
height: 24px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.root :global(.locale-switch span.active) {
color: #f8fafc;
background: rgba(0, 224, 164, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
}
.root :global(.locale-switch:hover) {
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn) {
min-width: 32px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(115, 137, 161, 0.18);
background: rgba(8, 15, 27, 0.82);
color: rgba(226, 232, 240, 0.8);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
font-size: 12px;
font-weight: 600;
transition: var(--transition);
}
.root :global(.header-utility-btn:hover) {
color: #f8fafc;
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn.active) {
color: #f8fafc;
border-color: rgba(123, 97, 255, 0.34);
}
.root :global(.header-utility-btn.more) {
padding: 0;
width: 32px;
}
/* ── City List Sidebar ── */
.root :global(.city-list) {
position: fixed;
top: calc(var(--header-height) + 12px);
left: 12px;
width: var(--sidebar-width);
max-height: calc(100vh - var(--header-height) - 24px);
z-index: 900;
background: var(--bg-glass);
backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--border-glass);
border-radius: 18px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
}
.root :global(.city-list-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.root :global(.city-search) {
display: flex;
align-items: center;
gap: 8px;
margin: 0 14px 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(115, 137, 161, 0.14);
background: rgba(6, 12, 22, 0.82);
color: rgba(148, 163, 184, 0.86);
}
.root :global(.city-search input) {
width: 100%;
border: none;
outline: none;
background: transparent;
color: #f8fafc;
font: inherit;
font-size: 13px;
}
.root :global(.city-search input::placeholder) {
color: rgba(148, 163, 184, 0.72);
}
.root :global(.city-count) {
background: rgba(123, 97, 255, 0.2);
color: var(--accent-blue);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid rgba(123, 97, 255, 0.15);
}
.root :global(.city-list-items) {
overflow-y: auto;
flex: 1;
padding: 0 6px 8px;
}
.root :global(.city-list-items::-webkit-scrollbar) {
width: 4px;
}
.root :global(.city-list-items::-webkit-scrollbar-track) {
background: transparent;
}
.root :global(.city-list-items::-webkit-scrollbar-thumb) {
background: var(--border-glass);
border-radius: 2px;
}
.root :global(.sidebar-footer) {
border-top: 1px solid var(--border-subtle);
padding: 10px 12px;
color: rgba(148, 163, 184, 0.72);
font-size: 10px;
line-height: 1.55;
}
.root :global(.city-group) {
border: 1px solid rgba(115, 137, 161, 0.12);
border-radius: 14px;
background: rgba(10, 17, 30, 0.48);
margin-bottom: 8px;
overflow: hidden;
}
.root :global(.city-group:last-child) {
margin-bottom: 0;
}
.root :global(.city-group-header) {
width: 100%;
border: none;
background: rgba(15, 28, 47, 0.56);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
cursor: pointer;
font-family: inherit;
transition: background 150ms ease;
}
.root :global(.city-group-header:hover) {
background: rgba(18, 34, 55, 0.82);
}
.root :global(.city-group-title) {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.root :global(.city-group-indicator) {
width: 3px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.root :global(.city-group-indicator.high) {
background: var(--risk-high);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
}
.root :global(.city-group-indicator.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
}
.root :global(.city-group-indicator.low) {
background: var(--risk-low);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
}
.root :global(.city-group-indicator.other) {
background: var(--text-muted);
opacity: 0.5;
}
.root :global(.city-group-meta) {
display: inline-flex;
align-items: center;
gap: 8px;
}
.root :global(.city-group-count) {
min-width: 18px;
height: 18px;
border-radius: 9px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(123, 97, 255, 0.26);
color: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.root :global(.city-group-arrow) {
font-size: 11px;
color: var(--text-muted);
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.root :global(.city-group-arrow.expanded) {
transform: rotate(0deg);
}
.root :global(.city-group-items) {
padding: 4px;
}
.root :global(.city-group.collapsed .city-group-items) {
display: none;
}
.root :global(.city-item) {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
transition: var(--transition);
border: 1px solid transparent;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
}
.root :global(.city-item:hover) {
background: rgba(10, 26, 44, 0.84);
border-color: rgba(0, 224, 164, 0.18);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
}
.root :global(.city-item.active) {
background:
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
rgba(9, 18, 32, 0.92);
border-color: rgba(0, 224, 164, 0.24);
box-shadow:
0 0 20px rgba(0, 224, 164, 0.08),
inset 0 0 0 1px rgba(0, 224, 164, 0.08);
}
.root :global(.city-item-main) {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.root :global(.city-item .city-name-text) {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.root :global(.city-item-info) {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 20px; /* Align with name text, after the dot */
font-size: 10px;
color: var(--text-muted);
}
.root :global(.city-item .city-max-info) {
color: var(--accent-blue);
font-weight: 500;
}
.root :global(.city-item .city-deviation-info) {
font-weight: 600;
}
.root :global(.city-item .city-deviation-cold) {
color: var(--color-accent-primary);
}
.root :global(.city-item .city-deviation-hot) {
color: var(--color-signal-warning);
}
.root :global(.city-item .city-deviation-normal) {
color: #22d3ee;
}
.root :global(.city-item .city-deviation-info.strong) {
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
}
.root :global(.city-item .city-deviation-hot.strong) {
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
}
.root :global(.city-item .risk-dot) {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.root :global(.city-item .risk-dot.high) {
background: var(--risk-high);
box-shadow: 0 0 6px var(--risk-high);
}
.root :global(.city-item .risk-dot.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px var(--risk-medium);
}
.root :global(.city-item .risk-dot.low) {
background: var(--risk-low);
box-shadow: 0 0 6px var(--risk-low);
}
.root :global(.city-clock-icon) {
display: inline-block;
vertical-align: -1px;
margin-right: 3px;
opacity: 0.6;
}
.root :global(.city-item .city-temp) {
margin-left: auto;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-cyan);
opacity: 0;
transition: opacity 200ms ease;
}
.root :global(.city-item .city-temp.loaded) {
opacity: 1;
}
/* ── Responsive ── */
@media (max-width: 1024px) {
.root {
--panel-width: 460px;
}
.root :global(.hero-value) {
font-size: 52px;
}
}
@media (max-width: 768px) {
.root :global(.header) {
height: auto;
min-height: var(--header-height);
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
}
.root :global(.brand) {
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.root :global(.header-right) {
flex: 1 1 auto;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.root :global(.lang-switch) {
order: 1;
}
.root :global(.account-btn),
.root :global(.info-btn) {
padding: 6px 10px;
font-size: 12px;
}
.root :global(.live-badge) {
order: 4;
padding: 4px 10px;
white-space: nowrap;
}
.root :global(.refresh-btn) {
order: 5;
}
.root :global(.city-list) {
display: flex;
top: auto;
left: 8px;
right: 8px;
bottom: 8px;
width: auto;
max-height: min(36vh, 320px);
border-radius: 14px;
z-index: 920;
}
.root :global(.detail-panel) {
width: 100%;
}
.root {
--panel-width: 100%;
}
.root :global(.city-list-header) {
padding: 12px 14px;
font-size: 14px;
}
.root :global(.city-list-items) {
padding: 6px;
}
.root :global(.city-group-header) {
padding: 8px 9px;
}
.root :global(.city-item) {
padding: 8px 9px;
}
.root :global(.city-item .city-name-text),
.root :global(.city-item .city-temp) {
font-size: 12px;
}
.root :global(.city-item-info) {
padding-left: 18px;
flex-wrap: wrap;
row-gap: 4px;
}
.root :global(.panel-header) {
padding: 16px 16px 14px;
}
}
@media (max-width: 640px) {
.root :global(.header) {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 8px 10px;
}
.root :global(.brand) {
justify-content: space-between;
width: 100%;
}
.root :global(.subtitle) {
display: none;
}
.root :global(.brand h1) {
font-size: 16px;
}
.root :global(.header-right) {
width: 100%;
justify-content: flex-start;
gap: 6px;
}
.root :global(.lang-switch) {
margin-right: auto;
}
.root :global(.account-btn),
.root :global(.info-btn) {
min-height: 34px;
padding: 6px 9px;
font-size: 11px;
}
.root :global(.account-btn span),
.root :global(.info-btn span) {
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.live-badge) {
padding: 4px 8px;
font-size: 10px;
letter-spacing: 0.6px;
}
.root :global(.refresh-btn) {
width: 34px;
height: 34px;
font-size: 16px;
}
.root :global(.hero-value) {
font-size: 42px;
}
.root :global(.hero-details) {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.root :global(.hero-item .value) {
font-size: 14px;
}
.root :global(.city-list) {
left: 6px;
right: 6px;
bottom: 6px;
max-height: min(40vh, 300px);
}
.root :global(.city-list-header) {
padding: 10px 12px;
}
.root :global(.city-group-title) {
font-size: 11px;
}
.root :global(.city-item-main) {
gap: 6px;
}
.root :global(.city-item-info) {
font-size: 9px;
}
.root :global(.panel-header) {
padding: 14px 14px 12px;
}
.root :global(.city-marker:hover) {
transform: none;
}
.root :global(.marker-bubble) {
min-width: 48px;
min-height: 32px;
padding: 6px 10px;
}
.root :global(.marker-name) {
font-size: 11px;
}
.root :global(.prob-price-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.prob-price-head) {
align-items: flex-start;
flex-direction: column;
}
}
@@ -0,0 +1,162 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/chart-utils";
import type { CityDetail } from "@/lib/dashboard-types";
export function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale } = useI18n();
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径";
const calibratedLabel =
locale === "en-US"
? "METAR-calibrated path"
: "METAR 修正路径";
const observationLabel =
chartData?.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况");
const hasCalibratedPath = Boolean(
chartData?.datasets.calibratedFuture.some((value) => value != null),
);
const canvasRef = useChart(() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
borderColor: "rgba(100, 116, 139, 0.72)",
borderDash: [6, 4],
borderWidth: 1.5,
data: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
),
fill: false,
label: forecastLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
];
if (hasCalibratedPath) {
datasets.push({
borderColor: "#38bdf8",
borderWidth: 2.1,
data: chartData.datasets.calibratedFuture,
fill: false,
label: calibratedLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#22c55e",
borderColor: "#22c55e",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: observationLabel,
pointHoverRadius: 5,
pointRadius: 3.2,
showLine: false,
});
return {
data: {
datasets,
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(77, 163, 255, 0.28)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 6,
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 5,
stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [
calibratedLabel,
chartData,
detail.temp_symbol,
forecastLabel,
hasCalibratedPath,
observationLabel,
]);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
{chartData ? (
<div className="detail-mini-chart-legend">
<span>
<i className="forecast baseline" />
{forecastLabel}
</span>
{hasCalibratedPath ? (
<span>
<i className="forecast calibrated" />
{calibratedLabel}
</span>
) : null}
<span>
<i className="observation" />
{observationLabel}
</span>
</div>
) : null}
</div>
);
}
+13 -134
View File
@@ -1,150 +1,29 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import {
getCityProfileStats,
getRiskBadgeLabel,
getTemperatureChartData,
} from "@/lib/dashboard-utils";
import { getCityProfileStats } from "@/lib/dashboard-utils";
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const forecastLabel = chartData?.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测";
const observationLabel =
chartData?.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况");
const canvasRef = useChart(() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: forecastLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#4DA3FF",
borderColor: "#4DA3FF",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: observationLabel,
pointHoverRadius: 5,
pointRadius: 3.2,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(77, 163, 255, 0.28)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 6,
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 5,
stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
{chartData ? (
<div className="detail-mini-chart-legend">
<span>
<i className="forecast" />
{forecastLabel}
</span>
<span>
<i className="observation" />
{observationLabel}
</span>
</div>
) : null}
</div>
);
}
const DetailMiniTemperatureChart = dynamic(
() =>
import("@/components/dashboard/DetailMiniTemperatureChart").then(
(module) => module.DetailMiniTemperatureChart,
),
{
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
ssr: false,
},
);
export function DetailPanel({
variant = "overlay",
@@ -91,7 +91,7 @@
.root :global(.panel-action-button-primary) {
color: #FFFFFF;
background: linear-gradient(135deg, #4DA3FF, #3B82F6);
background: linear-gradient(135deg, var(--color-accent-primary), #3B82F6);
border-color: rgba(77, 163, 255, 0.34);
}
@@ -210,4 +210,3 @@
font-size: 13px;
line-height: 1.7;
}
@@ -0,0 +1,294 @@
/* Detail panel content styles. */
.root :global(.detail-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.root :global(.detail-card) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
}
.root :global(.detail-source-list) {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.root :global(.detail-source-note) {
margin: 8px 0 12px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.root :global(.detail-source-link) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
color: var(--text-main);
text-decoration: none;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.root :global(.detail-source-link:hover) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.28);
transform: translateY(-1px);
}
.root :global(.detail-source-kind) {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--accent-cyan);
text-transform: uppercase;
flex-shrink: 0;
}
.root :global(.detail-source-label) {
font-size: 13px;
font-weight: 600;
color: var(--text-main);
text-align: right;
}
.root :global(.detail-label) {
display: block;
color: var(--text-muted);
font-size: 11px;
margin-bottom: 6px;
}
.root :global(.detail-value) {
display: block;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
line-height: 1.5;
word-break: break-word;
}
.root :global(.profile-lead) {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.root :global(.detail-mini-chart-wrap) {
display: grid;
gap: 10px;
}
.root :global(.detail-mini-chart) {
position: relative;
height: 210px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.root :global(.scan-city-detail-rail .detail-mini-chart) {
height: 230px;
}
.root :global(.detail-mini-chart canvas) {
width: 100% !important;
height: 100% !important;
}
.root :global(.detail-mini-chart-legend) {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.4;
}
.root :global(.detail-mini-chart-legend span) {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.root :global(.detail-mini-chart-legend i) {
width: 16px;
height: 3px;
border-radius: 999px;
flex: 0 0 auto;
}
.root :global(.detail-mini-chart-legend i.forecast) {
background: rgba(100, 116, 139, 0.72);
}
.root :global(.detail-mini-chart-legend i.forecast.calibrated) {
background: #38bdf8;
}
.root :global(.detail-mini-chart-legend i.observation) {
width: 7px;
height: 7px;
background: #22c55e;
}
.root :global(.detail-mini-meta) {
color: var(--text-muted);
font-size: 11px;
line-height: 1.55;
}
.root :global(.insight-list) {
display: grid;
gap: 10px;
}
.root :global(.insight-item) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
}
.root :global(.insight-title) {
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
}
.root :global(.insight-text) {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.root :global(.detail-scenery-card) {
position: relative;
min-height: 210px;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
rgba(255, 255, 255, 0.03);
}
.root :global(.detail-scenery-image) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.root :global(.detail-scenery-overlay) {
position: relative;
z-index: 1;
min-height: 210px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(
180deg,
rgba(2, 6, 23, 0.08) 0%,
rgba(2, 6, 23, 0.86) 100%
);
}
.root :global(.detail-scenery-copy),
.root :global(.detail-scenery-fallback) {
display: flex;
flex-direction: column;
gap: 6px;
}
.root :global(.detail-scenery-fallback) {
min-height: 210px;
padding: 18px;
justify-content: flex-end;
background:
radial-gradient(
circle at top left,
rgba(34, 211, 238, 0.12),
transparent 34%
),
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
}
.root :global(.detail-scenery-kicker) {
color: rgba(226, 232, 240, 0.78);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.root :global(.detail-scenery-title) {
color: #fff;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
}
.root :global(.detail-scenery-subtitle) {
max-width: 320px;
color: rgba(226, 232, 240, 0.9);
font-size: 13px;
line-height: 1.6;
text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
}
.root :global(.detail-scenery-credit) {
align-self: flex-start;
color: rgba(226, 232, 240, 0.9);
font-size: 11px;
text-decoration: none;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(2, 6, 23, 0.35);
backdrop-filter: blur(10px);
}
.root :global(.detail-scenery-credit:hover) {
border-color: rgba(34, 211, 238, 0.4);
color: #fff;
}
@media (max-width: 640px) {
.root :global(.detail-grid) {
grid-template-columns: 1fr;
}
.root :global(.detail-mini-chart) {
height: 170px;
}
.root :global(.detail-source-link) {
padding: 10px 12px;
align-items: flex-start;
flex-direction: column;
}
.root :global(.detail-source-label) {
text-align: left;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
"use client";
import type { useDashboardStore } from "@/hooks/useDashboardStore";
import type { useI18n } from "@/hooks/useI18n";
import type { getFutureModalView } from "@/lib/dashboard-utils";
import {
FutureModelForecastPanel,
FutureProbabilityPanel,
FutureTemperaturePathChart,
} from "./FutureForecastModalPanels";
type DashboardDetail = NonNullable<
ReturnType<typeof useDashboardStore>["selectedDetail"]
>;
type FutureModalView = ReturnType<typeof getFutureModalView>;
type TranslationFn = ReturnType<typeof useI18n>["t"];
export function FutureForecastForwardView({
dateStr,
detail,
t,
view,
}: {
dateStr: string;
detail: DashboardDetail;
t: TranslationFn;
view: FutureModalView;
}) {
return (
<>
<div className="future-forward-stats">
<div className="future-forward-stat-card">
<span className="label">{t("future.targetForecast")}</span>
<span className="val">
{view.forecastEntry?.max_temp ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="future-forward-stat-card">
<span className="label">{t("future.deb")}</span>
<span className="val">
{view.deb ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="future-forward-stat-card">
<span className="label">{t("future.mu")}</span>
<span className="val">
{view.mu != null
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
: "--"}
</span>
</div>
<div className="future-forward-stat-card">
<span className="label">{t("future.score")}</span>
<span className="val">
{view.front.score > 0 ? "+" : ""}
{view.front.score}
</span>
</div>
</div>
<section className="future-modal-section">
<h3>{t("future.targetTempTrend")}</h3>
<FutureTemperaturePathChart dateStr={dateStr} forceToday={false} />
</section>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.probability")}</h3>
<FutureProbabilityPanel detail={detail} targetDate={dateStr} hideTitle />
</section>
<section className="future-modal-section">
<h3>{t("future.models")}</h3>
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
</section>
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
export const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
export function normalizeMarketValue(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
export function formatMinuteAxisLabel(value: number) {
if (!Number.isFinite(value)) return "";
const total = Math.max(0, Math.round(value));
const hour = Math.floor(total / 60) % 24;
const minute = total % 60;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function formatMarketPercent(value?: number | null) {
const normalized = normalizeMarketValue(value);
if (normalized == null) return "--";
return `${(normalized * 100).toFixed(1)}%`;
}
export function formatBucketLabel(
bucket?: {
label?: string | null;
bucket?: string | null;
range?: string | null;
value?: number | null;
temp?: number | null;
} | null,
) {
if (!bucket) return "--";
const direct =
String(bucket.label || "").trim() ||
String(bucket.bucket || "").trim() ||
String(bucket.range || "").trim();
if (direct) {
let str = direct.toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
}
return str;
}
const temp = Number(bucket.value ?? bucket.temp);
if (Number.isFinite(temp)) {
return `${Math.round(temp)}`;
}
return "--";
}
export function parseBucketBoundaries(
bucket?: {
label?: string | null;
bucket?: string | null;
range?: string | null;
value?: number | null;
temp?: number | null;
} | null,
) {
if (!bucket) return null;
const raw =
String(bucket.label || "").trim() ||
String(bucket.bucket || "").trim() ||
String(bucket.range || "").trim();
if (!raw) return null;
const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
Number(match[0]),
);
if (!numbers.length) return null;
if (raw.includes("+")) {
return {
lower: numbers[0] ?? null,
upper: null as number | null,
boundaryLabel: `${numbers[0]}°C`,
};
}
if (numbers.length >= 2) {
return {
lower: numbers[0],
upper: numbers[1],
boundaryLabel: null as string | null,
};
}
return {
lower: numbers[0],
upper: null as number | null,
boundaryLabel: `${numbers[0]}°C`,
};
}
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function parseClockMinutes(value?: string | null) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
return hours * 60 + minutes;
}
export function parseLeadingNumber(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) return value;
const text = String(value || "").trim();
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
if (!match) return null;
const numeric = Number(match[0]);
return Number.isFinite(numeric) ? numeric : null;
}
export function parsePercentFromText(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) {
return clamp(value, 0, 100);
}
const text = String(value || "").trim();
const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
if (percentMatch) {
const numeric = Number(percentMatch[1]);
return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
}
return parseLeadingNumber(text);
}
export function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "high") return locale === "en-US" ? "High" : "高";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
return locale === "en-US" ? "Pending" : "待确认";
}
export function formatSignalDirection(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "support")
return locale === "en-US" ? "Support" : "支持升温";
if (normalized === "suppress")
return locale === "en-US" ? "Suppress" : "压制峰值";
return locale === "en-US" ? "Neutral" : "中性";
}
export function formatSignalStrength(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
return locale === "en-US" ? "Weak" : "弱";
}
export function signalTone(signal?: IntradayMeteorologySignal | null) {
const direction = String(signal?.direction || "")
.trim()
.toLowerCase();
if (direction === "support") return "cyan";
if (direction === "suppress") return "amber";
return "blue";
}
export function localizedText(
locale: string,
primary?: string | null,
english?: string | null,
) {
const en = String(english || "").trim();
const value = String(primary || "").trim();
if (locale === "en-US" && en) return en;
return value || en;
}
export function localizedList(
locale: string,
primary?: string[] | null,
english?: string[] | null,
) {
const en = Array.isArray(english)
? english.filter((item) => String(item || "").trim())
: [];
const value = Array.isArray(primary)
? primary.filter((item) => String(item || "").trim())
: [];
if (locale === "en-US" && en.length) return en;
return value.length ? value : en;
}
export function getTrendMetricVisual(metric: {
label?: string;
value?: string;
tone?: string;
}) {
const label = String(metric.label || "").toLowerCase();
const value = String(metric.value || "");
const numeric = parseLeadingNumber(value);
if (label.includes("降水") || label.includes("precip")) {
const precipPercent = parsePercentFromText(value);
if (precipPercent == null) return null;
return {
mode: "fill" as const,
percent: precipPercent,
tone: "cold" as const,
};
}
if (numeric == null) return null;
if (label.includes("温度") || label.includes("temp")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
if (label.includes("露点") || label.includes("dew")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
if (label.includes("气压") || label.includes("pressure")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
if (label.includes("云量") || label.includes("cloud")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
};
}
return null;
}
@@ -0,0 +1,417 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/chart-utils";
import { getFutureModalView } from "@/lib/dashboard-utils";
import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
export function DailyTemperatureChart({
dateStr,
forceToday = false,
}: {
dateStr: string;
forceToday?: boolean;
}) {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday =
forceToday || (detail ? dateStr === detail.local_date : false);
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
);
const canvasRef = useChart(() => {
if (!detail || !view) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
if (isToday && todayChartData) {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
datasets.push({
borderColor: "rgba(100, 116, 139, 0.72)",
borderDash: [6, 4],
borderWidth: 1.6,
data: todayChartData.datasets.debSeries,
fill: false,
label: locale === "en-US" ? "DEB baseline" : "DEB 原始路径",
parsing: false,
pointRadius: 0,
tension: 0.3,
});
if (todayChartData.datasets.calibratedFutureSeries.length > 0) {
datasets.push({
borderColor: "#38bdf8",
borderWidth: 2.4,
data: todayChartData.datasets.calibratedFutureSeries,
fill: false,
label:
locale === "en-US"
? "METAR-calibrated path"
: "METAR 修正路径",
parsing: false,
pointHoverRadius: 5,
pointRadius: 0,
tension: 0.32,
});
}
if (todayChartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderDash: [3, 3],
borderWidth: 1.2,
data: todayChartData.datasets.mgmHourlySeries,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
parsing: false,
pointHoverRadius: 6,
pointRadius: 0,
spanGaps: true,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#22c55e",
borderColor: "#22c55e",
borderWidth: 0,
data: todayChartData.datasets.metarSeries,
fill: false,
label:
todayChartData.observationLabel ||
(locale === "en-US" ? "Observation" : "观测实况"),
order: 0,
parsing: false,
pointHoverRadius: 7,
pointRadius: 5,
showLine: false,
});
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
datasets.push({
backgroundColor: "#86efac",
borderColor: "#86efac",
borderWidth: 1,
data: todayChartData.datasets.airportMetarSeries,
fill: false,
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0,
parsing: false,
pointHoverRadius: 6,
pointRadius: 4,
showLine: false,
});
}
if (todayChartData.datasets.mgmSeries?.length > 0) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: todayChartData.datasets.mgmSeries,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
parsing: false,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!todayChartData.datasets.hasMgmHourly &&
Math.abs(todayChartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(77, 163, 255, 0.22)",
borderDash: [2, 4],
borderWidth: 1,
data: todayChartData.datasets.tempsSeries,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
parsing: false,
pointRadius: 0,
tension: 0.3,
});
}
if ((todayChartData.tafMarkers || []).length > 0) {
datasets.push({
backgroundColor: "#f59e0b",
borderColor: "#f59e0b",
borderWidth: 0,
data: todayChartData.datasets.tafCurrentMarkerSeries,
fill: false,
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
order: -3,
parsing: false,
pointHoverRadius: 8,
pointRadius: 6,
showLine: false,
});
datasets.push({
backgroundColor: "rgba(250, 204, 21, 0.72)",
borderColor: "rgba(250, 204, 21, 0.72)",
borderWidth: 0,
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
fill: false,
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
order: -2,
parsing: false,
pointHoverRadius: 7,
pointRadius: 4,
showLine: false,
});
datasets.push({
backgroundColor: "#f59e0b",
borderColor: "#f59e0b",
borderWidth: 0,
data: todayChartData.datasets.tafMarkerSeries,
fill: false,
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
order: -4,
parsing: false,
pointHoverRadius: 0,
pointRadius: 0,
showLine: false,
});
}
return {
data: {
datasets,
labels: [],
},
options: {
interaction: { intersect: false, mode: "nearest" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: "#9FB2C7",
filter: (legendItem, chartData) => {
const text = String(legendItem.text || "");
if (!text) return false;
if (text === "TAF Timing" || text === "TAF 时段")
return false;
if (!text.includes("DEB")) return true;
const firstDebIndex = (chartData.datasets || []).findIndex(
(dataset) => String(dataset.label || "").includes("DEB"),
);
return legendItem.datasetIndex === firstDebIndex;
},
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
title: (items) => {
const rawX = items?.[0]?.parsed?.x;
return rawX != null
? formatMinuteAxisLabel(Number(rawX))
: "";
},
label: (ctx) => {
const label = String(ctx.dataset.label || "");
const raw = ctx.raw as
| {
marker?: {
summary?: string;
markerType?: string;
displayType?: string;
isCurrent?: boolean;
isPeakWindow?: boolean;
};
}
| undefined;
if (
label === "TAF Timing" ||
label === "TAF 时段" ||
label === "Current TAF" ||
label === "当前 TAF" ||
label === "Peak-window TAF" ||
label === "峰值窗口 TAF"
) {
const marker = raw?.marker;
if (!marker) return label;
const markerType = String(marker.markerType || "");
const displayType = String(
marker.displayType || marker.markerType || "",
);
const summary = String(marker.summary || "");
const prefix =
marker.isCurrent && marker.isPeakWindow
? locale === "en-US"
? "Current / peak-window TAF"
: "当前 / 峰值窗口 TAF"
: marker.isCurrent
? locale === "en-US"
? "Current TAF"
: "当前 TAF"
: marker.isPeakWindow
? locale === "en-US"
? "Peak-window TAF"
: "峰值窗口 TAF"
: label;
return `${prefix}: ${
markerType
? summary.replace(markerType, displayType)
: summary
}`;
}
const value = ctx.parsed.y;
if (value == null) return label;
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
},
},
},
},
responsive: true,
scales: {
x: {
max: todayChartData.xMax,
min: todayChartData.xMin,
grid: { color: "rgba(255,255,255,0.04)" },
type: "linear",
ticks: {
callback: (value) => {
const num = Number(value);
if (!Number.isFinite(num)) return "";
const minutes = Math.round(num);
if (
minutes !== todayChartData.xMin &&
minutes !== todayChartData.xMax &&
minutes % 120 !== 0
) {
return "";
}
return formatMinuteAxisLabel(minutes);
},
color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: todayChartData.max,
min: todayChartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { family: "Inter", size: 10 },
stepSize: todayChartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}
const labels = view.slice.map((point) => point.label);
const unit = detail.temp_symbol || "°C";
return {
data: {
datasets: [
{
backgroundColor: "rgba(77, 163, 255, 0.08)",
borderColor: "#4DA3FF",
data: view.slice.map((point) => point.temp),
fill: false,
label:
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
pointRadius: 2,
tension: 0.28,
},
{
backgroundColor: "transparent",
borderColor: "#93C5FD",
borderDash: [5, 4],
data: view.slice.map((point) => point.dewPoint),
fill: false,
label: locale === "en-US" ? "Dew Point" : "露点",
pointRadius: 0,
tension: 0.24,
},
],
labels,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: "#9FB2C7",
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (value) => `${value}${unit}`,
color: "#6B7A90",
font: { family: "Inter", size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [detail, isToday, locale, todayChartData, view]);
return (
<>
<div className="history-chart-wrapper future-chart-wrapper">
<canvas ref={canvasRef} />
</div>
{isToday && (
<div className="chart-legend">
{todayChartData?.legendText || t("future.chartLegendEmpty")}
</div>
)}
</>
);
}
@@ -0,0 +1,87 @@
"use client";
import dynamic from "next/dynamic";
import type { useDashboardStore } from "@/hooks/useDashboardStore";
import type { MarketScan } from "@/lib/dashboard-types";
const DailyTemperatureChart = dynamic(
() =>
import("./FutureForecastModalChart").then(
(module) => module.DailyTemperatureChart,
),
{
loading: () => <div className="history-chart-wrapper future-chart-wrapper" />,
ssr: false,
},
);
const ProbabilityDistribution = dynamic(
() =>
import("@/components/dashboard/PanelSections").then(
(module) => module.ProbabilityDistribution,
),
{
loading: () => <div className="future-v2-panel-loading" />,
ssr: false,
},
);
const ModelForecast = dynamic(
() =>
import("@/components/dashboard/PanelSections").then(
(module) => module.ModelForecast,
),
{
loading: () => <div className="future-v2-panel-loading" />,
ssr: false,
},
);
type DashboardDetail = NonNullable<
ReturnType<typeof useDashboardStore>["selectedDetail"]
>;
export function FutureTemperaturePathChart({
dateStr,
forceToday,
}: {
dateStr: string;
forceToday: boolean;
}) {
return <DailyTemperatureChart dateStr={dateStr} forceToday={forceToday} />;
}
export function FutureProbabilityPanel({
detail,
targetDate,
marketScan,
hideTitle = true,
}: {
detail: DashboardDetail;
targetDate: string;
marketScan?: MarketScan | null;
hideTitle?: boolean;
}) {
return (
<ProbabilityDistribution
detail={detail}
targetDate={targetDate}
marketScan={marketScan}
hideTitle={hideTitle}
/>
);
}
export function FutureModelForecastPanel({
detail,
targetDate,
hideTitle = true,
}: {
detail: DashboardDetail;
targetDate: string;
hideTitle?: boolean;
}) {
return (
<ModelForecast detail={detail} targetDate={targetDate} hideTitle={hideTitle} />
);
}
@@ -0,0 +1,63 @@
import clsx from "clsx";
export type FutureSyncStatusItem = {
key: string;
state: "ready" | "syncing";
label: string;
note: string;
};
export function FutureRefreshLock({ locale }: { locale: string }) {
return (
<div
className="future-v2-refresh-lock"
role="status"
aria-live="assertive"
>
<span className="future-v2-refresh-spinner" aria-hidden="true" />
<div>
<strong>
{locale === "en-US"
? "Refreshing latest intraday data"
: "正在刷新最新日内数据"}
</strong>
<p>
{locale === "en-US"
? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
: "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
</p>
</div>
</div>
);
}
export function FutureSyncStatusStrip({
items,
compact = false,
}: {
items: readonly FutureSyncStatusItem[];
compact?: boolean;
}) {
return (
<section
className={clsx("future-v2-sync-strip", compact && "future-v2-sync-strip-compact")}
aria-live="polite"
>
{items.map((item) => (
<div
key={item.key}
className={clsx(
"future-v2-sync-chip",
item.state === "syncing" && "syncing",
)}
>
<span className="future-v2-sync-dot" aria-hidden="true" />
<div className="future-v2-sync-copy">
<strong>{item.label}</strong>
<span>{item.note}</span>
</div>
</div>
))}
</section>
);
}
@@ -0,0 +1,26 @@
import {
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSnow,
CloudSun,
Search,
Sun,
Wind,
} from "lucide-react";
export function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
if (emoji === "⛅" || emoji === "🌤️")
return <CloudSun size={size} color="#4DA3FF" />;
if (emoji === "☁️") return <Cloud size={size} color="#9FB2C7" />;
if (emoji === "🌧️" || emoji === "🌦️")
return <CloudRain size={size} color="#60a5fa" />;
if (emoji === "⛈️") return <CloudLightning size={size} color="#c084fc" />;
if (emoji === "❄️" || emoji === "🌨️")
return <CloudSnow size={size} color="#7dd3fc" />;
if (emoji === "🌫️") return <CloudFog size={size} color="#a1a1aa" />;
if (emoji === "💨") return <Wind size={size} color="#cbd5e1" />;
return <Search size={size} color="#6B7A90" />;
}
@@ -0,0 +1,281 @@
import clsx from "clsx";
import type { CSSProperties } from "react";
import type { getTodayPaceView } from "@/lib/pace-utils";
import { WeatherIcon } from "./FutureForecastModalWeatherIcon";
type Locale = string;
type TodayPaceView = NonNullable<ReturnType<typeof getTodayPaceView>>;
type WeatherSummaryView = {
weatherIcon: string;
weatherText: string;
};
type DaylightProgressView = {
phase: string;
percent: number;
};
export type FuturePaceSignalItem = {
label: string;
tone: "cyan" | "blue" | "amber";
status: string;
value: string;
note: string;
};
export function FutureAnchorStatusCard({
locale,
currentTempText,
weatherSummary,
obsTime,
daylightProgress,
sunrise,
sunset,
topObservedTemp,
tempSymbol,
gapToBaseBucket,
pathStatus,
}: {
locale: Locale;
currentTempText: string;
weatherSummary: WeatherSummaryView;
obsTime?: string | null;
daylightProgress: DaylightProgressView | null;
sunrise?: string | null;
sunset?: string | null;
topObservedTemp: number | string | null | undefined;
tempSymbol: string;
gapToBaseBucket: number | null;
pathStatus: string;
}) {
const observedHigh = topObservedTemp ?? "--";
return (
<section className="future-v2-card future-v2-hero-card">
<div className="future-v2-card-head">
<h3 className="future-v2-hero-title">
{locale === "en-US" ? "Anchor Status" : "锚点状态"}
</h3>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Settlement anchor and current clock"
: "结算锚点与当前时钟"}
</div>
</div>
<div className="future-v2-hero-main">
<div className="future-v2-hero-temp">{currentTempText}</div>
<div className="future-v2-hero-divider" />
<div className="future-v2-hero-weather">
<span className="future-v2-hero-icon">
<WeatherIcon emoji={weatherSummary.weatherIcon} size={42} />
</span>
<span>{weatherSummary.weatherText}</span>
</div>
</div>
<div className="future-v2-hero-obs">@{obsTime || "--"}</div>
{daylightProgress ? (
<div className="future-v2-daylight">
<div className="future-v2-daylight-head">
<span>{locale === "en-US" ? "Solar Window" : "昼夜进度"}</span>
<strong>
{daylightProgress.phase} {Math.round(daylightProgress.percent)}%
</strong>
</div>
<div
className="future-v2-daylight-bar"
style={
{
"--daylight-progress": `${daylightProgress.percent}%`,
} as CSSProperties & { "--daylight-progress": string }
}
/>
<div className="future-v2-daylight-times">
<span>{sunrise || "--"}</span>
<span>{sunset || "--"}</span>
</div>
</div>
) : null}
<div className="future-v2-mini-grid">
<FutureMiniMetric
label={locale === "en-US" ? "High so far" : "日内已见高点"}
value={`${observedHigh}${tempSymbol}`}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Anchor clock" : "锚点时钟"}
value={obsTime || "--"}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Gap to base" : "距基准档"}
value={
gapToBaseBucket != null
? `${gapToBaseBucket.toFixed(1)}${tempSymbol}`
: "--"
}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Path state" : "路径状态"}
value={pathStatus}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Sunrise" : "日出时间"}
value={sunrise || "--"}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Sunset" : "日落时间"}
value={sunset || "--"}
/>
</div>
</section>
);
}
export function FuturePaceCard({
locale,
paceView,
tempSymbol,
signalItems,
}: {
locale: Locale;
paceView: TodayPaceView;
tempSymbol: string;
signalItems: FuturePaceSignalItem[];
}) {
return (
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Pace" : "当前节奏"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Expected now vs airport anchor"
: "此刻应到 vs 机场锚点"}
</div>
</div>
<div className="future-v2-pace-head">
<span className="future-v2-pace-kicker">{paceView.kicker}</span>
<FutureSignalTag tone={paceView.biasTone}>{paceView.badge}</FutureSignalTag>
</div>
<div
className={clsx(
"future-v2-pace-delta",
paceView.biasTone === "cold" && "cold",
paceView.biasTone === "neutral" && "neutral",
paceView.biasTone === "warm" && "warm",
)}
>
{paceView.deltaText}
</div>
<div className="future-v2-pace-summary">{paceView.summary}</div>
<div className="future-v2-pace-meter">
<span className="future-v2-pace-meter-midline" />
<span
className={clsx(
"future-v2-pace-meter-fill",
paceView.biasTone === "cold" && "cold",
paceView.biasTone === "neutral" && "neutral",
paceView.biasTone === "warm" && "warm",
)}
style={
{
"--pace-left": `${paceView.meterLeft}%`,
"--pace-width": `${paceView.meterWidth}%`,
} as CSSProperties & {
"--pace-left": string;
"--pace-width": string;
}
}
/>
</div>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<FutureMiniMetric
label={locale === "en-US" ? "Expected now" : "预期此刻"}
value={`${paceView.expectedNow.toFixed(1)}${tempSymbol}`}
/>
<FutureMiniMetric
label={paceView.observedLabel}
value={`${paceView.observedNow.toFixed(1)}${tempSymbol}`}
/>
<FutureMiniMetric
label={paceView.paceAdjustedLabel}
value={
paceView.paceAdjustedHigh != null
? `${paceView.paceAdjustedHigh.toFixed(1)}${tempSymbol}`
: "--"
}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Peak window" : "峰值窗口"}
value={paceView.peakWindowText}
/>
</div>
{signalItems.length ? (
<div className="future-v2-pace-signal-grid">
{signalItems.map((item) => (
<div key={item.label} className="future-v2-pace-signal-card">
<div className="future-v2-signal-head">
<span>{item.label}</span>
<FutureSignalTag tone={item.tone}>{item.status}</FutureSignalTag>
</div>
<strong>{item.value}</strong>
<div className="future-v2-pace-signal-note">{item.note}</div>
</div>
))}
</div>
) : null}
</section>
);
}
export function FuturePaceLoadingCard({ locale }: { locale: Locale }) {
return (
<section className="future-v2-card future-v2-support-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Pace" : "当前节奏"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Backfilling intraday pace context"
: "正在补齐日内节奏上下文"}
</div>
</div>
<div className="future-trend-summary future-trend-summary-muted">
{locale === "en-US"
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
</div>
</section>
);
}
function FutureMiniMetric({ label, value }: { label: string; value: string }) {
return (
<div className="future-v2-mini-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function FutureSignalTag({
tone,
children,
}: {
tone: string;
children: string;
}) {
return (
<em
className={clsx(
"future-v2-signal-tag",
(tone === "cold" || tone === "cyan") && "cyan",
(tone === "neutral" || tone === "blue") && "blue",
(tone === "warm" || tone === "amber") && "amber",
)}
>
{children}
</em>
);
}
@@ -0,0 +1,87 @@
"use client";
import { formatConfidenceLabel } from "./FutureForecastModal.utils";
export function FutureForecastTodayDecisionBrief({
anchorRuleText,
anchorSourceLabel,
baseCaseBucket,
confidence,
displayName,
downsideBucket,
gapToBaseText,
locale,
meteorologyHeadline,
nextObservationLabel,
nextObservationTime,
pathStatus,
settlementStationName,
upsideBucket,
}: {
anchorRuleText: string;
anchorSourceLabel: string;
baseCaseBucket: string;
confidence: string | null | undefined;
displayName: string;
downsideBucket: string | null | undefined;
gapToBaseText: string;
locale: string;
meteorologyHeadline: string;
nextObservationLabel: string;
nextObservationTime: string;
pathStatus: string;
settlementStationName: string;
upsideBucket: string | null | undefined;
}) {
return (
<section className="future-v2-meteorology-brief">
<div className="future-v2-meteorology-copy">
<div className="future-v2-anchor-row">
<div className="modal-section-kicker">
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
</div>
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
</div>
<h3>{meteorologyHeadline}</h3>
<p className="future-v2-anchor-rule">{anchorRuleText}</p>
<div className="future-v2-meteorology-meta">
<span>
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
{formatConfidenceLabel(confidence, locale)}
</span>
<span>
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
</span>
<span>
{nextObservationLabel} · {nextObservationTime}
</span>
</div>
</div>
<div className="future-v2-decision-rail" aria-label={displayName}>
<div className="future-v2-decision-anchor">
<span>{locale === "en-US" ? "Anchor" : "锚点"}</span>
<strong>{settlementStationName}</strong>
<small>{anchorSourceLabel}</small>
</div>
<div className="future-v2-decision-grid">
<div>
<span>{locale === "en-US" ? "Base" : "基准"}</span>
<strong>{baseCaseBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
<strong>{upsideBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
<strong>{downsideBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
<strong>{gapToBaseText}</strong>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,129 @@
"use client";
import clsx from "clsx";
import {
formatSignalDirection,
formatSignalStrength,
localizedText,
signalTone,
} from "./FutureForecastModal.utils";
type MeteorologySignal = {
direction?: string | null;
label?: string | null;
label_en?: string | null;
strength?: string | null;
summary?: string | null;
summary_en?: string | null;
};
export function FutureForecastTodayEvidenceGrid({
airportMetarAnchor,
confirmationRules,
invalidationRules,
locale,
meteorologySignals,
modelSummary,
}: {
airportMetarAnchor: boolean;
confirmationRules: string[];
invalidationRules: string[];
locale: string;
meteorologySignals: MeteorologySignal[];
modelSummary: string;
}) {
const fallbackInvalidationRule =
locale === "en-US"
? "If observations stop tracking the expected curve, wait for the next refresh."
: "若实测不再贴近预期曲线,等待下一次刷新确认。";
const fallbackConfirmationRule =
locale === "en-US"
? airportMetarAnchor
? "Keep watching the next anchor METAR report."
: "Keep watching the next official anchor observation."
: airportMetarAnchor
? "继续观察下一次锚点 METAR 报文。"
: "继续观察下一次官方锚点观测。";
return (
<div className="future-v2-meteorology-grid">
<section className="future-modal-section future-v2-evidence-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
</div>
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
</div>
<div className="future-v2-evidence-list">
{meteorologySignals.length > 0 ? (
meteorologySignals.map((signal, index) => (
<div
key={`${signal.label || "signal"}-${index}`}
className={clsx("future-v2-evidence-row", signalTone(signal))}
>
<div className="future-v2-evidence-head">
<strong>
{localizedText(locale, signal.label, signal.label_en) || "--"}
</strong>
<span>
{formatSignalDirection(signal.direction, locale)} ·{" "}
{formatSignalStrength(signal.strength, locale)}
</span>
</div>
<p>
{localizedText(locale, signal.summary, signal.summary_en) ||
"--"}
</p>
</div>
))
) : (
<div className="future-text-block">
{locale === "en-US"
? "Meteorology signals are still loading."
: "气象信号仍在加载。"}
</div>
)}
</div>
</section>
<section className="future-modal-section future-v2-rule-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Failure modes" : "失效条件"}
</div>
<h3>
{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
</h3>
</div>
<ul className="future-v2-rule-list">
{(invalidationRules.length > 0
? invalidationRules
: [fallbackInvalidationRule]
).map((rule, index) => (
<li key={`${rule}-${index}`}>{rule}</li>
))}
</ul>
</section>
<section className="future-modal-section future-v2-rule-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Confirmation" : "确认条件"}
</div>
<h3>
{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
</h3>
</div>
<ul className="future-v2-rule-list">
{(confirmationRules.length > 0
? confirmationRules
: [fallbackConfirmationRule]
).map((rule, index) => (
<li key={`${rule}-${index}`}>{rule}</li>
))}
</ul>
<div className="future-v2-model-note">{modelSummary}</div>
</section>
</div>
);
}
@@ -0,0 +1,163 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getHistorySummary } from "@/lib/dashboard-utils";
export function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgmSeriesComplete &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isNoaaSettlement
? locale === "en-US"
? `NOAA Settled High (${noaaStationCode})`
: `NOAA 结算最高温 (${noaaStationCode})`
: locale === "en-US"
? "Observed High"
: "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
@@ -0,0 +1,530 @@
/* History modal styles moved out of Dashboard.module.css. */
.root :global(.history-btn) {
background: rgba(34, 211, 238, 0.1);
color: var(--accent-cyan);
border: 1px solid rgba(34, 211, 238, 0.3);
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-left: 8px;
}
.root :global(.history-btn:hover) {
background: rgba(34, 211, 238, 0.2);
border-color: var(--accent-cyan);
}
.root :global(.history-btn:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
.root :global(.modal-content.history-modal) {
width: min(96vw, 1180px);
max-width: 1180px;
max-height: calc(100vh - 32px);
}
.root :global(.history-modal .modal-body) {
padding: 24px 28px 28px;
}
.root :global(.history-modal .history-stats) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.root :global(.history-modal .h-stat-card) {
min-width: 0;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.035) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.history-modal .h-stat-card .label) {
font-size: 12px;
margin-bottom: 8px;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 32px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 420px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 12px 14px 8px;
}
.root :global(.history-modal .history-chart-wrapper canvas) {
width: 100% !important;
height: 100% !important;
}
.root :global(.history-modal .history-chart-loading) {
background:
linear-gradient(
90deg,
rgba(255, 255, 255, 0.025),
rgba(96, 165, 250, 0.08),
rgba(255, 255, 255, 0.025)
);
background-size: 220% 100%;
animation: history-chart-loading 1.4s ease-in-out infinite;
}
@keyframes history-chart-loading {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
.root :global(.history-modal .history-modal-loading) {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
}
.root :global(.history-modal .history-fetch-loading) {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
width: min(100%, 420px);
padding: 24px 26px;
border-radius: 24px;
border: 1px solid rgba(96, 165, 250, 0.16);
background: linear-gradient(
180deg,
rgba(10, 18, 36, 0.72) 0%,
rgba(8, 14, 30, 0.5) 100%
);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.root :global(.history-modal .history-fetch-scan) {
position: relative;
width: 84px;
height: 84px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(14, 165, 233, 0.08) 0%,
rgba(14, 165, 233, 0.02) 62%,
transparent 100%
);
}
.root :global(.history-modal .history-fetch-ring) {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(56, 189, 248, 0.16);
}
.root :global(.history-modal .history-fetch-ring-1) {
transform: scale(0.7);
}
.root :global(.history-modal .history-fetch-ring-2) {
transform: scale(0.92);
}
.root :global(.history-modal .history-fetch-sweep) {
position: absolute;
inset: 5px;
border-radius: 50%;
background: conic-gradient(
from 120deg,
rgba(34, 211, 238, 0) 0deg,
rgba(34, 211, 238, 0.04) 220deg,
rgba(56, 189, 248, 0.78) 300deg,
rgba(253, 224, 71, 0.32) 332deg,
rgba(34, 211, 238, 0) 360deg
);
animation: history-fetch-spin 2.2s linear infinite;
}
.root :global(.history-modal .history-fetch-core) {
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
transform: translate(-50%, -50%);
background: rgba(103, 232, 249, 0.95);
box-shadow: 0 0 18px rgba(34, 211, 238, 0.42);
}
.root :global(.history-modal .history-fetch-bars) {
display: flex;
align-items: end;
gap: 7px;
height: 22px;
}
.root :global(.history-modal .history-fetch-bar) {
width: 8px;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(250, 204, 21, 0.18) 0%,
rgba(251, 146, 60, 0.9) 100%
);
animation: history-fetch-pulse 1.6s ease-in-out infinite;
}
.root :global(.history-modal .history-fetch-bar-1) {
height: 10px;
}
.root :global(.history-modal .history-fetch-bar-2) {
height: 18px;
animation-delay: 0.18s;
}
.root :global(.history-modal .history-fetch-bar-3) {
height: 14px;
animation-delay: 0.34s;
}
.root :global(.history-modal .history-fetch-bar-4) {
height: 20px;
animation-delay: 0.5s;
}
.root :global(.history-modal .history-fetch-lines) {
position: relative;
width: 180px;
height: 22px;
overflow: hidden;
}
.root :global(.history-modal .history-fetch-line) {
position: absolute;
left: -26%;
height: 1px;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0) 0%,
rgba(125, 211, 252, 0.72) 48%,
rgba(34, 211, 238, 0) 100%
);
animation: history-fetch-flow 2.4s linear infinite;
}
.root :global(.history-modal .history-fetch-line-1) {
top: 4px;
width: 122px;
}
.root :global(.history-modal .history-fetch-line-2) {
top: 11px;
width: 168px;
animation-delay: -0.8s;
}
.root :global(.history-modal .history-fetch-line-3) {
top: 18px;
width: 138px;
animation-delay: -1.4s;
}
.root :global(.history-modal .history-fetch-copy) {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.root :global(.history-modal .history-fetch-copy strong) {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(103, 232, 249, 0.94);
}
.root :global(.history-modal .history-fetch-copy span) {
max-width: 300px;
font-size: 12px;
line-height: 1.5;
color: rgba(203, 213, 225, 0.82);
}
@keyframes history-fetch-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes history-fetch-pulse {
0%,
100% {
transform: scaleY(0.86);
opacity: 0.8;
}
50% {
transform: scaleY(1.08);
opacity: 1;
}
}
@keyframes history-fetch-flow {
from {
transform: translateX(0);
}
to {
transform: translateX(150%);
}
}
.root :global(.history-modal .history-peak-reference) {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 16px;
background: rgba(15, 23, 42, 0.42);
}
.root :global(.history-modal .history-model-reference) {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 16px;
background: rgba(8, 47, 73, 0.2);
}
.root :global(.history-modal .history-peak-reference-title) {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
margin-bottom: 10px;
}
.root :global(.history-modal .history-model-reference-scroll),
.root :global(.history-modal .history-peak-reference-scroll) {
display: grid;
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding-right: 6px;
scrollbar-width: thin;
}
.root :global(.history-modal .history-model-reference-scroll) {
scrollbar-color: rgba(34, 211, 238, 0.5) rgba(15, 23, 42, 0.32);
}
.root :global(.history-modal .history-peak-reference-scroll) {
scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
width: 8px;
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
background: rgba(15, 23, 42, 0.32);
border-radius: 999px;
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
background: linear-gradient(
180deg,
rgba(56, 189, 248, 0.72) 0%,
rgba(96, 165, 250, 0.52) 100%
);
border-radius: 999px;
}
.root :global(.history-modal .history-peak-reference-row),
.root :global(.history-modal .history-model-reference-row) {
display: grid;
grid-template-columns: minmax(72px, 88px) 1fr;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
}
.root :global(.history-modal .history-peak-reference-date) {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.root :global(.history-modal .history-model-reference-body) {
display: grid;
gap: 8px;
min-width: 0;
}
.root :global(.history-modal .history-model-reference-summary) {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
color: var(--text-secondary);
font-size: 12px;
}
.root :global(.history-modal .history-model-reference-summary strong) {
color: var(--text-primary);
}
.root :global(.history-modal .history-model-reference-models) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.root :global(.history-modal .history-model-reference-model) {
display: grid;
gap: 3px;
min-width: 0;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
background: rgba(15, 23, 42, 0.38);
color: var(--text-secondary);
font-size: 11px;
}
.root :global(.history-modal .history-model-name) {
overflow: hidden;
color: var(--accent-cyan);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.history-modal .history-model-error) {
color: var(--text-muted);
}
.root :global(.history-modal .history-peak-reference-meta) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.7;
}
@media (max-width: 960px) {
.root :global(.history-modal .modal-body) {
padding: 18px;
}
.root :global(.history-modal .history-stats) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 360px;
}
.root :global(.history-modal .history-modal-loading) {
min-height: 340px;
}
.root :global(.history-modal .history-fetch-loading) {
width: min(100%, 360px);
}
.root :global(.history-modal .history-peak-reference-scroll),
.root :global(.history-modal .history-model-reference-scroll) {
max-height: 320px;
}
.root :global(.history-modal .history-model-reference-models) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.root :global(.modal-content.history-modal) {
width: min(100vw - 12px, 100%);
max-width: none;
}
.root :global(.history-modal .history-stats) {
grid-template-columns: 1fr;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 28px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 300px;
padding: 8px 10px 6px;
}
.root :global(.history-modal .history-modal-loading) {
min-height: 280px;
}
.root :global(.history-modal .history-fetch-loading) {
width: 100%;
padding: 18px 16px;
}
.root :global(.history-modal .history-fetch-lines) {
width: 150px;
}
.root :global(.history-modal .history-fetch-scan) {
width: 72px;
height: 72px;
}
.root :global(.history-modal .history-peak-reference),
.root :global(.history-modal .history-model-reference) {
padding: 12px;
}
.root :global(.history-modal .history-peak-reference-scroll),
.root :global(.history-modal .history-model-reference-scroll) {
max-height: 260px;
padding-right: 2px;
}
.root :global(.history-modal .history-peak-reference-row),
.root :global(.history-modal .history-model-reference-row) {
grid-template-columns: 1fr;
gap: 6px;
}
.root :global(.history-modal .history-model-reference-models) {
grid-template-columns: 1fr;
}
}
+11 -156
View File
@@ -1,167 +1,22 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { getHistorySummary } from "@/lib/dashboard-utils";
function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgmSeriesComplete &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isNoaaSettlement
? locale === "en-US"
? `NOAA Settled High (${noaaStationCode})`
: `NOAA 结算最高温 (${noaaStationCode})`
: locale === "en-US"
? "Observed High"
: "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
const HistoryChart = dynamic(
() =>
import("@/components/dashboard/HistoryChart").then(
(module) => module.HistoryChart,
),
{
loading: () => <div className="history-chart-wrapper history-chart-loading" />,
ssr: false,
},
);
export function HistoryModal() {
const store = useDashboardStore();
@@ -0,0 +1,283 @@
"use client";
import clsx from "clsx";
import { useI18n } from "@/hooks/useI18n";
import { CityDetail } from "@/lib/dashboard-types";
import { getModelView } from "@/lib/model-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function getModelGroupMeta(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const tier = String(meta.tier || "").toLowerCase();
const upperName = String(name || "").toUpperCase();
if (tier.includes("aifs") || upperName.includes("AIFS")) {
return {
key: "aifs",
label: locale === "en-US" ? "AIFS model" : "AIFS 模型",
order: 1,
tone: "blue",
};
}
if (
tier.includes("europe") ||
upperName.includes("ICON-EU") ||
upperName.includes("ICON-D2")
) {
return {
key: "europe",
label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
order: 2,
tone: "cyan",
};
}
if (
tier.includes("north_america") ||
upperName === "RDPS" ||
upperName === "HRDPS"
) {
return {
key: "north-america",
label:
locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3,
tone: "amber",
};
}
return {
key: "global",
label: locale === "en-US" ? "Global baseline" : "全球基准",
order: 0,
tone: "neutral",
};
}
function formatModelMetaLine(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const provider = String(meta.provider || "").trim();
const model = String(meta.model || "").trim();
const horizon = String(meta.horizon || "").trim();
const resolution = Number(meta.resolution_km);
const parts = [
provider,
model && model !== name ? model : "",
Number.isFinite(resolution)
? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
: "",
horizon,
].filter(Boolean);
return parts.join(" · ");
}
export function ModelForecast({
detail,
hideTitle = false,
targetDate,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelsMap = { ...view.models };
const modelMetadata =
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
const hasSingleModelOnly = modelEntries.length === 1;
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<EmptyState text={t("section.noModels")} />
</div>
</section>
);
}
const numericValues = modelEntries.map(([, value]) => Number(value));
const comparisonValues =
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
const minValue = comparisonValues.length
? Math.min(...comparisonValues) - 1
: 0;
const maxValue = comparisonValues.length
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
const sortedEntries = modelEntries.sort(
(a, b) => Number(b[1] || 0) - Number(a[1] || 0),
);
const groupedEntries = sortedEntries
.reduce(
(acc, [name, value]) => {
const group = getModelGroupMeta(name, modelMetadata, locale);
const existing = acc.find((item) => item.key === group.key);
const entry = {
metaLine: formatModelMetaLine(name, modelMetadata, locale),
name,
value: Number(value),
};
if (existing) {
existing.entries.push(entry);
} else {
acc.push({ ...group, entries: [entry] });
}
return acc;
},
[] as Array<{
entries: Array<{ metaLine: string; name: string; value: number }>;
key: string;
label: string;
order: number;
tone: string;
}>,
)
.sort((a, b) => a.order - b.order);
const spread =
numericValues.length >= 2
? Math.max(...numericValues) - Math.min(...numericValues)
: null;
const metadataSource =
detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
? "Open-Meteo"
: null;
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<div className="model-stack-summary">
<span>
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
<strong>{modelEntries.length}</strong>
</span>
<span>
{locale === "en-US" ? "Spread" : "分歧"} ·{" "}
<strong>
{spread != null
? `${spread.toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
</span>
{metadataSource && (
<span>
{locale === "en-US" ? "API" : "接口"} ·{" "}
<strong>{metadataSource}</strong>
</span>
)}
</div>
{hasSingleModelOnly && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "8px",
}}
>
{locale === "en-US"
? "Single-model fallback: waiting for the rest of the model cluster."
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{groupedEntries.map((group) => (
<div
key={group.key}
className={clsx("model-group", `model-group-${group.tone}`)}
>
<div className="model-group-heading">
<span>{group.label}</span>
<em>{group.entries.length}</em>
</div>
{group.entries.map(({ metaLine, name, value }) => {
const width = ((value - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row model-row-rich">
<div className="model-name" title={metaLine || name}>
<strong>{name}</strong>
{metaLine && <span>{metaLine}</span>}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill"
style={{ width: `${width}%` }}
/>
<span className="model-bar-value">
{value}
{detail.temp_symbol}
</span>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
);
})}
</div>
))}
{view.deb != null && (
<div
className="model-row"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: "6px",
paddingTop: "6px",
}}
>
<div
className="model-name"
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
>
DEB
</div>
<div className="model-bar-track">
<div
className="model-bar-fill deb"
style={{
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
}}
/>
<span className="model-bar-value deb">
{Number(view.deb)}
{detail.temp_symbol}
</span>
</div>
</div>
)}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,956 @@
"use client";
import clsx from "clsx";
import { useMemo } from "react";
import { useI18n } from "@/hooks/useI18n";
import {
CityDetail,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import { getModelView, getProbabilityView } from "@/lib/model-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`;
}
function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric;
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
const text = Number.isInteger(rounded)
? String(rounded.toFixed(0))
: String(rounded);
return `${text}c`;
}
function parseTempFromText(value: unknown) {
const text = String(value || "");
const match = text.match(/(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function getBucketTemp(bucket: ProbabilityBucket) {
if (bucket.value != null) {
const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
if (Number.isFinite(preferred)) return preferred;
}
if (scan?.yes_token?.implied_probability != null) {
const implied = Number(scan.yes_token.implied_probability);
if (Number.isFinite(implied)) return implied;
}
return null;
}
function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "")
.toUpperCase()
.includes("F");
}
function displayTempToMarketCelsius(
value: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (value == null || !Number.isFinite(value)) return null;
if (isFahrenheitSymbol(detail.temp_symbol)) {
return ((value - 32) * 5) / 9;
}
return value;
}
function formatBucketDisplayLabel(
bucket: ProbabilityBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (!bucketLabel) return "";
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
const symbol = detail.temp_symbol || "°C";
if (isFahrenheitSymbol(symbol)) {
str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
} else {
str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
}
str = str.replace(/°?C($|\+|-)/g, "°C$1");
str = str.replace(/°?F($|\+|-)/g, "°F$1");
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
str += symbol;
}
return str;
}
function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
return String(bucket?.unit || "").toUpperCase();
}
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("+") ||
text.includes("orhigher") ||
text.includes("or-higher")
);
}
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
);
}
function findMarketBucketForDisplayTemp(
buckets: MarketTopBucket[],
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
let best: MarketTopBucket | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const bucketUnit = String(bucket.unit || "").toUpperCase();
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) continue;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper) &&
compareTemp >= lower - 0.01 &&
compareTemp <= upper + 0.01
) {
return bucket;
}
const rawTemp = bucket.temp ?? bucket.value ?? null;
if (rawTemp == null) continue;
const candidateTemp = Number(rawTemp);
if (!Number.isFinite(candidateTemp)) continue;
const delta = Math.abs(candidateTemp - compareTemp);
if (delta < bestDelta) {
best = bucket;
bestDelta = delta;
}
}
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
return best && bestDelta <= tolerance ? best : null;
}
function marketBucketContainsDisplayTemp(
bucket: MarketTopBucket | null,
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
return false;
const bucketUnit = getMarketBucketUnit(bucket);
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) return false;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (lower != null && !Number.isFinite(lower)) return false;
if (upper != null && !Number.isFinite(upper)) return false;
if (lower != null && upper != null) {
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
}
if (lower != null && isMarketBucketAbove(bucket)) {
return compareTemp >= lower - 0.01;
}
if (lower != null && isMarketBucketBelow(bucket)) {
return compareTemp <= lower + 0.01;
}
const reference = bucket.temp ?? bucket.value ?? lower;
const numeric = reference != null ? Number(reference) : null;
if (numeric == null || !Number.isFinite(numeric)) return false;
const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
return Math.abs(compareTemp - numeric) <= tolerance;
}
function getAggregatedModelProbabilityForMarketBucket(
probabilities: ProbabilityBucket[],
bucket: MarketTopBucket | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket) return null;
let total = 0;
let matched = 0;
for (const probabilityBucket of probabilities) {
const temp = getBucketTemp(probabilityBucket);
if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
const probability = Number(probabilityBucket.probability);
if (!Number.isFinite(probability)) continue;
total += probability;
matched += 1;
}
return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
}
type ProbabilityDisplayRow = {
key: string;
label: string;
probability: number;
marketBucket?: MarketTopBucket | null;
};
function formatMarketBucketDisplayLabel(
bucket: MarketTopBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
const label = String(bucket.label || "").trim();
if (label) {
const unit = getMarketBucketUnit(bucket);
let normalized = label.toUpperCase().replace(/\s+/g, "");
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°F")
.replace(/°C/g, "°F")
.replace(/(?<=\d)F/g, "°F");
} else {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°C")
.replace(/°F/g, "°C")
.replace(/(?<=\d)C/g, "°C");
}
return normalized.replace(/\+/g, "+");
}
const unit =
getMarketBucketUnit(bucket) === "F" ||
isFahrenheitSymbol(detail.temp_symbol)
? "°F"
: "°C";
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper)
) {
return `${lower}-${upper}${unit}`;
}
const value = bucket.value ?? bucket.temp ?? lower;
const numeric = value != null ? Number(value) : null;
if (numeric != null && Number.isFinite(numeric)) {
return isMarketBucketAbove(bucket)
? `${numeric}${unit}+`
: `${numeric}${unit}`;
}
return "--";
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function normalizeModelNameForVote(name: string) {
return String(name || "")
.trim()
.toLowerCase()
.replace(/[\s_/-]/g, "");
}
function getModelVoteFamily(name: string) {
const normalized = normalizeModelNameForVote(name);
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
if (normalized === "ecmwf") return "ecmwf_ifs";
return normalized || name;
}
function getModelVotePriority(name: string) {
const normalized = normalizeModelNameForVote(name);
return (
{
icond2: 40,
iconeu: 30,
icon: 20,
hrdps: 40,
rdps: 35,
gdps: 30,
gem: 20,
ecmwfaifs: 30,
ecmwf: 30,
gfs: 30,
jma: 30,
mgm: 45,
nws: 45,
openmeteo: 15,
}[normalized] || 10
);
}
function getRoundedModelVoteDistribution(
detail: CityDetail,
targetDate?: string | null,
) {
const view = getModelView(detail, targetDate);
const representatives = new Map<
string,
{ name: string; priority: number; value: number }
>();
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name);
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
const value = Number(rawValue);
if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name);
const priority = getModelVotePriority(name);
const current = representatives.get(family);
if (!current || priority > current.priority) {
representatives.set(family, { name, priority, value });
}
});
const bucketMap = new Map<number, { count: number; models: string[] }>();
representatives.forEach(({ name, value }) => {
const rounded = Math.round(value);
const row = bucketMap.get(rounded) || { count: 0, models: [] };
row.count += 1;
row.models.push(name);
bucketMap.set(rounded, row);
});
const total = representatives.size;
const rows = Array.from(bucketMap.entries())
.map(([value, row]) => ({
count: row.count,
models: row.models,
percent: total > 0 ? row.count / total : 0,
value,
}))
.sort((a, b) => b.count - a.count || b.value - a.value);
return {
rows,
total,
};
}
function normalizeMarketProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
function normalizeSignedProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (Math.abs(numeric) > 1) return numeric / 100;
return numeric;
}
function formatSignedPercent(value?: number | null, digits = 1) {
const normalized = normalizeSignedProbability(value);
if (normalized == null) return "--";
const percent = normalized * 100;
const sign = percent > 0 ? "+" : "";
return `${sign}${percent.toFixed(digits)}%`;
}
function getMarketTopBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketAllBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.all_buckets)
? scan.all_buckets
: Array.isArray(scan?.top_buckets)
? scan.top_buckets
: [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketTopBucketKey(bucket: MarketTopBucket) {
if (bucket?.value != null) {
const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
if (bucket?.temp != null) {
const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`;
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
}
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
const view = getModelView(detail, targetDate);
return Object.keys(view.models || {}).some((name) =>
normalizeModelNameForVote(name).includes("lgbm"),
);
}
function formatProbabilityEngineLabel(
detail: CityDetail,
targetDate: string | null | undefined,
locale: string,
) {
const view = getProbabilityView(detail, targetDate);
if (hasLgbmModel(detail, targetDate)) {
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
}
const engine = String(view.engine || "")
.trim()
.toLowerCase();
const calibrationMode = String(view.calibrationMode || "")
.trim()
.toLowerCase();
if (engine === "emos" || calibrationMode.includes("emos")) {
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
}
return locale === "en-US" ? "Model probability" : "模型概率";
}
export function ProbabilityDistribution({
detail,
hideTitle = false,
targetDate,
marketScan,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
marketScan?: MarketScan | null;
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const modelView = getModelView(detail, targetDate);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const probabilityEngineLabel = formatProbabilityEngineLabel(
detail,
targetDate,
locale,
);
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
const modelVoteView = useMemo(
() => getRoundedModelVoteDistribution(detail, targetDate),
[detail, targetDate],
);
const modelVoteHint = modelVoteView.rows
.slice(0, 2)
.map(
(row) =>
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
)
.join(" · ");
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
const deduped: Array<MarketTopBucket & { probability: number }> = [];
const seenKeys = new Set<string>();
for (const row of sorted) {
const key = getMarketTopBucketKey(row);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
deduped.push(row);
if (deduped.length >= 4) break;
}
return deduped;
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
const topProbability = [...(view.probabilities || [])].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
)[0];
const topProbabilityText = toPercent(topProbability?.probability);
const topProbabilityLabel = topProbability
? formatBucketDisplayLabel(topProbability, detail)
: null;
const topProbabilityTemp = topProbability
? getBucketTemp(topProbability)
: null;
const probabilitiesForMarketContracts =
view.probabilitiesAll?.length > 0
? view.probabilitiesAll
: view.probabilities || [];
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
return [];
}
const rows: ProbabilityDisplayRow[] = [];
const seenKeys = new Set<string>();
for (const marketBucket of marketAllBuckets) {
const probability = getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
marketBucket,
detail,
);
const key =
marketBucket.slug ||
marketBucket.label ||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
if (seenKeys.has(key)) continue;
seenKeys.add(key);
rows.push({
key,
label: formatMarketBucketDisplayLabel(marketBucket, detail),
probability: probability ?? 0,
marketBucket,
});
}
return rows;
}, [
detail,
isToday,
marketAllBuckets,
marketScan?.available,
probabilitiesForMarketContracts,
]);
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
() =>
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
const bucketTemp = getBucketTemp(bucket);
return {
key: `${bucket.label || bucket.value || index}`,
label: formatBucketDisplayLabel(bucket, detail),
probability: Number(bucket.probability || 0),
marketBucket: findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
),
};
}),
[detail, marketAllBuckets, view.probabilities],
);
const probabilityRows =
marketContractRows.length > 0
? marketContractRows.slice(0, 8)
: modelProbabilityRows;
const topContractRow =
marketContractRows.length > 0
? marketContractRows.reduce((best, row) =>
row.probability > best.probability ? row : best,
)
: null;
const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
const displayTopProbability =
topContractRow?.probability ??
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const displayTopProbabilityText = toPercent(displayTopProbability);
const displayUsesMarketBuckets = marketContractRows.length > 0;
const linkedMarketBucket = useMemo(() => {
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
if (topProbabilityTemp == null) return null;
return findMarketBucketForDisplayTemp(
marketAllBuckets,
topProbabilityTemp,
detail,
);
}, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
const priceAnalysis = marketScan?.price_analysis;
const yesPriceView = priceAnalysis?.yes;
const noPriceView = priceAnalysis?.no;
const linkedMarketAsk =
linkedMarketBucket?.yes_buy ??
linkedMarketBucket?.market_price ??
yesPriceView?.ask ??
null;
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedContractLabel =
topContractRow?.label ||
(linkedMarketBucket
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
: null) ||
topProbabilityLabel ||
null;
const aggregatedMarketProbability =
getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
linkedMarketBucket,
detail,
);
const linkedMarketProbability =
topContractRow?.probability ??
aggregatedMarketProbability ??
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null
? linkedMarketProbability - Number(linkedMarketAsk)
: null;
const linkedNoEdge =
linkedMarketProbability != null && linkedNoAsk != null
? 1 - linkedMarketProbability - Number(linkedNoAsk)
: null;
const linkedBestSide =
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
? linkedNoEdge > linkedMarketEdge
? "no"
: "yes"
: null;
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
const linkedBestEdge =
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
const preferredPriceView = linkedMarketBucket
? {
ask: linkedBestAsk,
edge: linkedBestEdge,
}
: priceAnalysis?.best_side === "no"
? noPriceView
: yesPriceView;
const preferredSideLabel = linkedMarketBucket
? linkedBestSide === "no"
? "NO"
: "YES"
: priceAnalysis?.best_side === "no"
? locale === "en-US"
? "NO"
: "NO"
: locale === "en-US"
? "YES"
: "YES";
const yesDisplayPrice = linkedMarketBucket
? linkedMarketAsk
: yesPriceView?.ask;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge
: yesPriceView?.edge;
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
const hasPriceAnalysis =
isToday &&
(Boolean(priceAnalysis?.available) ||
Boolean(marketScan) ||
Boolean(topProbability));
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
const lockAvailable = Boolean(
priceAnalysis?.lock?.available && lockEdge != null,
);
const quoteSource =
linkedMarketBucket?.quote_source ||
marketScan?.yes_token?.quote_source ||
marketScan?.no_token?.quote_source ||
null;
const quoteAgeMs =
linkedMarketBucket?.quote_age_ms ??
marketScan?.yes_token?.quote_age_ms ??
marketScan?.no_token?.quote_age_ms;
const quoteSourceLabel =
quoteSource === "polymarket_ws"
? locale === "en-US"
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}` : ""}`
: locale === "en-US"
? "CLOB fallback"
: "CLOB 兜底";
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
const linkedContractOverpriced =
Boolean(linkedMarketBucket) &&
linkedBestSide === "no" &&
linkedMarketProbability != null &&
linkedMarketAsk != null &&
linkedMarketEdge != null &&
linkedMarketEdge < 0 &&
linkedNoEdge != null &&
linkedNoEdge > 0;
const linkedContractOverpay =
linkedContractOverpriced &&
linkedMarketProbability != null &&
linkedMarketAsk != null
? Number(linkedMarketAsk) - linkedMarketProbability
: null;
const actionText = !marketScan
? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? locale === "en-US"
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No quote"
: "无报价"
: actionableEdge >= 0.02
? linkedContractOverpriced
? locale === "en-US"
? "Overpriced"
: "市场偏贵"
: locale === "en-US"
? `Watch ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}`
: actionableEdge > 0
? linkedContractOverpriced
? locale === "en-US"
? "Slightly overpriced"
: "略偏贵"
: locale === "en-US"
? `Small ${preferredSideLabel}`
: `${preferredSideLabel} 优势较小`
: locale === "en-US"
? "No clear edge"
: "暂无优势";
const actionNote =
linkedContractOverpriced && linkedContractOverpay != null
? locale === "en-US"
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
: actionableEdge != null && actionableEdge >= 0.02
? locale === "en-US"
? `${formatSignedPercent(actionableEdge)} vs ask`
: `相对买价 ${formatSignedPercent(actionableEdge)}`
: locale === "en-US"
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
return (
<section className="prob-section">
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
<div className="prob-calibration-head">
<div>
<span className="prob-source-chip">{probabilityEngineLabel}</span>
<strong>
{displayTopLabel && displayTopProbabilityText
? locale === "en-US"
? displayUsesMarketBuckets
? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
: `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
: displayUsesMarketBuckets
? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
: `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
: locale === "en-US"
? "Awaiting calibrated buckets"
: "等待校准概率桶"}
</strong>
</div>
<p>
{hasLgbmProbability
? locale === "en-US"
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
: locale === "en-US"
? "Using the calibrated probability distribution; raw model points below are not probabilities."
: "使用校准后的概率分布;下方原始模型落点不是概率。"}
</p>
</div>
{marketScan?.available && (topMarketBucketText || marketYesText) && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{useMarketTopBuckets
? locale === "en-US"
? `Market reference only: top traded bucket ${topMarketBucketText}`
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
: locale === "en-US"
? `Market reference only: this bucket ${marketYesText}`
: `市场仅作参考:该温度桶 ${marketYesText}`}
</div>
)}
<div className="prob-distribution-panel">
<div className="prob-distribution-head">
<span>
{locale === "en-US"
? "EMOS probability distribution"
: "EMOS 概率分布"}
</span>
<em>
{marketContractRows.length > 0
? locale === "en-US"
? "market buckets are aggregated from single-degree EMOS buckets"
: "市场合约桶由单点 EMOS 概率聚合"
: locale === "en-US"
? "calibrated temperature buckets"
: "校准后的温度桶"}
</em>
</div>
{probabilityRows.length === 0 ? (
<EmptyState text={t("section.noProb")} />
) : (
probabilityRows.map((row, index) => {
const probability = Math.round(
Number(row.probability || 0) * 100,
);
return (
<div key={`${row.key || index}`} className="prob-row">
<div className="prob-label">{row.label}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
</div>
);
})
)}
</div>
{hasPriceAnalysis && (
<div className="prob-price-card">
<div className="prob-price-head">
<span>
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
</span>
<strong>
{!marketScan
? locale === "en-US"
? "Waiting for market context"
: "等待市场参照"
: !marketScan.available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: locale === "en-US"
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>
{locale === "en-US" ? "Bucket" : "温度桶"}
</span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
</strong>
<em>
{linkedMarketProbabilityText || topProbabilityText || "--"}
</em>
</div>
<div>
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
<strong>
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
<strong>{modelVoteHint || "--"}</strong>
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
<em>{quoteSourceLabel}</em>
</div>
</div>
<p>
{locale === "en-US"
? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
: "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
</p>
</div>
)}
{modelVoteHint && (
<div className="prob-model-hint">
<span>
{locale === "en-US" ? "Raw model points" : "原始模型落点"}
</span>
<strong>{modelVoteHint}</strong>
<em>
{locale === "en-US"
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
: "仅作诊断;EMOS 与合约行使用校准概率"}
</em>
</div>
)}
</div>
</section>
);
}
@@ -81,7 +81,7 @@ export function ScanFilterPanel({
<span>{isEn ? "Scan Mode" : "扫描模式"}</span>
<Info size={14} />
</div>
<div className="scan-mode-tabs">
<div className="scan-mode-tabs" role="tablist" aria-label={isEn ? "Scan mode" : "扫描模式"}>
{SCAN_MODES.map((mode) => {
const Icon = mode.icon;
const isActive = value.scan_mode === mode.key;
@@ -89,6 +89,8 @@ export function ScanFilterPanel({
<button
key={mode.key}
type="button"
role="tab"
aria-selected={isActive}
className={`scan-mode-tab ${isActive ? "active" : ""}`}
onClick={() => updateFilter("scan_mode", mode.key)}
>
@@ -1,65 +0,0 @@
"use client";
import React from "react";
import { useI18n } from "@/hooks/useI18n";
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
function formatTemperature(value?: number | null, unit?: string | null): string {
if (value == null || Number.isNaN(Number(value))) return "--";
return `${Number(value).toFixed(1)}${unit || "°C"}`;
}
export function ScanKPIBar({
response,
rows,
totalCities,
loading: _loading,
}: {
response: ScanTerminalResponse | null;
rows: ScanOpportunityRow[];
totalCities: number;
loading: boolean;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const bestRow = rows[0] || null;
const cards = [
{
label: isEn ? "AI Workspace" : "AI 工作区",
value: `${rows.length}/${totalCities || 0}`,
note: isEn
? "No automatic scan. Cities are added from map clicks."
: "不主动扫描;地图点选后加入城市分析。",
tone: response?.status === "failed" ? "red" : response?.status === "stale" ? "amber" : "cyan",
},
{
label: isEn ? "City Pool" : "城市池",
value: `${totalCities || 0}`,
note: isEn ? "Available cities for forecast review" : "可点击进入 AI 预测的城市范围",
tone: "blue",
},
{
label: isEn ? "Forecast Center" : "预测中枢",
value: bestRow ? formatTemperature(bestRow.deb_prediction, bestRow.temp_symbol || bestRow.target_unit) : "--",
note: bestRow
? `${isEn ? "Focus" : "焦点"} ${bestRow.city_display_name || bestRow.display_name || bestRow.city} · DEB / ${isEn ? "models" : "模型"} / METAR`
: isEn
? "Select a city to load DEB and observations"
: "选择城市后展示 DEB 与实测路径",
tone: "green",
},
];
return (
<section className="scan-kpi-bar">
{cards.map((card) => (
<article key={card.label} className={`scan-kpi-card ${card.tone}`}>
<div className="scan-kpi-label">{card.label}</div>
<div className="scan-kpi-value">{card.value}</div>
<div className="scan-kpi-note">{card.note}</div>
</article>
))}
</section>
);
}
@@ -0,0 +1,556 @@
/*
SCAN TERMINAL 3-Column Layout
*/
.root :global(.scan-terminal) {
display: flex;
width: 100%;
height: calc(100vh - var(--header-height, 72px));
margin-top: var(--header-height, 72px);
position: relative;
z-index: 2;
}
/* ── Left: Filter Panel ── */
.root :global(.scan-filter-panel) {
width: 220px;
min-width: 220px;
height: 100%;
background: var(--bg-secondary);
border-right: 1px solid var(--border-glass);
display: flex;
flex-direction: column;
padding: 20px 16px;
gap: 20px;
overflow-y: auto;
}
.root :global(.scan-filter-section) {
display: flex;
flex-direction: column;
gap: 12px;
}
.root :global(.scan-filter-label) {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
/* Mode Tabs */
.root :global(.scan-mode-tabs) {
display: flex;
flex-direction: column;
gap: 4px;
}
.root :global(.scan-mode-tab) {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-align: left;
position: relative;
}
.root :global(.scan-mode-tab:hover) {
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
}
.root :global(.scan-mode-tab.active) {
background: rgba(0, 224, 164, 0.08);
color: var(--color-accent-primary);
font-weight: 600;
}
.root :global(.scan-mode-tab.active)::before {
content: "";
position: absolute;
left: 0;
top: 25%;
height: 50%;
width: 3px;
background: var(--color-accent-primary);
border-radius: 0 2px 2px 0;
}
.root :global(.scan-mode-tab-indicator) {
display: none;
}
/* Filter Rows */
.root :global(.scan-filter-row) {
display: flex;
flex-direction: column;
gap: 6px;
}
.root :global(.scan-filter-row-label) {
font-size: 12px;
color: var(--text-muted);
}
.root :global(.scan-range-display) {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.root :global(.scan-range-slider) {
flex: 1;
appearance: none;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
outline: none;
}
.root :global(.scan-range-slider::-webkit-slider-thumb) {
appearance: none;
width: 14px;
height: 14px;
background: var(--color-accent-primary);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--bg-secondary);
}
/* Toggle */
.root :global(.scan-toggle) {
width: 36px;
height: 20px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
position: relative;
transition: all 0.2s;
padding: 0;
}
.root :global(.scan-toggle.active) {
background: rgba(0, 224, 164, 0.3);
border-color: var(--color-accent-primary);
}
.root :global(.scan-toggle-knob) {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--text-secondary);
position: absolute;
top: 2px;
left: 2px;
transition: all 0.2s;
}
.root :global(.scan-toggle.active .scan-toggle-knob) {
left: 18px;
background: var(--color-accent-primary);
}
/* Select */
.root :global(.scan-select) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
color: var(--text-primary);
outline: none;
cursor: pointer;
width: 100%;
}
.root :global(.scan-select:focus) {
border-color: var(--color-accent-primary);
}
/* Scan CTA */
.root :global(.scan-cta-button) {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, var(--color-accent-primary), #00c48f);
color: #030711;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.25s;
margin-top: auto;
}
.root :global(.scan-cta-button:hover) {
background: linear-gradient(135deg, #00f0b0, #00d89a);
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(0, 224, 164, 0.3);
}
.root :global(.scan-cta-button:disabled) {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ── Center: Data Grid ── */
.root :global(.scan-data-grid) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
gap: 16px;
}
.root :global(.scan-data-grid-header) {
display: flex;
align-items: center;
justify-content: space-between;
}
.root :global(.scan-data-grid-title) {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.root :global(.scan-data-grid-subtitle) {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.root :global(.scan-data-grid-controls) {
display: flex;
align-items: center;
gap: 8px;
}
/* View Tabs */
.root :global(.scan-view-tabs) {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-subtle);
}
.root :global(.scan-view-tab) {
padding: 8px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.root :global(.scan-view-tab:hover) {
color: var(--text-primary);
}
.root :global(.scan-view-tab.active) {
color: var(--color-accent-primary);
font-weight: 600;
}
.root :global(.scan-view-tab.active)::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--color-accent-primary);
border-radius: 4px;
opacity: 0.8;
transition: all 0.3s ease;
}
/* ── Right: Scan Detail Panel ── */
.root :global(.scan-detail-panel) {
width: 360px;
min-width: 360px;
height: 100%;
border-left: 1px solid var(--border-glass);
background: var(--bg-secondary);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.root :global(.scan-detail-header) {
padding: 20px;
display: flex;
align-items: flex-start;
gap: 14px;
border-bottom: 1px solid var(--border-subtle);
}
.root :global(.scan-detail-hero-img) {
width: 64px;
height: 64px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
}
.root :global(.scan-detail-hero-placeholder) {
width: 64px;
height: 64px;
border-radius: 10px;
background: linear-gradient(
135deg,
rgba(0, 224, 164, 0.15),
rgba(123, 97, 255, 0.15)
);
flex-shrink: 0;
}
.root :global(.scan-detail-city-info) {
flex: 1;
min-width: 0;
}
.root :global(.scan-detail-city-name) {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.root :global(.scan-detail-city-sub) {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.root :global(.scan-detail-volume) {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
margin-top: 6px;
}
.root :global(.scan-detail-section) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.root :global(.scan-detail-section-title) {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 12px;
}
/* Conditions Table */
.root :global(.scan-conditions-table) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.root :global(.scan-condition-item) {
display: flex;
flex-direction: column;
gap: 2px;
}
.root :global(.scan-condition-label) {
font-size: 11px;
color: var(--text-muted);
}
.root :global(.scan-condition-value) {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.root :global(.scan-condition-value.accent-green) {
color: #22C55E;
}
.root :global(.scan-condition-value.accent-red) {
color: #EF4444;
}
/* Trade Cards */
.root :global(.scan-trade-cards) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.root :global(.scan-trade-card) {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.root :global(.scan-trade-card.yes) {
border-top: 2px solid var(--color-accent-primary);
}
.root :global(.scan-trade-card.no) {
border-top: 2px solid #EF4444;
}
.root :global(.scan-trade-card-title) {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.root :global(.scan-trade-card-price) {
font-size: 12px;
color: var(--text-secondary);
}
.root :global(.scan-trade-card-edge) {
font-size: 11px;
font-weight: 600;
}
.root :global(.scan-trade-card-edge.positive) {
color: var(--color-accent-primary);
}
.root :global(.scan-trade-card-edge.negative) {
color: #EF4444;
}
.root :global(.scan-trade-card-note) {
font-size: 10px;
color: var(--text-muted);
}
/* Score Gauge */
.root :global(.scan-detail-score) {
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.root :global(.scan-detail-score-big) {
font-size: 40px;
font-weight: 900;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.root :global(.scan-detail-score-label) {
font-size: 12px;
color: var(--text-muted);
}
.root :global(.scan-detail-score-suffix) {
font-size: 18px;
font-weight: 600;
color: var(--text-muted);
}
/* Confidence Dots */
.root :global(.scan-confidence-dots) {
display: flex;
gap: 4px;
align-items: center;
}
.root :global(.scan-confidence-dot) {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
}
.root :global(.scan-confidence-dot.filled) {
background: var(--color-accent-primary);
}
.root :global(.scan-confidence-dot.filled.amber) {
background: #F59E0B;
}
.root :global(.scan-confidence-dot.filled.red) {
background: #EF4444;
}
/* Empty state */
.root :global(.scan-detail-empty) {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 13px;
padding: 40px;
text-align: center;
}
/*
SCAN TERMINAL OVERRIDES EMOS DESK
*/
.root :global(.scan-terminal) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(380px, 420px);
gap: 14px;
width: 100%;
min-height: 100vh;
height: auto;
margin-top: 0;
padding: 16px;
background:
radial-gradient(circle at top, rgba(77, 163, 255, 0.12), transparent 36%),
linear-gradient(180deg, #0B1220 0%, #07101D 100%);
overflow: auto;
}
.root :global(.scan-filter-panel),
.root :global(.scan-data-grid),
.root :global(.scan-detail-panel) {
min-height: calc(100vh - 32px);
border: 1px solid rgba(159, 178, 199, 0.14);
border-radius: 22px;
background: linear-gradient(180deg, rgba(17, 26, 46, 0.96), rgba(11, 18, 32, 0.96));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.root :global(.scan-data-grid) {
padding: 18px;
gap: 18px;
overflow: hidden;
min-height: calc(100vh - 32px);
max-height: calc(100vh - 32px);
}
@@ -0,0 +1,734 @@
/* Scan terminal scroll containers, map/calendar/settings views, table rows, and AI analysis workspace. */
.root :global(.scan-detail-panel) {
scrollbar-width: none;
-ms-overflow-style: none;
}
.root :global(.scan-map-view),
.root :global(.scan-calendar-view),
.root :global(.scan-settings-view) {
flex: 1;
min-height: 0;
}
.root :global(.scan-map-view) {
display: flex;
flex-direction: column;
gap: 12px;
block-size: 100%;
}
.root :global(.scan-map-shell) {
position: relative;
flex: 1;
min-height: clamp(560px, calc(100dvh - 292px), 760px);
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
overflow: hidden;
background: rgba(8, 17, 30, 0.8);
}
.root :global(.scan-upgrade-announcement) {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding: 16px 20px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 16px;
background:
radial-gradient(circle at top left, rgba(77, 163, 255, 0.08), transparent 36%),
rgba(11, 18, 32, 0.66);
backdrop-filter: blur(8px);
}
.root :global(.scan-upgrade-announcement-copy) {
display: grid;
gap: 6px;
}
.root :global(.scan-upgrade-announcement-copy span) {
color: var(--color-accent-primary);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.root :global(.scan-upgrade-announcement-copy strong) {
color: var(--color-text-primary);
font-size: 18px;
font-weight: 900;
}
.root :global(.scan-upgrade-announcement-copy p) {
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.55;
}
.root :global(.scan-upgrade-announcement ul) {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0;
list-style: none;
}
.root :global(.scan-upgrade-announcement li) {
padding: 5px 10px;
border: 1px solid rgba(77, 163, 255, 0.14);
border-radius: 999px;
background: rgba(77, 163, 255, 0.08);
color: #cfe5ff;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-terminal.focus-view-active .scan-upgrade-announcement) {
display: none;
}
.root :global(.scan-terminal.focus-view-active .scan-data-grid) {
gap: 12px;
}
.root :global(.scan-terminal.focus-view-active .scan-list-section) {
min-height: min(780px, calc(100dvh - 136px));
}
.root :global(.scan-terminal.focus-view-active .scan-list-header) {
margin-bottom: 8px;
}
.root :global(.scan-terminal.focus-view-active .scan-topbar) {
padding-bottom: 12px;
}
@media (max-width: 1680px), (max-height: 900px) {
.root :global(.scan-terminal.map-view-active > .detail-panel.scan-city-detail-rail) {
display: none;
}
}
.root :global(.scan-map-shell .map) {
position: absolute;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 18px;
box-shadow: none;
}
.root :global(.scan-map-shell .leaflet-container) {
width: 100%;
height: 100%;
background: #060d18;
}
.root :global(.scan-map-caption) {
color: #8fa4c3;
font-size: 13px;
}
.root :global(.scan-summary-card.wide) {
grid-column: span 1;
}
.root :global(.scan-summary-label) {
font-size: 13px;
color: #8fa4c3;
}
.root :global(.scan-summary-value) {
margin-top: 10px;
font-size: 28px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-settings-view) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-settings-copy) {
margin-top: 10px;
color: #c8d8ee;
font-size: 14px;
line-height: 1.6;
}
.root :global(.scan-table-row) {
display: grid;
grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
gap: 10px;
align-items: center;
min-height: 132px;
padding: 14px 18px;
border: none;
border-bottom: 1px solid rgba(90, 123, 166, 0.08);
border-radius: 0;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
text-align: left;
}
.root :global(.scan-table-row > *),
.root :global(.scan-table-header > *) {
min-width: 0;
}
.root :global(.scan-table-row:hover) {
transform: none;
background: linear-gradient(180deg, rgba(11, 24, 40, 0.96), rgba(8, 18, 31, 0.96));
}
.root :global(.scan-table-row.selected) {
background: linear-gradient(180deg, rgba(7, 34, 35, 0.92), rgba(8, 19, 29, 0.92));
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.48);
}
.root :global(.scan-edge-cell) {
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.scan-city-cell) {
display: block;
}
.root :global(.scan-city-copy) {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.root :global(.scan-city-name) {
font-size: 18px;
font-weight: 800;
}
.root :global(.scan-city-sub) {
font-size: 14px;
color: #a5bad5;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.root :global(.scan-time-cell) {
display: flex;
flex-direction: column;
gap: 10px;
}
.root :global(.scan-time-main) {
font-size: 16px;
font-weight: 700;
}
.root :global(.scan-time-remaining) {
font-size: 14px;
color: #9eb3ce;
}
.root :global(.scan-phase-badge),
.root :global(.scan-status-badge) {
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.12);
text-transform: none;
}
.root :global(.scan-phase-badge.red) {
color: #ff7f7f;
background: rgba(255, 93, 93, 0.1);
}
.root :global(.scan-phase-badge.green) {
color: #34e49f;
background: rgba(23, 217, 139, 0.12);
}
.root :global(.scan-phase-badge.blue) {
color: #7cb5ff;
background: rgba(63, 140, 255, 0.12);
}
.root :global(.scan-phase-badge.amber) {
color: #ffc14b;
background: rgba(255, 176, 32, 0.12);
}
.root :global(.scan-distribution-preview) {
display: block;
min-width: 0;
}
.root :global(.scan-model-compare) {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid rgba(86, 120, 168, 0.16);
border-radius: 12px;
background: rgba(13, 27, 45, 0.7);
}
.root :global(.scan-model-primary) {
display: grid;
grid-template-columns: minmax(76px, 1fr) auto auto;
gap: 8px;
align-items: baseline;
}
.root :global(.scan-model-primary span),
.root :global(.scan-model-rows b) {
color: rgba(146, 168, 197, 0.92);
font-size: 11px;
font-weight: 800;
}
.root :global(.scan-model-primary strong) {
color: #22eb98;
font-size: 18px;
font-weight: 900;
line-height: 1;
}
.root :global(.scan-model-primary em) {
color: #d7e7fb;
font-size: 13px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
.root :global(.scan-model-rows) {
display: flex;
flex-direction: column;
gap: 5px;
}
.root :global(.scan-model-rows span) {
display: grid;
grid-template-columns: 42px minmax(54px, auto) minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.root :global(.scan-model-rows strong) {
color: #eef7ff;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.root :global(.scan-model-rows em) {
overflow: hidden;
color: #9fb4cf;
font-size: 12px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.scan-trade-cell) {
display: flex;
flex-direction: column;
gap: 8px;
}
.root :global(.scan-trade-main) {
font-size: 17px;
font-weight: 800;
overflow-wrap: anywhere;
}
.root :global(.scan-trade-main.buy) {
color: #26e896;
}
.root :global(.scan-trade-main.sell) {
color: #ff6969;
}
.root :global(.scan-trade-sub),
.root :global(.scan-trade-note) {
font-size: 14px;
color: #a5bad5;
}
.root :global(.scan-edge-cell) {
font-size: 18px;
font-weight: 800;
}
.root :global(.scan-edge-cell.positive) {
color: #1de28f;
}
.root :global(.scan-edge-cell.negative) {
color: #ff6868;
}
.root :global(.scan-row-fav) {
display: none;
}
.root :global(.scan-view-all-wrap) {
display: flex;
justify-content: center;
padding: 16px;
}
.root :global(.scan-view-all-button) {
padding: 12px 18px;
font-weight: 700;
}
.root :global(.scan-ai-log-panel) {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
border: 1px solid rgba(68, 100, 150, 0.18);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(9, 23, 38, 0.92), rgba(7, 17, 30, 0.92)),
radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
}
.root :global(.scan-ai-log-panel.compact) {
flex: 0 0 auto;
display: block;
margin-top: 10px;
padding: 0;
overflow: hidden;
border-radius: 14px;
background: rgba(7, 17, 30, 0.72);
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 14px;
color: #9fb4cf;
font-size: 12px;
font-weight: 900;
cursor: pointer;
list-style: none;
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) {
color: #c7d8ef;
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) {
color: #7fb4ff;
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) {
padding: 0 12px 12px;
}
.root :global(.scan-ai-analysis-view) {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 520px;
}
.root :global(.scan-ai-analysis-view.empty) {
min-height: 620px;
}
.root :global(.scan-ai-summary-card) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)),
radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%);
}
.root :global(.scan-ai-summary-card strong) {
color: #ecf8ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-ai-summary-card p) {
margin: 6px 0 0;
color: #a6bbd7;
font-size: 13px;
font-weight: 700;
line-height: 1.55;
}
.root :global(.scan-ai-inline-button) {
min-height: 42px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 12px;
color: #d9fff6;
background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4));
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.root :global(.scan-ai-inline-button:disabled) {
cursor: not-allowed;
opacity: 0.48;
}
.root :global(.scan-ai-log-panel.in-tab) {
margin: 0;
}
.root :global(.scan-ai-city-list) {
display: flex;
flex-direction: column;
gap: 12px;
}
.root :global(.scan-ai-city-card) {
border: 1px solid rgba(68, 100, 150, 0.2);
border-radius: 18px;
overflow: hidden;
background: rgba(10, 24, 42, 0.82);
}
.root :global(.scan-ai-city-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
background: rgba(15, 34, 57, 0.9);
}
.root :global(.scan-ai-city-head strong) {
color: #f2f8ff;
font-size: 17px;
font-weight: 900;
}
.root :global(.scan-ai-city-head p) {
margin: 6px 0 0;
color: #9fb4d2;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-ai-city-head span) {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
color: #68f3c8;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.2);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-cluster-note) {
margin: 0 18px 14px;
padding: 10px 12px;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 12px;
color: #b7cbe6;
background: rgba(6, 18, 32, 0.5);
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-ai-contracts) {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
}
.root :global(.scan-ai-contract) {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 10px 16px;
align-items: center;
padding: 12px;
border-top: 1px solid rgba(68, 100, 150, 0.16);
}
.root :global(.scan-ai-contract b) {
display: block;
color: #edf7ff;
font-size: 13px;
font-weight: 900;
}
.root :global(.scan-ai-contract small) {
display: block;
margin-top: 3px;
color: #8fa8c7;
font-size: 11px;
font-weight: 800;
}
.root :global(.scan-ai-contract > span) {
justify-self: end;
padding: 5px 9px;
border-radius: 999px;
color: #a8bedc;
background: rgba(68, 100, 150, 0.16);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-contract.approve > span) {
color: #23f0a8;
background: rgba(16, 185, 129, 0.14);
}
.root :global(.scan-ai-contract.veto > span) {
color: #ff8a8a;
background: rgba(248, 113, 113, 0.12);
}
.root :global(.scan-ai-contract.downgrade > span),
.root :global(.scan-ai-contract.watchlist > span) {
color: #ffd166;
background: rgba(245, 158, 11, 0.12);
}
.root :global(.scan-ai-contract p) {
grid-column: 1 / -1;
margin: 0;
color: #9fb4d2;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-ai-log-head) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.root :global(.scan-ai-log-head strong) {
color: #e9f5ff;
font-size: 13px;
font-weight: 900;
}
.root :global(.scan-ai-log-head span) {
color: #8fa8c7;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-ai-log-list) {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.root :global(.scan-ai-log-list::-webkit-scrollbar) {
height: 5px;
}
.root :global(.scan-ai-log-list::-webkit-scrollbar-thumb) {
background: rgba(88, 125, 180, 0.26);
border-radius: 999px;
}
.root :global(.scan-ai-log-item) {
display: grid;
grid-template-columns: auto minmax(180px, 1fr);
gap: 8px;
min-width: 260px;
padding: 9px 10px;
border: 1px solid rgba(68, 100, 150, 0.16);
border-radius: 12px;
background: rgba(14, 29, 48, 0.76);
}
.root :global(.scan-ai-log-item b) {
display: block;
color: #edf7ff;
font-size: 12px;
font-weight: 900;
line-height: 1.25;
}
.root :global(.scan-ai-log-item small) {
display: block;
margin-top: 3px;
color: #9fb4d2;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.root :global(.scan-ai-log-time) {
color: #7892b1;
font-size: 11px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.root :global(.scan-ai-log-item.info) {
border-color: rgba(56, 189, 248, 0.22);
}
.root :global(.scan-ai-log-item.success) {
border-color: rgba(16, 185, 129, 0.32);
}
.root :global(.scan-ai-log-item.warning) {
border-color: rgba(245, 158, 11, 0.34);
}
.root :global(.scan-ai-log-item.error) {
border-color: rgba(248, 113, 113, 0.34);
}
.root :global(.scan-ai-log-empty) {
color: #8fa8c7;
font-size: 12px;
font-weight: 800;
}
@@ -0,0 +1,170 @@
/* Scan terminal calendar/action view styles. */
.root :global(.scan-calendar-view) {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
}
.root :global(.scan-calendar-group) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: 460px;
}
.root :global(.scan-calendar-group-head) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid rgba(90, 123, 166, 0.1);
}
.root :global(.scan-calendar-date) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-calendar-count) {
font-size: 13px;
color: #8fa4c3;
}
.root :global(.scan-calendar-subtitle) {
margin-top: 4px;
color: #7f96b6;
font-size: 12px;
font-weight: 700;
}
.root :global(.scan-calendar-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
padding: 14px;
}
.root :global(.scan-calendar-card),
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
}
.root :global(.scan-calendar-card) {
text-align: left;
padding: 14px;
cursor: pointer;
}
.root :global(.scan-calendar-card.selected) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42);
}
.root :global(.scan-calendar-card.peak-active) {
border-color: rgba(23, 217, 139, 0.34);
background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
}
.root :global(.scan-calendar-card.peak-next) {
border-color: rgba(94, 234, 212, 0.3);
}
.root :global(.scan-calendar-card.peak-past) {
opacity: 0.78;
}
.root :global(.scan-calendar-city) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-calendar-action) {
margin-top: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 15px;
font-weight: 800;
color: #dcecff;
}
.root :global(.scan-calendar-action span) {
color: #8fa4c3;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-action b) {
color: #eef7ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-calendar-action.buy) {
color: #21e391;
}
.root :global(.scan-calendar-action.sell) {
color: #ff6e6e;
}
.root :global(.scan-calendar-meta) {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 12px;
color: #8fa4c3;
font-size: 13px;
}
.root :global(.scan-calendar-countdown) {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.18);
border-radius: 12px;
background: rgba(20, 184, 166, 0.1);
color: #5fffe8;
font-size: 15px;
font-weight: 900;
}
.root :global(.scan-calendar-countdown small) {
display: block;
margin-top: 4px;
color: #9fb4d2;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-reason) {
margin: 10px 0 0;
color: #d6e4f5;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-summary-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-summary-grid.monitor) {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
padding: 18px;
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
LogIn,
@@ -17,11 +18,7 @@ import {
useState,
} from "react";
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
import { MapCanvas } from "@/components/dashboard/MapCanvas";
import { scanRootClass } from "./scan-root-styles";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import {
DashboardStoreProvider,
@@ -31,37 +28,49 @@ import { I18nProvider, useI18n } from "@/hooks/useI18n";
import type {
CityDetail,
ScanOpportunityRow,
ScanTerminalResponse,
} from "@/lib/dashboard-types";
import {
buildBrowserBackendHeaders,
fetchBackendApi,
} from "@/lib/backend-api";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview";
import {
findDetailForCity,
isFullEnoughForDeepAnalysis,
waitForDeepAnalysisQueue,
} from "@/components/dashboard/scan-terminal/city-detail-utils";
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import {
findRowForCity,
formatUserLocalTime,
normalizeCityKey,
prettifyCityName,
rowMatchesCity,
sortRowsByUserTime,
} from "@/components/dashboard/scan-terminal/decision-utils";
import { useAiPinnedCityWorkspace } from "@/components/dashboard/scan-terminal/use-ai-pinned-city-workspace";
import { useScanTerminalQuery } from "@/components/dashboard/scan-terminal/use-scan-terminal-query";
import {
useScanTerminalTheme,
useUserLocalClock,
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
type ThemeMode = "dark" | "light";
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
type ContentView = "analysis" | "map" | "calendar";
const CityDetailPanel = dynamic(
() =>
import("@/components/dashboard/DetailPanel").then(
(module) => module.DetailPanel,
),
{ ssr: false },
);
const FutureForecastModal = dynamic(
() =>
import("@/components/dashboard/FutureForecastModal").then(
(module) => module.FutureForecastModal,
),
{ ssr: false },
);
const MapCanvas = dynamic(
() =>
import("@/components/dashboard/MapCanvas").then(
(module) => module.MapCanvas,
),
{ ssr: false },
);
function ScanTerminalScreen() {
const store = useDashboardStore();
@@ -71,40 +80,56 @@ function ScanTerminalScreen() {
const accountHref = store.proAccess.authenticated
? "/account"
: "/auth/login?next=%2Faccount";
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
const [scanLoading, setScanLoading] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const {
refreshScanTerminalManually,
scanError,
scanLoading,
terminalData,
} = useScanTerminalQuery({
isPro,
proAccessLoading: store.proAccess.loading,
});
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<ContentView>("opportunities");
const [activeView, setActiveView] = useState<ContentView>("map");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
const [showScanPaywall, setShowScanPaywall] = useState(false);
const [userLocalTime, setUserLocalTime] = useState("--");
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
const lastMapSelectedCityRef = useRef<string>("");
const aiFullHydrationRef = useRef<Set<string>>(new Set());
const aiHydrationQueueRef = useRef<string[]>([]);
const aiHydrationRunningRef = useRef(false);
const scanRequestSeqRef = useRef(0);
const scanLoadingRef = useRef(false);
const lastForcedScanRefreshAtRef = useRef(0);
const [showAnnouncement, setShowAnnouncement] = useState(false);
useEffect(() => {
const root = document.documentElement;
const hadLight = root.classList.contains("light");
const hadDark = root.classList.contains("dark");
root.classList.toggle("light", themeMode === "light");
root.classList.toggle("dark", themeMode === "dark");
return () => {
root.classList.toggle("light", hadLight);
root.classList.toggle("dark", hadDark);
};
}, [themeMode]);
const key = "polyweather_v156_announcement_seen_at";
const seen = localStorage.getItem(key);
const now = Date.now();
if (!seen) {
localStorage.setItem(key, String(now));
setShowAnnouncement(true);
return;
}
const elapsed = now - Number(seen);
setShowAnnouncement(elapsed < 3 * 24 * 60 * 60 * 1000);
}, []);
const userLocalTime = useUserLocalClock();
const { setThemeMode, themeMode } = useScanTerminalTheme();
const lastMapSelectedCityRef = useRef<string>("");
const scanTerminalRootClassName = clsx(
styles.root,
scanRootClass,
themeMode === "light" && "light",
);
const timeSortedRows = useMemo(
() => sortRowsByUserTime(terminalData?.rows || []),
[terminalData?.rows],
);
const {
addAiPinnedCity,
aiPinnedCities,
refreshAiPinnedCityDetail,
removeAiPinnedCity,
} = useAiPinnedCityWorkspace({
locale,
store,
timeSortedRows,
});
const selectedRow = useMemo(() => {
if (!timeSortedRows.length) return null;
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
@@ -122,19 +147,6 @@ function ScanTerminalScreen() {
mapSelectedCityName || store.selectedCity,
);
}, [mapSelectedCityName, store.selectedCity, timeSortedRows]);
const kpiCityName =
mapSelectedCityName ||
store.selectedCity ||
aiPinnedCities[0]?.cityName ||
null;
const kpiDetail =
findDetailForCity(store.cityDetailsByName, kpiCityName) ||
(store.selectedDetail &&
normalizeCityKey(store.selectedDetail.name) === normalizeCityKey(kpiCityName)
? store.selectedDetail
: null);
const kpiRow = findRowForCity(timeSortedRows, kpiCityName);
const mapFallbackRow = useMemo(() => {
const rawCityName = mapSelectedCityName || store.selectedCity;
const cityKey = normalizeCityKey(rawCityName);
@@ -229,130 +241,6 @@ function ScanTerminalScreen() {
}
}, [activeView, isPro, store.proAccess.loading]);
const fetchScanTerminal = useCallback(
async ({
forceRefresh = false,
showLoading = false,
}: {
forceRefresh?: boolean;
showLoading?: boolean;
} = {}) => {
if (store.proAccess.loading || !isPro) return;
const requestSeq = ++scanRequestSeqRef.current;
const controller = new AbortController();
if (forceRefresh) {
lastForcedScanRefreshAtRef.current = Date.now();
}
if (showLoading) {
scanLoadingRef.current = true;
setScanLoading(true);
}
setScanError(null);
const params = new URLSearchParams({
scan_mode: "tradable",
min_price: "0.05",
max_price: "0.95",
min_edge_pct: "2",
min_liquidity: "500",
market_type: "maxtemp",
time_range: "today",
limit: "36",
force_refresh: String(forceRefresh),
});
if (forceRefresh) {
params.set("_ts", String(Date.now()));
}
try {
const headers = await buildBrowserBackendHeaders({
Accept: "application/json",
});
const response = await fetchBackendApi(`/api/scan/terminal?${params.toString()}`, {
cache: "no-store",
headers,
signal: controller.signal,
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const payload = await response.json();
message = String(payload?.error || payload?.detail || message);
} catch {
// Keep HTTP status message.
}
throw new Error(message);
}
const payload = (await response.json()) as ScanTerminalResponse;
if (requestSeq !== scanRequestSeqRef.current) return;
setTerminalData(payload);
setScanError(null);
} catch (error) {
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
setScanError(error instanceof Error ? error.message : String(error));
} finally {
if (showLoading) {
scanLoadingRef.current = false;
setScanLoading(false);
}
}
},
[isPro, store.proAccess.loading],
);
useEffect(() => {
if (store.proAccess.loading) return;
if (!isPro) {
scanLoadingRef.current = false;
setScanLoading(false);
setScanError(null);
setTerminalData(null);
return;
}
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
}, [fetchScanTerminal, isPro, store.proAccess.loading]);
const refreshScanTerminalManually = useCallback(() => {
const now = Date.now();
const lastForced = lastForcedScanRefreshAtRef.current;
const withinCooldown =
lastForced > 0 &&
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
terminalData;
if (withinCooldown) {
setScanError(null);
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
}, [fetchScanTerminal, terminalData]);
useEffect(() => {
if (store.proAccess.loading || !isPro) return;
const intervalId = window.setInterval(() => {
if (document.hidden) return;
if (scanLoadingRef.current) return;
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
return () => window.clearInterval(intervalId);
}, [fetchScanTerminal, isPro, store.proAccess.loading]);
useEffect(() => {
setUserLocalTime(formatUserLocalTime());
const intervalId = window.setInterval(() => {
setUserLocalTime(formatUserLocalTime());
}, 10_000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
const stored = window.localStorage.getItem("polyweather_scan_theme");
if (stored === "light") {
setThemeMode("light");
}
}, []);
useEffect(() => {
window.localStorage.setItem("polyweather_scan_theme", themeMode);
}, [themeMode]);
const resolvedView: ContentView = activeView;
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
const activeDetailRow =
@@ -369,115 +257,6 @@ function ScanTerminalScreen() {
}
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
const runAiHydrationQueue = useCallback(async () => {
if (aiHydrationRunningRef.current) return;
aiHydrationRunningRef.current = true;
try {
while (aiHydrationQueueRef.current.length > 0) {
const nextCity = aiHydrationQueueRef.current.shift();
const key = normalizeCityKey(nextCity || "");
if (!nextCity || !key) continue;
const existingDetail = findDetailForCity(store.cityDetailsByName, nextCity);
try {
const detail = await store.ensureCityDetail(
nextCity,
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
"full",
);
if (!isFullEnoughForDeepAnalysis(detail)) {
aiFullHydrationRef.current.delete(key);
}
} catch {
aiFullHydrationRef.current.delete(key);
}
await waitForDeepAnalysisQueue(1200);
}
} finally {
aiHydrationRunningRef.current = false;
if (aiHydrationQueueRef.current.length > 0) {
void runAiHydrationQueue();
}
}
}, [store.cityDetailsByName, store.ensureCityDetail]);
const queueAiFullHydration = useCallback(
(cityName: string) => {
const key = normalizeCityKey(cityName);
if (!key || aiFullHydrationRef.current.has(key)) return;
aiFullHydrationRef.current.add(key);
aiHydrationQueueRef.current.push(cityName);
void runAiHydrationQueue();
},
[runAiHydrationQueue],
);
const addAiPinnedCity = useCallback((cityName: string) => {
const cleanName = String(cityName || "").trim();
const key = normalizeCityKey(cleanName);
if (!key) return;
const matchedRow = findRowForCity(timeSortedRows, cleanName);
const prettyName = prettifyCityName(cleanName);
const displayName =
matchedRow?.city_display_name ||
matchedRow?.display_name ||
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
prettyName ||
cleanName;
setAiPinnedCities((current) => {
const existing = current.findIndex(
(item) => normalizeCityKey(item.cityName) === key,
);
const nextItem = {
cityName: matchedRow?.city || cleanName,
displayName,
addedAt: Date.now(),
};
if (existing >= 0) {
const next = [...current];
next[existing] = { ...next[existing], ...nextItem };
return [
next[existing],
...next.filter((_, index) => index !== existing),
];
}
return [nextItem, ...current].slice(0, 8);
});
queueAiFullHydration(matchedRow?.city || cleanName);
}, [locale, queueAiFullHydration, timeSortedRows]);
const removeAiPinnedCity = useCallback((cityName: string) => {
const key = normalizeCityKey(cityName);
aiFullHydrationRef.current.delete(key);
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
(queuedCity) => normalizeCityKey(queuedCity) !== key,
);
setAiPinnedCities((current) =>
current.filter((item) => normalizeCityKey(item.cityName) !== key),
);
}, []);
const refreshAiPinnedCityDetail = useCallback(
async (cityName: string) => {
const key = normalizeCityKey(cityName);
if (key) {
aiFullHydrationRef.current.delete(key);
}
await store.ensureCityDetail(cityName, true, "full");
},
[store.ensureCityDetail],
);
useEffect(() => {
aiPinnedCities.forEach((item) => {
const key = normalizeCityKey(item.cityName);
if (!key || aiFullHydrationRef.current.has(key)) return;
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
if (!needsFullHydration) return;
queueAiFullHydration(item.cityName);
});
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
const handleMapCitySelect = useCallback((cityName: string) => {
setMapSelectedCityName(cityName);
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
@@ -533,36 +312,6 @@ function ScanTerminalScreen() {
}, []);
const renderMainView = () => {
if (resolvedView === "opportunities") {
if (!isPro) {
return (
<div className="scan-opportunity-overview empty">
<strong>{isEn ? "Opportunity board is Pro" : "今日机会榜需 Pro 权限"}</strong>
<p>
{isEn
? "Map exploration and city briefing are still available."
: "地图探索和城市简报仍可使用。"}
</p>
<button type="button" onClick={openScanPaywall}>
{isEn ? "Unlock opportunity board" : "解锁机会榜"}
</button>
</div>
);
}
return (
<OpportunityOverview
rows={timeSortedRows}
terminalData={terminalData}
loading={scanLoading}
error={scanError}
locale={locale}
selectedRowId={selectedRowId}
onOpenDecision={handleOpenDecisionRow}
onSelectRow={handleSelectRow}
onOpenMap={() => setActiveView("map")}
/>
);
}
if (resolvedView === "map") {
return (
<div className="scan-map-view">
@@ -618,7 +367,7 @@ function ScanTerminalScreen() {
if (store.proAccess.loading) {
return (
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-loading-state">
@@ -638,18 +387,29 @@ function ScanTerminalScreen() {
}
return (
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<div className={scanTerminalRootClassName}>
<div
className={clsx(
"scan-terminal",
resolvedView === "map" && "map-view-active",
resolvedView !== "map" && "focus-view-active",
resolvedView === "analysis" && "analysis-view-active",
themeMode === "light" && "light",
)}
>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<div className="scan-topbar-brand">
<div className="scan-topbar-logo" aria-hidden="true" />
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<span>
{isEn
? "Start from opportunities, then open city cards to verify weather evidence"
: "先看今日机会榜,再打开城市决策卡验证天气证据"}
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
</div>
<div className="scan-topbar-actions">
<button
type="button"
@@ -701,28 +461,36 @@ function ScanTerminalScreen() {
</div>
</div>
<AiForecastKPIBar
pinnedCount={aiPinnedCities.length}
activeCityName={kpiCityName}
activeDetail={kpiDetail}
activeRow={kpiRow}
locale={locale}
/>
{showAnnouncement ? (
<section className="scan-upgrade-announcement" aria-label={isEn ? "Upgrade announcement" : "升级公告"}>
<div className="scan-upgrade-announcement-copy">
<span>{isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告"}</span>
<strong>
{isEn ? "Scan terminal is upgraded to v1.5.6" : "决策终端已升级到 v1.5.6"}
</strong>
<p>
{isEn
? "City decision cards have been redesigned with a compact hero layout and consistent DEB data source. Sticky headers are removed for smoother scrolling."
: "城市决策卡 hero 布局重新设计,三指标并列对比更直观;DEB 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。"}
</p>
</div>
<ul>
<li>{isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局"}</li>
<li>{isEn ? "Unified DEB data source" : "统一 DEB 数据源"}</li>
<li>{isEn ? "Light theme coverage" : "亮色主题补全覆盖"}</li>
<li>{isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}</li>
<li>{isEn ? "Smoother scrolling experience" : "滚动体验优化"}</li>
</ul>
</section>
) : null}
<section className="scan-list-section">
<div className="scan-list-header">
<div className="scan-list-tabs">
<button
type="button"
className={resolvedView === "opportunities" ? "active" : ""}
onClick={() => {
setActiveView("opportunities");
}}
>
{isEn ? "Opportunity Board" : "今日机会榜"}
</button>
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
<button
type="button"
role="tab"
aria-selected={resolvedView === "map"}
className={resolvedView === "map" ? "active" : ""}
onClick={() => {
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
@@ -733,6 +501,8 @@ function ScanTerminalScreen() {
</button>
<button
type="button"
role="tab"
aria-selected={resolvedView === "analysis"}
className={resolvedView === "analysis" ? "active" : ""}
onClick={() => {
setActiveView("analysis");
@@ -742,6 +512,8 @@ function ScanTerminalScreen() {
</button>
<button
type="button"
role="tab"
aria-selected={resolvedView === "calendar"}
className={resolvedView === "calendar" ? "active" : ""}
title={!isPro ? (isEn ? "Pro forecast calendar required" : "日历预测需 Pro") : undefined}
onClick={() => {
@@ -781,8 +553,8 @@ function ScanTerminalScreen() {
disabled={scanLoading}
title={
isEn
? "Force refresh opportunity board and calendar"
: "强制刷新今日机会榜和日历视图"
? "Force refresh decision cards and calendar"
: "强制刷新决策卡和日历视图"
}
>
<RefreshCw size={14} className={scanLoading ? "spin" : undefined} />
@@ -0,0 +1,353 @@
/* Scan terminal right-side detail panel styles for the current decision desk. */
.root :global(.scan-detail-panel) {
width: auto;
min-width: 0;
padding: 18px 16px;
border-left: 1px solid rgba(82, 114, 161, 0.18);
}
.root :global(.scan-detail-header) {
padding: 0 0 16px;
justify-content: space-between;
border-bottom: none;
margin-bottom: 10px;
}
.root :global(.scan-detail-top) {
display: flex;
gap: 0;
align-items: center;
}
.root :global(.scan-detail-title-wrap) {
display: flex;
flex-direction: column;
gap: 6px;
}
.root :global(.scan-detail-icon-button) {
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.root :global(.scan-detail-hero-placeholder) {
width: 66px;
height: 66px;
border-radius: 14px;
background: linear-gradient(135deg, #314d7b, #0d2038 60%, #08131f);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.root :global(.scan-detail-city-name) {
font-size: 22px;
font-weight: 800;
}
.root :global(.scan-detail-city-sub) {
font-size: 14px;
color: #a4bad6;
}
.root :global(.scan-detail-volume-row) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.root :global(.scan-detail-volume-big) {
font-size: 28px;
font-weight: 800;
}
.root :global(.scan-detail-volume-caption) {
margin-top: 4px;
font-size: 14px;
color: #90a7c4;
}
.root :global(.scan-detail-action-button) {
padding: 11px 14px;
font-weight: 700;
}
.root :global(.scan-detail-primary-actions) {
display: flex;
margin-bottom: 16px;
}
.root :global(.scan-detail-analysis-button) {
width: 100%;
border: 1px solid rgba(23, 217, 139, 0.3);
border-radius: 14px;
background: rgba(23, 217, 139, 0.12);
color: #27ea98;
padding: 12px 14px;
font-size: 14px;
font-weight: 800;
text-align: center;
}
.root :global(.scan-detail-analysis-button:hover) {
background: rgba(23, 217, 139, 0.18);
border-color: rgba(23, 217, 139, 0.4);
}
.root :global(.scan-detail-section) {
padding: 16px 0;
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(.scan-detail-section-title) {
font-size: 16px;
font-weight: 800;
color: #e8f2ff;
text-transform: none;
letter-spacing: 0;
margin-bottom: 12px;
}
.root :global(.scan-kv-list) {
display: flex;
flex-direction: column;
gap: 8px;
}
.root :global(.scan-kv) {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 4px 0;
color: #dbe7f8;
}
.root :global(.scan-kv span:first-child) {
color: #91a8c6;
}
.root :global(.scan-kv strong) {
font-size: 15px;
}
.root :global(.scan-kv strong.warn) {
color: #ffbf39;
}
.root :global(.scan-kv strong.danger) {
color: #ff7f7f;
}
.root :global(.scan-timeline-head) {
display: flex;
justify-content: space-between;
color: #a1b6d2;
font-size: 13px;
}
.root :global(.scan-timeline-bar) {
position: relative;
height: 8px;
margin-top: 8px;
border-radius: 999px;
background: linear-gradient(90deg, #e83c3c 0 72%, rgba(255, 255, 255, 0.18) 72% 100%);
}
.root :global(.scan-timeline-knob) {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
background: #dfe8f6;
box-shadow: 0 0 0 4px rgba(223, 232, 246, 0.12);
transform: translate(-50%, -50%);
}
.root :global(.scan-timeline-caption) {
margin-top: 8px;
color: #a1b6d2;
font-size: 13px;
text-align: right;
}
.root :global(.scan-chart-legend) {
display: flex;
gap: 16px;
margin-bottom: 14px;
color: #a8bdd8;
font-size: 13px;
}
.root :global(.scan-chart-legend .dot) {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.root :global(.scan-chart-legend .dot.green) {
background: #1ce393;
}
.root :global(.scan-chart-legend .dot.blue) {
background: #4ca2ff;
}
.root :global(.scan-chart-bars) {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
height: 170px;
padding: 8px 4px 0;
}
.root :global(.scan-chart-group) {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
gap: 6px;
position: relative;
}
.root :global(.scan-chart-group.highlighted .scan-chart-label) {
color: #22eb98;
font-weight: 800;
}
.root :global(.scan-chart-col) {
width: 20px;
border-radius: 8px 8px 0 0;
}
.root :global(.scan-chart-col.model) {
background: linear-gradient(180deg, #1ce393, #0b8d5a);
}
.root :global(.scan-chart-col.market) {
background: linear-gradient(180deg, #4ca2ff, #2263cf);
}
.root :global(.scan-chart-label) {
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
color: #93a8c4;
font-size: 13px;
}
.root :global(.scan-trade-cards) {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.root :global(.scan-trade-card) {
padding: 16px;
border-radius: 16px;
background: rgba(11, 24, 42, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.root :global(.scan-trade-card.buy) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.22);
}
.root :global(.scan-trade-card.sell) {
box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.22);
}
.root :global(.scan-trade-card-title) {
font-size: 16px;
font-weight: 800;
}
.root :global(.scan-trade-card.buy .scan-trade-card-title) {
color: #20e391;
}
.root :global(.scan-trade-card.sell .scan-trade-card-title) {
color: #ff7070;
}
.root :global(.scan-trade-card p) {
margin: 10px 0 0;
color: #d5e1f5;
font-size: 14px;
}
.root :global(.scan-trade-card p.positive) {
color: #20e391;
}
.root :global(.scan-trade-card p.negative) {
color: #ff7070;
}
.root :global(.scan-detail-score-block) {
margin-top: 18px;
padding-top: 16px;
}
.root :global(.scan-detail-score-head) {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.root :global(.scan-detail-score-label-text) {
font-size: 16px;
font-weight: 800;
}
.root :global(.scan-detail-score-meta) {
margin-top: 6px;
color: #89a0bf;
font-size: 13px;
}
.root :global(.scan-detail-score-value) {
font-size: 40px;
font-weight: 800;
}
.root :global(.scan-detail-score-value.green) {
color: #23e694;
}
.root :global(.scan-detail-score-value.yellow) {
color: #ffbe26;
}
.root :global(.scan-detail-score-value.red) {
color: #ff6f86;
}
.root :global(.scan-detail-score-value span) {
font-size: 22px;
color: #9fb5d2;
}
.root :global(.scan-detail-score-line) {
height: 6px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.root :global(.scan-detail-score-line span) {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #1be392, #12b46d);
box-shadow: 0 0 18px rgba(27, 227, 146, 0.22);
}
@@ -0,0 +1,241 @@
/* Scan terminal sidebar filters, mode tabs, range controls, toggles, and CTA.
Imported after ScanTerminal.module.css so these current-desk overrides win over legacy base rules. */
.root :global(.scan-filter-panel) {
width: auto;
min-width: 0;
border-right: 1px solid rgba(159, 178, 199, 0.14);
padding: 18px 14px;
gap: 18px;
overflow: hidden auto;
}
.root :global(.scan-sidebar-brand) {
display: flex;
align-items: center;
gap: 0;
padding: 8px 4px 18px;
border-bottom: 1px solid rgba(118, 146, 188, 0.12);
}
.root :global(.scan-sidebar-brand-mark) {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
color: #042113;
background: radial-gradient(circle at 30% 30%, #29f2ab, #0da46a 60%, #073c28 100%);
box-shadow: 0 0 24px rgba(23, 217, 139, 0.3);
}
.root :global(.scan-sidebar-brand-name) {
font-size: 30px;
font-weight: 800;
letter-spacing: 0;
color: #eef5ff;
}
.root :global(.scan-filter-heading) {
display: flex;
align-items: center;
justify-content: space-between;
margin: 6px 8px 8px;
font-size: 14px;
font-weight: 600;
color: #b8c7dd;
}
.root :global(.scan-mode-tabs) {
gap: 10px;
}
.root :global(.scan-mode-tab) {
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(87, 119, 166, 0.14);
background: rgba(11, 24, 42, 0.95);
color: #dbe7f8;
}
.root :global(.scan-mode-tab:hover) {
background: rgba(13, 28, 48, 0.98);
border-color: rgba(87, 119, 166, 0.24);
}
.root :global(.scan-mode-tab.active) {
background: linear-gradient(180deg, rgba(15, 34, 59, 1), rgba(9, 19, 34, 1));
border-color: rgba(78, 222, 151, 0.7);
color: #f3fbff;
box-shadow: inset 0 0 0 1px rgba(78, 222, 151, 0.35), 0 0 0 2px rgba(7, 199, 119, 0.08);
}
.root :global(.scan-mode-tab.active::before) {
display: none;
}
.root :global(.scan-mode-icon) {
width: 28px;
height: 28px;
border-radius: 10px;
display: grid;
place-items: center;
flex-shrink: 0;
background: rgba(63, 140, 255, 0.12);
color: #6cb1ff;
}
.root :global(.scan-mode-tab.active .scan-mode-icon) {
background: rgba(23, 217, 139, 0.14);
color: #2df3a5;
}
.root :global(.scan-mode-copy) {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.root :global(.scan-mode-tab-label) {
font-size: 15px;
font-weight: 700;
color: inherit;
}
.root :global(.scan-mode-tab-sub) {
font-size: 12px;
line-height: 1.4;
color: #8fa4c3;
}
.root :global(.scan-range-card),
.root :global(.scan-filter-row) {
background: rgba(11, 24, 42, 0.92);
border: 1px solid rgba(87, 119, 166, 0.12);
border-radius: 12px;
padding: 12px 14px;
}
.root :global(.scan-range-card) {
display: flex;
flex-direction: column;
gap: 10px;
}
.root :global(.scan-filter-row-title),
.root :global(.scan-filter-row-label) {
font-size: 14px;
color: #d9e7f8;
}
.root :global(.scan-filter-row) {
gap: 10px;
}
.root :global(.scan-filter-row.inline) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.root :global(.scan-range-track-wrap) {
position: relative;
height: 20px;
}
.root :global(.scan-range-track-wrap::before) {
content: "";
position: absolute;
inset: 7px 0;
border-radius: 999px;
background: linear-gradient(90deg, #1c2f4e, #2d4f84);
}
.root :global(.scan-range-slider) {
position: absolute;
inset: 0;
width: 100%;
height: 20px;
background: transparent;
pointer-events: none;
}
.root :global(.scan-range-slider::-webkit-slider-thumb) {
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: #4d93ff;
box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
pointer-events: auto;
}
.root :global(.scan-range-slider::-moz-range-thumb) {
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: #4d93ff;
box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
pointer-events: auto;
}
.root :global(.scan-range-slider::-webkit-slider-runnable-track),
.root :global(.scan-range-slider::-moz-range-track) {
background: transparent;
}
.root :global(.scan-range-labels) {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #aec0d9;
}
.root :global(.scan-select) {
border-radius: 10px;
padding: 10px 12px;
border-color: rgba(87, 119, 166, 0.12);
background: rgba(10, 22, 38, 0.95);
color: #d9e8fb;
}
.root :global(.scan-toggle) {
width: 42px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(90deg, #0e2840, #12324d);
}
.root :global(.scan-toggle.active) {
background: linear-gradient(90deg, #0e2840, #1f7f57);
border-color: rgba(39, 234, 152, 0.28);
}
.root :global(.scan-toggle-knob) {
top: 2px;
left: 3px;
width: 18px;
height: 18px;
background: #8ca5c8;
}
.root :global(.scan-toggle.active .scan-toggle-knob) {
left: 21px;
background: #1bf1a2;
box-shadow: 0 0 10px rgba(27, 241, 162, 0.45);
}
.root :global(.scan-cta-button) {
border-radius: 14px;
padding: 15px;
margin-top: auto;
background: linear-gradient(180deg, #1ddb8d, #0eb96d);
box-shadow: 0 8px 22px rgba(14, 185, 109, 0.22);
font-size: 16px;
font-weight: 800;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,520 @@
/* Scan terminal KPI, list tabs, table rows, v4 analysis, and AI workspace.
Kept separate from the shell/filter CSS so dense decision-board styles can evolve independently. */
.root :global(.scan-list-section) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.root :global(.scan-list-header) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.root :global(.scan-list-tabs) {
display: flex;
gap: 24px;
}
.root :global(.scan-list-tabs button) {
border: none;
background: transparent;
font-size: 16px;
font-weight: 700;
color: #91a7c4;
cursor: pointer;
}
.root :global(.scan-list-tabs button:disabled) {
opacity: 0.42;
cursor: not-allowed;
}
.root :global(.scan-list-tabs button.active) {
color: #fff;
}
.root :global(.scan-list-status) {
display: flex;
gap: 10px;
align-items: center;
}
.root :global(.scan-status-chip) {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(98, 126, 167, 0.22);
background: rgba(10, 22, 38, 0.92);
color: #c4d4e9;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.root :global(.scan-status-chip.live) {
border-color: rgba(55, 214, 255, 0.28);
background: rgba(55, 214, 255, 0.1);
color: #71e6ff;
}
.root :global(.scan-status-chip.stale) {
border-color: rgba(255, 184, 79, 0.24);
background: rgba(255, 184, 79, 0.1);
color: #ffc765;
}
.root :global(.scan-status-chip.ai) {
border-color: rgba(94, 234, 212, 0.28);
background: rgba(20, 184, 166, 0.12);
color: #87fff1;
}
.root :global(.scan-status-chip.focus) {
border-color: rgba(255, 176, 32, 0.3);
background: rgba(255, 176, 32, 0.12);
color: #ffd078;
}
.root :global(button.scan-status-chip.refresh) {
cursor: pointer;
}
.root :global(button.scan-status-chip.refresh:disabled) {
cursor: wait;
opacity: 0.68;
}
.root :global(.scan-status-chip .spin) {
animation: spin 1s linear infinite;
}
.root :global(.scan-table-shell) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
}
.root :global(.scan-table-shell.empty) {
min-height: 340px;
}
.root :global(.scan-table-banner) {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(95, 124, 168, 0.12);
background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
}
.root :global(.scan-table-banner strong) {
font-size: 13px;
font-weight: 800;
color: #ffd27f;
}
.root :global(.scan-table-banner span) {
font-size: 13px;
color: #c7d4e7;
}
.root :global(.scan-table-header) {
display: grid;
grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
gap: 10px;
align-items: center;
padding: 14px 18px;
background: rgba(11, 23, 40, 0.98);
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
color: #92a8c5;
font-size: 13px;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.root :global(.scan-table-body) {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.root :global(.scan-v4-analysis) {
grid-column: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 14px 14px;
padding: 14px;
border-color: rgba(56, 189, 248, 0.18);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
}
.root :global(.scan-v4-analysis section) {
padding: 13px;
border-color: rgba(92, 132, 188, 0.14);
background: rgba(10, 25, 43, 0.7);
}
.root :global(.scan-v4-analysis p),
.root :global(.scan-v4-analysis ul) {
color: #b8c9e3;
}
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis .scan-v4-decision) {
grid-column: 1 / -1;
border-color: rgba(45, 212, 191, 0.24);
background:
linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
}
.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
border-color: rgba(245, 158, 11, 0.24);
}
.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
border-color: rgba(248, 113, 113, 0.28);
}
.root :global(.scan-v4-decision p b) {
color: #ecfeff;
font-weight: 800;
}
.root :global(.scan-v4-metar-summary) {
margin-top: 9px;
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-evidence > div),
.root :global(.scan-v4-model-sources) {
gap: 8px;
}
.root :global(.scan-v4-analysis) {
display: flex;
flex-direction: column;
gap: 13px;
margin: 0 14px 14px;
padding: 15px 16px 16px;
border: 1px solid rgba(56, 189, 248, 0.16);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
}
.root :global(.scan-v4-analysis section),
.root :global(.scan-v4-analysis section:first-child),
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: auto;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.root :global(.scan-v4-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.14);
}
.root :global(.scan-v4-heading > div) {
display: grid;
gap: 7px;
min-width: 0;
}
.root :global(.scan-v4-heading strong),
.root :global(.scan-v4-current b),
.root :global(.scan-v4-brief-grid strong) {
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.root :global(.scan-v4-heading p) {
margin: 0;
color: #d2e1f4;
font-size: 14px;
font-weight: 800;
line-height: 1.55;
}
.root :global(.scan-v4-decision-pill) {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 999px;
background: rgba(20, 184, 166, 0.12);
color: #7dfbe7;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.root :global(.scan-v4-decision-pill.downgrade),
.root :global(.scan-v4-decision-pill.watchlist) {
border-color: rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.12);
color: #ffd166;
}
.root :global(.scan-v4-decision-pill.veto) {
border-color: rgba(248, 113, 113, 0.34);
background: rgba(248, 113, 113, 0.12);
color: #ff8585;
}
.root :global(.scan-v4-current) {
display: grid;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(.scan-v4-current span) {
color: #c8d8ec;
font-size: 13px;
font-weight: 800;
line-height: 1.5;
}
.root :global(.scan-v4-current small) {
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-brief-grid) {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
gap: 26px;
}
.root :global(.scan-v4-brief-grid section:first-child) {
grid-column: auto;
}
.root :global(.scan-v4-brief-grid ul) {
display: grid;
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.root :global(.scan-v4-brief-grid li) {
position: relative;
padding-left: 16px;
color: #aebfd8;
font-size: 12px;
font-weight: 800;
line-height: 1.48;
}
.root :global(.scan-v4-brief-grid li::before) {
content: "";
position: absolute;
top: 0.65em;
left: 0;
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--color-accent-primary);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
}
.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
background: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
}
.root :global(details.scan-v4-evidence) {
padding-top: 11px;
border-top: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(details.scan-v4-evidence summary) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border: 1px solid rgba(65, 118, 216, 0.28);
border-radius: 8px;
background: rgba(10, 30, 58, 0.52);
color: #8fbdff;
font-size: 12px;
font-weight: 800;
cursor: pointer;
list-style: none;
}
.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
display: none;
}
.root :global(details.scan-v4-evidence summary::after) {
content: "v";
color: #8fbdff;
font-size: 12px;
}
.root :global(details.scan-v4-evidence[open] summary::after) {
content: "^";
}
.root :global(details.scan-v4-evidence > div) {
margin-top: 10px;
}
.root :global(.scan-forecast-desk) {
gap: 16px;
padding: 16px 18px 20px;
}
.root :global(.scan-ai-workspace) {
flex: 1;
min-height: 0;
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-top: 64px;
border: 1px solid rgba(77, 163, 255, 0.16);
border-radius: 18px;
background: var(--color-bg-base);
padding: 14px;
}
.root :global(.scan-ai-workspace::-webkit-scrollbar) {
width: 8px;
}
.root :global(.scan-ai-workspace::-webkit-scrollbar-thumb) {
border-radius: 999px;
background: rgba(77, 163, 255, 0.28);
}
.root :global(.scan-ai-workspace.empty) {
display: grid;
place-items: center;
}
.root :global(.scan-ai-workspace-head) {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-end;
margin-bottom: 10px;
padding: 0 2px;
}
.root :global(.scan-ai-workspace-head span) {
display: block;
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
}
.root :global(.scan-ai-workspace-head strong) {
display: block;
color: var(--color-text-primary);
font-size: 20px;
line-height: 1.2;
margin-top: 4px;
}
.root :global(.scan-ai-workspace-head p) {
max-width: 560px;
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.55;
text-align: right;
}
.root :global(.scan-ai-city-jumpbar) {
display: flex;
gap: 8px;
margin: 0 0 12px;
padding: 8px;
overflow-x: auto;
border: 1px solid rgba(77, 163, 255, 0.14);
border-radius: 14px;
background: rgba(11, 18, 32, 0.92);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
scrollbar-width: none;
}
.root :global(.scan-ai-city-jumpbar::-webkit-scrollbar) {
display: none;
}
.root :global(.scan-ai-city-jumpbar button) {
flex: 0 0 auto;
min-height: 32px;
padding: 0 12px;
border: 1px solid rgba(77, 163, 255, 0.26);
border-radius: 999px;
background: rgba(77, 163, 255, 0.1);
color: #cfe5ff;
font-size: 12px;
font-weight: 900;
cursor: pointer;
white-space: nowrap;
}
.root :global(.scan-ai-city-jumpbar button:hover) {
border-color: rgba(111, 183, 255, 0.62);
background: rgba(77, 163, 255, 0.18);
}
.root :global(.scan-ai-city-card:not(.collapsed)) {
overflow: visible;
}
@@ -0,0 +1,535 @@
/* Scan terminal mobile action-card layout and responsive overrides. */
.root :global(.scan-mobile-decision-card) {
display: grid;
gap: 12px;
padding-bottom: 14px;
}
.root :global(.scan-mobile-decision-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 0;
}
.root :global(.scan-mobile-decision-head h3) {
margin: 6px 0 0;
color: var(--color-text-primary);
font-size: 24px;
line-height: 1.08;
}
.root :global(.scan-mobile-decision-metrics) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 0 14px;
}
.root :global(.scan-mobile-decision-metrics span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-mobile-decision-metrics small) {
color: var(--color-text-secondary);
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-mobile-decision-metrics b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-mobile-decision-reason) {
margin: 0 14px;
border: 1px solid rgba(77, 163, 255, 0.24);
border-radius: 13px;
background: rgba(77, 163, 255, 0.1);
color: #d8e7f8;
font-size: 14px;
font-weight: 900;
line-height: 1.45;
padding: 11px 12px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags),
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness),
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line),
.root :global(.scan-mobile-decision-folds) {
margin-right: 14px;
margin-left: 14px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) {
grid-template-columns: 1fr;
max-width: none;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-decision) {
display: none;
}
.root :global(.scan-mobile-decision-folds) {
display: grid;
gap: 10px;
}
.root :global(.scan-mobile-fold) {
padding: 13px 14px;
}
.root :global(.scan-mobile-fold summary) {
cursor: pointer;
list-style: none;
margin-bottom: 0;
}
.root :global(.scan-mobile-fold summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-mobile-fold[open] summary) {
margin-bottom: 10px;
}
.root :global(.scan-mobile-fold .scan-ai-city-section.models) {
border: 0;
background: transparent;
padding: 0;
}
.root :global(.scan-ai-city-collapse svg) {
transition: transform 0.18s ease;
}
@media (max-width: 1440px) {
.root :global(.scan-terminal) {
grid-template-columns: minmax(0, 1fr);
}
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
position: relative;
top: auto;
grid-column: 1 / -1;
height: auto;
max-height: none;
min-height: auto;
}
.root :global(.scan-detail-panel) {
grid-column: 1 / -1;
min-height: auto;
}
}
@media (max-width: 1024px) {
.root :global(.scan-terminal) {
grid-template-columns: 1fr;
min-height: 100dvh;
overflow-x: hidden;
}
.root :global(.scan-filter-panel),
.root :global(.scan-data-grid),
.root :global(.scan-detail-panel),
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
min-height: auto;
}
.root :global(.scan-data-grid) {
max-height: none;
overflow: visible;
}
.root :global(.scan-list-section),
.root :global(.scan-table-shell),
.root :global(.scan-ai-workspace) {
overflow: visible;
}
.root :global(.scan-ai-workspace) {
max-height: none;
}
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
width: 100%;
min-width: 0;
max-width: 100%;
}
.root :global(.scan-topbar) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-upgrade-announcement) {
grid-template-columns: 1fr;
}
.root :global(.scan-upgrade-announcement ul) {
justify-content: flex-start;
}
.root :global(.scan-table-header),
.root :global(.scan-table-row) {
grid-template-columns: 1fr;
}
.root :global(.scan-table-header) {
display: none;
}
.root :global(.scan-opportunity-group-head),
.root :global(.scan-opportunity-item) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-head),
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-brief-grid),
.root :global(.scan-ai-city-analysis-grid),
.root :global(.scan-ai-decision-band),
.root :global(.scan-ai-evidence-line) {
grid-template-columns: 1fr;
}
.root :global(.scan-ai-workspace-head),
.root :global(.scan-opportunity-hero),
.root :global(.scan-ai-city-hero),
.root :global(.scan-ai-city-section-head) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-opportunity-hero) {
display: flex;
}
.root :global(.scan-ai-workspace-head p),
.root :global(.scan-ai-city-hero-side) {
text-align: left;
justify-items: start;
}
.root :global(.scan-ai-decision-metrics) {
min-width: 0;
grid-template-columns: 1fr;
}
.root :global(.scan-ai-market-decision) {
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-card-grid),
.root :global(.scan-opportunity-summary) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-read) {
justify-items: start;
text-align: left;
}
.root :global(.scan-forecast-signals),
.root :global(.scan-ai-temperature-line) {
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-phase) {
flex-wrap: wrap;
}
.root :global(.scan-opportunity-branch) {
display: none;
}
.root :global(.scan-opportunity-stat.edge) {
justify-self: start;
justify-items: start;
}
.root :global(.scan-opportunity-ai),
.root :global(.scan-v4-analysis) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis) {
grid-template-columns: 1fr;
}
.root :global(.scan-trade-cards) {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.root :global(.scan-terminal) {
padding: 10px;
}
.root :global(.scan-data-grid) {
padding: 12px;
gap: 14px;
border-radius: 18px;
}
.root :global(.scan-topbar-actions) {
width: 100%;
justify-content: flex-start;
gap: 8px;
}
.root :global(.scan-topbar-title strong) {
font-size: 23px;
}
.root :global(.scan-topbar-title span) {
font-size: 13px;
}
.root :global(.scan-topbar-tabs) {
gap: 14px;
flex-wrap: wrap;
}
.root :global(.scan-upgrade-announcement) {
padding: 14px;
}
.root :global(.scan-upgrade-announcement-copy strong) {
font-size: 16px;
}
.root :global(.scan-upgrade-announcement ul) {
gap: 6px;
}
.root :global(.scan-upgrade-announcement li) {
font-size: 11px;
padding: 6px 8px;
}
.root :global(.scan-ai-city-hero) {
padding: 16px;
}
.root :global(.scan-ai-city-hero h3) {
margin-bottom: 10px;
font-size: 24px;
}
.root :global(.scan-ai-city-mobile-priority) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.root :global(.scan-ai-city-mobile-priority span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-ai-city-mobile-priority small) {
color: var(--color-text-secondary);
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-ai-city-mobile-priority b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-ai-city-pills) {
display: none;
}
.root :global(.scan-ai-city-freshness) {
grid-template-columns: 1fr;
}
.root :global(.scan-ai-decision-band) {
padding: 14px;
}
.root :global(.scan-ai-decision-band strong) {
font-size: 20px;
}
.root :global(.scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(11, 18, 32, 0.48);
padding: 10px 12px;
}
.root :global(.scan-ai-market-mobile-line span) {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-ai-market-mobile-line b) {
color: var(--color-text-primary);
font-size: 13px;
text-align: right;
}
.root :global(.scan-ai-market-decision) {
margin-top: 10px;
}
.root :global(.scan-ai-city-ai-read:not([open])) {
padding-bottom: 4px;
}
.root :global(.scan-list-header) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-list-tabs) {
width: 100%;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.root :global(.scan-list-tabs::-webkit-scrollbar) {
display: none;
}
.root :global(.scan-list-tabs button) {
flex: 0 0 auto;
min-height: 36px;
border: 1px solid rgba(99, 132, 180, 0.18);
border-radius: 999px;
background: rgba(8, 19, 34, 0.76);
padding: 0 12px;
font-size: 13px;
white-space: nowrap;
}
.root :global(.scan-list-tabs button.active) {
border-color: rgba(77, 163, 255, 0.38);
background: rgba(77, 163, 255, 0.16);
}
.root :global(.scan-list-status) {
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.scan-opportunity-groups) {
padding: 12px;
}
.root :global(.scan-opportunity-models span) {
white-space: normal;
}
.root :global(.scan-forecast-city-title strong) {
font-size: 21px;
}
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-analysis) {
padding: 13px;
}
.root :global(.scan-forecast-ai-line) {
grid-template-columns: 1fr;
padding: 0 13px 13px;
}
}
@media (max-width: 640px) {
.root :global(.scan-terminal) {
box-sizing: border-box;
max-width: 100vw;
overflow-x: clip;
padding: 6px;
}
.root :global(.scan-data-grid) {
box-sizing: border-box;
min-width: 0;
width: 100%;
max-width: 100%;
padding: 10px;
border-radius: 16px;
}
.root :global(.scan-topbar-tabs) {
max-width: 100%;
gap: 8px;
}
.root :global(.scan-upgrade-announcement) {
margin: -2px 0 2px;
padding: 12px;
border-radius: 16px;
}
.root :global(.scan-upgrade-announcement-copy p) {
display: none;
}
.root :global(.scan-upgrade-announcement ul) {
display: none;
}
.root :global(.scan-status-chip) {
height: 32px;
padding: 0 10px;
font-size: 11px;
}
.root :global(.scan-primary-button) {
min-height: 38px;
padding: 9px 12px;
font-size: 13px;
}
.root :global(.scan-account-button) {
width: 38px;
height: 38px;
}
.root :global(.scan-theme-button) {
width: 38px;
height: 38px;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
/* Scan terminal shell, topbar, upgrade announcement, and shared action buttons.
Extracted from ScanTerminal.module.css to keep terminal layout CSS maintainable. */
/* Button Hierarchy
Primary: .scan-primary-button filled gradient, main CTA
Secondary: .scan-ai-button tinted background, alternative action
Ghost: .scan-theme-button no border/background, icon-only
Chrome: .scan-account-button bordered icon, navigation
Chrome: .scan-locale-switch segmented control
Chrome: .scan-ghost-button bordered + bg, utility action
*/
.root :global(.scan-topbar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 0 18px;
border-bottom: 1px solid rgba(118, 146, 188, 0.1);
}
.root :global(.scan-topbar-brand) {
display: flex;
align-items: center;
gap: 8px;
}
.root :global(.scan-topbar-logo) {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
background: linear-gradient(135deg, var(--color-accent-primary), #22C55E);
box-shadow: 0 4px 12px rgba(77, 163, 255, 0.28);
position: relative;
overflow: hidden;
}
.root :global(.scan-topbar-logo::after) {
content: "";
position: absolute;
inset: 3px;
border-radius: 6px;
background:
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.6), transparent 60%);
}
.root :global(.scan-topbar-title) {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.root :global(.scan-topbar-title strong) {
margin: 0;
font-size: 28px;
line-height: 1.08;
letter-spacing: -0.04em;
color: #f3f8ff;
}
.root :global(.scan-topbar-title span) {
color: #8fa4c3;
font-size: 14px;
line-height: 1.5;
}
.root :global(.scan-topbar-actions) {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
color: #b4c8e2;
font-size: 14px;
}
.root :global(.scan-locale-switch) {
height: 36px;
padding: 3px;
border-radius: 12px;
border: 1px solid rgba(99, 132, 180, 0.18);
background: rgba(8, 19, 34, 0.9);
color: #a8bdd8;
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.root :global(.scan-locale-switch span) {
min-width: 34px;
height: 28px;
padding: 0 8px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.root :global(.scan-locale-switch span.active) {
color: #eef7ff;
background: rgba(63, 140, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(63, 140, 255, 0.22);
}
.root :global(.scan-topbar-time) {
color: #b4c8e2;
font-variant-numeric: tabular-nums;
}
.root :global(.scan-upgrade-announcement) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
margin: -4px 0 6px;
border: 1px solid rgba(77, 163, 255, 0.2);
border-radius: 18px;
background:
radial-gradient(circle at top left, rgba(77, 163, 255, 0.18), transparent 36%),
linear-gradient(135deg, rgba(18, 33, 58, 0.96), rgba(15, 23, 42, 0.92));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
padding: 16px 18px;
}
.root :global(.scan-upgrade-announcement-copy) {
min-width: 0;
}
.root :global(.scan-upgrade-announcement-copy span) {
color: #86efac;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.root :global(.scan-upgrade-announcement-copy strong) {
display: block;
margin-top: 5px;
color: #f3f8ff;
font-size: 18px;
line-height: 1.25;
}
.root :global(.scan-upgrade-announcement-copy p) {
margin: 6px 0 0;
color: #b4c8e2;
font-size: 13px;
line-height: 1.55;
}
.root :global(.scan-upgrade-announcement ul) {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.root :global(.scan-upgrade-announcement li) {
border: 1px solid rgba(77, 163, 255, 0.22);
border-radius: 999px;
background: rgba(77, 163, 255, 0.1);
color: #d7e5f7;
font-size: 12px;
font-weight: 800;
padding: 7px 10px;
}
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost),
.root :global(.scan-theme-button),
.root :global(.scan-detail-action-button),
.root :global(.scan-detail-icon-button),
.root :global(.scan-view-all-button) {
border: 1px solid rgba(99, 132, 180, 0.18);
background: rgba(8, 19, 34, 0.9);
color: #d7e5f7;
border-radius: 12px;
}
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost) {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
font-weight: 700;
}
.root :global(.scan-primary-button) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 11px 16px;
border: 1px solid rgba(77, 163, 255, 0.42);
border-radius: 12px;
background: linear-gradient(180deg, var(--color-accent-primary), #3B82F6);
color: #FFFFFF;
font-size: 14px;
font-weight: 900;
text-decoration: none;
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
cursor: pointer;
}
.root :global(.scan-primary-button:hover:not(:disabled)) {
filter: brightness(1.06);
}
.root :global(.scan-primary-button:disabled) {
cursor: not-allowed;
opacity: 0.68;
}
.root :global(.scan-ai-button) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 11px 14px;
border: 1px solid rgba(77, 163, 255, 0.3);
border-radius: 12px;
background: linear-gradient(180deg, rgba(77, 163, 255, 0.18), rgba(59, 130, 246, 0.12));
color: #CFE6FF;
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.root :global(.scan-ai-button:hover:not(:disabled)) {
border-color: rgba(111, 183, 255, 0.5);
background: linear-gradient(180deg, rgba(77, 163, 255, 0.26), rgba(59, 130, 246, 0.18));
}
.root :global(.scan-ai-button:disabled) {
cursor: not-allowed;
opacity: 0.55;
}
.root :global(.scan-cta-ghost) {
color: var(--color-accent-secondary);
border-color: rgba(23, 217, 139, 0.3);
background: rgba(23, 217, 139, 0.12);
}
.root :global(.scan-account-button) {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #d7e5f7;
text-decoration: none;
border: 1px solid rgba(99, 132, 180, 0.18);
background: rgba(8, 19, 34, 0.9);
border-radius: 12px;
}
.root :global(.scan-account-button:hover) {
border-color: rgba(63, 140, 255, 0.28);
background: rgba(12, 26, 44, 0.96);
}
.root :global(.scan-theme-button) {
width: 36px;
height: 36px;
display: inline-grid;
place-items: center;
color: #d7e5f7;
cursor: pointer;
}
.root :global(.scan-ghost-button .spin) {
animation: spin 1s linear infinite;
}
@@ -0,0 +1,316 @@
/* Scan terminal empty, loading, error, and skeleton signal states. */
/* ── Empty state ── */
.root :global(.scan-empty-state) {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 32px;
text-align: center;
gap: 12px;
}
.root :global(.scan-empty-icon) {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(77, 163, 255, 0.1);
border: 1px solid rgba(77, 163, 255, 0.16);
display: grid;
place-items: center;
color: var(--color-accent-secondary);
font-size: 20px;
margin-bottom: 4px;
}
.root :global(.scan-empty-title) {
font-size: 20px;
font-weight: 800;
color: #e8f2ff;
}
.root :global(.scan-empty-copy) {
max-width: 360px;
font-size: 14px;
color: #8fa4c3;
line-height: 1.6;
}
/* ── Error state ── */
.root :global(.scan-error-state) {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 32px;
text-align: center;
gap: 12px;
}
.root :global(.scan-error-icon) {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
display: grid;
place-items: center;
color: #FCA5A5;
font-size: 20px;
margin-bottom: 4px;
}
.root :global(.scan-error-title) {
font-size: 20px;
font-weight: 800;
color: #e8f2ff;
}
.root :global(.scan-error-copy) {
max-width: 360px;
font-size: 14px;
color: #8fa4c3;
line-height: 1.6;
}
.root :global(.scan-retry-button) {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 10px 16px;
border: 1px solid rgba(77, 163, 255, 0.3);
border-radius: 12px;
background: rgba(77, 163, 255, 0.12);
color: #9ecbff;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.root :global(.scan-retry-button:hover) {
background: rgba(77, 163, 255, 0.18);
border-color: rgba(111, 183, 255, 0.48);
}
.root :global(.scan-loading-state) {
flex: 1;
min-height: 520px;
display: grid;
place-items: center;
padding: 48px 32px;
text-align: center;
}
.root :global(.scan-loading-signal) {
position: relative;
width: min(100%, 420px);
display: grid;
justify-items: center;
gap: 18px;
padding: 28px 30px;
border: 1px solid rgba(125, 211, 252, 0.14);
border-radius: 28px;
background:
radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
box-shadow:
0 22px 70px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.root :global(.scan-loading-signal::before) {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 0%,
rgba(125, 211, 252, 0.08) 42%,
rgba(34, 197, 94, 0.1) 50%,
transparent 58%
);
transform: translateX(-120%);
animation: scan-loading-sweep 2.6s ease-in-out infinite;
pointer-events: none;
}
.root :global(.scan-loading-signal.compact) {
width: min(100%, 340px);
gap: 12px;
padding: 18px 20px;
border-radius: 22px;
background:
radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
rgba(10, 18, 32, 0.62);
box-shadow: none;
}
.root :global(.scan-loading-decision-flow) {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
align-items: center;
width: min(100%, 300px);
}
.root :global(.scan-loading-node) {
width: 16px;
height: 16px;
border: 1px solid rgba(226, 232, 240, 0.16);
border-radius: 999px;
background: #0b1220;
box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
}
.root :global(.scan-loading-node.hot) {
background: linear-gradient(135deg, #f97316, #fde047);
box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
}
.root :global(.scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, var(--color-accent-primary));
box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
}
.root :global(.scan-loading-node.action) {
background: linear-gradient(135deg, #22c55e, #1be392);
box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
}
.root :global(.scan-loading-rail) {
position: relative;
height: 2px;
overflow: hidden;
background: rgba(148, 163, 184, 0.16);
}
.root :global(.scan-loading-rail i) {
position: absolute;
inset: 0;
width: 48%;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
transform: translateX(-110%);
animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
}
.root :global(.scan-loading-rail:nth-of-type(4) i) {
animation-delay: 0.45s;
}
.root :global(.scan-loading-copy-block) {
position: relative;
z-index: 1;
display: grid;
gap: 7px;
justify-items: center;
}
.root :global(.scan-loading-copy-block strong) {
color: var(--color-text-primary);
font-size: 18px;
font-weight: 900;
letter-spacing: -0.02em;
}
.root :global(.scan-loading-copy-block span) {
max-width: 320px;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 600;
line-height: 1.55;
}
.root :global(.scan-loading-signal-bars) {
position: relative;
z-index: 1;
display: flex;
align-items: end;
gap: 5px;
height: 28px;
}
.root :global(.scan-loading-signal-bars span) {
width: 7px;
min-height: 8px;
border-radius: 999px 999px 4px 4px;
background: linear-gradient(180deg, #7dd3fc, var(--color-accent-primary) 52%, #1be392);
opacity: 0.42;
transform-origin: center bottom;
animation: scan-loading-bars 1.35s ease-in-out infinite;
}
.root :global(.scan-loading-signal-bars span:nth-child(1)) {
height: 12px;
}
.root :global(.scan-loading-signal-bars span:nth-child(2)) {
height: 22px;
animation-delay: 0.16s;
}
.root :global(.scan-loading-signal-bars span:nth-child(3)) {
height: 16px;
animation-delay: 0.32s;
}
.root :global(.scan-loading-signal-bars span:nth-child(4)) {
height: 25px;
animation-delay: 0.48s;
}
@keyframes scan-loading-sweep {
0% {
transform: translateX(-130%);
}
46%,
100% {
transform: translateX(130%);
}
}
@keyframes scan-loading-rail-flow {
0% {
transform: translateX(-110%);
opacity: 0.15;
}
45% {
opacity: 1;
}
100% {
transform: translateX(220%);
opacity: 0.15;
}
}
@keyframes scan-loading-node-breathe {
0%,
100% {
transform: scale(0.92);
filter: saturate(0.9);
}
50% {
transform: scale(1.08);
filter: saturate(1.25);
}
}
@keyframes scan-loading-bars {
0%,
100% {
opacity: 0.38;
transform: scaleY(0.72);
}
50% {
opacity: 0.95;
transform: scaleY(1);
}
}
@@ -0,0 +1,213 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatQuoteCents } from "./opportunity-format";
import { getLocalizedRowText } from "./opportunity-copy";
import { getModelSourceSummary } from "./opportunity-model-summary";
export function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
const edge = Number(edgePercent);
const normalized = Number.isFinite(edge) ? edge : 0;
if (normalized >= 20) {
return {
label: locale === "en-US" ? "High confidence" : "高胜率",
tone: "strong",
};
}
if (normalized >= 10) {
return {
label: locale === "en-US" ? "Medium confidence" : "中等胜率",
tone: "medium",
};
}
return {
label: locale === "en-US" ? "Watch" : "观察",
tone: "watch",
};
}
export function getShortAiConclusion(
row: ScanOpportunityRow,
locale: string,
_edgePercent?: number | null,
strengthLabel?: string,
) {
const directReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
if (directReason) return directReason;
const cityThesis = getLocalizedRowText(
row,
locale,
row.ai_city_thesis_zh,
row.ai_city_thesis_en,
);
if (cityThesis) return cityThesis;
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
if (locale === "en-US") {
return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
}
return `${strengthLabel || "观察"}AI 需结合${modelBasis}确认。`;
}
export function getRiskHints(
row: ScanOpportunityRow,
locale: string,
modelProbability?: number | null,
) {
const hints: string[] = [];
const spread = Number(row.spread);
if (Number.isFinite(spread) && spread > 0.03) {
hints.push(
locale === "en-US"
? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
);
}
const quoteAgeSeconds =
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
? Math.round(Number(row.quote_age_ms) / 1000)
: null;
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
hints.push(
locale === "en-US"
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
);
}
if (row.trend_alignment === false) {
hints.push(
locale === "en-US"
? "Intraday trend does not fully support this direction."
: "日内趋势未完全支持该方向。",
);
}
if (row.cluster_adjusted) {
hints.push(
locale === "en-US"
? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
: "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
);
}
if (modelProbability != null && modelProbability < 10) {
hints.push(
locale === "en-US"
? "Low model probability makes the setup sensitive to calibration error."
: "模型概率偏低,校准误差会显著影响判断。",
);
}
if (!hints.length) {
hints.push(
locale === "en-US"
? "Main residual risk is late observation updates or a shifted peak window."
: "主要残余风险是后续实测升温或峰值窗口漂移。",
);
}
return hints;
}
export function getRecommendationReasons(
row: ScanOpportunityRow,
locale: string,
_edgePercent?: number | null,
price?: number | null,
) {
const reasons: string[] = [];
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
reasons.push(aiReason);
}
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
reasons.push(
locale === "en-US"
? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
: `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
);
if (row.peak_alignment_score != null) {
reasons.push(
locale === "en-US"
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
);
}
return reasons.slice(0, 3);
}
export function getExclusionReasons(
row: ScanOpportunityRow,
locale: string,
edgePercent?: number | null,
) {
const decision = String(row.ai_decision || "").toLowerCase();
const aiReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
return [
aiReason ||
(locale === "en-US"
? "AI did not classify this row as the primary forecast bucket."
: "AI 未把该合约列为主预测桶。"),
];
}
if (edgePercent != null && Number(edgePercent) < 10) {
return [
locale === "en-US"
? "This bucket is not the current forecast center."
: "该桶不是当前预测中枢。",
];
}
return [
locale === "en-US"
? "No hard veto in the current AI/rule snapshot."
: "当前 AI/规则快照没有硬性排除项。",
];
}
export function getAiMeta(row: ScanOpportunityRow, locale: string) {
const decision = String(row.ai_decision || "").toLowerCase();
if (decision === "veto") {
return {
label: locale === "en-US" ? "AI veto" : "AI 排除",
tone: "veto",
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
};
}
if (decision === "downgrade") {
return {
label: locale === "en-US" ? "AI downgrade" : "AI 降级",
tone: "downgrade",
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
};
}
if (row.ai_rank != null || decision === "approve") {
return {
label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
tone: "approve",
reason:
(locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
row.ai_model_cluster_note ||
null,
};
}
if (decision === "watchlist") {
return {
label: locale === "en-US" ? "AI watch" : "AI 观察",
tone: "downgrade",
reason:
locale === "en-US"
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
};
}
return null;
}

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