启程清单关掉后不再翻报告,加人后不用刷新就打勾(BUG-1448)。
Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
7fe335c7e0
commit
413c1fde12
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user