启程清单关掉后不再翻报告,加人后不用刷新就打勾(BUG-1448)。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-26 23:45:23 +08:00
co-authored by Grok (agent loop)
parent 7fe335c7e0
commit 413c1fde12
14 changed files with 440 additions and 85 deletions
+16
View File
@@ -19690,3 +19690,19 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
## BUG-1448 | 启程清单关掉后仍翻全部报告,加人后不打勾
- 状态:resolved
- 首次发现:2026-10-26
- 最近更新:2026-10-26
- 影响面:已登录会话的启程清单。报告列表、合盘历史、档案列表。
- 用户现象:清单已经关掉的账户,每次打开仍会按人和页去翻报告。在页面里加一位家人、做成合盘或生成报告后,对应一项不打勾,要整页刷新才变。
- 触发条件:布局里每个账户拉一次清单。拉报告时本人加上最多 19 位其他人,每人最多 3 页。成功之后没有再读。
- 根因:关闭状态和「有没有报告」写在同一次大拉取里,关闭了也停不下来。清单用的人数和报告数只在这次拉取里更新。同一段里,动效开关用到了还没算出来的「清单是否可见」,外壳一渲染就抛错。
- 修复:清单已关时只读关闭状态,不再读档案、价格和报告。还开着时,报告和合盘改成一次「有没有至少一份」。加人、合盘存上、报告完成这几个成功之后再读这三项。进站时已经做完的账户没有单独的已满标记,所以仍会发这一次「有没有」,用来决定要不要把清单藏起来;不会再按页翻。动效开关改到清单是否可见算完之后再读。
- 验证:`frontend/tests/onboarding-facts-refresh.test.tsx`。清单已关只请求引导状态。打开时报告请求只有一次 `presence=1`。加一个人后不刷新页面,「加一位家人或朋友」变为已完成。
- 防复发:上面的请求计数和打勾测试。
- 相关记录:issue #111 验收 U5。#97。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
@@ -39,3 +39,11 @@
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 动效范围 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-motion-gate.test.tsx tests/onboarding-motion-20261022.test.ts tests/onboarding-hint-observer.test.tsx` | 10 通过,0 失败,退出码 0 |
## U5 · 清单关掉不翻报告,完成后会打勾(BUG-1448)
清单已关时只读「是否关掉」。还开着时,报告和合盘改成一次「有没有」。加人、合盘存上或报告完成后,这三项再读一次,不用整页刷新。进站时已经做完、但没有关掉的账户,仍会发这一次「有没有」,因为关闭时间这时不写;不再按人和页翻。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 拉取与打勾 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-facts-refresh.test.tsx` | 4 通过,0 失败,退出码 0 |
+1 -1
View File
@@ -40,7 +40,7 @@
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U4 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U5 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
+7
View File
@@ -45,6 +45,8 @@ import {
subjectIdFromQuery,
} from "@/lib/subject-birth";
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
import type { DataClient } from "@/lib/db/data-client";
import { readOnboardingReportPresence } from "@/lib/onboarding-report-presence";
import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor";
import { createSynastryFullReport, readSynastryPairState } from "@/lib/synastry-full-create";
@@ -111,6 +113,11 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const url = new URL(request.url);
// Checklist only needs "is there one ready report / synastry", not a page of bodies.
if (url.searchParams.get("presence") === "1") {
const presence = await readOnboardingReportPresence(supabase as DataClient, user.id);
return NextResponse.json(presence);
}
if (url.searchParams.get("reportType") === "synastry_full" && url.searchParams.get("pair") === "1") {
const state = await readSynastryPairState({
userId: user.id,
@@ -5,6 +5,7 @@ import { FileText, RefreshCw } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useCallback, useEffect, useRef, useState } from "react";
import { notifyError } from "@/lib/notify";
import { notifyOnboardingFactsChanged } from "@/lib/onboarding-signals";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubjectReady } from "@/lib/current-subject";
@@ -103,14 +104,17 @@ export function PersonalReportCenter() {
const [loadingMore, setLoadingMore] = useState(false);
const deletedIds = useRef(new Set<string>());
const cancelled = useRef(false);
const listedReports = useRef<readonly ReportListItem[]>(state.reports);
if (seenSubject !== subjectId) {
setSeenSubject(subjectId);
listedReports.current = [];
setState({ phase: "loading", ...EMPTY_LIST });
}
const applySnapshot = useCallback((snapshot: ReportsPageSnapshot) => {
const kept = (reports: readonly ReportListItem[]) => reports.filter(report => !deletedIds.current.has(report.id));
if (snapshot.kind === "unauthorized") {
listedReports.current = [];
setState({ phase: "unauthorized", ...EMPTY_LIST });
return;
}
@@ -118,7 +122,13 @@ export function PersonalReportCenter() {
setState(current => ({ ...current, phase: "error", reports: current.reports.length > 0 ? current.reports : kept(snapshot.reports) }));
return;
}
setState(current => ({ phase: "ready", ...applyFirstReportPage(current, { reports: kept(snapshot.reports), nextCursor: snapshot.nextCursor }) }));
const nextReports = kept(snapshot.reports);
const generating = new Set(listedReports.current.filter((report) => report.status === "generating").map((report) => report.id));
const finished = nextReports.filter((report) => report.status === "ready" && generating.has(report.id));
listedReports.current = nextReports;
if (finished.some((report) => report.reportType === "synastry_full")) notifyOnboardingFactsChanged("syn");
if (finished.some((report) => report.reportType !== "synastry_full")) notifyOnboardingFactsChanged("report");
setState(current => ({ phase: "ready", ...applyFirstReportPage(current, { reports: nextReports, nextCursor: snapshot.nextCursor }) }));
}, []);
const load = useCallback(async (showLoading = false) => {
@@ -11,6 +11,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { isRecord } from "@/lib/guards";
import { notifyOnboardingFactsChanged } from "@/lib/onboarding-signals";
import Link from "next/link";
@@ -353,6 +354,8 @@ export function PersonalReportPage({
return;
}
const next = classifyReportEnvelope(response.status, json);
if (next.phase === "synastry-ready") notifyOnboardingFactsChanged("syn");
else if (next.phase === "markdown-ready") notifyOnboardingFactsChanged("report");
if (next.phase === "generating") {
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
const signature = reportProgressSignature({
@@ -11,6 +11,7 @@ import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { fetchAccount } from "@/lib/home-cloud-sync";
import type { Profile } from "@/lib/home-types";
import { missingOtherProfileStep, readProfile } from "@/lib/home-profile";
import { notifyOnboardingFactsChanged } from "@/lib/onboarding-signals";
import { peopleCityLabel, peopleDisplayName } from "@/lib/people-archive-view";
import { PROFILE_GENDER_LABELS, parseProfileGender } from "@/lib/profile-gender";
import { MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
@@ -176,6 +177,7 @@ export function SynastryPicker() {
}
setAdding(null);
setDraft(emptySynastryDraft());
notifyOnboardingFactsChanged("person");
await load({ id: payload.profile.id, slot });
} finally {
setBusy(false);
+2
View File
@@ -16,6 +16,7 @@ import {
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry";
import { notifyOnboardingFactsChanged } from "@/lib/onboarding-signals";
import {
timestamp,
undoWindowMs,
@@ -176,6 +177,7 @@ export async function saveCloudSynastryReport(report: SynastryReportCard) {
body: JSON.stringify({ partnerName: report.partnerName, report }),
});
if (!response.ok) throw new Error("cloud_synastry_report_save_failed");
notifyOnboardingFactsChanged("syn");
const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null;
return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report;
}
+97 -83
View File
@@ -42,9 +42,12 @@ import {
import { missingOtherProfileStep, readProfile, selfChartComplete } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
import { parseOnboardingState, type OnboardingHintId, type OnboardingState } from "@/lib/onboarding-state";
import { setOnboardingDailyOpenedListener } from "@/lib/onboarding-signals";
import {
setOnboardingDailyOpenedListener,
setOnboardingFactsListener,
type OnboardingFactsReason,
} from "@/lib/onboarding-signals";
const REPORT_PAGE_CAP = 3;
const ANCHOR_NAMES = ["follow-up", "sidebar-chart", "chart-tab", "syn-ready", "ephemeris"];
const EMPTY_HINTS: readonly OnboardingHintId[] = [];
@@ -65,8 +68,6 @@ type RemoteSnapshot = {
readonly personalCount: number | null;
};
type ReportRow = { readonly status?: string; readonly reportType?: string };
type LiveState = {
readonly streaming: boolean;
readonly streamingSessionId: string | null;
@@ -148,46 +149,6 @@ function sameAnchors(left: Readonly<Record<string, boolean>>, right: Readonly<Re
return ANCHOR_NAMES.every((name) => Boolean(left[name]) === Boolean(right[name]));
}
function isReadyPersonal(row: ReportRow): boolean {
return row.status === "ready" && row.reportType !== "synastry_full";
}
async function countReports(path: string, accept: (row: ReportRow) => boolean): Promise<number | null> {
let count = 0;
let cursor: string | null = null;
for (let page = 0; page < REPORT_PAGE_CAP; page += 1) {
const url = new URL(path, window.location.origin);
if (cursor) url.searchParams.set("before", cursor);
const response = await fetch(`${url.pathname}${url.search}`, { cache: "no-store" });
if (!response.ok) return null;
const body = await response.json() as { reports?: ReportRow[]; nextCursor?: unknown };
count += (body.reports ?? []).filter(accept).length;
cursor = typeof body.nextCursor === "string" && body.nextCursor ? body.nextCursor : null;
if (!cursor) break;
}
return count;
}
async function countLegacySynastry(): Promise<number | null> {
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
if (response.status === 404) return 0;
if (!response.ok) return null;
const body = await response.json() as { reports?: unknown };
return Array.isArray(body.reports) ? body.reports.length : 0;
}
async function countPersonal(others: readonly RemoteOther[]): Promise<number | null> {
const selfCount = await countReports("/api/reports?subject=self", isReadyPersonal);
if (selfCount === null) return null;
if (selfCount > 0) return selfCount;
for (const other of others) {
const count = await countReports(`/api/reports?subject=${encodeURIComponent(other.id)}`, isReadyPersonal);
if (count === null) return null;
if (count > 0) return count;
}
return 0;
}
async function patchOnboarding(patch: OnboardingState): Promise<boolean> {
try {
const response = await fetch("/api/onboarding-state", {
@@ -201,27 +162,51 @@ async function patchOnboarding(patch: OnboardingState): Promise<boolean> {
}
}
async function loadRemote(userId: string): Promise<RemoteSnapshot> {
const [stateRes, profilesRes, accessRes] = await Promise.all([
fetch("/api/onboarding-state", { cache: "no-store" }),
fetch("/api/chart-profiles", { cache: "no-store" }),
fetch("/api/product-access", { cache: "no-store" }),
]);
if (stateRes.status === 401 || profilesRes.status === 401 || !stateRes.ok) {
function skippedRemote(userId: string, state: OnboardingState): RemoteSnapshot {
return {
status: "ready",
userId,
state,
others: [],
profilesKnown: true,
accessKnown: true,
compatibility: false,
reportCenter: false,
synastryCreditCost: null,
reportCreditCost: null,
synCount: 0,
personalCount: 0,
};
}
function readOthers(body: { profiles?: Array<{ id?: unknown; profile?: unknown; created_at?: unknown }> }): RemoteOther[] {
return (body.profiles ?? []).flatMap((row) => (
typeof row.id === "string" && row.id
? [{ id: row.id, profile: row.profile, createdAt: createdAtMs(row.created_at) }]
: []
));
}
/** Closed checklists do not need people, prices, or reports. Open ones ask once. */
export async function loadOnboardingRemote(userId: string): Promise<RemoteSnapshot> {
const stateRes = await fetch("/api/onboarding-state", { cache: "no-store" });
if (stateRes.status === 401 || !stateRes.ok) {
return { ...INITIAL_REMOTE, status: "hidden", userId };
}
const stateBody = await stateRes.json() as { state?: unknown };
const state = parseOnboardingState(stateBody.state);
if (state.checklistDismissedAt) return skippedRemote(userId, state);
const [profilesRes, accessRes] = await Promise.all([
fetch("/api/chart-profiles", { cache: "no-store" }),
fetch("/api/product-access", { cache: "no-store" }),
]);
if (profilesRes.status === 401) return { ...INITIAL_REMOTE, status: "hidden", userId };
let others: RemoteOther[] = [];
let profilesKnown = false;
if (profilesRes.ok) {
profilesKnown = true;
const body = await profilesRes.json() as { profiles?: Array<{ id?: unknown; profile?: unknown; created_at?: unknown }> };
others = (body.profiles ?? []).flatMap((row) => (
typeof row.id === "string" && row.id
? [{ id: row.id, profile: row.profile, createdAt: createdAtMs(row.created_at) }]
: []
));
others = readOthers(await profilesRes.json() as { profiles?: Array<{ id?: unknown; profile?: unknown; created_at?: unknown }> });
}
let accessKnown = false;
let compatibility = false;
@@ -238,17 +223,20 @@ async function loadRemote(userId: string): Promise<RemoteSnapshot> {
reportCreditCost = typeof body.reportCreditCost === "number" ? body.reportCreditCost : null;
}
}
// Session titles only cover the current person and the loaded page. Report
// lists stop after three pages. A failed price or flag read hides that row
// and refuses to treat the account as already finished.
const synCount = accessKnown && compatibility
? await (async () => {
const modern = await countReports("/api/reports?reportType=synastry_full", (row) => row.status === "ready");
const legacy = await countLegacySynastry();
return modern == null || legacy == null ? null : modern + legacy;
})()
: null;
const personalCount = accessKnown && reportCenter ? await countPersonal(others) : null;
// A failed price or flag read hides that row and refuses to treat the
// account as already finished. One presence read answers both rows.
// Arrival-complete accounts have no stored "full" flag, so this one read
// is how the shell learns to hide the checklist. It does not page.
let synCount: number | null = null;
let personalCount: number | null = null;
if (accessKnown && (compatibility || reportCenter)) {
const response = await fetch("/api/reports?presence=1", { cache: "no-store" });
const body = response.ok ? await response.json() as { personalReady?: unknown; synastryReady?: unknown } : null;
const personalReady = typeof body?.personalReady === "boolean" ? body.personalReady : null;
const synastryReady = typeof body?.synastryReady === "boolean" ? body.synastryReady : null;
if (compatibility) synCount = synastryReady === null ? null : synastryReady ? 1 : 0;
if (reportCenter) personalCount = personalReady === null ? null : personalReady ? 1 : 0;
}
return {
status: "ready",
userId,
@@ -327,6 +315,9 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
const userId = list.account?.user.id ?? "";
const remoteRef = useRef(remote);
const overlayRef = useRef(overlay);
const userIdRef = useRef(userId);
const requestSerial = useRef(0);
const arrivedRef = useRef(false);
const homeRef = useRef<HomeActions | null>(null);
const wasStreaming = useRef(false);
const hadAnswerBefore = useRef(false);
@@ -338,7 +329,21 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
useEffect(() => {
remoteRef.current = remote;
overlayRef.current = overlay;
}, [remote, overlay]);
userIdRef.current = userId;
arrivedRef.current = arrivedComplete;
}, [remote, overlay, userId, arrivedComplete]);
const publishRemote = useCallback((id: string) => {
const serial = requestSerial.current + 1;
requestSerial.current = serial;
void loadOnboardingRemote(id).then((snapshot) => {
if (requestSerial.current !== serial) return;
setRemote(snapshot);
}).catch(() => {
if (requestSerial.current !== serial) return;
setRemote({ ...INITIAL_REMOTE, status: "hidden", userId: id });
});
}, []);
const setLive = useCallback((patch: Partial<LiveState>) => {
setLiveState((current) => {
@@ -398,19 +403,28 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
useEffect(() => {
if (list.signedOut || !userId) {
requestSerial.current += 1;
queueMicrotask(() => setRemote({ ...INITIAL_REMOTE, status: "hidden" }));
return;
}
let cancelled = false;
void loadRemote(userId).then((snapshot) => {
if (!cancelled) setRemote(snapshot);
}).catch(() => {
if (!cancelled) setRemote({ ...INITIAL_REMOTE, status: "hidden", userId });
});
publishRemote(userId);
return () => {
cancelled = true;
requestSerial.current += 1;
};
}, [list.signedOut, userId]);
}, [list.signedOut, publishRemote, userId]);
useEffect(() => {
const onFacts = (reason: OnboardingFactsReason) => {
const id = userIdRef.current;
if (!id) return;
if (overlayRef.current.checklistDismissedAt || remoteRef.current.state.checklistDismissedAt) return;
if (arrivedRef.current) return;
if (reason === "person") setLive({ selfProfile: null });
publishRemote(id);
};
setOnboardingFactsListener(onFacts);
return () => setOnboardingFactsListener(null);
}, [publishRemote, setLive]);
const selfProfile = live.selfProfile ?? readProfile(list.account?.profile);
const people = useMemo(() => toPeople(selfProfile, remote.others), [selfProfile, remote.others]);
@@ -549,12 +563,6 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
streaming: live.streaming,
facts: hintFacts,
});
const motionEnabled = onboardingMotionEnabled({
checklistDismissed: Boolean(merged.checklistDismissedAt),
checklistOpen: showChecklist,
unfinishedItem: items.some((item) => !item.done),
beforeFirstQuestion: !hasAnswer && remote.status === "ready" && userId !== "",
});
const showChecklist = remote.status === "ready"
&& shouldShowChecklist({
profileComplete,
@@ -562,6 +570,12 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
arrivedComplete,
})
&& (!canDecide || arrivalSettled);
const motionEnabled = onboardingMotionEnabled({
checklistDismissed: Boolean(merged.checklistDismissedAt),
checklistOpen: showChecklist,
unfinishedItem: items.some((item) => !item.done),
beforeFirstQuestion: !hasAnswer && remote.status === "ready" && userId !== "",
});
useEffect(() => {
if (!showChecklist) return;
@@ -0,0 +1,43 @@
import type { DataClient, DataQueryResult } from "@/lib/db/data-client";
export type OnboardingReportPresence = Readonly<{
personalReady: boolean;
synastryReady: boolean;
}>;
async function hasRow(query: PromiseLike<DataQueryResult>): Promise<boolean> {
const { data, error } = await query;
if (error) throw error;
return Array.isArray(data) && data.length > 0;
}
/**
* One read for the onboarding checklist: whether this account has at least
* one ready personal report, and at least one ready synastry (new table or
* the legacy history). Stops at one id row. Never pages report bodies.
*/
export async function readOnboardingReportPresence(client: DataClient, userId: string): Promise<OnboardingReportPresence> {
const personal = client.from("personal_reports")
.select("id")
.eq("user_id", userId)
.eq("status", "ready")
.neq("report_type", "synastry_full")
.limit(1);
const modern = client.from("personal_reports")
.select("id")
.eq("user_id", userId)
.eq("status", "ready")
.eq("report_type", "synastry_full")
.limit(1);
const legacy = client.from("synastry_reports")
.select("id")
.eq("user_id", userId)
.is("rules_version", null)
.limit(1);
const [personalReady, modernReady, legacyReady] = await Promise.all([
hasRow(personal),
hasRow(modern),
hasRow(legacy),
]);
return { personalReady, synastryReady: modernReady || legacyReady };
}
+16
View File
@@ -11,3 +11,19 @@ export function setOnboardingDailyOpenedListener(listener: DailyOpenedListener |
export function notifyOnboardingDailyOpened(): void {
dailyOpenedListener?.();
}
/** Which checklist rows to refresh after a successful write. */
export type OnboardingFactsReason = "person" | "syn" | "report";
type FactsListener = (reason: OnboardingFactsReason) => void;
let factsListener: FactsListener | null = null;
export function setOnboardingFactsListener(listener: FactsListener | null): void {
factsListener = listener;
}
/** Person saved, synastry stored, or a report became ready. The shell refetches those three rows. */
export function notifyOnboardingFactsChanged(reason: OnboardingFactsReason): void {
factsListener?.(reason);
}
+2
View File
@@ -1,4 +1,5 @@
import type { Profile } from "./home-types.ts";
import { notifyOnboardingFactsChanged } from "./onboarding-signals.ts";
import { invalidateChartPage, invalidateEphemerisPage } from "./secondary-page-data.ts";
const IMMUTABLE_BIRTH_TIME = "填报的出生时间不能再改";
@@ -49,6 +50,7 @@ export async function saveOtherProfile(
if (!response.ok) throw new Error(failedSaveMessage(response.status, payload));
(options.invalidateChart ?? invalidateChartPage)();
(options.invalidateEphemeris ?? invalidateEphemerisPage)();
notifyOnboardingFactsChanged("person");
const returned = payload?.profile?.id;
return { id: typeof returned === "string" && returned !== "" ? returned : (id || null) };
}
+2
View File
@@ -3,6 +3,7 @@ import { applyPersistedBirthTime, birthTimePersistenceValues } from "./birth-tim
import { readChartBirthFingerprint } from "./chart-birth-truth.ts";
import { selectedBirthPlace } from "./home-profile.ts";
import type { Profile } from "./home-types.ts";
import { notifyOnboardingFactsChanged } from "./onboarding-signals.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
@@ -64,6 +65,7 @@ export async function saveSelfProfile(nextProfile: Profile, fetchImpl: Fetch = f
throw new Error(payload?.error || "账户资料暂时无法保存。");
}
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
notifyOnboardingFactsChanged("person");
return { profile: savedProfile, fingerprint: readChartBirthFingerprint(payload) };
}
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test, { describe } from "node:test";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import type { DataClient } from "../src/lib/db/data-client.ts";
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
import { readOnboardingReportPresence } from "../src/lib/onboarding-report-presence.ts";
import { loadOnboardingRemote, OnboardingJourneyProvider, useOnboardingJourney } from "../src/lib/onboarding-journey.tsx";
import { notifyOnboardingFactsChanged } from "../src/lib/onboarding-signals.ts";
import { invalidateAccountFlight, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { SessionListProvider } from "../src/lib/session-list-context.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111448";
const OTHER_ID = "22222222-2222-4222-8222-222222222448";
const DISMISSED_AT = "2026-10-01T00:00:00.000Z";
function completeProfile(name: string): Profile {
// `readProfile` keeps the snake_case birth fields the account route stores.
return {
...emptyProfile,
name,
date: "1990-01-02",
birthTimeSource: "unknown",
birth_time_source: "unknown",
birthPlaceLabel: "测试地点",
birth_place_label: "测试地点",
latitude: 31.2,
longitude: 121.5,
timezoneId: "Asia/Shanghai",
timezone_id: "Asia/Shanghai",
} as Profile;
}
const account: Account = {
user: { id: ACCOUNT_ID, email: null },
profile: completeProfile("Synthetic"),
avatar: null,
credits: 1,
isAdmin: false,
adminUrl: null,
rectificationPriceCredits: 0,
activeSubscription: null,
hasConfirmedBirthTime: false,
hasUsableBirthTime: false,
};
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
test("presence stops at one id row for a personal report and for synastry", async () => {
const calls: string[] = [];
const client = {
from(table: string) {
return {
select(columns: string) {
const steps = [`${table} select ${columns}`];
const chain = {
eq(column: string, value: unknown) {
steps.push(`eq ${column}=${String(value)}`);
return chain;
},
neq(column: string, value: unknown) {
steps.push(`neq ${column}=${String(value)}`);
return chain;
},
is(column: string, value: unknown) {
steps.push(`is ${column}=${String(value)}`);
return chain;
},
limit(count: number) {
calls.push(`${steps.join(" ")} limit ${count}`);
const personal = steps.some((step) => step.startsWith("neq "));
const legacy = table === "synastry_reports";
return Promise.resolve({ data: personal || legacy ? [{ id: "row" }] : [], error: null });
},
};
return chain;
},
};
},
};
const presence = await readOnboardingReportPresence(client as unknown as DataClient, ACCOUNT_ID);
assert.equal(calls.length, 3);
assert.equal(calls.every((call) => call.includes("select id") && call.endsWith("limit 1")), true);
assert.equal(calls.some((call) => call.includes("report,")), false);
assert.deepEqual(presence, { personalReady: true, synastryReady: true });
});
test("a closed checklist does not ask for people, prices, or reports", async () => {
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
urls.push(url);
assert.match(url, /\/api\/onboarding-state$/);
return jsonResponse({ state: { checklistDismissedAt: DISMISSED_AT } });
}) as typeof fetch;
try {
const snapshot = await loadOnboardingRemote(ACCOUNT_ID);
assert.equal(snapshot.status, "ready");
assert.equal(snapshot.state.checklistDismissedAt, DISMISSED_AT);
assert.deepEqual(urls, ["/api/onboarding-state"]);
} finally {
globalThis.fetch = original;
}
});
test("an open checklist asks once whether any report or synastry exists", async () => {
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
urls.push(url);
if (url.endsWith("/api/onboarding-state")) return jsonResponse({ state: {} });
if (url.endsWith("/api/chart-profiles")) return jsonResponse({ profiles: [] });
if (url.endsWith("/api/product-access")) {
return jsonResponse({ compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 });
}
if (url === "/api/reports?presence=1") return jsonResponse({ personalReady: true, synastryReady: false });
return jsonResponse({ error: "unexpected" }, 500);
}) as typeof fetch;
try {
const snapshot = await loadOnboardingRemote(ACCOUNT_ID);
assert.equal(snapshot.personalCount, 1);
assert.equal(snapshot.synCount, 0);
assert.deepEqual(urls.filter((url) => url.includes("/api/reports")), ["/api/reports?presence=1"]);
assert.equal(urls.some((url) => url.includes("/api/synastry-reports")), false);
assert.equal(urls.some((url) => url.includes("subject=")), false);
assert.equal(urls.some((url) => url.includes("before=")), false);
} finally {
globalThis.fetch = original;
}
});
function PersonProbe() {
const person = useOnboardingJourney().items.find((item) => item.id === "person");
return <span data-person={person ? String(person.done) : "missing"} />;
}
describe("checklist refresh inside the shell", { concurrency: 1 }, () => {
test("adding a person checks that row without a page reload", async () => {
resetSubjectCatalogForTests();
invalidateAccountFlight();
const urls: string[] = [];
let profiles: Array<{ id: string; profile: Profile; created_at: string }> = [];
const originalFetch = globalThis.fetch;
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => storage.clear(),
key: (index: number) => Array.from(storage.keys())[index] ?? null,
get length() { return storage.size; },
} satisfies Storage,
});
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
urls.push(url);
const path = url.split("?")[0] ?? url;
if (path.endsWith("/api/account")) return jsonResponse(account);
if (path.endsWith("/api/chart-profiles")) return jsonResponse({ profiles });
if (path.endsWith("/api/sessions")) return jsonResponse({ sessions: [], nextCursor: null });
if (path.endsWith("/api/models")) return jsonResponse({ defaultModelId: "test", models: [] });
if (path.endsWith("/api/onboarding-state")) return jsonResponse({ state: {} });
if (path.endsWith("/api/product-access")) {
return jsonResponse({ compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 });
}
if (url.includes("/api/reports?presence=1")) return jsonResponse({ personalReady: false, synastryReady: false });
return jsonResponse({});
}) as typeof fetch;
const harness = createClientLifecycleHarness();
Object.assign(harness.document.defaultView ?? {}, {
location: { pathname: "/", origin: "https://example.test", href: "https://example.test/" },
dispatchEvent: () => true,
});
const readPerson = () => harness.elements().find((node) => node.hasAttribute("data-person"))?.getAttribute("data-person") ?? null;
try {
await harness.render(
<SessionListProvider>
<SidebarProvider>
<OnboardingJourneyProvider>
<PersonProbe />
</OnboardingJourneyProvider>
</SidebarProvider>
</SessionListProvider>,
);
let seen = readPerson();
for (let i = 0; seen !== "false" && i < 40; i += 1) {
await harness.idle();
seen = readPerson();
}
assert.equal(seen, "false", harness.errors.map(String).join("\n"));
assert.equal(urls.filter((url) => url.includes("/api/reports")).length, 1);
assert.match(urls.find((url) => url.includes("/api/reports")) ?? "", /presence=1/);
profiles = [{ id: OTHER_ID, profile: completeProfile("小周"), created_at: "2026-10-02T00:00:00.000Z" }];
await harness.update(() => notifyOnboardingFactsChanged("person"));
for (let i = 0; seen !== "true" && i < 40; i += 1) {
await harness.idle();
seen = readPerson();
}
assert.equal(seen, "true", harness.errors.map(String).join("\n"));
assert.equal(urls.filter((url) => url.includes("/api/reports?presence=1")).length, 2);
assert.equal(urls.some((url) => url.includes("/api/reports") && url.includes("subject=")), false);
} finally {
await harness.close();
globalThis.fetch = originalFetch;
invalidateAccountFlight();
resetSubjectCatalogForTests();
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
}
});
});