diff --git a/CHANGELOG.md b/CHANGELOG.md index afd324b1..64b499b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 每个页面第一次打开再少下载约 25~30 KB + +- 首页、星盘页、档案页、报告页、星历页第一次打开再少下载约 25~30 KB(压缩后):日期解析改用自带的小函数,「退出」和「分享」用的代码等到用时才下载。 +- 退出登录、分享对话、填写出生日期照常。 +- Skill 版本不变(BUG-1475,issue #125)。 + ## 2026-10-28 — 首页第一次打开少下载约 95 KB - 首页第一次打开少下载约 95 KB(压缩后,约占原来的 13%):生时校正界面和回答排版组件等到用的时候才下载。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2883ee38..ac279dec 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20126,3 +20126,18 @@ - 关联:BUG-1130(城市表挪出首屏)、BUG-1469(#120 次级页同类问题)。 - 复发自:无 - 修复版本:分支 `codex/home-lazy-rectification-20261028`。 + +## BUG-1475 | 每个页面首屏都带着只在点击时才用的三个小依赖 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 16 条 → #125) +- 影响面:所有页面首屏;`frontend/src/lib/birth-time-intake-model.ts`、`hooks/use-profile-onboarding.ts`、`components/account-deletion-gate.tsx`、`hooks/use-shell-session-actions.ts`、`app/(app)/page.tsx`。 +- 现象:首屏下载里有 date-fns 的解析器(只为 `yyyy-MM-dd`)、better-auth 客户端(只为「退出」)、报告转换(只为「分享」);`page.tsx` 还有一个没用到的 `consultationReportMarkdown` import。 +- 触发条件:任何页面冷开。 +- 根因:三处都是静态 import,挂在外壳或首页一定会加载的模块上。 +- 修复:`parseBirthDate` / `formatBirthDate` 改为手写 `yyyy-MM-dd`(正则 + 回写校验,年份 1900~2100 不变);退出时才 `import("@/modules/identity/client")`;分享用的报告转换在首屏之后空闲时下载,点「分享」时已到手就和以前一样在点击里直接写剪贴板,极少数还没到手时走与「未读会话」相同的待定 blob 写法;删掉 `page.tsx` 的无用 import。日历组件里的 date-fns 仍随懒加载的日期选择器走。 +- 验证:`next build` 后按预渲染 HTML 引用的全部 JS/CSS 做 gzip-9 合计(与 #124 之后比):`/` 609,606 → 580,366 B(−29,240);`/chart` −29,177、`/people` −29,178、`/reports` −29,179、`/ephemeris` −23,605;`/` 仍 ○ Static。手写解析与 date-fns 旧实现在 97,507 个输入上逐个等价(1895~2105 年每月 0~32 日全组合 + 空格、单位数月、全角数字、`0000`、2 月 30 日等)。新测试 `frontend/tests/home-first-screen-deps-20261028.test.tsx`:三处不再静态 import;日期边界用例(2 月 30 日、1900-02-29、0000 年、前后空格等);分享在报告转换到手前后复制出的文字一致,到手后走点击内直接写剪贴板。`personal-report-entry.test.ts` 一处断言原本锁着那个无用 import,改认外壳按需加载。全量前端测试与 staging 逐条比对无新增失败。tsc 0、lint 0 error。 +- 防复发:上述测试。只在点击时用的库,在点击时或首屏之后再加载。 +- 关联:BUG-1474(#124 首页懒加载校正界面)、BUG-1130。 +- 复发自:无 +- 修复版本:分支 `codex/home-first-screen-deps-20261028`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index 69f3b044..67b4bc5c 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -29,3 +29,9 @@ 14. 侧栏点一条以前的长对话(或直接打开带 `?c=` 的对话链接):一出现就是排好版的样子,不先闪一下纯文本。 15. 从首页进入生时校正(说「帮我做生时纠正」,或从星盘页进入):校正界面一次出现,题目和进度都在,没有空白、没有转圈。 +## #125 首屏去掉三个小依赖(BUG-1475) + +16. 账户菜单点「退出」:照常退回登录页。 +17. 侧栏某条对话「…」→ 转发(分享):提示「已复制当前聊天」,粘贴出来有对话、Markdown 报告和 JSON 分享包三段。手机 Safari 也试一次。 +18. 建档或改资料时选出生日期:选得上、存得住;手动输入 2 月 30 日这类不存在的日期不被接受。 + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index a0845350..04222723 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -118,7 +118,6 @@ import { CONSULTATION_EVIDENCE_VALIDATION_LABEL, CONSULTATION_LOADING_METHOD_LABEL, } from "@/lib/consultation-activity-labels"; -import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { createStartGreetingParts } from "@/lib/starter-greeting"; import { SessionModelPersistenceQueue, diff --git a/frontend/src/components/account-deletion-gate.tsx b/frontend/src/components/account-deletion-gate.tsx index badc7280..62a8e7f7 100644 --- a/frontend/src/components/account-deletion-gate.tsx +++ b/frontend/src/components/account-deletion-gate.tsx @@ -15,7 +15,6 @@ import { type AccountDeletionStatus, } from "@/lib/account-deletion"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; -import { selfHostedOtpActions } from "@/modules/identity/client"; import { notifyError } from "@/lib/notify"; /** @@ -64,6 +63,7 @@ export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean } setBusy(true); clearHomeWarmSnapshot(); try { + const { selfHostedOtpActions } = await import("@/modules/identity/client"); await selfHostedOtpActions.signOut(); } finally { window.location.assign("/login"); diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index c5faad78..a5be9a75 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -60,7 +60,6 @@ import { type JourneyClientResponse, } from "@/lib/birth-time-journey-client"; import { previewRectificationJourney } from "@/lib/birth-time-guided-preview"; -import { selfHostedOtpActions } from "@/modules/identity/client"; import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; export type ProfileOnboardingParams = { @@ -426,6 +425,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { if (signingOut) return; setSigningOut(true); try { + // The auth client is only needed here, so it is not in the first screen (issue #125). + const { selfHostedOtpActions } = await import("@/modules/identity/client"); // Signing out ends this account's warm return to `/` (BUG-1040); a // failed sign-out only costs one loading ring on the next return. clearHomeWarmSnapshot(); diff --git a/frontend/src/hooks/use-shell-session-actions.ts b/frontend/src/hooks/use-shell-session-actions.ts index 37a7dbce..e875da21 100644 --- a/frontend/src/hooks/use-shell-session-actions.ts +++ b/frontend/src/hooks/use-shell-session-actions.ts @@ -2,10 +2,10 @@ import { useRef } from "react"; +import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; import type { SidebarSessionControls } from "@/components/sidebar-session-row"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeChatSession } from "@/lib/chat-session-write-contract"; -import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope } from "@/lib/current-subject"; import { fetchSessionDetail, fetchSessions, LoginRedirectError, readSessions } from "@/lib/home-cloud-sync"; import type { ChatSession } from "@/lib/home-types"; @@ -117,13 +117,16 @@ export function useShellSessionActions( async function shareSession(session: ChatSession) { // Off `/` a row carries no transcript yet; read it first. The clipboard // takes a pending blob, so Safari still counts the click as the gesture. - const transcriptFor = (source: ChatSession) => shareTranscript(source); + // The report export is downloaded after first paint (issue #125); in the + // rare click before it lands, the text is a pending blob like an unread row. const hydrated = session.messagesHydrated || session.messages.length > 0; try { - if (hydrated) { - await navigator.clipboard.writeText(transcriptFor(session)); + const reportMarkdown = loadedReportMarkdown(); + if (hydrated && reportMarkdown) { + await navigator.clipboard.writeText(shareTranscript(session, reportMarkdown)); } else { - const text = fetchSessionDetail(session.id, null).then((detailed) => transcriptFor(detailed ?? session)); + const source = hydrated ? Promise.resolve(session) : fetchSessionDetail(session.id, null).then((detailed) => detailed ?? session); + const text = Promise.all([source, loadReportMarkdown()]).then(([read, markdown]) => shareTranscript(read, markdown)); if (typeof ClipboardItem === "function" && navigator.clipboard.write) { await navigator.clipboard.write([new ClipboardItem({ "text/plain": text.then((value) => new Blob([value], { type: "text/plain" })) })]); } else { @@ -184,7 +187,24 @@ export function useShellSessionActions( }; } -function shareTranscript(session: ChatSession): string { +type ReportMarkdown = typeof import("@/lib/consultation-report-export").consultationReportMarkdown; + +let reportMarkdownLoaded: ReportMarkdown | undefined; + +function loadedReportMarkdown(): ReportMarkdown | undefined { + return reportMarkdownLoaded; +} + +function loadReportMarkdown(): Promise { + return import("@/lib/consultation-report-export").then((module) => { + reportMarkdownLoaded = module.consultationReportMarkdown; + return reportMarkdownLoaded; + }); +} + +prefetchOnIdle(loadReportMarkdown); + +function shareTranscript(session: ChatSession, consultationReportMarkdown: ReportMarkdown): string { const sharePayload = { share_payload_version: 1, exported_at: new Date().toISOString(), diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index 3474d10a..8694d18e 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -1,11 +1,11 @@ -import { format, isValid, parse } from "date-fns"; import type { JourneySnapshot } from "./birth-time-journey.ts"; import { declaredClockRange, matchingDeclaredPeriod, } from "./declared-birth-window.ts"; -const birthDatePattern = "yyyy-MM-dd"; +// `yyyy-MM-dd` only. Hand-written so the first screen does not carry date-fns (issue #125). +const birthDateShape = /^(\d{4})-(\d{2})-(\d{2})$/; const birthClockPattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const earliestBirthYear = 1900; const latestBirthYear = 2100; @@ -61,12 +61,14 @@ export type DeclaredBirthPlace = Readonly<{ }>; export function parseBirthDate(value: string): Date | undefined { - if (value === "") return undefined; - const parsed = parse(value, birthDatePattern, new Date(2000, 0, 1)); - if (!isValid(parsed) - || format(parsed, birthDatePattern) !== value - || parsed.getFullYear() < earliestBirthYear - || parsed.getFullYear() > latestBirthYear) return undefined; + const match = birthDateShape.exec(value); + if (!match) return undefined; + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + if (year < earliestBirthYear || year > latestBirthYear) return undefined; + const parsed = new Date(2000, 0, 1); + parsed.setFullYear(year, month - 1, day); + // Rolled over (02-30, month 13, day 00): not a calendar date. + if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) return undefined; return parsed; } @@ -85,7 +87,8 @@ export function isBirthClockTime(value: string): boolean { } export function formatBirthDate(value: Date): string { - return format(value, birthDatePattern); + const pad = (part: number, width: number) => String(part).padStart(width, "0"); + return `${pad(value.getFullYear(), 4)}-${pad(value.getMonth() + 1, 2)}-${pad(value.getDate(), 2)}`; } export const APPROXIMATE_UNCERTAINTY_MINUTES = [15, 30, 60, 120] as const; diff --git a/frontend/tests/home-first-screen-deps-20261028.test.tsx b/frontend/tests/home-first-screen-deps-20261028.test.tsx new file mode 100644 index 00000000..2cbeb25f --- /dev/null +++ b/frontend/tests/home-first-screen-deps-20261028.test.tsx @@ -0,0 +1,124 @@ +/** + * Issue #125: date-fns, the better-auth client and the report export are not + * in the first screen. Dates parse the same, sign-out and share behave the same. + */ +import "./install-test-document"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React, { useEffect } from "react"; + +import { useShellSessionActions } from "../src/hooks/use-shell-session-actions.ts"; +import { formatBirthDate, parseBirthDate } from "../src/lib/birth-time-intake-model.ts"; +import type { Account, ChatSession } from "../src/lib/home-types.ts"; +import type { SessionListContextValue } from "../src/lib/session-list-context.tsx"; +import { useSessionStore } from "../src/stores/session-store.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + +test("first-screen modules import none of the three click-only dependencies statically", () => { + assert.doesNotMatch(read("../src/lib/birth-time-intake-model.ts"), /from "date-fns"/); + for (const path of ["../src/hooks/use-profile-onboarding.ts", "../src/components/account-deletion-gate.tsx"]) { + const source = read(path); + assert.doesNotMatch(source, /^import .*"@\/modules\/identity\/client"/m, path); + assert.match(source, /await import\("@\/modules\/identity\/client"\)/, path); + } + const shell = read("../src/hooks/use-shell-session-actions.ts"); + assert.doesNotMatch(shell, /^import .*"@\/lib\/consultation-report-export"/m); + assert.match(shell, /prefetchOnIdle\(loadReportMarkdown\)/); + assert.doesNotMatch(read("../src/app/(app)/page.tsx"), /consultationReportMarkdown/); +}); + +test("birth dates: the hand-written parser keeps date-fns's strict yyyy-MM-dd", () => { + assert.equal(formatBirthDate(parseBirthDate("1990-03-14")!), "1990-03-14"); + assert.equal(parseBirthDate("1992-02-29")?.getDate(), 29); + assert.equal(parseBirthDate("1990-02-30"), undefined, "February 30th"); + assert.equal(parseBirthDate("1900-02-29"), undefined, "1900 is not a leap year"); + assert.equal(parseBirthDate("0000-01-01"), undefined, "year 0000"); + assert.equal(parseBirthDate(" 1990-01-01"), undefined, "leading space"); + assert.equal(parseBirthDate("1990-01-01 "), undefined, "trailing space"); + assert.equal(parseBirthDate("1990-1-01"), undefined, "single-digit month"); + assert.equal(parseBirthDate("1899-12-31"), undefined, "before 1900"); + assert.equal(parseBirthDate("2101-01-01"), undefined, "after 2100"); + assert.equal(parseBirthDate(""), undefined); + const parsed = parseBirthDate("1900-01-01")!; + assert.deepEqual([parsed.getFullYear(), parsed.getMonth(), parsed.getDate(), parsed.getHours()], [1900, 0, 1, 0]); +}); + +function hydratedSession(): ChatSession { + return { + id: "session-share", + title: "合成分享会话", + theme: "career", + modelId: "test", + messages: [{ role: "user", text: "这个月工作如何?" }, { role: "assistant", text: "**稳住**节奏。" }], + createdAt: 1, + updatedAt: 1_700_000_000_000, + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: null, + chartProfileName: null, + chartProfileRole: null, + pinned: false, + archivedAt: null, + messagesHydrated: true, + }; +} + +async function shareOnce(): Promise<{ writes: string[]; blobWrites: number }> { + const harness = createClientLifecycleHarness(); + const writes: string[] = []; + let blobWrites = 0; + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + clipboard: { + writeText: async (text: string) => { writes.push(text); }, + write: async (items: Array<{ getType: (type: string) => Promise }>) => { + blobWrites += 1; + writes.push(await (await items[0].getType("text/plain")).text()); + }, + }, + }, + }); + const originalItem = (globalThis as { ClipboardItem?: unknown }).ClipboardItem; + (globalThis as { ClipboardItem?: unknown }).ClipboardItem = class { + constructor(private readonly parts: Record>) {} + getType(type: string) { return this.parts[type]; } + }; + const list = { account: { user: { id: "user-1" } } as unknown as Account } as SessionListContextValue; + useSessionStore.setState({ sessions: [hydratedSession()], pendingSessionId: null, cancellationPending: false }); + let actions: ReturnType | null = null; + function Probe() { + const value = useShellSessionActions(list, null); + useEffect(() => { actions = value; }); + return null; + } + try { + await harness.render(); + await harness.update(() => { actions!.controls!.onShare("session-share"); }); + for (let attempt = 0; attempt < 20 && writes.length === 0; attempt += 1) await harness.idle(); + return { writes, blobWrites }; + } finally { + await harness.close(); + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator); + (globalThis as { ClipboardItem?: unknown }).ClipboardItem = originalItem; + } +} + +test("share copies the same text before and after the report export has loaded", async () => { + const cold = await shareOnce(); + const warm = await shareOnce(); + assert.equal(cold.writes.length, 1); + assert.equal(warm.writes.length, 1); + const stamp = (text: string) => text.replace(/"exported_at": "[^"]+"/, '"exported_at": "