From 413c1fde12a281fc54ed4cc5280eda407a3a5286 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 26 Oct 2026 23:45:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=AF=E7=A8=8B=E6=B8=85=E5=8D=95=E5=85=B3?= =?UTF-8?q?=E6=8E=89=E5=90=8E=E4=B8=8D=E5=86=8D=E7=BF=BB=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=EF=BC=8C=E5=8A=A0=E4=BA=BA=E5=90=8E=E4=B8=8D=E7=94=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=B0=B1=E6=89=93=E5=8B=BE=EF=BC=88BUG-1448=EF=BC=89?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Grok (agent loop) --- docs/BUG_HISTORY.md | 16 ++ ...PROGRESS-ui-kit-onboarding-fix-20261026.md | 8 + docs/tasks/README.md | 2 +- frontend/src/app/api/reports/route.ts | 7 + .../personal-report-center.tsx | 12 +- .../personal-report/personal-report-page.tsx | 3 + .../components/synastry/synastry-picker.tsx | 2 + frontend/src/lib/home-cloud-sync.ts | 2 + frontend/src/lib/onboarding-journey.tsx | 180 +++++++------- .../src/lib/onboarding-report-presence.ts | 43 ++++ frontend/src/lib/onboarding-signals.ts | 16 ++ frontend/src/lib/other-profile-save.ts | 2 + frontend/src/lib/self-profile-save.ts | 2 + .../tests/onboarding-facts-refresh.test.tsx | 230 ++++++++++++++++++ 14 files changed, 440 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/onboarding-report-presence.ts create mode 100644 frontend/tests/onboarding-facts-refresh.test.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index faad6639..5f1d085c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md index a8e8cc85..e967c08d 100644 --- a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -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 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index a421b1f9..8c604a81 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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 | diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 6f8cd712..f3bc13a8 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -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, diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 44141f24..454d492d 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -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()); const cancelled = useRef(false); + const listedReports = useRef(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) => { diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index cb1c575e..8d378bbf 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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({ diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index 26a2e5a2..25a2a6d8 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -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); diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index c3fe44e5..ca40a805 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -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; } diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx index 207f92b8..e60bbd65 100644 --- a/frontend/src/lib/onboarding-journey.tsx +++ b/frontend/src/lib/onboarding-journey.tsx @@ -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>, right: Readonly 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 { - 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 { - 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 { - 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 { try { const response = await fetch("/api/onboarding-state", { @@ -201,27 +162,51 @@ async function patchOnboarding(patch: OnboardingState): Promise { } } -async function loadRemote(userId: string): Promise { - 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 { + 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 { 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(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) => { 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; diff --git a/frontend/src/lib/onboarding-report-presence.ts b/frontend/src/lib/onboarding-report-presence.ts new file mode 100644 index 00000000..bdc94fc1 --- /dev/null +++ b/frontend/src/lib/onboarding-report-presence.ts @@ -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): Promise { + 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 { + 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 }; +} diff --git a/frontend/src/lib/onboarding-signals.ts b/frontend/src/lib/onboarding-signals.ts index ff2375bf..35146c92 100644 --- a/frontend/src/lib/onboarding-signals.ts +++ b/frontend/src/lib/onboarding-signals.ts @@ -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); +} diff --git a/frontend/src/lib/other-profile-save.ts b/frontend/src/lib/other-profile-save.ts index c757bd71..c7adb117 100644 --- a/frontend/src/lib/other-profile-save.ts +++ b/frontend/src/lib/other-profile-save.ts @@ -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) }; } diff --git a/frontend/src/lib/self-profile-save.ts b/frontend/src/lib/self-profile-save.ts index 740f3fe5..2dcf570f 100644 --- a/frontend/src/lib/self-profile-save.ts +++ b/frontend/src/lib/self-profile-save.ts @@ -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) }; } diff --git a/frontend/tests/onboarding-facts-refresh.test.tsx b/frontend/tests/onboarding-facts-refresh.test.tsx new file mode 100644 index 00000000..1482060c --- /dev/null +++ b/frontend/tests/onboarding-facts-refresh.test.tsx @@ -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 ; +} + +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(); + 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( + + + + + + + , + ); + 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"); + } + }); +});