diff --git a/docs/tasks/README.md b/docs/tasks/README.md index d57be9f3..4f685920 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -194,6 +194,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| `TASK-chart-page-skeleton-wait-20260924.md` | — | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 待领取 | — | | `TASK-smalltalk-test-stdout-mock-20260921.md` | `PROGRESS-smalltalk-test-stdout-mock-20260921.md` | **测试基础设施(BUG-995)**:`consultation-smalltalk.test.ts` 为验证「SDK 不得把私密文本写进日志」,用 `t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出——而 `node:test` 的 TAP 报告也走 `process.stdout`,于是运行器自己的 `# Subtest:` / `ok` 行被吞进测试的 `logs` 数组。实测四次全量:`default Mastra adapter` 四条用例只上报 4 / 2 / 1 / 1 条(`Subtest` 声明本身就少,不是 grep 锚点问题),`# tests` 因此 ±3 抖;单跑该文件 33 条静态展开只报 30 条,仅循环最后一个 `provider_error` 稳定可见。**更要紧**:注入必失败探针后,`invalid_schema` / `bad_json` 只剩一行 `not ok 1 - <文件绝对路径>`,**没有用例名、没有断言消息**(退出码仍是 1,门禁不会漏掉失败,不夸大)。定位 BUG-987 时靠的就是从门禁日志 grep `not ok` 抓用例名,红在这三条上那条路会断。**决策**:mock 改为记录后**透传给原始 `write`**(保留隐私断言,不换报告通道,不改 `npm test` 脚本);F3 把「比用例名列表 diff、不比 `# tests` 总数」写进 `frontend/AGENTS.md`。全仓只此一个文件用了这个手法(已 grep 确认)| 待验收 | `2503c019` | | `TASK-frontend-optimization-20260828.md` | `PROGRESS-frontend-optimization-20260828.md` | 前端优化九条 | 待核对 | 分支 `codex/frontend-optimization-20260828` | | `TASK-frontend-followup-20260829.md` | `PROGRESS-frontend-followup-20260829.md` | 九条收尾 | 待核对 | — | diff --git a/docs/tasks/TASK-chart-page-skeleton-wait-20260924.md b/docs/tasks/TASK-chart-page-skeleton-wait-20260924.md new file mode 100644 index 00000000..9baf44b1 --- /dev/null +++ b/docs/tasks/TASK-chart-page-skeleton-wait-20260924.md @@ -0,0 +1,119 @@ +# 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`(`secondary-page-shell.tsx`)把 `waiting` 渲染成居中一句 `

`。**`view === null` 是这句话唯一触发条件。** +3. `frontend/src/hooks/use-chart-page.ts` `useChartPage`:`useState(() => 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 ?

{waitingVarga}

: …}`;只看有没有数据,不看 `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`;`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,主盘位置放 `` + 等待句;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`),但每层必须能区分 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**,开工时核对,被占用则顺延并写进进度记录。