feat: standalone rectification entry at /rectification (issue #145)
- Sidebar 「生时校正」 under 星盘, behind a new account `rectification` flag. - /rectification page: status card from entry-summary (not started / in progress with window and confirmed event count / adopted time), a short data check, then the home rectification surface (?rectify=1, or ?rectify=new to start over; ?c=<id> to continue or view). No price on the page. - entry-summary gains session id, candidate range, confirmed evidence count and adopted times (add-only migration 20261028170000). - The offer card under a consultation answer becomes one line and a link to /rectification; an explicit request in chat also goes there. No case is opened from a conversation any more. - Starter home shows a rectification prompt for self charts with an estimated time; onboarding checklist gains 做一次生时校正 for them. - Chart-page link points to /rectification. Shell registers its router so home hooks can navigate client-side without useRouter in Home(). - Docs: VOICE, DESIGN, CHANGELOG, device checklist. Self profile only; other people are issue #160. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 生时校正有了自己的入口
|
||||
|
||||
- 侧栏「星盘」下面新增「生时校正」,打开一页就能看到现在的状态:没做过就开始,没做完就继续,做完了看结果或再校正一次。开始前先核对一遍出生资料。
|
||||
- 对话里提到出生时间不确定时,回答下面只给一句提示和「去做生时校正」的链接,不再从这段对话里开校正;明说要做校正时也跳到这一页。
|
||||
- 建完档、出生时间是估的,首页起步问题下面多一句提示;启程清单对这类账户多一项「做一次生时校正」。
|
||||
- 生时校正仍只支持本人,给家人做校正另见 issue #160。Skill 版本不变(issue #145)。
|
||||
|
||||
## 2026-10-28 — 两人同时做校正或生成报告时不再互相拖慢
|
||||
|
||||
- 排盘服务把最重的三类计算(生时校正打分、24 小时分块扫描、长报告参考)放到两个独立的工作进程里。两个人同时算时,各自的等待时间和一个人单独算差不多(以前接近翻倍)。结果不变。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 真机清单:生时校正独立入口(issue #145,2026-10-28)
|
||||
|
||||
本机没有 Docker 和后端,下面这些都没在浏览器里走过。部署到 staging 后由产品负责人逐条核对。
|
||||
|
||||
前置:staging 已应用迁移 `20261028170000_rectification_entry_summary_detail.sql`。
|
||||
|
||||
## 入口
|
||||
- [ ] 侧栏「星盘」下面有「生时校正」,桌面和手机抽屉都有;点了进 `/rectification`,当前项高亮。
|
||||
- [ ] 后台关掉 `product.rectification` 后刷新:侧栏没有这一项;直接打开 `/rectification` 显示「生时校正这会儿还做不了。」
|
||||
- [ ] 未登录打开 `/rectification`:「登录后可以做生时校正。」和「去登录」。
|
||||
|
||||
## 三种状态(本人档案)
|
||||
- [ ] 没做过:显示现在用的出生时间,「开始」→ 核对页 → 「开始校正」→ 首页打开校正面,自动发出开场。
|
||||
- [ ] 进行中:显示时间窗和已收大事数(只数确认过的),「继续」打开那次校正,不新开。
|
||||
- [ ] 已完成:显示采用的时刻;「查看结果」打开那次校正;「再校正一次」→ 核对 → 新开一次。
|
||||
- [ ] 核对页「改这份」打开本人资料编辑。
|
||||
- [ ] 本人资料不全(帮别人看的账户):提示先填资料,「去填资料」打开本人编辑。
|
||||
- [ ] 页面上任何地方都没有点数或价格。
|
||||
|
||||
## 其他入口
|
||||
- [ ] 咨询回答下面出现提议时,是一句「出生时间还不确定。」加链接「去做生时校正」,没有题数和点数;点了去 `/rectification`,不开案例;回答生成中链接置灰。
|
||||
- [ ] 在对话里明说「帮我做生时纠正」:回答一句过渡后跳到 `/rectification`。
|
||||
- [ ] 星盘页「出生时间校正」链接去 `/rectification`。
|
||||
- [ ] 新账户建档时出生时间选「家人记得」或「时段」或「不知道」:天空过场后首页起步问题下面有提示和「去校正」;选「出生证明」的账户没有。帮别人看的账户没有。
|
||||
- [ ] 同上的账户,启程清单 8 项,第三项「做一次生时校正」,「去做」到 `/rectification`;采用时间后打勾。出生证明账户仍是 7 项。
|
||||
|
||||
## 手机和减少动态效果
|
||||
- [ ] 375 px 宽:`/rectification`、核对页、首页提示都没有横向滚动,按钮不小于 44 px。
|
||||
- [ ] 打开系统「减少动态效果」:页面只淡入,没有位移。
|
||||
+12
-1
@@ -853,7 +853,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)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。第一问里的上升和月亮星座先取今日星语;没有今日星语时(出生时间不到分钟,或在看别人),读一次已算好的星盘,和星盘页共用同一份结果。上升只在出生时间范围内不会换座时才写;读到之前、读不到时,问题保持「从我的盘看……」的说法,不转圈(BUG-1459)。给别人建档后的天空过场,副句「那一刻,{名字}的上升在……」同样来自这一份,上升会换或读不到就不写这一句。已经建完档、还没点过「先不看了」、并且不是进站时就已经七项都做过时,顶栏右侧有「启程 n/N」。尺寸、抽屉和提示互斥见文首「启程清单与一次性提示」。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `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-28,issue #145,见「生时校正独立页」)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。第一问里的上升和月亮星座先取今日星语;没有今日星语时(出生时间不到分钟,或在看别人),读一次已算好的星盘,和星盘页共用同一份结果。上升只在出生时间范围内不会换座时才写;读到之前、读不到时,问题保持「从我的盘看……」的说法,不转圈(BUG-1459)。给别人建档后的天空过场,副句「那一刻,{名字}的上升在……」同样来自这一份,上升会换或读不到就不写这一句。已经建完档、还没点过「先不看了」、并且不是进站时就已经七项都做过时,顶栏右侧有「启程 n/N」。尺寸、抽屉和提示互斥见文首「启程清单与一次性提示」。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
### 首屏死屏兜底
|
||||
|
||||
@@ -1346,6 +1346,17 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
|
||||
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。开案还没成功时,同一张提议卡仍是普通提议(不说「已经开始」,按钮仍是「开始核对出生时间」)。开案成功并绑定来源后才换成已开始的文案。打开失败时按钮仍可点。
|
||||
|
||||
## 生时校正独立页(2026-10-28,issue #145)
|
||||
|
||||
推翻 2026-10-07「校正只从对话里的提议卡进入」(BUG-1247):产品要求校正单独有入口。
|
||||
|
||||
- **侧栏**:导航里「星盘」下面一项「生时校正」(Lucide `Clock3`),链接 `/rectification`。账户的 `rectification` 开关为 true 才出现;开关没到之前不出现,和「婚恋合盘」同一规则。
|
||||
- **`/rectification`**:沿用二级页外壳(`SecondaryPageShell`),标题旁不放人物切换(校正只支持本人,他人见 #160)。正文一栏宽 `min(100%, 680px)` 居中,上边距 32px(手机 20px,左右 16px)。一句说明,下面一张状态卡(`.rectification-page-card`:细边、`--radius-lg`、24px 内边距,手机 20px)。状态卡分没做过 / 进行中 / 已完成三种,状态只来自服务端 `entry-summary`(含会话 id、时间窗、确认过的大事数、采用时间)。进行中是两列数字(时间窗、已收大事);已完成时刻 28px 衬线数字。主按钮是组件库默认按钮,次按钮描边,都是真链接(`AppLink`)。「开始」「再校正一次」先进核对:两行资料表(手机上标签和值上下排),「开始校正」+ 链接「改这份」(`/people?self=1`)。开始后进首页校正面(`/?rectify=1` 续上或新开;做完之后再校正用 `/?rectify=new`),「继续」「查看结果」打开那次校正的会话(`/?c=<id>`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。
|
||||
- **对话回答下面**(`.rectification-offer-card`):改成一行,不再是带底色的卡片:灰字一句 + 主色下划线链接「去做生时校正」和 Lucide `ArrowRight`。点链接去 `/rectification`,不从这段对话开案例;对话进行中链接置灰不可点。回来后的分隔线、「按校正前的时间」不变(旧的已绑定案例照常显示)。
|
||||
- **明确要求**:过渡句之后跳到 `/rectification`,不再直接打开校正面。
|
||||
- **建完档的首页**:起步三问下面一行浅底圆角条(`.starter-rectify-hint`,`--color-canvas-soft`),一句 + 描边胶囊「去校正」。只在本人出生时间是估的、首问还没用掉时出现,按启程进场依次浮出。
|
||||
- **启程清单**:本人时间是估的账户在「问第一个问题」后多一项「做一次生时校正」,清单变成 8 项;其他账户仍是 7 项。
|
||||
|
||||
## 用量页分段计时(2026-10-05,在途)
|
||||
|
||||
`/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。
|
||||
|
||||
@@ -63,6 +63,22 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
|
||||
## 生时校正独立入口(2026-10-28,issue #145)
|
||||
|
||||
产品 2026-10-28 定:校正有自己的入口,不再从对话里开案例。下面这节「生时校正从对话进入」里的卡片文案和「明确要求则直接进入」由本节取代,其余(何时提议、做不了必须说一句、正文怎么提、回来)不变。
|
||||
|
||||
- **侧栏**:「生时校正」,在「星盘」下面。
|
||||
- **页面**:标题「生时校正」,下面一句「出生时间不确定时,用几件记得日期的大事,把钟点一步步收窄。」页面上不写点数和价格。
|
||||
- 没做过:小字「你现在用的时间」,下面是出生日期和时间(与建档回读同一句),按钮「开始」。
|
||||
- 进行中:小字「进行中」,「时间窗」HH:MM – HH:MM,「已收大事」N 件(只数用户确认过的),按钮「继续」。
|
||||
- 已完成:小字「已采用」,时刻 HH:MM,「之后排盘、咨询和报告都用这个时间。」按钮「查看结果」「再校正一次」。
|
||||
- 核对:标题「先核对一下资料」,两行「出生时间」「出生城市」,按钮「开始校正」,链接「改这份」。返回按钮「返回」。
|
||||
- 读不到:「校正记录这会儿读不到。」按钮「再试一次」。未登录:「登录后可以做生时校正。」「去登录」。开关关着:「生时校正这会儿还做不了。」本人资料不全:「先把你自己的出生日期、时间和地点填好,才能做生时校正。」「去填资料」。
|
||||
- **对话回答下面**:一句「出生时间还不确定。」加链接「去做生时校正」,指向 `/rectification`。不写题数和点数,开没开过案都是这一句。
|
||||
- **明确要求**:过渡句仍是「好,我们开始核对出生时间。」/「核对还没做完,我们接着做。」,之后跳到 `/rectification`,由页面给「开始」或「继续」。
|
||||
- **建完档的首页**:本人出生时间是估的(家人记得、时段、不知道),且首问还没用掉时,起步三问下面一行「出生时间还不确定,可以先做生时校正。」加按钮「去校正」。帮别人看时不出现。
|
||||
- **启程清单**:同样只对本人时间是估的账户多一项「做一次生时校正」,采用过时间就打勾。
|
||||
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
|
||||
|
||||
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ShellFrame } from "@/components/shell-frame";
|
||||
import { SessionDeleteDialog } from "@/components/session-delete-dialog";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { useShellSessionActions } from "@/hooks/use-shell-session-actions";
|
||||
import { navigateAppPath } from "@/lib/app-navigation";
|
||||
import { navigateAppPath, registerShellNavigate } from "@/lib/app-navigation";
|
||||
import { sessionHref } from "@/lib/chat-session-url";
|
||||
import { OnboardingJourneyProvider } from "@/lib/onboarding-journey";
|
||||
import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list";
|
||||
@@ -33,7 +33,12 @@ function AppShell({ children }: { children: ReactNode }) {
|
||||
const registration = useShellRegistration();
|
||||
const { sessions, account } = sessionListSidebarModel(list, registration);
|
||||
const compatibilityEnabled = list.account?.compatibility === true;
|
||||
const rectificationEnabled = list.account?.rectification === true;
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
registerShellNavigate((href) => router.push(href));
|
||||
return () => registerShellNavigate(null);
|
||||
}, [router]);
|
||||
// The rows' menu, delete confirmation and drag sorting, on every page (issue #116).
|
||||
const sessionActions = useShellSessionActions(list, registration?.sessionHooks ?? null);
|
||||
|
||||
@@ -51,6 +56,7 @@ function AppShell({ children }: { children: ReactNode }) {
|
||||
settled={list.settled}
|
||||
signedOut={list.signedOut}
|
||||
compatibilityEnabled={compatibilityEnabled}
|
||||
rectificationEnabled={rectificationEnabled}
|
||||
controls={registration?.controls}
|
||||
sessionControls={sessionActions.controls}
|
||||
onOpenSession={(sessionId) => navigateAppPath(sessionHref("", sessionId), router.push)}
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
import { CreditBalanceButton } from "@/components/credit-balance-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { navigateInApp } from "@/lib/app-navigation";
|
||||
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
|
||||
import { birthTimeNeedsRectification } from "@/lib/rectification-page";
|
||||
import { ProfilePanel } from "@/components/profile-panel";
|
||||
import { AccountDeletionSection } from "@/components/account-deletion-section";
|
||||
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
|
||||
@@ -206,6 +209,8 @@ function clearNudgeComposerTimer() {
|
||||
|
||||
export default function Home() {
|
||||
const sessionList = useSessionList();
|
||||
// Every chat path into rectification lands on the standalone page (issue #145).
|
||||
const openRectificationPage = () => navigateInApp(RECTIFICATION_PAGE_HREF);
|
||||
const {
|
||||
sessions,
|
||||
setSessions,
|
||||
@@ -462,7 +467,7 @@ export default function Home() {
|
||||
|
||||
const {
|
||||
setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
|
||||
openRectificationFromChat, openRectificationSession, reopenRectificationSession,
|
||||
openRectificationSession, reopenRectificationSession,
|
||||
} = useRectificationSurface({
|
||||
account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
|
||||
persistSession, profile, rectification, setRectification,
|
||||
@@ -790,7 +795,7 @@ export default function Home() {
|
||||
startNewChat,
|
||||
continueInNewChat,
|
||||
refreshAccount,
|
||||
openAccountDialog, openRectificationSession, openRectificationFromChat,
|
||||
openAccountDialog, openRectificationSession, openRectificationPage,
|
||||
onboardingPaywallShown, setOnboardingPaywallOpen,
|
||||
});
|
||||
const jumpToLatestVisible = !rectificationSurfaceOpen
|
||||
@@ -1096,8 +1101,8 @@ export default function Home() {
|
||||
onFollowUp(question) {
|
||||
void send(question, activeSession?.theme);
|
||||
},
|
||||
onStartRectification(sourceSessionId) {
|
||||
void openRectificationFromChat(sourceSessionId);
|
||||
onStartRectification() {
|
||||
openRectificationPage();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1347,6 +1352,7 @@ export default function Home() {
|
||||
chartSignsFallback={dailyStarlanguage.kind === "unavailable"}
|
||||
onAsk={(text) => void startSuggestedConsultation(text)}
|
||||
onNudge={nudgeComposer}
|
||||
rectifyHint={currentSubjectId === "self" && account.rectification === true && birthTimeNeedsRectification(profile)}
|
||||
/>
|
||||
) : null}
|
||||
{jumpToLatestVisible && (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { RectificationEntryPage } from "@/components/rectification-page/rectification-entry-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "生时校正 · Jyotisha",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function RectificationRoute() {
|
||||
return <RectificationEntryPage />;
|
||||
}
|
||||
@@ -136,7 +136,10 @@ export async function GET() {
|
||||
const activeSubscription = activeSubscriptions?.[0] ?? null;
|
||||
const isAdmin = await isAdminUser(user);
|
||||
const adminUrl = isAdmin ? adminEntryUrl() : null;
|
||||
const compatibility = await isProductEnabled("compatibility");
|
||||
const [compatibility, rectification] = await Promise.all([
|
||||
isProductEnabled("compatibility"),
|
||||
isProductEnabled("rectification"),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email ?? null },
|
||||
@@ -173,6 +176,7 @@ export async function GET() {
|
||||
},
|
||||
chartBirth: resolveServerOwnedChartBirth(profile),
|
||||
compatibility,
|
||||
rectification,
|
||||
firstConsultFree: firstConsultFreeFromProfile(profile),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -6967,15 +6967,13 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
sits under the answer that offered it, at the same inset as the follow-ups;
|
||||
the return divider is a quiet centred line, not a message; answers written
|
||||
before the birth time changed get one caption above them. */
|
||||
/* Issue #145: one line and a link to /rectification, not a box with a button. */
|
||||
.rectification-offer-card {
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
margin: var(--space-2) 0 var(--space-3) var(--assistant-content-inset);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-muted);
|
||||
}
|
||||
.rectification-offer-card p {
|
||||
margin: 0;
|
||||
@@ -6983,6 +6981,28 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rectification-offer-link {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
color: var(--color-action);
|
||||
font-size: var(--type-body-sm);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.rectification-offer-link svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.rectification-offer-link:hover {
|
||||
color: var(--color-action-hover);
|
||||
}
|
||||
.rectification-offer-link[aria-disabled="true"] {
|
||||
color: var(--color-ink-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The divider's entry carries no actions row, so it needs no trailing gap of its own. */
|
||||
.message-entry--divider {
|
||||
padding: 0;
|
||||
@@ -7011,3 +7031,173 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
justify-content: flex-start;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* /rectification (issue #145): one status card, then a short check before the
|
||||
case opens. Same column width as the conversation; no price on this page. */
|
||||
.rectification-page {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
width: min(100%, 680px);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6) var(--space-10);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rectification-page-intro {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-md);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.rectification-page-card,
|
||||
.rectification-page-check {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.rectification-page-card {
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.rectification-page-label {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-page-label--done {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.rectification-page-value,
|
||||
.rectification-page-heading {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.rectification-page-heading {
|
||||
font-size: var(--type-title-lg);
|
||||
}
|
||||
.rectification-page-time {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rectification-page-note {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.rectification-page-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
.rectification-page-stats dt {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-page-stats dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-lg);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rectification-page-facts {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.rectification-page-facts > div {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
}
|
||||
.rectification-page-facts > div + div {
|
||||
border-top: 1px solid var(--color-canvas-muted);
|
||||
}
|
||||
.rectification-page-facts dt {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-page-facts dd {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rectification-page-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.rectification-page-action {
|
||||
text-decoration: none;
|
||||
}
|
||||
.rectification-page-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--color-action);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-page-back {
|
||||
justify-self: start;
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.rectification-page {
|
||||
padding: var(--space-5) var(--space-4) var(--space-8);
|
||||
}
|
||||
.rectification-page-card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.rectification-page-facts > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Onboarding prompt under the starter questions (issue #145): self chart with an
|
||||
estimated time only. A quiet row, not a card; the link goes to /rectification. */
|
||||
.starter-rectify-hint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.starter-rectify-hint p {
|
||||
margin: 0;
|
||||
color: var(--color-ink-strong);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.starter-rectify-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.starter-rectify-link:hover {
|
||||
background: var(--color-canvas-muted);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
FileText,
|
||||
Heart,
|
||||
LogOut,
|
||||
@@ -113,6 +114,8 @@ export type AppSidebarProps = {
|
||||
signedOut?: boolean;
|
||||
/** Product flag from the shell. Absent means hidden, so the entry does not flash before the flag arrives. */
|
||||
compatibilityEnabled?: boolean;
|
||||
/** Same rule for the /rectification entry (issue #145). */
|
||||
rectificationEnabled?: boolean;
|
||||
controls?: AppSidebarControls;
|
||||
/** The rows' menu and drag sorting, from the shell on every page. */
|
||||
sessionControls?: SidebarSessionControls | null;
|
||||
@@ -122,6 +125,7 @@ export type AppSidebarProps = {
|
||||
|
||||
const NAV_PAGES = [
|
||||
{ href: "/chart", label: "星盘", icon: Orbit },
|
||||
{ href: "/rectification", label: "生时校正", icon: Clock3 },
|
||||
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
|
||||
{ href: "/reports", label: "我的报告", icon: FileText },
|
||||
{ href: "/synastry", label: "婚恋合盘", icon: Heart },
|
||||
@@ -136,6 +140,7 @@ export function AppSidebar({
|
||||
settled = true,
|
||||
signedOut = false,
|
||||
compatibilityEnabled = false,
|
||||
rectificationEnabled = false,
|
||||
controls,
|
||||
sessionControls,
|
||||
onOpenSession,
|
||||
@@ -350,7 +355,7 @@ export function AppSidebar({
|
||||
</SidebarMenuLink>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
{NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => {
|
||||
{NAV_PAGES.filter((page) => (page.href !== "/synastry" || compatibilityEnabled) && (page.href !== "/rectification" || rectificationEnabled)).map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<SidebarMenuItem key={href} className="sidebar-nav-item">
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
type ChartViewResponse,
|
||||
type ChartViewTabId,
|
||||
} from "@/lib/chart-view-contract";
|
||||
import { rectificationEntryHref } from "@/lib/chat-session-url";
|
||||
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
|
||||
import { setComposerDraft } from "@/lib/composer-draft";
|
||||
import { lagnaPrecheckLine } from "@/lib/lagna-precheck";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
@@ -84,7 +84,7 @@ function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck
|
||||
return (
|
||||
<p className="chart-lagna-precheck">
|
||||
{line.before}
|
||||
{line.linked ? <a href={rectificationEntryHref()}>出生时间校正</a> : null}
|
||||
{line.linked ? <a href={RECTIFICATION_PAGE_HREF}>出生时间校正</a> : null}
|
||||
{line.after}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
|
||||
import {
|
||||
RECTIFICATION_BEFORE_CHANGE_LABEL,
|
||||
RECTIFICATION_OFFER_CONTINUE_LABEL,
|
||||
RECTIFICATION_OFFER_OPENED_COPY,
|
||||
RECTIFICATION_OFFER_START_LABEL,
|
||||
rectificationOfferCardCopy,
|
||||
RECTIFICATION_OFFER_HINT_COPY,
|
||||
RECTIFICATION_OFFER_LINK_LABEL,
|
||||
rectificationReturnDividerText,
|
||||
rectificationRevisitLabel,
|
||||
type RectificationOfferRecord,
|
||||
@@ -14,11 +16,10 @@ import {
|
||||
} from "@/lib/rectification-in-chat";
|
||||
|
||||
/**
|
||||
* The card under a consultation answer that offered birth-time rectification
|
||||
* (BUG-1248). The server decided it may be shown and wrote the price; the
|
||||
* click opens the case (the charge comes with the first answered question).
|
||||
* A card whose case is open says so and offers to go back in; a card whose
|
||||
* case has returned is gone (the divider below says what happened).
|
||||
* Under a consultation answer whose reading was held back by the birth time
|
||||
* (BUG-1248). Issue #145: one line and a link to /rectification. The page
|
||||
* shows 开始 or 继续; nothing is opened or charged from this conversation.
|
||||
* A card whose case has returned is gone (the divider below says what happened).
|
||||
*/
|
||||
export function RectificationOfferCard({
|
||||
offer,
|
||||
@@ -26,15 +27,18 @@ export function RectificationOfferCard({
|
||||
onStart,
|
||||
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
|
||||
if (offer.status === "returned") return null;
|
||||
// 「已经开始」 / 继续核对 only after the case is bound. autoOpen still shows
|
||||
// the ordinary offer and 「开始核对出生时间」 until status is opened.
|
||||
const opened = offer.status === "opened";
|
||||
const follow = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (!disabled) onStart();
|
||||
};
|
||||
return (
|
||||
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
|
||||
<p>{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}</p>
|
||||
<Button type="button" variant={opened ? "outline" : "default"} size="sm" disabled={disabled} onClick={onStart}>
|
||||
{opened ? RECTIFICATION_OFFER_CONTINUE_LABEL : RECTIFICATION_OFFER_START_LABEL}
|
||||
</Button>
|
||||
<div className="rectification-offer-card" role="group" aria-label="出生时间">
|
||||
<p>{RECTIFICATION_OFFER_HINT_COPY}</p>
|
||||
<a className="rectification-offer-link" href={RECTIFICATION_PAGE_HREF} aria-disabled={disabled || undefined} onClick={follow}>
|
||||
{RECTIFICATION_OFFER_LINK_LABEL}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { useSessionList } from "@/hooks/use-session-list";
|
||||
import { formatBirthMoment, profilePlaceLabel, readProfile, selfChartComplete } from "@/lib/home-profile";
|
||||
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
import {
|
||||
RECTIFICATION_PAGE_COPY as COPY,
|
||||
evidenceCountLabel,
|
||||
rectificationPageState,
|
||||
rectificationSessionHref,
|
||||
rectificationStartHref,
|
||||
type RectificationPageState,
|
||||
} from "@/lib/rectification-page";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SummaryRead =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly status: "failed" }
|
||||
| { readonly status: "ready"; readonly summary: RectificationEntrySummary };
|
||||
|
||||
const EMPTY_SUMMARY: RectificationEntrySummary = {
|
||||
hasResumableCase: false,
|
||||
hasTerminalCaseWithTime: false,
|
||||
latestResumable: null,
|
||||
latestTerminal: null,
|
||||
};
|
||||
|
||||
async function readEntrySummary(): Promise<SummaryRead> {
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
|
||||
if (!response.ok) return { status: "failed" };
|
||||
return { status: "ready", summary: entrySummaryFromResponse(await response.json()) };
|
||||
} catch {
|
||||
return { status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
/** A real link styled as a button; plain clicks stay on the client router (AppLink). */
|
||||
function PageLink({
|
||||
href,
|
||||
variant = "default",
|
||||
children,
|
||||
}: {
|
||||
readonly href: string;
|
||||
readonly variant?: "default" | "outline" | "link";
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppLink href={href} data-onboarding-press="" className={cn(buttonVariants({ variant }), "rectification-page-action")}>
|
||||
{children}
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function RectificationEntryPage() {
|
||||
const list = useSessionList();
|
||||
const signedIn = Boolean(list.account) && !list.signedOut;
|
||||
const enabled = list.account?.rectification;
|
||||
const profile = readProfile(list.account?.profile);
|
||||
const selfComplete = selfChartComplete(profile);
|
||||
const [read, setRead] = useState<SummaryRead>({ status: "loading" });
|
||||
const [checking, setChecking] = useState(false);
|
||||
const needsSummary = signedIn && enabled === true && selfComplete;
|
||||
|
||||
const load = useCallback(() => {
|
||||
setRead({ status: "loading" });
|
||||
void readEntrySummary().then(setRead);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsSummary) return;
|
||||
let live = true;
|
||||
void readEntrySummary().then((next) => {
|
||||
if (live) setRead(next);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [needsSummary]);
|
||||
|
||||
if (!list.settled && !list.signedOut) {
|
||||
return <SecondaryPageShell title={COPY.title} besideTitle={<span />} waiting={COPY.waiting} />;
|
||||
}
|
||||
if (needsSummary && read.status === "loading") {
|
||||
return <SecondaryPageShell title={COPY.title} besideTitle={<span />} waiting={COPY.waiting} />;
|
||||
}
|
||||
|
||||
const state: RectificationPageState = rectificationPageState({
|
||||
signedOut: !signedIn,
|
||||
enabled,
|
||||
selfComplete,
|
||||
summary: read.status === "ready" ? read.summary : EMPTY_SUMMARY,
|
||||
});
|
||||
|
||||
return (
|
||||
<SecondaryPageShell title={COPY.title} besideTitle={<span />}>
|
||||
<div className="rectification-page">
|
||||
{checking && (state.kind === "none" || state.kind === "done") ? (
|
||||
<section className="rectification-page-check" aria-labelledby="rectification-check-title">
|
||||
<Button type="button" variant="ghost" className="rectification-page-back" onClick={() => setChecking(false)}>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
{COPY.back}
|
||||
</Button>
|
||||
<h2 id="rectification-check-title" className="rectification-page-heading" data-onboarding-enter="rectification-check" data-onboarding-index={0}>
|
||||
{COPY.checkTitle}
|
||||
</h2>
|
||||
<dl className="rectification-page-facts" data-onboarding-enter="rectification-facts" data-onboarding-index={1}>
|
||||
<div>
|
||||
<dt>{COPY.birthTime}</dt>
|
||||
<dd>{formatBirthMoment(profile)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{COPY.birthPlace}</dt>
|
||||
<dd>{profilePlaceLabel(profile)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="rectification-page-actions" data-onboarding-enter="rectification-check-actions" data-onboarding-index={2}>
|
||||
<PageLink href={rectificationStartHref(state)}>{COPY.confirmStart}</PageLink>
|
||||
<PageLink href="/people?self=1" variant="link">{COPY.editProfile}</PageLink>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="rectification-page-intro" data-onboarding-enter="rectification-intro" data-onboarding-index={0}>{COPY.intro}</p>
|
||||
<StatusCard state={state} failed={needsSummary && read.status === "failed"} onRetry={load} onStart={() => setChecking(true)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
state,
|
||||
failed,
|
||||
onRetry,
|
||||
onStart,
|
||||
}: {
|
||||
readonly state: RectificationPageState;
|
||||
readonly failed: boolean;
|
||||
readonly onRetry: () => void;
|
||||
readonly onStart: () => void;
|
||||
}) {
|
||||
const list = useSessionList();
|
||||
const profile = readProfile(list.account?.profile);
|
||||
const enter = { "data-onboarding-enter": "rectification-status", "data-onboarding-index": 1 } as const;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<section className="rectification-page-card" {...enter}>
|
||||
<p className="rectification-page-note">{COPY.loadFailed}</p>
|
||||
<div className="rectification-page-actions">
|
||||
<Button type="button" variant="outline" onClick={onRetry}>{COPY.retry}</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
switch (state.kind) {
|
||||
case "signed-out":
|
||||
return (
|
||||
<section className="rectification-page-card" {...enter}>
|
||||
<p className="rectification-page-note">{COPY.signedOut}</p>
|
||||
<div className="rectification-page-actions"><PageLink href="/login">{COPY.signIn}</PageLink></div>
|
||||
</section>
|
||||
);
|
||||
case "unavailable":
|
||||
return (
|
||||
<section className="rectification-page-card" {...enter}>
|
||||
<p className="rectification-page-note">{COPY.unavailable}</p>
|
||||
</section>
|
||||
);
|
||||
case "self-missing":
|
||||
return (
|
||||
<section className="rectification-page-card" {...enter}>
|
||||
<p className="rectification-page-note">{COPY.selfMissing}</p>
|
||||
<div className="rectification-page-actions"><PageLink href="/people?self=1">{COPY.fillSelf}</PageLink></div>
|
||||
</section>
|
||||
);
|
||||
case "none":
|
||||
return (
|
||||
<section className="rectification-page-card" aria-label={COPY.currentTime} {...enter}>
|
||||
<p className="rectification-page-label">{COPY.currentTime}</p>
|
||||
<p className="rectification-page-value">{formatBirthMoment(profile)}</p>
|
||||
<div className="rectification-page-actions">
|
||||
<Button type="button" data-onboarding-press="" onClick={onStart}>{COPY.start}</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case "active":
|
||||
return (
|
||||
<section className="rectification-page-card" aria-label={COPY.activeLabel} {...enter}>
|
||||
<p className="rectification-page-label">{COPY.activeLabel}</p>
|
||||
<dl className="rectification-page-stats">
|
||||
{state.range ? (
|
||||
<div>
|
||||
<dt>{COPY.windowLabel}</dt>
|
||||
<dd>{state.range.start} – {state.range.end}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{state.evidenceCount !== null ? (
|
||||
<div>
|
||||
<dt>{COPY.evidenceLabel}</dt>
|
||||
<dd>{evidenceCountLabel(state.evidenceCount)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="rectification-page-actions">
|
||||
<PageLink href={rectificationSessionHref(state.sessionId)}>{COPY.resume}</PageLink>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case "done":
|
||||
return (
|
||||
<section className="rectification-page-card" aria-label={COPY.doneLabel} {...enter}>
|
||||
<p className="rectification-page-label rectification-page-label--done">{COPY.doneLabel}</p>
|
||||
<p className="rectification-page-time">{state.time}</p>
|
||||
<p className="rectification-page-note">{COPY.doneNote}</p>
|
||||
<div className="rectification-page-actions">
|
||||
{state.sessionId ? <PageLink href={rectificationSessionHref(state.sessionId)}>{COPY.viewResult}</PageLink> : null}
|
||||
<Button type="button" variant="outline" data-onboarding-press="" onClick={onStart}>{COPY.again}</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
type StarterAudience,
|
||||
type StarterChartSigns,
|
||||
} from "@/lib/starter-questions";
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
|
||||
import { RECTIFICATION_HOME_HINT } from "@/lib/rectification-page";
|
||||
|
||||
export function StarterQuestions({
|
||||
audience,
|
||||
@@ -20,6 +23,7 @@ export function StarterQuestions({
|
||||
chartSignsFallback,
|
||||
onAsk,
|
||||
onNudge,
|
||||
rectifyHint = false,
|
||||
}: {
|
||||
readonly audience: StarterAudience;
|
||||
readonly subjectId: string;
|
||||
@@ -28,6 +32,8 @@ export function StarterQuestions({
|
||||
readonly chartSignsFallback: boolean;
|
||||
readonly onAsk: (question: string) => void;
|
||||
readonly onNudge: () => void;
|
||||
/** Self chart with an estimated time (issue #145): one line and a link to /rectification. */
|
||||
readonly rectifyHint?: boolean;
|
||||
}) {
|
||||
const questions = starterQuestions(audience, signs);
|
||||
const moonKnown = Boolean(signs.moonSign);
|
||||
@@ -52,6 +58,12 @@ export function StarterQuestions({
|
||||
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-1" data-onboarding-index={1} data-onboarding-follow-source="" onClick={() => onAsk(questions[1])}>{questions[1]}</Button>
|
||||
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-2" data-onboarding-index={2} onClick={onNudge}>{questions[2]}</Button>
|
||||
</div>
|
||||
{rectifyHint ? (
|
||||
<div className="starter-rectify-hint" data-onboarding-enter="starter-rectify" data-onboarding-index={3}>
|
||||
<p>{RECTIFICATION_HOME_HINT.text}</p>
|
||||
<AppLink className="starter-rectify-link" href={RECTIFICATION_PAGE_HREF} data-onboarding-press="">{RECTIFICATION_HOME_HINT.link}</AppLink>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,8 +155,11 @@ export type ConsultationRunParams = {
|
||||
onboardingPaywallShown?: MutableRefObject<boolean>;
|
||||
setOnboardingPaywallOpen?: (open: boolean) => void;
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
|
||||
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
|
||||
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
|
||||
/**
|
||||
* A turn that asked for rectification (BUG-1306) goes to the standalone
|
||||
* /rectification page (issue #145). No case is opened from the chat.
|
||||
*/
|
||||
openRectificationPage: () => void;
|
||||
};
|
||||
|
||||
export function useConsultationRun(params: ConsultationRunParams) {
|
||||
@@ -217,7 +220,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
onboardingPaywallShown,
|
||||
setOnboardingPaywallOpen,
|
||||
openRectificationSession,
|
||||
openRectificationFromChat,
|
||||
openRectificationPage,
|
||||
} = params;
|
||||
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
|
||||
const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({
|
||||
@@ -1088,17 +1091,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(sessionId, () => completedSession);
|
||||
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
|
||||
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
|
||||
} else if (rectificationDirectOpen?.mode === "new") {
|
||||
// Stored offer stays offered. bindOfferToCase marks opened only after
|
||||
// the case opens. A failed open must not mark it opened here.
|
||||
void Promise.resolve(openRectificationFromChat(rectificationDirectOpen.sourceSessionId)).then((opened) => {
|
||||
if (opened) return;
|
||||
}, () => {
|
||||
// The opener threw. Leave the stored offer offered.
|
||||
});
|
||||
}
|
||||
// Resume or new, the page decides: it shows 继续 for an open case and
|
||||
// 开始 otherwise. The stored offer stays offered.
|
||||
if (rectificationDirectOpen) openRectificationPage();
|
||||
try {
|
||||
await persistSession(completedSession);
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolveSessionTitle } from "@/lib/agent-reply";
|
||||
import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync";
|
||||
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { parseRectificationEntryIntent, RECTIFICATION_ENTRY_QUERY_KEY, writeSessionUrl } from "@/lib/chat-session-url";
|
||||
import { RECTIFICATION_ENTRY_QUERY_KEY, rectificationEntryIntent, writeSessionUrl } from "@/lib/chat-session-url";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
import { readCurrentSubjectId } from "@/lib/current-subject";
|
||||
import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile";
|
||||
@@ -127,8 +127,9 @@ export type RectificationSurfaceParams = {
|
||||
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
|
||||
};
|
||||
|
||||
/** The live chart-page opener. `/?rectify=1` queues one homepage open after the hook is ready. */
|
||||
/** The live openers. `/?rectify=1` queues one homepage open, `/?rectify=new` one new case, after the hook is ready. */
|
||||
let latestChartRectifyStart: () => Promise<unknown> = async () => undefined;
|
||||
let latestNewRectifyStart: () => Promise<unknown> = async () => undefined;
|
||||
|
||||
export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
const {
|
||||
@@ -475,17 +476,19 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
rectificationSessionOpenerRef.current = openRectificationSession;
|
||||
// Latest starter, including after a strict-mode remount, so the queued open hits the live hook.
|
||||
latestChartRectifyStart = openChartPageRectification;
|
||||
latestNewRectifyStart = startNewRectification;
|
||||
if (chartRectifyConsumed.current) return;
|
||||
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending) return;
|
||||
const search = globalThis.location?.search ?? "";
|
||||
if (!parseRectificationEntryIntent(search)) return;
|
||||
const intent = rectificationEntryIntent(search);
|
||||
if (!intent) return;
|
||||
chartRectifyConsumed.current = true;
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(RECTIFICATION_ENTRY_QUERY_KEY);
|
||||
globalThis.history.replaceState(null, "", `${url.pathname}${url.search}`);
|
||||
sessionSelectionSource.current = "user";
|
||||
queueMicrotask(() => {
|
||||
void latestChartRectifyStart();
|
||||
void (intent === "new" ? latestNewRectifyStart() : latestChartRectifyStart());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -97,6 +97,21 @@ function isModifiedClick(event: AppNavigationClick): boolean {
|
||||
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app shell's client router, registered by `(app)/layout.tsx`. Lets code
|
||||
* that runs without its own router (the home page's hooks) still move on the
|
||||
* client. Without a shell (tests, other roots) it is a document load.
|
||||
*/
|
||||
let shellNavigate: ((href: string) => void) | null = null;
|
||||
|
||||
export function registerShellNavigate(navigate: ((href: string) => void) | null): void {
|
||||
shellNavigate = navigate;
|
||||
}
|
||||
|
||||
export function navigateInApp(href: string): void {
|
||||
navigateAppPath(href, shellNavigate ?? undefined);
|
||||
}
|
||||
|
||||
export function interceptAppNavigation(event: AppNavigationClick, href: string): void {
|
||||
if (event.defaultPrevented || isModifiedClick(event)) return;
|
||||
if (!href.startsWith("/") || href.startsWith("//")) return;
|
||||
|
||||
@@ -38,14 +38,28 @@ export function parseNewChatIntent(search: string): boolean {
|
||||
return params.has(NEW_CHAT_QUERY_KEY);
|
||||
}
|
||||
|
||||
/** Home URL the chart-page sentence uses. The case itself is still the self profile only. */
|
||||
export function rectificationEntryHref(): string {
|
||||
return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=1`;
|
||||
/** The standalone entry (issue #145). Every link that offers rectification points here. */
|
||||
export const RECTIFICATION_PAGE_HREF = "/rectification";
|
||||
|
||||
/**
|
||||
* Home URL that opens the rectification surface. `homepage` resumes an open
|
||||
* case or creates one; `new` starts a fresh case ("再校正一次"). The case is
|
||||
* still the self profile only.
|
||||
*/
|
||||
export function rectificationEntryHref(intent: "homepage" | "new" = "homepage"): string {
|
||||
return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=${intent === "new" ? "new" : "1"}`;
|
||||
}
|
||||
|
||||
export function rectificationEntryIntent(search: string): "homepage" | "new" | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
const value = params.get(RECTIFICATION_ENTRY_QUERY_KEY);
|
||||
if (value === "1") return "homepage";
|
||||
if (value === "new") return "new";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseRectificationEntryIntent(search: string): boolean {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
return params.get(RECTIFICATION_ENTRY_QUERY_KEY) === "1";
|
||||
return rectificationEntryIntent(search) !== null;
|
||||
}
|
||||
|
||||
export function parseSessionUrlQuery(search: string): SessionUrlQuery {
|
||||
|
||||
@@ -47,16 +47,26 @@ export type OpenRectificationCaseResponse = Omit<OpenRectificationCaseResponseWi
|
||||
readonly status: RectificationCaseStatus;
|
||||
};
|
||||
|
||||
/** Range, count and times feed the /rectification status card (issue #145); older payloads omit them. */
|
||||
export type RectificationEntryRange = Readonly<{ startTime: string; endTime: string }>;
|
||||
|
||||
type LatestResumableWire = Readonly<{
|
||||
caseId: string;
|
||||
status: string;
|
||||
lastActivityAt: string;
|
||||
sessionId?: string | null;
|
||||
candidateRange?: RectificationEntryRange | null;
|
||||
acceptedTime?: string | null;
|
||||
evidenceCount?: number | null;
|
||||
}>;
|
||||
|
||||
type LatestTerminalWire = Readonly<{
|
||||
caseId: string;
|
||||
status: string;
|
||||
hasUsableTime: boolean;
|
||||
sessionId?: string | null;
|
||||
acceptedTime?: string | null;
|
||||
confirmedTime?: string | null;
|
||||
}>;
|
||||
|
||||
export type RectificationEntrySummaryWire = Readonly<{
|
||||
|
||||
@@ -149,6 +149,8 @@ export type Account = {
|
||||
profile: unknown;
|
||||
chartBirth?: { fingerprint?: string | null } | null;
|
||||
compatibility?: boolean;
|
||||
/** Product flag for the /rectification entry (issue #145). Missing means hidden. */
|
||||
rectification?: boolean;
|
||||
/** True while this account's one free standard question is still available. Missing means the paywall stays. */
|
||||
firstConsultFree?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
|
||||
|
||||
export const CHECKLIST_ITEM_IDS = ["self", "ask", "daily", "chart", "person", "syn", "report"] as const;
|
||||
export const CHECKLIST_ITEM_IDS = ["self", "ask", "rectify", "daily", "chart", "person", "syn", "report"] as const;
|
||||
|
||||
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
|
||||
|
||||
@@ -42,6 +42,11 @@ export type ChecklistFacts = {
|
||||
readonly synastryReportCount: number;
|
||||
readonly personalReportCount: number;
|
||||
readonly flags: ChecklistFlags;
|
||||
/**
|
||||
* Issue #145. Present only when the rectification product is on and the
|
||||
* self birth time was an estimate; `done` once a time was adopted.
|
||||
*/
|
||||
readonly rectification?: { readonly done: boolean } | null;
|
||||
};
|
||||
|
||||
export type AnswerTurn = {
|
||||
@@ -62,7 +67,7 @@ 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 SELF_ORDER: readonly ChecklistItemId[] = ["self", "ask", "rectify", "daily", "chart", "person", "syn", "report"];
|
||||
const OTHER_ORDER: readonly ChecklistItemId[] = ["self", "ask", "person", "daily", "chart", "syn", "report"];
|
||||
|
||||
export function isCompleteNonSmalltalkAnswer(turn: AnswerTurn): boolean {
|
||||
@@ -144,6 +149,7 @@ export function buildChecklist(facts: ChecklistFacts): ChecklistItem[] {
|
||||
for (const id of order) {
|
||||
if (id === "syn" && facts.flags.compatibility !== true) continue;
|
||||
if (id === "report" && facts.flags.reportCenter !== true) continue;
|
||||
if (id === "rectify" && !facts.rectification) continue;
|
||||
items.push(itemFor(id, path, name, synBase, facts));
|
||||
}
|
||||
return items;
|
||||
@@ -166,6 +172,13 @@ function itemFor(
|
||||
};
|
||||
case "ask":
|
||||
return { id, label: "问第一个问题", done: facts.hasNonSmalltalkAnswer, go: { kind: "compose" } };
|
||||
case "rectify":
|
||||
return {
|
||||
id,
|
||||
label: "做一次生时校正",
|
||||
done: facts.rectification?.done === true,
|
||||
go: { kind: "href", href: "/rectification" },
|
||||
};
|
||||
case "daily":
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/onboarding-hints";
|
||||
import { missingOtherProfileStep, readProfile, selfChartComplete } from "@/lib/home-profile";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
import { birthTimeWasEstimated } from "@/lib/rectification-page";
|
||||
import { parseOnboardingState, type OnboardingHintId, type OnboardingState } from "@/lib/onboarding-state";
|
||||
import {
|
||||
setOnboardingDailyOpenedListener,
|
||||
@@ -453,6 +454,12 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
|
||||
streamingSessionId: live.streamingSessionId,
|
||||
firstConsultFree: list.account?.firstConsultFree,
|
||||
});
|
||||
const rectificationShown = list.account?.rectification === true && birthTimeWasEstimated(selfProfile);
|
||||
const rectificationDone = selfProfile.birthTimeStatus === "accepted" || selfProfile.birthTimeStatus === "confirmed";
|
||||
const rectificationItem = useMemo(
|
||||
() => (rectificationShown ? { done: rectificationDone } : null),
|
||||
[rectificationShown, rectificationDone],
|
||||
);
|
||||
const items = useMemo(() => buildChecklist({
|
||||
people,
|
||||
profileComplete,
|
||||
@@ -467,7 +474,9 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
|
||||
synastryCreditCost: remote.synastryCreditCost,
|
||||
reportCreditCost: remote.reportCreditCost,
|
||||
},
|
||||
rectification: rectificationItem,
|
||||
}), [
|
||||
rectificationItem,
|
||||
people,
|
||||
profileComplete,
|
||||
hasAnswer,
|
||||
|
||||
@@ -511,6 +511,10 @@ export async function getRectificationEntrySummary(
|
||||
: "draft",
|
||||
lastActivityAt:
|
||||
typeof latestResumable.last_activity_at === "string" ? latestResumable.last_activity_at : "",
|
||||
sessionId: textOrNull(latestResumable.session_id),
|
||||
candidateRange: entryRange(latestResumable.candidate_range),
|
||||
acceptedTime: textOrNull(latestResumable.accepted_time),
|
||||
evidenceCount: typeof latestResumable.evidence_count === "number" ? latestResumable.evidence_count : null,
|
||||
}
|
||||
: null,
|
||||
latestTerminal:
|
||||
@@ -521,11 +525,26 @@ export async function getRectificationEntrySummary(
|
||||
? latestTerminal.status
|
||||
: ("closed" as RectificationCaseStatus),
|
||||
hasUsableTime: latestTerminal.has_usable_time === true,
|
||||
sessionId: textOrNull(latestTerminal.session_id),
|
||||
acceptedTime: textOrNull(latestTerminal.accepted_time),
|
||||
confirmedTime: textOrNull(latestTerminal.confirmed_time),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function textOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function entryRange(value: unknown): { startTime: string; endTime: string } | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const range = value as { start_time?: unknown; end_time?: unknown };
|
||||
return typeof range.start_time === "string" && typeof range.end_time === "string"
|
||||
? { startTime: range.start_time, endTime: range.end_time }
|
||||
: null;
|
||||
}
|
||||
|
||||
function caseView(row: Record<string, unknown>): RectificationCaseView {
|
||||
const range = row.candidate_range && typeof row.candidate_range === "object"
|
||||
? (row.candidate_range as { start_time?: unknown; end_time?: unknown })
|
||||
|
||||
@@ -60,6 +60,10 @@ export function entrySummaryFromResponse(value: unknown): RectificationEntrySumm
|
||||
caseId: resumableCaseId,
|
||||
status: String(latestResumable.status ?? ""),
|
||||
lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""),
|
||||
sessionId: textOrNull(field(latestResumable, "sessionId", "session_id")),
|
||||
candidateRange: rangeOrNull(field(latestResumable, "candidateRange", "candidate_range")),
|
||||
acceptedTime: textOrNull(field(latestResumable, "acceptedTime", "accepted_time")),
|
||||
evidenceCount: countOrNull(field(latestResumable, "evidenceCount", "evidence_count")),
|
||||
}
|
||||
: null,
|
||||
latestTerminal: latestTerminal && typeof terminalCaseId === "string"
|
||||
@@ -67,11 +71,30 @@ export function entrySummaryFromResponse(value: unknown): RectificationEntrySumm
|
||||
caseId: terminalCaseId,
|
||||
status: String(latestTerminal.status ?? ""),
|
||||
hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true,
|
||||
sessionId: textOrNull(field(latestTerminal, "sessionId", "session_id")),
|
||||
acceptedTime: textOrNull(field(latestTerminal, "acceptedTime", "accepted_time")),
|
||||
confirmedTime: textOrNull(field(latestTerminal, "confirmedTime", "confirmed_time")),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function textOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function countOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null;
|
||||
}
|
||||
|
||||
function rangeOrNull(value: unknown): { startTime: string; endTime: string } | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
const start = field(row, "startTime", "start_time");
|
||||
const end = field(row, "endTime", "end_time");
|
||||
return typeof start === "string" && typeof end === "string" ? { startTime: start, endTime: end } : null;
|
||||
}
|
||||
|
||||
export function openResponseFromPayload(value: unknown): OpenRectificationCaseResponse | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
|
||||
@@ -66,13 +66,12 @@ export type RectificationOfferDecision =
|
||||
|
||||
// ---- copy (VOICE.md「生时校正并入对话」) ----
|
||||
|
||||
export function rectificationOfferCardCopy(offer: Pick<RectificationOfferRecord, "creditCost" | "questionEstimate">): string {
|
||||
return `可以用几道过去经历的题,核对一下你的出生时间。大约 ${offer.questionEstimate} 题,会用掉 ${offer.creditCost} 点;开始答第一题时才扣。`;
|
||||
}
|
||||
|
||||
export const RECTIFICATION_OFFER_START_LABEL = "开始核对出生时间";
|
||||
export const RECTIFICATION_OFFER_CONTINUE_LABEL = "继续核对出生时间";
|
||||
export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始。";
|
||||
/**
|
||||
* Issue #145: the card under an answer is a hint that links to /rectification.
|
||||
* It no longer opens a case from this conversation, and it carries no price.
|
||||
*/
|
||||
export const RECTIFICATION_OFFER_HINT_COPY = "出生时间还不确定。";
|
||||
export const RECTIFICATION_OFFER_LINK_LABEL = "去做生时校正";
|
||||
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
|
||||
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The standalone /rectification page (issue #145). Which card it shows comes
|
||||
* from the server's entry summary and the account; the page never guesses
|
||||
* from session lists. Self profile only (other people: issue #160).
|
||||
*/
|
||||
|
||||
import { rectificationEntryHref, sessionHref } from "./chat-session-url.ts";
|
||||
import { blocksNewRectification } from "./rectification-in-chat.ts";
|
||||
import type { RectificationEntrySummary } from "./rectification-entry.ts";
|
||||
|
||||
export type RectificationPageState =
|
||||
| { readonly kind: "signed-out" }
|
||||
| { readonly kind: "unavailable" }
|
||||
| { readonly kind: "self-missing" }
|
||||
| { readonly kind: "none" }
|
||||
| {
|
||||
readonly kind: "active";
|
||||
readonly sessionId: string | null;
|
||||
readonly range: { readonly start: string; readonly end: string } | null;
|
||||
readonly evidenceCount: number | null;
|
||||
}
|
||||
| { readonly kind: "done"; readonly sessionId: string | null; readonly time: string };
|
||||
|
||||
/**
|
||||
* The birth time is only an estimate and no time has been adopted yet. Drives
|
||||
* the onboarding prompt and the checklist item (issue #145). A certificate or
|
||||
* a self-declared exact minute does not count; nor does an adopted time.
|
||||
*/
|
||||
export function birthTimeNeedsRectification(profile: {
|
||||
readonly birthTimeSource: string;
|
||||
readonly birthTimeStatus: string;
|
||||
}): boolean {
|
||||
if (profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed") return false;
|
||||
return birthTimeWasEstimated(profile);
|
||||
}
|
||||
|
||||
/** The self birth time came from an estimate, adopted since or not (checklist item, issue #145). */
|
||||
export function birthTimeWasEstimated(profile: { readonly birthTimeSource: string }): boolean {
|
||||
return profile.birthTimeSource === "approximate"
|
||||
|| profile.birthTimeSource === "period_only"
|
||||
|| profile.birthTimeSource === "unknown";
|
||||
}
|
||||
|
||||
export const RECTIFICATION_HOME_HINT = {
|
||||
text: "出生时间还不确定,可以先做生时校正。",
|
||||
link: "去校正",
|
||||
} as const;
|
||||
|
||||
export const RECTIFICATION_PAGE_COPY = {
|
||||
title: "生时校正",
|
||||
intro: "出生时间不确定时,用几件记得日期的大事,把钟点一步步收窄。",
|
||||
waiting: "校正记录还在读",
|
||||
loadFailed: "校正记录这会儿读不到。",
|
||||
retry: "再试一次",
|
||||
signedOut: "登录后可以做生时校正。",
|
||||
signIn: "去登录",
|
||||
unavailable: "生时校正这会儿还做不了。",
|
||||
selfMissing: "先把你自己的出生日期、时间和地点填好,才能做生时校正。",
|
||||
fillSelf: "去填资料",
|
||||
currentTime: "你现在用的时间",
|
||||
start: "开始",
|
||||
activeLabel: "进行中",
|
||||
windowLabel: "时间窗",
|
||||
evidenceLabel: "已收大事",
|
||||
resume: "继续",
|
||||
doneLabel: "已采用",
|
||||
doneNote: "之后排盘、咨询和报告都用这个时间。",
|
||||
viewResult: "查看结果",
|
||||
again: "再校正一次",
|
||||
back: "返回",
|
||||
checkTitle: "先核对一下资料",
|
||||
birthDate: "出生日期",
|
||||
birthTime: "出生时间",
|
||||
birthPlace: "出生城市",
|
||||
confirmStart: "开始校正",
|
||||
editProfile: "改这份",
|
||||
} as const;
|
||||
|
||||
/** `07:42:00` → `07:42`. Anything else is returned trimmed. */
|
||||
export function clockLabel(value: string): string {
|
||||
const match = /^(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(value.trim());
|
||||
return match ? `${match[1]}:${match[2]}` : value.trim();
|
||||
}
|
||||
|
||||
export function evidenceCountLabel(count: number): string {
|
||||
return `${count} 件`;
|
||||
}
|
||||
|
||||
export function rectificationPageState(input: {
|
||||
readonly signedOut: boolean;
|
||||
/** `account.rectification`; missing means the flag did not arrive, so the page stays closed. */
|
||||
readonly enabled: boolean | undefined;
|
||||
readonly selfComplete: boolean;
|
||||
readonly summary: RectificationEntrySummary;
|
||||
}): RectificationPageState {
|
||||
if (input.signedOut) return { kind: "signed-out" };
|
||||
if (input.enabled !== true) return { kind: "unavailable" };
|
||||
if (!input.selfComplete) return { kind: "self-missing" };
|
||||
|
||||
const open = input.summary.latestResumable;
|
||||
if (open && blocksNewRectification(open.status)) {
|
||||
const range = open.candidateRange
|
||||
? { start: clockLabel(open.candidateRange.startTime), end: clockLabel(open.candidateRange.endTime) }
|
||||
: null;
|
||||
return {
|
||||
kind: "active",
|
||||
sessionId: open.sessionId ?? null,
|
||||
range,
|
||||
evidenceCount: open.evidenceCount ?? null,
|
||||
};
|
||||
}
|
||||
// An accepted case is a finished stop (BUG-1423), even though the shared
|
||||
// resumable set still lists its status.
|
||||
if (open && open.status === "candidate_accepted" && open.acceptedTime) {
|
||||
return { kind: "done", sessionId: open.sessionId ?? null, time: clockLabel(open.acceptedTime) };
|
||||
}
|
||||
const finished = input.summary.latestTerminal;
|
||||
const finishedTime = finished?.confirmedTime ?? finished?.acceptedTime ?? null;
|
||||
if (finished && finished.hasUsableTime && finishedTime) {
|
||||
return { kind: "done", sessionId: finished.sessionId ?? null, time: clockLabel(finishedTime) };
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
/** Where 开始校正 goes: a fresh case after a finished one, otherwise the homepage opener. */
|
||||
export function rectificationStartHref(state: RectificationPageState): string {
|
||||
return rectificationEntryHref(state.kind === "done" ? "new" : "homepage");
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续 / 查看结果: the case's own conversation. Without a session id (an older
|
||||
* server) the homepage opener still resumes the open case.
|
||||
*/
|
||||
export function rectificationSessionHref(sessionId: string | null): string {
|
||||
return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage");
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
begin;
|
||||
|
||||
-- Issue #145. Add-only. The /rectification status card reads the latest open
|
||||
-- and finished case in one call: session id (to reopen it), the current
|
||||
-- candidate range, how many events the user confirmed, and the adopted time.
|
||||
-- Existing keys are unchanged. Never returns the birth snapshot.
|
||||
|
||||
create or replace function public.get_agentic_rectification_entry_summary(
|
||||
p_user_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_resumable public.agentic_rectification_cases%rowtype;
|
||||
v_terminal public.agentic_rectification_cases%rowtype;
|
||||
begin
|
||||
if p_user_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_resumable
|
||||
from public.agentic_rectification_cases
|
||||
where user_id = p_user_id
|
||||
and status = any (public.agentic_rectification_resumable_statuses())
|
||||
order by last_activity_at desc, id
|
||||
limit 1;
|
||||
|
||||
select * into v_terminal
|
||||
from public.agentic_rectification_cases
|
||||
where user_id = p_user_id
|
||||
and status in ('confirmed', 'closed', 'abandoned', 'superseded')
|
||||
order by last_activity_at desc, id
|
||||
limit 1;
|
||||
|
||||
return jsonb_build_object(
|
||||
'has_resumable_case', v_resumable.id is not null,
|
||||
'has_terminal_case_with_time', v_terminal.id is not null
|
||||
and (v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null),
|
||||
'latest_resumable', case
|
||||
when v_resumable.id is null then null
|
||||
else jsonb_build_object(
|
||||
'case_id', v_resumable.id,
|
||||
'status', v_resumable.status,
|
||||
'last_activity_at', v_resumable.last_activity_at,
|
||||
'session_id', v_resumable.session_id,
|
||||
'candidate_range', v_resumable.candidate_range,
|
||||
'accepted_time', v_resumable.accepted_time,
|
||||
'evidence_count', (
|
||||
select count(*)
|
||||
from public.agentic_rectification_evidence as evidence
|
||||
where evidence.case_id = v_resumable.id
|
||||
and evidence.status = 'confirmed'
|
||||
)
|
||||
)
|
||||
end,
|
||||
'latest_terminal', case
|
||||
when v_terminal.id is null then null
|
||||
else jsonb_build_object(
|
||||
'case_id', v_terminal.id,
|
||||
'status', v_terminal.status,
|
||||
'has_usable_time', v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null,
|
||||
'session_id', v_terminal.session_id,
|
||||
'accepted_time', v_terminal.accepted_time,
|
||||
'confirmed_time', v_terminal.confirmed_time
|
||||
)
|
||||
end
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.get_agentic_rectification_entry_summary(uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_entry_summary(uuid)
|
||||
to service_role;
|
||||
|
||||
end;
|
||||
@@ -130,7 +130,8 @@ test("the birth line is followed by whether the ascendant changes inside the sta
|
||||
} } }));
|
||||
assert.match(fixed, /chart-lagna-precheck/);
|
||||
assert.match(fixed, /按你给的时间精度(前后约 2 分钟),上升星座都是白羊座,这一点不会因为时间误差而变。/);
|
||||
assert.doesNotMatch(fixed, /出生时间校正|href="\/\?rectify=1"/);
|
||||
// 原值: href="/?rectify=1";新值: href="/rectification";原因: issue #145 独立入口
|
||||
assert.doesNotMatch(fixed, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"/);
|
||||
|
||||
const two = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
|
||||
state: "may_change",
|
||||
@@ -142,7 +143,7 @@ test("the birth line is followed by whether the ascendant changes inside the sta
|
||||
afterMinutes: 15,
|
||||
} } }));
|
||||
assert.match(two, /按你给的时间精度,上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。想定下来可以做一次/);
|
||||
assert.match(two, /href="\/\?rectify=1"/);
|
||||
assert.match(two, /href="\/rectification"/);
|
||||
assert.match(two, />出生时间校正</);
|
||||
|
||||
const many = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
|
||||
@@ -199,12 +200,12 @@ test("someone else's chart states the ascendant without a rectification link", a
|
||||
};
|
||||
const otherTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: mayChange } }));
|
||||
assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。/);
|
||||
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/\?rectify=1"|想定下来/);
|
||||
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|想定下来/);
|
||||
const otherUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: unknown } }));
|
||||
assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来。/);
|
||||
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/\?rectify=1"|可以缩小范围/);
|
||||
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|可以缩小范围/);
|
||||
const selfTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: mayChange } }));
|
||||
assert.match(selfTwo, /href="\/\?rectify=1"/);
|
||||
assert.match(selfTwo, /href="\/rectification"/);
|
||||
assert.match(selfTwo, />出生时间校正</);
|
||||
const selfUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: unknown } }));
|
||||
assert.match(selfUnknown, />出生时间校正</);
|
||||
|
||||
@@ -157,8 +157,12 @@ test("ordinary product drafts keep the public question and clear hidden routing
|
||||
// 原值: 发送时 consultEntrypoint === "birth_time_rectification"(文字口令)→ openRectificationFromHomepage(pendingQuestion, currentSession.id)
|
||||
// 新值: 发送路径不再开校正;提议卡 → openRectificationFromChat(sourceSessionId)
|
||||
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
|
||||
// 原值(2): 提议卡 → openRectificationFromChat(sourceSessionId)
|
||||
// 新值(2): 提议卡与明说要校正都去独立页 /rectification(openRectificationPage),不从这段对话开案例
|
||||
// 原因(2): issue #145(产品 2026-10-28 定:入口单独拆出,对话里只给链接)
|
||||
assert.doesNotMatch(source, /isRectificationHandoffQuestion\(question\)/);
|
||||
assert.match(source, /openRectificationFromChat\(sourceSessionId\)/);
|
||||
assert.match(source, /openRectificationPage\(\)/);
|
||||
assert.doesNotMatch(source, /openRectificationFromChat\(sourceSessionId\)/);
|
||||
assert.doesNotMatch(source, /personalChartAvailable \? "daily_starlanguage" : null/);
|
||||
assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/);
|
||||
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*consultEntrypoint \?\? undefined,[\s\S]*?question,/);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import { StarterQuestions } from "../src/components/starter-questions.tsx";
|
||||
import {
|
||||
RECTIFICATION_PAGE_HREF,
|
||||
parseRectificationEntryIntent,
|
||||
rectificationEntryHref,
|
||||
rectificationEntryIntent,
|
||||
} from "../src/lib/chat-session-url.ts";
|
||||
import { buildChecklist, type ChecklistFacts } from "../src/lib/onboarding-checklist.ts";
|
||||
import { entrySummaryFromResponse, type RectificationEntrySummary } from "../src/lib/rectification-entry.ts";
|
||||
import {
|
||||
RECTIFICATION_HOME_HINT,
|
||||
RECTIFICATION_PAGE_COPY,
|
||||
birthTimeNeedsRectification,
|
||||
birthTimeWasEstimated,
|
||||
clockLabel,
|
||||
rectificationPageState,
|
||||
rectificationSessionHref,
|
||||
rectificationStartHref,
|
||||
} from "../src/lib/rectification-page.ts";
|
||||
|
||||
// Issue #145: the standalone /rectification entry. Fictional ids only.
|
||||
const SESSION = "11111111-1111-4111-8111-111111111111";
|
||||
const CASE = "22222222-2222-4222-8222-222222222222";
|
||||
const EMPTY: RectificationEntrySummary = {
|
||||
hasResumableCase: false,
|
||||
hasTerminalCaseWithTime: false,
|
||||
latestResumable: null,
|
||||
latestTerminal: null,
|
||||
};
|
||||
|
||||
function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; selfComplete: boolean }> = {}) {
|
||||
return rectificationPageState({ signedOut: false, enabled: true, selfComplete: true, summary, ...patch });
|
||||
}
|
||||
|
||||
test("the page refuses before reading cases: signed out, product off or unknown, no self chart", () => {
|
||||
assert.equal(state(EMPTY, { signedOut: true }).kind, "signed-out");
|
||||
assert.equal(state(EMPTY, { enabled: false }).kind, "unavailable");
|
||||
assert.equal(state(EMPTY, { enabled: undefined }).kind, "unavailable", "a flag that did not arrive keeps the page closed");
|
||||
assert.equal(state(EMPTY, { selfComplete: false }).kind, "self-missing");
|
||||
assert.equal(state(EMPTY).kind, "none");
|
||||
});
|
||||
|
||||
test("an open case shows its window and confirmed events; an accepted case is finished (BUG-1423)", () => {
|
||||
const active = state({
|
||||
...EMPTY,
|
||||
hasResumableCase: true,
|
||||
latestResumable: {
|
||||
caseId: CASE,
|
||||
status: "collecting_evidence",
|
||||
lastActivityAt: "2026-10-26T00:00:00Z",
|
||||
sessionId: SESSION,
|
||||
candidateRange: { startTime: "07:00:00", endTime: "08:00" },
|
||||
evidenceCount: 3,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(active, { kind: "active", sessionId: SESSION, range: { start: "07:00", end: "08:00" }, evidenceCount: 3 });
|
||||
|
||||
const accepted = state({
|
||||
...EMPTY,
|
||||
hasResumableCase: true,
|
||||
latestResumable: { caseId: CASE, status: "candidate_accepted", lastActivityAt: "", sessionId: SESSION, acceptedTime: "07:42:00" },
|
||||
});
|
||||
assert.deepEqual(accepted, { kind: "done", sessionId: SESSION, time: "07:42" });
|
||||
});
|
||||
|
||||
test("a finished case prefers the confirmed time; a finished case without a time is not done", () => {
|
||||
const confirmed = state({
|
||||
...EMPTY,
|
||||
hasTerminalCaseWithTime: true,
|
||||
latestTerminal: { caseId: CASE, status: "confirmed", hasUsableTime: true, sessionId: SESSION, acceptedTime: "07:40:00", confirmedTime: "07:42:10" },
|
||||
});
|
||||
assert.deepEqual(confirmed, { kind: "done", sessionId: SESSION, time: "07:42" });
|
||||
const empty = state({
|
||||
...EMPTY,
|
||||
latestTerminal: { caseId: CASE, status: "abandoned", hasUsableTime: false, sessionId: SESSION },
|
||||
});
|
||||
assert.equal(empty.kind, "none");
|
||||
});
|
||||
|
||||
test("start goes to the homepage opener, or a new case after a finished one; resume uses the case's conversation", () => {
|
||||
assert.equal(rectificationStartHref({ kind: "none" }), "/?rectify=1");
|
||||
assert.equal(rectificationStartHref({ kind: "done", sessionId: SESSION, time: "07:42" }), "/?rectify=new");
|
||||
assert.equal(rectificationSessionHref(SESSION), `/?c=${SESSION}`);
|
||||
assert.equal(rectificationSessionHref(null), "/?rectify=1", "an older server without session ids still resumes");
|
||||
assert.equal(rectificationEntryHref(), "/?rectify=1");
|
||||
assert.equal(rectificationEntryIntent("?rectify=1"), "homepage");
|
||||
assert.equal(rectificationEntryIntent("?rectify=new"), "new");
|
||||
assert.equal(rectificationEntryIntent("?rectify=yes"), null);
|
||||
assert.equal(parseRectificationEntryIntent("?rectify=new"), true);
|
||||
assert.equal(RECTIFICATION_PAGE_HREF, "/rectification");
|
||||
});
|
||||
|
||||
test("the summary parser reads the new fields in either case and keeps older payloads valid", () => {
|
||||
const camel = entrySummaryFromResponse({
|
||||
hasResumableCase: true,
|
||||
hasTerminalCaseWithTime: false,
|
||||
latestResumable: { caseId: CASE, status: "paused", lastActivityAt: "x", sessionId: SESSION, candidateRange: { startTime: "06:30", endTime: "07:30" }, evidenceCount: 2 },
|
||||
latestTerminal: null,
|
||||
});
|
||||
assert.equal(camel.latestResumable?.sessionId, SESSION);
|
||||
assert.deepEqual(camel.latestResumable?.candidateRange, { startTime: "06:30", endTime: "07:30" });
|
||||
assert.equal(camel.latestResumable?.evidenceCount, 2);
|
||||
|
||||
const snake = entrySummaryFromResponse({
|
||||
has_resumable_case: false,
|
||||
has_terminal_case_with_time: true,
|
||||
latest_resumable: null,
|
||||
latest_terminal: { case_id: CASE, status: "confirmed", has_usable_time: true, session_id: SESSION, confirmed_time: "07:42:00" },
|
||||
});
|
||||
assert.equal(snake.latestTerminal?.sessionId, SESSION);
|
||||
assert.equal(snake.latestTerminal?.confirmedTime, "07:42:00");
|
||||
|
||||
const old = entrySummaryFromResponse({ hasResumableCase: true, latestResumable: { caseId: CASE, status: "draft", lastActivityAt: "x" } });
|
||||
assert.equal(old.latestResumable?.sessionId, null);
|
||||
assert.equal(old.latestResumable?.candidateRange, null);
|
||||
assert.equal(old.latestResumable?.evidenceCount, null);
|
||||
});
|
||||
|
||||
test("clock labels drop seconds only", () => {
|
||||
assert.equal(clockLabel("07:42:00"), "07:42");
|
||||
assert.equal(clockLabel("07:42"), "07:42");
|
||||
assert.equal(clockLabel(" 07:42:05.5 "), "07:42");
|
||||
});
|
||||
|
||||
test("the page never mentions credits (product 2026-10-28)", () => {
|
||||
for (const text of Object.values(RECTIFICATION_PAGE_COPY)) {
|
||||
assert.doesNotMatch(text, /点数|扣点|\d+\s*点|会用掉/);
|
||||
}
|
||||
const page = readFileSync(new URL("../src/components/rectification-page/rectification-entry-page.tsx", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(page, /credit|Credit|扣/);
|
||||
});
|
||||
|
||||
test("an estimated time needs rectification until a time is adopted; an exact minute never does", () => {
|
||||
for (const source of ["approximate", "period_only", "unknown"]) {
|
||||
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "reported" }), true, source);
|
||||
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "accepted" }), false, source);
|
||||
assert.equal(birthTimeWasEstimated({ birthTimeSource: source }), true, source);
|
||||
}
|
||||
for (const source of ["hospital_record", "family_exact", "legacy_import", ""]) {
|
||||
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "" }), false, source);
|
||||
assert.equal(birthTimeWasEstimated({ birthTimeSource: source }), false, source);
|
||||
}
|
||||
});
|
||||
|
||||
function facts(rectification: ChecklistFacts["rectification"], selfComplete = true): ChecklistFacts {
|
||||
return {
|
||||
people: [{ id: "self", role: "self", name: "小林", complete: selfComplete, createdAt: null }],
|
||||
profileComplete: true,
|
||||
hasNonSmalltalkAnswer: true,
|
||||
dailyCount: 0,
|
||||
chartVisited: false,
|
||||
synastryReportCount: 0,
|
||||
personalReportCount: 0,
|
||||
flags: { compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: null },
|
||||
rectification,
|
||||
};
|
||||
}
|
||||
|
||||
test("the checklist adds 做一次生时校正 after the first question only when given, and ticks it once a time is adopted", () => {
|
||||
const without = buildChecklist(facts(null));
|
||||
assert.equal(without.some((item) => item.id === "rectify"), false);
|
||||
assert.equal(without.length, 7);
|
||||
|
||||
const open = buildChecklist(facts({ done: false }));
|
||||
assert.deepEqual(open.slice(0, 3).map((item) => item.id), ["self", "ask", "rectify"]);
|
||||
const row = open.find((item) => item.id === "rectify")!;
|
||||
assert.equal(row.label, "做一次生时校正");
|
||||
assert.equal(row.done, false);
|
||||
assert.deepEqual(row.go, { kind: "href", href: "/rectification" });
|
||||
assert.equal(open.length, 8);
|
||||
|
||||
assert.equal(buildChecklist(facts({ done: true })).find((item) => item.id === "rectify")?.done, true);
|
||||
assert.equal(buildChecklist(facts({ done: false }, false)).some((item) => item.id === "rectify"), false, "help-other path never lists it");
|
||||
});
|
||||
|
||||
test("the provider gives the checklist item only with the product on and an estimated self time", () => {
|
||||
const provider = readFileSync(new URL("../src/lib/onboarding-journey.tsx", import.meta.url), "utf8");
|
||||
assert.match(provider, /list\.account\?\.rectification === true && birthTimeWasEstimated\(selfProfile\)/);
|
||||
assert.match(provider, /rectification: rectificationItem,/);
|
||||
});
|
||||
|
||||
test("the starter home shows the rectification prompt only when asked, linking to /rectification", () => {
|
||||
const props = {
|
||||
audience: { kind: "self" } as const,
|
||||
subjectId: "self",
|
||||
signs: { ascendantSign: null, moonSign: null },
|
||||
chartSignsFallback: false,
|
||||
onAsk() {},
|
||||
onNudge() {},
|
||||
};
|
||||
const plain = renderToString(createElement(StarterQuestions, props));
|
||||
assert.doesNotMatch(plain, /starter-rectify-hint/);
|
||||
const hinted = renderToString(createElement(StarterQuestions, { ...props, rectifyHint: true }));
|
||||
assert.match(hinted, /starter-rectify-hint/);
|
||||
assert.match(hinted, new RegExp(RECTIFICATION_HOME_HINT.text));
|
||||
assert.match(hinted, /href="\/rectification"/);
|
||||
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /rectifyHint=\{currentSubjectId === "self" && account\.rectification === true && birthTimeNeedsRectification\(profile\)\}/);
|
||||
});
|
||||
|
||||
test("the sidebar lists 生时校正 under 星盘, behind the account flag", () => {
|
||||
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||||
const chart = sidebar.indexOf('{ href: "/chart", label: "星盘"');
|
||||
const entry = sidebar.indexOf('{ href: "/rectification", label: "生时校正"');
|
||||
const ephemeris = sidebar.indexOf('{ href: "/ephemeris", label: "星历"');
|
||||
assert.ok(chart >= 0 && chart < entry && entry < ephemeris);
|
||||
assert.match(sidebar, /page\.href !== "\/rectification" \|\| rectificationEnabled/);
|
||||
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
||||
assert.match(layout, /rectificationEnabled=\{rectificationEnabled\}/);
|
||||
assert.match(layout, /registerShellNavigate\(/);
|
||||
const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
|
||||
assert.match(account, /isProductEnabled\("rectification"\)/);
|
||||
assert.match(account, /\n\s+rectification,\n/);
|
||||
});
|
||||
|
||||
test("the entry-summary migration is add-only and counts confirmed events only", () => {
|
||||
const sql = readFileSync(new URL("../supabase/migrations/20261028170000_rectification_entry_summary_detail.sql", import.meta.url), "utf8");
|
||||
assert.match(sql, /create or replace function public\.get_agentic_rectification_entry_summary\(/);
|
||||
for (const key of ["'session_id', v_resumable.session_id", "'candidate_range', v_resumable.candidate_range", "'session_id', v_terminal.session_id", "'confirmed_time', v_terminal.confirmed_time"]) {
|
||||
assert.ok(sql.includes(key), key);
|
||||
}
|
||||
assert.match(sql, /evidence\.status = 'confirmed'/);
|
||||
assert.doesNotMatch(sql, /baseline_birth_snapshot/);
|
||||
assert.doesNotMatch(sql, /\bdrop (table|column|function)\b/i);
|
||||
assert.match(sql, /grant execute on function public\.get_agentic_rectification_entry_summary\(uuid\)\s+to service_role;/);
|
||||
});
|
||||
@@ -16,14 +16,11 @@ import {
|
||||
RECTIFICATION_DIRECT_NOT_SELF_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_OFFER_OPENED_COPY,
|
||||
RECTIFICATION_OFFER_START_LABEL,
|
||||
RECTIFICATION_OFFER_TOOL_ID,
|
||||
attachOpenedOfferToFreeMessage,
|
||||
bindOfferToCase,
|
||||
offerForCase,
|
||||
parseRectificationOfferRecord,
|
||||
rectificationOfferCardCopy,
|
||||
sourceFromSessionRows,
|
||||
type RectificationOfferRecord,
|
||||
} from "../src/lib/rectification-in-chat.ts";
|
||||
@@ -737,16 +734,19 @@ test("the offer rule is defined once, and direct entry settles with the free com
|
||||
assert.doesNotMatch(stream, /请你帮我进行生时纠正/);
|
||||
|
||||
const hook = src("../src/hooks/use-consultation-run.ts");
|
||||
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
|
||||
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
|
||||
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
|
||||
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
|
||||
assert.match(directOpen, /if \(opened\) return/);
|
||||
// 原值:直达在浏览器里 openRectificationFromChat(sourceSessionId) 新开,或 openRectificationSession(resumeSessionId) 续上。
|
||||
// 新值:新开和续上都只跳到 /rectification(openRectificationPage),页面自己给「开始」或「继续」。
|
||||
// 原因:issue #145(产品 2026-10-28 定:入口单独拆出,不从对话开案例)。
|
||||
assert.equal(hook.match(/if \(rectificationDirectOpen\) openRectificationPage\(\);/g)?.length, 1);
|
||||
assert.doesNotMatch(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
|
||||
assert.doesNotMatch(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
|
||||
const directOpen = hook.slice(hook.indexOf("if (rectificationDirectOpen)"), hook.indexOf("await persistSession(completedSession)"));
|
||||
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
|
||||
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
|
||||
|
||||
const page = src("../src/app/(app)/page.tsx");
|
||||
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationFromChat,/);
|
||||
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationPage,/);
|
||||
assert.match(page, /navigateInApp\(RECTIFICATION_PAGE_HREF\)/);
|
||||
assert.doesNotMatch(page, /请你帮我进行生时纠正/);
|
||||
|
||||
const server = src("../src/lib/rectification-in-chat-server.ts");
|
||||
@@ -779,13 +779,16 @@ function cardHtml(record: RectificationOfferRecord) {
|
||||
}
|
||||
|
||||
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
|
||||
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
|
||||
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
|
||||
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const html = cardHtml(pending);
|
||||
assert.match(html, /大约 8 题,会用掉 3 点/);
|
||||
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
||||
assert.equal(html.includes("继续核对出生时间"), false);
|
||||
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(html, /出生时间还不确定。/);
|
||||
assert.match(html, /href="\/rectification"/);
|
||||
assert.match(html, /去做生时校正/);
|
||||
assert.doesNotMatch(html, /会用掉|点;/);
|
||||
assert.equal(html.includes("开始核对出生时间"), false);
|
||||
|
||||
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
|
||||
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
|
||||
@@ -794,9 +797,8 @@ test("a direct-open offer still shows the ordinary card until the case is bound"
|
||||
assert.equal(opened.autoOpen, true);
|
||||
assert.equal(opened.caseId, CASE_ID);
|
||||
const openedHtml = cardHtml(opened);
|
||||
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
||||
assert.match(openedHtml, /继续核对出生时间/);
|
||||
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
|
||||
assert.match(openedHtml, /href="\/rectification"/);
|
||||
assert.equal(openedHtml.includes("出生时间核对已经开始"), false);
|
||||
});
|
||||
|
||||
test("a failed direct open leaves the offer offered and clickable", () => {
|
||||
@@ -808,8 +810,9 @@ test("a failed direct open leaves the offer offered and clickable", () => {
|
||||
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
|
||||
const html = cardHtml(pending);
|
||||
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
||||
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.equal(html.includes("出生时间核对已经开始"), false);
|
||||
assert.match(html, /href="\/rectification"/);
|
||||
assert.equal(html.includes("aria-disabled"), false);
|
||||
assert.equal(html.includes("disabled=\"\""), false);
|
||||
assert.equal(html.includes("disabled=\"disabled\""), false);
|
||||
});
|
||||
@@ -826,8 +829,8 @@ test("stored offers offered, opened, and returned still parse; returned hides th
|
||||
assert.equal(flagged?.autoOpen, true);
|
||||
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
|
||||
assert.equal(cardHtml(offer("returned")), "");
|
||||
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
||||
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.match(cardHtml(offer("opened")), /href="\/rectification"/);
|
||||
assert.match(cardHtml(offer("offered")), /href="\/rectification"/);
|
||||
});
|
||||
|
||||
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
|
||||
|
||||
@@ -249,9 +249,13 @@ function transcriptProps(messages: ChatMessage[], calls: string[]): ChatTranscri
|
||||
test("the transcript shows the offer card, the divider, the old-time caption and the revisit button (P2, BUG-1248/1250)", () => {
|
||||
const offered = conversation() as ChatMessage[];
|
||||
const card = renderToString(createElement(ChatTranscript, transcriptProps(offered, [])));
|
||||
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
|
||||
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
|
||||
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
|
||||
assert.match(card, /rectification-offer-card/);
|
||||
assert.match(card, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
|
||||
assert.match(card, /开始核对出生时间/);
|
||||
assert.match(card, /出生时间还不确定。/);
|
||||
assert.match(card, /href="\/rectification"/);
|
||||
assert.doesNotMatch(card, /会用掉/);
|
||||
assert.doesNotMatch(card, /按校正前的时间/);
|
||||
|
||||
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
|
||||
@@ -310,15 +314,17 @@ test("an auto-open offer shows the ordinary card until the case is bound, and a
|
||||
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
|
||||
];
|
||||
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
|
||||
assert.match(html, /开始核对出生时间/);
|
||||
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
|
||||
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
|
||||
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
|
||||
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
|
||||
assert.match(html, /href="\/rectification"/);
|
||||
assert.doesNotMatch(html, /会用掉/);
|
||||
assert.doesNotMatch(html, /出生时间核对已经开始/);
|
||||
assert.doesNotMatch(html, /继续核对出生时间/);
|
||||
|
||||
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
|
||||
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
|
||||
assert.match(openedHtml, /出生时间核对已经开始/);
|
||||
assert.match(openedHtml, /继续核对出生时间/);
|
||||
assert.match(openedHtml, /href="\/rectification"/);
|
||||
assert.doesNotMatch(openedHtml, /出生时间核对已经开始/);
|
||||
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
|
||||
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user