First screen drops date-fns, the auth client and the report export (issue #125, BUG-1475)
Independent Staging Quality Gate / validate (push) Successful in 14m54s
Independent Staging Quality Gate / publish (push) Successful in 4m7s

- parseBirthDate/formatBirthDate: hand-written yyyy-MM-dd with the same strict
  round-trip check; equal to the date-fns version on 97,507 inputs.
- Sign-out imports the better-auth client on click.
- The share report export downloads at idle after first paint; once loaded,
  share writes the clipboard inside the click exactly as before.
- page.tsx: drop the unused consultationReportMarkdown import.

gzip-9 of every referenced JS/CSS: / -29,240 B (still Static); /chart, /people,
/reports about -29 KB; /ephemeris -23.6 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 11:35:09 +08:00
co-authored by Claude Opus 5.5
parent 55ebdd7a95
commit a81b32349f
10 changed files with 198 additions and 19 deletions
+6
View File
@@ -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%):生时校正界面和回答排版组件等到用的时候才下载。
+15
View File
@@ -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`。
+6
View File
@@ -29,3 +29,9 @@
14. 侧栏点一条以前的长对话(或直接打开带 `?c=` 的对话链接):一出现就是排好版的样子,不先闪一下纯文本。
15. 从首页进入生时校正(说「帮我做生时纠正」,或从星盘页进入):校正界面一次出现,题目和进度都在,没有空白、没有转圈。
## #125 首屏去掉三个小依赖(BUG-1475)
16. 账户菜单点「退出」:照常退回登录页。
17. 侧栏某条对话「…」→ 转发(分享):提示「已复制当前聊天」,粘贴出来有对话、Markdown 报告和 JSON 分享包三段。手机 Safari 也试一次。
18. 建档或改资料时选出生日期:选得上、存得住;手动输入 2 月 30 日这类不存在的日期不被接受。
-1
View File
@@ -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,
@@ -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");
+2 -1
View File
@@ -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();
@@ -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<ReportMarkdown> {
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(),
+12 -9
View File
@@ -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;
@@ -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<Blob> }>) => {
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<string, Promise<Blob>>) {}
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<typeof useShellSessionActions> | null = null;
function Probe() {
const value = useShellSessionActions(list, null);
useEffect(() => { actions = value; });
return null;
}
try {
await harness.render(<Probe />);
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": "<time>"');
assert.equal(stamp(cold.writes[0]), stamp(warm.writes[0]));
assert.match(warm.writes[0], /---- Markdown 报告 ----/);
assert.match(warm.writes[0], /share_payload_version/);
// Loaded: the plain writeText inside the click, as before #125.
assert.equal(warm.blobWrites, 0);
});
+6 -1
View File
@@ -313,7 +313,12 @@ test("legacy consultation Markdown export is untouched and still works", () => {
assert.doesNotMatch(markdown, /technique_truth/);
assert.doesNotMatch(markdown, /MEVG/);
assert.equal(typeof downloadMarkdownReport, "function");
assert.match(pageSource, /consultation-report-export/);
// 原值:page.tsx 里有 consultation-report-export 的 import
// 新值:page.tsx 不再引用它(那是一个没用到的 import);分享在外壳里按需加载它
// 原因:issue #125 首屏去掉只在点「分享」时才用的报告转换
const shellActions = readFileSync(new URL("../src/hooks/use-shell-session-actions.ts", import.meta.url), "utf8");
assert.match(shellActions, /import\("@\/lib\/consultation-report-export"\)/);
assert.doesNotMatch(pageSource, /consultation-report-export/);
assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/);
});