diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9cb53b..8d4e7fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-26 — 首页开场小字拆开,今日星语和点数换图标(待验收) + +- 今日趋势(今日星语卡片那一句,每天生成)挪到问候语下面单独一行,浅色、最多两行;还没生成时写「今天的星语还没写出来。」,没有出生分钟时不显示。 +- 入口按钮下面平时不再有字,只在需要动手时出一句:有没做完的校正写「上次那次校正还没完成,可以接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。删掉「不确定出生时间时…」「上次已经校正完…」两句。 +- 修好已校正过的账户仍被当成第一次的问题(BUG-1041):首页读校正状态时一直读错字段名,所以从没出现过「再次校正」和未完成提示。 +- 今日星语入口图标换成带星的月亮,右上角点数换成硬币,两处不再是同一个图标;读屏名称不变。 +- Skill 版本不 bump。不改接口、今日星语生成逻辑和数据库;登录态真机与部署待验收。 + ## 2026-09-26 — 从其他页面回首页不再重放加载动画(待验收) - 同一次打开网页里,从星盘、星历、我的报告、星盘档案点「新建对话」、点侧栏里的历史对话、点左下角头像或按浏览器返回回到首页,不再出现「正在载入账户 / 正在准备对话」加载环,首页立刻可以输入。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 79bbf9e6..10fd40fe 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13928,3 +13928,18 @@ - 相关记录:BUG-966(次级页模块缓存先例)、BUG-479(一次等待一次揭幕)、BUG-1021(不半揭幕,快照缺项回冷启动)、BUG-1038(新建意图与存根规则,暖路径沿用)、BUG-936(首屏兜底,`data-hydrated` 不变)。 - 复发自:无。 - 修复版本:`codex/home-warm-return-20260926`,未部署。 + +## BUG-1041 | 首页校正入口摘要解析读错键名:已校正账户仍显示首次文案、未完成的校正从不提示 + +- 状态:investigating(已修复待部署;登录态真机未复测) +- 首次发现 / 最近更新:2026-09-26 / 2026-09-26 +- 影响面:`frontend/src/lib/rectification-entry.ts` `entrySummaryFromResponse`(首页 `page.tsx` 启动 effect 与 `useRectificationSurface.refreshRectificationEntrySummary` 共用),进而影响首页生时校正入口的可访问名(`开始新的生时校正` / `再次校正`)与入口下方提示。 +- 用户现象:产品 09-26 真机截图:账户已经校正过,首页入口下方仍写首次文案「不确定出生时间时,用记得住的经历一步步缩小范围」;按钮可访问名也一直是「开始新的生时校正」,从未出现「再次校正」。 +- 触发条件:任何有未完成或已完成校正 Case 的账户打开首页。 +- 根因:`GET /api/rectification/cases/entry-summary` 返回 `getRectificationEntrySummary` 投影后的 **camelCase** 对象(`hasResumableCase` / `hasTerminalCaseWithTime` / `latestTerminal.caseId`…),只有底下的 RPC 行是 snake_case;而浏览器端 `entrySummaryFromResponse` 只读 snake_case 键(`has_resumable_case` 等),所以每一个真实响应都被解析成「什么都没有」。`resolveRectificationEntryAction` 恒为 `start`,`rectificationCardAction === "restart"` 与 `hasResumableCase` 分支从未命中。自 2026-08-11 `ca748466`(BUG-163 入口改造)起存在。测试没拦住:`rectification-v9-entry-routing.test.ts`、`home-warm-return-lifecycle.test.tsx`、`new-chat-from-people-lifecycle.test.tsx` 的 entry-summary mock 都是手写的 snake_case,不是路由真实输出;`rectification-v9-case-service.test.ts` 只测到服务端投影为止,两段之间没有往返合同。 +- 修复:`entrySummaryFromResponse` 以路由真实的 camelCase 形状为准,同时仍读 snake_case(RPC 形状不会再静默塌成空)。首页小字按 TASK-starter-home-polish-20260926 D2 改为只读 summary 的 `hasResumableCase` 与当前人物,不再依赖派生的 `restart`。 +- 验证:`frontend/tests/starter-home-polish-20260926.test.tsx`「BUG-1041: the homepage parser reads what the entry-summary route actually returns」:用 `getRectificationEntrySummary`(假 RPC)产出、`JSON` 往返后交给 `entrySummaryFromResponse`,断言 `hasTerminalCaseWithTime`、`latestTerminal`、`restart` 与未完成提示;修复前该条失败(解析结果全 false)。本地 `next start` + Chrome 151,`/api/*` 以 camelCase 形状回放:已校正账户可访问名「再次校正」、无小字;未完成账户显示「上次那次校正还没完成,可以接着做。」。全量 3960 条 / 61 失败,失败名单与基线逐条一致。 +- 防复发:往返合同测试锁住「路由输出 → 首页解析」一段;新的首页测试 fixture 用路由真实的 camelCase 形状。旧的 snake_case mock 仍能被解析,未改动。 +- 相关记录:BUG-163(入口摘要由服务端 Case 驱动)、BUG-1040(暖快照缓存的正是解析后的 summary,快照结构不变)。 +- 复发自:无。 +- 修复版本:`codex/starter-home-polish-20260926`,未部署。 diff --git a/docs/tasks/PROGRESS-starter-home-polish-20260926.md b/docs/tasks/PROGRESS-starter-home-polish-20260926.md new file mode 100644 index 00000000..4c88a89b --- /dev/null +++ b/docs/tasks/PROGRESS-starter-home-polish-20260926.md @@ -0,0 +1,69 @@ +# PROGRESS · 首页开场小字拆分 + 今日星语 / 点数图标(2026-09-26) + +任务书:`TASK-starter-home-polish-20260926.md`。执行方:Claude 子代理(直接执行模式)。分支 `codex/starter-home-polish-20260926`,worktree `.worktrees/starter-home-polish-20260926`,本地提交未推送。 + +## 基线 + +- `origin/staging` = `7475eb6e`(BUG-1038、BUG-1040 已合入)。 +- 基线全量前端测试(Node 20.19.2):3953 条,pass 3865 / fail 61 / skipped 27(61 条均为环境缺口:Node 20 `mock.module` 的 `ERR_MODULE_NOT_FOUND @/…`、无 Docker)。 +- 基线 `next build --webpack`:`/` ○ Static;rootMainFiles gzip 130933 B;`index.html` 引用的全部 js/css gzip 合计 656646 B。 +- BUG 编号:开工时 `docs/BUG_HISTORY.md` 最大 BUG-1040,远端各分支无更大号 → 本单用 BUG-1041。 + +## 改动 + +| 任务 | 文件 | 内容 | +| --- | --- | --- | +| T1 / D1 | `components/starter-home.tsx`、`app/(app)/page.tsx`、`globals.css` | `StarterGreeting` 加可选 `dailyTrend` / `dailyTrendSettled`,渲染 `.starter-greeting-sub`(`--color-ink-secondary`、`--type-body-sm`、两行截断)。`page.tsx` 传 `natalMinuteAvailable ? dailyStarlanguageTrend : null`,读的就是 BUG-1040 暖快照初始化的同一个 `dailyStarlanguage` state;未就绪沿用静态句「今天的星语还没写出来。」+ `role="status"`,无 `aria-busy`、无新等待态。 | +| T2 / D2 | `components/starter-home.tsx` | 新增纯函数 `rectificationEntryHint(forSelf, summary)`:非本人 →「生时校正暂时只支持本人。」;`summary.hasResumableCase` →「上次那次校正还没完成,可以接着做。」;其余 `null`,不渲染 `

