From 9a616107c2d928e7db194b9960c8586b372a7c1b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 30 Sep 2026 13:30:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=E6=88=91=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=20enters=20on=20a=20static=20shell=20and=20joins=20th?= =?UTF-8?q?e=20prefetch;=20=E8=BF=87=E5=BE=80=E7=9A=84=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=20pages=2010=20at=20a=20time=20with=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=20(BUG-1123,=20BUG-1124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 27 +++ frontend/DESIGN.md | 1 + frontend/src/app/(app)/reports/page.tsx | 2 - frontend/src/app/api/reports/route.ts | 29 ++- frontend/src/app/globals.css | 6 + .../personal-report-center.tsx | 97 ++++++++-- frontend/src/lib/report-cursor.ts | 96 ++++++++++ frontend/src/lib/secondary-page-data.ts | 43 ++++- .../tests/people-archive-regressions.test.ts | 2 +- .../report-list-paging-20260930.test.tsx | 177 ++++++++++++++++++ .../tests/report-polling-contract.test.ts | 5 +- frontend/tests/report-row-delete.test.tsx | 7 +- frontend/tests/stale-client-recovery.test.ts | 6 +- 14 files changed, 470 insertions(+), 33 deletions(-) create mode 100644 frontend/src/lib/report-cursor.ts create mode 100644 frontend/tests/report-list-paging-20260930.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 89502910..8d548832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-30 — 「我的报告」进得更快,过往报告可以一直往前翻 + +- 点「我的报告」不再先等一轮服务器渲染;侧栏悬停时已开始读的列表会直接用上,不再重读一遍。之前读过的列表进页即显示,后台再刷新。 +- 「过往的报告」每次显示 10 份,底部「加载更多」再往前 10 份,直到没有更早的;之前固定只显示最新 20 份,更早的看不到。生成中自动刷新不会把已经展开的列表收回去。Skill 版本不 bump。 + ## 2026-09-30 — 星盘符号气泡不再闪;星盘表格恢复圆角 - 鼠标停在星盘里的行星、上升或星座编号上,说明气泡会稳定显示;之前鼠标每动一下气泡就消失再出现。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5360568b..cb349a0c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -14963,3 +14963,30 @@ - 相关记录:BUG-1100(气泡引入) - 修复版本:分支 `codex/notice-loading-polish-20260930` +## BUG-1123 | 点「我的报告」要等很久才进页 + +- 状态:resolved(代码 + 回归测试;待部署,真机体感待产品复核) +- 首次发现 / 最近更新:2026-09-30 / 2026-09-30 +- 影响面:`frontend/src/app/(app)/reports/page.tsx`、`frontend/src/components/personal-report/personal-report-center.tsx`、`frontend/src/lib/secondary-page-data.ts` +- 现象:产品反馈「点击我的报告 跳转页面的时间很长」。 +- 触发条件:从任一页面点侧栏「我的报告」。 +- 根因:三段串行等待叠加,且用来掩盖等待的预取被丢弃。① 页面 `force-dynamic` 且无 `loading.tsx`,客户端跳转必须先等一轮服务端 RSC 渲染才切页(`/reports` 并不读 cookies,是纯客户端外壳;BUG-716 已把 `/chart` 改成静态外壳,`/reports` 当时没跟上,还被 `stale-client-recovery.test.ts` 钉住)。② 进页挂载时 `invalidateReportsPage()` 重置缓存,侧栏悬停 / 按下时已发出的 `GET /api/reports` 结果被作废,再发第二次同样的请求。③ 列表接口本身(登录校验 → 列表 → 失败段落)排在后面。 +- 修复:去掉 `force-dynamic`,`/reports` 成为静态外壳;进页不再作废缓存:有缓存直接显示并后台重读,无缓存则加入正在进行的预取请求(同一 inflight)。删除行时仍作废缓存,旧 GET 不能让已删行复活。 +- 验证:`report-list-paging-20260930.test.tsx`「entering 我的报告 joins the sidebar prefetch: one GET, not two」——预取后挂载只发 1 次 GET(修前 2 次);`report-row-delete.test.tsx` 删除后旧 GET 不复活仍通过;`stale-client-recovery.test.ts` 断言改为不再 force-dynamic(三栏注释)。未在真实 staging 计时,体感待产品复核。 +- 防复发:次级页(chart / ephemeris / people / reports)一律静态外壳 + 客户端取数;进页 effect 不得 reset 共享缓存,只能 join 或后台重读。 +- 相关记录:BUG-716、BUG-1104 +- 修复版本:分支 `codex/reports-list-paging-20260930` + +## BUG-1124 | 「过往的报告」只显示最新 20 份,更早的无法访问 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-09-30 / 2026-09-30 +- 影响面:`frontend/src/app/api/reports/route.ts`(GET)、`frontend/src/lib/report-cursor.ts`(新)、`personal-report-center.tsx` +- 现象:产品反馈「过往报告列表应该加一个分页」;核查发现接口写死 `.limit(20)` 且无游标,第 21 份起在列表里永远看不到。 +- 根因:列表接口初版只按 `created_at desc` 取前 20 行,没有分页参数;前端一次性渲染全部返回行。 +- 修复:`GET /api/reports?subject=&limit=10&before=` 键集分页,按 `(created_at desc, id desc)` 排序、取 `limit + 1` 判断是否还有更多,返回 `{ reports, nextCursor }`;非法游标 400。不带 `limit` 视为首页 10 份(原为 20)。前端 10 份一批 +「加载更多」(产品选定);3 秒轮询与刷新只重读首页并合并到已展开的列表,不丢已加载的更早页;删除本地移除;换人物重置。`.or(created_at.lt…,and(…))` 与双 `.order` 的写法和 `/api/sessions` 游标同一套,自托管兼容层已在该接口上线使用。 +- 验证:`report-list-paging-20260930.test.tsx` 7 条(游标编解码与 Date 行、接口源码合同、合并规则、客户端 URL 与 before、加载更多追加 / 加载中 / 到底隐藏、静态外壳与样式);前端全量 4,478 条,失败 24 条与基线逐条相同,无测试名消失。未跑 `test:db`(本轮不动表;游标查询写法与已上线的会话分页相同)。 +- 防复发:列表接口不得用写死的上限代替分页;新列表按 `session-cursor` / `report-cursor` 的键集模式。 +- 相关记录:BUG-1123 +- 修复版本:分支 `codex/reports-list-paging-20260930` + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a166c96f..a2f3ceae 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -585,6 +585,7 @@ The chart page, the ephemeris and the report list wait with one motion: the home - **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits alone in the 46px header. The body opens with the **generate card** (`.report-center-create`, 2026-09-29, product sketch): a centred `--color-canvas` sheet, `--space-6` below the header (BUG-1103: it used to sit flush under it), with a file icon, the title 「完整本命报告」 (what you get), one line 「星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。」 and the filled 「生成报告」 button (what it does) — title and button no longer say the same thing — the page's only generate entry; the header button was deleted rather than kept as a second one. Below it, 「过往的报告」 heads the **row list** of reports; with none yet, a single quiet line 「还没有个人报告。」 replaces the old second big empty card. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title. It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link. - **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12. +- **Paging (2026-09-30, BUG-1124):** the list shows the newest 10; under it an outline 「加载更多」 button (centred, at least 160px; full width below 767px) brings the next 10. While that page loads the button reads 「加载中」 and is disabled — no spinner. It disappears when nothing older exists. A failed page is a toast 「更早的报告没读出来,再试一次。」, the list stays. The 3s poll while a report is generating and the 刷新 button re-read the first page only and merge it over the pages already opened, so a refresh never collapses the list back to 10. Entering 我的报告 shows the cached first page at once and revalidates behind it (BUG-1123); a cold entry keeps the one shared waiting state. - **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut. - **Status chip:** a caption pill with a 5px `currentColor` dot and a text label. Ready uses `--color-success`, generating `--color-warning`, failed `--color-danger`; each background mixes the same token at 12%. No spinner. Ready rows use `--report-paper`; all other rows use `--color-canvas`. - **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line. diff --git a/frontend/src/app/(app)/reports/page.tsx b/frontend/src/app/(app)/reports/page.tsx index 1ed43238..42b1374b 100644 --- a/frontend/src/app/(app)/reports/page.tsx +++ b/frontend/src/app/(app)/reports/page.tsx @@ -2,8 +2,6 @@ import type { Metadata } from "next"; import { PersonalReportCenter } from "@/components/personal-report/personal-report-center"; -export const dynamic = "force-dynamic"; - export const metadata: Metadata = { title: "个人报告中心 · Jyotisha", robots: { index: false, follow: false }, diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 7c8e8acb..3ca2e205 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -44,6 +44,7 @@ import { subjectIdFromQuery, } from "@/lib/subject-birth"; import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; +import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor"; export const runtime = "nodejs"; export const maxDuration = 120; @@ -107,7 +108,16 @@ export async function GET(request: Request) { if (authError || !user) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } - const subjectId = subjectIdFromQuery(new URL(request.url).searchParams.get("subject")); + const url = new URL(request.url); + // Keyset pages of 10 (BUG-1124). No `limit` is the first page, so an old + // client that never sends one gets the newest 10 (was a fixed 20). + const limit = clampReportLimit(url.searchParams.get("limit")); + const beforeRaw = url.searchParams.get("before"); + const cursor = parseReportCursor(beforeRaw); + if (beforeRaw && !cursor) { + return NextResponse.json({ error: "报告列表请求无效" }, { status: 400 }); + } + const subjectId = subjectIdFromQuery(url.searchParams.get("subject")); if (subjectId !== "self" && !isChartSubjectId(subjectId)) { return NextResponse.json({ error: "星盘不存在", code: "subject_not_found" }, { status: 404 }); } @@ -118,11 +128,16 @@ export async function GET(request: Request) { listQuery = subjectId === "self" ? listQuery.is("chart_profile_id", null) : listQuery.eq("chart_profile_id", subjectId); - const { data, error } = await listQuery + listQuery = listQuery .order("created_at", { ascending: false }) - .limit(20); + .order("id", { ascending: false }) + .limit(limit + 1); + if (cursor) listQuery = listQuery.or(reportCursorFilter(cursor)); + const { data: pageRows, error } = await listQuery; if (error) throw error; - const reports = Array.isArray(data) ? data.map((row) => listReportView(row)).filter((report) => report.id) : []; + const nextCursor = nextReportCursor(Array.isArray(pageRows) ? pageRows : [], limit); + const data = Array.isArray(pageRows) ? pageRows.slice(0, limit) : []; + const reports = data.map((row) => listReportView(row)).filter((report) => report.id); const failed = reports.filter((report) => report.status === "failed" && report.requestId); if (failed.length > 0) { const { data: sections, error: sectionError } = await supabase @@ -144,9 +159,9 @@ export async function GET(request: Request) { } for (const [index, report] of reports.entries()) { if (report.status !== "failed") continue; - const source = Array.isArray(data) ? data.find((row) => ( + const source = data.find((row) => ( row && typeof row === "object" && (row as { id?: unknown }).id === report.id - )) : null; + )) ?? null; reports[index] = listReportView(source, summarizePersonalReportFailure({ themeCount: report.themes.length, sections: grouped.get(report.requestId) ?? [], @@ -154,7 +169,7 @@ export async function GET(request: Request) { })); } } - return NextResponse.json({ reports }); + return NextResponse.json({ reports, nextCursor }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json( diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 93f7c630..5ea5aac3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3870,6 +3870,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class /* Row list, not a card grid. A card grid costs one scan per card; past five or six reports the status is what the reader is looking for, and a single column of rows puts every status chip on the same x. D12. */ +/* 「加载更多」 under 过往的报告 (2026-09-30, BUG-1124): 10 at a time, an + outline button centred under the list, full width on phones. */ +.report-center-more { display: flex; margin: var(--space-4) auto 0; min-width: 160px; } +@media (max-width: 767px) { + .report-center-more { width: 100%; } +} .report-center-list { display: grid; gap: 1px; diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 2189c7df..9994a3c6 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -4,6 +4,7 @@ import { AppLink } from "@/components/app-link"; import { FileText, RefreshCw } from "lucide-react"; import { InlineSpinner } from "@/components/inline-spinner"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubjectReady } from "@/lib/current-subject"; @@ -13,7 +14,9 @@ import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; import { PERSONAL_REPORT_GENERATING_COPY } from "@/lib/personal-report-longform-copy"; import { REPORT_DEPTH_LABELS, reportThemeLabel } from "@/lib/personal-report-progress"; import { ReportRowDelete } from "./report-row-delete"; +import { appendReportPage, mergeFirstReportPage } from "@/lib/report-cursor"; import { + fetchMoreReports, invalidateReportsPage, peekReportsPage, refreshReportsPage, @@ -25,11 +28,19 @@ import { const LIST_POLL_INTERVAL_MS = 3000; -type CenterState = - | { phase: "loading"; reports: readonly ReportListItem[] } - | { phase: "ready"; reports: readonly ReportListItem[] } - | { phase: "unauthorized"; reports: readonly ReportListItem[] } - | { phase: "error"; reports: readonly ReportListItem[] }; +/** + * `nextCursor` is where 「加载更多」 continues; `extended` says the user has + * opened pages past the first, so a refreshed first page is merged over them + * instead of replacing the list (BUG-1124). + */ +type CenterState = { + phase: "loading" | "ready" | "unauthorized" | "error"; + reports: readonly ReportListItem[]; + nextCursor: string | null; + extended: boolean; +}; + +const EMPTY_LIST = { reports: [], nextCursor: null, extended: false } as const; const STATUS_COPY = { generating: { label: "生成中", description: PERSONAL_REPORT_GENERATING_COPY }, @@ -38,10 +49,25 @@ const STATUS_COPY = { } as const; function snapshotToState(snapshot: ReportsPageSnapshot | null): CenterState { - if (snapshot?.kind === "ready") return { phase: "ready", reports: snapshot.reports }; - if (snapshot?.kind === "unauthorized") return { phase: "unauthorized", reports: [] }; - if (snapshot?.kind === "error") return { phase: "error", reports: snapshot.reports }; - return { phase: "loading", reports: [] }; + if (snapshot?.kind === "ready") return { phase: "ready", reports: snapshot.reports, nextCursor: snapshot.nextCursor, extended: false }; + if (snapshot?.kind === "unauthorized") return { phase: "unauthorized", ...EMPTY_LIST }; + if (snapshot?.kind === "error") return { phase: "error", ...EMPTY_LIST, reports: snapshot.reports }; + return { phase: "loading", ...EMPTY_LIST }; +} + +/** + * A fresh first page applied to what is on screen. Before 「加载更多」 it simply + * replaces the list; after it, the older rows the user opened stay and the + * cursor keeps pointing past them. + */ +export function applyFirstReportPage( + current: Pick, + page: { reports: readonly ReportListItem[]; nextCursor: string | null }, +): Pick { + if (!current.extended || page.nextCursor === null) { + return { reports: page.reports, nextCursor: page.nextCursor, extended: false }; + } + return { reports: mergeFirstReportPage(page.reports, current.reports), nextCursor: current.nextCursor, extended: true }; } function formatDate(value: string): string { @@ -69,30 +95,32 @@ export function PersonalReportCenter() { const subjectReady = useCurrentSubjectReady(); const [seenSubject, setSeenSubject] = useState(subjectId); const [state, setState] = useState(() => snapshotToState(peekReportsPage())); + const [loadingMore, setLoadingMore] = useState(false); const deletedIds = useRef(new Set()); const cancelled = useRef(false); if (seenSubject !== subjectId) { setSeenSubject(subjectId); - setState({ phase: "loading", reports: [] }); + setState({ phase: "loading", ...EMPTY_LIST }); } const applySnapshot = useCallback((snapshot: ReportsPageSnapshot) => { + const kept = (reports: readonly ReportListItem[]) => reports.filter(report => !deletedIds.current.has(report.id)); if (snapshot.kind === "unauthorized") { - setState({ phase: "unauthorized", reports: [] }); + setState({ phase: "unauthorized", ...EMPTY_LIST }); return; } if (snapshot.kind === "error") { - setState({ phase: "error", reports: snapshot.reports.filter(report => !deletedIds.current.has(report.id)) }); + setState(current => ({ ...current, phase: "error", reports: current.reports.length > 0 ? current.reports : kept(snapshot.reports) })); return; } - setState({ phase: "ready", reports: snapshot.reports.filter(report => !deletedIds.current.has(report.id)) }); + setState(current => ({ phase: "ready", ...applyFirstReportPage(current, { reports: kept(snapshot.reports), nextCursor: snapshot.nextCursor }) })); }, []); const load = useCallback(async (showLoading = false) => { if (!subjectReady) return; const requestedSubject = subjectId; if (showLoading) { - setState((current) => ({ phase: "loading", reports: current.reports })); + setState((current) => ({ ...current, phase: "loading" })); } try { const snapshot = await refreshReportsPage(); @@ -108,11 +136,14 @@ export function PersonalReportCenter() { } }, [applySnapshot, subjectId, subjectReady]); + // Entering the page shows the cached first page at once and revalidates in + // the background. The sidebar's hover / pointerdown prefetch is the same + // in-flight request `load` joins; invalidating here threw it away and + // started a second GET (BUG-1123). useEffect(() => { if (!subjectReady) return; cancelled.current = false; - invalidateReportsPage(); - const timer = window.setTimeout(() => void load(true), 0); + const timer = window.setTimeout(() => void load(peekReportsPage() === null), 0); return () => { window.clearTimeout(timer); cancelled.current = true; @@ -127,6 +158,29 @@ export function PersonalReportCenter() { onPoll: refresh, }); + const loadMore = useCallback(async () => { + const cursor = state.nextCursor; + if (!cursor || loadingMore) return; + const requestedSubject = subjectId; + setLoadingMore(true); + try { + const page = await fetchMoreReports(cursor); + if (cancelled.current || requestedSubject !== readCurrentSubjectScope()) return; + if (page.kind === "unauthorized") { + setState({ phase: "unauthorized", ...EMPTY_LIST }); + } else if (page.kind === "ready") { + const fresh = page.reports.filter(report => !deletedIds.current.has(report.id)); + setState(current => ({ ...current, reports: appendReportPage(current.reports, fresh), nextCursor: page.nextCursor, extended: true })); + } else { + toast.error("更早的报告没读出来,再试一次。"); + } + } catch { + if (!cancelled.current) toast.error("更早的报告没读出来,再试一次。"); + } finally { + if (!cancelled.current) setLoadingMore(false); + } + }, [loadingMore, state.nextCursor, subjectId]); + const removeReport = useCallback((id: string) => { deletedIds.current.add(id); invalidateReportsPage(); @@ -221,6 +275,17 @@ export function PersonalReportCenter() { })} )} + {/* 10 at a time (2026-09-30, BUG-1124). No spinner: the label says it. */} + {state.nextCursor && state.reports.length > 0 ? ( + + ) : null} diff --git a/frontend/src/lib/report-cursor.ts b/frontend/src/lib/report-cursor.ts new file mode 100644 index 00000000..87f6db5a --- /dev/null +++ b/frontend/src/lib/report-cursor.ts @@ -0,0 +1,96 @@ +/** + * Keyset paging for 「过往的报告」 (2026-09-30, BUG-1124): newest first by + * (created_at desc, id desc), `limit + 1` rows to know whether more exist. + * Modelled on session-cursor.ts; reports page on created_at, not updated_at. + */ +const reportIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** One batch under 「加载更多」 (product: 10 at a time). Also the first page. */ +export const REPORT_PAGE_SIZE = 10; +export const REPORT_PAGE_LIMIT_MAX = 50; + +export type ReportCursor = { + readonly createdAt: string; + readonly id: string; +}; + +export function clampReportLimit(raw: string | null | undefined): number { + if (raw == null || raw === "") return REPORT_PAGE_SIZE; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return REPORT_PAGE_SIZE; + return Math.min(REPORT_PAGE_LIMIT_MAX, Math.max(1, Math.trunc(parsed))); +} + +export function encodeReportCursor(createdAt: string, id: string): string { + return `${createdAt},${id}`; +} + +export function parseReportCursor(raw: string | null | undefined): ReportCursor | null { + if (!raw) return null; + const comma = raw.lastIndexOf(","); + if (comma <= 0 || comma === raw.length - 1) return null; + const createdAt = raw.slice(0, comma); + const id = raw.slice(comma + 1); + if (!Number.isFinite(Date.parse(createdAt))) return null; + if (!reportIdPattern.test(id)) return null; + return { createdAt, id }; +} + +/** PostgREST `or=` filter: strictly older than the cursor row. */ +export function reportCursorFilter(cursor: ReportCursor): string { + const createdAt = JSON.stringify(cursor.createdAt); + const id = JSON.stringify(cursor.id); + return `created_at.lt.${createdAt},and(created_at.eq.${createdAt},id.lt.${id})`; +} + +function rawCreatedAt(value: unknown): string { + if (typeof value === "string") return Number.isFinite(Date.parse(value)) ? value : ""; + if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); + return ""; +} + +/** The cursor after the `limit`-th row when a `limit + 1`-th row came back, else null. */ +export function nextReportCursor( + rows: readonly unknown[], + limit: number, +): string | null { + if (rows.length <= limit) return null; + const last = rows[limit - 1]; + if (!last || typeof last !== "object") return null; + const row = last as { id?: unknown; created_at?: unknown }; + const createdAt = rawCreatedAt(row.created_at); + if (!createdAt || typeof row.id !== "string") return null; + return encodeReportCursor(createdAt, row.id); +} + +type Keyed = { readonly id: string; readonly createdAt: string }; + +/** Newest first, as the endpoint orders them. */ +function olderThan(left: Keyed, right: Keyed): boolean { + const l = Date.parse(left.createdAt); + const r = Date.parse(right.createdAt); + if (l !== r) return l < r; + return left.id < right.id; +} + +/** + * A fresh first page merged over the rows already on screen: the first page + * wins for every row it carries; rows the user loaded with 「加载更多」 that are + * older than the first page's last row stay. Used by the 3s poll and 刷新, so + * a refresh never throws away pages the user opened. + */ +export function mergeFirstReportPage( + firstPage: readonly T[], + loaded: readonly T[], +): T[] { + const tail = firstPage[firstPage.length - 1]; + if (!tail) return []; + const ids = new Set(firstPage.map((row) => row.id)); + return [...firstPage, ...loaded.filter((row) => !ids.has(row.id) && olderThan(row, tail))]; +} + +/** One more page appended after what is on screen; duplicates are dropped. */ +export function appendReportPage(loaded: readonly T[], page: readonly T[]): T[] { + const ids = new Set(loaded.map((row) => row.id)); + return [...loaded, ...page.filter((row) => !ids.has(row.id))]; +} diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index 8b440940..b487fb3d 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -4,6 +4,7 @@ import type { ChartViewResponse } from "@/lib/chart-view-contract"; import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; +import { REPORT_PAGE_SIZE } from "@/lib/report-cursor"; export const REPORTS_WAITING_COPY = "报告列表还没拿到。"; export const EPHEMERIS_WAITING_COPY = "这一天的星历还没拿到。"; @@ -30,8 +31,9 @@ export type ReportListItem = Readonly<{ cardSummary?: string | null; }>; +/** The cache holds the first page only; `nextCursor` asks for the next 10 (BUG-1124). */ export type ReportsPageSnapshot = - | { kind: "ready"; reports: readonly ReportListItem[] } + | { kind: "ready"; reports: readonly ReportListItem[]; nextCursor: string | null } | { kind: "unauthorized" } | { kind: "error"; reports: readonly ReportListItem[] }; @@ -313,10 +315,45 @@ export function readReports(value: unknown): ReportListItem[] { }); } +export function readReportsNextCursor(value: unknown): string | null { + if (!value || typeof value !== "object" || !("nextCursor" in value)) return null; + const cursor = (value as { nextCursor?: unknown }).nextCursor; + return typeof cursor === "string" && cursor ? cursor : null; +} + +function reportsUrl(subjectId: string, before: string | null): string { + const params = new URLSearchParams({ subject: subjectId, limit: String(REPORT_PAGE_SIZE) }); + if (before) params.set("before", before); + return `/api/reports?${params.toString()}`; +} + +export type ReportsMorePage = + | { kind: "ready"; reports: readonly ReportListItem[]; nextCursor: string | null } + | { kind: "unauthorized" } + | { kind: "error" }; + +/** + * 「加载更多」: the page after `before`. Never cached — only the first page is, + * and the list merges fresh first pages over what the user already opened. + */ +export async function fetchMoreReports(before: string): Promise { + const subjectId = readCurrentSubjectId(); + const response = await fetch(reportsUrl(subjectId, before), { + credentials: "same-origin", + headers: { Accept: "application/json" }, + cache: "no-store", + }); + const json: unknown = await response.json().catch(() => null); + if (readCurrentSubjectId() !== subjectId) throw new Error("reports_subject_changed"); + if (response.status === 401) return { kind: "unauthorized" }; + if (!response.ok) return { kind: "error" }; + return { kind: "ready", reports: readReports(json), nextCursor: readReportsNextCursor(json) }; +} + let reportsSubject = ""; const reportsCache = createMemoryCache(async () => { const subjectId = readCurrentSubjectId(); - const response = await fetch(`/api/reports?subject=${encodeURIComponent(subjectId)}`, { + const response = await fetch(reportsUrl(subjectId, null), { credentials: "same-origin", headers: { Accept: "application/json" }, cache: "no-store", @@ -332,7 +369,7 @@ const reportsCache = createMemoryCache(async () => { return { kind: "error", reports: [] }; } reportsSubject = subjectId; - return { kind: "ready", reports: readReports(json) }; + return { kind: "ready", reports: readReports(json), nextCursor: readReportsNextCursor(json) }; }); export function peekChartPage(): ChartPageSnapshot | null { diff --git a/frontend/tests/people-archive-regressions.test.ts b/frontend/tests/people-archive-regressions.test.ts index 9f111c4c..1833e284 100644 --- a/frontend/tests/people-archive-regressions.test.ts +++ b/frontend/tests/people-archive-regressions.test.ts @@ -117,7 +117,7 @@ test("same self subject in another account cannot reuse cache or publish a late resetSecondaryPageDataForTests(); resetSubjectCatalogForTests(); bindCurrentSubjectAccount("fictional-A", []); - writeReportsPage({ kind: "ready", reports: [] }); + writeReportsPage({ kind: "ready", reports: [], nextCursor: null }); assert.ok(peekReportsPage()); let finish!: (response: Response) => void; const original = globalThis.fetch; diff --git a/frontend/tests/report-list-paging-20260930.test.tsx b/frontend/tests/report-list-paging-20260930.test.tsx new file mode 100644 index 00000000..206401bc --- /dev/null +++ b/frontend/tests/report-list-paging-20260930.test.tsx @@ -0,0 +1,177 @@ +// 我的报告: keyset pages of 10 with 「加载更多」 (BUG-1124) and entering the page +// joins the sidebar's prefetch instead of discarding it (BUG-1123). +// Fictional list metadata only (AGENTS §8). +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { AppRouterContext, type AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import { SidebarProvider } from "../src/components/ui/sidebar"; +import { applyFirstReportPage, PersonalReportCenter } from "../src/components/personal-report/personal-report-center"; +import { + appendReportPage, + clampReportLimit, + encodeReportCursor, + mergeFirstReportPage, + nextReportCursor, + parseReportCursor, + REPORT_PAGE_SIZE, + reportCursorFilter, +} from "../src/lib/report-cursor"; +import { + fetchMoreReports, + invalidateReportsPage, + peekReportsPage, + prefetchSecondaryPage, + refreshReportsPage, + writeReportsPage, + type ReportListItem, +} from "../src/lib/secondary-page-data"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; + +Object.assign(globalThis, { React }); +type Harness = ReturnType; +const buttons = (h: Harness, text: string) => h.elements().filter(node => node.tagName === "BUTTON" && node.text === text); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } + +const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +/** Report n was created n days before 2026-09-30; higher n is older. */ +function report(n: number, extra: Partial = {}): ReportListItem { + const created = new Date(Date.UTC(2026, 8, 30) - n * 86_400_000).toISOString(); + return { id: uuid(n), reportType: "personal_full", presentationMode: "default", depth: "standard", themes: ["career"], status: "ready", failureCode: null, createdAt: created, completedAt: created, ...extra }; +} +const route = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8"); +const page = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const router = { bfcacheId: "test", push() {}, replace() {}, refresh() {}, prefetch() {}, back() {}, forward() {} } as AppRouterInstance; + +test("cursor helpers: 10 by default, clamped, (created_at, id) keyset, Date rows from self-hosted postgres", () => { + assert.equal(REPORT_PAGE_SIZE, 10); + assert.equal(clampReportLimit(null), 10); + assert.equal(clampReportLimit("abc"), 10); + assert.equal(clampReportLimit("0"), 1); + assert.equal(clampReportLimit("999"), 50); + const cursor = encodeReportCursor("2026-09-01T00:00:00.123456+00:00", uuid(3)); + assert.deepEqual(parseReportCursor(cursor), { createdAt: "2026-09-01T00:00:00.123456+00:00", id: uuid(3) }); + assert.equal(parseReportCursor("not-a-date," + uuid(3)), null); + assert.equal(parseReportCursor("2026-09-01T00:00:00Z,drop table"), null); + assert.equal( + reportCursorFilter({ createdAt: "2026-09-01T00:00:00Z", id: uuid(3) }), + `created_at.lt."2026-09-01T00:00:00Z",and(created_at.eq."2026-09-01T00:00:00Z",id.lt."${uuid(3)}")`, + ); + const rows = Array.from({ length: 11 }, (_, i) => ({ id: uuid(i), created_at: new Date(Date.UTC(2026, 8, 30 - i)) })); + assert.equal(nextReportCursor(rows.slice(0, 10), 10), null, "exactly one page: no more"); + assert.equal(nextReportCursor(rows, 10), encodeReportCursor(rows[9]!.created_at.toISOString(), uuid(9))); +}); + +test("GET /api/reports pages by (created_at desc, id desc), fetches limit + 1 and returns nextCursor", () => { + assert.match(route, /clampReportLimit\(url\.searchParams\.get\("limit"\)\)/); + assert.match(route, /parseReportCursor\(beforeRaw\)/); + assert.match(route, /if \(beforeRaw && !cursor\) \{\s*return NextResponse\.json\(\{ error: "报告列表请求无效" \}, \{ status: 400 \}\);/); + assert.match(route, /\.order\("created_at", \{ ascending: false \}\)\s*\.order\("id", \{ ascending: false \}\)\s*\.limit\(limit \+ 1\)/); + assert.match(route, /if \(cursor\) listQuery = listQuery\.or\(reportCursorFilter\(cursor\)\)/); + assert.match(route, /nextReportCursor\(Array\.isArray\(pageRows\) \? pageRows : \[\], limit\)/); + assert.match(route, /pageRows\.slice\(0, limit\)/); + assert.match(route, /return NextResponse\.json\(\{ reports, nextCursor \}\);/); + assert.doesNotMatch(route, /\.limit\(20\)/, "the fixed 20 made older reports unreachable (BUG-1124)"); +}); + +test("a fresh first page replaces the list until 加载更多 was used, then merges over the opened pages", () => { + const first = [1, 2, 3].map(n => report(n)); + const opened = [1, 2, 3, 4, 5, 6].map(n => report(n)); + assert.deepEqual(applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: false }, { reports: first, nextCursor: "c3" }), { reports: first, nextCursor: "c3", extended: false }); + // A new report on top pushes 3 below the first page; 3 stays, nothing is lost, the cursor still points past 6. + const polled = [0, 1, 2].map(n => report(n, n === 0 ? { status: "generating" } : {})); + const merged = applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: true }, { reports: polled, nextCursor: "c2" }); + assert.deepEqual(merged.reports.map(r => r.id), [0, 1, 2, 3, 4, 5, 6].map(uuid)); + assert.equal(merged.nextCursor, "c6"); + assert.equal(merged.extended, true); + // The first page's copy of a row wins (status changed while polling). + assert.equal(mergeFirstReportPage([report(1, { status: "failed" })], [report(1), report(2)])[0]!.status, "failed"); + // Everything now fits on the first page: the list is exactly that page. + assert.deepEqual(applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: true }, { reports: first, nextCursor: null }), { reports: first, nextCursor: null, extended: false }); + assert.deepEqual(appendReportPage([report(1), report(2)], [report(2), report(3)]).map(r => r.id), [1, 2, 3].map(uuid)); +}); + +test("the client asks for 10 at a time and 加载更多 sends the cursor", async () => { + const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts"); + bindCurrentSubjectAccount("fictional-report-owner", []); + const fetchBefore = globalThis.fetch; const urls: string[] = []; + globalThis.fetch = (async (url: RequestInfo | URL) => { urls.push(String(url)); return Response.json({ reports: [report(1)], nextCursor: "2026-09-29T00:00:00.000Z," + uuid(1) }); }) as typeof fetch; + try { + invalidateReportsPage(); + const first = await refreshReportsPage(); + assert.equal(first.kind, "ready"); + assert.equal(first.kind === "ready" ? first.nextCursor : "", "2026-09-29T00:00:00.000Z," + uuid(1)); + assert.deepEqual(first.kind === "ready" ? first.reports.map(r => r.id) : [], [uuid(1)]); + const more = await fetchMoreReports("2026-09-29T00:00:00.000Z," + uuid(1)); + assert.equal(more.kind, "ready"); + assert.match(urls[0]!, /^\/api\/reports\?subject=self&limit=10$/); + assert.equal(new URL(urls[1]!, "http://x").searchParams.get("before"), "2026-09-29T00:00:00.000Z," + uuid(1)); + assert.equal(new URL(urls[1]!, "http://x").searchParams.get("limit"), "10"); + } finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); } +}); + +test("entering 我的报告 joins the sidebar prefetch: one GET, not two (BUG-1123)", async () => { + const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts"); + bindCurrentSubjectAccount("fictional-report-owner", []); + const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch; + const response = deferred(); let gets = 0; + globalThis.fetch = (async (url: RequestInfo | URL) => { + if (String(url).startsWith("/api/reports")) { gets += 1; return response.promise; } + return Response.json({}, { status: 401 }); + }) as typeof fetch; + try { + invalidateReportsPage(); + prefetchSecondaryPage("/reports"); + assert.equal(gets, 1); + await h.render(); + await h.idle(); + response.resolve(Response.json({ reports: [report(1)], nextCursor: null })); + await h.idle(); + assert.equal(gets, 1, "the in-flight prefetch is reused"); + assert.equal(peekReportsPage()?.kind, "ready"); + assert.match(h.container.text, /标准深度/); + assert.deepEqual(h.errors, []); + } finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); } +}); + +test("加载更多 appends the next 10, says 加载中 while waiting and disappears at the end", async () => { + const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts"); + bindCurrentSubjectAccount("fictional-report-owner", []); + const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch; + const firstPage = Array.from({ length: 10 }, (_, i) => report(i + 1)); + const secondPage = [report(11), report(12)]; + const more = deferred(); const befores: (string | null)[] = []; + writeReportsPage({ kind: "ready", reports: firstPage, nextCursor: "c10" }); + globalThis.fetch = (async (url: RequestInfo | URL) => { + const u = new URL(String(url), "http://x"); + if (u.pathname === "/api/reports") { + befores.push(u.searchParams.get("before")); + if (u.searchParams.get("before")) return more.promise; + return Response.json({ reports: firstPage, nextCursor: "c10" }); + } + return Response.json({}, { status: 401 }); + }) as typeof fetch; + try { + await h.render(); + await h.idle(); + assert.equal(h.elements().filter(n => n.tagName === "ARTICLE").length, 10); + const [button] = buttons(h, "加载更多"); + assert.ok(button, "加载更多 is under the list while more exist"); + await h.event(button); + assert.equal(buttons(h, "加载中").length, 1); + more.resolve(Response.json({ reports: secondPage, nextCursor: null })); + await h.idle(); + assert.deepEqual(befores, [null, "c10"]); + assert.equal(h.elements().filter(n => n.tagName === "ARTICLE").length, 12); + assert.equal(buttons(h, "加载更多").length, 0, "hidden when there is nothing older"); + assert.deepEqual(h.errors, []); + } finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); } +}); + +test("/reports is a static shell and the button has a rule", () => { + assert.doesNotMatch(page, /force-dynamic/); + assert.match(css, /\.report-center-more \{[^}]*margin: var\(--space-4\) auto 0/); + assert.match(css, /@media \(max-width: 767px\) \{\s*\.report-center-more \{ width: 100%; \}/); +}); diff --git a/frontend/tests/report-polling-contract.test.ts b/frontend/tests/report-polling-contract.test.ts index 92afa260..8fe7b87c 100644 --- a/frontend/tests/report-polling-contract.test.ts +++ b/frontend/tests/report-polling-contract.test.ts @@ -146,7 +146,10 @@ test("cancellation guards survive the rewrite and no poll runs outside the gener assert.match(centerSource, /const cancelled = useRef\(false\)/); // 原值:if (cancelled.current) return;新值:取消或账户/人物范围已变均 return。 // 原因:D3 加强了旧请求防线;成功和异常两条分支都必须保留取消检查,不接受只校验人物。 - assert.equal((centerSource.match(/if \(cancelled\.current \|\| requestedSubject !== readCurrentSubjectScope\(\)\) return;/g) ?? []).length, 2); + // 原值: 该守卫出现 2 次(load 的成功与异常分支) + // 新值: 3 次——「加载更多」的结果回来时同样要先查取消与人物范围 + // 原因: BUG-1124 报告列表分页新增 loadMore 请求;load 的两处保留不变 + assert.equal((centerSource.match(/if \(cancelled\.current \|\| requestedSubject !== readCurrentSubjectScope\(\)\) return;/g) ?? []).length, 3); assert.match(centerSource, /cancelled\.current = true;/); assert.match(pageSource, /const generating = state\.phase === "generating";/); }); diff --git a/frontend/tests/report-row-delete.test.tsx b/frontend/tests/report-row-delete.test.tsx index 16b6e6c6..883972f6 100644 --- a/frontend/tests/report-row-delete.test.tsx +++ b/frontend/tests/report-row-delete.test.tsx @@ -70,7 +70,10 @@ test("report cache invalidation rejects stale GET writes and stale finally keeps assert.equal(peekReportsPage(), null); assert.equal(refreshReportsPage(), fresh); assert.equal(calls, 2); second.resolve(Response.json({ reports: [] })); await fresh; - assert.deepEqual(peekReportsPage(), { kind: "ready", reports: [] }); + // 原值: { kind: "ready", reports: [] } + // 新值: { kind: "ready", reports: [], nextCursor: null } + // 原因: 报告列表按 10 份分页(BUG-1124),快照带上「加载更多」的游标;无更多时为 null + assert.deepEqual(peekReportsPage(), { kind: "ready", reports: [], nextCursor: null }); } finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); } }); @@ -79,7 +82,7 @@ test("mounted report center removes deleted row and an older GET cannot resurrec bindCurrentSubjectAccount("fictional-report-owner", []); const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch; const stale = deferred(); let gets = 0; - writeReportsPage({ kind: "ready", reports: [row] }); + writeReportsPage({ kind: "ready", reports: [row], nextCursor: null }); globalThis.fetch = async (url, options) => { if (options?.method === "DELETE") return new Response(null, { status: 204 }); if (String(url).startsWith("/api/reports")) { gets += 1; return stale.promise; } diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index 2111a2cc..ea5e4663 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -79,7 +79,11 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t const reportsPage = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8"); const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); assert.match(loginPage, /export const dynamic = "force-dynamic"/); - assert.match(reportsPage, /export const dynamic = "force-dynamic"/); + // 原值: assert.match(reportsPage, /export const dynamic = "force-dynamic"/) + // 新值: 断言不再 force-dynamic + // 原因: BUG-1123——/reports 是不读 cookies 的客户端外壳,force-dynamic 让每次点「我的报告」 + // 先等一轮服务端渲染;与 BUG-716 的 /chart 同样改为静态外壳,数据由客户端取。 + assert.doesNotMatch(reportsPage, /export const dynamic = "force-dynamic"/); assert.match(adminLayout, /export const dynamic = "force-dynamic"/); assert.match(layoutSource, /StaleClientRecovery/); });