diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d060485..34b87f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ - 生时校正 20 条密封案例与 900 个报时窗口,名次与分钟误差与上一份回放逐条相同。 - Skill 版本不变(BUG-1494、BUG-1495,issue #159)。 +## 2026-10-28 — 生时校正有了自己的入口 + +- 侧栏「星盘」下面新增「生时校正」,打开一页就能看到现在的状态:没做过就开始,没做完就继续,做完了看结果或再校正一次。开始前先核对一遍出生资料。 +- 对话里提到出生时间不确定时,回答下面只给一句提示和「去做生时校正」的链接,不再从这段对话里开校正;明说要做校正时也跳到这一页。 +- 建完档、出生时间是估的,首页起步问题下面多一句提示;启程清单对这类账户多一项「做一次生时校正」。 +- 生时校正仍只支持本人,给家人做校正另见 issue #160。Skill 版本不变(issue #145)。 + ## 2026-10-28 — 两人同时做校正或生成报告时不再互相拖慢 - 排盘服务把最重的三类计算(生时校正打分、24 小时分块扫描、长报告参考)放到两个独立的工作进程里。两个人同时算时,各自的等待时间和一个人单独算差不多(以前接近翻倍)。结果不变。 diff --git a/docs/testing/rectification-entry-page-20261028.md b/docs/testing/rectification-entry-page-20261028.md new file mode 100644 index 00000000..f4cf4283 --- /dev/null +++ b/docs/testing/rectification-entry-page-20261028.md @@ -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。 +- [ ] 打开系统「减少动态效果」:页面只淡入,没有位移。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 2f1e075d..829bdd45 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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=`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。 +- **对话回答下面**(`.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。没有新的等待动画或输入框。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 00f230c8..c89103e1 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -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`。不用关键词判断。 diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 05d8762b..3d7157f8 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -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)} diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 04222723..e91b03df 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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 && ( diff --git a/frontend/src/app/(app)/rectification/page.tsx b/frontend/src/app/(app)/rectification/page.tsx new file mode 100644 index 00000000..c1ae7666 --- /dev/null +++ b/frontend/src/app/(app)/rectification/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index ba8a2af6..c50ad6fe 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -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) { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4957a174..30894b2b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); +} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 500f9394..f8235360 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -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({ )} - {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 ( diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 231ed7cd..55877359 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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 (

{line.before} - {line.linked ? 出生时间校正 : null} + {line.linked ? 出生时间校正 : null} {line.after}

); diff --git a/frontend/src/components/rectification-in-chat-cards.tsx b/frontend/src/components/rectification-in-chat-cards.tsx index 2522d201..f9ce9a0b 100644 --- a/frontend/src/components/rectification-in-chat-cards.tsx +++ b/frontend/src/components/rectification-in-chat-cards.tsx @@ -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) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + if (!disabled) onStart(); + }; return ( -
-

{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}

- +
+

{RECTIFICATION_OFFER_HINT_COPY}

+ + {RECTIFICATION_OFFER_LINK_LABEL} +
); } diff --git a/frontend/src/components/rectification-page/rectification-entry-page.tsx b/frontend/src/components/rectification-page/rectification-entry-page.tsx new file mode 100644 index 00000000..270535a5 --- /dev/null +++ b/frontend/src/components/rectification-page/rectification-entry-page.tsx @@ -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 { + 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 ( + + {children} + + ); +} + +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({ 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 } waiting={COPY.waiting} />; + } + if (needsSummary && read.status === "loading") { + return } waiting={COPY.waiting} />; + } + + const state: RectificationPageState = rectificationPageState({ + signedOut: !signedIn, + enabled, + selfComplete, + summary: read.status === "ready" ? read.summary : EMPTY_SUMMARY, + }); + + return ( + }> +
+ {checking && (state.kind === "none" || state.kind === "done") ? ( +
+ +

+ {COPY.checkTitle} +

+
+
+
{COPY.birthTime}
+
{formatBirthMoment(profile)}
+
+
+
{COPY.birthPlace}
+
{profilePlaceLabel(profile)}
+
+
+
+ {COPY.confirmStart} + {COPY.editProfile} +
+
+ ) : ( + <> +

{COPY.intro}

+ setChecking(true)} /> + + )} +
+
+ ); +} + +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 ( +
+

{COPY.loadFailed}

+
+ +
+
+ ); + } + + switch (state.kind) { + case "signed-out": + return ( +
+

{COPY.signedOut}

+
{COPY.signIn}
+
+ ); + case "unavailable": + return ( +
+

{COPY.unavailable}

+
+ ); + case "self-missing": + return ( +
+

{COPY.selfMissing}

+
{COPY.fillSelf}
+
+ ); + case "none": + return ( +
+

{COPY.currentTime}

+

{formatBirthMoment(profile)}

+
+ +
+
+ ); + case "active": + return ( +
+

{COPY.activeLabel}

+
+ {state.range ? ( +
+
{COPY.windowLabel}
+
{state.range.start} – {state.range.end}
+
+ ) : null} + {state.evidenceCount !== null ? ( +
+
{COPY.evidenceLabel}
+
{evidenceCountLabel(state.evidenceCount)}
+
+ ) : null} +
+
+ {COPY.resume} +
+
+ ); + case "done": + return ( +
+

{COPY.doneLabel}

+

{state.time}

+

{COPY.doneNote}

+
+ {state.sessionId ? {COPY.viewResult} : null} + +
+
+ ); + } +} diff --git a/frontend/src/components/starter-questions.tsx b/frontend/src/components/starter-questions.tsx index 078fdceb..ab1689c0 100644 --- a/frontend/src/components/starter-questions.tsx +++ b/frontend/src/components/starter-questions.tsx @@ -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({
+ {rectifyHint ? ( +
+

{RECTIFICATION_HOME_HINT.text}

+ {RECTIFICATION_HOME_HINT.link} +
+ ) : null} ); } diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index f8f19442..d2289d80 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -155,8 +155,11 @@ export type ConsultationRunParams = { onboardingPaywallShown?: MutableRefObject; setOnboardingPaywallOpen?: (open: boolean) => void; openRectificationSession: (exactSessionId: string) => Promise | void; - /** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */ - openRectificationFromChat: (sourceSessionId: string) => Promise | 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) { diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index f2dcb741..17b83259 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -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>; }; -/** 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 = async () => undefined; +let latestNewRectifyStart: () => Promise = 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()); }); }); diff --git a/frontend/src/lib/app-navigation.ts b/frontend/src/lib/app-navigation.ts index e50bd997..f1f4ec89 100644 --- a/frontend/src/lib/app-navigation.ts +++ b/frontend/src/lib/app-navigation.ts @@ -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; diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts index d9b0a4b3..c3b691df 100644 --- a/frontend/src/lib/chat-session-url.ts +++ b/frontend/src/lib/chat-session-url.ts @@ -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 { diff --git a/frontend/src/lib/contracts/rectification-entry.ts b/frontend/src/lib/contracts/rectification-entry.ts index 61ab3f4c..813349af 100644 --- a/frontend/src/lib/contracts/rectification-entry.ts +++ b/frontend/src/lib/contracts/rectification-entry.ts @@ -47,16 +47,26 @@ export type OpenRectificationCaseResponse = Omit; + 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<{ diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index 8d1d0617..401a76f0 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -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; }; diff --git a/frontend/src/lib/onboarding-checklist.ts b/frontend/src/lib/onboarding-checklist.ts index b5d430e7..2d3dcb2e 100644 --- a/frontend/src/lib/onboarding-checklist.ts +++ b/frontend/src/lib/onboarding-checklist.ts @@ -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, diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx index b47d2cb8..fa184b6d 100644 --- a/frontend/src/lib/onboarding-journey.tsx +++ b/frontend/src/lib/onboarding-journey.tsx @@ -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, diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index 37559fba..0e8229ef 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -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): RectificationCaseView { const range = row.candidate_range && typeof row.candidate_range === "object" ? (row.candidate_range as { start_time?: unknown; end_time?: unknown }) diff --git a/frontend/src/lib/rectification-entry.ts b/frontend/src/lib/rectification-entry.ts index 885a8d42..53b21940 100644 --- a/frontend/src/lib/rectification-entry.ts +++ b/frontend/src/lib/rectification-entry.ts @@ -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; + 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; diff --git a/frontend/src/lib/rectification-in-chat.ts b/frontend/src/lib/rectification-in-chat.ts index b1a08a09..22a887ac 100644 --- a/frontend/src/lib/rectification-in-chat.ts +++ b/frontend/src/lib/rectification-in-chat.ts @@ -66,13 +66,12 @@ export type RectificationOfferDecision = // ---- copy (VOICE.md「生时校正并入对话」) ---- -export function rectificationOfferCardCopy(offer: Pick): 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 = "按校正前的时间"; diff --git a/frontend/src/lib/rectification-page.ts b/frontend/src/lib/rectification-page.ts new file mode 100644 index 00000000..dee140c1 --- /dev/null +++ b/frontend/src/lib/rectification-page.ts @@ -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"); +} diff --git a/frontend/supabase/migrations/20261028170000_rectification_entry_summary_detail.sql b/frontend/supabase/migrations/20261028170000_rectification_entry_summary_detail.sql new file mode 100644 index 00000000..28f5f5ae --- /dev/null +++ b/frontend/supabase/migrations/20261028170000_rectification_entry_summary_detail.sql @@ -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; diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 0b881a67..6e0f38ff 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -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, />出生时间校正出生时间校正出生时间校正 = {}) { + 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;/); +}); diff --git a/frontend/tests/rectification-explicit-entry-20261012.test.ts b/frontend/tests/rectification-explicit-entry-20261012.test.ts index 34436ca7..622fc1af 100644 --- a/frontend/tests/rectification-explicit-entry-20261012.test.ts +++ b/frontend/tests/rectification-explicit-entry-20261012.test.ts @@ -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", () => { diff --git a/frontend/tests/rectification-in-chat-20261007.test.ts b/frontend/tests/rectification-in-chat-20261007.test.ts index d7c40429..897a5a9b 100644 --- a/frontend/tests/rectification-in-chat-20261007.test.ts +++ b/frontend/tests/rectification-in-chat-20261007.test.ts @@ -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); diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 8548d2f1..fa0e4a3d 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -1637,6 +1637,10 @@ def test_capability_audit_scans_registry_and_local_sources() -> None: # 新值: 加入 privacy(people 与 reports 之间)和 terms(末尾) # 原因: 隐私政策与用户协议是未登录也能打开的公开页面(2026-09-30 合规轮) 'privacy', + # 原值: privacy 后面直接是 reports + # 新值: 中间加入 rectification + # 原因: 生时校正独立入口 app/(app)/rectification(issue #145) + 'rectification', 'reports', 'reports/[reportId]', # 原值: reports/[reportId] 后面直接是 terms