`。删「不确定出生时间时…」「上次已经校正完…」「未完成的那次可以从左侧继续」「看看今天的整体节奏」四句与「·」拼接。`StarterEntries` 去掉不再用的 `dailyStarlanguage` / `dailyStarlanguageTrend` / `rectificationCardAction` 三个 prop。 | +| T2 / 分支误判 | `lib/rectification-entry.ts` | **真 bug(BUG-1041)**:`/api/rectification/cases/entry-summary` 返回 camelCase(`getRectificationEntrySummary` 的投影),`entrySummaryFromResponse` 只读 snake_case,每个真实响应都解析成全 false。改为优先读 camelCase、仍兼容 snake_case。结果:已校正账户可访问名变回「再次校正」,未完成提示能出现。 | +| T2 / D4 | — | 边界句「出生时间不足以支持的判断我会说明,不会补造具体分钟。」原位保留。 | +| T3 / D3 | `components/starter-home.tsx`、`app/(app)/page.tsx` | 今日星语入口 `Sparkles` → `MoonStar`;右上角点数 `Sparkles` → `Coins`(`className="credit-icon"`、`aria-hidden`、按钮 `aria-label` 不变)。已确认 lucide-react 1.24.0 同时导出 `MoonStar`、`Coins`。 | +| T4 | `CHANGELOG.md`、`frontend/DESIGN.md`(Starter home + §9)、`frontend/docs/VOICE.md`、`docs/BUG_HISTORY.md`(BUG-1041, investigating)、`docs/testing/starter-home-polish-20260926.md`、`docs/tasks/README.md` | 记录。 | + +红线核对:`Home()` 未新增 hook(只改 JSX 表达式);`page.tsx` 1373 → 1370 行;无语句压行;未改今日星语接口与生成逻辑;未改依赖、迁移、workflow。 + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(126 warning,均为既有文件的既有 warning,本单文件无新增) | +| 全量 `npm test` | 3960 条,pass 3872 / fail 61 / skipped 27 | +| 失败名单 vs 基线 | 逐条一致(`diff` 为空),新增失败 0 | +| 测试名单 vs 基线 | +7 条新名(下文),消失 0 条 | +| Python 源码合同 | `tests/test_daily_and_rectification_entrypoints.py`、`test_session_management_entrypoints.py`、`test_birth_time_journey_contract.py`、`test_supabase_user_data_contract.py` 全过 | +| `npm run build -- --webpack` | 通过;`/` ○ Static,`/chart` `/ephemeris` `/people` ○ | +| gzip | rootMainFiles 130933 → 130933 B(0);首屏引用全部 js/css 656646 → 656729 B(+83 B,+0.013%) | + +新增 `frontend/tests/starter-home-polish-20260926.test.tsx`(7 条):D2 首次 / 已校正无提示、未完成一句;D2 非本人(纯函数 + 真实 subject store 渲染,按钮禁用);D4 边界句;D1 副行三态(就绪 / 静态句 + status / 无分钟不渲染);D1 page.tsx 读暖快照同源 state;D3 图标与可访问名;BUG-1041 路由输出 JSON 往返后解析(修复前此条失败,已实测)。 + +### 改动既有断言(三栏同时写在测试文件内) + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `frontend/tests/icon-motion-audit.test.ts` | `import { Clock3, Sparkles }`、`

