Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 4d63475caf 建档先问看谁的盘,空白首页给出起步三问(#96)。
Independent Staging Quality Gate / validate (push) Failing after 10m31s
Independent Staging Quality Gate / publish (push) Skipped
建档改为四步。帮别人看写入已有人物档案,不新增数据库列。本人没有出生资料时不显示今日星语,「和我合盘」改成一句说明。起步三问只读已有上升和月亮:前两问直接发送,第三问只聚焦输入框。

自测:tsc 退出码 0;lint 0 error、121 warning;定向测试 142 项,pass 142,fail 0,cancelled 0;build 成功,/ 仍是 Static。首屏 gzip 592895,相对基线 578435 为 +2.50%。未跑全量 npm test、Python 快速门和 test:db。

Co-Authored-By: Grok (agent loop)
2026-10-24 22:51:06 +08:00
36 changed files with 1025 additions and 174 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-24 — 建档先问看谁的盘,空白首页有三个起步问题
- 建档从三步变成四步:称呼、看谁的盘、出生日期与时间、出生地。
- 可以先帮别人看。那一位的出生资料单独保存,自己的出生资料可以以后再补。建完后当前人物是这一位。
- 自己还没有出生资料时,首页不显示今日星语;「和我合盘」改成一句「加上你自己后可合盘」。
- 建完档后的空白首页,在首问还没用掉时给出三个起步问题。点前两问直接发送,第一问不扣点。点第三问只让输入框亮一下。
- Skill 版本不变(BUG-1431)。
## 2026-10-24 — 组件库底座:先备好零件,页面还没换
- 对话入场从 GSAP 换成 Motion,仍是 160 毫秒,仍看系统「减少动态效果」,仍是用到才加载。
+16
View File
@@ -19382,3 +19382,19 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-0-base-20261023`。
## BUG-1431 | 建档先看谁的盘,空白首页起步三问
- 状态:enhancement
- 首次发现:2026-10-24
- 最近更新:2026-10-24
- 影响面:首页建档、空白首页、今日星语入口、星盘档案里的「和我合盘」、首次建盘天空过场的一行副句。
- 用户现象:建档多一步「这次先看谁的盘」。选帮别人看时,可以只留下自己的称呼,先把那一位的盘排好。建完后的空白首页,在第一次提问还没用掉时,给出三个起步问题。第一问不扣点。
- 触发条件:新账户走完称呼;或者已经建完档、账户仍标记首问免费、并且当前对话是空的。
- 根因:不是缺陷。产品在新用户启程里定了先看谁的盘、本人资料可后补、起步三问用盘上已有的上升和月亮拼出来(任务书 D4、D5;D1 的首问免费只读取,不在这一张重做)。
- 修复:建档改为四步。帮别人看的出生资料写入已有的人物档案,不新增数据库列。「我自己」在出生资料还没保存前,用本机记录接着问。建完的口径改为本人完整,或档案里至少一位完整。本人没有出生资料时不显示今日星语,合盘位置改成一句说明。起步三问放在已有的空白首页上,前两问走原来的发送,第三问只聚焦输入框。顺手去掉建档里到不了的校正分支,以及首页说明里已经不存在的生时校正卡。
- 验证:见 `docs/tasks/PROGRESS-onboarding-2-intake-20261022.md`。没有浏览器登录态,不把建档走查写成通过。
- 防复发:`frontend/tests/onboarding-intake-20261022.test.ts` 锁住建完口径和三问文案,并锁住第三问不发送。人物档案测试锁住「加上你自己后可合盘」。
- 相关记录:issue #96。任务书 `docs/tasks/TASK-onboarding-journey-20261022.md` 的 T3、T4。前置 #95 的首问免费与关系字段。
- 复发自:无
- 修复版本:分支 `codex/onboarding-2-intake-20261022`。
@@ -0,0 +1,76 @@
# PROGRESS · 新用户启程(二)建档先看谁的盘、起步三问(2026-10-24)
工单 #96。分支 `codex/onboarding-2-intake-20261022`。基线 `origin/staging` `d3be9491eeb115559424c4c5e457497062765e18`(#104 已在这个提交里)。只做任务书 T3、T4。T1、T2 用 #95 已经合入的字段,没有新迁移。T5–T8 和 D6 动效不做。
## 结论
建档多了一步「这次先看谁的盘」。可以先帮别人看,自己的出生资料以后再补。建完后的空白首页,在第一次提问还没用掉时,给出三个起步问题。第一问不扣点。点前两问直接发出去,点第三问只让输入框亮一下。
## 做了什么
- 建档四步:称呼、看谁的盘、出生日期与时间、出生地。进度条四格。第二步的两句大选项、五个关系、第三步和第四步的句子,按任务书逐字。
- 选「我自己」:后面仍写本人资料。选「帮别人看」:名字和关系写入已有的人物档案;本人只留下称呼。建完后当前人物是这一位,新的空白对话也绑在这一位。
- 建完的口径:本人资料完整,或档案里至少有一位资料完整的人。中途退出,下次从缺的那一步接着问。
- 「我自己」在出生资料还没保存之前,选择记在这台浏览器里(`localStorage`,键 `jyotisha_onboarding_intake:` 加账户 id)。没有新的数据库列,也没有写入 `onboarding_state`。
- 本人没有出生资料时,首页不显示今日星语按钮,问候下面的趋势句也不显示。「和我合盘」换成一句「加上你自己后可合盘」。补上本人之后,今日星语出现,合盘可以点。
- 当前人物不是本人时,生时校正本来就不会提议(已有「生时校正暂时只支持本人。」)。这一张没有重做。
- 起步三问的显示条件:已经建完档、账户仍是首问免费、当前是空白首页,并且当前这位的盘是完整的。用掉首问后,下次回到空白首页不再出现。前端只读 `firstConsultFree`,不在本地改它。
- 三个问题的句子放在 `frontend/src/lib/starter-questions.ts`。星座只用今日星语已经算好的上升和月亮,转成「白羊」这种短名。没有多发请求,也没有再调模型。出生时间不知道到分钟时,今日星语不算上升,第一问走「从盘看」那一档。
- 新界面用已有的 shadcn 按钮、输入框、单选。白名单没有加长。`Home()` 的 `useState` / `useRef` 没有增加。
- 顺手去掉建档里到不了的校正分支,以及首页说明里已经不存在的生时校正卡。过场结束仍然不弹提示。`onboardingJustCompleted` 仍保留,但永远是关着的:删掉这个状态会碰到很多调用点,而冻结规则不许再加状态。页面上不再显示那句到不了的完成语。
## 没有新迁移
读写的是 #95 已有的人物档案、关系和账户上的 `firstConsultFree`。选「我自己」的中途记录只在本机。没有改查询形状,没有跑、也没有宣称 `npm run test:db`。
## 断言改动(原值 / 新值 / 原因)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `birth-time-guided-review-fixes.test.ts` | 引导屏含 `onboardingStep === "rectification"` | 引导屏不再出现这一支 | BUG-1431。校正步骤到不了,地点回显留在出生地那一步 |
| `profile-onboarding-birth-sky-reveal.test.ts` | `onOwnBirthPlaceSaved?.()` | `onOwnBirthPlaceSaved?.(subjectId)` | 帮别人看时过场带上这一位;本人仍是 self |
| 同上 | `loadBirthSkyRevealSky(signal)` | `loadBirthSkyRevealSky(signal, id)` | 天空按当前人物取,默认仍是本人 |
| `people-archive-view.test.tsx` | 他人详情始终是可点的「和我合盘」 | 本人资料不完整时,该位置是「加上你自己后可合盘」,没有「和我合盘」 | 先帮别人看时,合盘要等本人资料齐。本人完整的那一则仍断言「和我合盘」 |
| `rectification-lazy-loading.test.ts` | 引导屏动态加载 `BirthTimeRectification` | 引导屏不再包含它;首页仍挂载对话里的校正 | BUG-1431。建档里的校正卡是死代码 |
| `home-bootstrap-screens.test.tsx` | 另渲一屏校正,断言「出生时间尚未完成评估」和「重新评估」 | 出生步不再出现这两句 | 同上。另加一屏「看谁的盘」,锁住四格进度和两个选项 |
| `rectification-agentic-entry.test.ts` | `missingProfileStep(profile)` | `missingProfileStep(profile, chartLibrary)` | 建完改为本人完整,或档案里至少一位完整 |
| `onboarding-intake-20261022.test.ts`(新文件) | 起草时曾要求「看谁的盘」源码里直接写出「其他」 | 组件用 `PERSON_RELATIONS.map` 和 `PERSON_RELATION_LABELS[item]`;五个中文在 `person-relation.ts` | 关系文案本来就集中在那一处。产品句子没有改 |
其余既有断言没有放宽。源码里仍保留「和我合盘」这四个字,给本人资料完整时使用。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0,无输出。补合盘说明样式是 CSS,不影响类型。其后 `npm run build` 的 TypeScript 步骤也没有报错 |
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。0 error,121 warning。同一基线上组件库那一单记下的是 122 warning。没有去修无关警告 |
| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 下面 14 个文件 | 142 项,pass 142,fail 0,cancelled 0,skipped 0,退出码 0。第一次是 140 通过、2 失败(关系文案断言写错位置、合盘说明缺一条样式)。改完后整组再跑是这一行 |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。`/` 是 `○` Static。79 页。skill-package-registry 的 5 条 Turbopack 警告是改前就有的 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加 | 基线 `d3be9491`:33 个文件,578435 字节。这一张:33 个文件,缺失 0,592895 字节。+14460 字节,+2.50%。±2% 的上限是 590004,超出约 2891 字节 |
定向测试文件:`onboarding-intake-20261022`、`home-bootstrap-screens`、`profile-onboarding-birth-sky-reveal`、`birth-time-guided-review-fixes`、`rectification-lazy-loading`、`rectification-agentic-entry`、`people-archive-view`、`starter-questions`、`daily-starlanguage`、`daily-starlanguage-library`、`home-shell-growth-contract`、`chart-library-other-profile`、`birth-time-assessment-loading`、`ui-kit-whitelist`。
首屏仍是这 33 个脚本,没有新的首屏文件。多出来的体积是首页接上「看谁的盘」、起步三问,以及从已有的今日星语结果里记住上升和月亮。没有为了凑进 ±2% 去删无关代码,也没有把起步三问改成用到才加载。空白首页本来就是首屏。
没有跑前端全量 `npm test`,没有跑 Python 快速门。Python 入口和 `scripts/jyotish_api_server.py` 没有改。
## 限制(验收时会碰到)
- 选「我自己」之后、出生资料还没保存就关掉:同一台浏览器会接着问。换一台设备会回到「看谁的盘」,因为这一步只记在本机。已经写下别人的名字和关系时,档案在服务器上,换设备也会接着问这个人的出生资料。
- 档案里如果留下一位写了名字但资料不齐的人,而本机又没有「我自己」的记录,下次会接着问这个人,而不是先问看谁的盘。这就是「从缺的那一步接着问」。
- 帮别人看的天空过场,副句是「那一刻,{名字}的上升在{星座}」。上升要等今日星语那一次计算返回。过场常常先结束,这时不另写一行,原来的天空句子照常播。没有为了这一行再排一次盘。星座稍后才到时,只补上这一行,过场动画不重头播。
- 名字或上升还不知道时,不显示这行副句。
- 档案列表在保存后短暂还没回来时,出生地那一页会先留着,避免闪回出生日期。列表回来后才进入首页。
- 第一问回答完,当时的对话里问题会消失。若在账户状态刷新之前又开一条空对话,三个问题可能再闪一次。扣不扣点仍由服务器决定,前端不把「已用」写死在本地。
- 没有做 650 毫秒转场、星点和闪烁。看谁的盘展开用的是原来 180 毫秒的卡片入场。
## 环境缺口
- 没有登录态,也没有用浏览器走完建档。不能写成「已经在真机上看过」。可照做的步骤在 `docs/testing/onboarding-journey-20261022.md` 的「先看谁的盘」和「起步三问」。
- 没有 Docker 上的数据库测试。这一张不改数据库访问层。
## 记录
- BUG-1431,状态 `enhancement`。不是缺陷。
- `CHANGELOG.md`、`frontend/DESIGN.md`、`frontend/docs/VOICE.md`(写明 2026-10-24 起步三问这条例外)、`docs/testing/onboarding-journey-20261022.md`。
- 任务书基线 `c1aa72fcb` 和 BUG 起点 1420 都已过期。本单基线是上面的 `d3be9491`,编号用 1431。
+1 -1
View File
@@ -40,7 +40,7 @@
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0 待验收(K1–K7 仍待领取) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022` | (一)已合入 staging;(二)(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022` | (一)已合入 staging;(二)待验收;(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` |
| —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) |
+21 -2
View File
@@ -1,6 +1,6 @@
# 新用户启程 · 真机清单(2026-10-22)
后面的 issue 往这份清单末尾追加。本页先只覆盖数据层(issue #95,T1 首问免费、T2 关系与引导状态)。起步三问、启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。
后面的 issue 往这份清单末尾追加。数据层(issue #95)和建档、起步三问(issue #96)按下面的条目验收。启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。
用新注册的邮箱。不要把真实出生资料写进记录。
@@ -18,8 +18,27 @@
2. 找一份关系还是空的旧档案,打开编辑,补上「父母」,保存后再打开仍是「父母」。没改过的空档案,名字后面显示「其他」。
3. 本人这一行仍只显示「本人」,编辑本人时没有「TA 是你的」。
## 先看谁的盘
用一个新账户。出生日期用随便一个虚构日子,不要用真人的。
1. 写下称呼之后,下一句是「这次想先看谁的盘」。上面的进度是四格。
2. 选「我自己」,再填出生日期、时间和出生地。进首页之后,顶栏当前人物是自己。有今日星语。星盘档案里看另一个人时,「和我合盘」可以点。
3. 另开一个新账户。选「帮别人看」,写下名字和关系(伴侣、孩子、父母、朋友、其他里的一个)。下面有一句「你自己的资料可以以后再补。」
4. 接着填的是这一位的出生日期和出生地,不是自己的。建完后,首页和顶栏的当前人物是这一位。首页没有今日星语。星盘档案里这一位的详情,原来「和我合盘」的位置是一句「加上你自己后可合盘」,不能点。
5. 建到一半关掉页面,再用同一台浏览器打开:从还没填完的那一步接着问。换一台设备时,如果选的是「我自己」而且出生资料还没保存,会回到「看谁的盘」;如果已经写下了别人的名字,会接着问这个人的出生资料。
6. 在星盘档案里补上自己的出生资料。回到首页,今日星语出现,「和我合盘」可以点。
## 起步三问
仍用上面建完的账户,第一问还没用掉。
1. 空白首页的问候下面有一行引导,一枚「这一次不扣点」,以及三个问题。帮别人看时还有一枚「正在看:名字 · 关系」。
2. 点第一个或第二个问题,问题直接发出去,不用再按发送。这一问之后点数仍是 0,不弹兑换窗。
3. 回到空白首页(新开一条空对话),三个问题不再出现。
4. 另开一个还没提问的账户。点第三个问题:输入框亮一下并且可以打字,不会自动把问题发出去。
## 以后追加
- (二)先看谁的盘、起步三问
- (三)启程清单、一次性提示
- (四)动效,以及打开「减少动态效果」再走一遍
+6 -6
View File
@@ -839,7 +839,7 @@ The 👍 / 👎 under an answer (回复评价) is saved per conversation and mes
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, no trailing navigation chevron, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
- **Content width:** the 880px frame now gives the content pane a deliberate inset instead of leaving controls against the divider. Form panes (个人资料, 通用设置) cap their children at 560px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed within the inset so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698).
- **Personal profile:** one row of avatar editing (56px preview, eight palettes, 换一个形象) plus nickname and login email. Gender is **not** set here (2026-09-30, BUG-1119): it lives only in the people archive's edit form, for the owner and everyone else; onboarding never asks. The header uses the product-level “设置” eyebrow above the current pane title; there is no duplicate 管理星盘资料 button.
- **Chart archive (`/people`):** a static secondary page beside 星盘 / 星历 / 我的报告. Self is the first row and cannot be deleted, but is edited here like everyone else (2026-09-28, BUG-1081). Each other row is a name plus birth facts, with no relationship field. Detail shows the reported time and the chart time separately, then place and ayanamsa. Shortcuts are 和 TA 对话, 看星盘, 生成报告, and 和我合盘. Adding a twenty-first person is disabled with 「最多保存 20 个人的星盘」. Delete confirms with the session and report counts before the server removes them together. There is no 「设为默认」 and no 「当前默认」 badge.
- **Chart archive (`/people`):** a static secondary page beside 星盘 / 星历 / 我的报告. Self is the first row and cannot be deleted, but is edited here like everyone else (2026-09-28, BUG-1081). Each other row is a name plus birth facts, with no relationship field. Detail shows the reported time and the chart time separately, then place and ayanamsa. Shortcuts are 和 TA 对话, 看星盘, 生成报告, and 和我合盘. When the holder's own birth data is incomplete, that last control is the sentence 「加上你自己后可合盘」. Adding a twenty-first person is disabled with 「最多保存 20 个人的星盘」. Delete confirms with the session and report counts before the server removes them together. There is no 「设为默认」 and no 「当前默认」 badge.
- **Subject switcher:** the same control sits beside the title on chat, chart, ephemeris, reports, and the archive. It is not in the sidebar header. Choosing someone in a chat that already has messages switches the current person and opens an empty chat. An empty chat rebinds in place. The list shows every saved person, up to 20, and scrolls inside the menu so it does not grow past the screen.
- **States:** open, pane switch, list / self / other / add, saving, success, and error.
@@ -949,7 +949,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
**今日星语改为固定句库,并随次级页预取(2026-09-30,BUG-1109 / BUG-1110)。** 首页问候下面那句不再由模型写:`lib/daily-starlanguage-library.ts` 按「今日月亮落在本命第几宫(1–12,只在出生时间已确认时用)× 月相(新月前后 / 渐盈 / 满月前后 / 渐亏,按平均朔望月推算,误差约 ±1 天)× 按日期轮换的变体」拼出 trend,action / caution 按宫各两条交替;没有宫位时按月亮所在星座写一句,再没有就用原来的证据句。服务端不再在后台让模型改写并覆盖缓存,所以同一天同一个人看到的永远是同一句。次级页空闲时的预取(BUG-1104)同时取本人今天这句,写进同一个暖快照槽位与本地存储;从星盘等页点「新建对话」进首页,首帧就是真句子,不再先显示「今天的星语还没写出来。」再换掉。他人档案作为当前人物时暂不预取(其资料在人物目录里),首帧仍按原规则。
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),内容到达后静默替换。登录后的空首页是问候语、今日星语和输入框,没有主题卡,也没有生时校正卡(2026-10-07,BUG-1247)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
### 首屏死屏兜底
@@ -1185,7 +1185,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签;别人后跟同一枚标签,写关系,没填过写「其他」)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
- **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成「最多保存 20 个人的星盘」并禁用。
- **人多时的滚动**(2026-10-17):`.people-archive-rows` 限高 `min(70vh, 640px)`,只在列表内部纵向滚动。标题旁和聊天里的人物菜单(`.chat-profile-picker-options`)限高 `min(50vh, 320px)`,同样只纵向滚动,名单放到 20 人也不把弹层撑出屏幕。两处都 `overflow-x: hidden`。
- **详情卡**:头部 48px 头像 + 名字(别人的名字后跟关系标签,没填过写「其他」;本人的标题仍只有名字)+「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。
- **详情卡**:头部 48px 头像 + 名字(别人的名字后跟关系标签,没填过写「其他」;本人的标题仍只有名字)+「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;本人出生资料还不完整时,这个位置改成一句「加上你自己后可合盘」,不能点。每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。
- **编辑表单里的关系**(2026-10-23):只在编辑或添加别人时出现,放在出生地点之后、性别之前。标题「TA 是你的」,五个选项:伴侣、孩子、父母、朋友、其他。复用性别那一排的尺寸和样式,不新造颜色。没点过就保持空,页面上仍显示「其他」;点了才写入。本人的编辑表单没有这一项。
- **编辑表单里的性别(选填)**(2026-09-27):放在关系之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
@@ -1342,9 +1342,9 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
### 位置三:首次建盘群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928 F4,替换原弹窗)
- **触发不变:** 只有一处——首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功、下一步已设好之后)。每个账户只发生一次,没有「看过」标记、不写库。星盘档案里新建家人、设置里改资料、平时打开首页都不触发。
- **不等待:** 保存成功后 onboarding 照常走到下一步,同时取 `/api/birth-sky?subject=self`、等宋体(最多 1.5 秒)、加载过场代码;三样都在 **3 秒**内到齐才开始放,否则静默跳过:不提示、不重试、不转圈、不骨架(§9)。过场开始时数据已全部就绪。
- **时序(约 6.5 秒,第三轮放慢):** ① 汇聚约 3 秒:地平线以上的恒星与行星从散开的位置 ease-out(三次方)汇聚到真实投影位置;散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 400ms 淡入,避免页面一下变黑。② 显现文字约 2.5 秒:顶部小字、细线、主句、事实句、caption、底部说明在前 600ms 淡入,然后停留。③ 淡出约 1 秒:整层透明度降到 0,露出**原本的下一步**(问候 / 首页,或出生时间校正引导),下一步不因它改变。
- **触发:** 首页建档里出生地保存成功(`saveOnboardingPlace` 成功、下一步已设好之后)。本人路径取本人的天空;帮别人看取这一位的天空。没有「看过」标记、不写库。星盘档案里另外新建家人、设置里改资料、平时打开首页都不触发。
- **不等待:** 保存成功后建档照常走开,同时取 `/api/birth-sky?subject=` 当前这一位、等宋体(最多 1.5 秒)、加载过场代码;三样都在 **3 秒**内到齐才开始放,否则静默跳过:不提示、不重试、不转圈、不骨架(§9)。过场开始时画面数据已就绪。帮别人看时,若上升星座已经在今日星语那次计算里拿到,画面上多一行「那一刻,{名字}的上升在{星座}」。名字或星座还没有时不写这行,原来的天空句子照常,不等第二次排盘。
- **时序(约 6.5 秒,第三轮放慢):** ① 汇聚约 3 秒:地平线以上的恒星与行星从散开的位置 ease-out(三次方)汇聚到真实投影位置;散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 400ms 淡入,避免页面一下变黑。② 显现文字约 2.5 秒:顶部小字、细线、主句、事实句、caption、底部说明在前 600ms 淡入,然后停留。③ 淡出约 1 秒:整层透明度降到 0,露出**原本的下一步**(问候 / 首页),下一步不因它改变。
- **最后一帧就是封面:** 每一帧都是 `drawBirthSky` 画的,只对位置插值(t = 0 散开、t = 1 真实位置,`interpolateSkyPoint`,t = 1 精确返回真实点)、对透明度乘系数;停留阶段的画布调用序列与导出封面逐条相同(单测逐调用比对)。
- **全屏、无按钮:** 原生 `<dialog>`,`createPortal` 挂到 `document.body`(在 `SidebarInset` 之外,不受弹窗打开时的 `inert` 影响,与账户弹窗、兑换弹窗同一做法,BUG-968 / BUG-1080)铺满视口,底色是 `night` 的页底色;画布按视口取最大的 3:4 区域、乘 `devicePixelRatio` 绘制,`requestAnimationFrame` 驱动,卸载时取消。点击、触摸(`pointerdown`)或 Esc 立刻结束,直接进入下一步。
- **减少动态效果:** `prefers-reduced-motion: reduce` 时不汇聚:成品画面整体淡入(600ms)、停留、同一时刻淡出(1 秒),总长同样约 6.5 秒。
+3 -1
View File
@@ -21,7 +21,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
**起步三问例外(2026-10-24)。** 只在已经建完档、首问还没用掉、并且当前是空白首页时,问候下面可以多一行「盘排好了。先问一个问题吧。」(帮别人看则是「{名字}的盘排好了。先问一个关于 TA 的问题吧。」),再加一枚「这一次不扣点」和三个起步问题。帮别人看时再加一枚「正在看:{名字} · {关系}」。这三问消失之后,首页回到上面这一行的规则。
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
## 婚恋合盘报告(2026-10-19)
+70 -19
View File
@@ -3,7 +3,7 @@
import dynamic from "next/dynamic";
import { loadBillingPanel } from "@/lib/billing-panel-loader";
import { Coins } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
import { ProfilePanel } from "@/components/profile-panel";
@@ -38,16 +38,19 @@ import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration";
import { useSessionList } from "@/hooks/use-session-list";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { bindCurrentSubjectAccount, readCurrentSubjectId, useCurrentSubjectProfile } from "@/lib/current-subject";
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
import { personRelationLabel } from "@/lib/person-relation";
import { readStarterSignMap, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
import { AccountDialogDeepLink, SynastryDeepLink } from "@/components/people-home-links";
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
import { StarterQuestions } from "@/components/starter-questions";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
isGeneralDailyFortuneQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import {
clearBirthTimeConsultationConsent,
createLatestAccountRequestGuard,
@@ -139,12 +142,13 @@ import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog";
import {
birthQuestion,
completedOnboardingMessage,
completedOnboardingTranscript,
isProfileComplete,
placeQuestion,
readProfile,
sessionChartLabel, upsertSelfChart,
selfChartComplete,
sessionChartLabel,
upsertSelfChart,
whoQuestion,
} from "@/lib/home-profile";
import {
CancellationResponseError,
@@ -179,6 +183,8 @@ import {
type BootstrapPhase,
} from "@/lib/home-bootstrap";
const EMPTY_SUBJECT_CATALOG: readonly ChartLibraryRecord[] = [];
const BillingPanel = dynamic(loadBillingPanel, {
ssr: false,
loading: () => <p className="secondary-page-waiting">账户信息还没拿到。</p>,
@@ -297,10 +303,16 @@ export default function Home() {
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPendingRef = useRef(false);
const birthSkyReveal = useBirthSkyReveal();
const subjectCatalog = useSyncExternalStore(
subscribeSubjectCatalog,
() => peekSubjectCatalog() ?? EMPTY_SUBJECT_CATALOG,
() => EMPTY_SUBJECT_CATALOG,
);
const currentSubjectId = useCurrentSubject();
const starterSignMap = useSyncExternalStore(subscribeStarterSigns, readStarterSignMap, readStarterSignMap);
const {
profile,
setProfile,
profileDraft,
setProfileDraft,
profileNotice,
setProfileNotice,
@@ -317,6 +329,11 @@ export default function Home() {
saveOnboardingPlace,
retryBirthTimeAssessment,
signOut,
onboardingDraft,
setOnboardingDraft,
intakeTarget,
chooseOnboardingSelf,
chooseOnboardingOther,
} = useProfileOnboarding({
account, accountRefreshGuard, accountTrigger,
birthTimeRevisionPendingRef, dialogReturnTargetRef,
@@ -327,6 +344,7 @@ export default function Home() {
setSigningOut, setStartGreeting, signingOut, uiPreview,
initialProfile: () => warmStart()?.profile ?? null,
onOwnBirthPlaceSaved: birthSkyReveal.begin,
chartLibrary: subjectCatalog,
});
const birthTimeGuided = useBirthTimeGuidedJourney({
@@ -600,7 +618,7 @@ export default function Home() {
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
}, [account, accountId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesDegraded, markChatProfilesReady, profile]);
const profileComplete = isProfileComplete(profile);
const profileComplete = isProfileComplete(profile, subjectCatalog);
const dailyProfile = useCurrentSubjectProfile(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(dailyProfile ?? { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, birthTimeConsultationConsent, activeSessionId);
const natalMinuteAvailable = birthTimeRoute.kind === "consult"
@@ -654,14 +672,14 @@ export default function Home() {
cancelled = true;
};
}, [hydrated]);
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
const onboardingAudience = intakeTarget === "other" ? "other" : "self";
const onboardingSubjectName = onboardingAudience === "other" ? onboardingDraft.name.trim() : profile.name.trim();
const currentOnboardingMessage = onboardingStep === "who"
? whoQuestion(profile.name.trim())
: onboardingStep === "birth"
? birthQuestion(profileDraft.name.trim())
? birthQuestion(onboardingSubjectName, onboardingAudience)
: onboardingStep === "place"
? placeQuestion(profileDraft)
: onboardingStep === "rectification" && birthTimeJourney
? assistantIntentCopy(birthTimeJourney.snapshot.assistantIntent)
? placeQuestion(onboardingDraft, onboardingAudience, onboardingSubjectName)
: presetOnboardingMessage;
const shouldStreamOnboarding = !profileComplete;
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
@@ -707,7 +725,7 @@ export default function Home() {
onboardingJustCompleted,
pendingConsultation,
pendingSessionId,
profile,
profile: dailyProfile ?? profile,
subjectDeleted,
sessions,
setAccount,
@@ -886,6 +904,16 @@ export default function Home() {
}
}, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]);
useEffect(() => {
setOnboardingFinishedListener((subjectId) => {
if (!subjectId || subjectId === "self") return;
const catalog = peekSubjectCatalog();
if (catalog) setChartLibrary([...catalog]);
void startNewChat();
});
return () => setOnboardingFinishedListener(null);
}, [startNewChat]);
useEffect(() => {
if (activeAccountDialog === null) return;
window.requestAnimationFrame(() => {
@@ -968,6 +996,7 @@ export default function Home() {
&& activeSession.sessionType === "consultation"
&& activeSession.messagesHydrated
&& activeSession.messages.length === 0
&& sessionMatchesSubject(activeSession, readCurrentSubjectId())
? activeSession
: null;
if (!targetSession) {
@@ -988,6 +1017,14 @@ export default function Home() {
);
}
function nudgeComposer() {
const input = composerInput.current;
if (!input) return;
input.classList.add("is-starter-focus");
input.focus({ preventScroll: true });
window.setTimeout(() => input.classList.remove("is-starter-focus"), 700);
}
async function copyAssistantMessage(renderKey: string, text: string) {
try {
await navigator.clipboard.writeText(text);
@@ -1201,8 +1238,12 @@ export default function Home() {
{!synastryChat && !profileComplete ? (
<HomeOnboardingShellSlot
profileComplete={profileComplete}
profileDraft={profileDraft}
setProfileDraft={setProfileDraft}
profileDraft={onboardingDraft}
setProfileDraft={setOnboardingDraft}
readerName={profile.name}
intakeTarget={intakeTarget}
onChooseSelf={chooseOnboardingSelf}
onChooseOther={chooseOnboardingOther}
onboardingStep={onboardingStep}
presetMessageLength={presetMessageLength}
currentOnboardingMessage={currentOnboardingMessage}
@@ -1221,7 +1262,7 @@ export default function Home() {
) : null}
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
<StarterGreeting starterGreeting={starterGreeting} dailyTrend={natalMinuteAvailable ? dailyStarlanguageTrend : null} dailyTrendSettled={dailyStarlanguage.kind === "ready"} />
<StarterGreeting starterGreeting={starterGreeting} dailyTrend={selfChartComplete(profile) && natalMinuteAvailable ? dailyStarlanguageTrend : null} dailyTrendSettled={dailyStarlanguage.kind === "ready"} />
)}
</div>
) : sessionMessagesLoading ? (
@@ -1268,6 +1309,16 @@ export default function Home() {
)}
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{starterHomeVisible && account?.firstConsultFree === true && (currentSubjectId === "self" ? selfChartComplete(profile) : Boolean(dailyProfile && selfChartComplete(dailyProfile))) ? (
<StarterQuestions
audience={currentSubjectId !== "self" && dailyProfile
? { kind: "other", name: dailyProfile.name.trim() || "TA", relationLabel: personRelationLabel(dailyProfile.relation) }
: { kind: "self" }}
signs={starterSignMap[currentSubjectId] ?? readStarterSigns(currentSubjectId)}
onAsk={(text) => void startSuggestedConsultation(text)}
onNudge={nudgeComposer}
/>
) : null}
{jumpToLatestVisible && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
@@ -1302,7 +1353,7 @@ export default function Home() {
)}
onStop={() => void stopResponse()}
/>
{starterHomeVisible && (
{starterHomeVisible && selfChartComplete(profile) && (
<StarterEntries
natalMinuteAvailable={natalMinuteAvailable}
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
@@ -19,9 +19,10 @@ type Profile = ReturnType<typeof globalBirthProfileFromAccountRow>;
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
/** The line comes from a fixed library (BUG-1109); no model rewrites it later. */
type CardSource = "library";
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard; readonly source: CardSource };
type SignPair = { readonly ascendantSign: string | null; readonly moonSign: string | null };
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard; readonly source: CardSource } & SignPair;
type Generated =
| { readonly kind: "card"; readonly card: DailyStarlanguageCard; readonly source: CardSource }
| { readonly kind: "card"; readonly card: DailyStarlanguageCard; readonly source: CardSource } & SignPair
| { readonly kind: "failed"; readonly reason: "chart_unavailable" };
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
@@ -53,7 +54,7 @@ function readCache(key: string, today: string): CacheEntry | null {
return entry;
}
function writeCache(key: string, today: string, card: DailyStarlanguageCard, source: CardSource) {
function writeCache(key: string, today: string, card: DailyStarlanguageCard, source: CardSource, signs: SignPair) {
const store = cache();
for (const [storedKey, entry] of store) {
if (entry.day !== today) store.delete(storedKey);
@@ -63,7 +64,7 @@ function writeCache(key: string, today: string, card: DailyStarlanguageCard, sou
if (oldest.done) break;
store.delete(oldest.value);
}
store.set(key, { day: today, card, source });
store.set(key, { day: today, card, source, ascendantSign: signs.ascendantSign, moonSign: signs.moonSign });
}
async function fetchEngine(path: string, body: Record<string, unknown>) {
@@ -96,7 +97,13 @@ async function generateCard(payload: BirthPayload, profile: Profile, today: stri
if (!packet) return { kind: "failed", reason: "chart_unavailable" };
const evidence = evidenceFromDailyGuidance(packet, today, profile.birthTimeStatus === "confirmed");
if (!evidence) return { kind: "failed", reason: "chart_unavailable" };
return { kind: "card", card: composeDailyCard(evidence, today), source: "library" };
return {
kind: "card",
card: composeDailyCard(evidence, today),
source: "library",
ascendantSign: evidence.ascendantSign,
moonSign: evidence.moonSign,
};
}
function unavailable(reason: string) {
@@ -108,11 +115,13 @@ function unavailable(reason: string) {
});
}
function ok(card: DailyStarlanguageCard, source: string) {
function ok(card: DailyStarlanguageCard, source: string, signs: SignPair) {
return NextResponse.json({
status: "ok",
card,
source,
ascendantSign: signs.ascendantSign,
moonSign: signs.moonSign,
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
@@ -144,7 +153,7 @@ export async function POST(request: Request) {
const key = dailyStarlanguageCacheKey(user.id, subjectId, JSON.stringify(payload), today);
const cached = readCache(key, today);
if (cached) return ok(cached.card, `${cached.source}_cache`);
if (cached) return ok(cached.card, `${cached.source}_cache`, { ascendantSign: cached.ascendantSign ?? null, moonSign: cached.moonSign ?? null });
const limited = consumeUserRequestRateLimit("dailyStarlanguage", user.id);
if (!limited.ok) {
@@ -166,6 +175,7 @@ export async function POST(request: Request) {
const generated = await inFlight;
if (generated.kind === "failed") return unavailable(generated.reason);
writeCache(key, today, generated.card, generated.source);
return ok(generated.card, generated.source);
const signs = { ascendantSign: generated.ascendantSign, moonSign: generated.moonSign };
writeCache(key, today, generated.card, generated.source, signs);
return ok(generated.card, generated.source, signs);
}
+27
View File
@@ -497,6 +497,23 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4
.onboarding-card-heading { display: grid; gap: 4px; padding-bottom: 2px; }
.onboarding-card-actions { display: flex; justify-content: flex-end; padding-top: 2px; }
.onboarding-card-actions .button-primary { min-width: 84px; }
.onboarding-progress { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; max-width: 680px; margin: 0 0 var(--space-3); padding: 0; list-style: none; }
.onboarding-progress li { height: 4px; border-radius: 999px; background: var(--color-border); }
.onboarding-progress li.is-done { background: var(--color-action); }
.onboarding-who { display: grid; gap: 12px; max-width: 680px; }
.onboarding-who-options { display: grid; gap: 8px; }
.onboarding-who-option { height: auto; min-height: 44px; white-space: normal; align-items: flex-start; flex-direction: column; justify-content: center; text-align: left; padding: 12px 14px; }
.onboarding-who-option b { font-weight: 500; }
.onboarding-who-option small { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.onboarding-who-more { display: grid; gap: 8px; animation: onboarding-card-enter 180ms var(--ease-out) both; }
.onboarding-who-relation-title, .onboarding-who-note { margin: 0; }
.onboarding-who-note { color: var(--color-ink-secondary); }
.onboarding-relation { display: flex; align-items: center; gap: 8px; min-height: 44px; }
.starter-questions { display: grid; justify-items: start; gap: 8px; margin: 0 0 var(--space-3); }
.starter-questions-lead { margin: 0; color: var(--color-ink-secondary); }
.starter-questions-list { display: grid; gap: 8px; width: min(100%, 420px); }
.starter-questions-list button { height: auto; min-height: 44px; white-space: normal; justify-content: flex-start; text-align: left; }
.composer textarea.is-starter-focus { outline: 2px solid var(--color-action-strong); outline-offset: 2px; }
.birth-time-transition-card { position: relative; overflow: visible; }
/* Toasts (2026-09-30): the only place action results and errors appear. A
compact card at the top centre with a coloured icon; no left bar, no page
@@ -6938,6 +6955,16 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
cursor: pointer;
}
.people-archive-secondary:hover { background: var(--color-canvas-muted); }
.people-archive-synastry-later {
margin: 0;
min-height: 44px;
display: grid;
align-items: center;
justify-items: center;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
text-align: center;
}
.people-archive-foot { display: grid; gap: var(--space-2); justify-items: center; text-align: center; }
.people-archive-confirm {
display: grid;
@@ -21,6 +21,7 @@
.birth-sky-converge:not([open]) { display: none; }
.birth-sky-converge::backdrop { background: transparent; }
.birth-sky-converge canvas { display: block; }
.birth-sky-subject-line { position: absolute; left: 16px; right: 16px; bottom: 12%; margin: 0; text-align: center; color: #f4efe4; font-size: 16px; line-height: 1.5; pointer-events: none; }
@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } }
@media print { .birth-sky-converge { display: none !important; } }
/* Chart page replay controls (TASK-mobile-chart-and-confirmed-edit-20260929 M4):
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -10,6 +10,19 @@ import { birthSkyHoldAtMs, birthSkyRevealAt, birthSkyScatter, type BirthSkyRevea
import { BIRTH_SKY_PALETTES, drawBirthSky, type BirthSkyCanvas } from "@/lib/birth-sky/draw";
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
import { peekSubjectCatalog, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { displaySign, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
function useOtherSkySubjectLine(): string {
const subjectId = useCurrentSubject();
const catalog = useSyncExternalStore(subscribeSubjectCatalog, peekSubjectCatalog, () => null);
const signs = useSyncExternalStore(subscribeStarterSigns, () => readStarterSigns(subjectId), () => readStarterSigns(subjectId));
if (!subjectId || subjectId === "self") return "";
const name = catalog?.find((row) => row.id === subjectId)?.profile.name.trim() ?? "";
const sign = displaySign(signs.ascendantSign);
if (!name || !sign) return "";
return `那一刻,${name}的上升在${sign}`;
}
/** Largest 3:4 box that fits the viewport, in CSS pixels. */
export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: number) {
@@ -37,6 +50,8 @@ export function BirthSkyConverge({ reveal, onDone, mode = "reveal", controls }:
}) {
const dialog = useRef<HTMLDialogElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const otherSubjectLine = useOtherSkySubjectLine();
const subjectLine = mode === "reveal" ? otherSubjectLine : "";
useEffect(() => {
const layer = dialog.current;
@@ -132,6 +147,7 @@ export function BirthSkyConverge({ reveal, onDone, mode = "reveal", controls }:
role="img"
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
/>
{subjectLine ? <p className="birth-sky-subject-line">{subjectLine}</p> : null}
{controls}
</dialog>,
document.body,
@@ -1,11 +1,11 @@
"use client";
import dynamic from "next/dynamic";
import type { FormEvent } from "react";
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { BirthLocationFields } from "@/components/birth-location-fields";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
import { OnboardingWhoStep } from "@/components/onboarding-who-step";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import {
applyBirthTimeDraftPatch,
@@ -18,16 +18,13 @@ import {
formatBirthMoment,
placeQuestion,
selectedBirthPlace,
whoQuestion,
type OnboardingAudience,
} from "@/lib/home-profile";
import { presetOnboardingMessage, type OnboardingStep, type Profile } from "@/lib/home-types";
import type { PersonRelation } from "@/lib/person-relation";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
{
ssr: false,
loading: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
},
);
const ONBOARDING_PROGRESS = ["name", "who", "birth", "place"] as const;
export type HomeOnboardingShellProps = {
readonly profileComplete: boolean;
@@ -47,6 +44,10 @@ export type HomeOnboardingShellProps = {
readonly saveOnboardingBirth: (event: FormEvent<HTMLFormElement>) => void;
readonly saveOnboardingPlace: (event: FormEvent<HTMLFormElement>) => void;
readonly retryBirthTimeAssessment: () => void;
readonly intakeTarget?: "self" | "other" | null;
readonly readerName?: string;
readonly onChooseSelf?: () => void;
readonly onChooseOther?: (name: string, relation: PersonRelation) => void;
};
export function HomeOnboardingShell({
@@ -55,31 +56,45 @@ export function HomeOnboardingShell({
setProfileDraft,
onboardingStep,
presetMessageLength,
currentOnboardingMessage,
onboardingJustCompleted,
onboardingCardReady,
presetMessageFinished,
birthTimeJourney,
birthTimeAssessmentPhase,
birthTimeError,
birthTimeGuided,
profileSaving,
saveOnboardingBirth,
saveOnboardingPlace,
retryBirthTimeAssessment,
intakeTarget = null,
readerName,
onChooseSelf,
onChooseOther,
}: HomeOnboardingShellProps) {
const audience: OnboardingAudience = intakeTarget === "other" ? "other" : "self";
const reader = (readerName ?? profileDraft.name).trim();
const subjectName = audience === "other" ? profileDraft.name.trim() : reader;
const pastWho = onboardingStep === "birth" || onboardingStep === "place" || profileComplete;
const pastBirth = onboardingStep === "place" || profileComplete;
const progressIndex = onboardingStep === "name" ? 0 : onboardingStep === "who" ? 1 : onboardingStep === "birth" ? 2 : 3;
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const choiceText = audience === "other" ? `帮别人看:${subjectName}` : "我自己";
return (
<>
{!profileComplete ? (
<ol className="onboarding-progress" aria-label="建档进度">
{ONBOARDING_PROGRESS.map((step, index) => (
<li key={step} className={index <= progressIndex ? "is-done" : undefined} />
))}
</ol>
) : null}
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={reader} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={whoQuestion(reader)} streaming={onboardingStep === "who" && !profileComplete} length={presetMessageLength} />}
{pastWho && <OnboardingChatMessage role="user" text={choiceText} />}
{pastWho && <OnboardingChatMessage role="assistant" text={birthQuestion(subjectName, audience)} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{pastBirth && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{pastBirth && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft, audience, subjectName)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{!profileComplete && onboardingStep === "who" && onboardingCardReady && onChooseSelf && onChooseOther ? (
<OnboardingWhoStep saving={profileSaving} onChooseSelf={onChooseSelf} onChooseOther={onChooseOther} />
) : null}
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
<div className="onboarding-card-reveal">
@@ -117,27 +132,6 @@ export function HomeOnboardingShell({
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<BirthTimeRectification
journey={birthTimeJourney}
controller={birthTimeGuided}
externalError={birthTimeError}
/>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
<div className="onboarding-card birth-time-retry-card" role="status">
<b>出生时间尚未完成评估</b>
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
</div>
)}
</>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
PERSON_RELATION_LABELS,
PERSON_RELATION_TITLE,
PERSON_RELATIONS,
parsePersonRelation,
type PersonRelation,
} from "@/lib/person-relation";
export function OnboardingWhoStep({
saving,
onChooseSelf,
onChooseOther,
}: {
readonly saving: boolean;
readonly onChooseSelf: () => void;
readonly onChooseOther: (name: string, relation: PersonRelation) => void;
}) {
const [helping, setHelping] = useState(false);
const [name, setName] = useState("");
const [relation, setRelation] = useState<PersonRelation | "">("");
const trimmed = name.trim();
const canSaveOther = Boolean(trimmed) && relation !== "" && !saving;
return (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<div className="onboarding-card onboarding-who">
<div className="onboarding-who-options">
<Button
type="button"
variant="outline"
className="onboarding-who-option"
disabled={saving}
onClick={onChooseSelf}
>
<b>我自己</b>
<small>看看自己的性格、事业和这几年的运势</small>
</Button>
<Button
type="button"
variant="outline"
className="onboarding-who-option"
aria-expanded={helping}
disabled={saving}
onClick={() => setHelping(true)}
>
<b>帮别人看</b>
<small>伴侣、孩子、父母或朋友</small>
</Button>
</div>
{helping ? (
<div className="onboarding-who-more">
<Label htmlFor="onboarding-other-name">TA 叫什么</Label>
<Input
id="onboarding-other-name"
value={name}
maxLength={80}
disabled={saving}
onChange={(event) => setName(event.target.value)}
/>
<p className="onboarding-who-relation-title">{PERSON_RELATION_TITLE}</p>
<RadioGroup
value={relation}
aria-label={PERSON_RELATION_TITLE}
onValueChange={(value) => {
const next = parsePersonRelation(value);
if (next) setRelation(next);
}}
>
{PERSON_RELATIONS.map((item) => (
<Label key={item} className="onboarding-relation">
<RadioGroupItem value={item} />
{PERSON_RELATION_LABELS[item]}
</Label>
))}
</RadioGroup>
<p className="onboarding-who-note">你自己的资料可以以后再补。</p>
<div className="onboarding-card-actions">
<Button
type="button"
disabled={!canSaveOther}
onClick={() => {
if (relation === "") return;
onChooseOther(trimmed, relation);
}}
>
{saving ? "保存中" : "继续"}
</Button>
</div>
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -489,7 +489,9 @@ export function PeopleArchiveView({
</button>
</div>
{selected.role === "other" ? (
<button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</button>
people.some((person) => person.role === "self" && !person.incomplete)
? <button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</button>
: <p className="people-archive-synastry-later">加上你自己后可合盘</p>
) : null}
{/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
<div className="people-archive-foot">
@@ -0,0 +1,38 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
STARTER_FREE_BADGE,
starterLead,
starterQuestions,
starterWatching,
type StarterAudience,
type StarterChartSigns,
} from "@/lib/starter-questions";
export function StarterQuestions({
audience,
signs,
onAsk,
onNudge,
}: {
readonly audience: StarterAudience;
readonly signs: StarterChartSigns;
readonly onAsk: (question: string) => void;
readonly onNudge: () => void;
}) {
const questions = starterQuestions(audience, signs);
return (
<div className="starter-questions">
<p className="starter-questions-lead">{starterLead(audience)}</p>
{audience.kind === "other" ? <Badge variant="outline">{starterWatching(audience)}</Badge> : null}
<Badge variant="secondary">{STARTER_FREE_BADGE}</Badge>
<div className="starter-questions-list">
<Button type="button" variant="outline" onClick={() => onAsk(questions[0])}>{questions[0]}</Button>
<Button type="button" variant="outline" onClick={() => onAsk(questions[1])}>{questions[1]}</Button>
<Button type="button" variant="outline" onClick={onNudge}>{questions[2]}</Button>
</div>
</div>
);
}
+3 -2
View File
@@ -17,13 +17,14 @@ export function useBirthSkyReveal() {
const [cover, setCover] = useState<BirthSkyRevealSky | null>(null);
const cancel = useRef<(() => void) | null>(null);
const begin = useCallback(() => {
const begin = useCallback((subjectId?: string) => {
const id = subjectId?.trim() || "self";
cancel.current?.();
cancel.current = startBirthSkyReveal<BirthSkyRevealSky>({
// The overlay chunk loads inside the same budget, so the first frame never waits for code.
load: async (signal) => {
const [sky] = await Promise.all([
loadBirthSkyRevealSky(signal),
loadBirthSkyRevealSky(signal, id),
import("@/components/birth-sky/birth-sky-converge"),
]);
return sky;
+122 -18
View File
@@ -25,10 +25,21 @@ import {
pinChartSnapshotIdentity,
} from "@/lib/secondary-page-data";
import {
missingOtherProfileStep,
missingProfileStep,
onboardingSelfBirthStarted,
readProfile,
selectedBirthPlace,
} from "@/lib/home-profile";
import { setCurrentSubject, loadSubjectCatalog, invalidateSubjectCatalog } from "@/lib/current-subject";
import {
clearOnboardingIntake,
notifyOnboardingFinished,
readOnboardingIntake,
writeOnboardingIntake,
} from "@/lib/onboarding-intake";
import { saveOtherProfile } from "@/lib/other-profile-save";
import type { PersonRelation } from "@/lib/person-relation";
import { notifyError, notifySuccess } from "@/lib/notify";
import { saveSelfProfile } from "@/lib/self-profile-save";
import { createStartGreeting } from "@/lib/starter-greeting";
@@ -36,6 +47,7 @@ import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
type Account,
type AccountDialog,
type ChartLibraryRecord,
type OnboardingStep,
type OpenAccountDialogOptions,
type Profile,
@@ -76,7 +88,9 @@ export type ProfileOnboardingParams = {
/** Warm return to `/` (BUG-1040): the profile Home starts with instead of empty. */
initialProfile?: () => Profile | null;
/** The reader's own birth place was saved in onboarding (first chart): 「那一刻的天空」 reveal. */
onOwnBirthPlaceSaved?: () => void;
onOwnBirthPlaceSaved?: (subjectId?: string) => void;
/** People already on the account. Used to resume an unfinished other chart. */
chartLibrary?: readonly ChartLibraryRecord[];
};
export function useProfileOnboarding(params: ProfileOnboardingParams) {
@@ -104,6 +118,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
uiPreview,
initialProfile,
onOwnBirthPlaceSaved,
chartLibrary = [],
} = params;
// Owned here since 2026-09-16 (state lowering batch 2). Home reads them back
@@ -113,6 +128,19 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
const [profileNotice, setProfileNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [intakeChoice, setIntakeChoice] = useState<"self" | "other" | null>(null);
const [chosenOtherId, setChosenOtherId] = useState<string | null>(null);
const [draftOverride, setDraftOverride] = useState<Profile | null>(null);
const intakeHint = account ? readOnboardingIntake(account.user.id) : null;
const pendingOther = chartLibrary.find((row) => {
if (row.role === "self") return false;
if (intakeHint?.target === "other" && intakeHint.otherId) return row.id === intakeHint.otherId;
return Boolean(row.profile.name.trim()) && missingOtherProfileStep(row.profile) !== null;
});
const intakeTarget = intakeChoice
?? (intakeHint?.target === "self" || onboardingSelfBirthStarted(profile) ? "self" : pendingOther ? "other" : null);
const otherId = intakeTarget === "other" ? (chosenOtherId ?? pendingOther?.id ?? intakeHint?.otherId ?? null) : null;
const otherDraft = intakeTarget === "other" ? (draftOverride ?? pendingOther?.profile ?? null) : null;
async function refreshAccount() {
const requestIdentity = accountRefreshGuard.current.begin();
@@ -235,7 +263,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setStartGreeting(createStartGreeting(savedProfile.name));
setDraft("");
setPresetMessageLength(0);
const nextStep = missingProfileStep(savedProfile);
const nextStep = missingProfileStep(savedProfile, chartLibrary, readOnboardingIntake(account.user.id));
if (nextStep) setOnboardingStep(nextStep);
else setOnboardingJustCompleted(false);
} catch (caught) {
@@ -247,18 +275,25 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
const otherBirth = intakeTarget === "other" && otherId && otherDraft
? { id: otherId, draft: otherDraft }
: null;
const draft = otherBirth ? otherBirth.draft : profileDraft;
if (!isBirthTimeDraftReady(draft) || !account || profileSaving) return;
setProfileSaving(true);
setBirthTimeAssessmentPhase("saving_profile");
if (!otherBirth) setBirthTimeAssessmentPhase("saving_profile");
try {
const savedProfile = await persistProfile(profileDraft);
birthTimeRevisionPendingRef.current = false;
setProfile(savedProfile);
setProfileDraft(savedProfile);
if (otherBirth) {
await saveOtherProfile(otherBirth.draft, { id: otherBirth.id });
setDraftOverride(otherBirth.draft);
} else {
const savedProfile = await persistProfile(draft);
birthTimeRevisionPendingRef.current = false;
setProfile(savedProfile);
setProfileDraft(savedProfile);
}
setPresetMessageLength(0);
const nextStep = missingProfileStep(savedProfile);
if (nextStep) setOnboardingStep(nextStep);
else setOnboardingJustCompleted(false);
setOnboardingStep("place");
} catch (caught) {
notifyError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败"));
} finally {
@@ -276,18 +311,35 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
async function saveOnboardingPlace(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
const otherPlace = intakeTarget === "other" && otherId && otherDraft
? { id: otherId, draft: otherDraft }
: null;
const draft = otherPlace ? otherPlace.draft : profileDraft;
if (!selectedBirthPlace(draft) || !account || profileSaving) return;
setProfileSaving(true);
setBirthTimeAssessmentPhase("entering_home");
if (!otherPlace) setBirthTimeAssessmentPhase("entering_home");
try {
const savedProfile = await persistProfile(profileDraft);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setStartGreeting(createStartGreeting(savedProfile.name));
let subjectId = "self";
if (otherPlace) {
await saveOtherProfile(otherPlace.draft, { id: otherPlace.id });
subjectId = otherPlace.id;
clearOnboardingIntake(account.user.id);
invalidateSubjectCatalog();
setCurrentSubject(subjectId);
await loadSubjectCatalog();
notifyOnboardingFinished(subjectId);
setStartGreeting(createStartGreeting(profile.name.trim() || otherPlace.draft.name));
} else {
const savedProfile = await persistProfile(profileDraft);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setStartGreeting(createStartGreeting(savedProfile.name));
clearOnboardingIntake(account.user.id);
}
setPresetMessageLength(0);
setOnboardingJustCompleted(false);
// Fire and forget: the flow above has already moved on and never waits for it.
onOwnBirthPlaceSaved?.();
onOwnBirthPlaceSaved?.(subjectId);
} catch (caught) {
notifyError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
} finally {
@@ -296,6 +348,53 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
}
}
function chooseOnboardingSelf() {
if (!account || profileSaving) return;
writeOnboardingIntake(account.user.id, { target: "self" });
setIntakeChoice("self");
setChosenOtherId(null);
setDraftOverride(null);
setPresetMessageLength(0);
setOnboardingStep("birth");
}
async function chooseOnboardingOther(name: string, relation: PersonRelation) {
if (!account || profileSaving) return;
const trimmed = name.replace(/\s+/g, " ").trim().slice(0, 80);
if (!trimmed) return;
setProfileSaving(true);
try {
const draft: Profile = { ...emptyProfile, name: trimmed, relation };
const saved = await saveOtherProfile(draft);
if (!saved.id) throw new Error("人物没有保存成功");
writeOnboardingIntake(account.user.id, { target: "other", otherId: saved.id });
setIntakeChoice("other");
setChosenOtherId(saved.id);
setDraftOverride(draft);
invalidateSubjectCatalog();
void loadSubjectCatalog().catch(() => undefined);
setPresetMessageLength(0);
setOnboardingStep("birth");
} catch (caught) {
notifyError(friendlyError(caught instanceof Error ? caught.message : "人物保存失败"));
} finally {
setProfileSaving(false);
}
}
function setOnboardingDraft(update: Profile | ((current: Profile) => Profile)) {
if (intakeTarget === "other") {
setDraftOverride((current) => {
const base = current ?? otherDraft ?? emptyProfile;
return typeof update === "function" ? update(base) : update;
});
return;
}
setProfileDraft(update);
}
const onboardingDraft = intakeTarget === "other" && otherDraft ? otherDraft : profileDraft;
function completeGuidedBirthTime(result: JourneyClientResponse) {
if (result.nextAction.kind !== "ready") return;
const confirmedProfile: Profile = {
@@ -347,6 +446,11 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setProfile,
profileDraft,
setProfileDraft,
onboardingDraft,
setOnboardingDraft,
intakeTarget,
chooseOnboardingSelf,
chooseOnboardingOther,
profileNotice,
setProfileNotice,
avatarSaving,
@@ -214,7 +214,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
sessionSelectionSource.current = "user";
return null;
}
const missingStep = missingProfileStep(profile);
const missingStep = missingProfileStep(profile, chartLibrary);
if (missingStep) {
sessionSelectionSource.current = "user";
setRectificationSessionId(null);
@@ -432,7 +432,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationSessionId(null);
setRectificationCaseId(null);
setRectificationSource(null);
const missingStep = missingProfileStep(profile);
const missingStep = missingProfileStep(profile, chartLibrary);
if (missingStep) {
setOnboardingStep(missingStep);
setComposerNotice("请先完成出生资料,再开始生时校正。");
+2 -2
View File
@@ -12,7 +12,7 @@ import {
writeSessionUrl,
} from "@/lib/chat-session-url";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { currentSubjectCatalogReady, peekSubjectCatalog, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
@@ -352,7 +352,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(options?.subjectId ?? NEW_CHAT_SUBJECT_ID(), chartLibrary, profile),
...chartSnapshotForSession(options?.subjectId ?? NEW_CHAT_SUBJECT_ID(), peekSubjectCatalog() ?? chartLibrary, profile),
};
for (const session of sessions) {
if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id);
+3 -3
View File
@@ -14,10 +14,10 @@ export const BIRTH_SKY_REVEAL_TIMEOUT_MS = 3_000;
/** Everything the convergence overlay needs before its first frame. */
export type BirthSkyRevealSky = Readonly<{ sky: BirthSkyOk; serif: boolean }>;
/** Fetch the reader's own sky and wait (bounded) for the heading serif. Null on any failure. */
export async function loadBirthSkyRevealSky(signal: AbortSignal): Promise<BirthSkyRevealSky | null> {
/** Fetch one person's sky and wait (bounded) for the heading serif. Null on any failure. */
export async function loadBirthSkyRevealSky(signal: AbortSignal, subjectId = "self"): Promise<BirthSkyRevealSky | null> {
try {
const sky = await fetchBirthSky("self", signal);
const sky = await fetchBirthSky(subjectId.trim() || "self", signal);
if (!sky || signal.aborted) return null;
const { BIRTH_SKY_TOP_LABEL, birthSkyHeadline } = await import("./sentence.ts");
const { waitForHeadingSerif } = await import("./render.ts");
+5 -2
View File
@@ -45,6 +45,7 @@ import {
} from "@/lib/home-cloud-sync";
import { chartSnapshotForSession, isProfileComplete, missingProfileStep, readProfile } from "@/lib/home-profile";
import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
import { readOnboardingIntake } from "@/lib/onboarding-intake";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import {
emptyProfile,
@@ -350,7 +351,9 @@ export async function runAccountPhase(
const nextProfile = readProfile(nextAccount.profile);
// The onboarding shell is no longer in the first-screen bundle (BUG-1130);
// someone who will see it gets it before reveal, not a blank frame after.
if (!isProfileComplete(nextProfile)) await io.preloadOnboardingShell();
const onboardingOthers = peekSubjectCatalog() ?? [];
const onboardingHint = readOnboardingIntake(nextAccount.user.id);
if (!isProfileComplete(nextProfile, onboardingOthers, onboardingHint)) await io.preloadOnboardingShell();
if (signal.aborted) return { kind: "stop" };
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
@@ -441,7 +444,7 @@ export async function resolveLanding(
deps.commit.setProfile(ready.nextProfile);
deps.commit.setProfileDraft(ready.nextProfile);
deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : "");
deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile) ?? "name");
deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile, peekSubjectCatalog() ?? [], readOnboardingIntake(ready.nextAccount.user.id)) ?? "name");
deps.commit.setSessions(nextSessions);
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
deps.commit.setActiveSessionId(landingSessionId);
+7
View File
@@ -238,6 +238,13 @@ export async function fetchDailyStarlanguage(signal: AbortSignal, subjectId = "s
if (!response.ok) return { kind: "unavailable" };
const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null;
if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" };
if ("ascendantSign" in payload || "moonSign" in payload) {
const { rememberStarterSigns } = await import("@/lib/starter-questions");
rememberStarterSigns(subjectId, {
ascendantSign: typeof payload.ascendantSign === "string" ? payload.ascendantSign : null,
moonSign: typeof payload.moonSign === "string" ? payload.moonSign : null,
});
}
return { kind: "ready", card: payload.card };
}
export type SessionListPage = {
+68 -10
View File
@@ -26,6 +26,7 @@ import {
import { resolveAyanamsa } from "@/lib/ayanamsa";
import { loadedChinaCountry } from "@/lib/china-locations-client";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import type { OnboardingIntakeHint } from "@/lib/onboarding-intake";
import { parsePersonRelation } from "@/lib/person-relation";
import { parseProfileGender } from "@/lib/profile-gender";
@@ -74,8 +75,22 @@ export function selectedBirthPlace(profile: Profile): BirthPlace | null {
timezoneId: "Asia/Shanghai",
};
}
/** The account holder's own chart is complete. Other people do not count. */
export function selfChartComplete(profile: Profile) {
return Boolean(profile.name.trim())
&& isDeclaredBirthProfileComplete(profile)
&& isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile));
}
/** A saved date, a declared birth, or a place means 「我自己」 already started. */
export function onboardingSelfBirthStarted(profile: Profile) {
return isDeclaredBirthProfileComplete(profile)
|| Boolean(selectedBirthPlace(profile))
|| Boolean(profile.date.trim());
}
export function profileReadyForLibrary(profile: Profile) {
return !missingProfileStep(profile);
return selfChartComplete(profile);
}
export function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
@@ -162,11 +177,40 @@ export function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Prof
evidenceRequest,
].join("\n");
}
export function missingProfileStep(profile: Profile): OnboardingStep | null {
export type OnboardingOtherRecord = {
readonly id?: string;
readonly role?: string;
readonly profile: Profile;
};
/**
* Onboarding is done when the holder is complete, or at least one other person is.
* `others` defaults to none, so a caller that only has the holder row still means "self".
* The who-choice before a birth row exists lives in localStorage (`onboarding-intake`), not a column.
*/
export function missingProfileStep(
profile: Profile,
others: readonly OnboardingOtherRecord[] = [],
hint: OnboardingIntakeHint | null = null,
): OnboardingStep | null {
const otherRecords = others.filter((row) => row.role !== "self");
if (selfChartComplete(profile) || otherRecords.some((row) => missingOtherProfileStep(row.profile) === null && row.profile.name.trim())) {
return null;
}
if (!profile.name.trim()) return "name";
if (!isDeclaredBirthProfileComplete(profile)) return "birth";
if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place";
return null;
const incompleteNamed = otherRecords.filter((row) => row.profile.name.trim() && missingOtherProfileStep(row.profile) !== null);
const followOther = hint?.target === "other"
|| (hint?.target !== "self" && !onboardingSelfBirthStarted(profile) && incompleteNamed.length > 0);
if (followOther && incompleteNamed.length > 0) {
const chosen = (hint?.otherId ? incompleteNamed.find((row) => row.id === hint.otherId) : undefined) ?? incompleteNamed[0];
const step = chosen ? missingOtherProfileStep(chosen.profile) : null;
return step === "birth" || step === "place" ? step : "who";
}
if (hint?.target === "self" || onboardingSelfBirthStarted(profile)) {
return isDeclaredBirthProfileComplete(profile) ? "place" : "birth";
}
return "who";
}
export function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null {
@@ -176,15 +220,25 @@ export function missingOtherProfileStep(profile: Profile): "name" | "birth" | "p
return null;
}
export function birthQuestion(name: string) {
return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`;
export type OnboardingAudience = "self" | "other";
export function whoQuestion(name: string) {
return `好的,${name}。这次想先看谁的盘?以后随时可以再加别人。`;
}
export function birthQuestion(name: string, audience: OnboardingAudience = "self") {
if (audience === "other") {
return `好,先看${name}的。请告诉我 TA 的出生日期,以及你对 TA 出生时间知道到什么程度。家人朋友的出生时间常常记不准,选个大概时段就行。`;
}
return `好,先看你自己的。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`;
}
export function formatBirthMoment(profile: Profile) {
return describeBirthTimeDraft(profile);
}
export function placeQuestion(profile: Profile) {
export function placeQuestion(profile: Profile, audience: OnboardingAudience = "self", otherName = "") {
if (audience === "other") return `最后一个问题,${otherName.trim() || profile.name.trim()}出生在哪里?`;
return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`;
}
@@ -340,6 +394,10 @@ export function invalidateCandidateAfterLocationChange(current: Profile, next: P
|| (current.birthTimeStatus !== "candidate" && !current.time)) return next;
return { ...next, time: "", activeDate: undefined, activeTimezoneOffset: undefined, birthTimeStatus: "reported" };
}
export function isProfileComplete(profile: Profile) {
return missingProfileStep(profile) === null;
export function isProfileComplete(
profile: Profile,
others: readonly OnboardingOtherRecord[] = [],
hint: OnboardingIntakeHint | null = null,
) {
return missingProfileStep(profile, others, hint) === null;
}
+4 -1
View File
@@ -150,7 +150,7 @@ export type Account = {
/** True while this account's one free standard question is still available. Missing means the paywall stays. */
firstConsultFree?: boolean;
};
export type OnboardingStep = "name" | "birth" | "place" | "rectification";
export type OnboardingStep = "name" | "who" | "birth" | "place";
export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout";
export type BillingPaneTab = "membership" | "credits" | "redeem" | "orders";
export type OpenAccountDialogOptions = {
@@ -174,6 +174,9 @@ export type DailyStarlanguageApiResponse = {
status?: "ok" | "unavailable" | "unauthenticated";
card?: DailyStarlanguageCard;
source?: "library" | "library_cache" | "engine_evidence" | "engine_evidence_cache" | "agent" | "agent_cache";
/** Already computed for the daily card. Starter questions read these; they do not request them again. */
ascendantSign?: string | null;
moonSign?: string | null;
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
+5 -2
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/chat-session-url";
import { takeClientNavigationTarget } from "@/lib/client-navigation-target";
import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
import { readOnboardingIntake } from "@/lib/onboarding-intake";
import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage";
import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation } from "@/lib/home-bootstrap";
import { createSession, readStoredDailyStarlanguage } from "@/lib/home-cloud-sync";
@@ -159,9 +160,11 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart |
const snapshot = readHomeWarmSnapshot(account.user.id);
if (!isCompleteHomeWarmSnapshot(snapshot)) return null;
const profile = readProfile(account.profile);
const onboardingOthers = input.subjectCatalog ?? [];
const onboardingHint = readOnboardingIntake(account.user.id);
// Onboarding keeps the cold path: its streamed questions and birth-time
// assessment are not part of the snapshot.
if (!isProfileComplete(profile)) return null;
if (!isProfileComplete(profile, onboardingOthers, onboardingHint)) return null;
const catalog = snapshot.modelCatalog;
const landing = resolveWarmLanding({
sessions: input.sessions,
@@ -179,7 +182,7 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart |
modelCatalog: catalog,
profile,
startGreeting: profile.name.trim() ? createStartGreeting(profile.name) : "",
onboardingStep: missingProfileStep(profile) ?? "name",
onboardingStep: missingProfileStep(profile, onboardingOthers, onboardingHint) ?? "name",
entrySummary: snapshot.entrySummary,
daily: warmDaily(account.user.id, input.subjectId, subjectProfile(input.subjectId, profile, input.subjectCatalog), input.now),
sessionsCursor: input.boot === snapshot.listBoot ? snapshot.sessionsCursor : input.boot.cursor,
+67
View File
@@ -0,0 +1,67 @@
/** Who the first chart is for, before a birth row exists. Not a database column. */
export type OnboardingIntakeHint = {
readonly target: "self" | "other";
readonly otherId?: string;
};
const STORAGE_PREFIX = "jyotisha_onboarding_intake:";
function storage(): Storage | null {
try {
return typeof localStorage === "undefined" ? null : localStorage;
} catch {
return null;
}
}
export function readOnboardingIntake(accountId: string): OnboardingIntakeHint | null {
const store = storage();
if (!accountId || !store) return null;
try {
const raw = store.getItem(STORAGE_PREFIX + accountId);
if (!raw) return null;
const parsed = JSON.parse(raw) as { target?: unknown; otherId?: unknown };
if (parsed.target !== "self" && parsed.target !== "other") return null;
const otherId = typeof parsed.otherId === "string" && parsed.otherId.trim() ? parsed.otherId.trim() : undefined;
return { target: parsed.target, otherId };
} catch {
return null;
}
}
export function writeOnboardingIntake(accountId: string, hint: OnboardingIntakeHint): void {
const store = storage();
if (!accountId || !store) return;
try {
store.setItem(STORAGE_PREFIX + accountId, JSON.stringify({
target: hint.target,
...(hint.otherId ? { otherId: hint.otherId } : {}),
}));
} catch {
// The next visit on this browser asks again. The server row still holds a started other person.
}
}
export function clearOnboardingIntake(accountId: string): void {
const store = storage();
if (!accountId || !store) return;
try {
store.removeItem(STORAGE_PREFIX + accountId);
} catch {
// Leaving the hint only affects which step this browser resumes.
}
}
type FinishedListener = (subjectId: string) => void;
let finishedListener: FinishedListener | null = null;
/** Home registers this so the finished other-person chart can open their empty chat. */
export function setOnboardingFinishedListener(listener: FinishedListener | null): void {
finishedListener = listener;
}
export function notifyOnboardingFinished(subjectId: string): void {
finishedListener?.(subjectId);
}
+4 -2
View File
@@ -8,6 +8,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
type Dispatch,
type ReactNode,
type SetStateAction,
@@ -15,7 +16,7 @@ import {
import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, peekSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { readSessions } from "@/lib/home-cloud-sync";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
@@ -242,7 +243,8 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
// so its first 「新建对话」 would cold-start Home. Once the list is settled and
// no Home is mounted, fill the snapshot in the background (read-only GETs).
// Home mounting, sign-out, an account switch or a list reload cancels it.
const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile))
const onboardingCatalog = useSyncExternalStore(subscribeSubjectCatalog, peekSubjectCatalog, () => null);
const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile), onboardingCatalog ?? [])
? account.user.id
: null;
useEffect(() => {
+83
View File
@@ -0,0 +1,83 @@
import { SIGN_ZH, type SignName } from "@/lib/chart-view-labels";
export type StarterAudience =
| { readonly kind: "self" }
| { readonly kind: "other"; readonly name: string; readonly relationLabel: string };
export type StarterChartSigns = {
readonly ascendantSign: string | null;
readonly moonSign: string | null;
};
export const STARTER_FREE_BADGE = "这一次不扣点";
const EMPTY_SIGNS: StarterChartSigns = { ascendantSign: null, moonSign: null };
const SIGN_LABELS: Readonly<Record<string, string>> = SIGN_ZH;
/** English engine names become the short Chinese sign. An already-Chinese label is kept, without 座. */
export function displaySign(value: string | null | undefined): string | null {
const text = value?.trim() ?? "";
if (!text) return null;
const known = SIGN_LABELS[text as SignName];
if (known) return known;
return text.endsWith("座") ? text.slice(0, -1) : text;
}
export function starterLead(audience: StarterAudience): string {
if (audience.kind === "self") return "盘排好了。先问一个问题吧。";
return `${audience.name}的盘排好了。先问一个关于 TA 的问题吧。`;
}
export function starterWatching(audience: Extract<StarterAudience, { kind: "other" }>): string {
return `正在看:${audience.name} · ${audience.relationLabel}`;
}
export function starterQuestions(audience: StarterAudience, signs: StarterChartSigns): readonly [string, string, string] {
const ascendant = displaySign(signs.ascendantSign);
const moon = displaySign(signs.moonSign);
const who = audience.kind === "self" ? "我" : audience.name;
const first = ascendant && moon
? `上升${ascendant}、月亮${moon},${who}是个什么样的人`
: moon
? `月亮在${moon},${who}是个什么样的人`
: audience.kind === "self"
? "从我的盘看,我是个什么样的人"
: `从${audience.name}的盘看,${audience.name}是个什么样的人`;
const second = audience.kind === "self"
? "接下来这两年,事业上该注意什么"
: `${audience.name}接下来这两年,事业上该注意什么`;
const third = audience.kind === "self" ? "我想问一件具体的事" : "我想问一件关于 TA 的事";
return [first, second, third];
}
type SignMap = Readonly<Record<string, StarterChartSigns>>;
let signs: SignMap = {};
const listeners = new Set<() => void>();
export function rememberStarterSigns(subjectId: string, next: StarterChartSigns): void {
const id = subjectId.trim() || "self";
const current = signs[id] ?? EMPTY_SIGNS;
if (current.ascendantSign === next.ascendantSign && current.moonSign === next.moonSign) return;
signs = { ...signs, [id]: { ascendantSign: next.ascendantSign, moonSign: next.moonSign } };
for (const listener of listeners) listener();
}
export function readStarterSigns(subjectId: string): StarterChartSigns {
return signs[subjectId] ?? EMPTY_SIGNS;
}
export function readStarterSignMap(): SignMap {
return signs;
}
export function subscribeStarterSigns(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function resetStarterSignsForTests(): void {
signs = {};
for (const listener of listeners) listener();
}
@@ -167,14 +167,11 @@ test("completed rectification transcript does not repeat the birth place turn",
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const shell = readFileSync(new URL("../src/components/home-onboarding-shell.tsx", import.meta.url), "utf8");
// 原值: 地点回显写在 page.tsx。
// 新值: 回显在引导屏,page.tsx 仍挂载它。
// 原因: 首页拆分搬走了引导屏,完成校正后不重复地点的条件没变。
// 原值: 引导屏含 `{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft)`。
// 新值: 引导屏不再出现 onboardingStep === "rectification"。
// 原因: BUG-1431,这一支到不了(校正步骤已从建档去掉),地点回显留在出生地那一步。
assert.match(pageSource, /<HomeOnboardingShell/);
assert.match(
shell,
/\{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace\(profileDraft\)/,
);
assert.doesNotMatch(shell, /onboardingStep === "rectification"/);
});
test("journey turn implementation stays within the 250 pure-LOC boundary", () => {
+39 -26
View File
@@ -45,7 +45,7 @@ test("the fatal screen keeps its copy and retries through retryHomeBootstrap", a
assert.doesNotMatch(page, /window\.location\.reload\(\)/);
});
test("onboarding keeps the name prompt, birth card, place card, and retry card", () => {
test("onboarding keeps the name prompt, birth card, and place card", () => {
const guided = {} as BirthTimeGuidedController;
const nameMarkup = renderToStaticMarkup(
<HomeOnboardingShell
@@ -72,6 +72,38 @@ test("onboarding keeps the name prompt, birth card, place card, and retry card",
assert.match(nameMarkup, /请问我该怎么称呼你/);
assert.doesNotMatch(nameMarkup, /出生日期与时间/);
const whoMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
profileDraft={{ ...emptyProfile, name: "林遥" }}
setProfileDraft={() => undefined}
onboardingStep="who"
presetMessageLength={200}
currentOnboardingMessage="谁"
onboardingJustCompleted={false}
onboardingCardReady
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={guided}
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
readerName="林遥"
onChooseSelf={() => undefined}
onChooseOther={() => undefined}
/>,
);
assert.match(whoMarkup, /好的,林遥。这次想先看谁的盘?以后随时可以再加别人。/);
assert.match(whoMarkup, /我自己/);
assert.match(whoMarkup, /看看自己的性格、事业和这几年的运势/);
assert.match(whoMarkup, /帮别人看/);
assert.match(whoMarkup, /伴侣、孩子、父母或朋友/);
assert.match(whoMarkup, /aria-label="建档进度"/);
assert.equal((whoMarkup.match(/<li/g) ?? []).length, 4);
const birthMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
@@ -93,8 +125,14 @@ test("onboarding keeps the name prompt, birth card, place card, and retry card",
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(birthMarkup, /好,先看你自己的/);
assert.match(birthMarkup, /出生日期与时间/);
assert.match(birthMarkup, />继续</);
// 原值: 另渲一屏 onboardingStep="rectification",断言「出生时间尚未完成评估」和「重新评估」。
// 新值: 出生步不再出现这两句。
// 原因: BUG-1431,建档里的校正重试卡已删除。
assert.doesNotMatch(birthMarkup, /出生时间尚未完成评估/);
assert.doesNotMatch(birthMarkup, /重新评估/);
const placeMarkup = renderToStaticMarkup(
<HomeOnboardingShell
@@ -120,29 +158,4 @@ test("onboarding keeps the name prompt, birth card, place card, and retry card",
assert.match(placeMarkup, /出生地点/);
assert.match(placeMarkup, /搜索全球城市或区县,系统会匹配出生当日的时区。/);
assert.match(placeMarkup, />确定</);
const retryMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
profileDraft={{ ...emptyProfile, name: "林遥" }}
setProfileDraft={() => undefined}
onboardingStep="rectification"
presetMessageLength={80}
currentOnboardingMessage="校正"
onboardingJustCompleted={false}
onboardingCardReady
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={guided}
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(retryMarkup, /出生时间尚未完成评估/);
assert.match(retryMarkup, /资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。/);
assert.match(retryMarkup, />重新评估</);
});
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { readOnboardingIntake } from "../src/lib/onboarding-intake.ts";
import { missingProfileStep } from "../src/lib/home-profile.ts";
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
import { starterQuestions, type StarterChartSigns } from "../src/lib/starter-questions.ts";
/** Fictional chart. 1990-01-02 and 杭州 are not a real person's birth data. */
function chart(name: string, ready: boolean): Profile {
return {
...emptyProfile,
name,
date: ready ? "1990-01-02" : "",
birthTimeSource: ready ? "unknown" : "",
birthPlaceLabel: ready ? "杭州" : "",
latitude: ready ? 30.27 : null,
longitude: ready ? 120.15 : null,
timezoneId: ready ? "Asia/Shanghai" : "",
};
}
const none: StarterChartSigns = { ascendantSign: null, moonSign: null };
const moonOnly: StarterChartSigns = { ascendantSign: null, moonSign: "Taurus" };
const both: StarterChartSigns = { ascendantSign: "Aries", moonSign: "Taurus" };
test("missingProfileStep: self complete, only someone else complete, neither, and a stopped visit", () => {
const selfName = chart("林遥", false);
const selfDone = chart("林遥", true);
const otherDone = { id: "other-1", role: "other" as const, profile: chart("小舟", true) };
const otherBirth = { id: "other-1", role: "other" as const, profile: chart("小舟", false) };
const otherPlace = {
id: "other-1",
role: "other" as const,
profile: { ...chart("小舟", false), date: "1990-01-02", birthTimeSource: "unknown" as const },
};
assert.equal(missingProfileStep(selfDone), null);
assert.equal(missingProfileStep(selfName, [otherDone]), null);
assert.equal(missingProfileStep(selfName), "who");
assert.equal(missingProfileStep(selfName, [otherBirth]), "birth");
assert.equal(missingProfileStep(selfName, [], { target: "self" }), "birth");
assert.equal(missingProfileStep(selfDone, [], { target: "self" }), null);
assert.equal(missingProfileStep(
{ ...chart("林遥", false), date: "1990-01-02", birthTimeSource: "unknown" },
[],
{ target: "self" },
), "place");
assert.equal(missingProfileStep(selfName, [otherPlace], { target: "other", otherId: "other-1" }), "place");
assert.equal(missingProfileStep({ ...emptyProfile }, []), "name");
});
test("starter questions use the chart signs already on hand, for both people", () => {
const self = { kind: "self" as const };
const other = { kind: "other" as const, name: "小舟", relationLabel: "朋友" };
assert.deepEqual(starterQuestions(self, both), [
"上升白羊、月亮金牛,我是个什么样的人",
"接下来这两年,事业上该注意什么",
"我想问一件具体的事",
]);
assert.deepEqual(starterQuestions(self, moonOnly), [
"月亮在金牛,我是个什么样的人",
"接下来这两年,事业上该注意什么",
"我想问一件具体的事",
]);
assert.deepEqual(starterQuestions(self, none), [
"从我的盘看,我是个什么样的人",
"接下来这两年,事业上该注意什么",
"我想问一件具体的事",
]);
assert.deepEqual(starterQuestions(other, both), [
"上升白羊、月亮金牛,小舟是个什么样的人",
"小舟接下来这两年,事业上该注意什么",
"我想问一件关于 TA 的事",
]);
assert.deepEqual(starterQuestions(other, moonOnly), [
"月亮在金牛,小舟是个什么样的人",
"小舟接下来这两年,事业上该注意什么",
"我想问一件关于 TA 的事",
]);
assert.deepEqual(starterQuestions(other, none), [
"从小舟的盘看,小舟是个什么样的人",
"小舟接下来这两年,事业上该注意什么",
"我想问一件关于 TA 的事",
]);
});
test("the third starter question focuses the composer and does not send", () => {
const questions = readFileSync(new URL("../src/components/starter-questions.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const who = readFileSync(new URL("../src/components/onboarding-who-step.tsx", import.meta.url), "utf8");
assert.match(questions, /onClick=\{onNudge\}/);
assert.doesNotMatch(questions, /onAsk\(questions\[2\]\)/);
assert.match(page, /starterHomeVisible && account\?\.firstConsultFree === true/);
assert.match(page, /onAsk=\{\(text\) => void startSuggestedConsultation\(text\)\}/);
assert.match(page, /onNudge=\{nudgeComposer\}/);
const nudge = page.slice(page.indexOf("function nudgeComposer()"), page.indexOf("async function copyAssistantMessage"));
assert.match(nudge, /focus\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(nudge, /startSuggestedConsultation|void send\(|setDraft\(/);
assert.match(who, /你自己的资料可以以后再补。/);
assert.match(who, /TA 叫什么/);
assert.match(who, /PERSON_RELATIONS\.map/);
assert.match(who, /PERSON_RELATION_LABELS\[item\]/);
const relations = readFileSync(new URL("../src/lib/person-relation.ts", import.meta.url), "utf8");
assert.match(relations, /partner: "伴侣"/);
assert.match(relations, /child: "孩子"/);
assert.match(relations, /parent: "父母"/);
assert.match(relations, /friend: "朋友"/);
assert.match(relations, /other: "其他"/);
assert.doesNotMatch(who, /<button/);
});
test("the who-choice hint does nothing when this process has no localStorage", () => {
const source = readFileSync(new URL("../src/lib/onboarding-intake.ts", import.meta.url), "utf8");
assert.match(source, /typeof localStorage === "undefined"/);
if (typeof localStorage === "undefined") {
assert.equal(readOnboardingIntake("fictional-account"), null);
}
});
@@ -180,6 +180,7 @@ test("D3 another person's detail offers 编辑 and 和我合盘, and delete only
assert.match(detail, /class="people-archive-time"><small>排盘时间<\/small><b>10:01<\/b>/);
assert.match(detail, /<dd>Lahiri<\/dd>/);
assert.match(detail, /和我合盘/);
assert.doesNotMatch(detail, /加上你自己后可合盘/);
assert.match(detail, />编辑</);
assert.doesNotMatch(detail, /删除这个人/);
@@ -201,6 +202,23 @@ test("D3 another person's detail offers 编辑 and 和我合盘, and delete only
assert.doesNotMatch(adding, /删除这个人/);
});
test("D3 本人资料不完整时,他人详情把「和我合盘」换成说明", () => {
// 原值: 他人详情始终是可点的「和我合盘」(上一则,本人 incomplete: false)。
// 新值: 本人 incomplete 时该位置是文字「加上你自己后可合盘」,没有「和我合盘」。
// 原因: 先帮别人看时本人出生资料可以后补,合盘要等本人资料齐。
const self = { ...people[0], incomplete: true };
const other = people[1];
const detail = render({
people: [self, other],
view: "detail",
selected: other,
selectedId: other.id,
subjectId: other.id,
});
assert.match(detail, /加上你自己后可合盘/);
assert.doesNotMatch(detail, /和我合盘/);
});
test("D3 the delete-count failure shows under the delete button, not as a form success", () => {
const other = people[1];
const markup = render({
@@ -115,10 +115,13 @@ test("only the reader's own onboarding place save triggers it, after the flow ha
const place = onboarding.slice(onboarding.indexOf("async function saveOnboardingPlace"), onboarding.indexOf("function completeGuidedBirthTime"));
const persisted = place.indexOf("await persistProfile(profileDraft)");
const nextStep = place.indexOf("setOnboardingJustCompleted(false)");
const trigger = place.indexOf("onOwnBirthPlaceSaved?.()");
// 原值: onOwnBirthPlaceSaved?.()
// 新值: onOwnBirthPlaceSaved?.(subjectId)
// 原因: 帮别人看时过场要带上这一位的资料,本人路径 subjectId 仍是 self
const trigger = place.indexOf("onOwnBirthPlaceSaved?.(subjectId)");
assert.ok(persisted > 0 && nextStep > persisted && trigger > nextStep, "trigger runs after the save succeeded and the next step is set");
assert.ok(trigger < place.indexOf("catch (caught)"), "a failed save never triggers it");
assert.equal(onboarding.match(/onOwnBirthPlaceSaved\?\.\(\)/g)?.length, 1);
assert.equal(onboarding.match(/onOwnBirthPlaceSaved\?\.\(subjectId\)/g)?.length, 1);
// Profile edits and the birth-time step do not trigger it.
// 原值: 切出首页 hook 的 `saveProfile` 断言不含 onOwnBirthPlaceSaved
// 新值: 首页 hook 不再有 `saveProfile`;本人编辑在 people-page(下方循环已断言它不触发)
@@ -132,7 +135,10 @@ test("only the reader's own onboarding place save triggers it, after the flow ha
assert.doesNotMatch(readFileSync(new URL(path, import.meta.url), "utf8"), /BirthSkyReveal|useBirthSkyReveal|onOwnBirthPlaceSaved/);
}
assert.match(page, /onOwnBirthPlaceSaved: birthSkyReveal\.begin,/);
assert.match(revealHook, /loadBirthSkyRevealSky\(signal\)/);
// 原值: loadBirthSkyRevealSky(signal)
// 新值: loadBirthSkyRevealSky(signal, id)
// 原因: 过场按当前人物取天空,默认仍是本人
assert.match(revealHook, /loadBirthSkyRevealSky\(signal, id\)/);
});
test("interpolation is pure: t=0 is the seeded scatter, t=1 is the true projection point for point", () => {
@@ -152,10 +152,13 @@ test("incomplete profiles stay in the shared onboarding flow before any open req
page.indexOf("async function openRectificationCase"),
page.indexOf("async function openRectificationFromHomepage"),
);
assert.match(opening, /const missingStep = missingProfileStep\(profile\)/);
// 原值: missingProfileStep(profile)
// 新值: missingProfileStep(profile, chartLibrary)
// 原因: 建完档改为本人完整,或档案里至少一位完整;校正入口用同一口径
assert.match(opening, /const missingStep = missingProfileStep\(profile, chartLibrary\)/);
assert.match(opening, /setOnboardingStep\(missingStep\)/);
assert.ok(
opening.indexOf("const missingStep = missingProfileStep(profile)")
opening.indexOf("const missingStep = missingProfileStep(profile, chartLibrary)")
< opening.indexOf("/api/rectification/cases/open"),
);
assert.match(chat, /payload\?\.code === "profile_incomplete"/);
@@ -6,13 +6,11 @@ const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url),
const shell = readFileSync(new URL("../src/components/home-onboarding-shell.tsx", import.meta.url), "utf8");
test("loads birth-time rectification only when its onboarding stage is reached", () => {
// 原值: dynamic() 写在 page.tsx。
// 新值: dynamic() 在引导屏组件,page.tsx 仍挂载该组件。
// 原因: 首页拆分搬走了引导屏,懒加载本身没变。
// 原值: 引导屏 dynamic() 加载 BirthTimeRectification。
// 新值: 引导屏不再包含 BirthTimeRectification;首页仍挂载 ConversationalBirthTimeRectification。
// 原因: BUG-1431,建档里的校正卡是死代码,校正只从对话进入。
assert.match(page, /<HomeOnboardingShell/);
assert.match(shell, /import dynamic from "next\/dynamic"/);
assert.match(shell, /const BirthTimeRectification = dynamic\(/);
assert.match(shell, /import\("@\/components\/birth-time-rectification"\)/);
assert.match(shell, /ssr: false/);
assert.match(shell, /role="status"/);
assert.match(page, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(shell, /BirthTimeRectification/);
assert.doesNotMatch(shell, /onboardingStep === "rectification"/);
});