feat(report): drop the report sky cover; docs, checklist and progress for M1–M5 (M5+M3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
60b5542b7d
commit
447a19b561
+1
-1
@@ -1,6 +1,6 @@
|
||||
# BLOCKED
|
||||
|
||||
## ~~TASK-birth-sky-cover T5:报告封面没有可靠的出生资料绑定(2026-09-28)~~ → 已解除:产品 2026-09-28 选 A
|
||||
## ~~TASK-birth-sky-cover T5:报告封面没有可靠的出生资料绑定(2026-09-28)~~ → 已解除:产品 2026-09-28 选 A → 2026-09-29 产品决定去掉报告封面(TASK-mobile-chart-and-confirmed-edit-20260929 M5;生成时写入的 `subject.birthFingerprint` 保留)
|
||||
|
||||
- ~~任务书要求「只有报告生成时的出生资料与现在一致时才显示封面」。逐项查过,报告侧**没有任何出生资料指纹**:~~
|
||||
- ~~`personal_reports` 表列(`20260806010000_personal_reports.sql` 及其后 `add column`):`chart_profile_id`、`request_fingerprint`、`calculation_hash`、`evidence_hash` 等,没有出生资料快照或 `profileFingerprint`。~~
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-29 — 手机上星盘能完整看到、改了出生资料排盘跟着变、「那一刻的天空」改为动画定格、报告去掉星图封面(待验收)
|
||||
|
||||
- 手机上星盘页的北印度盘不再被撑出屏幕:盘完整显示在屏幕内,下面的表格在自己的框里左右滑(BUG-1083)。电脑上右侧被裁掉的参数卡和「≡」盘型按钮也一并回来了。
|
||||
- 出生时间已经「确认」(包括采用过生时校正结果)时,在星盘档案里改了出生日期、时间或地点,星盘就按新资料重新排;只改称呼、岁差、性别不动已确认的时间。改资料会让之前采用的校正结果作废。
|
||||
- 星盘页点「那一刻的天空」:每次打开都重放一遍星星汇聚,然后停在完整画面上不再消失;左上角 × 关闭,右上角「分享」——手机上弹出系统分享,电脑上直接下载图片。分享出去的就是屏幕上停住的那一帧。原来的「保存图片」按钮去掉。
|
||||
- 报告页顶部的星图封面去掉,打印 PDF 第一页就是正文。报告生成时仍会记下当时的出生资料指纹,只是现在不用。
|
||||
- Skill 版本不 bump(Skill 文本未改)。不改数据库结构。
|
||||
|
||||
## 2026-09-28 — 星盘档案能改自己的资料、每页点头像都弹账户菜单、我的报告去掉顶部说明(待验收)
|
||||
|
||||
- 星盘档案里「本人」也有「编辑」了:和家人同一张表,能改称呼、出生日期与时间、出生地、岁差和性别;本人仍然不能删除。此前全站找不到改自己出生资料的地方(BUG-1081)。改完后首页、星盘页、星历按新资料重读。
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# PROGRESS · 手机星盘 / 确认时间可改 / 天空定格 / 去掉报告封面(2026-09-29)
|
||||
|
||||
任务书:`docs/tasks/TASK-mobile-chart-and-confirmed-edit-20260929.md`(M1–M4 在 `53c0b9ea`,M5 在 `47eda7e1` 追加)。产品授权直接执行(Claude 子代理)。
|
||||
|
||||
- 基线:`origin/staging` = `53c0b9ea`(开工时;其后 `47eda7e1` 只加了任务书 M5 段,纯文档,分支未 rebase)
|
||||
- 分支 / 工作树:`codex/mobile-chart-confirmed-edit-20260929` / `.worktrees/mobile-chart-confirmed-edit-20260929`,**未推送**
|
||||
- 环境:Node v22.14.0(`/exec-daemon/node`);系统 `python3`;无 Docker;无头 Chrome 151(`--disable-dev-shm-usage`,CDP 设备模拟)
|
||||
- BUG 编号:开工时最大 BUG-1082;本单 M1 = BUG-1083;M2 是 BUG-264 下的产品修订段,不另开号
|
||||
|
||||
## 结论
|
||||
|
||||
| 项 | 结果 | 提交 |
|
||||
| --- | --- | --- |
|
||||
| M1 手机星盘完整显示(BUG-1083) | 完成 | 1 |
|
||||
| M2 已确认时间,改声明字段后重置 | 完成(数据库侧未跑 `test:db`,见环境缺口) | 2 |
|
||||
| M4 「那一刻的天空」重放 → 定格 → 右上角分享 | 完成 | 3 |
|
||||
| M5 去掉报告星图封面 | 完成 | 4 |
|
||||
| M3 文档 | 完成(DESIGN、VOICE、CHANGELOG、BUG_HISTORY、BLOCKED、真机清单) | 1–4 各自带 |
|
||||
|
||||
## M1 · 根因与修复(BUG-1083)
|
||||
|
||||
复现方法:真实组件(`ChartPageView` / `ChartVedicTab` / `AppSidebar` / `SidebarInset`)+ golden 夹具(`chart-view-golden.json`、`chart-view-bhava-golden.json`)SSR 成静态页,套上**本次构建产物**里 `/chart` 引用的全部 CSS,无头 Chrome 用 CDP `Emulation.setDeviceMetricsOverride` 模拟 390×844(新版无头窗口最小 500 宽,`--window-size` 不够用),从盘面 SVG 往上逐层量 `getBoundingClientRect().width`。
|
||||
|
||||
修复前(基线 CSS,本命与 Bhava 相同):
|
||||
|
||||
| 层 | 宽度 |
|
||||
| --- | ---: |
|
||||
| `svg.personal-report-chart-svg` | 1043 |
|
||||
| `.chart-page-vedic-stage` / `-vedic-layout` / `.chart-page-vedic` / `.chart-page-panel` | 1069 |
|
||||
| **`.chart-page-body`(第一个超宽祖先)** | **1101** |
|
||||
| `.secondary-page` | 390 |
|
||||
| `.secondary-panel` / `main.chat-app` | 390 |
|
||||
|
||||
- 根因:`.secondary-page` 是 `display: grid` 且没有 `grid-template-columns`,隐式列是 `auto`;子项 `.chart-page-body` 保持默认 `min-width: auto`,这一列就按正文最小内容宽度定宽(不折行的 23 个盘型 chip、34rem 行星表)。内层表格与盘型条自己的 `min-width: 0` + 横滚都在,撑宽发生在它们外面。面板 `overflow-x: hidden` 把溢出直接裁掉,所以看起来是「盘很大、右边没了」。
|
||||
- 修复:`.secondary-page { grid-template-columns: minmax(0, 1fr); }`。修在四个次级页共用的这一层。
|
||||
- 修复后(本次构建 CSS):390×844 下本命 / D9 / Bhava / 月亮 / D10 都是 `documentElement.scrollWidth` = 390、没有超宽祖先、盘面 332px。大运 / 基础信息 / 西洋盘三个 Tab 修复前就不溢出(让步 1:未另改)。
|
||||
- 桌面:1280×900 下修复前其实也被裁(`.chart-page-vedic-layout` 1069px 塞在约 1024px 的内容区,参数卡右边被切、「≡」看不到);修复后两栏布局不变,盘面 549 → 480px,参数卡与「≡」完整可见。
|
||||
- 回归测试:`frontend/tests/chart-page-mobile-width-20260929.test.ts`(2 条)。
|
||||
|
||||
截图(`/tmp/claude-1000/-workspace-Jyotisha/fb88e01e-1ced-4bcc-92fe-44c5bdcc878e/scratchpad/mobilechart/`):
|
||||
|
||||
- 修复前:`before-D1-390.png`、`before-BHAVA-390.png`、`before-D1-1280.png`
|
||||
- 修复后(本次构建 CSS):`after-D1-390.png`、`after-D9-390.png`、`after-BHAVA-390.png`、`after-MOON-390.png`、`after-D10-390.png`、`after-D1-1280.png`
|
||||
- 桌面前后对比:`cmp-1280.png`
|
||||
|
||||
## M2 · 结论
|
||||
|
||||
- 改法:`resolveAccountBirthTimeApplicationPatch` 去掉「confirmed 直接 `{}`」。confirmed(含状态为空、有遗留分钟的 legacy 行)**只有声明字段真的变了**才重置,之后与未确认完全同一套派生:零误差家人时间 → `accepted` + 新分钟;否则 `reported`、`active_birth_time = null`;`rectification_case_id = null`。只改称呼、岁差、性别、头像 → `{}`。
|
||||
- 跨午夜三列(硬红线 3):
|
||||
- 写入方:采用 / 确认候选的 RPC(`20260920020000_adopted_birth_date.sql` 起的 dated-v1 路径);读取方:`server-owned-birth-profile.ts`(`active_birth_timezone_offset` 覆盖声明时区)、`chart-birth-truth.ts`(`adoption = "adopted"`、指纹)。
|
||||
- 数据库触发器 `zz_guard_adopted_birth_date`(BEFORE INSERT/UPDATE)在任一声明字段(日期、时间、来源、时段、范围、误差、经纬度、时区)变化或状态不在 accepted/confirmed 时把三列置空;前端镜像是 `applyAdoptedBirthDateGuard`。
|
||||
- 本次仍在派生补丁里**显式**写三列为 null(仅当当前行有值),不只依赖触发器。
|
||||
- 遗留列 `birth_time`:`chart-birth-truth.ts` 与 `server-owned-birth-profile.ts` 在 `active_birth_time` 为空时会回落读 `birth_time`。confirmed / legacy 重置成「只有时段」时,若不清它,星盘仍会按旧分钟排。所以当前行 `birth_time` 非空时一并写 null。
|
||||
- 触发器会不会改回 confirmed:不会。
|
||||
- `guard_birth_time_journey` 最新版(`20260804020000`)只在「状态为空且有 active 分钟」时补 `confirmed`,本修订总写明确状态。
|
||||
- `agentic_rectification_profiles_rebaseline_guard` 与 `invalidate_agentic_rectification_results_on_profile_change`(最新版 `20260827010000`)在声明变化时把进行中的校正 Case 标成 `needs_rebaseline`、把结果标 `invalidated_at`,这是期望行为。
|
||||
- 没有改触发器、没有加迁移。
|
||||
- 并发守卫:派生补丁非空时,路由照旧用 `applyAccountProfileConcurrencyGuards` 按当前行做条件更新,confirmed 行现在也会走到这条,能挡住与一次并发采用的竞争。
|
||||
- 客户端:`/people` 保存本人走 `saveSelfProfile` → `applyPersistedBirthTime`,用路由返回的 `status` / `activeTime`(取自写回的行);新增单测证明「确认 07:52 → 改 08:05」后本地是 `accepted` / `08:05`,改成时段后是 `reported` / 空分钟。缓存失效沿用 BUG-1081 那一套。
|
||||
- **偏离(请 Claude 定)**:声明比较由 `!==` 改成按值比较(`sameDeclarationValue`:库里 `HH:MM:SS` 与表单 `HH:MM`、数字与数字字符串、空串与 null 视为相同)。原因:`/people` 每次保存都整表重提全部出生字段,库里的 `time` 列回来是 `HH:MM:SS`,原来的 `!==` 会把「只改称呼」判成新声明,于是本单改完后会把已确认分钟清掉。副作用:未确认状态(例如 `accepted`)下「只改称呼」以前也会被误判并退回 `reported`,现在不会了——这是修正,不是新行为。
|
||||
|
||||
## M4 · 完成情况
|
||||
|
||||
- 纯函数:`birthSkyRevealAt(elapsed, reduced, mode)`,新增 `mode = "hold"`:汇聚与文字与首页过场完全相同,文字淡入完(3.6 s;减少动态效果时 0.6 s)就 `held`,此后永远是定格帧、`layer = 1`、`done = false`。`birthSkyHoldAtMs(reduced)` 给出定格时刻。首页过场(`reveal`)时间轴不变。
|
||||
- 组件:`BirthSkyConverge` 加 `mode` 与 `controls` 两个可选参数(默认 `reveal`、无控件,首页过场行为不变)。`hold` 模式:
|
||||
- 定格后停止 `requestAnimationFrame`;resize 会清空画布,所以定格后 resize 时重画一次;
|
||||
- 点画面(不在控件上)直接跳到定格帧,不关闭;
|
||||
- Esc 关闭;卸载时取消动画。
|
||||
- 新组件 `components/birth-sky/birth-sky-replay.tsx`:左上 ×(读屏名「关闭」),右上「分享」(`saveBirthSkyImage(cover.blob)`:能分享文件就调系统分享,不能就下载;用户取消不提示;其他失败提示「没能分享,再试一次。」)。样式在 `birth-sky-converge.css`,44px,顶部让出安全区。入口 `birth-sky-entry.tsx` 懒加载它,每次打开都重放。
|
||||
- 定格帧 = 海报:定格帧画布调用序列与导出 PNG 逐条相同(单测,三个 phase);并且 `BirthSkyCover` 新增 `serif`(`render.ts` 新增 `paintBirthSkyPng` 同时返回 PNG 与宋体判定),重放用画 PNG 时同一个字体判定,避免「PNG 用黑体、屏幕用宋体」。
|
||||
- 删除:静态海报弹层 `birth-sky-dialog.tsx` 与 `birth-sky.css`(不再有引用)。
|
||||
- 无头 Chrome 实测(真实组件 esbuild 打包 + golden 夜盘 + 「中国 · 浙江省 · 杭州市 · 西湖区」标签),390×844 与 1280×900 都通过:
|
||||
- 1.5 s 在播放;
|
||||
- 5 s 已定格、不透明;
|
||||
- 8 s 仍打开;
|
||||
- 0.7 s 时点画面 → 跳到定格、仍打开;
|
||||
- Esc → 关闭。
|
||||
截图:`replay-390-playing.png`、`replay-390-held.png`、`replay-390-tapped.png`、`replay-1280-playing.png`、`replay-1280-held.png`、`replay-1280-tapped.png`、`replay-390-strip.png`(同一目录)。
|
||||
- 「分享」按钮「PNG 未就绪时禁用」:没有实现禁用态。星盘页只在 PNG 画好后才渲染入口按钮,重放层打开时 PNG 一定已经在手,这一状态不存在(偏离,见下)。
|
||||
|
||||
## M5 · 完成情况
|
||||
|
||||
- 删除:`components/birth-sky/report-sky-cover.tsx` / `.css`、`lib/birth-sky/report-cover.ts`、`personal-report-page.tsx` 的动态导入与挂载点(连同只给它用的 `next/dynamic` import)、`classifyReportEnvelope` 的 `coverSubject` 解析、`ReportLoadState` / `ReportEnvelopeView` 上的字段。
|
||||
- 服务端:`reportView` 的 `coverSubject` 参数、`readyReportCoverSubject`、`ReportReadCoreDeps.loadCurrentBirthFingerprint`、详情路由里的 `loadCurrentBirthFingerprint`(以及随之无用的 `subjectBirthFingerprint` / `loadSubjectBirth` import)全部删除;读报告不再多查一次人物资料。
|
||||
- `lib/report-birth-binding.ts` 只保留 F1 写入用到的:`REPORT_BIRTH_FINGERPRINT_KEY`、`subjectBirthFingerprint`、`stampReportBirthFingerprint`、`readReportBirthFingerprint`、`withReportBirthFingerprint`;删掉 `reportDocumentBirthFingerprint`、`reportSubjectId`、`reportCoverSubject`。
|
||||
- 保留:三条生成路径写 `subject.birthFingerprint`、TS / JSON Schema / Python 三处 schema 可选字段及其测试。
|
||||
- `grep -rn "ReportSkyCover\|report-sky-cover\|coverSubject" frontend/src` = 0。
|
||||
- `BLOCKED.md` T5 保持划掉,追加「→ 2026-09-29 产品决定去掉报告封面」。
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 基线 `53c0b9ea` | 交付 |
|
||||
| --- | --- | --- |
|
||||
| `tsc --noEmit` | 0 错 | 0 错 |
|
||||
| `npm run lint` | 0 error / 126 warning | 0 error / 126 warning |
|
||||
| `npm test` | tests 4335 · pass 4280 · fail 24 · cancelled 0 · skipped 31 | tests 4335 · pass 4280 · fail 24 · cancelled 0 · skipped 31 |
|
||||
| 失败清单 | 24 条(Docker / PostgreSQL / 部署) | 与基线逐条同名 |
|
||||
| 测试名对比(同机同命令跑基线) | — | 消失 15、新增 15(下表逐条) |
|
||||
| `run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime`(门禁同参数) | — | 997 passed / 1 skipped,`Quality gate passed.`,退出 0 |
|
||||
| `commercial_privacy_artifact_scan.py --json` | — | `status: pass`,`finding_count: 0` |
|
||||
| pytest `test_repo_privacy_markers` + `test_birth_sky` + `test_api_server_growth_contract` + `test_personal_report_contract` | — | 126 passed |
|
||||
| `rm -rf .next && next build` | `/` ○ Static | `/` ○ Static |
|
||||
| `page.tsx` / `jyotish_api_server.py` | — | 未改 |
|
||||
|
||||
### 首屏 gzip(`index.html` 引用的全部 `/_next/static/*.js|css` 逐个 gzip -9 求和;同一工作树先在基线代码上全量构建一次,再在交付代码上全量构建)
|
||||
|
||||
| | 基线 | 交付 | 变化 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| JS | 651,520 B | 651,512 B | −8 B(−0.001%) |
|
||||
| CSS | 66,031 B | 66,035 B | +4 B(+0.006%) |
|
||||
|
||||
### 消失的测试名(15 条,§7.3)
|
||||
|
||||
| 消失的测试名 | 去向 / 原因 |
|
||||
| --- | --- |
|
||||
| ordinary declaration edits clear stale candidate application but never overwrite confirmed active time | 改名为 “…and a new declaration drops a confirmed minute too”;产品 2026-09-29 推翻 BUG-264 约定(M2) |
|
||||
| confirmed ordinary edits keep the active minute and clear a complete adopted tuple together | 改名为 “confirmed declaration edits drop the minute and clear a complete adopted tuple together”;同上(M2) |
|
||||
| chart page dialog shows only the picture: no title bar, no panel; icon × and 「保存图片」 | 由 “chart page replay stays on the poster: × top left, 「分享」 top right…” 取代;静态弹层删除(M4) |
|
||||
| the chart page dialog no longer has a reveal variant (「继续」, is-reveal) | 由 “the chart page replay has no 「继续」 and the poster dialog is gone” 取代;读的两个文件已删除(M4) |
|
||||
| GET /api/reports/:id compares on the server through the chart page's subject loader | 由 “GET /api/reports/:id no longer looks up the subject for a cover (M5)” 取代;产品决定去掉报告封面(M5) |
|
||||
| coverSubject: only a matching fingerprint yields the subject id, self when chart_profile_id is null | 删除:比对函数 `reportCoverSubject` 已删(M5) |
|
||||
| core read: coverSubject is the subject id when the stored fingerprint matches the current one | 删除;由 “core read: the detail response has no coverSubject, and never the fingerprint or birth fields” 取代(M5) |
|
||||
| core read: coverSubject is null when the birth data changed since the report | 同上(M5) |
|
||||
| core read: coverSubject is null for a legacy report without the field, and nothing is looked up | 同上(M5) |
|
||||
| core read: coverSubject is null for a subject that is not the reader's (foreign, deleted, unreadable) | 同上(M5) |
|
||||
| matching fingerprint → the ready state carries coverSubject; mismatch or legacy → no cover | 删除:`report-sky-cover.test.ts` 整个文件随组件删除(M5) |
|
||||
| the cover sits above the body, lazy, and only when coverSubject is set | 同上(M5) |
|
||||
| loader paints night for the screen and paper for print with the same sky | 同上(M5) |
|
||||
| fetch failure, an empty sky or an abort → no cover, and nothing throws into the report | 同上(M5) |
|
||||
| print: paper picture alone on the first A4 page; the screen picture is hidden | 同上(M5) |
|
||||
|
||||
新增 15 条:M1 2 条(`chart-page-mobile-width-20260929.test.ts`);M2 4 条(两条改名 + “confirmed minute follows a new declaration and survives everything else” + “saving a new time over a confirmed minute takes the server's new status and minute”);M4 7 条(`birth-sky-replay-20260929.test.ts` 5 条 + 两条替代);M5 2 条(替代)。总数 4335 不变。
|
||||
|
||||
## 改动的既有断言(原值 / 新值 / 原因)
|
||||
|
||||
| 测试(文件) | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `account-api.test.ts` 第一条 confirmed + 新声明(两处) | `{}` | `{ active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, birth_time: null }` | M2:新声明丢掉已确认分钟;夹具 `birth_time` 有值,所以一并清 |
|
||||
| `account-api.test.ts` confirmed / legacy + 新零误差家人时间 | `{}` | `accepted` + `05:40`(legacy 另加 `birth_time: null`) | M2 |
|
||||
| `account-api.test.ts` “PATCH answers…” confirmed / legacy + 改 `district_code` | `confirmed` / `05:18` | `accepted` / `05:00`(夹具声明是零误差家人时间 05:00);另加两条「只改称呼 / 岁差 → 仍 confirmed 05:18」 | M2:改地点属于新声明 |
|
||||
| `account-api.test.ts` confirmed + 只改岁差 | `{}` | 不变 | —— |
|
||||
| `chart-profile-update-consistency.test.ts` 跨午夜 confirmed + 新声明 | `{}`,排盘仍 confirmed 23:55(legacy) | 回到 `reported` 并显式清三列,排盘按新声明 00:20(declared) | M2 |
|
||||
| `profile-onboarding-birth-sky-reveal.test.ts` overlay | `layer.addEventListener("pointerdown", finish)` | `onPointerDown = mode === "hold" ? skip : finish`,默认 `mode = "reveal"` | M4:定格模式点一下只跳到定格帧 |
|
||||
| `chart-page-view.test.tsx` 入口懒加载 | `import("./birth-sky-dialog")` | `import("./birth-sky-replay")` | M4 |
|
||||
| `chart-page-view.test.tsx` 封面夹具 | 无 `serif` | `serif: false` | `BirthSkyCover` 新增字段(夹具形状,断言未变) |
|
||||
| `report-birth-binding.test.ts` longform cover | 另用 `reportDocumentBirthFingerprint` 读回 | 只看 `subject.birthFingerprint` | M5:读回函数已删;F1 写入断言保留 |
|
||||
|
||||
改动的断言都在测试文件里就地写了三栏注释。
|
||||
|
||||
## 偏离任务书(请 Claude 定)
|
||||
|
||||
1. **M2 声明比较改成按值比较**(见 M2 节)。不改的话,本单会让「只改称呼」清掉已确认分钟,违反决策记录第 1 条。
|
||||
2. **M2 同时清遗留 `birth_time`**(任务书只写了三列)。原因:图表在 `active_birth_time` 为空时会回落读它。
|
||||
3. **M4 「分享」没有做禁用态**:PNG 在入口按钮出现前已就绪,这个状态不存在。
|
||||
4. **M4 失败文案改为「没能分享,再试一次。」**,原「没能保存。长按或右键上面的图,也能存下来。」停用,因为画面是画布,不能长按存图。已收进 VOICE。
|
||||
5. **M1 修在 `.secondary-page`**(四个次级页共用),而不是只给 `.chart-page-body` 补 `min-width: 0`。桌面宽度下被裁的问题也随之消失。
|
||||
6. 首页过场结束后的提示「这片天空在星盘页可以保存。」未改;星盘页现在的按钮字是「分享」,保存要经分享面板或下载,句意仍成立。请 Claude 判断是否改句。
|
||||
7. 分支基于 `53c0b9ea`,未 rebase 到只加了文档的 `47eda7e1`(按协调要求);`docs/tasks/README.md` 索引行的状态未改,留给 Claude 交付时处理,避免冲突。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- **无 Docker**:
|
||||
- 24 条 DB / 部署套件失败,与基线逐条同名;
|
||||
- 没跑 `npm run test:db`,所以 M2 在真实 PostgreSQL 上的效果(触发器链、条件更新)只做了静态核对。staging 实测见真机清单第 5–7 条。
|
||||
- **无登录态与真机**:M1 只在无头 Chrome 设备模拟上验证;M4 的系统分享面板、M5 的打印 PDF 没有实测。见 `docs/testing/mobile-chart-confirmed-edit-20260929.md`(18 条)。
|
||||
- **无模型凭据**:本单不涉及模型输出。
|
||||
- 未推送、未部署。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 真机清单 · 手机星盘 / 确认时间可改 / 天空定格 / 去掉报告封面(2026-09-29)
|
||||
|
||||
任务书:`docs/tasks/TASK-mobile-chart-and-confirmed-edit-20260929.md`。在 staging 上用受控测试账号走;不要用真实用户资料截图外发。
|
||||
|
||||
| # | 步骤 | 期望 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | iPhone Safari 打开 `/chart`,依次点本命、D9、Bhava、行运、D10 | 每一种盘都完整显示在屏幕内(四个角都看得到),页面不能左右拖动 | |
|
||||
| 2 | 同上,在盘下的表格上左右滑 | 表格在自己的框里左右滑,页面不跟着动 | |
|
||||
| 3 | 盘型条最右边 | 「≡」按钮看得到、点得开 | |
|
||||
| 4 | 电脑浏览器 1280 宽打开 `/chart` | 左盘右参数两栏,参数卡右边不被切,「≡」可见 | |
|
||||
| 5 | 用采用过校正结果的账号,在星盘档案里把本人出生时间改成另一个精确时间,保存 | 星盘页「出生时间」一行是新时间,不再是校正后的时间 | |
|
||||
| 6 | 同一账号在 staging 看账户 PATCH 后的状态(星盘页时间标注) | 不是「已校正」;刷新后仍是新时间(数据库触发器没有把它改回确认) | |
|
||||
| 7 | 只改称呼,保存 | 星盘页时间不变,仍是原来的(确认)时间 | |
|
||||
| 8 | 改资料后打开改之前生成的报告 | 报告正常打开,顶部没有星图封面 | |
|
||||
| 9 | 打开任意一份报告 | 顶部没有星图封面 | |
|
||||
| 10 | 报告页用浏览器打印成 PDF | 第一页就是正文 | |
|
||||
| 11 | 星盘页点「那一刻的天空」 | 全屏播放星星汇聚,约 3.5 秒后停在完整画面上,不会自己消失 | |
|
||||
| 12 | 关掉再点一次 | 重新从头播放 | |
|
||||
| 13 | 播放中点一下画面 | 直接跳到最后的完整画面,不关闭 | |
|
||||
| 14 | iPhone 上点右上角「分享」 | 弹出系统分享面板,里面有「存储图像」;存下来的图就是屏幕上那张 | |
|
||||
| 15 | 电脑上点「分享」 | 直接下载 `jyotisha-sky.png` | |
|
||||
| 16 | 点左上角 × / 电脑上按 Esc | 关闭,回到星盘页 | |
|
||||
| 17 | iPhone 开「减少动态效果」后再点「那一刻的天空」 | 没有汇聚,整张画面淡入后停住 | |
|
||||
| 18 | 新账号第一次建盘 | 首页过场照旧:放完自动淡出,没有按钮 | |
|
||||
+3
-7
@@ -1170,15 +1170,11 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
- 重放层、画布与导出代码都在懒加载 chunk 里(`next/dynamic` + 动态 `import()`,`components/birth-sky/birth-sky-replay.tsx`),首页 `/` 不带它们。按钮本身是星盘页里的一个普通 `<Button>`(只在数据就绪时渲染)。
|
||||
- 等待:没有等待态。按钮出现时数据与 PNG 已就绪;打开就开始放。
|
||||
|
||||
### 位置二:报告封面(2026-09-28 追加,TASK-birth-sky-followup-20260928 F1–F3)
|
||||
### 位置二:报告封面(2026-09-28 追加;**2026-09-29 已去掉**)
|
||||
|
||||
产品选方案 A:报告只在「生成时的出生资料」与「现在的出生资料」一致时才有封面。
|
||||
产品 2026-09-29 决定去掉报告里的星图封面(TASK-mobile-chart-and-confirmed-edit-20260929 M5,推翻 followup F2 / F3):报告页正文上方没有封面,打印 / PDF 第一页就是正文;`GET /api/reports/:id` 不再下发 `coverSubject`,读报告时也不再为比对去查人物资料。组件 `report-sky-cover.tsx` / `.css` 与 `lib/birth-sky/report-cover.ts` 已删除。
|
||||
|
||||
- **绑定:** 新生成的报告在文档 `subject.birthFingerprint` 里记下生成时的出生资料指纹(与星盘页 `profileFingerprint` 同一个函数、同取存储行、在时区补全之前取)。普通报告、分章报告、长报告三条生成路径都写;指纹不进给模型的证据包。旧报告没有这个字段,**永远没有封面**,不补算、不回填;不改表结构。
|
||||
- **比对在服务端:** `GET /api/reports/:id` 读当前人物的指纹,一致时 `report.coverSubject` = 人物 id(本人为 `"self"`),其余一律 `null`(不一致、缺字段、人物不是自己的、已删除、读不到)。浏览器拿不到指纹,也拿不到任何出生资料。
|
||||
- **屏幕:** ready 态正文最上方一块 `<figure>`:`night` palette,宽随正文列,3:4,圆角 `--radius-md`,下接正文 `--space-6`。封面下没有按钮。
|
||||
- **打印 / PDF:** 同一次取数同时画一张 `paper` palette 的图,只在打印时显示;封面独占 A4 第一页(`break-after: page`,高 246mm 居中,恰好放进报告 `@page` 的 186 × 270mm 版心)。不新增打印入口,仍是报告原来的「打印」。
|
||||
- **取数与等待:** 组件与正文同一次渲染挂载,副作用在正文上屏之后才发请求;两张图都画好才出现。取数、画图任一失败就不显示封面,不留占位、不加等待态,正文不受影响。组件与样式在懒加载 chunk 里(`components/birth-sky/report-sky-cover.tsx`),首页不带。
|
||||
保留的只有 F1:新生成的报告仍在文档 `subject.birthFingerprint` 记下生成时的出生资料指纹(与星盘页 `profileFingerprint` 同一个函数,不进给模型的证据包,不下发浏览器),schema 里是可选字段,旧报告照常能读。现在没有任何地方读它,留给以后需要「报告对得上资料」时用。
|
||||
|
||||
### 位置三:首次建盘群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928 F4,替换原弹窗)
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
重放层文案(2026-09-29 M4,替换静态海报弹层):星盘页按钮字「那一刻的天空」(也是重放层的无障碍名称,层里不显示标题);层里只有定格的画面、左上一个只有图标的关闭(读屏名「关闭」)和右上「分享」。分享失败(极少见,分享与下载都不可用时)写「没能分享,再试一次。」画面的替代文字是主句加事实句。原「保存图片」与「没能保存。长按或右键上面的图,也能存下来。」已不再使用:画面是画布,不能长按存图。
|
||||
|
||||
首次建盘的群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928)里**没有按钮、没有额外文字**,字就是封面上的主句与事实句;原来弹层上的「继续」已删除。过场结束后首页轻提示一次:「这片天空在星盘页可以保存。」——一句陈述,句号结尾,不加感叹、不加 emoji,不催用户去做。报告封面也不加任何文字或按钮。
|
||||
首次建盘的群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928)里**没有按钮、没有额外文字**,字就是封面上的主句与事实句;原来弹层上的「继续」已删除。过场结束后首页轻提示一次:「这片天空在星盘页可以保存。」——一句陈述,句号结尾,不加感叹、不加 emoji,不催用户去做。(报告封面 2026-09-29 已去掉。)
|
||||
|
||||
## 七条原则
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ import { createSupabasePersonalReportJobService } from "@/lib/personal-report-jo
|
||||
import { createPersonalReportSectionService } from "@/lib/personal-report-section-service";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { subjectBirthFingerprint } from "@/lib/report-birth-binding";
|
||||
import { loadSubjectBirth } from "@/lib/subject-birth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -51,7 +49,6 @@ async function resolvePersistenceForUser() {
|
||||
jobs: null,
|
||||
listSections: undefined,
|
||||
loadLongformAppendix: undefined,
|
||||
loadCurrentBirthFingerprint: undefined,
|
||||
};
|
||||
}
|
||||
const persistence = createSupabasePersonalReportService(supabase);
|
||||
@@ -87,17 +84,12 @@ async function resolvePersistenceForUser() {
|
||||
markdown: row.markdown,
|
||||
};
|
||||
},
|
||||
// Report cover binding: the same stored-row fingerprint the chart page uses.
|
||||
// A foreign, deleted or unreadable subject throws and resolves to no cover.
|
||||
loadCurrentBirthFingerprint: async (ownerId: string, subjectId: string) => (
|
||||
subjectBirthFingerprint((await loadSubjectBirth(supabase, ownerId, subjectId)).row)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { userId, persistence, jobs, listSections, loadLongformAppendix, loadCurrentBirthFingerprint } = await resolvePersistenceForUser();
|
||||
const { userId, persistence, jobs, listSections, loadLongformAppendix } = await resolvePersistenceForUser();
|
||||
const { reportId } = await context.params;
|
||||
if (!uuidPattern.test(reportId)) {
|
||||
return NextResponse.json(
|
||||
@@ -124,7 +116,6 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
jobs: jobs ?? undefined,
|
||||
listSections,
|
||||
loadLongformAppendix,
|
||||
loadCurrentBirthFingerprint,
|
||||
validateReadyDocument: (document) => {
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
return parsed.ok
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Report cover (TASK-birth-sky-followup-20260928 F3). Loaded with the lazy
|
||||
cover chunk on the report page only. Screen: night palette, as wide as the
|
||||
reading column, 3:4. Print: paper palette, alone on the first A4 page. */
|
||||
.report-sky-cover { margin: 0 0 var(--space-6); }
|
||||
.report-sky-cover img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.report-sky-cover-print { display: none !important; }
|
||||
@media print {
|
||||
.report-sky-cover {
|
||||
margin: 0;
|
||||
break-after: page;
|
||||
page-break-after: always;
|
||||
break-inside: avoid-page;
|
||||
}
|
||||
.report-sky-cover-screen { display: none !important; }
|
||||
.report-sky-cover-print {
|
||||
display: block !important;
|
||||
/* A4 inside the report's @page margins is 186mm × 270mm; 3:4 at 186mm
|
||||
wide is 248mm tall, so 246mm keeps it on one page. */
|
||||
width: auto !important;
|
||||
max-width: 100%;
|
||||
height: 246mm !important;
|
||||
margin: 0 auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import "./report-sky-cover.css";
|
||||
|
||||
import { loadReportSkyCover } from "@/lib/birth-sky/report-cover";
|
||||
|
||||
type ReportSkyCoverImages = Readonly<{ screen: string; print: string; description: string }>;
|
||||
|
||||
/**
|
||||
* 「那一刻的天空」 as the report cover (TASK-birth-sky-followup-20260928 F3).
|
||||
*
|
||||
* Mounted only after the report body is on screen and only when the server
|
||||
* said the report's birth data still matches (`coverSubject`). It fetches the
|
||||
* sky and paints two PNGs with the same `drawBirthSky`: `night` for the screen,
|
||||
* `paper` for print. Until both are painted, and on any failure, it renders
|
||||
* nothing — no placeholder, no waiting state (DESIGN §9).
|
||||
*/
|
||||
export function ReportSkyCover({ subjectId }: { subjectId: string }) {
|
||||
const [images, setImages] = useState<ReportSkyCoverImages | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const urls: string[] = [];
|
||||
void loadReportSkyCover(subjectId, controller.signal).then((pngs) => {
|
||||
if (!pngs || controller.signal.aborted) return;
|
||||
urls.push(URL.createObjectURL(pngs.screen), URL.createObjectURL(pngs.print));
|
||||
setImages({ screen: urls[0], print: urls[1], description: pngs.description });
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
for (const url of urls) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [subjectId]);
|
||||
|
||||
if (!images) return null;
|
||||
return (
|
||||
<figure className="report-sky-cover">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- blob: URL of the painted cover */}
|
||||
<img className="report-sky-cover-screen" src={images.screen} alt={images.description} width={1080} height={1440} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- blob: URL of the painted cover, paper palette */}
|
||||
<img className="report-sky-cover-print" src={images.print} alt="" width={1080} height={1440} />
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Clock3, TriangleAlert } from "lucide-react";
|
||||
@@ -41,12 +40,6 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
|
||||
// Mounted with the body, so the sky is only fetched once the report is on screen.
|
||||
const ReportSkyCover = dynamic(
|
||||
() => import("@/components/birth-sky/report-sky-cover").then((module) => module.ReportSkyCover),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export type ReportLoadState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "unauthorized" }
|
||||
@@ -61,7 +54,7 @@ export type ReportLoadState =
|
||||
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
|
||||
| { phase: "invalid"; message: string }
|
||||
| { phase: "network-error" }
|
||||
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"]; coverSubject?: string }
|
||||
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
|
||||
| { phase: "legacy-unavailable" };
|
||||
|
||||
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
|
||||
@@ -79,8 +72,6 @@ export interface ReportEnvelopeView {
|
||||
progressPercent?: number;
|
||||
progressPhase?: string;
|
||||
sections?: readonly ReportSectionProgress[];
|
||||
/** Report cover: set by the server only when the report's birth data still matches. */
|
||||
coverSubject?: string | null;
|
||||
}
|
||||
|
||||
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
|
||||
@@ -140,11 +131,9 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
if (markdown.length > 0) {
|
||||
const facts = reportFactTablesSchema.safeParse(json.factTables);
|
||||
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
|
||||
const coverSubject = typeof view.coverSubject === "string" && view.coverSubject.length > 0 ? view.coverSubject : null;
|
||||
return {
|
||||
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
|
||||
...(charts.success ? { calculationCharts: charts.data } : {}),
|
||||
...(coverSubject ? { coverSubject } : {}),
|
||||
phase: "markdown-ready",
|
||||
markdown,
|
||||
reportId: typeof view.id === "string" ? view.id : "",
|
||||
@@ -493,7 +482,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
reportDate={state.createdAt}
|
||||
/>
|
||||
<div className="personal-report-reader-body">
|
||||
{state.coverSubject ? <ReportSkyCover subjectId={state.coverSubject} /> : null}
|
||||
<PersonalReportMarkdownView markdown={state.markdown} />
|
||||
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} />}
|
||||
{state.factTables && <ReportFactTables tables={state.factTables} />}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { BirthSkyOk } from "./contract.ts";
|
||||
import { fetchBirthSky } from "./cover.ts";
|
||||
import type { BirthSkyPaletteName } from "./draw.ts";
|
||||
import { birthSkyFactLine, birthSkyHeadline } from "./sentence.ts";
|
||||
|
||||
/** Screen (`night`) and print (`paper`) pictures of one report cover, painted by the same `drawBirthSky`. */
|
||||
export type ReportSkyCoverPngs = Readonly<{ screen: Blob; print: Blob; description: string }>;
|
||||
|
||||
export type ReportSkyCoverDeps = Readonly<{
|
||||
fetchSky: (subjectId: string, signal?: AbortSignal) => Promise<BirthSkyOk | null>;
|
||||
render: (sky: BirthSkyOk, palette: BirthSkyPaletteName) => Promise<Blob>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Report cover loader (TASK-birth-sky-followup-20260928 F3). Resolves null on
|
||||
* any failure or abort; the report shows no cover and nothing else changes.
|
||||
*/
|
||||
export async function loadReportSkyCover(
|
||||
subjectId: string,
|
||||
signal: AbortSignal,
|
||||
deps?: ReportSkyCoverDeps,
|
||||
): Promise<ReportSkyCoverPngs | null> {
|
||||
try {
|
||||
const fetchSky = deps?.fetchSky ?? fetchBirthSky;
|
||||
const render = deps?.render ?? (await import("./render.ts")).renderBirthSkyPng;
|
||||
const sky = await fetchSky(subjectId, signal);
|
||||
if (!sky || signal.aborted) return null;
|
||||
const [screen, print] = await Promise.all([render(sky, "night"), render(sky, "paper")]);
|
||||
if (signal.aborted) return null;
|
||||
return { screen, print, description: `${birthSkyHeadline(sky)}${birthSkyFactLine(sky)}。` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,7 @@ import {
|
||||
type SkillSnapshot,
|
||||
} from "./personal-report-generation";
|
||||
import { REPORT_STABLE_CODES } from "./personal-report-codes";
|
||||
import {
|
||||
readReportBirthFingerprint,
|
||||
reportCoverSubject,
|
||||
reportDocumentBirthFingerprint,
|
||||
reportSubjectId,
|
||||
} from "./report-birth-binding";
|
||||
import { readReportBirthFingerprint } from "./report-birth-binding";
|
||||
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
|
||||
import {
|
||||
deriveSectionProgressState,
|
||||
@@ -155,7 +150,6 @@ export function reportView(
|
||||
job?: PersonalReportJobRecord | null,
|
||||
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
|
||||
sections?: readonly ReportSectionProgress[] | null,
|
||||
coverSubject?: string | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -173,8 +167,6 @@ export function reportView(
|
||||
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
|
||||
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
|
||||
...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}),
|
||||
// Report cover binding: a subject id or null, never the fingerprint itself.
|
||||
...(coverSubject !== undefined ? { coverSubject } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -688,30 +680,8 @@ export type ReportReadCoreDeps = Readonly<{
|
||||
lastErrorCode: string | null;
|
||||
markdown: string | null;
|
||||
} | null>;
|
||||
/**
|
||||
* Current birth fingerprint of one of the reader's own subjects (`"self"` or
|
||||
* a chart profile id), read on the server. Null or a throw means no cover.
|
||||
*/
|
||||
loadCurrentBirthFingerprint?: (userId: string, subjectId: string) => Promise<string | null>;
|
||||
}>;
|
||||
|
||||
/** Server-side comparison for the report cover; only its verdict leaves the server. */
|
||||
async function readyReportCoverSubject(
|
||||
deps: ReportReadCoreDeps,
|
||||
userId: string,
|
||||
row: PersonalReportRecord,
|
||||
): Promise<string | null> {
|
||||
const storedFingerprint = reportDocumentBirthFingerprint(row.reportDocument);
|
||||
if (!storedFingerprint || !deps.loadCurrentBirthFingerprint) return null;
|
||||
let currentFingerprint: string | null = null;
|
||||
try {
|
||||
currentFingerprint = await deps.loadCurrentBirthFingerprint(userId, reportSubjectId(row.chartProfileId));
|
||||
} catch {
|
||||
currentFingerprint = null;
|
||||
}
|
||||
return reportCoverSubject({ chartProfileId: row.chartProfileId, storedFingerprint, currentFingerprint });
|
||||
}
|
||||
|
||||
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
|
||||
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
|
||||
if (!originDecision.ok) {
|
||||
@@ -759,7 +729,6 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
))
|
||||
: null;
|
||||
if (row.status === "ready") {
|
||||
const coverSubject = await readyReportCoverSubject(deps, deps.userId, row);
|
||||
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
|
||||
? appendix.markdown
|
||||
: null;
|
||||
@@ -772,7 +741,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
report: reportView(row, job, null, null, coverSubject),
|
||||
report: reportView(row, job),
|
||||
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
|
||||
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
|
||||
? { calculationCharts: validated.document.charts } : {}),
|
||||
@@ -792,7 +761,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
if (deps.loadLongformMarkdown || deps.loadLongformAppendix) {
|
||||
return {
|
||||
status: 200,
|
||||
body: { report: reportView(row, job, null, null, coverSubject), longformMarkdown: null },
|
||||
body: { report: reportView(row, job), longformMarkdown: null },
|
||||
};
|
||||
}
|
||||
if (!validated.ok) {
|
||||
@@ -801,13 +770,13 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
body: {
|
||||
error: "报告内容未通过合同校验",
|
||||
code: REPORT_STABLE_CODES.schemaInvalid,
|
||||
report: reportView(row, job, null, null, coverSubject),
|
||||
report: reportView(row, job),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { report: reportView(row, job, null, null, coverSubject), reportDocument: projectOrdinaryReportDocument(validated.document) },
|
||||
body: { report: reportView(row, job), reportDocument: projectOrdinaryReportDocument(validated.document) },
|
||||
};
|
||||
}
|
||||
return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } };
|
||||
|
||||
@@ -8,8 +8,10 @@ import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts";
|
||||
* `subject.birthFingerprint`. The fingerprint is the chart page's
|
||||
* `profileFingerprint`: `resolveServerOwnedChartBirth` over the **stored** row,
|
||||
* before any in-memory timezone fill. Reports generated before this field
|
||||
* existed never get one and never show a cover. Neither the fingerprint nor
|
||||
* any birth field leaves the server; the reader only receives `coverSubject`.
|
||||
* existed never get one. Nothing reads it back yet: the report sky cover that
|
||||
* compared it was removed (TASK-mobile-chart-and-confirmed-edit-20260929 M5,
|
||||
* product 2026-09-29); the field is kept so a later binding can use it. It is
|
||||
* never sent to a client or to the model.
|
||||
*/
|
||||
|
||||
/** Server-only key stamped on the loaded profile row; never persisted, never serialized to a client. */
|
||||
@@ -51,27 +53,3 @@ export function withReportBirthFingerprint<T>(document: T, fingerprint: string |
|
||||
if (!source || !subject || !fingerprint) return document;
|
||||
return { ...source, subject: { ...subject, birthFingerprint: fingerprint } } as T;
|
||||
}
|
||||
|
||||
export function reportDocumentBirthFingerprint(document: unknown): string | null {
|
||||
const value = record(record(document)?.subject)?.birthFingerprint;
|
||||
return typeof value === "string" && FINGERPRINT_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** The subject id the reader may ask `/api/birth-sky` for; `"self"` when the report has no chart profile. */
|
||||
export function reportSubjectId(chartProfileId: string | null): string {
|
||||
return chartProfileId && chartProfileId.trim() ? chartProfileId.trim() : "self";
|
||||
}
|
||||
|
||||
/**
|
||||
* `coverSubject` for `reportView`: the subject id only when the stored
|
||||
* fingerprint equals the subject's current one; null otherwise (mismatch,
|
||||
* legacy report without the field, foreign or deleted person, lookup failure).
|
||||
*/
|
||||
export function reportCoverSubject(input: Readonly<{
|
||||
chartProfileId: string | null;
|
||||
storedFingerprint: string | null;
|
||||
currentFingerprint: string | null;
|
||||
}>): string | null {
|
||||
if (!input.storedFingerprint || !input.currentFingerprint) return null;
|
||||
return input.storedFingerprint === input.currentFingerprint ? reportSubjectId(input.chartProfileId) : null;
|
||||
}
|
||||
|
||||
@@ -1453,7 +1453,6 @@ test("failed report reads aggregate existing section error codes without importi
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BIRTH_FP = "0123456789abcdef0123456789abcdef";
|
||||
const OTHER_FP = "fedcba9876543210fedcba9876543210";
|
||||
|
||||
test("core create: inline generation writes subject.birthFingerprint from the stamped profile", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
@@ -1471,24 +1470,18 @@ test("core create: inline generation writes subject.birthFingerprint from the st
|
||||
assert.equal("birthFingerprint" in (unstamped.rows.get(REPORT_ID)?.reportDocument?.subject ?? {}), false);
|
||||
});
|
||||
|
||||
async function readWithCover(input: Readonly<{
|
||||
storedFingerprint: string | null;
|
||||
chartProfileId?: string | null;
|
||||
current: (userId: string, subjectId: string) => Promise<string | null>;
|
||||
}>) {
|
||||
// 原值:readWithCover + 四条 core read 测试(指纹一致 → subject id;不一致 / 缺字段 / 非本人 → null;响应不含指纹)
|
||||
// 新值:一条测试——即使报告存了指纹,详情响应也没有 `coverSubject`,且不含指纹与出生字段
|
||||
// 原因:TASK-mobile-chart-and-confirmed-edit-20260929 M5,产品 2026-09-29 决定去掉报告星图封面;F1 写入与隐私断言保留
|
||||
test("core read: the detail response has no coverSubject, and never the fingerprint or birth fields", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const subject = {
|
||||
displayName: "测试用户",
|
||||
birthTimeStatus: "confirmed",
|
||||
birthPlaceLabel: "北京",
|
||||
...(input.storedFingerprint ? { birthFingerprint: input.storedFingerprint } : {}),
|
||||
};
|
||||
persistence.rows.set(REPORT_ID, {
|
||||
...seedRecord(),
|
||||
chartProfileId: input.chartProfileId ?? null,
|
||||
reportDocument: { schemaVersion: "report_document.v2", subject } as unknown as PersonalReportRecord["reportDocument"],
|
||||
reportDocument: {
|
||||
schemaVersion: "report_document.v2",
|
||||
subject: { displayName: "测试用户", birthTimeStatus: "confirmed", birthPlaceLabel: "北京", birthFingerprint: BIRTH_FP },
|
||||
} as unknown as PersonalReportRecord["reportDocument"],
|
||||
});
|
||||
const asked: string[] = [];
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
@@ -1498,47 +1491,10 @@ async function readWithCover(input: Readonly<{
|
||||
persistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
loadLongformAppendix: async () => ({ status: "ready", lastErrorCode: null, markdown: "# 报告\n\n正文。" }),
|
||||
loadCurrentBirthFingerprint: async (userId, subjectId) => {
|
||||
asked.push(`${userId}:${subjectId}`);
|
||||
return input.current(userId, subjectId);
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return { response, asked, report: response.body.report as Record<string, unknown> };
|
||||
}
|
||||
|
||||
test("core read: coverSubject is the subject id when the stored fingerprint matches the current one", async () => {
|
||||
const self = await readWithCover({ storedFingerprint: BIRTH_FP, current: async () => BIRTH_FP });
|
||||
assert.equal(self.report.coverSubject, "self");
|
||||
assert.deepEqual(self.asked, [`${UUID_A}:self`]);
|
||||
const person = await readWithCover({ storedFingerprint: BIRTH_FP, chartProfileId: SESSION_ID, current: async () => BIRTH_FP });
|
||||
assert.equal(person.report.coverSubject, SESSION_ID);
|
||||
assert.deepEqual(person.asked, [`${UUID_A}:${SESSION_ID}`]);
|
||||
// Serialized response: no fingerprint, no birth fields.
|
||||
const body = JSON.stringify(self.response.body);
|
||||
assert.equal("coverSubject" in (response.body.report as Record<string, unknown>), false);
|
||||
const body = JSON.stringify(response.body);
|
||||
assert.doesNotMatch(body, new RegExp(BIRTH_FP));
|
||||
assert.doesNotMatch(body, /birthFingerprint|birth_date|latitude|longitude|timezone|birthPlaceLabel|北京/);
|
||||
});
|
||||
|
||||
test("core read: coverSubject is null when the birth data changed since the report", async () => {
|
||||
const { report, response } = await readWithCover({ storedFingerprint: BIRTH_FP, current: async () => OTHER_FP });
|
||||
assert.equal(report.coverSubject, null);
|
||||
assert.doesNotMatch(JSON.stringify(response.body), new RegExp(`${BIRTH_FP}|${OTHER_FP}`));
|
||||
});
|
||||
|
||||
test("core read: coverSubject is null for a legacy report without the field, and nothing is looked up", async () => {
|
||||
const { report, asked } = await readWithCover({ storedFingerprint: null, current: async () => BIRTH_FP });
|
||||
assert.equal(report.coverSubject, null);
|
||||
assert.deepEqual(asked, []);
|
||||
});
|
||||
|
||||
test("core read: coverSubject is null for a subject that is not the reader's (foreign, deleted, unreadable)", async () => {
|
||||
const foreign = await readWithCover({
|
||||
storedFingerprint: BIRTH_FP,
|
||||
chartProfileId: SESSION_ID,
|
||||
current: async () => { throw new Error("subject_not_found"); },
|
||||
});
|
||||
assert.equal(foreign.report.coverSubject, null);
|
||||
const missing = await readWithCover({ storedFingerprint: BIRTH_FP, current: async () => null });
|
||||
assert.equal(missing.report.coverSubject, null);
|
||||
assert.doesNotMatch(body, /coverSubject|birthFingerprint|birth_date|latitude|longitude|timezone|birthPlaceLabel|北京/);
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@ import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subje
|
||||
import {
|
||||
REPORT_BIRTH_FINGERPRINT_KEY,
|
||||
readReportBirthFingerprint,
|
||||
reportCoverSubject,
|
||||
reportDocumentBirthFingerprint,
|
||||
stampReportBirthFingerprint,
|
||||
subjectBirthFingerprint,
|
||||
withReportBirthFingerprint,
|
||||
@@ -109,10 +107,11 @@ test("longform cover document carries the fingerprint and still parses; without
|
||||
const fingerprint = subjectBirthFingerprint(storedSelf);
|
||||
const bound = buildLongformCoverDocument({ ...input, birthFingerprint: fingerprint });
|
||||
assert.equal(bound.subject.birthFingerprint, fingerprint);
|
||||
assert.equal(reportDocumentBirthFingerprint(bound), fingerprint);
|
||||
// 原值:另用 reportDocumentBirthFingerprint(bound / legacy) 读回 fingerprint / null
|
||||
// 新值:只看 subject 上的字段(上下两行已断言)
|
||||
// 原因:M5 去掉报告封面后没有读回方,只给比对用的 reportDocumentBirthFingerprint 已删除
|
||||
const legacy = buildLongformCoverDocument(input);
|
||||
assert.equal("birthFingerprint" in legacy.subject, false);
|
||||
assert.equal(reportDocumentBirthFingerprint(legacy), null);
|
||||
});
|
||||
|
||||
test("documents without the field (old reports) still parse; a malformed fingerprint does not", () => {
|
||||
@@ -138,16 +137,11 @@ test("documents without the field (old reports) still parse; a malformed fingerp
|
||||
assert.equal(withReportBirthFingerprint(document, undefined), document, "no fingerprint → untouched");
|
||||
});
|
||||
|
||||
test("coverSubject: only a matching fingerprint yields the subject id, self when chart_profile_id is null", () => {
|
||||
const fp = "a".repeat(32);
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: fp }), "self");
|
||||
assert.equal(reportCoverSubject({ chartProfileId: "11111111-1111-4111-8111-111111111111", storedFingerprint: fp, currentFingerprint: fp }), "11111111-1111-4111-8111-111111111111");
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: "b".repeat(32) }), null);
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: null, currentFingerprint: fp }), null);
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: null }), null);
|
||||
});
|
||||
|
||||
test("GET /api/reports/:id compares on the server through the chart page's subject loader", () => {
|
||||
assert.match(itemRouteSource, /loadCurrentBirthFingerprint: async \(ownerId: string, subjectId: string\) => \(\s*subjectBirthFingerprint\(\(await loadSubjectBirth\(supabase, ownerId, subjectId\)\)\.row\)/);
|
||||
assert.match(routeCoreSource, /reportDocumentBirthFingerprint\(row\.reportDocument\)/);
|
||||
// 原值:两条测试——`reportCoverSubject` 只在指纹一致时给出 subject id;详情路由经 `loadSubjectBirth` 在服务端比对
|
||||
// 新值:一条测试——详情路由与路由核心不再查人物资料、不再比对、不下发 `coverSubject`;比对函数已删
|
||||
// 原因:TASK-mobile-chart-and-confirmed-edit-20260929 M5,产品 2026-09-29 决定去掉报告星图封面(推翻 followup F2 / F3);F1 写入保留
|
||||
test("GET /api/reports/:id no longer looks up the subject for a cover (M5)", () => {
|
||||
assert.doesNotMatch(itemRouteSource, /loadCurrentBirthFingerprint|loadSubjectBirth|subjectBirthFingerprint/);
|
||||
assert.doesNotMatch(routeCoreSource, /coverSubject|readyReportCoverSubject|loadCurrentBirthFingerprint/);
|
||||
assert.match(routeCoreSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/, "F1 still writes the fingerprint");
|
||||
});
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
|
||||
import type { BirthSkyPaletteName } from "../src/lib/birth-sky/draw.ts";
|
||||
import { loadReportSkyCover } from "../src/lib/birth-sky/report-cover.ts";
|
||||
import { goldenSky } from "./birth-sky-test-support.ts";
|
||||
|
||||
// Report cover (TASK-birth-sky-followup-20260928 F3).
|
||||
|
||||
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/birth-sky/report-sky-cover.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/components/birth-sky/report-sky-cover.css", import.meta.url), "utf8");
|
||||
|
||||
function readyEnvelope(coverSubject?: unknown) {
|
||||
return {
|
||||
report: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "ready",
|
||||
createdAt: "2026-09-28T00:00:00.000Z",
|
||||
...(coverSubject === undefined ? {} : { coverSubject }),
|
||||
},
|
||||
longformMarkdown: "# 报告\n\n正文。",
|
||||
};
|
||||
}
|
||||
|
||||
test("matching fingerprint → the ready state carries coverSubject; mismatch or legacy → no cover", () => {
|
||||
const bound = classifyReportEnvelope(200, readyEnvelope("self"));
|
||||
assert.equal(bound.phase, "markdown-ready");
|
||||
assert.equal(bound.phase === "markdown-ready" && bound.coverSubject, "self");
|
||||
for (const value of [null, undefined, "", 42]) {
|
||||
const state = classifyReportEnvelope(200, readyEnvelope(value));
|
||||
assert.equal(state.phase, "markdown-ready");
|
||||
assert.equal("coverSubject" in state, false, `coverSubject=${String(value)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the cover sits above the body, lazy, and only when coverSubject is set", () => {
|
||||
assert.match(page, /\{state\.coverSubject \? <ReportSkyCover subjectId=\{state\.coverSubject\} \/> : null\}\s*<PersonalReportMarkdownView/);
|
||||
assert.match(page, /const ReportSkyCover = dynamic\(\s*\(\) => import\("@\/components\/birth-sky\/report-sky-cover"\)/);
|
||||
// No buttons, no placeholder, no waiting state.
|
||||
assert.doesNotMatch(component, /<Button|<button|Spinner|skeleton|正在/i);
|
||||
assert.match(component, /if \(!images\) return null;/, "nothing renders before both pictures are painted");
|
||||
});
|
||||
|
||||
test("loader paints night for the screen and paper for print with the same sky", async () => {
|
||||
const sky = goldenSky("day");
|
||||
const painted: BirthSkyPaletteName[] = [];
|
||||
const asked: string[] = [];
|
||||
const pngs = await loadReportSkyCover("self", new AbortController().signal, {
|
||||
fetchSky: async (subjectId) => { asked.push(subjectId); return sky; },
|
||||
render: async (_sky, palette) => { painted.push(palette); return new Blob([palette]); },
|
||||
});
|
||||
assert.deepEqual(asked, ["self"]);
|
||||
assert.deepEqual(painted.sort(), ["night", "paper"]);
|
||||
assert.equal(await pngs?.screen.text(), "night");
|
||||
assert.equal(await pngs?.print.text(), "paper");
|
||||
assert.match(pngs?.description ?? "", /^星星都在,只是还看不见。|太阳/);
|
||||
});
|
||||
|
||||
test("fetch failure, an empty sky or an abort → no cover, and nothing throws into the report", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
const render = async () => new Blob(["x"]);
|
||||
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => { throw new Error("offline"); }, render }), null);
|
||||
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => null, render }), null);
|
||||
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => goldenSky("day"), render: async () => { throw new Error("canvas"); } }), null);
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
assert.equal(await loadReportSkyCover("self", aborted.signal, { fetchSky: async () => goldenSky("day"), render }), null);
|
||||
});
|
||||
|
||||
test("print: paper picture alone on the first A4 page; the screen picture is hidden", () => {
|
||||
assert.match(component, /className="report-sky-cover-print" src=\{images\.print\}/);
|
||||
assert.match(css, /\.report-sky-cover-print \{ display: none !important; \}/);
|
||||
const print = css.slice(css.indexOf("@media print"));
|
||||
assert.match(print, /\.report-sky-cover \{[^}]*break-after: page;/);
|
||||
assert.match(print, /\.report-sky-cover-screen \{ display: none !important; \}/);
|
||||
assert.match(print, /\.report-sky-cover-print \{[^}]*display: block !important;/);
|
||||
assert.match(css, /aspect-ratio: 3 \/ 4;/);
|
||||
});
|
||||
Reference in New Issue
Block a user