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

120 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 独立档)。
## 开工前置命令
```bash
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**,开工时核对,被占用则顺延并写进进度记录。