feat(reports): 我的报告 enters on a static shell and joins the prefetch; 过往的报告 pages 10 at a time with 加载更多 (BUG-1123, BUG-1124)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 13:45:44 +08:00
co-authored by Claude Opus 5.5
parent 90b1b6fba9
commit 9a616107c2
14 changed files with 470 additions and 33 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-30 — 「我的报告」进得更快,过往报告可以一直往前翻
- 点「我的报告」不再先等一轮服务器渲染;侧栏悬停时已开始读的列表会直接用上,不再重读一遍。之前读过的列表进页即显示,后台再刷新。
- 「过往的报告」每次显示 10 份,底部「加载更多」再往前 10 份,直到没有更早的;之前固定只显示最新 20 份,更早的看不到。生成中自动刷新不会把已经展开的列表收回去。Skill 版本不 bump。
## 2026-09-30 — 星盘符号气泡不再闪;星盘表格恢复圆角
- 鼠标停在星盘里的行星、上升或星座编号上,说明气泡会稳定显示;之前鼠标每动一下气泡就消失再出现。
+27
View File
@@ -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,id>` 键集分页,按 `(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`
+1
View File
@@ -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.
-2
View File
@@ -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 },
+22 -7
View File
@@ -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(
+6
View File
@@ -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;
@@ -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<CenterState, "reports" | "nextCursor" | "extended">,
page: { reports: readonly ReportListItem[]; nextCursor: string | null },
): Pick<CenterState, "reports" | "nextCursor" | "extended"> {
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<CenterState>(() => snapshotToState(peekReportsPage()));
const [loadingMore, setLoadingMore] = useState(false);
const deletedIds = useRef(new Set<string>());
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() {
})}
</div>
)}
{/* 10 at a time (2026-09-30, BUG-1124). No spinner: the label says it. */}
{state.nextCursor && state.reports.length > 0 ? (
<Button
className="report-center-more"
variant="outline"
disabled={loadingMore}
onClick={() => void loadMore()}
>
{loadingMore ? "加载中" : "加载更多"}
</Button>
) : null}
</section>
</div>
</SecondaryPageShell>
+96
View File
@@ -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<T extends Keyed>(
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<T extends Keyed>(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))];
}
+40 -3
View File
@@ -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<ReportsMorePage> {
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<ReportsPageSnapshot>(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<ReportsPageSnapshot>(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 {
@@ -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;
@@ -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<typeof createClientLifecycleHarness>;
const buttons = (h: Harness, text: string) => h.elements().filter(node => node.tagName === "BUTTON" && node.text === text);
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(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> = {}): 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<Response>(); 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(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>);
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<Response>(); 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(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>);
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%; \}/);
});
@@ -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";/);
});
+5 -2
View File
@@ -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<Response>(); 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; }
+5 -1
View File
@@ -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/);
});