From 99425fe61fcd34c697e9beef432c399be5739b13 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 01:32:38 +0000 Subject: [PATCH] refactor(home): stabilize shell setters and lower the synastry cluster 5.1: memoize createRectificationShellSetters so the four shell setters keep a stable identity across renders, then list the two the entry-summary effect uses in its dependency array. Lint warnings 120 -> 119 with no new warning. 5.2: move the four synastry useStates and draftSynastryQuestionFromChart into the new useSynastry hook. Home keeps only two stable callbacks the chart-library effect calls; the chart-library dialog receives one spread synastryPanel object. Zero behavior change, zero copy change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- frontend/src/app/page.tsx | 113 +++-------------------- frontend/src/hooks/use-synastry.ts | 143 +++++++++++++++++++++++++++++ frontend/tests/home-surface.ts | 1 + 3 files changed, 159 insertions(+), 98 deletions(-) create mode 100644 frontend/src/hooks/use-synastry.ts diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 45252ab8..65006c9a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { Sparkles } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { ProfilePanel } from "@/components/profile-panel"; @@ -75,6 +75,7 @@ import { EMPTY_RECTIFICATION_SHELL, useRectificationSurface, } from "@/hooks/use-rectification-surface"; +import { useSynastry } from "@/hooks/use-synastry"; import { useSessionManagement } from "@/hooks/use-session-management"; import { sortSessions } from "@/lib/session-groups"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; @@ -170,13 +171,10 @@ import { type RequestError, type StoredPendingConsultation, type StreamingReply, - type SynastryRelationshipType, - type SynastryReportCard, type Theme, } from "@/lib/home-types"; import { birthQuestion, - buildSynastryQuestion, chartSnapshotForSession, completedOnboardingMessage, completedOnboardingTranscript, @@ -216,7 +214,6 @@ import { readStoredDailyStarlanguage, readStoredPendingConsultation, redirectToLogin, - saveCloudSynastryReport, waitForUndoWindow, writeStoredDailyStarlanguage, } from "@/lib/home-cloud-sync"; @@ -251,10 +248,6 @@ export default function Home() { const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); const [activeChartId, setActiveChartId] = useState("self"); - const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); - const [synastryPendingId, setSynastryPendingId] = useState(null); - const [synastryReportCard, setSynastryReportCard] = useState(null); - const [synastryHistory, setSynastryHistory] = useState([]); const [billingPane, setBillingPane] = useState({}); const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" }); const [profileNotice, setProfileNotice] = useState(""); @@ -301,7 +294,7 @@ export default function Home() { setRectificationErrorSessionId, setRectificationEntrySummary, setRectificationEntrySummarySettled, - } = createRectificationShellSetters(setRectification); + } = useMemo(() => createRectificationShellSetters(setRectification), [setRectification]); const [hydrated, setHydrated] = useState(false); const [bootstrapPhase, setBootstrapPhase] = useState("account"); const prepareStartedAt = useRef(null); @@ -467,6 +460,10 @@ export default function Home() { guidedBirthTimeReadyRef, editDeclaredBirthTimeDetailsRef, }); + const { synastryPanel, clearSynastryHistory, applyCloudSynastryHistory } = useSynastry({ + accountId, profile, chooseSuggestedQuestion, closeAccountDialog, + }); + const { setRectificationHeaderSlot, rectificationPanel, @@ -554,7 +551,7 @@ export default function Home() { setRectificationEntrySummarySettled(true); } })(); - }, [accountId, bootstrapPhase]); + }, [accountId, bootstrapPhase, setRectificationEntrySummary, setRectificationEntrySummarySettled]); useEffect(() => { if (!hydrated || !accountId) return; @@ -568,7 +565,7 @@ export default function Home() { chartLibraryLoadedAccount.current = ""; queueMicrotask(() => { setChartLibrary([]); - setSynastryHistory([]); + clearSynastryHistory(); setActiveChartId("self"); }); return; @@ -578,7 +575,7 @@ export default function Home() { chartLibraryLoadedAccount.current = accountId; discardLegacyCloudMirrorKeys(accountId); setChartLibrary(chartLibraryOnCloudFailure(profileForLibrary, upsertSelfChart)); - setSynastryHistory([]); + clearSynastryHistory(); void fetchCloudChartLibrary() .then((cloudLibrary) => { setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart)); @@ -599,7 +596,7 @@ export default function Home() { }); void fetchCloudSynastryHistory() .then((cloudHistory) => { - setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10)); + applyCloudSynastryHistory(cloudHistory); }) .catch(() => { setComposerNotice("合盘历史暂时无法读取,请重试。", { @@ -607,7 +604,7 @@ export default function Home() { onClick: () => { void fetchCloudSynastryHistory() .then((cloudHistory) => { - setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10)); + applyCloudSynastryHistory(cloudHistory); }) .catch(() => { setComposerNotice("合盘历史暂时无法读取,请重试。"); @@ -617,7 +614,7 @@ export default function Home() { }); } setChartLibrary((current) => upsertSelfChart(current, profileForLibrary)); - }, [account, accountId, activeChartId, profile]); + }, [account, accountId, activeChartId, applyCloudSynastryHistory, clearSynastryHistory, profile]); useEffect(() => { if (!hydrated || !accountId || chartLibrary.length === 0) return; @@ -1408,84 +1405,6 @@ export default function Home() { ); } - async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { - if (record.role !== "other") return; - if (synastryPendingId) return; - const baseQuestion = buildSynastryQuestion(profile, record.profile, relationshipType); - setSynastryPendingId(record.id); - setComposerNotice("正在计算基础合盘证据,请稍候。"); - try { - const response = await fetch("/api/synastry", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ partnerChartProfileId: record.id, relationshipType }), - }); - const payload = await response.json().catch(() => null) as { status?: string; claimStatus?: string; blockedLayers?: string[]; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null; - if (response.ok && payload?.status === "ok") { - const score = payload.synastry?.total_score; - const max = payload.synastry?.max_score; - const assessment = payload.synastry?.assessment; - const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9"; - const evidenceSummary = relationshipType === "business" - ? `已完成基础商业合作证据筛查:${layers};声明状态:${payload.claimStatus || "partial"};未用层:${(payload.blockedLayers || []).join(" / ") || "A10 / 双方 Dasha-Narayana / 功能吉凶"}。请勿将其表述为合作保证或精确时点。` - : `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`; - const reportCard: SynastryReportCard = { - id: `${record.id}-${timestamp()}`, - partnerName: record.profile.name || "对方", - partnerChartId: record.id, - score, - maxScore: max, - assessment, - headline: payload.relationshipReport?.headline, - scoreBand: payload.relationshipReport?.scoreBand, - strengths: payload.relationshipReport?.strengths, - risks: payload.relationshipReport?.risks, - nextEvidence: payload.relationshipReport?.nextEvidence, - createdAt: timestamp(), - }; - let savedReportCard = reportCard; - let historyPersisted = !accountId; - if (accountId) { - try { - savedReportCard = await saveCloudSynastryReport(reportCard); - historyPersisted = true; - } catch { - historyPersisted = false; - } - } - setSynastryReportCard(savedReportCard); - if (accountId && historyPersisted) { - setSynastryHistory((current) => ( - [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10) - )); - } - chooseSuggestedQuestion([ - baseQuestion, - "", - evidenceSummary, - payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "", - ].join("\n"), relationshipType === "business" ? "career" : "marriage"); - if (accountId && !historyPersisted) { - setComposerNotice("未能存入历史"); - } - } else { - chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); - setComposerNotice(response.status === 404 - ? "请先把对方星盘保存到云端,再用于合盘。" - : response.status === 429 - ? "合盘请求过于频繁,请稍后再试。" - : payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); - } - } catch { - chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); - setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。"); - } finally { - setSynastryPendingId(null); - } - closeAccountDialog(); - } - - async function copyAssistantMessage(renderKey: string, text: string) { try { await navigator.clipboard.writeText(text); @@ -1609,10 +1528,8 @@ export default function Home() { account={account} accountId={accountId} chartLibrary={chartLibrary} setChartLibrary={setChartLibrary} profile={profile} profileDraft={profileDraft} setProfileDraft={setProfileDraft} setProfile={setProfile} profileSaving={profileSaving} profileNotice={profileNotice} setProfileNotice={setProfileNotice} setAccountError={setAccountError} - synastryRelationshipType={synastryRelationshipType} setSynastryRelationshipType={setSynastryRelationshipType} - synastryPendingId={synastryPendingId} synastryReportCard={synastryReportCard} setSynastryReportCard={setSynastryReportCard} - synastryHistory={synastryHistory} activeChartId={activeChartId} setActiveChartId={setActiveChartId} - saveProfile={saveProfile} draftSynastryQuestionFromChart={draftSynastryQuestionFromChart} + {...synastryPanel} activeChartId={activeChartId} setActiveChartId={setActiveChartId} + saveProfile={saveProfile} /> ); diff --git a/frontend/src/hooks/use-synastry.ts b/frontend/src/hooks/use-synastry.ts new file mode 100644 index 00000000..0a22ad7d --- /dev/null +++ b/frontend/src/hooks/use-synastry.ts @@ -0,0 +1,143 @@ +"use client"; + +import { useCallback, useState, type Dispatch, type SetStateAction } from "react"; + +import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; +import { saveCloudSynastryReport } from "@/lib/home-cloud-sync"; +import { buildSynastryQuestion } from "@/lib/home-profile"; +import { + timestamp, + type ChartLibraryRecord, + type Profile, + type SynastryRelationshipType, + type SynastryReportCard, + type Theme, +} from "@/lib/home-types"; + +export type SynastryParams = { + accountId: string | undefined; + profile: Profile; + chooseSuggestedQuestion: (question: string, theme: Theme) => void; + closeAccountDialog: () => void; +}; + +export type SynastryPanel = { + synastryRelationshipType: SynastryRelationshipType; + setSynastryRelationshipType: Dispatch>; + synastryPendingId: string | null; + synastryReportCard: SynastryReportCard | null; + setSynastryReportCard: Dispatch>; + synastryHistory: SynastryReportCard[]; + draftSynastryQuestionFromChart: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => Promise; +}; + +/** + * Owns the four synastry states that used to sit in Home(). Home keeps only the + * two stable history hooks the chart-library effect calls; everything else the + * chart-library dialog needs travels as one `synastryPanel` object. + */ +export function useSynastry(params: SynastryParams) { + const { accountId, profile, chooseSuggestedQuestion, closeAccountDialog } = params; + const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); + const [synastryPendingId, setSynastryPendingId] = useState(null); + const [synastryReportCard, setSynastryReportCard] = useState(null); + const [synastryHistory, setSynastryHistory] = useState([]); + + // Stable across renders so Home's chart-library effect can list them in its + // dependency array without gaining a re-run. + const clearSynastryHistory = useCallback(() => { + setSynastryHistory([]); + }, []); + const applyCloudSynastryHistory = useCallback((cloudHistory: SynastryReportCard[]) => { + setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10)); + }, []); + + async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { + if (record.role !== "other") return; + if (synastryPendingId) return; + const baseQuestion = buildSynastryQuestion(profile, record.profile, relationshipType); + setSynastryPendingId(record.id); + setComposerNotice("正在计算基础合盘证据,请稍候。"); + try { + const response = await fetch("/api/synastry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ partnerChartProfileId: record.id, relationshipType }), + }); + const payload = await response.json().catch(() => null) as { status?: string; claimStatus?: string; blockedLayers?: string[]; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null; + if (response.ok && payload?.status === "ok") { + const score = payload.synastry?.total_score; + const max = payload.synastry?.max_score; + const assessment = payload.synastry?.assessment; + const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9"; + const evidenceSummary = relationshipType === "business" + ? `已完成基础商业合作证据筛查:${layers};声明状态:${payload.claimStatus || "partial"};未用层:${(payload.blockedLayers || []).join(" / ") || "A10 / 双方 Dasha-Narayana / 功能吉凶"}。请勿将其表述为合作保证或精确时点。` + : `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`; + const reportCard: SynastryReportCard = { + id: `${record.id}-${timestamp()}`, + partnerName: record.profile.name || "对方", + partnerChartId: record.id, + score, + maxScore: max, + assessment, + headline: payload.relationshipReport?.headline, + scoreBand: payload.relationshipReport?.scoreBand, + strengths: payload.relationshipReport?.strengths, + risks: payload.relationshipReport?.risks, + nextEvidence: payload.relationshipReport?.nextEvidence, + createdAt: timestamp(), + }; + let savedReportCard = reportCard; + let historyPersisted = !accountId; + if (accountId) { + try { + savedReportCard = await saveCloudSynastryReport(reportCard); + historyPersisted = true; + } catch { + historyPersisted = false; + } + } + setSynastryReportCard(savedReportCard); + if (accountId && historyPersisted) { + setSynastryHistory((current) => ( + [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10) + )); + } + chooseSuggestedQuestion([ + baseQuestion, + "", + evidenceSummary, + payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "", + ].join("\n"), relationshipType === "business" ? "career" : "marriage"); + if (accountId && !historyPersisted) { + setComposerNotice("未能存入历史"); + } + } else { + chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); + setComposerNotice(response.status === 404 + ? "请先把对方星盘保存到云端,再用于合盘。" + : response.status === 429 + ? "合盘请求过于频繁,请稍后再试。" + : payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); + } + } catch { + chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); + setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。"); + } finally { + setSynastryPendingId(null); + } + closeAccountDialog(); + } + + const synastryPanel: SynastryPanel = { + synastryRelationshipType, + setSynastryRelationshipType, + synastryPendingId, + synastryReportCard, + setSynastryReportCard, + synastryHistory, + draftSynastryQuestionFromChart, + }; + + return { synastryPanel, clearSynastryHistory, applyCloudSynastryHistory }; +} diff --git a/frontend/tests/home-surface.ts b/frontend/tests/home-surface.ts index 3f1c5bc8..77c6d27e 100644 --- a/frontend/tests/home-surface.ts +++ b/frontend/tests/home-surface.ts @@ -19,6 +19,7 @@ const optionalHomeHookFiles = [ "../src/hooks/use-consultation-run.ts", "../src/hooks/use-profile-onboarding.ts", "../src/hooks/use-rectification-surface.ts", + "../src/hooks/use-synastry.ts", ] as const; export const homeSurface = [