From 8f7c0f7db42f8cca6122ae39498435f59d63a483 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sat, 24 Oct 2026 23:48:15 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=8A=A0=E4=B8=8A=E5=90=AF?= =?UTF-8?q?=E7=A8=8B=E6=B8=85=E5=8D=95=E5=92=8C=E4=B8=80=E6=AC=A1=E6=80=A7?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=EF=BC=88#97=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 右上角「启程 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) --- CHANGELOG.md | 7 + CONTEXT.md | 8 + docs/BUG_HISTORY.md | 16 + ...ROGRESS-onboarding-3-checklist-20261022.md | 63 ++ docs/testing/onboarding-journey-20261022.md | 28 +- frontend/DESIGN.md | 12 +- frontend/docs/VOICE.md | 25 + frontend/src/app/(app)/layout.tsx | 5 + frontend/src/app/(app)/page.tsx | 20 +- frontend/src/app/api/chart-profiles/route.ts | 1 + frontend/src/app/api/product-access/route.ts | 29 +- frontend/src/app/globals.css | 54 ++ frontend/src/components/app-sidebar.tsx | 6 + .../components/chart-page/chart-page-view.tsx | 29 +- .../components/conversation-follow-ups.tsx | 2 +- .../src/components/onboarding-checklist.tsx | 159 +++++ frontend/src/components/onboarding-dot.tsx | 13 + .../src/components/onboarding-hint-layer.tsx | 99 +++ .../src/components/onboarding-home-bridge.tsx | 77 ++ .../components/onboarding-progress-ring.tsx | 22 + .../src/components/people/people-page.tsx | 31 +- frontend/src/components/secondary-header.tsx | 6 +- frontend/src/components/ui/popover.tsx | 4 +- frontend/src/lib/onboarding-checklist.ts | 239 +++++++ frontend/src/lib/onboarding-hints.ts | 160 +++++ frontend/src/lib/onboarding-journey.tsx | 657 ++++++++++++++++++ frontend/src/lib/onboarding-signals.ts | 13 + frontend/src/lib/subject-birth.ts | 2 +- .../onboarding-checklist-20261022.test.ts | 304 ++++++++ 29 files changed, 2073 insertions(+), 18 deletions(-) create mode 100644 docs/tasks/PROGRESS-onboarding-3-checklist-20261022.md create mode 100644 frontend/src/components/onboarding-checklist.tsx create mode 100644 frontend/src/components/onboarding-dot.tsx create mode 100644 frontend/src/components/onboarding-hint-layer.tsx create mode 100644 frontend/src/components/onboarding-home-bridge.tsx create mode 100644 frontend/src/components/onboarding-progress-ring.tsx create mode 100644 frontend/src/lib/onboarding-checklist.ts create mode 100644 frontend/src/lib/onboarding-hints.ts create mode 100644 frontend/src/lib/onboarding-journey.tsx create mode 100644 frontend/src/lib/onboarding-signals.ts create mode 100644 frontend/tests/onboarding-checklist-20261022.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4801b3..4f6fbb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-24 — 右上角出现启程清单,用过的功能会打勾 + +- 建完档之后,首页右上角是「启程 n/7」。七项按真实使用打勾。合盘和个人报告写出计价表上的价格;对应开关关着时,这一行不出现。 +- 这一次把七项都做完,会再打开一次,写「都走过一遍了」。点「先不看了」之后不再出现。进站时已经都做过的账户不显示。 +- 八条提示一次只出一条。点「知道了」、点页面别处或按 Esc 之后不再出现。天空过场、兑换窗、弹窗和回答还在生成时先不出。 +- Skill 版本不变(BUG-1432)。 + ## 2026-10-24 — 建档先问看谁的盘,空白首页有三个起步问题 - 建档从三步变成四步:称呼、看谁的盘、出生日期与时间、出生地。 diff --git a/CONTEXT.md b/CONTEXT.md index bd076f42..e26150de 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,6 +76,14 @@ _避免使用_:当前星盘、关系、默认星盘 这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。 _避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户 +**启程清单**: +首页顶栏右侧的「启程 n/N」。七项对应已经能用的功能,勾从真实使用推导,不另存一份进度。点「先不看了」之后不再出现。收费项的价格跟计价表走。 +_避免使用_:新手任务、引导任务、单独把进度环叫进度条 + +**一次性提示**: +第一次用到某处功能时,指着那个位置说的一句。一共八条,同一时刻只出一条。看过就记下来,不再出现。天空过场、兑换窗、弹窗和回答还在生成时先不出。 +_避免使用_:新手引导、教程、教学气泡 + **星盘档案**: 侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。 _避免使用_:星盘库、星盘资料(设置里的旧面板) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bec87df8..ea1b0244 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-onboarding-3-checklist-20261022.md b/docs/tasks/PROGRESS-onboarding-3-checklist-20261022.md new file mode 100644 index 00000000..6c400c40 --- /dev/null +++ b/docs/tasks/PROGRESS-onboarding-3-checklist-20261022.md @@ -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 毫秒、勾飞进环、星点或闪烁。 diff --git a/docs/testing/onboarding-journey-20261022.md b/docs/testing/onboarding-journey-20261022.md index 340fae82..bed510c7 100644 --- a/docs/testing/onboarding-journey-20261022.md +++ b/docs/testing/onboarding-journey-20261022.md @@ -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,这一条消失。刷新后不再出现。提示出现时,输入框里的光标不被抢走。 + ## 以后追加 -- (三)启程清单、一次性提示 - (四)动效,以及打开「减少动态效果」再走一遍 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 43d747fa..ab8ab532 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 ### 首屏死屏兜底 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index f40c298f..0c004d2c 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -23,6 +23,31 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 **起步三问例外(2026-10-24)。** 只在已经建完档、首问还没用掉、并且当前是空白首页时,问候下面可以多一行「盘排好了。先问一个问题吧。」(帮别人看则是「{名字}的盘排好了。先问一个关于 TA 的问题吧。」),再加一枚「这一次不扣点」和三个起步问题。帮别人看时再加一枚「正在看:{名字} · {关系}」。这三问消失之后,首页回到上面这一行的规则。 +**启程清单与一次性提示(2026-10-24)。** 顶栏胶囊只写「启程 n/N」。清单里的句子只用下面这些,收费项的价格从计价表读,没有价格就不写「· N 点」: + +- 排好自己的盘 / 排好{名字}的盘 +- 问第一个问题 +- 打开今日星语 +- 看看自己的星盘 / 看看星盘 +- 加一位家人或朋友 / 加上你自己 +- 和 TA 合一次盘 / 和{名字}合一次盘 +- 生成个人报告 +- 去做 +- 先不看了 +- 都走过一遍了 +- 以后有新东西,我会在用到的时候再告诉你。 + +一次只出一条提示,按钮只写「知道了」: + +- 想接着聊,点下面任一个就直接问。 +- 你的盘、大运和分盘都在这里。 +- 这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。当前人物不是本人时,这句里的「你的」改成「{名字}的」。 +- 排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。 +- 一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。 +- 同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。 +- 有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。 +- 每天的天象和行运在这里,可以按日期翻。 + 首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。 ## 婚恋合盘报告(2026-10-19) diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 7a214d92..ec36ff4a 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -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 ( +
+ +
); diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 6faceb94..b0dc5a36 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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} /> + {replyAnnouncement} @@ -1205,7 +1219,10 @@ export default function Home() { ) : null}
- + + + +
{activeSession?.title || "新对话"} {activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? ( @@ -1223,6 +1240,7 @@ export default function Home() { ) : null}
+
))}
-
+
{view == null ? : <> {tab === "vedic" ? ( ) { if (questions.length === 0) return null; return ( -
+
{questions.map((question) => ( + ) : } + + ))} + + ); +} + +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 ( +
+

{CHECKLIST_CELEBRATION_TITLE}

+

{CHECKLIST_CELEBRATION_BODY}

+ +
+ ); + } + return ( +
+

{label}

+ + +
+ ); +} + +function ChecklistFace({ label, done, total }: { readonly label: string; readonly done: number; readonly total: number }) { + return ( + <> + + {label} + + ); +} + +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 = ; + + 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 = ( + + ); + + if (!ready) { + return ( + + ); + } + + if (mobile) { + return ( + + {face} + + + {journey.celebration ? CHECKLIST_CELEBRATION_TITLE : journey.label} + {journey.celebration ? {CHECKLIST_CELEBRATION_BODY} : null} + + {body} + + + + ); + } + + return ( + + {face} + + {journey.celebration ? ( + {CHECKLIST_CELEBRATION_BODY} + ) : null} + {body} + + + ); +} diff --git a/frontend/src/components/onboarding-dot.tsx b/frontend/src/components/onboarding-dot.tsx new file mode 100644 index 00000000..2374ff00 --- /dev/null +++ b/frontend/src/components/onboarding-dot.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey"; + +export function OnboardingDot() { + return
{selected.role === "other" ? ( people.some((person) => person.role === "self" && !person.incomplete) - ? + ? :

加上你自己后可合盘

) : null} {/* BUG-1081: 本人 is edited here too; only deleting stays off. */} diff --git a/frontend/src/components/secondary-header.tsx b/frontend/src/components/secondary-header.tsx index ad241911..d609bb29 100644 --- a/frontend/src/components/secondary-header.tsx +++ b/frontend/src/components/secondary-header.tsx @@ -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 (
- + + + +
{title} {besideTitle} diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx index 82ea3ce2..a901cb1f 100644 --- a/frontend/src/components/ui/popover.tsx +++ b/frontend/src/components/ui/popover.tsx @@ -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 ( 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; +} diff --git a/frontend/src/lib/onboarding-hints.ts b/frontend/src/lib/onboarding-hints.ts new file mode 100644 index 00000000..434b7f78 --- /dev/null +++ b/frontend/src/lib/onboarding-hints.ts @@ -0,0 +1,160 @@ +import { ONBOARDING_HINT_IDS, type OnboardingHintId } from "@/lib/onboarding-state"; + +export const HINT_COPY: Record = { + "follow-up": "想接着聊,点下面任一个就直接问。", + "sidebar-chart": "你的盘、大运和分盘都在这里。", + "tab-chart": "这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。", + "tab-info": "排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。", + "tab-dasha": "一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。", + "tab-west": "同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。", + "syn-ready": "有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。", + "ephemeris": "每天的天象和行运在这里,可以按日期翻。", +}; + +export const HINT_ANCHORS: Record = { + "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>; +}; + +function eligible(facts: HintFacts): OnboardingHintId | null { + const seen = new Set(facts.hintsSeen); + const ready: Record = { + "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: {} }); +} diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx new file mode 100644 index 00000000..bf55f7fc --- /dev/null +++ b/frontend/src/lib/onboarding-journey.tsx @@ -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>; + 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; + 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) => void; + readonly registerHome: (actions: HomeActions | null) => void; +}; + +const OnboardingJourneyContext = createContext(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>, right: Readonly>): 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 { + 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 { + 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 { + 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 { + 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 { + 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; + 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(INITIAL_REMOTE); + const [overlay, setOverlay] = useState({}); + const [live, setLiveState] = useState(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(null); + const wasStreaming = useRef(false); + const hadAnswerBefore = useRef(false); + const ephemerisPhase = useRef("watch"); + const arrivalPhase = useRef("pending"); + const visitedRef = useRef(null); + + useEffect(() => { + remoteRef.current = remote; + overlayRef.current = overlay; + }, [remote, overlay]); + + const setLive = useCallback((patch: Partial) => { + 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(() => ({ + 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(() => ({ + 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 {children}; +} + +function followUpAfterStreamSafe(was: boolean, streaming: boolean, had: boolean, has: boolean): boolean { + return was && !streaming && !had && has; +} diff --git a/frontend/src/lib/onboarding-signals.ts b/frontend/src/lib/onboarding-signals.ts new file mode 100644 index 00000000..ff2375bf --- /dev/null +++ b/frontend/src/lib/onboarding-signals.ts @@ -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?.(); +} diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts index c22f9149..e766fa0e 100644 --- a/frontend/src/lib/subject-birth.ts +++ b/frontend/src/lib/subject-birth.ts @@ -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; diff --git a/frontend/tests/onboarding-checklist-20261022.test.ts b/frontend/tests/onboarding-checklist-20261022.test.ts new file mode 100644 index 00000000..96da98bb --- /dev/null +++ b/frontend/tests/onboarding-checklist-20261022.test.ts @@ -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 { + 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 { + 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, //); + assert.match(page, //); + const header = page.slice(page.indexOf('
'), page.indexOf("composer-wrap")); + assert.match(header, //); + 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); +});