+
{questions.map((question) => (
{selected.role === "other" ? (
people.some((person) => person.role === "self" && !person.incomplete)
- ?
window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘
+ ?
window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘
:
加上你自己后可合盘
) : null}
{/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
diff --git a/frontend/src/components/secondary-header.tsx b/frontend/src/components/secondary-header.tsx
index ad241911..d609bb29 100644
--- a/frontend/src/components/secondary-header.tsx
+++ b/frontend/src/components/secondary-header.tsx
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
+import { OnboardingChartDot } from "@/components/onboarding-dot";
import { SidebarTrigger } from "@/components/ui/sidebar";
/**
@@ -35,7 +36,10 @@ export function SecondaryHeader({ title, actions, note, besideTitle, reserveNote
const showNote = Boolean(note) || reserveNote;
return (
-
+
+
+
+
{title}
{besideTitle}
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx
index 82ea3ce2..a901cb1f 100644
--- a/frontend/src/components/ui/popover.tsx
+++ b/frontend/src/components/ui/popover.tsx
@@ -19,15 +19,17 @@ function PopoverContent({
alignOffset = 0,
side = "bottom",
sideOffset = 4,
+ anchor,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset"
+ "align" | "alignOffset" | "side" | "sideOffset" | "anchor"
>) {
return (
0
+ && turn.responseKind !== "smalltalk";
+}
+
+export function sessionCountsAsDaily(session: DailySession): boolean {
+ const titled = Boolean(session.title?.includes("今日节奏"));
+ const asked = (session.messages ?? []).some((message) => (
+ message.role === "user" && isGeneralDailyFortuneQuestion(message.text ?? "")
+ ));
+ if (!titled && !asked) return false;
+ if (session.messagesHydrated === false) return titled;
+ return (session.messages ?? []).some(isCompleteNonSmalltalkAnswer);
+}
+
+export function countDailySessions(sessions: readonly DailySession[]): number {
+ return sessions.filter(sessionCountsAsDaily).length;
+}
+
+/** A still-streaming reply does not count. `firstConsultFree === false` counts only after the stream settles. */
+export function hasCompleteNonSmalltalkAnswer(input: {
+ readonly sessions: readonly DailySession[];
+ readonly streaming?: boolean;
+ readonly streamingSessionId?: string | null;
+ readonly firstConsultFree?: boolean | null;
+}): boolean {
+ const streaming = Boolean(input.streaming);
+ const fromMessages = input.sessions.some((session) => {
+ if (session.messagesHydrated === false) return false;
+ let messages = session.messages ?? [];
+ if (streaming && input.streamingSessionId && session.id === input.streamingSessionId) {
+ const last = messages[messages.length - 1];
+ if (last?.role === "assistant") messages = messages.slice(0, -1);
+ }
+ return messages.some(isCompleteNonSmalltalkAnswer);
+ });
+ if (fromMessages) return true;
+ if (streaming) return false;
+ return input.firstConsultFree === false;
+}
+
+/** Both people complete → self copy. Help-other only when self is still incomplete. */
+export function checklistPath(people: readonly ChecklistPerson[]): ChecklistPath {
+ return people.some((person) => person.role === "self" && person.complete) ? "self" : "other";
+}
+
+/** Earliest filed complete other person. Blank or missing timestamps fall back to "TA". */
+export function helpedName(people: readonly ChecklistPerson[]): string {
+ const others = people.filter((person) => person.role === "other" && person.complete);
+ if (others.length === 0) return "TA";
+ const dated = others.filter((person) => person.createdAt != null);
+ const pool = dated.length > 0
+ ? [...dated].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
+ : others;
+ const name = pool[0]?.name.trim();
+ return name || "TA";
+}
+
+function withPrice(base: string, price: number | null): string {
+ return price == null ? base : `${base} · ${price} 点`;
+}
+
+function personDone(path: ChecklistPath, people: readonly ChecklistPerson[]): boolean {
+ if (path === "other") return people.some((person) => person.role === "self" && person.complete);
+ return people.filter((person) => person.complete).length >= 2;
+}
+
+export function buildChecklist(facts: ChecklistFacts): ChecklistItem[] {
+ const path = checklistPath(facts.people);
+ const name = helpedName(facts.people);
+ const order = path === "other" ? OTHER_ORDER : SELF_ORDER;
+ const synBase = path === "self" ? "和 TA 合一次盘" : `和${name}合一次盘`;
+ const items: ChecklistItem[] = [];
+ for (const id of order) {
+ if (id === "syn" && facts.flags.compatibility !== true) continue;
+ if (id === "report" && facts.flags.reportCenter !== true) continue;
+ items.push(itemFor(id, path, name, synBase, facts));
+ }
+ return items;
+}
+
+function itemFor(
+ id: ChecklistItemId,
+ path: ChecklistPath,
+ name: string,
+ synBase: string,
+ facts: ChecklistFacts,
+): ChecklistItem {
+ switch (id) {
+ case "self":
+ return {
+ id,
+ label: path === "self" ? "排好自己的盘" : `排好${name}的盘`,
+ done: facts.profileComplete,
+ go: null,
+ };
+ case "ask":
+ return { id, label: "问第一个问题", done: facts.hasNonSmalltalkAnswer, go: { kind: "compose" } };
+ case "daily":
+ return {
+ id,
+ label: "打开今日星语",
+ done: facts.dailyCount >= 1,
+ go: path === "self" ? { kind: "daily" } : { kind: "href", href: "/people" },
+ };
+ case "chart":
+ return {
+ id,
+ label: path === "self" ? "看看自己的星盘" : "看看星盘",
+ done: facts.chartVisited,
+ go: { kind: "href", href: "/chart" },
+ };
+ case "person":
+ return {
+ id,
+ label: path === "self" ? "加一位家人或朋友" : "加上你自己",
+ done: personDone(path, facts.people),
+ go: path === "self"
+ ? { kind: "href", href: "/people?add=1" }
+ : { kind: "href", href: "/people?self=1" },
+ };
+ case "syn":
+ return {
+ id,
+ label: withPrice(synBase, facts.flags.synastryCreditCost),
+ done: facts.synastryReportCount >= 1,
+ go: { kind: "href", href: "/people" },
+ };
+ case "report":
+ return {
+ id,
+ label: withPrice("生成个人报告", facts.flags.reportCreditCost),
+ done: facts.personalReportCount >= 1,
+ go: { kind: "href", href: "/reports" },
+ };
+ }
+}
+
+export function checklistProgress(items: readonly ChecklistItem[]): { done: number; total: number; label: string } {
+ const total = items.length;
+ const done = items.filter((item) => item.done).length;
+ return { done, total, label: `启程 ${done}/${total}` };
+}
+
+export function checklistAllDone(items: readonly ChecklistItem[]): boolean {
+ return items.length > 0 && items.every((item) => item.done);
+}
+
+export function shouldShowChecklist(input: {
+ readonly profileComplete: boolean;
+ readonly dismissedAt: string | null | undefined;
+ readonly arrivedComplete: boolean;
+}): boolean {
+ return input.profileComplete && !input.dismissedAt && !input.arrivedComplete;
+}
+
+export type ArrivalPhase = "pending" | "gap" | "arrived";
+
+/** First settled snapshot that is already full never celebrates. A later full snapshot does, once. */
+export function nextArrival(input: {
+ readonly decided: ArrivalPhase;
+ readonly canDecide: boolean;
+ readonly allDone: boolean;
+}): ArrivalPhase | "celebrate" {
+ if (!input.canDecide) return input.decided;
+ if (input.decided === "pending") return input.allDone ? "arrived" : "gap";
+ if (input.decided === "gap" && input.allDone) return "celebrate";
+ return input.decided;
+}
diff --git a/frontend/src/lib/onboarding-hints.ts b/frontend/src/lib/onboarding-hints.ts
new file mode 100644
index 00000000..434b7f78
--- /dev/null
+++ b/frontend/src/lib/onboarding-hints.ts
@@ -0,0 +1,160 @@
+import { ONBOARDING_HINT_IDS, type OnboardingHintId } from "@/lib/onboarding-state";
+
+export const HINT_COPY: Record = {
+ "follow-up": "想接着聊,点下面任一个就直接问。",
+ "sidebar-chart": "你的盘、大运和分盘都在这里。",
+ "tab-chart": "这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。",
+ "tab-info": "排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。",
+ "tab-dasha": "一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。",
+ "tab-west": "同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。",
+ "syn-ready": "有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。",
+ "ephemeris": "每天的天象和行运在这里,可以按日期翻。",
+};
+
+export const HINT_ANCHORS: Record = {
+ "follow-up": "follow-up",
+ "sidebar-chart": "sidebar-chart",
+ "tab-chart": "chart-tab",
+ "tab-info": "chart-tab",
+ "tab-dasha": "chart-tab",
+ "tab-west": "chart-tab",
+ "syn-ready": "syn-ready",
+ "ephemeris": "ephemeris",
+};
+
+export const TAB_HINT_IDS = {
+ vedic: "tab-chart",
+ basics: "tab-info",
+ dasha: "tab-dasha",
+ western: "tab-west",
+} as const;
+
+export type ChartHintTab = keyof typeof TAB_HINT_IDS;
+
+export const HINT_DISMISS_LABEL = "知道了";
+
+const HINT_ORDER = ONBOARDING_HINT_IDS;
+
+export function tabChartCopy(name: string | null): string {
+ const text = HINT_COPY["tab-chart"];
+ const trimmed = name?.trim() ?? "";
+ return trimmed ? text.replace("你的", `${trimmed}的`) : text;
+}
+
+export function hintCopy(id: OnboardingHintId, chartSubjectName: string | null): string {
+ return id === "tab-chart" ? tabChartCopy(chartSubjectName) : HINT_COPY[id];
+}
+
+export function followUpAfterStream(input: {
+ readonly wasStreaming: boolean;
+ readonly streaming: boolean;
+ readonly hadAnswerBefore: boolean;
+ readonly hasAnswerNow: boolean;
+}): boolean {
+ return input.wasStreaming && !input.streaming && !input.hadAnswerBefore && input.hasAnswerNow;
+}
+
+export function sidebarChartReady(input: {
+ readonly followUpSeen: boolean;
+ readonly chartVisited: boolean;
+ readonly sidebarOpen: boolean;
+}): boolean {
+ return input.followUpSeen && !input.chartVisited && input.sidebarOpen;
+}
+
+export type EphemerisPhase = "watch" | "wait-close" | "ready";
+
+/** The third daily open arms the *next* sidebar open. An already-open sidebar waits for a close first. */
+export function nextEphemerisPhase(input: {
+ readonly phase: EphemerisPhase;
+ readonly seen: boolean;
+ readonly dailyOpens: number;
+ readonly sidebarOpen: boolean;
+ readonly viewportReady: boolean;
+}): { readonly phase: EphemerisPhase; readonly armed: boolean } {
+ if (!input.viewportReady || input.seen || input.dailyOpens < 3) {
+ return { phase: input.dailyOpens < 3 && !input.seen ? "watch" : input.phase, armed: false };
+ }
+ let phase = input.phase;
+ if (phase === "watch") phase = input.sidebarOpen ? "wait-close" : "ready";
+ else if (phase === "wait-close" && !input.sidebarOpen) phase = "ready";
+ return { phase, armed: phase === "ready" && input.sidebarOpen };
+}
+
+export function showChartMenuDot(input: { readonly askDone: boolean; readonly chartVisited: boolean }): boolean {
+ return input.askDone && !input.chartVisited;
+}
+
+export function tabHasDot(tabId: string, hintsSeen: readonly OnboardingHintId[]): boolean {
+ const hintId = TAB_HINT_IDS[tabId as ChartHintTab];
+ return Boolean(hintId) && !hintsSeen.includes(hintId);
+}
+
+export function synReadyFact(input: {
+ readonly compatibility: boolean | null;
+ readonly selfComplete: boolean;
+ readonly completeOthers: number;
+ readonly synastryReportCount: number;
+}): boolean {
+ return input.compatibility === true
+ && input.selfComplete
+ && input.completeOthers >= 1
+ && input.synastryReportCount === 0;
+}
+
+export function hintsBlocked(input: {
+ readonly sky: boolean;
+ readonly dialog: boolean;
+ readonly streaming: boolean;
+ readonly checklistOpen: boolean;
+ readonly foreignDialog: boolean;
+}): boolean {
+ return input.sky || input.dialog || input.streaming || input.checklistOpen || input.foreignDialog;
+}
+
+export type HintFacts = {
+ readonly hintsSeen: readonly OnboardingHintId[];
+ readonly followUpArmed: boolean;
+ readonly chartVisited: boolean;
+ readonly sidebarOpen: boolean;
+ readonly activeChartTab: ChartHintTab | null;
+ readonly synReady: boolean;
+ readonly ephemerisArmed: boolean;
+ readonly blocked: boolean;
+ readonly anchors: Readonly>;
+};
+
+function eligible(facts: HintFacts): OnboardingHintId | null {
+ const seen = new Set(facts.hintsSeen);
+ const ready: Record = {
+ "follow-up": facts.followUpArmed,
+ "sidebar-chart": sidebarChartReady({
+ followUpSeen: facts.hintsSeen.includes("follow-up"),
+ chartVisited: facts.chartVisited,
+ sidebarOpen: facts.sidebarOpen,
+ }),
+ "tab-chart": facts.activeChartTab === "vedic",
+ "tab-info": facts.activeChartTab === "basics",
+ "tab-dasha": facts.activeChartTab === "dasha",
+ "tab-west": facts.activeChartTab === "western",
+ "syn-ready": facts.synReady,
+ "ephemeris": facts.ephemerisArmed && facts.sidebarOpen,
+ };
+ for (const id of HINT_ORDER) {
+ if (ready[id] && !seen.has(id)) return id;
+ }
+ return null;
+}
+
+/** The hint that should show once its anchor exists and nothing is covering the page. */
+export function selectHint(facts: HintFacts): OnboardingHintId | null {
+ if (facts.blocked) return null;
+ const id = eligible(facts);
+ if (!id) return null;
+ return facts.anchors[HINT_ANCHORS[id]] ? id : null;
+}
+
+/** Same order, ignoring blockers and missing anchors, so a covered hint is not dropped. */
+export function queuedHint(facts: HintFacts): OnboardingHintId | null {
+ return eligible({ ...facts, blocked: false, anchors: {} });
+}
diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx
new file mode 100644
index 00000000..bf55f7fc
--- /dev/null
+++ b/frontend/src/lib/onboarding-journey.tsx
@@ -0,0 +1,657 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+import { useSidebar } from "@/components/ui/sidebar";
+import { useSessionList } from "@/hooks/use-session-list";
+import {
+ buildChecklist,
+ checklistAllDone,
+ checklistProgress,
+ countDailySessions,
+ hasCompleteNonSmalltalkAnswer,
+ nextArrival,
+ shouldShowChecklist,
+ type ArrivalPhase,
+ type ChecklistItem,
+ type ChecklistPerson,
+} from "@/lib/onboarding-checklist";
+import {
+ hintCopy,
+ hintsBlocked,
+ nextEphemerisPhase,
+ selectHint,
+ showChartMenuDot,
+ synReadyFact,
+ tabHasDot,
+ type ChartHintTab,
+ type EphemerisPhase,
+ type HintFacts,
+} from "@/lib/onboarding-hints";
+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";
+
+const REPORT_PAGE_CAP = 3;
+const ANCHOR_NAMES = ["follow-up", "sidebar-chart", "chart-tab", "syn-ready", "ephemeris"];
+const EMPTY_HINTS: readonly OnboardingHintId[] = [];
+
+type RemoteOther = { readonly id: string; readonly profile: unknown; readonly createdAt: number | null };
+
+type RemoteSnapshot = {
+ readonly status: "loading" | "ready" | "hidden";
+ readonly userId: string;
+ readonly state: OnboardingState;
+ readonly others: readonly RemoteOther[];
+ readonly profilesKnown: boolean;
+ readonly accessKnown: boolean;
+ readonly compatibility: boolean;
+ readonly reportCenter: boolean;
+ readonly synastryCreditCost: number | null;
+ readonly reportCreditCost: number | null;
+ readonly synCount: number | null;
+ readonly personalCount: number | null;
+};
+
+type ReportRow = { readonly status?: string; readonly reportType?: string };
+
+type LiveState = {
+ readonly streaming: boolean;
+ readonly streamingSessionId: string | null;
+ readonly skyOpen: boolean;
+ readonly dialogOpen: boolean;
+ readonly foreignDialog: boolean;
+ readonly checklistOpen: boolean;
+ readonly anchors: Readonly>;
+ readonly selfProfile: Profile | null;
+ readonly activeChartTab: ChartHintTab | null;
+ readonly chartSubjectName: string | null;
+};
+
+type HomeActions = {
+ readonly focusComposer: () => void;
+ readonly startDaily: () => void;
+};
+
+export type OnboardingJourney = {
+ readonly showChecklist: boolean;
+ readonly items: readonly ChecklistItem[];
+ readonly label: string;
+ readonly celebration: boolean;
+ readonly activeHint: OnboardingHintId | null;
+ readonly hintText: string;
+ readonly showChartDot: boolean;
+ readonly tabHasDot: (tabId: string) => boolean;
+ readonly dismissChecklist: () => void;
+ readonly dismissHint: (id: OnboardingHintId) => Promise;
+ readonly go: (item: ChecklistItem) => void;
+ readonly setChartTab: (tab: string | null, name: string | null) => void;
+ readonly markChartVisited: () => void;
+ readonly setChecklistOpen: (open: boolean) => void;
+ readonly setLive: (patch: Partial) => void;
+ readonly registerHome: (actions: HomeActions | null) => void;
+};
+
+const OnboardingJourneyContext = createContext(null);
+
+const INITIAL_REMOTE: RemoteSnapshot = {
+ status: "loading",
+ userId: "",
+ state: {},
+ others: [],
+ profilesKnown: false,
+ accessKnown: false,
+ compatibility: false,
+ reportCenter: false,
+ synastryCreditCost: null,
+ reportCreditCost: null,
+ synCount: null,
+ personalCount: null,
+};
+
+const EMPTY_LIVE: LiveState = {
+ streaming: false,
+ streamingSessionId: null,
+ skyOpen: false,
+ dialogOpen: false,
+ foreignDialog: false,
+ checklistOpen: false,
+ anchors: {},
+ selfProfile: null,
+ activeChartTab: null,
+ chartSubjectName: null,
+};
+
+function createdAtMs(value: unknown): number | null {
+ if (typeof value !== "string") return null;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+function sameAnchors(left: Readonly>, right: Readonly>): boolean {
+ 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 {
+ 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", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(patch),
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+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) {
+ return { ...INITIAL_REMOTE, status: "hidden", userId };
+ }
+ const stateBody = await stateRes.json() as { state?: unknown };
+ const state = parseOnboardingState(stateBody.state);
+ 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) }]
+ : []
+ ));
+ }
+ let accessKnown = false;
+ let compatibility = false;
+ let reportCenter = false;
+ let synastryCreditCost: number | null = null;
+ let reportCreditCost: number | null = null;
+ if (accessRes.ok) {
+ const body = await accessRes.json() as Record;
+ if (typeof body.compatibility === "boolean" && typeof body.reportCenter === "boolean") {
+ accessKnown = true;
+ compatibility = body.compatibility;
+ reportCenter = body.reportCenter;
+ synastryCreditCost = typeof body.synastryCreditCost === "number" ? body.synastryCreditCost : null;
+ 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;
+ return {
+ status: "ready",
+ userId,
+ state,
+ others,
+ profilesKnown,
+ accessKnown,
+ compatibility,
+ reportCenter,
+ synastryCreditCost,
+ reportCreditCost,
+ synCount,
+ personalCount,
+ };
+}
+
+function toPeople(selfProfile: Profile, others: readonly RemoteOther[]): ChecklistPerson[] {
+ return [
+ {
+ id: "self",
+ role: "self",
+ name: selfProfile.name.trim() || "我",
+ complete: selfChartComplete(selfProfile),
+ createdAt: null,
+ },
+ ...others.map((row) => {
+ const profile = readProfile(row.profile);
+ return {
+ id: row.id,
+ role: "other" as const,
+ name: profile.name.trim(),
+ complete: missingOtherProfileStep(profile) === null && Boolean(profile.name.trim()),
+ createdAt: row.createdAt,
+ };
+ }),
+ ];
+}
+
+function chartTab(value: string | null): ChartHintTab | null {
+ return value === "vedic" || value === "basics" || value === "dasha" || value === "western" ? value : null;
+}
+
+export function useOnboardingJourneyOptional(): OnboardingJourney | null {
+ return useContext(OnboardingJourneyContext);
+}
+
+export function useOnboardingJourney(): OnboardingJourney {
+ const value = useOnboardingJourneyOptional();
+ if (!value) throw new Error("Onboarding journey is only available inside the app shell");
+ return value;
+}
+
+/** The one hint selector. `condition` is the caller's extra gate; the provider still queues the rest. */
+export function useOneTimeHint(id: OnboardingHintId, condition: boolean): { show: boolean; dismiss: () => void } {
+ const journey = useOnboardingJourneyOptional();
+ const show = Boolean(condition && journey?.activeHint === id);
+ const dismiss = useCallback(() => {
+ journey?.dismissHint(id);
+ }, [journey, id]);
+ return { show, dismiss };
+}
+
+export function OnboardingJourneyProvider({ children }: { readonly children: ReactNode }) {
+ const list = useSessionList();
+ const { open, openMobile, viewport, ready } = useSidebar();
+ const sidebarOpen = ready && (viewport === "mobile" ? openMobile : open);
+ const [remote, setRemote] = useState(INITIAL_REMOTE);
+ const [overlay, setOverlay] = useState({});
+ const [live, setLiveState] = useState(EMPTY_LIVE);
+ const [dailyFloor, setDailyFloor] = useState(0);
+ const [followUpArmed, setFollowUpArmed] = useState(false);
+ const [ephemerisArmed, setEphemerisArmed] = useState(false);
+ const [arrivedComplete, setArrivedComplete] = useState(false);
+ const [arrivalSettled, setArrivalSettled] = useState(false);
+ const [celebration, setCelebration] = useState(false);
+ const userId = list.account?.user.id ?? "";
+ const remoteRef = useRef(remote);
+ const overlayRef = useRef(overlay);
+ const homeRef = useRef(null);
+ const wasStreaming = useRef(false);
+ const hadAnswerBefore = useRef(false);
+ const ephemerisPhase = useRef("watch");
+ const arrivalPhase = useRef("pending");
+ const visitedRef = useRef(null);
+
+ useEffect(() => {
+ remoteRef.current = remote;
+ overlayRef.current = overlay;
+ }, [remote, overlay]);
+
+ const setLive = useCallback((patch: Partial) => {
+ setLiveState((current) => {
+ const anchors = patch.anchors ?? current.anchors;
+ const next: LiveState = {
+ streaming: patch.streaming ?? current.streaming,
+ streamingSessionId: patch.streamingSessionId !== undefined ? patch.streamingSessionId : current.streamingSessionId,
+ skyOpen: patch.skyOpen ?? current.skyOpen,
+ dialogOpen: patch.dialogOpen ?? current.dialogOpen,
+ foreignDialog: patch.foreignDialog ?? current.foreignDialog,
+ checklistOpen: patch.checklistOpen ?? current.checklistOpen,
+ anchors,
+ selfProfile: patch.selfProfile !== undefined ? patch.selfProfile : current.selfProfile,
+ activeChartTab: patch.activeChartTab !== undefined ? patch.activeChartTab : current.activeChartTab,
+ chartSubjectName: patch.chartSubjectName !== undefined ? patch.chartSubjectName : current.chartSubjectName,
+ };
+ if (
+ next.streaming === current.streaming
+ && next.streamingSessionId === current.streamingSessionId
+ && next.skyOpen === current.skyOpen
+ && next.dialogOpen === current.dialogOpen
+ && next.foreignDialog === current.foreignDialog
+ && next.checklistOpen === current.checklistOpen
+ && next.selfProfile === current.selfProfile
+ && next.activeChartTab === current.activeChartTab
+ && next.chartSubjectName === current.chartSubjectName
+ && sameAnchors(next.anchors, current.anchors)
+ ) return current;
+ return next;
+ });
+ }, []);
+
+ useEffect(() => {
+ setOnboardingDailyOpenedListener(() => {
+ setDailyFloor((count) => count + 1);
+ });
+ return () => setOnboardingDailyOpenedListener(null);
+ }, []);
+
+ useEffect(() => {
+ wasStreaming.current = false;
+ hadAnswerBefore.current = false;
+ ephemerisPhase.current = "watch";
+ arrivalPhase.current = "pending";
+ visitedRef.current = null;
+ queueMicrotask(() => {
+ setFollowUpArmed(false);
+ setEphemerisArmed(false);
+ setArrivedComplete(false);
+ setArrivalSettled(false);
+ setCelebration(false);
+ setOverlay({});
+ setDailyFloor(0);
+ });
+ }, [userId]);
+
+ useEffect(() => {
+ if (list.signedOut || !userId) {
+ 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 });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [list.signedOut, userId]);
+
+ const selfProfile = live.selfProfile ?? readProfile(list.account?.profile);
+ const people = useMemo(() => toPeople(selfProfile, remote.others), [selfProfile, remote.others]);
+ const profileComplete = people.some((person) => person.complete);
+ const merged = useMemo(() => ({
+ checklistDismissedAt: overlay.checklistDismissedAt !== undefined ? overlay.checklistDismissedAt : remote.state.checklistDismissedAt,
+ chartVisitedAt: overlay.chartVisitedAt !== undefined ? overlay.chartVisitedAt : remote.state.chartVisitedAt,
+ hintsSeen: overlay.hintsSeen ?? remote.state.hintsSeen,
+ }), [overlay, remote.state]);
+ const hintsSeen = merged.hintsSeen ?? EMPTY_HINTS;
+ const followUpSeen = hintsSeen.includes("follow-up");
+ const ephemerisSeen = hintsSeen.includes("ephemeris");
+ const chartVisited = Boolean(merged.chartVisitedAt);
+ const dismissed = Boolean(merged.checklistDismissedAt);
+ const dailySessions = countDailySessions(list.sessions);
+ const dailyOpens = Math.max(dailySessions, dailyFloor);
+ const hasAnswer = hasCompleteNonSmalltalkAnswer({
+ sessions: list.sessions,
+ streaming: live.streaming,
+ streamingSessionId: live.streamingSessionId,
+ firstConsultFree: list.account?.firstConsultFree,
+ });
+ const items = useMemo(() => buildChecklist({
+ people,
+ profileComplete,
+ hasNonSmalltalkAnswer: hasAnswer,
+ dailyCount: dailySessions,
+ chartVisited,
+ synastryReportCount: remote.synCount ?? 0,
+ personalReportCount: remote.personalCount ?? 0,
+ flags: {
+ compatibility: remote.accessKnown ? remote.compatibility : null,
+ reportCenter: remote.accessKnown ? remote.reportCenter : null,
+ synastryCreditCost: remote.synastryCreditCost,
+ reportCreditCost: remote.reportCreditCost,
+ },
+ }), [
+ people,
+ profileComplete,
+ hasAnswer,
+ dailySessions,
+ chartVisited,
+ remote.synCount,
+ remote.personalCount,
+ remote.accessKnown,
+ remote.compatibility,
+ remote.reportCenter,
+ remote.synastryCreditCost,
+ remote.reportCreditCost,
+ ]);
+ const progress = checklistProgress(items);
+ const allDone = checklistAllDone(items);
+ const countsReady = (!remote.accessKnown || !remote.compatibility || remote.synCount !== null)
+ && (!remote.accessKnown || !remote.reportCenter || remote.personalCount !== null);
+ const canDecide = remote.status === "ready"
+ && remote.userId === userId
+ && userId !== ""
+ && remote.accessKnown
+ && remote.profilesKnown
+ && countsReady
+ && list.settled
+ && profileComplete
+ && !dismissed;
+
+ useEffect(() => {
+ if (merged.chartVisitedAt) visitedRef.current = merged.chartVisitedAt;
+ }, [merged.chartVisitedAt]);
+
+ useEffect(() => {
+ if (!canDecide) return;
+ const next = nextArrival({ decided: arrivalPhase.current, canDecide: true, allDone });
+ if (next === "arrived") arrivalPhase.current = "arrived";
+ else if (next === "gap" || next === "celebrate") arrivalPhase.current = "gap";
+ queueMicrotask(() => {
+ if (next === "arrived") setArrivedComplete(true);
+ if (next === "celebrate") setCelebration(true);
+ setArrivalSettled(true);
+ });
+ }, [canDecide, allDone]);
+
+ useEffect(() => {
+ if (followUpSeen) {
+ wasStreaming.current = live.streaming;
+ return;
+ }
+ const was = wasStreaming.current;
+ const had = hadAnswerBefore.current;
+ if (!was && live.streaming) hadAnswerBefore.current = hasAnswer;
+ wasStreaming.current = live.streaming;
+ if (!followUpAfterStreamSafe(was, live.streaming, had, hasAnswer)) return;
+ queueMicrotask(() => setFollowUpArmed(true));
+ }, [live.streaming, hasAnswer, followUpSeen]);
+
+ useEffect(() => {
+ const next = nextEphemerisPhase({
+ phase: ephemerisPhase.current,
+ seen: ephemerisSeen,
+ dailyOpens,
+ sidebarOpen,
+ viewportReady: ready,
+ });
+ ephemerisPhase.current = next.phase;
+ queueMicrotask(() => setEphemerisArmed((current) => current === next.armed ? current : next.armed));
+ }, [ephemerisSeen, dailyOpens, sidebarOpen, ready]);
+
+ const selfComplete = people.some((person) => person.role === "self" && person.complete);
+ const completeOthers = people.filter((person) => person.role === "other" && person.complete).length;
+ const synReady = remote.accessKnown && remote.compatibility && remote.synCount !== null && synReadyFact({
+ compatibility: true,
+ selfComplete,
+ completeOthers,
+ synastryReportCount: remote.synCount,
+ });
+ const blocked = hintsBlocked({
+ sky: live.skyOpen,
+ dialog: live.dialogOpen,
+ streaming: live.streaming,
+ checklistOpen: live.checklistOpen,
+ foreignDialog: live.foreignDialog,
+ });
+ const hintFacts: HintFacts = {
+ hintsSeen,
+ followUpArmed,
+ chartVisited,
+ sidebarOpen,
+ activeChartTab: live.activeChartTab,
+ synReady,
+ ephemerisArmed,
+ blocked,
+ anchors: live.anchors,
+ };
+ const activeHint = selectHint(hintFacts);
+ const hintText = activeHint ? hintCopy(activeHint, live.chartSubjectName) : "";
+ const showChecklist = remote.status === "ready"
+ && shouldShowChecklist({
+ profileComplete,
+ dismissedAt: merged.checklistDismissedAt,
+ arrivedComplete,
+ })
+ && (!canDecide || arrivalSettled);
+
+ const dismissChecklist = useCallback(() => {
+ const stamp = new Date().toISOString();
+ setOverlay((current) => ({ ...current, checklistDismissedAt: stamp }));
+ setCelebration(false);
+ void patchOnboarding({ checklistDismissedAt: stamp }).then((ok) => {
+ if (!ok) setOverlay((current) => ({ ...current, checklistDismissedAt: remoteRef.current.state.checklistDismissedAt ?? null }));
+ });
+ }, []);
+
+ const dismissHint = useCallback((id: OnboardingHintId) => {
+ const seen = overlayRef.current.hintsSeen ?? remoteRef.current.state.hintsSeen ?? [];
+ if (seen.includes(id)) return Promise.resolve(true);
+ const hintsSeenNext = [...seen, id];
+ setOverlay((current) => ({ ...current, hintsSeen: hintsSeenNext }));
+ if (id === "follow-up") setFollowUpArmed(false);
+ return patchOnboarding({ hintsSeen: hintsSeenNext }).then((ok) => {
+ if (!ok) {
+ setOverlay((current) => ({ ...current, hintsSeen: seen }));
+ if (id === "follow-up") setFollowUpArmed(true);
+ }
+ return ok;
+ });
+ }, []);
+
+ const markChartVisited = useCallback(() => {
+ if (visitedRef.current) return;
+ const stamp = new Date().toISOString();
+ visitedRef.current = stamp;
+ setOverlay((current) => ({ ...current, chartVisitedAt: stamp }));
+ void patchOnboarding({ chartVisitedAt: stamp }).then((ok) => {
+ if (!ok) {
+ visitedRef.current = remoteRef.current.state.chartVisitedAt ?? null;
+ setOverlay((current) => ({ ...current, chartVisitedAt: remoteRef.current.state.chartVisitedAt ?? null }));
+ }
+ });
+ }, []);
+
+ const setChartTab = useCallback((tab: string | null, name: string | null) => {
+ setLive({ activeChartTab: chartTab(tab), chartSubjectName: name });
+ }, [setLive]);
+
+ const setChecklistOpen = useCallback((next: boolean) => {
+ setLive({ checklistOpen: next });
+ }, [setLive]);
+
+ const registerHome = useCallback((actions: HomeActions | null) => {
+ homeRef.current = actions;
+ }, []);
+
+ const go = useCallback((item: ChecklistItem) => {
+ setLive({ checklistOpen: false });
+ const target = item.go;
+ if (!target) return;
+ if (target.kind === "compose") {
+ if (homeRef.current) homeRef.current.focusComposer();
+ else window.location.assign("/?compose=1");
+ return;
+ }
+ if (target.kind === "daily") {
+ if (homeRef.current) homeRef.current.startDaily();
+ else window.location.assign("/?daily=1");
+ return;
+ }
+ window.location.assign(target.href);
+ }, [setLive]);
+
+ const dotVisible = showChartMenuDot({ askDone: hasAnswer, chartVisited });
+ const tabDot = useCallback((tabId: string) => tabHasDot(tabId, hintsSeen), [hintsSeen]);
+
+ const value = useMemo(() => ({
+ showChecklist,
+ items,
+ label: progress.label,
+ celebration,
+ activeHint,
+ hintText,
+ showChartDot: dotVisible,
+ tabHasDot: tabDot,
+ dismissChecklist,
+ dismissHint,
+ go,
+ setChartTab,
+ markChartVisited,
+ setChecklistOpen,
+ setLive,
+ registerHome,
+ }), [
+ showChecklist,
+ items,
+ progress.label,
+ celebration,
+ activeHint,
+ hintText,
+ dotVisible,
+ tabDot,
+ dismissChecklist,
+ dismissHint,
+ go,
+ setChartTab,
+ markChartVisited,
+ setChecklistOpen,
+ setLive,
+ registerHome,
+ ]);
+
+ return {children};
+}
+
+function followUpAfterStreamSafe(was: boolean, streaming: boolean, had: boolean, has: boolean): boolean {
+ return was && !streaming && !had && has;
+}
diff --git a/frontend/src/lib/onboarding-signals.ts b/frontend/src/lib/onboarding-signals.ts
new file mode 100644
index 00000000..ff2375bf
--- /dev/null
+++ b/frontend/src/lib/onboarding-signals.ts
@@ -0,0 +1,13 @@
+/** One listener: the home page fires it when 今日星语 actually starts. */
+
+type DailyOpenedListener = () => void;
+
+let dailyOpenedListener: DailyOpenedListener | null = null;
+
+export function setOnboardingDailyOpenedListener(listener: DailyOpenedListener | null): void {
+ dailyOpenedListener = listener;
+}
+
+export function notifyOnboardingDailyOpened(): void {
+ dailyOpenedListener?.();
+}
diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts
index c22f9149..e766fa0e 100644
--- a/frontend/src/lib/subject-birth.ts
+++ b/frontend/src/lib/subject-birth.ts
@@ -40,7 +40,7 @@ export const SELF_SUBJECT_ID = "self";
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation";
-export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at";
+export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at,created_at";
export const MAX_OTHER_SUBJECTS = 19;
export const MAX_SAVED_SUBJECTS = 20;
diff --git a/frontend/tests/onboarding-checklist-20261022.test.ts b/frontend/tests/onboarding-checklist-20261022.test.ts
new file mode 100644
index 00000000..96da98bb
--- /dev/null
+++ b/frontend/tests/onboarding-checklist-20261022.test.ts
@@ -0,0 +1,304 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ buildChecklist,
+ checklistAllDone,
+ checklistProgress,
+ CHECKLIST_CELEBRATION_BODY,
+ CHECKLIST_CELEBRATION_TITLE,
+ countDailySessions,
+ hasCompleteNonSmalltalkAnswer,
+ helpedName,
+ isCompleteNonSmalltalkAnswer,
+ nextArrival,
+ sessionCountsAsDaily,
+ shouldShowChecklist,
+ type ChecklistFacts,
+ type ChecklistPerson,
+} from "../src/lib/onboarding-checklist.ts";
+import {
+ followUpAfterStream,
+ HINT_COPY,
+ hintCopy,
+ nextEphemerisPhase,
+ queuedHint,
+ selectHint,
+ showChartMenuDot,
+ sidebarChartReady,
+ tabChartCopy,
+ tabHasDot,
+ type EphemerisPhase,
+ type HintFacts,
+} from "../src/lib/onboarding-hints.ts";
+
+function person(role: "self" | "other", name: string, complete: boolean, createdAt: number | null = null): ChecklistPerson {
+ return { id: role === "self" ? "self" : name, role, name, complete, createdAt };
+}
+
+function facts(patch: Partial = {}): ChecklistFacts {
+ return {
+ people: [person("self", "我", true)],
+ profileComplete: true,
+ hasNonSmalltalkAnswer: false,
+ dailyCount: 0,
+ chartVisited: false,
+ synastryReportCount: 0,
+ personalReportCount: 0,
+ flags: { compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 },
+ ...patch,
+ };
+}
+
+const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
+
+test("self path keeps the seven labels, order, and done rules", () => {
+ const open = buildChecklist(facts());
+ assert.deepEqual(open.map((item) => item.id), ["self", "ask", "daily", "chart", "person", "syn", "report"]);
+ assert.equal(open[0]?.label, "排好自己的盘");
+ assert.equal(open[0]?.go, null);
+ assert.equal(open[1]?.label, "问第一个问题");
+ assert.deepEqual(open[1]?.go, { kind: "compose" });
+ assert.equal(open[2]?.label, "打开今日星语");
+ assert.deepEqual(open[2]?.go, { kind: "daily" });
+ assert.equal(open[3]?.label, "看看自己的星盘");
+ assert.deepEqual(open[3]?.go, { kind: "href", href: "/chart" });
+ assert.equal(open[4]?.label, "加一位家人或朋友");
+ assert.equal(open[4]?.done, false);
+ assert.deepEqual(open[4]?.go, { kind: "href", href: "/people?add=1" });
+ assert.equal(open[5]?.label, "和 TA 合一次盘 · 19 点");
+ assert.deepEqual(open[5]?.go, { kind: "href", href: "/people" });
+ assert.equal(open[6]?.label, "生成个人报告 · 29 点");
+ assert.deepEqual(open[6]?.go, { kind: "href", href: "/reports" });
+ assert.equal(checklistProgress(open).label, "启程 1/7");
+});
+
+test("smalltalk is not ask, one self is not person, and a second other does not finish the help-other person row", () => {
+ assert.equal(isCompleteNonSmalltalkAnswer({ role: "assistant", text: "在的", responseKind: "smalltalk" }), false);
+ assert.equal(hasCompleteNonSmalltalkAnswer({
+ sessions: [{ messagesHydrated: true, messages: [{ role: "assistant", text: "在的", responseKind: "smalltalk" }] }],
+ firstConsultFree: true,
+ }), false);
+ assert.equal(hasCompleteNonSmalltalkAnswer({
+ sessions: [{ id: "s", messagesHydrated: true, messages: [{ role: "assistant", text: "完整回答" }] }],
+ streaming: true,
+ streamingSessionId: "s",
+ firstConsultFree: false,
+ }), false);
+ const selfOnly = buildChecklist(facts());
+ assert.equal(selfOnly.find((item) => item.id === "person")?.done, false);
+ const helped = facts({
+ people: [person("self", "我", false), person("other", "小周", true, 2), person("other", "小陈", true, 3)],
+ profileComplete: true,
+ });
+ const rows = buildChecklist(helped);
+ assert.deepEqual(rows.map((item) => item.id), ["self", "ask", "person", "daily", "chart", "syn", "report"]);
+ assert.equal(rows[0]?.label, "排好小周的盘");
+ assert.equal(rows.find((item) => item.id === "person")?.label, "加上你自己");
+ assert.equal(rows.find((item) => item.id === "person")?.done, false);
+ assert.deepEqual(rows.find((item) => item.id === "person")?.go, { kind: "href", href: "/people?self=1" });
+ assert.deepEqual(rows.find((item) => item.id === "daily")?.go, { kind: "href", href: "/people" });
+ assert.equal(rows.find((item) => item.id === "chart")?.label, "看看星盘");
+ assert.equal(rows.find((item) => item.id === "syn")?.label, "和小周合一次盘 · 19 点");
+});
+
+test("both people complete uses the self copy, and a missing price omits the price clause", () => {
+ const both = facts({
+ people: [person("self", "我", true, 5), person("other", "小周", true, 1)],
+ });
+ assert.equal(helpedName(both.people), "小周");
+ assert.equal(buildChecklist(both)[0]?.label, "排好自己的盘");
+ const unpriced = buildChecklist(facts({
+ flags: { compatibility: true, reportCenter: true, synastryCreditCost: null, reportCreditCost: null },
+ }));
+ assert.equal(unpriced.find((item) => item.id === "syn")?.label, "和 TA 合一次盘");
+ assert.equal(unpriced.find((item) => item.id === "report")?.label, "生成个人报告");
+});
+
+test("closed switches drop rows, and progress labels follow the remaining rows", () => {
+ assert.equal(buildChecklist(facts({ flags: { compatibility: false, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 } })).length, 6);
+ assert.equal(buildChecklist(facts({ flags: { compatibility: true, reportCenter: false, synastryCreditCost: 19, reportCreditCost: null } })).some((item) => item.id === "report"), false);
+ assert.equal(buildChecklist(facts({ flags: { compatibility: null, reportCenter: null, synastryCreditCost: null, reportCreditCost: null } })).length, 5);
+ const fresh = checklistProgress(buildChecklist(facts()));
+ assert.equal(fresh.label, "启程 1/7");
+ const older = buildChecklist(facts({
+ people: [person("self", "我", true), person("other", "小周", true)],
+ hasNonSmalltalkAnswer: true,
+ dailyCount: 0,
+ chartVisited: false,
+ synastryReportCount: 1,
+ personalReportCount: 1,
+ }));
+ assert.equal(checklistProgress(older).label, "启程 5/7");
+ assert.equal(older.find((item) => item.id === "daily")?.done, false);
+ assert.equal(older.find((item) => item.id === "chart")?.done, false);
+});
+
+test("a full account that already arrived stays hidden, and dismiss stays hidden", () => {
+ const full = buildChecklist(facts({
+ people: [person("self", "我", true), person("other", "小周", true)],
+ hasNonSmalltalkAnswer: true,
+ dailyCount: 1,
+ chartVisited: true,
+ synastryReportCount: 1,
+ personalReportCount: 1,
+ }));
+ assert.equal(checklistAllDone(full), true);
+ assert.equal(shouldShowChecklist({ profileComplete: true, dismissedAt: null, arrivedComplete: true }), false);
+ assert.equal(shouldShowChecklist({ profileComplete: true, dismissedAt: "2026-10-24T00:00:00.000Z", arrivedComplete: false }), false);
+ assert.equal(shouldShowChecklist({ profileComplete: false, dismissedAt: null, arrivedComplete: false }), false);
+ assert.equal(nextArrival({ decided: "pending", canDecide: true, allDone: true }), "arrived");
+ assert.equal(nextArrival({ decided: "pending", canDecide: true, allDone: false }), "gap");
+ assert.equal(nextArrival({ decided: "gap", canDecide: true, allDone: true }), "celebrate");
+ assert.equal(nextArrival({ decided: "pending", canDecide: false, allDone: true }), "pending");
+ assert.equal(CHECKLIST_CELEBRATION_TITLE, "都走过一遍了");
+ assert.equal(CHECKLIST_CELEBRATION_BODY, "以后有新东西,我会在用到的时候再告诉你。");
+});
+
+test("daily counts titled sessions and complete daily questions, not smalltalk", () => {
+ assert.equal(sessionCountsAsDaily({ title: "今日节奏 · 10月24日", messagesHydrated: false }), true);
+ assert.equal(sessionCountsAsDaily({
+ title: "今日节奏 · 10月24日",
+ messagesHydrated: true,
+ messages: [{ role: "assistant", text: "在的", responseKind: "smalltalk" }],
+ }), false);
+ assert.equal(sessionCountsAsDaily({
+ messagesHydrated: true,
+ messages: [
+ { role: "user", text: "深入看今日" },
+ { role: "assistant", text: "今天宜缓。" },
+ ],
+ }), true);
+ assert.equal(countDailySessions([
+ { title: "新对话", messagesHydrated: false },
+ { title: "10月2日 · 今日节奏", messagesHydrated: false },
+ ]), 1);
+});
+
+function hintFacts(patch: Partial = {}): HintFacts {
+ return {
+ hintsSeen: [],
+ followUpArmed: false,
+ chartVisited: false,
+ sidebarOpen: false,
+ activeChartTab: null,
+ synReady: false,
+ ephemerisArmed: false,
+ blocked: false,
+ anchors: { "follow-up": true, "sidebar-chart": true, "chart-tab": true, "syn-ready": true, ephemeris: true },
+ ...patch,
+ };
+}
+
+test("each hint appears on its condition and stays gone once seen", () => {
+ assert.equal(selectHint(hintFacts({ followUpArmed: true })), "follow-up");
+ assert.equal(selectHint(hintFacts({ followUpArmed: true, hintsSeen: ["follow-up"] })), null);
+ assert.equal(sidebarChartReady({ followUpSeen: false, chartVisited: false, sidebarOpen: true }), false);
+ assert.equal(selectHint(hintFacts({
+ hintsSeen: ["follow-up"],
+ sidebarOpen: true,
+ })), "sidebar-chart");
+ assert.equal(selectHint(hintFacts({ hintsSeen: ["follow-up"], sidebarOpen: true, chartVisited: true, activeChartTab: "vedic" })), "tab-chart");
+ assert.equal(selectHint(hintFacts({ activeChartTab: "basics" })), "tab-info");
+ assert.equal(selectHint(hintFacts({ activeChartTab: "dasha" })), "tab-dasha");
+ assert.equal(selectHint(hintFacts({ activeChartTab: "western" })), "tab-west");
+ assert.equal(selectHint(hintFacts({ synReady: true })), "syn-ready");
+ assert.equal(selectHint(hintFacts({ ephemerisArmed: true, sidebarOpen: true })), "ephemeris");
+ assert.equal(selectHint(hintFacts({
+ hintsSeen: ["follow-up", "sidebar-chart", "tab-chart", "tab-info", "tab-dasha", "tab-west", "syn-ready", "ephemeris"],
+ followUpArmed: true,
+ sidebarOpen: true,
+ activeChartTab: "vedic",
+ synReady: true,
+ ephemerisArmed: true,
+ })), null);
+});
+
+test("blocked hints and missing anchors do not show and are not dropped", () => {
+ const blocked = hintFacts({ followUpArmed: true, blocked: true });
+ assert.equal(selectHint(blocked), null);
+ assert.equal(queuedHint(blocked), "follow-up");
+ const streaming = hintFacts({ synReady: true, blocked: true, activeChartTab: "vedic" });
+ assert.equal(selectHint(streaming), null);
+ assert.equal(queuedHint(streaming), "tab-chart");
+ const missing = hintFacts({ followUpArmed: true, anchors: {} });
+ assert.equal(selectHint(missing), null);
+ assert.equal(queuedHint(missing), "follow-up");
+});
+
+test("follow-up only arms on the falling edge of the first real answer", () => {
+ assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: false, hasAnswerNow: true }), true);
+ assert.equal(followUpAfterStream({ wasStreaming: false, streaming: false, hadAnswerBefore: false, hasAnswerNow: true }), false);
+ assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: true, hasAnswerNow: true }), false);
+ assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: false, hasAnswerNow: false }), false);
+ assert.equal(followUpAfterStream({ wasStreaming: true, streaming: true, hadAnswerBefore: false, hasAnswerNow: false }), false);
+});
+
+test("ephemeris waits for the next sidebar open after the third daily", () => {
+ let phase: EphemerisPhase = "watch";
+ let step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 2, sidebarOpen: true, viewportReady: true });
+ phase = step.phase;
+ assert.equal(step.armed, false);
+ step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: true });
+ phase = step.phase;
+ assert.equal(phase, "wait-close");
+ assert.equal(step.armed, false);
+ step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: false, viewportReady: true });
+ phase = step.phase;
+ assert.equal(phase, "ready");
+ assert.equal(step.armed, false);
+ step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: true });
+ assert.equal(step.armed, true);
+ const closed = nextEphemerisPhase({ phase: "watch", seen: false, dailyOpens: 3, sidebarOpen: false, viewportReady: true });
+ assert.equal(closed.phase, "ready");
+ assert.equal(closed.armed, false);
+ assert.equal(nextEphemerisPhase({ phase: "watch", seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: false }).armed, false);
+});
+
+test("chart tab dots are recorded separately, and another person's chart renames one phrase", () => {
+ assert.equal(tabHasDot("vedic", []), true);
+ assert.equal(tabHasDot("basics", ["tab-chart"]), true);
+ assert.equal(tabHasDot("dasha", ["tab-dasha"]), false);
+ assert.equal(tabHasDot("western", ["tab-west"]), false);
+ assert.equal(tabHasDot("vedic", ["tab-chart"]), false);
+ assert.equal(tabChartCopy(null), HINT_COPY["tab-chart"]);
+ assert.equal(hintCopy("tab-chart", "小周"), HINT_COPY["tab-chart"].replace("你的", "小周的"));
+ assert.equal(hintCopy("tab-chart", "小周").includes("你的"), false);
+ assert.equal(hintCopy("tab-info", "小周"), HINT_COPY["tab-info"]);
+ assert.equal(showChartMenuDot({ askDone: true, chartVisited: false }), true);
+ assert.equal(showChartMenuDot({ askDone: true, chartVisited: true }), false);
+ assert.equal(showChartMenuDot({ askDone: false, chartVisited: false }), false);
+});
+
+test("checklist and hint wiring stay inside the existing shell", () => {
+ const page = read("../src/app/(app)/page.tsx");
+ const layout = read("../src/app/(app)/layout.tsx");
+ const sidebar = read("../src/components/app-sidebar.tsx");
+ const hint = read("../src/components/onboarding-hint-layer.tsx");
+ const css = read("../src/app/globals.css");
+ const people = read("../src/components/people/people-page.tsx");
+ const journey = read("../src/lib/onboarding-journey.tsx");
+ const home = page.slice(page.indexOf("export default function Home("));
+ assert.match(page, //);
+ assert.match(page, //);
+ const header = page.slice(page.indexOf(''), page.indexOf("composer-wrap"));
+ assert.match(header, //);
+ assert.equal(header.includes("composer-wrap"), false);
+ assert.match(layout, /list\.account\?\.compatibility === true/);
+ assert.doesNotMatch(layout, /\/api\/product-access/);
+ assert.doesNotMatch(sidebar, /supabase|fetch\(|\/api\//i);
+ assert.match(people, /searchParams\.get\("add"\) === "1"/);
+ assert.match(people, /searchParams\.get\("self"\) === "1"/);
+ assert.match(people, /data-onboarding-anchor="syn-ready"/);
+ assert.match(hint, /initialFocus=\{false\}/);
+ assert.match(hint, /finalFocus=\{false\}/);
+ assert.match(hint, /event\.key === "Escape"/);
+ assert.match(journey, /export function useOneTimeHint\(/);
+ assert.match(css, /\.chat-app \{[^}]*overflow:\s*clip/);
+ assert.match(css, /\.chat-header \{[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\) auto/);
+ assert.match(css, /\.onboarding-checklist-panel,\s*\n\.onboarding-checklist-sheet \{\s*\n\s*width: min\(22rem, calc\(100vw - 2rem\)\);/);
+ assert.match(css, /grid-template-columns:\s*1\.25rem minmax\(0,\s*1fr\) auto/);
+ assert.equal((home.match(/\buseState[<(]/g) ?? []).length <= 36, true);
+ assert.equal((home.match(/\buseRef[<(]/g) ?? []).length <= 37, true);
+});