首页加上启程清单和一次性提示(#97)。
Independent Staging Quality Gate / validate (push) Failing after 9m6s
Independent Staging Quality Gate / publish (push) Skipped

右上角「启程 n/N」按真实使用打勾,收费项读计价表。八条提示一次一条,看过写入已有的 onboarding_state。没有新表、新接口,也没有增加 Home 的状态。

自测:tsc --noEmit 通过;frontend 下 npm run lint 是 0 error、121 warning;定向测试 94 项全过,清单测试在最后一次改动后再跑 12 项全过;npm run build 后 / 仍是 Static。首屏 JS gzip-9 是 617860 字节,相对 592895 增加 4.21%,原因写在进度里。没有跑全量 npm test,没有跑 Python 快速门,没有宣称 test:db。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-24 23:48:15 +08:00
co-authored by Grok (agent loop)
parent 4d63475caf
commit 8f7c0f7db4
29 changed files with 2073 additions and 18 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-10-24 — 右上角出现启程清单,用过的功能会打勾
- 建完档之后,首页右上角是「启程 n/7」。七项按真实使用打勾。合盘和个人报告写出计价表上的价格;对应开关关着时,这一行不出现。
- 这一次把七项都做完,会再打开一次,写「都走过一遍了」。点「先不看了」之后不再出现。进站时已经都做过的账户不显示。
- 八条提示一次只出一条。点「知道了」、点页面别处或按 Esc 之后不再出现。天空过场、兑换窗、弹窗和回答还在生成时先不出。
- Skill 版本不变(BUG-1432)。
## 2026-10-24 — 建档先问看谁的盘,空白首页有三个起步问题
- 建档从三步变成四步:称呼、看谁的盘、出生日期与时间、出生地。
+8
View File
@@ -76,6 +76,14 @@ _避免使用_:当前星盘、关系、默认星盘
这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。
_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户
**启程清单**:
首页顶栏右侧的「启程 n/N」。七项对应已经能用的功能,勾从真实使用推导,不另存一份进度。点「先不看了」之后不再出现。收费项的价格跟计价表走。
_避免使用_:新手任务、引导任务、单独把进度环叫进度条
**一次性提示**:
第一次用到某处功能时,指着那个位置说的一句。一共八条,同一时刻只出一条。看过就记下来,不再出现。天空过场、兑换窗、弹窗和回答还在生成时先不出。
_避免使用_:新手引导、教程、教学气泡
**星盘档案**:
侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。
_避免使用_:星盘库、星盘资料(设置里的旧面板)
+16
View File
@@ -19398,3 +19398,19 @@
- 复发自:无
- 修复版本:分支 `codex/onboarding-2-intake-20261022`。
## BUG-1432 | 首页启程清单和一次性提示
- 状态:enhancement
- 首次发现:2026-10-24
- 最近更新:2026-10-24
- 影响面:首页顶栏、侧栏星盘入口、星盘四个页签、星盘档案里的「和我合盘」、星历入口。
- 用户现象:建完档之后,顶栏右侧出现「启程 n/N」。七项按真实使用打勾,收费项写出计价表上的价格。八条提示一次只出一条,看过就不再出现。
- 触发条件:档案里至少有一份完整资料,并且没有点过「先不看了」。进站时七项已经都做过的账户不显示。提示在对应的第一次使用时出现,并且当时没有天空过场、兑换窗、弹窗或正在生成的回答。
- 根因:不是缺陷。产品在新用户启程里定了清单和一次性提示(任务书 T5、T6)。勾和「看过」用已有的档案、会话、报告和 `onboarding_state`,不另存进度。
- 修复:清单和提示放在首页外壳的子组件里,不增加 `Home()` 的状态。价格和开关读现有的产品接口,补上报告中心是否开启和报告价格。人物列表补上已有的建档时间,用来决定帮别人看时写谁的名字。没有新表,没有新接口。
- 验证:见 `docs/tasks/PROGRESS-onboarding-3-checklist-20261022.md`。没有浏览器登录态,不把清单走查写成通过。
- 防复发:`frontend/tests/onboarding-checklist-20261022.test.ts` 锁住两条路径的句子、打勾规则、开关造成的行数、提示出现条件和互斥。
- 相关记录:issue #97。任务书 `docs/tasks/TASK-onboarding-journey-20261022.md` 的 T5、T6。前置 #95 的 `onboarding_state`,#96 的建档。
- 复发自:无
- 修复版本:分支 `codex/onboarding-3-checklist-20261022`。
@@ -0,0 +1,63 @@
# PROGRESS · 新用户启程(三)启程清单与一次性提示(2026-10-24)
工单 #97。分支 `codex/onboarding-3-checklist-20261022`。基线 `origin/staging` `4d63475cafe6112ca08432c758676e45e2808c16`(#96 已在这个提交里)。只做任务书 T5、T6。T1–T4 用已合入的字段和页面,没有新迁移。T7 动效不做。
## 结论
建完档之后,首页右上角出现「启程 n/N」。七项按真实使用打勾,合盘和个人报告写出计价表上的价格。八条提示一次只出一条,看过就不再出现。
## 做了什么
- 胶囊在首页顶栏右侧、点数按钮左边。手机是底部抽屉,平板和桌面是弹出层。视口还没分清之前,胶囊不可打开,避免先弹出错误的一层。
- 本人路径和帮别人看的路径,句子、顺序、「做过」的判定和「去做」去哪里,按任务书的表。本人资料完整就用本人的句子;只有别人完整时才用帮别人看的句子。名字用最早建档的那一位完整他人;没有建档时间时用列表里的第一位,再没有就写 TA。
- 「先不看了」写入已有的 `checklistDismissedAt`。七项在这一次使用里刚满时,再打开一次「都走过一遍了」,关掉后同样写入。进站时已经满的账户,胶囊和这一屏都不出现,也不写入。
- 合盘行在 `product.compatibility` 开启时出现,价格读 `synastry.full`。个人报告行在 `product.report_center` 和 `PERSONAL_REPORT_ENABLED` 都开启时出现,有 `report.full` 的价格才写「· N 点」。读不到开关时,这两行不出现,也不把账户当成已经做完。
- 八条提示用同一个 `useOneTimeHint`。点「知道了」、点页面别处或按 Esc 写入已有的 `hintsSeen`。被天空过场、兑换窗、弹窗、抽屉或正在生成的回答挡住时先不出,条件过去再出。提示不抢焦点。
- 问过之后、还没打开星盘时,侧栏「星盘」和手机菜单按钮带一个 8px 小圆点。星盘四个页签没看过时右上角也有小点,各自记住。
- 打开星盘且盘已经出来,写入已有的 `chartVisitedAt`。当前人物不是本人时,本命盘那句的「你的」换成「{名字}的」。
- `Home()` 的 `useState` / `useRef` 没有增加。白名单没有加长。新界面用已有的按钮、抽屉和弹出层。没有改 `.gitea/`,没有改生产部署配置。
## 没有新迁移
读写的是 #95 已有的 `onboarding_state`,以及已有的档案、会话、报告和产品开关。人物列表多返回已有的 `created_at`。产品开关的返回里多了报告中心是否开启和报告价格,原来的合盘字段还在。没有新表,没有新接口,没有改查询写法,没有跑、也没有宣称 `npm run test:db`。
## 断言改动(原值 / 新值 / 原因)
没有改既有断言。新增 `frontend/tests/onboarding-checklist-20261022.test.ts`。星盘页、侧栏和首页增长合同仍按原断言通过。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0。中间曾有档案列表的 `role` 被放宽成 `string`,补上原来的 `PersonRow` 后通过 |
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。0 error,121 warning。与 #96 记下的 121 条相同,没有新增。没有去修无关警告 |
| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 下面 5 个文件 | 94 项,pass 94,fail 0,cancelled 0,skipped 0,退出码 0。清单测试在最后一次改档案页之后又单独跑过,12 项全过 |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。`/` 是 `○` Static。79 页。skill-package-registry 的 5 条 Turbopack 警告是改前就有的 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加 | 基线 `4d63475`:33 个文件,592895 字节。这一张:32 个文件,缺失 0,617860 字节。+24965 字节,+4.21%。±2% 的上限是 604753,超出约 13107 字节 |
定向测试文件:`onboarding-checklist-20261022`、`home-shell-growth-contract`、`sidebar-contract`、`chart-page-view`、`profile-gender-20260927`。
首屏脚本少了一个文件,总体积变大。清单胶囊、八条提示和手机底部抽屉都挂在首页外壳上,首页一打开就要看见胶囊,所以它们进了首屏脚本,没有拆成之后再加载。没有为了凑进 ±2% 去删无关页面。
没有跑前端全量 `npm test`,没有跑 Python 快速门。Python 入口和 `scripts/jyotish_api_server.py` 没有改。
## 限制(验收时会碰到)
- 「问过」只看侧栏已经加载的会话:当前人物,默认每页 40 条。更早的页和其他人的对话不会为了打勾再去翻。标题里有「今日节奏」的算打开过今日星语;消息还没展开时,只要标题对得上也算。
- 星历那条提示的「打开三次」,除了上面这些对话,也会把点「今日星语」按钮算进去。点了但没写成一条对话,清单上的「打开今日星语」仍不算做过。
- 合盘「做过」看已经写好的合盘报告,加上旧的合盘列表里有没有记录。生成中或失败不算。新列表最多看三页、每页 10 条。旧列表只看返回的最多 10 条;只要这 10 条里有一条,就算做过。
- 个人报告「做过」先看本人,没有再按别人逐个看,每人最多三页。更早的一份可能暂时没被数到,这时这一行先不打勾,也不会把账户当成已经做完。
- 合盘或报告的数量读失败时,这一行先显示为没做,胶囊仍在,但不会出现「都走过一遍了」。
- 跟进提示只在这一次回答从生成中变成写完、并且这是第一条正式回答时出现。打开页面时历史上已经有回答,不补这一条。寒暄不算。
- 星历提示要等第三次今日星语之后的下一次打开侧栏。当时侧栏已经开着,要先关上再打开。
## 环境缺口
- 没有登录态,也没有用浏览器走完清单和提示。不能写成「已经在真机上看过」。可照做的步骤在 `docs/testing/onboarding-journey-20261022.md` 的「启程清单」和「一次性提示」。手机 375 宽不横向滚动,由页面 `overflow: clip`、顶栏 `minmax(0, 1fr)` 和清单宽度 `min(22rem, calc(100vw - 2rem))` 约束,测试锁住这些样式,没有在浏览器里量。
- 没有 Docker 上的数据库测试。这一张不改数据库访问层。
## 记录
- BUG-1432,状态 `enhancement`。不是缺陷。
- `CHANGELOG.md`、`frontend/DESIGN.md`、`frontend/docs/VOICE.md`、`CONTEXT.md`、`docs/testing/onboarding-journey-20261022.md`。
- 动效留给后面的单,这一张没有写 650 毫秒、勾飞进环、星点或闪烁。
+26 -2
View File
@@ -1,6 +1,6 @@
# 新用户启程 · 真机清单(2026-10-22)
后面的 issue 往这份清单末尾追加。数据层(issue #95)和建档、起步三问(issue #96)按下面的条目验收。启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。
后面的 issue 往这份清单末尾追加。数据层(issue #95)、建档和起步三问(issue #96)、启程清单和一次性提示(issue #97)按下面的条目验收。动效还没做,不要在这一轮里验收它。
用新注册的邮箱。不要把真实出生资料写进记录。
@@ -38,7 +38,31 @@
3. 回到空白首页(新开一条空对话),三个问题不再出现。
4. 另开一个还没提问的账户。点第三个问题:输入框亮一下并且可以打字,不会自动把问题发出去。
## 启程清单
用建完档的账户。出生日期用虚构日子。合盘和个人报告都开着时,总数是 7;关掉一项变成 6,都关掉变成 5。价格跟当时的计价表,不要写成写死的数。现在合盘是 19 点。
1. 新账户建完档、还没正式提问。首页右上角是「启程 1/7」。点开后,本人路径按这个顺序:排好自己的盘(已勾,没有「去做」)、问第一个问题、打开今日星语、看看自己的星盘、加一位家人或朋友、和 TA 合一次盘 · N 点、生成个人报告。报告有价格才出现「· N 点」。
2. 另开一个只帮别人看、自己资料还没齐的账户。句子换成:排好{名字}的盘、问第一个问题、打开今日星语、看看星盘、加上你自己、和{名字}合一次盘 · N 点、生成个人报告。今日星语的「去做」去星盘档案。再加上第二位别人,不算做完「加上你自己」。
3. 本人和别人的资料都齐时,仍用本人那一套句子。
4. 只寒暄,不算问过。只有自己一个人,不算加过家人或朋友。
5. 老账户如果只差看星盘和今日星语,胶囊是「启程 5/7」(七项都开着时)。
6. 进站时七项已经都做过的账户,胶囊不出现,也不出现「都走过一遍了」。
7. 在这一次使用里做完最后一项:清单再打开一次。标题是「都走过一遍了」,正文是「以后有新东西,我会在用到的时候再告诉你。」关掉之后胶囊消失。
8. 点「先不看了」,刷新后胶囊不再出现。
9. 手机宽 375:清单从底部拉出,句子换行,页面不横向滚动。平板和桌面是顶栏旁边的弹出层。
10. 合盘开关关闭时没有合盘那一行。个人报告开关关闭时没有报告那一行。
## 一次性提示
1. 第一次正式回答刚写完,追问下面出现「想接着聊,点下面任一个就直接问。」寒暄不算。以前已经问过的账户,打开页面时不补这一条。
2. 看过上一句、还没打开过星盘、侧栏开着时,星盘菜单旁出现「你的盘、大运和分盘都在这里。」侧栏「星盘」和手机上的菜单按钮有一个小圆点。打开过星盘后圆点消失。
3. 星盘页四个页签,没看过的右上角有小点。四句分别是:本命盘那句、原始资料那句、大运那句、西洋盘那句。当前人物不是本人时,本命盘那句的「你的」换成「{名字}的」。换页签不把上一页记成看过。
4. 档案里有两份完整资料、还没有合盘、并且合盘开着时,指着「和我合盘」说「有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。」合盘关着不出现。
5. 打开今日星语满三次之后,下一次打开侧栏,星历旁出现「每天的天象和行运在这里,可以按日期翻。」第三次发生时侧栏已经开着,要先关上再打开才出现。
6. 同一时刻只有一条。天空过场、兑换窗、任何弹窗或抽屉、回答还在生成时,提示不出现。这些过去之后再出现,不丢。
7. 点「知道了」、点页面别处或按 Esc,这一条消失。刷新后不再出现。提示出现时,输入框里的光标不被抢走。
## 以后追加
- (三)启程清单、一次性提示
- (四)动效,以及打开「减少动态效果」再走一遍
+11 -1
View File
@@ -1,5 +1,15 @@
# Jyotisha Web Design System
## 启程清单与一次性提示(2026-10-24)
首页顶栏右侧、点数按钮左边,是一枚胶囊「启程 n/N」(`.onboarding-checklist-trigger`)。它沿用顶栏按钮的 32px 高度,圆角 999px,字和图标间距 6px。左边是 18×18 的进度环,描边按已完成的项画满。视口还没分出手机或桌面时,这枚胶囊不可打开。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。
七项在这一次使用里刚满时,抽屉或弹出层再打开一次:标题「都走过一遍了」,正文「以后有新东西,我会在用到的时候再告诉你。」关掉后胶囊消失。不另做星点。进站时已经满的账户,胶囊和这一屏都不出现。
一次性提示用现有弹出层,宽 `min(18rem, calc(100vw - 2rem))`,不抢焦点。侧栏两项从锚点右侧伸出,其余从上方伸出,间距 8px。按钮是「知道了」。同一时刻最多一条。天空过场、兑换窗、任何弹窗或抽屉、启程清单正打开、回答还在生成时,提示不出;挡着的那一条等条件过去再出。侧栏「星盘」、手机菜单和星盘四个页签上的小圆点是 8px,贴在右上角 4px,不是字符。
不新增动效时长。弹出层和抽屉仍走现有的 120ms / 180ms。
## 婚恋合盘报告(2026-10-19)
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
@@ -949,7 +959,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`),内容到达后静默替换。登录后的空首页是问候语、今日星语和输入框,没有主题卡,也没有生时校正卡(2026-10-07,BUG-1247)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `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)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。已经建完档、还没点过「先不看了」、并且不是进站时就已经七项都做过时,顶栏右侧有「启程 n/N」。尺寸、抽屉和提示互斥见文首「启程清单与一次性提示」。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
### 首屏死屏兜底
+25
View File
@@ -23,6 +23,31 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
**起步三问例外(2026-10-24)。** 只在已经建完档、首问还没用掉、并且当前是空白首页时,问候下面可以多一行「盘排好了。先问一个问题吧。」(帮别人看则是「{名字}的盘排好了。先问一个关于 TA 的问题吧。」),再加一枚「这一次不扣点」和三个起步问题。帮别人看时再加一枚「正在看:{名字} · {关系}」。这三问消失之后,首页回到上面这一行的规则。
**启程清单与一次性提示(2026-10-24)。** 顶栏胶囊只写「启程 n/N」。清单里的句子只用下面这些,收费项的价格从计价表读,没有价格就不写「· N 点」:
- 排好自己的盘 / 排好{名字}的盘
- 问第一个问题
- 打开今日星语
- 看看自己的星盘 / 看看星盘
- 加一位家人或朋友 / 加上你自己
- 和 TA 合一次盘 / 和{名字}合一次盘
- 生成个人报告
- 去做
- 先不看了
- 都走过一遍了
- 以后有新东西,我会在用到的时候再告诉你。
一次只出一条提示,按钮只写「知道了」:
- 想接着聊,点下面任一个就直接问。
- 你的盘、大运和分盘都在这里。
- 这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。当前人物不是本人时,这句里的「你的」改成「{名字}的」。
- 排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。
- 一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。
- 同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。
- 有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。
- 每天的天象和行运在这里,可以按日期翻。
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
## 婚恋合盘报告(2026-10-19)
+5
View File
@@ -4,8 +4,10 @@ import type { ReactNode } from "react";
import { AccountDeletionGate } from "@/components/account-deletion-gate";
import { AppSidebar } from "@/components/app-sidebar";
import { OnboardingHintLayer } from "@/components/onboarding-hint-layer";
import { LegalConsentGate } from "@/components/legal/legal-consent-gate";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { OnboardingJourneyProvider } from "@/lib/onboarding-journey";
import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list";
import "../site-styles";
@@ -17,6 +19,7 @@ function AppShell({ children }: { children: ReactNode }) {
return (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<OnboardingJourneyProvider>
<main className="chat-app">
<AppSidebar
sessions={sessions}
@@ -36,6 +39,8 @@ function AppShell({ children }: { children: ReactNode }) {
</SidebarInset>
<LegalConsentGate signedIn={Boolean(account) && !list.signedOut} />
</main>
<OnboardingHintLayer />
</OnboardingJourneyProvider>
<AccountDeletionGate signedIn={Boolean(account)} />
</SidebarProvider>
);
+19 -1
View File
@@ -40,9 +40,13 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals";
import { personRelationLabel } from "@/lib/person-relation";
import { readStarterSignMap, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
import { OnboardingChecklistEntry } from "@/components/onboarding-checklist";
import { OnboardingChartDot } from "@/components/onboarding-dot";
import { OnboardingHomeBridge } from "@/components/onboarding-home-bridge";
import { AccountDialogDeepLink, SynastryDeepLink } from "@/components/people-home-links";
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
import { StarterQuestions } from "@/components/starter-questions";
@@ -1010,6 +1014,7 @@ export default function Home() {
}
function startDailyStarlanguageConsultation() {
notifyOnboardingDailyOpened();
void startSuggestedConsultation(
dailyStarlanguageQuestion,
"timing",
@@ -1185,6 +1190,15 @@ export default function Home() {
natalMinuteAvailable={natalMinuteAvailable}
setDailyStarlanguage={setDailyStarlanguage}
/>
<OnboardingHomeBridge
inputRef={composerInput}
startDaily={startDailyStarlanguageConsultation}
streaming={consultationPhase === "streaming" || consultationPhase === "recovering"}
streamingSessionId={activeSessionId}
skyOpen={birthSkyReveal.cover != null}
dialogOpen={activeAccountDialog !== null || onboardingPaywallOpen || Boolean(pendingSessionDeletion)}
profile={profile}
/>
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
<AccountDialogDeepLink ready={hydrated && Boolean(account)} open={openAccountDialog} />
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
@@ -1205,7 +1219,10 @@ export default function Home() {
</div>
) : null}
<header className="chat-header">
<SidebarTrigger placement="inset" />
<span className="onboarding-menu-anchor">
<SidebarTrigger placement="inset" />
<OnboardingChartDot />
</span>
<div className="chat-header-title">
<strong>{activeSession?.title || "新对话"}</strong>
{activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
@@ -1223,6 +1240,7 @@ export default function Home() {
) : null}
</div>
<div className="chat-header-actions">
<OnboardingChecklistEntry />
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
<button className="credit-button" type="button" onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Coins className="credit-icon" aria-hidden="true" />
@@ -39,6 +39,7 @@ function toClientProfile(row: Record<string, unknown>) {
role: "other",
profile: libraryProfileFromSubjectRow(row),
updated_at: row.updated_at,
created_at: row.created_at,
};
}
+24 -5
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { isPersonalReportFeatureEnabled } from "@/lib/personal-report-entitlement";
import { isProductEnabled } from "@/lib/product-access";
import { consumeRequestRateLimit } from "@/lib/request-rate-limit";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
@@ -28,16 +29,34 @@ export async function GET(request: Request) {
);
}
const compatibility = await isProductEnabled("compatibility");
const reportCenter = (await isProductEnabled("report_center")) && isPersonalReportFeatureEnabled(process.env);
let synastryCreditCost: number | null = null;
if (compatibility) {
let reportCreditCost: number | null = null;
if (compatibility || reportCenter) {
try {
const catalog = await loadLanguageModelCatalog();
if (!catalog.defaultModelId) throw new FeaturePricingError("feature_pricing_invalid_response");
const pricing = await resolveFeaturePricing(createAdminSupabaseClient(), "synastry.full", catalog.defaultModelId);
synastryCreditCost = pricing.credit_cost;
const client = createAdminSupabaseClient();
if (compatibility) {
try {
synastryCreditCost = (await resolveFeaturePricing(client, "synastry.full", catalog.defaultModelId)).credit_cost;
} catch (error) {
if (!(error instanceof FeaturePricingError)) synastryCreditCost = null;
}
}
if (reportCenter) {
try {
reportCreditCost = (await resolveFeaturePricing(client, "report.full", catalog.defaultModelId)).credit_cost;
} catch (error) {
if (!(error instanceof FeaturePricingError)) reportCreditCost = null;
}
}
} catch (error) {
if (!(error instanceof FeaturePricingError)) synastryCreditCost = null;
if (!(error instanceof FeaturePricingError)) {
synastryCreditCost = null;
reportCreditCost = null;
}
}
}
return NextResponse.json({ compatibility, synastryCreditCost });
return NextResponse.json({ compatibility, synastryCreditCost, reportCenter, reportCreditCost });
}
+54
View File
@@ -1077,6 +1077,60 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
content: "";
}
.chat-header-actions button > svg { width: 14px; height: 14px; }
.onboarding-menu-anchor { position: relative; display: inline-flex; }
.chat-header-actions button.onboarding-checklist-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas-muted);
color: var(--color-ink);
}
.chat-header-actions button.onboarding-checklist-trigger > svg { width: 18px; height: 18px; }
.onboarding-checklist-popup,
.onboarding-checklist-panel,
.onboarding-checklist-sheet {
width: min(22rem, calc(100vw - 2rem));
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
.onboarding-checklist-sheet { width: 100%; }
.onboarding-checklist-panel { display: grid; gap: 12px; padding: 4px 4px 8px; }
.onboarding-checklist-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.onboarding-checklist-row {
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: center;
}
.onboarding-checklist-copy { min-width: 0; overflow-wrap: anywhere; }
.onboarding-checklist-title { margin: 0; font-size: var(--type-body-md); font-weight: 500; }
.onboarding-checklist-body { margin: 0; color: var(--color-ink-secondary); overflow-wrap: anywhere; }
.onboarding-checklist-mark { width: 1rem; height: 1rem; }
.onboarding-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--color-action);
pointer-events: none;
}
.onboarding-menu-anchor .onboarding-dot,
.chart-page-tab .onboarding-dot,
[data-sidebar="menu-button"] .onboarding-dot {
position: absolute;
top: 4px;
right: 4px;
}
.chart-page-tab,
[data-sidebar="menu-button"].report-nav-button { position: relative; }
.onboarding-hint {
width: min(18rem, calc(100vw - 2rem));
max-width: 100%;
overflow-wrap: anywhere;
}
.onboarding-hint p { margin: 0; }
/* Header chips share one size. `.chat-header-actions button` is 32px and
more specific than `.credit-button`, which is why the credit pill looked
shorter than 「当前盘面」(BUG-921). */
+6
View File
@@ -22,8 +22,10 @@ import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import { AppLink } from "@/components/app-link";
import { OnboardingDot } from "@/components/onboarding-dot";
import { accountDialogHref, type AccountDialogIntent } from "@/lib/account-dialog-intent";
import { newChatHref, sessionHref } from "@/lib/chat-session-url";
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
import { prefetchBillingPanel } from "@/lib/billing-panel-loader";
import { prefetchPaymentPackages } from "@/lib/payment-packages-cache";
@@ -125,6 +127,7 @@ export function AppSidebar({
controls,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const showChartDot = useOnboardingJourneyOptional()?.showChartDot ?? false;
/* Null outside an app-router context (and in unit tests that mount this
component directly), even though the type says string. */
const pathname = usePathname() ?? "";
@@ -305,9 +308,12 @@ export function AppSidebar({
onPointerEnter={() => prefetchSecondaryPage(href)}
onPointerDown={() => prefetchSecondaryPage(href)}
onClick={leaveChat}
{...(href === "/chart" ? { "data-onboarding-anchor": "sidebar-chart" } : {})}
{...(href === "/ephemeris" ? { "data-onboarding-anchor": "ephemeris" } : {})}
>
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
{href === "/chart" && showChartDot ? <OnboardingDot /> : null}
</SidebarMenuLink>
</SidebarMenuItem>
))}
@@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Pencil } from "lucide-react";
import { OnboardingDot } from "@/components/onboarding-dot";
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
@@ -17,6 +19,7 @@ import {
} from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels";
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
import { CHART_SETTINGS_HREF } from "@/lib/settings-url";
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { ChartPageSkeleton } from "./chart-page-skeleton";
@@ -93,10 +96,31 @@ export function ChartPageView({
onBirthSaved?: () => Promise<void> | void;
}) {
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const journey = useOnboardingJourneyOptional();
const setJourneyTab = journey?.setChartTab;
const markChartVisited = journey?.markChartVisited;
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
const [editingBirth, setEditingBirth] = useState(false);
const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara");
useEffect(() => {
if (!setJourneyTab) return;
const name = view?.status === "ok" && view.subjectId && view.subjectId !== "self"
? view.profile.name.trim() || null
: null;
const tabId = view?.status === "ok" ? tab : null;
queueMicrotask(() => {
if (view?.status === "ok") markChartVisited?.();
setJourneyTab(tabId, name);
});
}, [markChartVisited, setJourneyTab, tab, view]);
useEffect(() => {
return () => {
queueMicrotask(() => setJourneyTab?.(null, null));
};
}, [setJourneyTab]);
function selectTab(id: ChartViewTabId) {
setTab(id);
if (id === "dasha") onNeedLayer?.("chara");
@@ -178,10 +202,11 @@ export function ChartPageView({
onClick={() => selectTab(item.id)}
>
{item.label}
{journey?.tabHasDot(item.id) ? <OnboardingDot /> : null}
</button>
))}
</div>
<section className="chart-page-panel" role="tabpanel">
<section className="chart-page-panel" role="tabpanel" data-onboarding-anchor="chart-tab">
{view == null ? <ChartPageSkeleton copy={CHART_VIEW_COPY.waitingChart} /> : <>
{tab === "vedic" ? (
<ChartVedicTab
@@ -11,7 +11,7 @@ export function ConversationFollowUps({
}>) {
if (questions.length === 0) return null;
return (
<div className="conversation-follow-ups" aria-label="可以继续问">
<div className="conversation-follow-ups" aria-label="可以继续问" data-onboarding-anchor="follow-up">
{questions.map((question) => (
<button
key={question}
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { OnboardingProgressRing } from "@/components/onboarding-progress-ring";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import { Popover, PopoverContent, PopoverDescription, PopoverTrigger } from "@/components/ui/popover";
import { useSidebar } from "@/components/ui/sidebar";
import {
CHECKLIST_CELEBRATION_BODY,
CHECKLIST_CELEBRATION_TITLE,
CHECKLIST_DISMISS_LABEL,
type ChecklistItem,
} from "@/lib/onboarding-checklist";
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
function ChecklistRows({ items, onGo }: { readonly items: readonly ChecklistItem[]; readonly onGo: (item: ChecklistItem) => void }) {
return (
<ul className="onboarding-checklist-rows">
{items.map((item) => (
<li key={item.id} className="onboarding-checklist-row" data-done={item.done ? "true" : "false"}>
{item.done ? <Check aria-hidden="true" /> : <span className="onboarding-checklist-mark" aria-hidden="true" />}
<span className="onboarding-checklist-copy">{item.label}</span>
{!item.done && item.go ? (
<Button variant="outline" size="sm" onClick={() => onGo(item)}>去做</Button>
) : <span />}
</li>
))}
</ul>
);
}
function ChecklistBody({
celebration,
items,
label,
onDismiss,
onGo,
}: {
readonly celebration: boolean;
readonly items: readonly ChecklistItem[];
readonly label: string;
readonly onDismiss: () => void;
readonly onGo: (item: ChecklistItem) => void;
}) {
if (celebration) {
return (
<div className="onboarding-checklist-panel">
<h2 className="onboarding-checklist-title">{CHECKLIST_CELEBRATION_TITLE}</h2>
<p className="onboarding-checklist-body">{CHECKLIST_CELEBRATION_BODY}</p>
<Button variant="outline" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
</div>
);
}
return (
<div className="onboarding-checklist-panel">
<h2 className="sr-only">{label}</h2>
<ChecklistRows items={items} onGo={onGo} />
<Button variant="ghost" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
</div>
);
}
function ChecklistFace({ label, done, total }: { readonly label: string; readonly done: number; readonly total: number }) {
return (
<>
<OnboardingProgressRing done={done} total={total} />
<span>{label}</span>
</>
);
}
export function OnboardingChecklistEntry() {
const journey = useOnboardingJourneyOptional();
const { viewport, ready } = useSidebar();
const [open, setOpen] = useState(false);
const celebrated = useRef(false);
const mobile = ready && viewport === "mobile";
useEffect(() => {
if (!journey?.celebration || !ready || celebrated.current) return;
celebrated.current = true;
queueMicrotask(() => {
setOpen(true);
journey.setChecklistOpen(true);
});
}, [journey, ready]);
if (!journey?.showChecklist) return null;
const done = journey.items.filter((item) => item.done).length;
const total = journey.items.length;
const face = <ChecklistFace label={journey.label} done={done} total={total} />;
function changeOpen(next: boolean) {
setOpen(next);
journey?.setChecklistOpen(next);
if (!next && journey?.celebration) journey.dismissChecklist();
}
function dismiss() {
setOpen(false);
journey?.setChecklistOpen(false);
journey?.dismissChecklist();
}
function onGo(item: ChecklistItem) {
setOpen(false);
journey?.setChecklistOpen(false);
journey?.go(item);
}
const body = (
<ChecklistBody
celebration={journey.celebration}
items={journey.items}
label={journey.label}
onDismiss={dismiss}
onGo={onGo}
/>
);
if (!ready) {
return (
<Button variant="ghost" className="onboarding-checklist-trigger" disabled>
{face}
</Button>
);
}
if (mobile) {
return (
<Drawer open={open} onOpenChange={changeOpen} swipeDirection="down" showSwipeHandle>
<DrawerTrigger className="onboarding-checklist-trigger">{face}</DrawerTrigger>
<DrawerContent className="onboarding-checklist-sheet">
<DrawerHeader className="sr-only">
<DrawerTitle>{journey.celebration ? CHECKLIST_CELEBRATION_TITLE : journey.label}</DrawerTitle>
{journey.celebration ? <DrawerDescription>{CHECKLIST_CELEBRATION_BODY}</DrawerDescription> : null}
</DrawerHeader>
{body}
<DrawerFooter />
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={changeOpen}>
<PopoverTrigger className="onboarding-checklist-trigger">{face}</PopoverTrigger>
<PopoverContent align="end" side="bottom" sideOffset={8} className="onboarding-checklist-popup">
{journey.celebration ? (
<PopoverDescription className="sr-only">{CHECKLIST_CELEBRATION_BODY}</PopoverDescription>
) : null}
{body}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,13 @@
"use client";
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
export function OnboardingDot() {
return <span className="onboarding-dot" aria-hidden="true" />;
}
export function OnboardingChartDot() {
const journey = useOnboardingJourneyOptional();
if (!journey?.showChartDot) return null;
return <OnboardingDot />;
}
@@ -0,0 +1,99 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent } from "@/components/ui/popover";
import { HINT_ANCHORS, HINT_DISMISS_LABEL } from "@/lib/onboarding-hints";
import { useOnboardingJourney } from "@/lib/onboarding-journey";
import type { OnboardingHintId } from "@/lib/onboarding-state";
const ANCHOR_NAMES = ["follow-up", "sidebar-chart", "chart-tab", "syn-ready", "ephemeris"] as const;
function userDismissReason(reason: string): boolean {
return reason === "escape-key" || reason === "outside-press" || reason === "close-press";
}
export function OnboardingHintLayer() {
const journey = useOnboardingJourney();
const hint = journey.activeHint;
const setLive = journey.setLive;
const dismissHint = journey.dismissHint;
const [anchor, setAnchor] = useState<Element | null>(null);
const dismissed = useRef<string | null>(null);
const dismiss = useCallback((id: OnboardingHintId) => {
if (dismissed.current === id) return;
dismissed.current = id;
void dismissHint(id).then((ok) => {
if (!ok && dismissed.current === id) dismissed.current = null;
});
}, [dismissHint]);
useEffect(() => {
const scan = () => {
const anchors: Record<string, boolean> = {};
for (const name of ANCHOR_NAMES) {
anchors[name] = document.querySelector(`[data-onboarding-anchor="${name}"]`) != null;
}
let foreignDialog = false;
document.querySelectorAll('[role="dialog"],[role="alertdialog"],[data-slot="drawer-popup"]').forEach((node) => {
if (!(node instanceof Element) || node.closest("[data-onboarding-hint]")) return;
foreignDialog = true;
});
setLive({ anchors, foreignDialog });
const next = hint ? document.querySelector(`[data-onboarding-anchor="${HINT_ANCHORS[hint]}"]`) : null;
setAnchor((current) => current === next ? current : next);
};
const timer = window.setTimeout(scan, 0);
const observer = new MutationObserver(scan);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["role", "data-slot", "hidden", "open", "data-onboarding-anchor", "data-onboarding-hint"] });
return () => {
window.clearTimeout(timer);
observer.disconnect();
};
}, [hint, setLive]);
useEffect(() => {
if (!hint || !anchor) return;
const onPointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (document.querySelector("[data-onboarding-hint]")?.contains(target)) return;
dismiss(hint);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") dismiss(hint);
};
document.addEventListener("pointerdown", onPointer, true);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointer, true);
document.removeEventListener("keydown", onKey);
};
}, [hint, anchor, dismiss]);
if (!hint || !anchor) return null;
const side = HINT_ANCHORS[hint] === "sidebar-chart" || HINT_ANCHORS[hint] === "ephemeris" ? "right" : "top";
return (
<Popover
open
modal={false}
onOpenChange={(next, details) => {
if (!next && userDismissReason(details.reason)) dismiss(hint);
}}
>
<PopoverContent
anchor={anchor}
initialFocus={false}
finalFocus={false}
side={side}
sideOffset={8}
className="onboarding-hint"
data-onboarding-hint=""
>
<p>{journey.hintText}</p>
<Button variant="outline" size="sm" onClick={() => dismiss(hint)}>{HINT_DISMISS_LABEL}</Button>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import { selfChartComplete } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
export function OnboardingHomeBridge({
inputRef,
startDaily,
streaming,
streamingSessionId,
skyOpen,
dialogOpen,
profile,
}: {
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
readonly startDaily: () => void;
readonly streaming: boolean;
readonly streamingSessionId: string | null;
readonly skyOpen: boolean;
readonly dialogOpen: boolean;
readonly profile: Profile;
}) {
const journey = useOnboardingJourneyOptional();
const startDailyRef = useRef(startDaily);
const consumedQuery = useRef(false);
useEffect(() => {
startDailyRef.current = startDaily;
}, [startDaily]);
useEffect(() => {
if (!journey) return;
journey.registerHome({
focusComposer() {
inputRef.current?.focus({ preventScroll: true });
},
startDaily() {
startDailyRef.current();
},
});
return () => journey.registerHome(null);
}, [journey, inputRef]);
useEffect(() => {
journey?.setLive({
streaming,
streamingSessionId: streaming ? streamingSessionId : null,
skyOpen,
dialogOpen,
selfProfile: profile,
});
}, [journey, streaming, streamingSessionId, skyOpen, dialogOpen, profile]);
useEffect(() => {
if (!journey || consumedQuery.current) return;
const url = new URL(window.location.href);
const compose = url.searchParams.get("compose") === "1";
const daily = url.searchParams.get("daily") === "1";
if (!compose && !daily) return;
consumedQuery.current = true;
url.searchParams.delete("compose");
url.searchParams.delete("daily");
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
if (compose) inputRef.current?.focus({ preventScroll: true });
if (!daily) return;
if (!selfChartComplete(profile)) {
window.location.assign("/people");
return;
}
startDailyRef.current();
}, [journey, profile, inputRef]);
return null;
}
@@ -0,0 +1,22 @@
export function OnboardingProgressRing({ done, total }: { readonly done: number; readonly total: number }) {
const radius = 7;
const circumference = 2 * Math.PI * radius;
const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
return (
<svg className="onboarding-progress-ring" viewBox="0 0 18 18" aria-hidden="true">
<circle cx="9" cy="9" r={radius} fill="none" stroke="currentColor" strokeOpacity="0.28" strokeWidth="2" />
<circle
cx="9"
cy="9"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={circumference * (1 - ratio)}
transform="rotate(-90 9 9)"
/>
</svg>
);
}
+28 -3
View File
@@ -106,10 +106,35 @@ export function PeoplePage() {
});
accountIdRef.current = account.user.id;
bindCurrentSubjectAccount(account.user.id, others.map((person) => person.id));
setPeople([
const nextPeople: PersonRow[] = [
{ id: "self", role: "self", profile: self, incomplete: incompleteProfile(self) },
...others,
]);
];
setPeople(nextPeople);
const url = new URL(window.location.href);
const add = url.searchParams.get("add") === "1";
const openSelf = url.searchParams.get("self") === "1";
if (add || openSelf) {
url.searchParams.delete("add");
url.searchParams.delete("self");
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
if (openSelf) {
const selfRow = nextPeople.find((person) => person.role === "self");
setSelectedId("self");
setDraft(selfRow?.profile ?? emptyProfile);
setEditing(true);
setPendingDelete(null);
setView("detail");
} else if (nextPeople.filter((person) => person.role === "other").length >= MAX_OTHER_SUBJECTS) {
notifyInfo(LIMIT_COPY);
} else {
setDraft(emptyProfile);
setEditing(true);
setPendingDelete(null);
setSelectedId("");
setView("detail");
}
}
setStatus("ready");
} catch {
setStatus("failed");
@@ -490,7 +515,7 @@ export function PeopleArchiveView({
</div>
{selected.role === "other" ? (
people.some((person) => person.role === "self" && !person.incomplete)
? <button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</button>
? <button type="button" className="people-archive-secondary" data-onboarding-anchor="syn-ready" 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. */}
+5 -1
View File
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingChartDot } from "@/components/onboarding-dot";
import { SidebarTrigger } from "@/components/ui/sidebar";
/**
@@ -35,7 +36,10 @@ export function SecondaryHeader({ title, actions, note, besideTitle, reserveNote
const showNote = Boolean(note) || reserveNote;
return (
<header className="chat-header has-title">
<SidebarTrigger placement="inset" />
<span className="onboarding-menu-anchor">
<SidebarTrigger placement="inset" />
<OnboardingChartDot />
</span>
<div className="chat-header-title">
<strong>{title}</strong>
{besideTitle}
+3 -1
View File
@@ -19,15 +19,17 @@ function PopoverContent({
alignOffset = 0,
side = "bottom",
sideOffset = 4,
anchor,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
anchor={anchor}
align={align}
alignOffset={alignOffset}
side={side}
+239
View File
@@ -0,0 +1,239 @@
import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
export const CHECKLIST_ITEM_IDS = ["self", "ask", "daily", "chart", "person", "syn", "report"] as const;
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
export type ChecklistPath = "self" | "other";
export type ChecklistPerson = {
readonly id: string;
readonly role: "self" | "other";
readonly name: string;
readonly complete: boolean;
readonly createdAt: number | null;
};
export type ChecklistFlags = {
readonly compatibility: boolean | null;
readonly reportCenter: boolean | null;
readonly synastryCreditCost: number | null;
readonly reportCreditCost: number | null;
};
export type ChecklistGo =
| { readonly kind: "compose" }
| { readonly kind: "daily" }
| { readonly kind: "href"; readonly href: string };
export type ChecklistItem = {
readonly id: ChecklistItemId;
readonly label: string;
readonly done: boolean;
readonly go: ChecklistGo | null;
};
export type ChecklistFacts = {
readonly people: readonly ChecklistPerson[];
readonly profileComplete: boolean;
readonly hasNonSmalltalkAnswer: boolean;
readonly dailyCount: number;
readonly chartVisited: boolean;
readonly synastryReportCount: number;
readonly personalReportCount: number;
readonly flags: ChecklistFlags;
};
export type AnswerTurn = {
readonly role: string;
readonly text?: string;
readonly responseKind?: string;
readonly rectificationReturn?: unknown;
};
export type DailySession = {
readonly id?: string;
readonly title?: string;
readonly messagesHydrated?: boolean;
readonly messages?: readonly AnswerTurn[];
};
export const CHECKLIST_CELEBRATION_TITLE = "都走过一遍了";
export const CHECKLIST_CELEBRATION_BODY = "以后有新东西,我会在用到的时候再告诉你。";
export const CHECKLIST_DISMISS_LABEL = "先不看了";
const SELF_ORDER: readonly ChecklistItemId[] = ["self", "ask", "daily", "chart", "person", "syn", "report"];
const OTHER_ORDER: readonly ChecklistItemId[] = ["self", "ask", "person", "daily", "chart", "syn", "report"];
export function isCompleteNonSmalltalkAnswer(turn: AnswerTurn): boolean {
if (turn.rectificationReturn) return false;
return turn.role === "assistant"
&& typeof turn.text === "string"
&& turn.text.trim().length > 0
&& turn.responseKind !== "smalltalk";
}
export function sessionCountsAsDaily(session: DailySession): boolean {
const titled = Boolean(session.title?.includes("今日节奏"));
const asked = (session.messages ?? []).some((message) => (
message.role === "user" && isGeneralDailyFortuneQuestion(message.text ?? "")
));
if (!titled && !asked) return false;
if (session.messagesHydrated === false) return titled;
return (session.messages ?? []).some(isCompleteNonSmalltalkAnswer);
}
export function countDailySessions(sessions: readonly DailySession[]): number {
return sessions.filter(sessionCountsAsDaily).length;
}
/** A still-streaming reply does not count. `firstConsultFree === false` counts only after the stream settles. */
export function hasCompleteNonSmalltalkAnswer(input: {
readonly sessions: readonly DailySession[];
readonly streaming?: boolean;
readonly streamingSessionId?: string | null;
readonly firstConsultFree?: boolean | null;
}): boolean {
const streaming = Boolean(input.streaming);
const fromMessages = input.sessions.some((session) => {
if (session.messagesHydrated === false) return false;
let messages = session.messages ?? [];
if (streaming && input.streamingSessionId && session.id === input.streamingSessionId) {
const last = messages[messages.length - 1];
if (last?.role === "assistant") messages = messages.slice(0, -1);
}
return messages.some(isCompleteNonSmalltalkAnswer);
});
if (fromMessages) return true;
if (streaming) return false;
return input.firstConsultFree === false;
}
/** Both people complete → self copy. Help-other only when self is still incomplete. */
export function checklistPath(people: readonly ChecklistPerson[]): ChecklistPath {
return people.some((person) => person.role === "self" && person.complete) ? "self" : "other";
}
/** Earliest filed complete other person. Blank or missing timestamps fall back to "TA". */
export function helpedName(people: readonly ChecklistPerson[]): string {
const others = people.filter((person) => person.role === "other" && person.complete);
if (others.length === 0) return "TA";
const dated = others.filter((person) => person.createdAt != null);
const pool = dated.length > 0
? [...dated].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
: others;
const name = pool[0]?.name.trim();
return name || "TA";
}
function withPrice(base: string, price: number | null): string {
return price == null ? base : `${base} · ${price} 点`;
}
function personDone(path: ChecklistPath, people: readonly ChecklistPerson[]): boolean {
if (path === "other") return people.some((person) => person.role === "self" && person.complete);
return people.filter((person) => person.complete).length >= 2;
}
export function buildChecklist(facts: ChecklistFacts): ChecklistItem[] {
const path = checklistPath(facts.people);
const name = helpedName(facts.people);
const order = path === "other" ? OTHER_ORDER : SELF_ORDER;
const synBase = path === "self" ? "和 TA 合一次盘" : `和${name}合一次盘`;
const items: ChecklistItem[] = [];
for (const id of order) {
if (id === "syn" && facts.flags.compatibility !== true) continue;
if (id === "report" && facts.flags.reportCenter !== true) continue;
items.push(itemFor(id, path, name, synBase, facts));
}
return items;
}
function itemFor(
id: ChecklistItemId,
path: ChecklistPath,
name: string,
synBase: string,
facts: ChecklistFacts,
): ChecklistItem {
switch (id) {
case "self":
return {
id,
label: path === "self" ? "排好自己的盘" : `排好${name}的盘`,
done: facts.profileComplete,
go: null,
};
case "ask":
return { id, label: "问第一个问题", done: facts.hasNonSmalltalkAnswer, go: { kind: "compose" } };
case "daily":
return {
id,
label: "打开今日星语",
done: facts.dailyCount >= 1,
go: path === "self" ? { kind: "daily" } : { kind: "href", href: "/people" },
};
case "chart":
return {
id,
label: path === "self" ? "看看自己的星盘" : "看看星盘",
done: facts.chartVisited,
go: { kind: "href", href: "/chart" },
};
case "person":
return {
id,
label: path === "self" ? "加一位家人或朋友" : "加上你自己",
done: personDone(path, facts.people),
go: path === "self"
? { kind: "href", href: "/people?add=1" }
: { kind: "href", href: "/people?self=1" },
};
case "syn":
return {
id,
label: withPrice(synBase, facts.flags.synastryCreditCost),
done: facts.synastryReportCount >= 1,
go: { kind: "href", href: "/people" },
};
case "report":
return {
id,
label: withPrice("生成个人报告", facts.flags.reportCreditCost),
done: facts.personalReportCount >= 1,
go: { kind: "href", href: "/reports" },
};
}
}
export function checklistProgress(items: readonly ChecklistItem[]): { done: number; total: number; label: string } {
const total = items.length;
const done = items.filter((item) => item.done).length;
return { done, total, label: `启程 ${done}/${total}` };
}
export function checklistAllDone(items: readonly ChecklistItem[]): boolean {
return items.length > 0 && items.every((item) => item.done);
}
export function shouldShowChecklist(input: {
readonly profileComplete: boolean;
readonly dismissedAt: string | null | undefined;
readonly arrivedComplete: boolean;
}): boolean {
return input.profileComplete && !input.dismissedAt && !input.arrivedComplete;
}
export type ArrivalPhase = "pending" | "gap" | "arrived";
/** First settled snapshot that is already full never celebrates. A later full snapshot does, once. */
export function nextArrival(input: {
readonly decided: ArrivalPhase;
readonly canDecide: boolean;
readonly allDone: boolean;
}): ArrivalPhase | "celebrate" {
if (!input.canDecide) return input.decided;
if (input.decided === "pending") return input.allDone ? "arrived" : "gap";
if (input.decided === "gap" && input.allDone) return "celebrate";
return input.decided;
}
+160
View File
@@ -0,0 +1,160 @@
import { ONBOARDING_HINT_IDS, type OnboardingHintId } from "@/lib/onboarding-state";
export const HINT_COPY: Record<OnboardingHintId, string> = {
"follow-up": "想接着聊,点下面任一个就直接问。",
"sidebar-chart": "你的盘、大运和分盘都在这里。",
"tab-chart": "这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。",
"tab-info": "排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。",
"tab-dasha": "一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。",
"tab-west": "同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。",
"syn-ready": "有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。",
"ephemeris": "每天的天象和行运在这里,可以按日期翻。",
};
export const HINT_ANCHORS: Record<OnboardingHintId, string> = {
"follow-up": "follow-up",
"sidebar-chart": "sidebar-chart",
"tab-chart": "chart-tab",
"tab-info": "chart-tab",
"tab-dasha": "chart-tab",
"tab-west": "chart-tab",
"syn-ready": "syn-ready",
"ephemeris": "ephemeris",
};
export const TAB_HINT_IDS = {
vedic: "tab-chart",
basics: "tab-info",
dasha: "tab-dasha",
western: "tab-west",
} as const;
export type ChartHintTab = keyof typeof TAB_HINT_IDS;
export const HINT_DISMISS_LABEL = "知道了";
const HINT_ORDER = ONBOARDING_HINT_IDS;
export function tabChartCopy(name: string | null): string {
const text = HINT_COPY["tab-chart"];
const trimmed = name?.trim() ?? "";
return trimmed ? text.replace("你的", `${trimmed}的`) : text;
}
export function hintCopy(id: OnboardingHintId, chartSubjectName: string | null): string {
return id === "tab-chart" ? tabChartCopy(chartSubjectName) : HINT_COPY[id];
}
export function followUpAfterStream(input: {
readonly wasStreaming: boolean;
readonly streaming: boolean;
readonly hadAnswerBefore: boolean;
readonly hasAnswerNow: boolean;
}): boolean {
return input.wasStreaming && !input.streaming && !input.hadAnswerBefore && input.hasAnswerNow;
}
export function sidebarChartReady(input: {
readonly followUpSeen: boolean;
readonly chartVisited: boolean;
readonly sidebarOpen: boolean;
}): boolean {
return input.followUpSeen && !input.chartVisited && input.sidebarOpen;
}
export type EphemerisPhase = "watch" | "wait-close" | "ready";
/** The third daily open arms the *next* sidebar open. An already-open sidebar waits for a close first. */
export function nextEphemerisPhase(input: {
readonly phase: EphemerisPhase;
readonly seen: boolean;
readonly dailyOpens: number;
readonly sidebarOpen: boolean;
readonly viewportReady: boolean;
}): { readonly phase: EphemerisPhase; readonly armed: boolean } {
if (!input.viewportReady || input.seen || input.dailyOpens < 3) {
return { phase: input.dailyOpens < 3 && !input.seen ? "watch" : input.phase, armed: false };
}
let phase = input.phase;
if (phase === "watch") phase = input.sidebarOpen ? "wait-close" : "ready";
else if (phase === "wait-close" && !input.sidebarOpen) phase = "ready";
return { phase, armed: phase === "ready" && input.sidebarOpen };
}
export function showChartMenuDot(input: { readonly askDone: boolean; readonly chartVisited: boolean }): boolean {
return input.askDone && !input.chartVisited;
}
export function tabHasDot(tabId: string, hintsSeen: readonly OnboardingHintId[]): boolean {
const hintId = TAB_HINT_IDS[tabId as ChartHintTab];
return Boolean(hintId) && !hintsSeen.includes(hintId);
}
export function synReadyFact(input: {
readonly compatibility: boolean | null;
readonly selfComplete: boolean;
readonly completeOthers: number;
readonly synastryReportCount: number;
}): boolean {
return input.compatibility === true
&& input.selfComplete
&& input.completeOthers >= 1
&& input.synastryReportCount === 0;
}
export function hintsBlocked(input: {
readonly sky: boolean;
readonly dialog: boolean;
readonly streaming: boolean;
readonly checklistOpen: boolean;
readonly foreignDialog: boolean;
}): boolean {
return input.sky || input.dialog || input.streaming || input.checklistOpen || input.foreignDialog;
}
export type HintFacts = {
readonly hintsSeen: readonly OnboardingHintId[];
readonly followUpArmed: boolean;
readonly chartVisited: boolean;
readonly sidebarOpen: boolean;
readonly activeChartTab: ChartHintTab | null;
readonly synReady: boolean;
readonly ephemerisArmed: boolean;
readonly blocked: boolean;
readonly anchors: Readonly<Record<string, boolean>>;
};
function eligible(facts: HintFacts): OnboardingHintId | null {
const seen = new Set(facts.hintsSeen);
const ready: Record<OnboardingHintId, boolean> = {
"follow-up": facts.followUpArmed,
"sidebar-chart": sidebarChartReady({
followUpSeen: facts.hintsSeen.includes("follow-up"),
chartVisited: facts.chartVisited,
sidebarOpen: facts.sidebarOpen,
}),
"tab-chart": facts.activeChartTab === "vedic",
"tab-info": facts.activeChartTab === "basics",
"tab-dasha": facts.activeChartTab === "dasha",
"tab-west": facts.activeChartTab === "western",
"syn-ready": facts.synReady,
"ephemeris": facts.ephemerisArmed && facts.sidebarOpen,
};
for (const id of HINT_ORDER) {
if (ready[id] && !seen.has(id)) return id;
}
return null;
}
/** The hint that should show once its anchor exists and nothing is covering the page. */
export function selectHint(facts: HintFacts): OnboardingHintId | null {
if (facts.blocked) return null;
const id = eligible(facts);
if (!id) return null;
return facts.anchors[HINT_ANCHORS[id]] ? id : null;
}
/** Same order, ignoring blockers and missing anchors, so a covered hint is not dropped. */
export function queuedHint(facts: HintFacts): OnboardingHintId | null {
return eligible({ ...facts, blocked: false, anchors: {} });
}
+657
View File
@@ -0,0 +1,657 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useSidebar } from "@/components/ui/sidebar";
import { useSessionList } from "@/hooks/use-session-list";
import {
buildChecklist,
checklistAllDone,
checklistProgress,
countDailySessions,
hasCompleteNonSmalltalkAnswer,
nextArrival,
shouldShowChecklist,
type ArrivalPhase,
type ChecklistItem,
type ChecklistPerson,
} from "@/lib/onboarding-checklist";
import {
hintCopy,
hintsBlocked,
nextEphemerisPhase,
selectHint,
showChartMenuDot,
synReadyFact,
tabHasDot,
type ChartHintTab,
type EphemerisPhase,
type HintFacts,
} from "@/lib/onboarding-hints";
import { missingOtherProfileStep, readProfile, selfChartComplete } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
import { parseOnboardingState, type OnboardingHintId, type OnboardingState } from "@/lib/onboarding-state";
import { setOnboardingDailyOpenedListener } from "@/lib/onboarding-signals";
const REPORT_PAGE_CAP = 3;
const ANCHOR_NAMES = ["follow-up", "sidebar-chart", "chart-tab", "syn-ready", "ephemeris"];
const EMPTY_HINTS: readonly OnboardingHintId[] = [];
type RemoteOther = { readonly id: string; readonly profile: unknown; readonly createdAt: number | null };
type RemoteSnapshot = {
readonly status: "loading" | "ready" | "hidden";
readonly userId: string;
readonly state: OnboardingState;
readonly others: readonly RemoteOther[];
readonly profilesKnown: boolean;
readonly accessKnown: boolean;
readonly compatibility: boolean;
readonly reportCenter: boolean;
readonly synastryCreditCost: number | null;
readonly reportCreditCost: number | null;
readonly synCount: number | null;
readonly personalCount: number | null;
};
type ReportRow = { readonly status?: string; readonly reportType?: string };
type LiveState = {
readonly streaming: boolean;
readonly streamingSessionId: string | null;
readonly skyOpen: boolean;
readonly dialogOpen: boolean;
readonly foreignDialog: boolean;
readonly checklistOpen: boolean;
readonly anchors: Readonly<Record<string, boolean>>;
readonly selfProfile: Profile | null;
readonly activeChartTab: ChartHintTab | null;
readonly chartSubjectName: string | null;
};
type HomeActions = {
readonly focusComposer: () => void;
readonly startDaily: () => void;
};
export type OnboardingJourney = {
readonly showChecklist: boolean;
readonly items: readonly ChecklistItem[];
readonly label: string;
readonly celebration: boolean;
readonly activeHint: OnboardingHintId | null;
readonly hintText: string;
readonly showChartDot: boolean;
readonly tabHasDot: (tabId: string) => boolean;
readonly dismissChecklist: () => void;
readonly dismissHint: (id: OnboardingHintId) => Promise<boolean>;
readonly go: (item: ChecklistItem) => void;
readonly setChartTab: (tab: string | null, name: string | null) => void;
readonly markChartVisited: () => void;
readonly setChecklistOpen: (open: boolean) => void;
readonly setLive: (patch: Partial<LiveState>) => void;
readonly registerHome: (actions: HomeActions | null) => void;
};
const OnboardingJourneyContext = createContext<OnboardingJourney | null>(null);
const INITIAL_REMOTE: RemoteSnapshot = {
status: "loading",
userId: "",
state: {},
others: [],
profilesKnown: false,
accessKnown: false,
compatibility: false,
reportCenter: false,
synastryCreditCost: null,
reportCreditCost: null,
synCount: null,
personalCount: null,
};
const EMPTY_LIVE: LiveState = {
streaming: false,
streamingSessionId: null,
skyOpen: false,
dialogOpen: false,
foreignDialog: false,
checklistOpen: false,
anchors: {},
selfProfile: null,
activeChartTab: null,
chartSubjectName: null,
};
function createdAtMs(value: unknown): number | null {
if (typeof value !== "string") return null;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
function sameAnchors(left: Readonly<Record<string, boolean>>, right: Readonly<Record<string, boolean>>): boolean {
return ANCHOR_NAMES.every((name) => Boolean(left[name]) === Boolean(right[name]));
}
function isReadyPersonal(row: ReportRow): boolean {
return row.status === "ready" && row.reportType !== "synastry_full";
}
async function countReports(path: string, accept: (row: ReportRow) => boolean): Promise<number | null> {
let count = 0;
let cursor: string | null = null;
for (let page = 0; page < REPORT_PAGE_CAP; page += 1) {
const url = new URL(path, window.location.origin);
if (cursor) url.searchParams.set("before", cursor);
const response = await fetch(`${url.pathname}${url.search}`, { cache: "no-store" });
if (!response.ok) return null;
const body = await response.json() as { reports?: ReportRow[]; nextCursor?: unknown };
count += (body.reports ?? []).filter(accept).length;
cursor = typeof body.nextCursor === "string" && body.nextCursor ? body.nextCursor : null;
if (!cursor) break;
}
return count;
}
async function countLegacySynastry(): Promise<number | null> {
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
if (response.status === 404) return 0;
if (!response.ok) return null;
const body = await response.json() as { reports?: unknown };
return Array.isArray(body.reports) ? body.reports.length : 0;
}
async function countPersonal(others: readonly RemoteOther[]): Promise<number | null> {
const selfCount = await countReports("/api/reports?subject=self", isReadyPersonal);
if (selfCount === null) return null;
if (selfCount > 0) return selfCount;
for (const other of others) {
const count = await countReports(`/api/reports?subject=${encodeURIComponent(other.id)}`, isReadyPersonal);
if (count === null) return null;
if (count > 0) return count;
}
return 0;
}
async function patchOnboarding(patch: OnboardingState): Promise<boolean> {
try {
const response = await fetch("/api/onboarding-state", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
return response.ok;
} catch {
return false;
}
}
async function loadRemote(userId: string): Promise<RemoteSnapshot> {
const [stateRes, profilesRes, accessRes] = await Promise.all([
fetch("/api/onboarding-state", { cache: "no-store" }),
fetch("/api/chart-profiles", { cache: "no-store" }),
fetch("/api/product-access", { cache: "no-store" }),
]);
if (stateRes.status === 401 || profilesRes.status === 401 || !stateRes.ok) {
return { ...INITIAL_REMOTE, status: "hidden", userId };
}
const stateBody = await stateRes.json() as { state?: unknown };
const state = parseOnboardingState(stateBody.state);
let others: RemoteOther[] = [];
let profilesKnown = false;
if (profilesRes.ok) {
profilesKnown = true;
const body = await profilesRes.json() as { profiles?: Array<{ id?: unknown; profile?: unknown; created_at?: unknown }> };
others = (body.profiles ?? []).flatMap((row) => (
typeof row.id === "string" && row.id
? [{ id: row.id, profile: row.profile, createdAt: createdAtMs(row.created_at) }]
: []
));
}
let accessKnown = false;
let compatibility = false;
let reportCenter = false;
let synastryCreditCost: number | null = null;
let reportCreditCost: number | null = null;
if (accessRes.ok) {
const body = await accessRes.json() as Record<string, unknown>;
if (typeof body.compatibility === "boolean" && typeof body.reportCenter === "boolean") {
accessKnown = true;
compatibility = body.compatibility;
reportCenter = body.reportCenter;
synastryCreditCost = typeof body.synastryCreditCost === "number" ? body.synastryCreditCost : null;
reportCreditCost = typeof body.reportCreditCost === "number" ? body.reportCreditCost : null;
}
}
// Session titles only cover the current person and the loaded page. Report
// lists stop after three pages. A failed price or flag read hides that row
// and refuses to treat the account as already finished.
const synCount = accessKnown && compatibility
? await (async () => {
const modern = await countReports("/api/reports?reportType=synastry_full", (row) => row.status === "ready");
const legacy = await countLegacySynastry();
return modern == null || legacy == null ? null : modern + legacy;
})()
: null;
const personalCount = accessKnown && reportCenter ? await countPersonal(others) : null;
return {
status: "ready",
userId,
state,
others,
profilesKnown,
accessKnown,
compatibility,
reportCenter,
synastryCreditCost,
reportCreditCost,
synCount,
personalCount,
};
}
function toPeople(selfProfile: Profile, others: readonly RemoteOther[]): ChecklistPerson[] {
return [
{
id: "self",
role: "self",
name: selfProfile.name.trim() || "我",
complete: selfChartComplete(selfProfile),
createdAt: null,
},
...others.map((row) => {
const profile = readProfile(row.profile);
return {
id: row.id,
role: "other" as const,
name: profile.name.trim(),
complete: missingOtherProfileStep(profile) === null && Boolean(profile.name.trim()),
createdAt: row.createdAt,
};
}),
];
}
function chartTab(value: string | null): ChartHintTab | null {
return value === "vedic" || value === "basics" || value === "dasha" || value === "western" ? value : null;
}
export function useOnboardingJourneyOptional(): OnboardingJourney | null {
return useContext(OnboardingJourneyContext);
}
export function useOnboardingJourney(): OnboardingJourney {
const value = useOnboardingJourneyOptional();
if (!value) throw new Error("Onboarding journey is only available inside the app shell");
return value;
}
/** The one hint selector. `condition` is the caller's extra gate; the provider still queues the rest. */
export function useOneTimeHint(id: OnboardingHintId, condition: boolean): { show: boolean; dismiss: () => void } {
const journey = useOnboardingJourneyOptional();
const show = Boolean(condition && journey?.activeHint === id);
const dismiss = useCallback(() => {
journey?.dismissHint(id);
}, [journey, id]);
return { show, dismiss };
}
export function OnboardingJourneyProvider({ children }: { readonly children: ReactNode }) {
const list = useSessionList();
const { open, openMobile, viewport, ready } = useSidebar();
const sidebarOpen = ready && (viewport === "mobile" ? openMobile : open);
const [remote, setRemote] = useState<RemoteSnapshot>(INITIAL_REMOTE);
const [overlay, setOverlay] = useState<OnboardingState>({});
const [live, setLiveState] = useState<LiveState>(EMPTY_LIVE);
const [dailyFloor, setDailyFloor] = useState(0);
const [followUpArmed, setFollowUpArmed] = useState(false);
const [ephemerisArmed, setEphemerisArmed] = useState(false);
const [arrivedComplete, setArrivedComplete] = useState(false);
const [arrivalSettled, setArrivalSettled] = useState(false);
const [celebration, setCelebration] = useState(false);
const userId = list.account?.user.id ?? "";
const remoteRef = useRef(remote);
const overlayRef = useRef(overlay);
const homeRef = useRef<HomeActions | null>(null);
const wasStreaming = useRef(false);
const hadAnswerBefore = useRef(false);
const ephemerisPhase = useRef<EphemerisPhase>("watch");
const arrivalPhase = useRef<ArrivalPhase>("pending");
const visitedRef = useRef<string | null>(null);
useEffect(() => {
remoteRef.current = remote;
overlayRef.current = overlay;
}, [remote, overlay]);
const setLive = useCallback((patch: Partial<LiveState>) => {
setLiveState((current) => {
const anchors = patch.anchors ?? current.anchors;
const next: LiveState = {
streaming: patch.streaming ?? current.streaming,
streamingSessionId: patch.streamingSessionId !== undefined ? patch.streamingSessionId : current.streamingSessionId,
skyOpen: patch.skyOpen ?? current.skyOpen,
dialogOpen: patch.dialogOpen ?? current.dialogOpen,
foreignDialog: patch.foreignDialog ?? current.foreignDialog,
checklistOpen: patch.checklistOpen ?? current.checklistOpen,
anchors,
selfProfile: patch.selfProfile !== undefined ? patch.selfProfile : current.selfProfile,
activeChartTab: patch.activeChartTab !== undefined ? patch.activeChartTab : current.activeChartTab,
chartSubjectName: patch.chartSubjectName !== undefined ? patch.chartSubjectName : current.chartSubjectName,
};
if (
next.streaming === current.streaming
&& next.streamingSessionId === current.streamingSessionId
&& next.skyOpen === current.skyOpen
&& next.dialogOpen === current.dialogOpen
&& next.foreignDialog === current.foreignDialog
&& next.checklistOpen === current.checklistOpen
&& next.selfProfile === current.selfProfile
&& next.activeChartTab === current.activeChartTab
&& next.chartSubjectName === current.chartSubjectName
&& sameAnchors(next.anchors, current.anchors)
) return current;
return next;
});
}, []);
useEffect(() => {
setOnboardingDailyOpenedListener(() => {
setDailyFloor((count) => count + 1);
});
return () => setOnboardingDailyOpenedListener(null);
}, []);
useEffect(() => {
wasStreaming.current = false;
hadAnswerBefore.current = false;
ephemerisPhase.current = "watch";
arrivalPhase.current = "pending";
visitedRef.current = null;
queueMicrotask(() => {
setFollowUpArmed(false);
setEphemerisArmed(false);
setArrivedComplete(false);
setArrivalSettled(false);
setCelebration(false);
setOverlay({});
setDailyFloor(0);
});
}, [userId]);
useEffect(() => {
if (list.signedOut || !userId) {
queueMicrotask(() => setRemote({ ...INITIAL_REMOTE, status: "hidden" }));
return;
}
let cancelled = false;
void loadRemote(userId).then((snapshot) => {
if (!cancelled) setRemote(snapshot);
}).catch(() => {
if (!cancelled) setRemote({ ...INITIAL_REMOTE, status: "hidden", userId });
});
return () => {
cancelled = true;
};
}, [list.signedOut, userId]);
const selfProfile = live.selfProfile ?? readProfile(list.account?.profile);
const people = useMemo(() => toPeople(selfProfile, remote.others), [selfProfile, remote.others]);
const profileComplete = people.some((person) => person.complete);
const merged = useMemo<OnboardingState>(() => ({
checklistDismissedAt: overlay.checklistDismissedAt !== undefined ? overlay.checklistDismissedAt : remote.state.checklistDismissedAt,
chartVisitedAt: overlay.chartVisitedAt !== undefined ? overlay.chartVisitedAt : remote.state.chartVisitedAt,
hintsSeen: overlay.hintsSeen ?? remote.state.hintsSeen,
}), [overlay, remote.state]);
const hintsSeen = merged.hintsSeen ?? EMPTY_HINTS;
const followUpSeen = hintsSeen.includes("follow-up");
const ephemerisSeen = hintsSeen.includes("ephemeris");
const chartVisited = Boolean(merged.chartVisitedAt);
const dismissed = Boolean(merged.checklistDismissedAt);
const dailySessions = countDailySessions(list.sessions);
const dailyOpens = Math.max(dailySessions, dailyFloor);
const hasAnswer = hasCompleteNonSmalltalkAnswer({
sessions: list.sessions,
streaming: live.streaming,
streamingSessionId: live.streamingSessionId,
firstConsultFree: list.account?.firstConsultFree,
});
const items = useMemo(() => buildChecklist({
people,
profileComplete,
hasNonSmalltalkAnswer: hasAnswer,
dailyCount: dailySessions,
chartVisited,
synastryReportCount: remote.synCount ?? 0,
personalReportCount: remote.personalCount ?? 0,
flags: {
compatibility: remote.accessKnown ? remote.compatibility : null,
reportCenter: remote.accessKnown ? remote.reportCenter : null,
synastryCreditCost: remote.synastryCreditCost,
reportCreditCost: remote.reportCreditCost,
},
}), [
people,
profileComplete,
hasAnswer,
dailySessions,
chartVisited,
remote.synCount,
remote.personalCount,
remote.accessKnown,
remote.compatibility,
remote.reportCenter,
remote.synastryCreditCost,
remote.reportCreditCost,
]);
const progress = checklistProgress(items);
const allDone = checklistAllDone(items);
const countsReady = (!remote.accessKnown || !remote.compatibility || remote.synCount !== null)
&& (!remote.accessKnown || !remote.reportCenter || remote.personalCount !== null);
const canDecide = remote.status === "ready"
&& remote.userId === userId
&& userId !== ""
&& remote.accessKnown
&& remote.profilesKnown
&& countsReady
&& list.settled
&& profileComplete
&& !dismissed;
useEffect(() => {
if (merged.chartVisitedAt) visitedRef.current = merged.chartVisitedAt;
}, [merged.chartVisitedAt]);
useEffect(() => {
if (!canDecide) return;
const next = nextArrival({ decided: arrivalPhase.current, canDecide: true, allDone });
if (next === "arrived") arrivalPhase.current = "arrived";
else if (next === "gap" || next === "celebrate") arrivalPhase.current = "gap";
queueMicrotask(() => {
if (next === "arrived") setArrivedComplete(true);
if (next === "celebrate") setCelebration(true);
setArrivalSettled(true);
});
}, [canDecide, allDone]);
useEffect(() => {
if (followUpSeen) {
wasStreaming.current = live.streaming;
return;
}
const was = wasStreaming.current;
const had = hadAnswerBefore.current;
if (!was && live.streaming) hadAnswerBefore.current = hasAnswer;
wasStreaming.current = live.streaming;
if (!followUpAfterStreamSafe(was, live.streaming, had, hasAnswer)) return;
queueMicrotask(() => setFollowUpArmed(true));
}, [live.streaming, hasAnswer, followUpSeen]);
useEffect(() => {
const next = nextEphemerisPhase({
phase: ephemerisPhase.current,
seen: ephemerisSeen,
dailyOpens,
sidebarOpen,
viewportReady: ready,
});
ephemerisPhase.current = next.phase;
queueMicrotask(() => setEphemerisArmed((current) => current === next.armed ? current : next.armed));
}, [ephemerisSeen, dailyOpens, sidebarOpen, ready]);
const selfComplete = people.some((person) => person.role === "self" && person.complete);
const completeOthers = people.filter((person) => person.role === "other" && person.complete).length;
const synReady = remote.accessKnown && remote.compatibility && remote.synCount !== null && synReadyFact({
compatibility: true,
selfComplete,
completeOthers,
synastryReportCount: remote.synCount,
});
const blocked = hintsBlocked({
sky: live.skyOpen,
dialog: live.dialogOpen,
streaming: live.streaming,
checklistOpen: live.checklistOpen,
foreignDialog: live.foreignDialog,
});
const hintFacts: HintFacts = {
hintsSeen,
followUpArmed,
chartVisited,
sidebarOpen,
activeChartTab: live.activeChartTab,
synReady,
ephemerisArmed,
blocked,
anchors: live.anchors,
};
const activeHint = selectHint(hintFacts);
const hintText = activeHint ? hintCopy(activeHint, live.chartSubjectName) : "";
const showChecklist = remote.status === "ready"
&& shouldShowChecklist({
profileComplete,
dismissedAt: merged.checklistDismissedAt,
arrivedComplete,
})
&& (!canDecide || arrivalSettled);
const dismissChecklist = useCallback(() => {
const stamp = new Date().toISOString();
setOverlay((current) => ({ ...current, checklistDismissedAt: stamp }));
setCelebration(false);
void patchOnboarding({ checklistDismissedAt: stamp }).then((ok) => {
if (!ok) setOverlay((current) => ({ ...current, checklistDismissedAt: remoteRef.current.state.checklistDismissedAt ?? null }));
});
}, []);
const dismissHint = useCallback((id: OnboardingHintId) => {
const seen = overlayRef.current.hintsSeen ?? remoteRef.current.state.hintsSeen ?? [];
if (seen.includes(id)) return Promise.resolve(true);
const hintsSeenNext = [...seen, id];
setOverlay((current) => ({ ...current, hintsSeen: hintsSeenNext }));
if (id === "follow-up") setFollowUpArmed(false);
return patchOnboarding({ hintsSeen: hintsSeenNext }).then((ok) => {
if (!ok) {
setOverlay((current) => ({ ...current, hintsSeen: seen }));
if (id === "follow-up") setFollowUpArmed(true);
}
return ok;
});
}, []);
const markChartVisited = useCallback(() => {
if (visitedRef.current) return;
const stamp = new Date().toISOString();
visitedRef.current = stamp;
setOverlay((current) => ({ ...current, chartVisitedAt: stamp }));
void patchOnboarding({ chartVisitedAt: stamp }).then((ok) => {
if (!ok) {
visitedRef.current = remoteRef.current.state.chartVisitedAt ?? null;
setOverlay((current) => ({ ...current, chartVisitedAt: remoteRef.current.state.chartVisitedAt ?? null }));
}
});
}, []);
const setChartTab = useCallback((tab: string | null, name: string | null) => {
setLive({ activeChartTab: chartTab(tab), chartSubjectName: name });
}, [setLive]);
const setChecklistOpen = useCallback((next: boolean) => {
setLive({ checklistOpen: next });
}, [setLive]);
const registerHome = useCallback((actions: HomeActions | null) => {
homeRef.current = actions;
}, []);
const go = useCallback((item: ChecklistItem) => {
setLive({ checklistOpen: false });
const target = item.go;
if (!target) return;
if (target.kind === "compose") {
if (homeRef.current) homeRef.current.focusComposer();
else window.location.assign("/?compose=1");
return;
}
if (target.kind === "daily") {
if (homeRef.current) homeRef.current.startDaily();
else window.location.assign("/?daily=1");
return;
}
window.location.assign(target.href);
}, [setLive]);
const dotVisible = showChartMenuDot({ askDone: hasAnswer, chartVisited });
const tabDot = useCallback((tabId: string) => tabHasDot(tabId, hintsSeen), [hintsSeen]);
const value = useMemo<OnboardingJourney>(() => ({
showChecklist,
items,
label: progress.label,
celebration,
activeHint,
hintText,
showChartDot: dotVisible,
tabHasDot: tabDot,
dismissChecklist,
dismissHint,
go,
setChartTab,
markChartVisited,
setChecklistOpen,
setLive,
registerHome,
}), [
showChecklist,
items,
progress.label,
celebration,
activeHint,
hintText,
dotVisible,
tabDot,
dismissChecklist,
dismissHint,
go,
setChartTab,
markChartVisited,
setChecklistOpen,
setLive,
registerHome,
]);
return <OnboardingJourneyContext.Provider value={value}>{children}</OnboardingJourneyContext.Provider>;
}
function followUpAfterStreamSafe(was: boolean, streaming: boolean, had: boolean, has: boolean): boolean {
return was && !streaming && !had && has;
}
+13
View File
@@ -0,0 +1,13 @@
/** One listener: the home page fires it when 今日星语 actually starts. */
type DailyOpenedListener = () => void;
let dailyOpenedListener: DailyOpenedListener | null = null;
export function setOnboardingDailyOpenedListener(listener: DailyOpenedListener | null): void {
dailyOpenedListener = listener;
}
export function notifyOnboardingDailyOpened(): void {
dailyOpenedListener?.();
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const SELF_SUBJECT_ID = "self";
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation";
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at";
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at,created_at";
export const MAX_OTHER_SUBJECTS = 19;
export const MAX_SAVED_SUBJECTS = 20;
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildChecklist,
checklistAllDone,
checklistProgress,
CHECKLIST_CELEBRATION_BODY,
CHECKLIST_CELEBRATION_TITLE,
countDailySessions,
hasCompleteNonSmalltalkAnswer,
helpedName,
isCompleteNonSmalltalkAnswer,
nextArrival,
sessionCountsAsDaily,
shouldShowChecklist,
type ChecklistFacts,
type ChecklistPerson,
} from "../src/lib/onboarding-checklist.ts";
import {
followUpAfterStream,
HINT_COPY,
hintCopy,
nextEphemerisPhase,
queuedHint,
selectHint,
showChartMenuDot,
sidebarChartReady,
tabChartCopy,
tabHasDot,
type EphemerisPhase,
type HintFacts,
} from "../src/lib/onboarding-hints.ts";
function person(role: "self" | "other", name: string, complete: boolean, createdAt: number | null = null): ChecklistPerson {
return { id: role === "self" ? "self" : name, role, name, complete, createdAt };
}
function facts(patch: Partial<ChecklistFacts> = {}): ChecklistFacts {
return {
people: [person("self", "我", true)],
profileComplete: true,
hasNonSmalltalkAnswer: false,
dailyCount: 0,
chartVisited: false,
synastryReportCount: 0,
personalReportCount: 0,
flags: { compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 },
...patch,
};
}
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("self path keeps the seven labels, order, and done rules", () => {
const open = buildChecklist(facts());
assert.deepEqual(open.map((item) => item.id), ["self", "ask", "daily", "chart", "person", "syn", "report"]);
assert.equal(open[0]?.label, "排好自己的盘");
assert.equal(open[0]?.go, null);
assert.equal(open[1]?.label, "问第一个问题");
assert.deepEqual(open[1]?.go, { kind: "compose" });
assert.equal(open[2]?.label, "打开今日星语");
assert.deepEqual(open[2]?.go, { kind: "daily" });
assert.equal(open[3]?.label, "看看自己的星盘");
assert.deepEqual(open[3]?.go, { kind: "href", href: "/chart" });
assert.equal(open[4]?.label, "加一位家人或朋友");
assert.equal(open[4]?.done, false);
assert.deepEqual(open[4]?.go, { kind: "href", href: "/people?add=1" });
assert.equal(open[5]?.label, "和 TA 合一次盘 · 19 点");
assert.deepEqual(open[5]?.go, { kind: "href", href: "/people" });
assert.equal(open[6]?.label, "生成个人报告 · 29 点");
assert.deepEqual(open[6]?.go, { kind: "href", href: "/reports" });
assert.equal(checklistProgress(open).label, "启程 1/7");
});
test("smalltalk is not ask, one self is not person, and a second other does not finish the help-other person row", () => {
assert.equal(isCompleteNonSmalltalkAnswer({ role: "assistant", text: "在的", responseKind: "smalltalk" }), false);
assert.equal(hasCompleteNonSmalltalkAnswer({
sessions: [{ messagesHydrated: true, messages: [{ role: "assistant", text: "在的", responseKind: "smalltalk" }] }],
firstConsultFree: true,
}), false);
assert.equal(hasCompleteNonSmalltalkAnswer({
sessions: [{ id: "s", messagesHydrated: true, messages: [{ role: "assistant", text: "完整回答" }] }],
streaming: true,
streamingSessionId: "s",
firstConsultFree: false,
}), false);
const selfOnly = buildChecklist(facts());
assert.equal(selfOnly.find((item) => item.id === "person")?.done, false);
const helped = facts({
people: [person("self", "我", false), person("other", "小周", true, 2), person("other", "小陈", true, 3)],
profileComplete: true,
});
const rows = buildChecklist(helped);
assert.deepEqual(rows.map((item) => item.id), ["self", "ask", "person", "daily", "chart", "syn", "report"]);
assert.equal(rows[0]?.label, "排好小周的盘");
assert.equal(rows.find((item) => item.id === "person")?.label, "加上你自己");
assert.equal(rows.find((item) => item.id === "person")?.done, false);
assert.deepEqual(rows.find((item) => item.id === "person")?.go, { kind: "href", href: "/people?self=1" });
assert.deepEqual(rows.find((item) => item.id === "daily")?.go, { kind: "href", href: "/people" });
assert.equal(rows.find((item) => item.id === "chart")?.label, "看看星盘");
assert.equal(rows.find((item) => item.id === "syn")?.label, "和小周合一次盘 · 19 点");
});
test("both people complete uses the self copy, and a missing price omits the price clause", () => {
const both = facts({
people: [person("self", "我", true, 5), person("other", "小周", true, 1)],
});
assert.equal(helpedName(both.people), "小周");
assert.equal(buildChecklist(both)[0]?.label, "排好自己的盘");
const unpriced = buildChecklist(facts({
flags: { compatibility: true, reportCenter: true, synastryCreditCost: null, reportCreditCost: null },
}));
assert.equal(unpriced.find((item) => item.id === "syn")?.label, "和 TA 合一次盘");
assert.equal(unpriced.find((item) => item.id === "report")?.label, "生成个人报告");
});
test("closed switches drop rows, and progress labels follow the remaining rows", () => {
assert.equal(buildChecklist(facts({ flags: { compatibility: false, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 } })).length, 6);
assert.equal(buildChecklist(facts({ flags: { compatibility: true, reportCenter: false, synastryCreditCost: 19, reportCreditCost: null } })).some((item) => item.id === "report"), false);
assert.equal(buildChecklist(facts({ flags: { compatibility: null, reportCenter: null, synastryCreditCost: null, reportCreditCost: null } })).length, 5);
const fresh = checklistProgress(buildChecklist(facts()));
assert.equal(fresh.label, "启程 1/7");
const older = buildChecklist(facts({
people: [person("self", "我", true), person("other", "小周", true)],
hasNonSmalltalkAnswer: true,
dailyCount: 0,
chartVisited: false,
synastryReportCount: 1,
personalReportCount: 1,
}));
assert.equal(checklistProgress(older).label, "启程 5/7");
assert.equal(older.find((item) => item.id === "daily")?.done, false);
assert.equal(older.find((item) => item.id === "chart")?.done, false);
});
test("a full account that already arrived stays hidden, and dismiss stays hidden", () => {
const full = buildChecklist(facts({
people: [person("self", "我", true), person("other", "小周", true)],
hasNonSmalltalkAnswer: true,
dailyCount: 1,
chartVisited: true,
synastryReportCount: 1,
personalReportCount: 1,
}));
assert.equal(checklistAllDone(full), true);
assert.equal(shouldShowChecklist({ profileComplete: true, dismissedAt: null, arrivedComplete: true }), false);
assert.equal(shouldShowChecklist({ profileComplete: true, dismissedAt: "2026-10-24T00:00:00.000Z", arrivedComplete: false }), false);
assert.equal(shouldShowChecklist({ profileComplete: false, dismissedAt: null, arrivedComplete: false }), false);
assert.equal(nextArrival({ decided: "pending", canDecide: true, allDone: true }), "arrived");
assert.equal(nextArrival({ decided: "pending", canDecide: true, allDone: false }), "gap");
assert.equal(nextArrival({ decided: "gap", canDecide: true, allDone: true }), "celebrate");
assert.equal(nextArrival({ decided: "pending", canDecide: false, allDone: true }), "pending");
assert.equal(CHECKLIST_CELEBRATION_TITLE, "都走过一遍了");
assert.equal(CHECKLIST_CELEBRATION_BODY, "以后有新东西,我会在用到的时候再告诉你。");
});
test("daily counts titled sessions and complete daily questions, not smalltalk", () => {
assert.equal(sessionCountsAsDaily({ title: "今日节奏 · 10月24日", messagesHydrated: false }), true);
assert.equal(sessionCountsAsDaily({
title: "今日节奏 · 10月24日",
messagesHydrated: true,
messages: [{ role: "assistant", text: "在的", responseKind: "smalltalk" }],
}), false);
assert.equal(sessionCountsAsDaily({
messagesHydrated: true,
messages: [
{ role: "user", text: "深入看今日" },
{ role: "assistant", text: "今天宜缓。" },
],
}), true);
assert.equal(countDailySessions([
{ title: "新对话", messagesHydrated: false },
{ title: "10月2日 · 今日节奏", messagesHydrated: false },
]), 1);
});
function hintFacts(patch: Partial<HintFacts> = {}): HintFacts {
return {
hintsSeen: [],
followUpArmed: false,
chartVisited: false,
sidebarOpen: false,
activeChartTab: null,
synReady: false,
ephemerisArmed: false,
blocked: false,
anchors: { "follow-up": true, "sidebar-chart": true, "chart-tab": true, "syn-ready": true, ephemeris: true },
...patch,
};
}
test("each hint appears on its condition and stays gone once seen", () => {
assert.equal(selectHint(hintFacts({ followUpArmed: true })), "follow-up");
assert.equal(selectHint(hintFacts({ followUpArmed: true, hintsSeen: ["follow-up"] })), null);
assert.equal(sidebarChartReady({ followUpSeen: false, chartVisited: false, sidebarOpen: true }), false);
assert.equal(selectHint(hintFacts({
hintsSeen: ["follow-up"],
sidebarOpen: true,
})), "sidebar-chart");
assert.equal(selectHint(hintFacts({ hintsSeen: ["follow-up"], sidebarOpen: true, chartVisited: true, activeChartTab: "vedic" })), "tab-chart");
assert.equal(selectHint(hintFacts({ activeChartTab: "basics" })), "tab-info");
assert.equal(selectHint(hintFacts({ activeChartTab: "dasha" })), "tab-dasha");
assert.equal(selectHint(hintFacts({ activeChartTab: "western" })), "tab-west");
assert.equal(selectHint(hintFacts({ synReady: true })), "syn-ready");
assert.equal(selectHint(hintFacts({ ephemerisArmed: true, sidebarOpen: true })), "ephemeris");
assert.equal(selectHint(hintFacts({
hintsSeen: ["follow-up", "sidebar-chart", "tab-chart", "tab-info", "tab-dasha", "tab-west", "syn-ready", "ephemeris"],
followUpArmed: true,
sidebarOpen: true,
activeChartTab: "vedic",
synReady: true,
ephemerisArmed: true,
})), null);
});
test("blocked hints and missing anchors do not show and are not dropped", () => {
const blocked = hintFacts({ followUpArmed: true, blocked: true });
assert.equal(selectHint(blocked), null);
assert.equal(queuedHint(blocked), "follow-up");
const streaming = hintFacts({ synReady: true, blocked: true, activeChartTab: "vedic" });
assert.equal(selectHint(streaming), null);
assert.equal(queuedHint(streaming), "tab-chart");
const missing = hintFacts({ followUpArmed: true, anchors: {} });
assert.equal(selectHint(missing), null);
assert.equal(queuedHint(missing), "follow-up");
});
test("follow-up only arms on the falling edge of the first real answer", () => {
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: false, hasAnswerNow: true }), true);
assert.equal(followUpAfterStream({ wasStreaming: false, streaming: false, hadAnswerBefore: false, hasAnswerNow: true }), false);
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: true, hasAnswerNow: true }), false);
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: false, hasAnswerNow: false }), false);
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: true, hadAnswerBefore: false, hasAnswerNow: false }), false);
});
test("ephemeris waits for the next sidebar open after the third daily", () => {
let phase: EphemerisPhase = "watch";
let step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 2, sidebarOpen: true, viewportReady: true });
phase = step.phase;
assert.equal(step.armed, false);
step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: true });
phase = step.phase;
assert.equal(phase, "wait-close");
assert.equal(step.armed, false);
step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: false, viewportReady: true });
phase = step.phase;
assert.equal(phase, "ready");
assert.equal(step.armed, false);
step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: true });
assert.equal(step.armed, true);
const closed = nextEphemerisPhase({ phase: "watch", seen: false, dailyOpens: 3, sidebarOpen: false, viewportReady: true });
assert.equal(closed.phase, "ready");
assert.equal(closed.armed, false);
assert.equal(nextEphemerisPhase({ phase: "watch", seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: false }).armed, false);
});
test("chart tab dots are recorded separately, and another person's chart renames one phrase", () => {
assert.equal(tabHasDot("vedic", []), true);
assert.equal(tabHasDot("basics", ["tab-chart"]), true);
assert.equal(tabHasDot("dasha", ["tab-dasha"]), false);
assert.equal(tabHasDot("western", ["tab-west"]), false);
assert.equal(tabHasDot("vedic", ["tab-chart"]), false);
assert.equal(tabChartCopy(null), HINT_COPY["tab-chart"]);
assert.equal(hintCopy("tab-chart", "小周"), HINT_COPY["tab-chart"].replace("你的", "小周的"));
assert.equal(hintCopy("tab-chart", "小周").includes("你的"), false);
assert.equal(hintCopy("tab-info", "小周"), HINT_COPY["tab-info"]);
assert.equal(showChartMenuDot({ askDone: true, chartVisited: false }), true);
assert.equal(showChartMenuDot({ askDone: true, chartVisited: true }), false);
assert.equal(showChartMenuDot({ askDone: false, chartVisited: false }), false);
});
test("checklist and hint wiring stay inside the existing shell", () => {
const page = read("../src/app/(app)/page.tsx");
const layout = read("../src/app/(app)/layout.tsx");
const sidebar = read("../src/components/app-sidebar.tsx");
const hint = read("../src/components/onboarding-hint-layer.tsx");
const css = read("../src/app/globals.css");
const people = read("../src/components/people/people-page.tsx");
const journey = read("../src/lib/onboarding-journey.tsx");
const home = page.slice(page.indexOf("export default function Home("));
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
assert.match(page, /<OnboardingChecklistEntry \/>/);
const header = page.slice(page.indexOf('<header className="chat-header">'), page.indexOf("composer-wrap"));
assert.match(header, /<OnboardingChecklistEntry \/>/);
assert.equal(header.includes("composer-wrap"), false);
assert.match(layout, /list\.account\?\.compatibility === true/);
assert.doesNotMatch(layout, /\/api\/product-access/);
assert.doesNotMatch(sidebar, /supabase|fetch\(|\/api\//i);
assert.match(people, /searchParams\.get\("add"\) === "1"/);
assert.match(people, /searchParams\.get\("self"\) === "1"/);
assert.match(people, /data-onboarding-anchor="syn-ready"/);
assert.match(hint, /initialFocus=\{false\}/);
assert.match(hint, /finalFocus=\{false\}/);
assert.match(hint, /event\.key === "Escape"/);
assert.match(journey, /export function useOneTimeHint\(/);
assert.match(css, /\.chat-app \{[^}]*overflow:\s*clip/);
assert.match(css, /\.chat-header \{[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\) auto/);
assert.match(css, /\.onboarding-checklist-panel,\s*\n\.onboarding-checklist-sheet \{\s*\n\s*width: min\(22rem, calc\(100vw - 2rem\)\);/);
assert.match(css, /grid-template-columns:\s*1\.25rem minmax\(0,\s*1fr\) auto/);
assert.equal((home.match(/\buseState[<(]/g) ?? []).length <= 36, true);
assert.equal((home.match(/\buseRef[<(]/g) ?? []).length <= 37, true);
});