From 142595f8103a1d969f2a0a94aa89947c156ca6dc Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 28 Oct 2026 06:18:05 +0800 Subject: [PATCH] Chart and people pages no longer load the city table up front (issue #120, BUG-1469) The birth editor on /chart now loads when the pen row is clicked (warmed on hover/focus), the people page's add/edit form is fetched after mount, and the people city column reads the on-demand table. /chart first load -116 KB gzip, /people -111 KB; home +0.19%, still static. A static import graph test keeps the table out of both pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoz1Qk3y9buDv1mkmTu --- CHANGELOG.md | 6 ++ docs/BUG_HISTORY.md | 16 ++++ docs/testing/code-health-118-20261028.md | 10 +++ frontend/DESIGN.md | 2 +- .../components/chart-page/chart-page-view.tsx | 12 ++- .../src/components/people/people-page.tsx | 10 ++- frontend/src/lib/people-archive-view.ts | 10 ++- .../secondary-first-load-20261028.test.ts | 89 +++++++++++++++++++ 8 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 docs/testing/code-health-118-20261028.md create mode 100644 frontend/tests/secondary-first-load-20261028.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d31503..232767d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 星盘页、星盘档案页打开更快 + +- 星盘页第一次打开少下载约 116 KB:「改出生资料」弹窗等到点了那一行才加载(鼠标移上去就开始预取)。 +- 星盘档案页第一次打开少下载约 111 KB:「添加 / 编辑人物」的表单在页面出来后再在后台取,城市一列改读按需加载的城市表。 +- 页面内容和操作都不变。Skill 版本不变(BUG-1469,issue #120)。 + ## 2026-10-28 — 每个页面的侧栏会话都能「…」和拖动 - 在星盘、星历、我的报告、星盘档案页,侧栏的会话也有「…」菜单(收藏、重命名、转发、删除),也能拖动排序,跟首页一样。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 71228daa..a1f3381c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20033,3 +20033,19 @@ - 相关记录:BUG-1042~1047(该测试来源)、BUG-1467。 - 复发自:无 - 修复版本:分支 `codex/session-shell-116-20261027`。 + +## BUG-1469 | 星盘页、星盘档案页首次加载静态带着全国城市表(BUG-1130 在次级页复发) + +- 状态:resolved(代码 + 回归测试;待部署,真机待产品) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检 → #120) +- 影响面:`/chart`、`/people` 首次加载;`frontend/src/components/chart-page/chart-page-view.tsx`、`frontend/src/components/people/people-page.tsx`、`frontend/src/lib/people-archive-view.ts`。 +- 现象:星盘页首次加载约 704 KB(gzip,本地构建),其中约 108 KB 是只有点「改出生资料」才用得到的弹窗、出生地选择器、全国城市表和日历;档案页约 663 KB,同样带着城市表。 +- 触发条件:冷开 `/chart` 或 `/people`。 +- 根因:星盘页静态引入 `ChartBirthEditor`(→ `chart-profile-form` → `profile-fields` → `birth-location-fields` → `birth-place-picker` → `china-birth-place` → `data/china-locations`);档案页静态引入 `ChartProfileForm`,城市一列的 `peopleCityLabel` 又直接用 `china-birth-place` 查表。BUG-1130 只把首页首屏里的城市表挪走,并有意让档案页继续静态带表;防复发只覆盖首页首屏,所以次级页的静态路径没人拦。 +- 修复:星盘页的编辑弹窗改为点击时加载(`next/dynamic`,无占位),鼠标移上或聚焦那一行时预取;档案页的添加 / 编辑表单改为页面挂载后在后台取;`peopleCityLabel` 改读按需加载的表(`home-profile` 的 `findProvince` / `findCity`):只有编码没有已存地点的老资料,冷启动在人物列表落定前已把表载入(`current-subject.ts` `loadPlacesFor`),其余资料用已存地点名里的城市段,名字相同。 +- 验证:本地 `next build`(gzip-9,含 noModule 补丁,前后同口径):`/chart` 703,510 → 587,183 B(−116 KB),`/people` 662,898 → 551,646 B(−111 KB),首页 702,985 → 704,289 B(+0.19%,仍 `○ Static`)。新测试 `frontend/tests/secondary-first-load-20261028.test.ts`:从两个页面沿静态 import 图走,走不到城市表(在改动前的代码上失败并打印完整引用链);城市列在表未载入时用已存地点名、载入后与查表结果一致。 +- 防复发:上面的静态 import 图测试覆盖两个次级页;只在少数操作里用到的大模块(编辑弹窗、地点选择、城市表)不进任何页面的首次加载,首页之外的页面同样适用。 +- 相关记录:BUG-1130(首页首屏版本)、BUG-1127~1129。 +- 复发自:BUG-1130(防复发只覆盖首页首屏) +- 修复版本:分支 `codex/chart-lazy-editor-20261028`。 + diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md new file mode 100644 index 00000000..8116371e --- /dev/null +++ b/docs/testing/code-health-118-20261028.md @@ -0,0 +1,10 @@ +# 代码体检小单真机清单(issue #118,#120~#139) + +每张小单部署到 staging 后在这里加几条。需要 staging 测试账号。 + +## #120 星盘页、档案页打开更快(BUG-1469) + +1. 电脑打开星盘页:盘、表、右栏照常出来。点盘上方那行「日期 时间 · 地点」:弹出编辑框,能改能存,存完盘按新资料重排。 +2. 手机打开星盘页,点那一行:同样能弹出编辑框(可能比以前慢一点点,但不出现转圈)。 +3. 打开星盘档案页:每个人那一行的「日期 · 时间 · 城市」和以前一样;老资料(只选过省市、很早建的)城市也照常显示。 +4. 档案页点「添加一个人」、点某人的「编辑」:表单照常弹出,出生地能选、能存。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 818143cf..9f33902d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -412,7 +412,7 @@ The birth-time rectification session is the consultation transcript plus a house - Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends. - Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner. - Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line. -- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it. +- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it. The editor's code is not in `/chart`'s first load: it loads on the click and is warmed when the pen row is hovered or focused, and nothing is drawn while it arrives (no placeholder; BUG-1469). The people page's add / edit form likewise stays out of `/people`'s first load and is fetched right after the page mounts. - With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px. - On viewports ≤767px the header row is 52px plus the safe-area inset. The credit control (`.credit-balance`) is a normal outline button, 44px, with a coins icon and the number; 点 stays in the accessible name. The rectification 「当前盘面」 chip stays 40px. The shared `::before` hit area stays inset -2px. The onboarding checklist trigger stays 32px. - Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen. diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index b29d48fb..27f30e8e 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Pencil } from "lucide-react"; @@ -21,7 +22,6 @@ import { ChartTags, } from "@/components/chart-board/chart-side-cards"; import { ChartTypeTable } from "@/components/chart-board/chart-type-table"; -import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover"; import { useChartBoard } from "@/hooks/use-chart-board"; @@ -47,6 +47,14 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { ChartPageSkeleton } from "./chart-page-skeleton"; import { ChartWesternTab } from "./chart-western-tab"; +// The pencil is the only way into the birth editor, so its code (profile form, +// place picker with the China city table, calendar) loads on that click and is +// warmed when the pencil is hovered or focused. It used to ride in /chart's +// first load (BUG-1469, the secondary-page return of BUG-1130). +const loadChartBirthEditor = () => import("@/components/chart-page/chart-birth-editor") + .then((module) => module.ChartBirthEditor); +const ChartBirthEditor = dynamic(loadChartBirthEditor, { ssr: false, loading: () => null }); + export function ChartPageRoute() { const { view, pendingLayers, layerFailures, requestLayer, reload } = useChartPage(); const ok = view?.status === "ok" ? view : null; @@ -277,6 +285,8 @@ export function ChartPageView({ variant="ghost" className="chart-birth-line h-auto rounded-none border-0 bg-transparent px-0 font-normal shadow-none" aria-label={`编辑出生资料,${view.profile.date} ${view.profile.time},${view.profile.placeLabel}`} + onPointerEnter={() => { void loadChartBirthEditor(); }} + onFocus={() => { void loadChartBirthEditor(); }} onClick={() => setEditingBirth(true)} > {`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`} diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index d7d55159..4132d124 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -2,10 +2,10 @@ import { useEffect, useRef, useState, type Dispatch, type FormEvent, type ReactNode, type SetStateAction } from "react"; import { ChevronLeft, ChevronRight, FileText, MessageSquareText, MoreHorizontal, Orbit, Plus } from "lucide-react"; +import dynamic from "next/dynamic"; import { newChatHref } from "@/lib/chat-session-url"; import { effectiveBirthDate } from "@/lib/effective-birth-date"; -import { ChartProfileForm } from "@/components/chart-profile-form"; import { personRelationLabel } from "@/lib/person-relation"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; @@ -60,6 +60,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +// The add / edit form carries the place picker and with it the China city +// table. It is not needed to read the archive, so it stays out of /people's +// first load and is fetched right after the page mounts (BUG-1469). +const loadChartProfileForm = () => import("@/components/chart-profile-form").then((module) => module.ChartProfileForm); +const ChartProfileForm = dynamic(loadChartProfileForm, { ssr: false, loading: () => null }); + export type PersonRow = { readonly id: string; readonly role: "self" | "other"; @@ -119,6 +125,8 @@ export function PeoplePage() { const accountIdRef = useRef(""); const { setAccount } = useSessionList(); + useEffect(() => { void loadChartProfileForm(); }, []); + async function load() { setStatus("loading"); try { diff --git a/frontend/src/lib/people-archive-view.ts b/frontend/src/lib/people-archive-view.ts index 59c86afd..c0dbced8 100644 --- a/frontend/src/lib/people-archive-view.ts +++ b/frontend/src/lib/people-archive-view.ts @@ -5,8 +5,7 @@ */ import { AYANAMSA_OPTIONS, resolveAyanamsa } from "@/lib/ayanamsa"; import { effectiveBirthDate } from "@/lib/effective-birth-date"; -import { findCityNode, findProvinceNode } from "@/lib/china-birth-place"; -import { profileBirthTimeLabel } from "@/lib/home-profile"; +import { findCity, findProvince, profileBirthTimeLabel } from "@/lib/home-profile"; import type { Profile } from "@/lib/home-types"; /** D4: four existing element tokens; the person's id picks one, stably. */ @@ -40,8 +39,11 @@ function trimCitySuffix(name: string): string { * of a 「中国 · 省 · 市 · 区」 label. Empty when the place is not complete. */ export function peopleCityLabel(profile: Profile): string { - // The people page carries the table itself (china-birth-place imports it statically). - const city = findCityNode(findProvinceNode(profile.provinceCode), profile.cityCode); + // Reads the on-demand table (BUG-1469). A profile that has codes but no saved + // label + coordinates has the table loaded before the people list settles + // (current-subject.ts loadPlacesFor); every other profile carries its label, + // whose city segment is the same name. + const city = findCity(findProvince(profile.provinceCode), profile.cityCode); if (city) return trimCitySuffix(city.name); const label = profile.birthPlaceLabel.trim(); if (!label) return ""; diff --git a/frontend/tests/secondary-first-load-20261028.test.ts b/frontend/tests/secondary-first-load-20261028.test.ts new file mode 100644 index 00000000..9d742333 --- /dev/null +++ b/frontend/tests/secondary-first-load-20261028.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { emptyProfile } from "../src/lib/home-types.ts"; +import { ensureChinaLocations, resetChinaLocationsForTests } from "../src/lib/china-locations-client.ts"; +import { peopleCityLabel } from "../src/lib/people-archive-view.ts"; + +// BUG-1469 (issue #120): BUG-1130 took the China city table out of the home +// first screen, but /chart still loaded it statically through the birth +// editor and /people through the add / edit form and the city column. +// These tests walk the static import graph, so a new static path back to the +// table fails here, whichever file it goes through. + +const src = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src"); +const extensions = [".ts", ".tsx", ".json", "/index.ts", "/index.tsx"]; + +function resolveImport(from: string, specifier: string): string | null { + let base: string; + if (specifier.startsWith("@/")) base = path.join(src, specifier.slice(2)); + else if (specifier.startsWith(".")) base = path.resolve(path.dirname(from), specifier); + else return null; + if (existsSync(base) && statSync(base).isFile()) return base; + for (const extension of extensions) { + if (existsSync(base + extension)) return base + extension; + } + return null; +} + +/** Value imports and re-exports only: `import type`, `export type` and `import()` do not load code. */ +function staticImports(file: string): string[] { + if (file.endsWith(".json")) return []; + const text = readFileSync(file, "utf8"); + const found: string[] = []; + const pattern = /^\s*(?:import|export)\s+(?!type\b)(?:[^;'"]*?\sfrom\s+)?["']([^"']+)["']/gm; + for (const match of text.matchAll(pattern)) { + const resolved = resolveImport(file, match[1]); + if (resolved) found.push(resolved); + } + return found; +} + +function staticallyReaches(entry: string, target: RegExp): string[] | null { + const seen = new Map([[entry, null]]); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.shift()!; + if (target.test(file)) { + const chain: string[] = []; + for (let at: string | null = file; at; at = seen.get(at) ?? null) chain.unshift(path.relative(src, at)); + return chain; + } + for (const next of staticImports(file)) { + if (seen.has(next)) continue; + seen.set(next, file); + queue.push(next); + } + } + return null; +} + +const cityTable = /[\\/]data[\\/]china-locations(\.json|\.ts)?$/; + +for (const entry of ["components/chart-page/chart-page-view.tsx", "components/people/people-page.tsx"]) { + test(`${entry} does not load the China city table statically (BUG-1469)`, () => { + const chain = staticallyReaches(path.join(src, entry), cityTable); + assert.equal(chain, null, chain ? `static path: ${chain.join(" → ")}` : ""); + }); +} + +test("the walker does see the table where it is still static, so the check above is not vacuous", () => { + assert.ok(staticallyReaches(path.join(src, "components/birth-place-picker.tsx"), cityTable)); +}); + +test("the people city column reads the on-demand table and falls back to the saved label (BUG-1469)", async () => { + resetChinaLocationsForTests(); + // Codes plus the label the picker saves: the label's city segment, same name as the table's. + const saved = { ...emptyProfile, provinceCode: "130000", cityCode: "130100", birthPlaceLabel: "中国 · 河北省 · 石家庄市 · 长安区" }; + assert.equal(peopleCityLabel(saved), "石家庄"); + // Codes only (an old profile): blank until the table is loaded, which the cold boot does before the list settles. + const codesOnly = { ...emptyProfile, provinceCode: "130000", cityCode: "130100" }; + assert.equal(peopleCityLabel(codesOnly), ""); + await ensureChinaLocations(); + assert.equal(peopleCityLabel(codesOnly), "石家庄"); + assert.equal(peopleCityLabel(saved), "石家庄"); + resetChinaLocationsForTests(); +});