Files
Jyotisha/docs/tasks/TASK-chart-page-skeleton-wait-20260924.md
T

13 KiB
Raw Blame History

TASK · 星盘页骨架盘等待态 + 三个「永远还没拿到」死角(2026-09-24)

基线

  • origin/staging = 8902e484(fix(chat): honor new-chat intent from secondary pages)。
  • staging 线上 deployment.gitCommit = 018b2b48,8902e484 尚未部署(门禁状态待产品在 Gitea 核对)。本单不依赖它。
  • 执行分支:codex/chart-page-skeleton-wait-20260924,worktree .worktrees/chart-page-skeleton-wait-20260924。
  • 同期无人改 frontend/src/components/chart-page/**、frontend/src/hooks/use-chart-page.ts;TASK-console-noise-20260916.md(待领取)里 BUG-906 动的是 vedic-chart-svg.tsx 的 height=auto,若两单同期开工,本单后合,合前 rebase。

事故实证

产品真机:进入 /chart 看到一整块空白区域中间一句「这一张盘还没拿到。」,分不清是在算还是已经失败。

代码链(按 origin/staging 8902e484):

  1. 文案在 frontend/src/lib/chart-view-labels.ts CHART_VIEW_COPY:waitingChart「这一张盘还没拿到。」、waitingLayer「这一栏还没拿到。」、waitingVarga「这一分盘还没拿到。」;失败句另有 busy / unavailable / rateLimited / unauthenticated 等。
  2. frontend/src/components/chart-page/chart-page-view.tsx ChartPageView:<SecondaryPageShell waiting={view == null ? CHART_VIEW_COPY.waitingChart : null}>;SecondaryPageShell(secondary-page-shell.tsx)把 waiting 渲染成居中一句 <p className="secondary-page-waiting">。view === null 是这句话唯一触发条件。
  3. frontend/src/hooks/use-chart-page.ts useChartPage:useState<ChartViewResponse | null>(() => viewFromSnapshot(peekChartPage()));挂载后 refreshChartPage()。状态只有 null | view,没有 loading / failed 的显式态。
  4. frontend/src/lib/secondary-page-data.ts ChartPageSnapshot = {kind:"view"} | {kind:"unauthenticated"},缺失即 null;错误被编码成 status !== "ok" 的 view。
  5. frontend/src/lib/chart-view-client.ts fetchChartView 接受 signal,但 useChartPage 两处调用都没传;服务端 /api/chart-view maxDuration = 20,引擎 AbortSignal.timeout(10000)(chart-view-engine.ts),但 auth.getUser 与 profiles 读库无超时。
  6. frontend/src/components/chart-page/chart-vedic-tab.tsx ChartVedicTab:const waiting = vargaId !== "D1" && !selected → {waiting || !varga ? <p>{waitingVarga}</p> : …};只看有没有数据,不看 pendingLayers。
  7. useChartPage.requestLayer 的 .catch 只 warnClientFailure("chart_page_layer_failed"),.then 里 status !== "ok" 时写缓存但 D1 之外的分盘不会因此有 selected;结果分盘失败后永远停在「这一分盘还没拿到。」。

三个死角(同一句话、无出口):

死角 触发 现状
A 请求不回 客户端无超时;服务端 20s 上限之外的读库无超时 等待句永远挂着
B 未登录 401 → window.location.assign("/login") 之前 等待句挂到跳转
C 分盘层失败 / 429 /api/varga_full、/api/western、/api/qizheng 在 HEAVY_COMPUTE_PATHS(并发 2,饱和 429) 「这一分盘还没拿到。」永远挂着;西洋 / 七政的 pendingLayers 清掉后也回不到失败句

另一个小口:useChartPage 首次请求 .catch 里 if (peekChartPage()?.kind === "view") return; —— 缓存里已有 view 但组件 state 仍 null 时不 setView,等待句留住。

根因

  • 等待与失败的状态模型缺失:只有 null | view,"在等"、"等不到"、"等到了但坏了"没有各自的态;分盘层没有任何失败态。
  • 视觉上,等待态只有一句静态文案,整块内容区空白,用户无法判断进度;这是 2026-09-18 产品选定的「方案一」(BUG-966:去掉中间态、不放 spinner),现在产品要推翻。

决策记录

  • D1(推翻既有红线,产品 2026-09-24 拍板):星盘页主盘与分盘未到时显示骨架盘 + 呼吸动效。这条推翻:
    • frontend/DESIGN.md §9「等待与加载」的"揭幕后不得再出现任何阻塞等待或组件级 spinner"对星盘页的适用;
    • frontend/DESIGN.md §15「只读星盘页 · 等待态」"揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」";
    • AGENTS.md §6 "第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外)"——本单在该句后追加例外「星盘页盘位骨架盘除外(BUG-1016 决策)」;
    • BUG-716 / BUG-966 的防复发条款中"不得再上骨架"的部分。
    • 范围只限星盘页会出现盘轮的位置:D1 主盘、非 D1 分盘、西洋盘。首页、侧栏、星历页、报告页、大运 / 七政的表格区不适用,仍用静态句。
  • D2 骨架盘不是新造一套图形:复用 frontend/src/components/personal-report/vedic-chart-svg.tsx 的 VedicChartSvg 几何(北印度盘外框 + 十二宫菱形格),以空盘模型(无行星、无度数)渲染,加 data-skeleton="true",颜色用现有中性 token;西洋盘骨架复用西洋盘 SVG 组件的空模型。不得复制一份路径数据到新组件。
  • D3 呼吸动效:opacity 0.35 ↔ 0.6,周期 2.4s,ease-in-out infinite;@media (prefers-reduced-motion: reduce) 下静止在 0.5。不用 shimmer 渐变扫光、不用旋转。
  • D4 骨架盘下方保留原静态句(「这一张盘还没拿到。」/「这一分盘还没拿到。」),文案不改;aria-busy="true" 加在骨架容器上。
  • D5 三个死角的出口:
    • A:useChartPage 首次请求与 requestLayer 都传 AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS),常量 = 25 000(比服务端 20s 上限长,避免客户端先于服务端放弃)。超时后主盘显示新文案 timedOut:「这张盘等了太久没有回来,再打开一次试试。」(不承诺速度、不写"正在",对照 VOICE.md)。
    • B:缓存或响应为 unauthenticated 时,先把 view 置为 status: "unauthenticated" 的失败体(文案已存在:「请先登录后再看星盘。」),再 location.assign("/login")。
    • C:useChartPage 新增按层的失败态 layerFailures: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;requestLayer 的非 ok 响应与 catch 都写入(429 → engine_busy,超时 → timedOut,其它 → unavailable);ChartVedicTab / 西洋 / 七政 / 大运各 Tab 在 pending 为假且该层有失败态时渲染对应失败句,而不是等待句。点击 Tab 再次请求时清掉该层失败态。
    • 小口:首次 .catch 里若缓存已有 view,setView(cached.view) 而不是 return。
  • D6 文案新增只有一句 timedOut;其余全部复用 CHART_VIEW_COPY 既有条目。
  • D7 不改的:/api/chart-view 路由、引擎超时、HEAVY_COMPUTE_PATHS、并发数(BUG-717 红线);SecondaryPageShell 对星历 / 报告的行为;/chart 保持 ○ Static。

硬红线

  1. 骨架盘只在星盘页;不得把骨架 / 呼吸动效带进首页、侧栏、星历、报告列表(这些页的"不放骨架"合同不动)。
  2. 不得新增第二份盘轮几何;骨架必须由 VedicChartSvg(及西洋盘既有组件)以空模型渲染。
  3. 不得为了"解决" 429 扩 HEAVY_COMPUTE_PATHS 或调并发(BUG-717)。
  4. 429 必须独立一档(BUG-715 / 723):分盘层 429 显示 busy 句,不得并入 unavailable。
  5. /chart 必须保持 ○ Static;useChartPage 不得引入服务端读取。
  6. 既有断言改动逐条写"原值 / 新值 / 原因";测试总数不低于开工实测。
  7. 不改 jyotish_api_server.py;不顺手修 warning;不升级依赖。

任务分解

T1 · 状态模型(use-chart-page.ts、secondary-page-data.ts)

  • useChartPage 返回值增加 layerFailures;首次请求与 requestLayer 传超时 signal;401 先写失败体再跳转;catch 小口改 setView(cached.view)。
  • 超时常量 CHART_VIEW_CLIENT_TIMEOUT_MS = 25_000 与文案 timedOut 放在 chart-view-labels.ts / chart-view-failure.ts 现有映射表里(FAILURE_COPY / FAILURE_STATUS 加一档 client_timeout)。

验收:frontend/tests/chart-page-hook.test.ts(新)≥5 条纯函数 / 模拟 fetch 测试:主盘超时 → view.status === "client_timeout" 且文案是 timedOut;分盘 429 → layerFailures.get(layer) === "engine_busy";分盘 catch → unavailable;再次 requestLayer 清失败态并重新 pending;缓存已有 view 时 catch 不留 null。

T2 · 骨架盘(chart-page-view.tsx、chart-vedic-tab.tsx、vedic-chart-svg.tsx、globals.css)

  • vedic-chart-svg.tsx 导出 EMPTY_NORTH_INDIAN_CHART(或等价空模型工厂)与 skeleton?: boolean prop(加 data-skeleton、去掉行星层)。
  • ChartPageView:view == null 时不再走 SecondaryPageShell.waiting,而是正常渲染 body,主盘位置放 <VedicChartSvg skeleton> + 等待句;Tab 条可见但禁用。
  • ChartVedicTab:waiting 且该层无失败态 → 骨架盘 + 等待句;有失败态 → 失败句;西洋 Tab 同理用西洋盘骨架;大运 / 七政表格区维持静态句(D1 范围)。
  • CSS:.chart-page-skeleton { animation: chart-skeleton-breathe 2.4s ease-in-out infinite } + prefers-reduced-motion 静止;class 名与 keyframe 只此一处。

验收:frontend/tests/chart-page-view.test.tsx 既有五条里涉及"不得出现骨架 / spinner"的断言按三栏改成"出现 data-skeleton 且不出现 InlineSpinner / 「正在加载」";新增:分盘失败态渲染失败句不渲染骨架;prefers-reduced-motion 规则存在。frontend/tests/secondary-page-entry.test.ts 三条:星历 / 报告的"不得骨架"断言不变,星盘那条改为允许 data-skeleton(三栏)。grep -rn "chart-skeleton-breathe" frontend/src = 恰好 CSS 一处。

T3 · 规则文件

  • frontend/DESIGN.md §9 表格加一行"盘位骨架(仅星盘页)→ VedicChartSvg skeleton",§15 等待态改写并更新表(主盘未到 → 骨架盘 + 句;分盘失败 → 失败句;超时 → timedOut 句);写明推翻 09-18 方案一的决策日期。
  • AGENTS.md §6 那一句追加例外(见 D1)。
  • frontend/docs/VOICE.md 星盘段加 timedOut 一句。

T4 · 记录

  • docs/BUG_HISTORY.md:BUG-1016 主盘等待句吞掉超时 / 401 / 缓存小口(死角 A、B、小口);BUG-1017 分盘层失败永远显示「这一分盘还没拿到」(死角 C)。两条 resolved 各带针对性测试;关联 BUG-715、716、717、723、966、996;BUG-1016 的"防复发"里写明 BUG-716 / 966 的"不得骨架"条款被 D1 有限推翻。
  • CHANGELOG.md:星盘页等待改骨架盘 + 呼吸;分盘失败会说明原因;等太久有出口。
  • docs/tasks/PROGRESS-chart-page-skeleton-wait-20260924.md;docs/testing/chart-page-skeleton-20260924.md 真机清单:① 冷进 /chart 看到骨架盘呼吸 + 句,主盘到后无闪烁;② 点 D9 看到分盘骨架;③ 连点西洋 / 七政 / D9 / D10 触发 429 → 显示「算盘的服务正忙…」而非等待句,再点恢复;④ 断网后进页 → 25 秒内出现 timedOut 句;⑤ 系统开"减弱动态效果"→ 骨架静止;⑥ 星历 / 报告页无骨架。

让步顺序

  1. 西洋盘骨架若其 SVG 组件无法以空模型渲染,允许西洋 Tab 沿用静态句 + 同一呼吸容器包一个空 VedicChartSvg,写进进度记录——不得新画一套西洋盘轮廓。
  2. layerFailures 若让 useChartPage 复杂度超出,允许把失败态并入 pendingLayers 的同一个 Map(ReadonlyMap<layer, "pending" | failureStatus>),但每层必须能区分 pending / failed。
  3. 不得让步:D1 范围只限星盘页;红线 2(不复制几何);红线 4(429 独立档)。

开工前置命令

git fetch origin --prune
git worktree add -b codex/chart-page-skeleton-wait-20260924 .worktrees/chart-page-skeleton-wait-20260924 origin/staging
cd .worktrees/chart-page-skeleton-wait-20260924/frontend
./node_modules/.bin/tsc --noEmit && npm run lint
npm test 2>&1 | tail -5     # 记开工总数与失败清单
npx tsx --test tests/chart-page-view.test.tsx tests/secondary-page-entry.test.ts tests/chart-profile-update-consistency.test.ts tests/chart-view-engine.test.ts tests/chart-view-route.test.ts

完成后:tsc 0 错、lint 0 error、npm test 失败清单与基线逐条一致、next build 后 / 与 /chart 仍 ○ Static、首屏 gzip ±2%(/chart 首屏多带的骨架不计入 /)。纯前端,不要求 pre_work_check.py。

BUG 编号起点

写单时最大号 BUG-1015;本单用 BUG-1016 / 1017,开工时核对,被占用则顺延并写进进度记录。