Resume an unfinished rectification from the chart link.
Independent Staging Quality Gate / validate (push) Successful in 17m49s
Independent Staging Quality Gate / publish (push) Successful in 3m55s

The chart page now continues a case already open instead of showing a conflict. Someone else's chart no longer offers that link.

tsc exit 0. lint 0 errors, 122 warnings. Precheck and chart-page tests 56 pass. Opening tests 70 pass.

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-27 07:43:40 +08:00
co-authored by Grok (agent loop)
parent 44346dccfc
commit 9ca933968c
11 changed files with 307 additions and 20 deletions
+32
View File
@@ -19658,3 +19658,35 @@
- 复发自:无
- 修复版本:分支 `codex/lagna-precheck-20261027`。
## BUG-1446 | 星盘页的校正链接在已有校正时直接报错
- 状态:resolved
- 首次发现:2026-10-27
- 最近更新:2026-10-27
- 影响面:本人星盘页上「出生时间校正」链接。上升会换的人最容易已经开过一次校正。
- 用户现象:点这个链接,已经有一次没做完的校正时,页面报「仍有未完成的校正」,回不到那一次。
- 触发条件:校正还在进行,从星盘页打开 `/?rectify=1`。
- 根因:这个链接走的是「新开一次」的意图。数据库在已有可续校正时拒绝新开,并抛出冲突。首页入口用的是另一种意图:有就续上,没有就新建。
- 修复:星盘页链接改走首页入口的同一条路,不新建意图,不改数据库。重新开始校正的按钮仍是新开。
- 验证:`frontend/tests/lagna-precheck-rectify-entry.test.tsx`。已有校正时请求意图是 homepage,不出现冲突句;没有时同样是 homepage,由服务端新建。
- 防复发:星盘页入口不得再调用「新开一次」。冲突句只留给真的新开。
- 相关记录:BUG-1444。issue #112 第 1 次打回。
- 复发自:无
- 修复版本:分支 `codex/lagna-precheck-20261027`。
## BUG-1447 | 别人的星盘页也放了校正链接
- 状态:resolved
- 首次发现:2026-10-27
- 最近更新:2026-10-27
- 影响面:人物档案里其他人的星盘页页头。
- 用户现象:别人的星盘上也有「出生时间校正」。点进去只会说校正暂时只支持本人,是个没用的入口。
- 触发条件:打开人物档案里另一个人的星盘,上升会换,或不知道出生时间。
- 根因:这一句对所有星盘都加了链接。校正只做本人,链接在别人的页面上没有去处。
- 修复:不是本人时,句子停在换座时刻或「上升星座定不下来。」,不写校正,也不放链接。本人不变。
- 验证:`frontend/tests/chart-page-view.test.tsx` 里非本人两种情况没有链接、没有「出生时间校正」;本人仍有。`frontend/tests/lagna-precheck.test.ts` 锁住这两句。
- 防复发:非本人的页头句子不得再出现校正链接。
- 相关记录:BUG-1444、BUG-1446。issue #112 第 1 次打回。
- 复发自:无
- 修复版本:分支 `codex/lagna-precheck-20261027`。
@@ -42,3 +42,23 @@
- 已采用(accepted)和已确认(confirmed)都不显示这一句。候选时间仍显示。
- 顶栏在这一句出现时会高于原来的 46px,没有这一句时仍是 46px。见 `frontend/DESIGN.md`。
- Skill 文件没有改,版本不升。模型说明改在页面用的系统提示里;模型漏写限度时仍换回固定句。
## 第 1 次打回(修复单 TASK-lagna-precheck-fix-20261027)
开工前 `git merge origin/staging`(当时 staging `efede95bf`)。同一分支继续。
- 星盘页的「出生时间校正」改走首页那条路:已经有没做完的校正就回到那一次,没有才新建。不再走「新开一次」,所以不会再报「仍有未完成的校正」。重新开始的按钮没动。
- 人物档案里别人的星盘:上升会换或不知道时间时,句子停在换座时刻或「上升星座定不下来。」,没有链接,也不写去做校正。本人不变。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 规范 | `frontend` 下 `npm run lint` | 0 error,122 warning(原有,没有新增 error) |
| 时间窗、页头、链接 | `tests/lagna-precheck.test.ts`、`tests/lagna-precheck-chart.test.ts`、`tests/lagna-precheck-rectify-entry.test.tsx`、`tests/chart-page-view.test.tsx`、`tests/chat-session-url.test.ts` | 56 pass,0 fail |
| 校正开场三份和入口合同 | `rectification-opening-plain-20260926.test.ts`、同名 `.tsx`、`rectification-nearest-boundary-opening-20261012.test.ts`,加上表面合同四份 | 70 pass,0 fail |
| 全量 `npm test`、`next build` | 未跑 | 留给验收 |
| Python 快速门、`test:db` | 未跑 | 没改 Python,没改表 |
既有断言的通过值没有改。新增的是别人星盘没有链接,以及 `?rectify=1` 走 homepage。
BUG-1446、BUG-1447。真人清单第 5、6 条已按这次改法重写。
+2 -2
View File
@@ -8,5 +8,5 @@
2. 大概时间(前后 15 分钟或你选过的范围):同样看这一句是否和你填的精度一致。
3. 只知道一个时段:句子改成「按你给的时间段(开始–结束)」,不再写「前后约 N 分钟」。
4. 已经采用过校正时间的盘:这一句不出现。
5. 人物档案里另一个人的星盘页:这一句可以出现。点「出生时间校正」会回到首页,并提示生时校正暂时只支持本人。不会给他另开一套校正。
6. 本人点「出生时间校正」:进入现有的生时校正。开场要能听到三层意思:在哪段时间里找、你说几件大事和大概年月、不保证精确到某一分钟,说不准就照实记,中途可以停。
5. 人物档案里另一个人的星盘页:这一句可以出现。上升会换或不知道时间时,句子里没有「出生时间校正」,也没有可点的链接。不会给他另开一套校正。
6. 本人点「出生时间校正」:如果已经有一次没做完的校正,回到那一次,不要出现「仍有未完成的校正」。如果还没有,新开一次。开场要能听到三层意思:在哪段时间里找、你说几件大事和大概年月、不保证精确到某一分钟,说不准就照实记,中途可以停。
+1 -1
View File
@@ -1043,7 +1043,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
### 结构
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。出生资料下面可以多一句上升是否会换(`.chart-lagna-precheck`,caption、三级色,没有卡片和图标)。这一句在的时候,顶栏改成随内容增高,不再卡在 46px;没有这一句时顶栏仍是 46px。句中的「出生时间校正」是下划线链接,上升已经确定时没有链接。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。这一句和星盘一起出现,不算出来就不占位置,不另做等待。
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。出生资料下面可以多一句上升是否会换(`.chart-lagna-precheck`,caption、三级色,没有卡片和图标)。这一句在的时候,顶栏改成随内容增高,不再卡在 46px;没有这一句时顶栏仍是 46px。句中的「出生时间校正」是下划线链接,只出现在本人的星盘上,而且上升已经确定时没有链接。人物档案里别人的星盘只说上升会不会换,不放这个链接,也不写去做校正。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。这一句和星盘一起出现,不算出来就不占位置,不另做等待。
四个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。一排等重的药丸按钮会读成几个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。2026-10-25 起这四个页签是组件库 `Tabs`,`variant="line"`。盘还没到时四个都不可选。
+1
View File
@@ -271,6 +271,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。
- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。
- 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
## 报告分块导出与删除
@@ -66,8 +66,8 @@ function WaitingNote({ copy }: { copy: string }) {
return <p className="chart-page-note">{copy}</p>;
}
function ChartLagnaPrecheckNote({ value }: { value: ChartViewLagnaPrecheck }) {
const line = lagnaPrecheckLine(value);
function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck; self: boolean }) {
const line = lagnaPrecheckLine(value, { self });
return (
<p className="chart-lagna-precheck">
{line.before}
@@ -169,7 +169,9 @@ export function ChartPageView({
<span>{`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`}</span>
<Pencil aria-hidden="true" />
</Button>
{view.lagnaPrecheck ? <ChartLagnaPrecheckNote value={view.lagnaPrecheck} /> : null}
{view.lagnaPrecheck ? (
<ChartLagnaPrecheckNote value={view.lagnaPrecheck} self={(view.subjectId ?? "self") === "self"} />
) : null}
</>
)
: null;
@@ -126,7 +126,7 @@ export type RectificationSurfaceParams = {
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
};
/** The live self-only opener. A chart-page `/?rectify=1` queues one call after the hook is ready. */
/** The live chart-page opener. `/?rectify=1` queues one homepage open after the hook is ready. */
let latestChartRectifyStart: () => Promise<unknown> = async () => undefined;
export function useRectificationSurface(params: RectificationSurfaceParams) {
@@ -431,6 +431,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationCase("new", null, null);
}
/** Chart page link. Homepage resumes a case already open; it does not raise the new-case conflict. */
async function openChartPageRectification() {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("homepage", null, null);
}
function handleRectificationProfileIncomplete() {
setRectificationError("profile_incomplete");
setRectificationSessionId(null);
@@ -464,7 +470,7 @@ 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 = startNewRectification;
latestChartRectifyStart = openChartPageRectification;
if (chartRectifyConsumed.current) return;
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending) return;
const search = globalThis.location?.search ?? "";
+13 -12
View File
@@ -177,9 +177,14 @@ function signList(signs: readonly string[]): string {
return signs.map((sign) => `${sign}座`).join("、");
}
/** Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has no link. */
export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckLine {
/** Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has no link. Someone else's chart never links. */
export function lagnaPrecheckLine(
value: ChartViewLagnaPrecheck,
audience: { readonly self?: boolean } = {},
): LagnaPrecheckLine {
const self = audience.self !== false;
if (value.state === "unknown_time") {
if (!self) return { before: "不知道出生时间时,上升星座定不下来。", linked: false, after: "" };
return {
before: "不知道出生时间时,上升星座定不下来;做一次",
linked: true,
@@ -195,11 +200,9 @@ export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckL
};
}
if (value.signs.length >= 3) {
return {
before: `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。想定下来可以做一次`,
linked: true,
after: "。",
};
const listed = `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。`;
if (!self) return { before: listed, linked: false, after: "" };
return { before: `${listed}想定下来可以做一次`, linked: true, after: "。" };
}
const [first, second] = value.signs;
const when = value.changeAt?.[0];
@@ -207,9 +210,7 @@ export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckL
const lead = value.precisionKind === "period" && value.periodStart && value.periodEnd
? `按你给的时间段(${value.periodStart}–${value.periodEnd})`
: "按你给的时间精度";
return {
before: `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。想定下来可以做一次`,
linked: true,
after: "。",
};
const signs = `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。`;
if (!self) return { before: signs, linked: false, after: "" };
return { before: `${signs}想定下来可以做一次`, linked: true, after: "。" };
}
+32
View File
@@ -172,6 +172,38 @@ test("the birth line is followed by whether the ascendant changes inside the sta
assert.doesNotMatch(period, /出生时间校正/);
});
test("someone else's chart states the ascendant without a rectification link", async () => {
const view = await okView();
const other = { ...view, subjectId: "person-1" };
const self = { ...view, subjectId: "self" };
const mayChange = {
state: "may_change" as const,
signs: ["白羊", "金牛"],
changeAt: ["12:01"],
windowMinutes: 31,
precisionKind: "minutes" as const,
beforeMinutes: 15,
afterMinutes: 15,
};
const unknown = {
state: "unknown_time" as const,
signs: ["白羊"],
windowMinutes: 1440,
precisionKind: "unknown" as const,
};
const otherTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: mayChange } }));
assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。/);
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/\?rectify=1"|想定下来/);
const otherUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: unknown } }));
assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来。/);
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/\?rectify=1"|可以缩小范围/);
const selfTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: mayChange } }));
assert.match(selfTwo, /href="\/\?rectify=1"/);
assert.match(selfTwo, />出生时间校正</);
const selfUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: unknown } }));
assert.match(selfUnknown, />出生时间校正</);
});
test("the four tabs render and can each be selected", async () => {
const view = await okView();
for (const tab of CHART_VIEW_TABS) {
@@ -0,0 +1,184 @@
/**
* Chart-page `/?rectify=1` uses the homepage open, which resumes a case
* already in progress. Intent "new" would surface a conflict instead.
* Fictional profile only. BUG-1446.
*/
import assert from "node:assert/strict";
import test from "node:test";
import React, { useRef, useState } from "react";
import {
EMPTY_RECTIFICATION_SHELL,
useRectificationSurface,
} from "../src/hooks/use-rectification-surface.ts";
import type { Account, Profile } from "../src/lib/home-types.ts";
import type { PublicLanguageModelCatalog } from "../src/lib/public-models.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const SESSION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
reportedTime: "12:00",
birthTimeSource: "hospital_record",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
birthTimeStatus: "reported",
countryCode: "",
provinceCode: "",
cityCode: "",
districtCode: "",
birthPlaceLabel: "北京",
birthPlaceType: "city",
birthPlaceProvider: "test",
birthPlaceProviderId: "beijing",
timezoneId: "Asia/Shanghai",
timezoneSource: "manual",
latitude: 39.9,
longitude: 116.4,
timezoneOffset: 8,
rectificationCaseId: "",
ayanamsa: "raman",
} satisfies Profile;
const catalog: PublicLanguageModelCatalog = {
models: [{ id: "m", label: "M", description: "", creditCost: 1, isDefault: true }],
defaultModelId: "m",
};
function jsonResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}
function installLocation(search: string) {
const location = {
pathname: "/",
search,
href: `https://jyotisha.chat/${search}`,
};
const history = {
replaceState(_data: null, _title: string, next: string) {
const url = new URL(next, "https://jyotisha.chat");
location.pathname = url.pathname;
location.search = url.search;
location.href = url.toString();
},
pushState(_data: null, _title: string, next: string) {
history.replaceState(_data, _title, next);
},
};
const view = globalThis.window as { location?: typeof location; history?: typeof history };
view.location = location;
view.history = history;
Object.assign(globalThis, { location, history });
}
function Probe() {
const [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL);
const activeSessionIdRef = useRef("");
const sessionSelectionSource = useRef<"user" | "history">("user");
const uiPreview = useRef(false);
const rectificationSessionOpenerRef = useRef<(exactSessionId: string) => Promise<void>>(async () => undefined);
useRectificationSurface({
account: { id: "user-1" } as unknown as Account,
activeSessionIdRef,
chartLibrary: [],
creatingSession: false,
modelCatalog: catalog,
persistSession: async () => undefined,
profile,
rectification,
setRectification,
sessionSelectionSource,
sessions: [],
setActiveSessionId: () => undefined,
setDraft: () => undefined,
setDraftEntrypoint: () => undefined,
setDraftTheme: () => undefined,
setOnboardingStep: () => undefined,
setProfileNotice: () => undefined,
setSessions: () => undefined,
uiPreview,
updateSession: () => undefined,
openAccountDialog: () => undefined,
refreshAccount: async () => undefined,
rectificationSessionOpenerRef,
});
return React.createElement("p", {
"data-testid": "rectify-result",
"data-error": rectification.error,
"data-session": rectification.sessionId ?? "",
});
}
async function openFromChart(disposition: "resumed" | "created") {
const posts: Array<{ intent?: string }> = [];
const previousFetch = globalThis.fetch;
const harness = createClientLifecycleHarness();
installLocation("?rectify=1");
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/rectification/cases/open") {
posts.push(JSON.parse(String(init?.body ?? "{}")) as { intent?: string });
return jsonResponse({
disposition,
caseId: "case-1",
sessionId: SESSION_ID,
status: "draft",
shouldStartOpening: disposition === "created",
skillVersion: "10.0.33",
});
}
if (url.includes("/source")) return jsonResponse({ source: null });
if (url.startsWith("/api/sessions/")) return jsonResponse({ session: null });
return jsonResponse({});
}) as typeof fetch;
try {
await harness.render(React.createElement(Probe));
await harness.idle();
await harness.idle();
const node = harness.elements().find((element) => element.getAttribute("data-testid") === "rectify-result");
return {
posts,
box: {
errors: node?.getAttribute("data-error") ? [node.getAttribute("data-error") ?? ""] : [],
sessionId: node?.getAttribute("data-session") || null,
},
errors: harness.errors,
};
} finally {
globalThis.fetch = previousFetch;
await harness.close();
}
}
test("?rectify=1 resumes an open case with the homepage intent and does not show a conflict", async () => {
const { posts, box, errors } = await openFromChart("resumed");
assert.equal(errors.length, 0);
assert.equal(posts.length, 1);
assert.equal(posts[0]?.intent, "homepage");
assert.equal(box.sessionId, SESSION_ID);
assert.deepEqual(box.errors, []);
assert.equal(box.errors.join(""), "");
});
test("?rectify=1 starts a case with the homepage intent when none is open", async () => {
const { posts, box, errors } = await openFromChart("created");
assert.equal(errors.length, 0);
assert.equal(posts.length, 1);
assert.equal(posts[0]?.intent, "homepage");
assert.equal(box.sessionId, SESSION_ID);
assert.deepEqual(box.errors, []);
});
+9
View File
@@ -135,4 +135,13 @@ test("header sentences name the sign and link only when it can change", () => {
};
assert.equal(lagnaPrecheckLine(unknown).linked, true);
assert.match(lagnaPrecheckLine(unknown).before, /不知道出生时间时/);
const otherTwo = lagnaPrecheckLine(two, { self: false });
assert.equal(otherTwo.linked, false);
assert.equal(otherTwo.after, "");
assert.match(otherTwo.before, /大约 12:01 换座)。$/);
assert.doesNotMatch(otherTwo.before, /出生时间校正|想定下来/);
const otherUnknown = lagnaPrecheckLine(unknown, { self: false });
assert.equal(otherUnknown.linked, false);
assert.equal(otherUnknown.before, "不知道出生时间时,上升星座定不下来。");
});