@@ -1244,7 +1244,7 @@ export default function Home() { ) : null} {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && ( - + )}
) : sessionMessagesLoading ? ( @@ -1328,13 +1328,10 @@ export default function Home() { {starterHomeVisible && (

{starterGreeting.text}

+ {dailyTrend ? ( +

+ {dailyTrend} +

+ ) : null} ); } +/* The line under the pills only appears when there is something to do about + it. First runs and finished cases say nothing: the pill already names the + action. The branch reads the entry summary itself, not the derived + start/restart label, and a non-self subject wins because the case summary + belongs to the account owner. */ +export function rectificationEntryHint( + rectificationForSelf: boolean, + summary: RectificationEntrySummary | null, +): string | null { + if (!rectificationForSelf) return "生时校正暂时只支持本人。"; + if (summary?.hasResumableCase) return "上次那次校正还没完成,可以接着做。"; + return null; +} + export type StarterEntriesProps = { - readonly dailyStarlanguage: DailyStarlanguageState; readonly natalMinuteAvailable: boolean; readonly dailyStarlanguageQuestion: string; - readonly dailyStarlanguageTrend: string; readonly productEntrypointsDisabled: boolean; readonly startDailyStarlanguageConsultation: () => void; readonly rectificationCardLabel: string; - readonly rectificationCardAction: string; readonly rectificationLoading: boolean; readonly rectificationMutationPending: boolean; readonly openRectificationFromHomepage: () => void; @@ -53,13 +75,10 @@ export type StarterEntriesProps = { export function StarterEntries({ natalMinuteAvailable, - dailyStarlanguage, dailyStarlanguageQuestion, - dailyStarlanguageTrend, productEntrypointsDisabled, startDailyStarlanguageConsultation, rectificationCardLabel, - rectificationCardAction, rectificationLoading, rectificationMutationPending, openRectificationFromHomepage, @@ -71,20 +90,7 @@ export function StarterEntries({ const subjectId = useCurrentSubject(); const rectificationForSelf = subjectId === "self"; const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势"; - /* The trend line is the only part that arrives late. It stays a live region so - a screen reader hears it settle, but it is a tooltip-rank hint now, not a - card body — the pill is actionable from the first paint either way. */ - const dailyHint = natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏"; - /* Three branches, same as the card it replaces: an unfinished case, a finished - one that can be redone, and a first run. The label itself is server-driven - from the entry summary (`rectificationCardAction`), not from the session list. */ - const rectificationHint = !rectificationForSelf - ? "生时校正暂时只支持本人" - : rectificationEntrySummary?.hasResumableCase - ? "未完成的那次可以从左侧继续" - : rectificationCardAction === "restart" - ? "上次已经校正完,可以拿最新资料再来一次" - : "不确定出生时间时,用记得住的经历一步步缩小范围"; + const rectificationHint = rectificationEntryHint(rectificationForSelf, rectificationEntrySummary); return (
@@ -96,7 +102,7 @@ export function StarterEntries({ disabled={productEntrypointsDisabled} onClick={startDailyStarlanguageConsultation} > -
-

- {dailyHint} · {rectificationHint} -

+ {rectificationHint &&

{rectificationHint}

} {!natalMinuteAvailable && (

出生时间不足以支持的判断我会说明,不会补造具体分钟。

)} diff --git a/frontend/src/lib/rectification-entry.ts b/frontend/src/lib/rectification-entry.ts index 1b6cbc9f..04a397da 100644 --- a/frontend/src/lib/rectification-entry.ts +++ b/frontend/src/lib/rectification-entry.ts @@ -69,32 +69,45 @@ export function isTerminalRectificationStatus(status: string): boolean { return TERMINAL_STATUSES.has(status); } +/* `GET /api/rectification/cases/entry-summary` answers with the camelCase + object `getRectificationEntrySummary` returns; only the RPC underneath speaks + snake_case. This parser used to read the snake_case keys alone, so every real + response parsed as "nothing on record" and the homepage never saw a resumable + or finished case (BUG-1041). The route's camelCase shape wins; snake_case is + still read so an RPC-shaped payload does not silently collapse either. */ +function field(row: Record, camel: string, snake: string): unknown { + return camel in row ? row[camel] : row[snake]; +} + +function objectField(row: Record, camel: string, snake: string): Record | null { + const value = field(row, camel, snake); + return value && typeof value === "object" ? value as Record : null; +} + export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary { if (!value || typeof value !== "object") { return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null }; } const row = value as Record; - const latestResumable = row.latest_resumable && typeof row.latest_resumable === "object" - ? row.latest_resumable as Record - : null; - const latestTerminal = row.latest_terminal && typeof row.latest_terminal === "object" - ? row.latest_terminal as Record - : null; + const latestResumable = objectField(row, "latestResumable", "latest_resumable"); + const latestTerminal = objectField(row, "latestTerminal", "latest_terminal"); + const resumableCaseId = latestResumable ? field(latestResumable, "caseId", "case_id") : null; + const terminalCaseId = latestTerminal ? field(latestTerminal, "caseId", "case_id") : null; return { - hasResumableCase: row.has_resumable_case === true, - hasTerminalCaseWithTime: row.has_terminal_case_with_time === true, - latestResumable: latestResumable && typeof latestResumable.case_id === "string" + hasResumableCase: field(row, "hasResumableCase", "has_resumable_case") === true, + hasTerminalCaseWithTime: field(row, "hasTerminalCaseWithTime", "has_terminal_case_with_time") === true, + latestResumable: latestResumable && typeof resumableCaseId === "string" ? { - caseId: latestResumable.case_id, + caseId: resumableCaseId, status: String(latestResumable.status ?? ""), - lastActivityAt: String(latestResumable.last_activity_at ?? ""), + lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""), } : null, - latestTerminal: latestTerminal && typeof latestTerminal.case_id === "string" + latestTerminal: latestTerminal && typeof terminalCaseId === "string" ? { - caseId: latestTerminal.case_id, + caseId: terminalCaseId, status: String(latestTerminal.status ?? ""), - hasUsableTime: latestTerminal.has_usable_time === true, + hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true, } : null, }; diff --git a/frontend/tests/icon-motion-audit.test.ts b/frontend/tests/icon-motion-audit.test.ts index dfbed629..38be0e7a 100644 --- a/frontend/tests/icon-motion-audit.test.ts +++ b/frontend/tests/icon-motion-audit.test.ts @@ -6,10 +6,14 @@ const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", i const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); test("the 2026-09-22 icon audit keeps the existing entry icons and adds no loop", () => { - assert.match(starterHome, /import \{ Clock3, Sparkles \} from "lucide-react"/); - assert.match(starterHome, /