perf : increase admin page speed

This commit is contained in:
2026-10-01 13:03:38 +07:00 Unverified
parent 9c91561abe
commit c5037154cb
7 changed files with 164 additions and 24 deletions
+31
View File
@@ -0,0 +1,31 @@
---
name: admin-performance
description: Preserve Buzz Sheet admin performance when adding or changing admin routes, editor data loading, catalog payloads, navigation, or refresh behavior. Diagnose slow admin navigation with measurements and targeted fixes.
---
# Admin performance
Read the relevant installed Next.js guides in `node_modules/next/dist/docs/` before changing routing, caching, streaming, or data fetching. This project enables Cache Components; do not assume older Next.js conventions.
## Data boundaries
- Trace the route through authentication, queries, Server Component props, and the actual editor save action before narrowing its data. Keep authorization and version-conflict checks intact.
- Use `getCatalogOptions()` in `lib/guides/queries.ts` for label/icon pickers. Use `getActiveCatalog()` only when the screen consumes full character details or materials. Full character records contain nested talent, constellation, ascension, leveling, and stat data; avoid sending those records for every picker option.
- Section editors call `getAdminGuideContent(slug, page)` using the actual route slug, including custom extra slugs. Omitting the section intentionally loads the complete guide for preview/validation callers. Do not narrow those callers accidentally.
- Get the current character's talents and constellations from the loaded guide content, not a second full catalog read. Preserve glossary aliases in picker data.
- Catalog data reads must not depend on object-storage version metadata that the screen does not use. The dashboard's explicit version display is a separate consumer.
- Keep independent work concurrent after authorization, while respecting the existing bounded query concurrency and per-pod database pool. Avoid N+1 queries and unbounded Promise.all over records.
## Navigation and freshness
Keep `app/admin/loading.tsx` as lightweight, non-sensitive feedback while protected content loads. A loading state improves feedback; it does not prove the editor becomes usable sooner. Evaluate link prefetch changes against server load, especially lists with many guides. Do not enable full-route prefetch everywhere as a substitute for fixing expensive reads.
Admin edits must remain immediately visible. Before adding cross-request caching, identify every invalidation path: autosave, glossary changes, catalog sync, trash/restore, and live refresh as applicable. Request-scoped deduplication and smaller queries are preferable when invalidation cannot be established. Never cache authorization globally.
## Measure and verify
For reported slowness, establish the affected route and whether it occurs in development or production. Separate cold compilation from production latency. Capture navigation-to-usable time, response/payload size, query time/count, and external service waits where accessible. Compare repeated warm runs and a cold run under equivalent conditions. Do not treat TTFB or a spinner as completion.
Read-only production diagnostics may inspect pod resources and time bounded database/storage reads when accessible. Output aggregate timings and sizes only; avoid credentials, session tokens, or private content. Do not deploy, migrate, or change infrastructure merely to benchmark.
Run `bun run typecheck` and focused tests for affected query/auth/save behavior. `lib/guides/queries.test.ts` guards section-specific reads and complete-guide compatibility. For route or streaming changes, also verify a production build. Verify edits retain selected-section data, custom extras, aliases, and conflict handling. Report measured improvements separately from expected gains; explicitly state when authenticated browser timings were unavailable.
+4
View File
@@ -7,3 +7,7 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
## Admin performance
For changes to admin routes, editor data loading, catalog payloads, navigation, or refresh behavior, use `.agents/skills/admin-performance/SKILL.md` to preserve load performance and edit freshness.
+4 -6
View File
@@ -11,7 +11,7 @@ import { EditorPresence } from "@/components/admin/editor-presence";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
import {
getActiveCatalog,
getCatalogOptions,
getAdminGuideContent,
getGuideEditorHeader,
} from "@/lib/guides/queries";
@@ -53,13 +53,11 @@ export default async function StructuredEditorPage({
?? guide.sections.find((section) => section.slug === page)?.title
?? page;
const [catalog, content] = await Promise.all([
getActiveCatalog(),
getAdminGuideContent(guide.slug),
getCatalogOptions(),
getAdminGuideContent(guide.slug, page),
]);
if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find(
(item) => item.key === guide.characterKey,
);
const guideCharacter = content.character;
return (
<div className="min-h-svh">
<AdminHeader />
+2 -2
View File
@@ -5,13 +5,13 @@ import { AdminHeader } from "@/components/admin/admin-header";
import { GlossaryManager } from "@/components/admin/glossary-manager";
import { getAdminSession } from "@/lib/auth/server";
import { listGlossaryAliases } from "@/lib/glossary-repository";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCatalogOptions } from "@/lib/guides/queries";
export default async function GlossaryPage() {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const [catalog, aliases] = await Promise.all([
getActiveCatalog(),
getCatalogOptions(),
listGlossaryAliases(),
]);
if (!catalog) redirect("/admin");
+7
View File
@@ -0,0 +1,7 @@
export default function AdminLoading() {
return (
<main className="mx-auto flex min-h-svh w-full max-w-7xl items-start p-4 sm:p-6 lg:p-8">
<p role="status" className="text-sm text-muted-foreground">กำลังโหลด…</p>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ reads: [] as unknown[], rows: new Map<unknown, unknown[]>() }));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/media/storage", () => ({ publicMediaUrl: (key: string) => `/media/${key}` }));
vi.mock("@/db", () => ({
getDb: () => ({
select: () => {
let table: unknown;
const query = {
from(value: unknown) { table = value; return query; },
innerJoin() { return query; },
leftJoin() { return query; },
where() { return query; },
orderBy() { return query; },
limit() { return query; },
then(resolve: (rows: unknown[]) => unknown) {
mocks.reads.push(table);
return Promise.resolve(mocks.rows.get(table) ?? []).then(resolve);
},
};
return query;
},
}),
}));
import {
artifactConditions, constellationConditions, extraSectionRows,
guides, guideSections, teamConditions, weaponConditions,
catalogMaterials, guidePosterBleeding,
} from "@/db/schema";
import { getAdminGuideContent } from "./queries";
beforeEach(() => {
mocks.reads.length = 0;
mocks.rows.clear();
mocks.rows.set(guides, [{
guide: { id: "guide", slug: "test", posterArtMediaId: null },
cover: { objectKey: "cover", status: "ready" },
character: { key: "test", imageKey: "character", constellations: [], talents: [], ascension: [], leveling: [] },
}]);
mocks.rows.set(guideSections, [
{ id: "extra-a", slug: "extra-a", kind: "extra" },
{ id: "extra-b", slug: "extra-b", kind: "extra" },
]);
});
describe("admin section reads", () => {
it.each([
["weapon", weaponConditions],
["artifact", artifactConditions],
["constellations", constellationConditions],
["team", teamConditions],
])("loads only the %s section's conditions", async (section, expectedTable) => {
const result = await getAdminGuideContent("test", section);
expect(result).not.toBeNull();
expect(mocks.reads).toContain(expectedTable);
for (const table of [weaponConditions, artifactConditions, constellationConditions, teamConditions]) {
if (table !== expectedTable) expect(mocks.reads).not.toContain(table);
}
expect(mocks.reads).not.toContain(extraSectionRows);
expect(mocks.reads).not.toContain(catalogMaterials);
expect(mocks.reads).not.toContain(guidePosterBleeding);
});
it("loads just the selected extra section", async () => {
const result = await getAdminGuideContent("test", "extra-b");
expect(result?.extras.map((extra) => extra.id)).toEqual(["extra-b"]);
expect(mocks.reads).toContain(extraSectionRows);
expect(mocks.reads).not.toContain(weaponConditions);
expect(mocks.reads).not.toContain(teamConditions);
});
it("preserves full-guide reads for existing preview and validation callers", async () => {
const result = await getAdminGuideContent("test");
for (const table of [weaponConditions, artifactConditions, constellationConditions, teamConditions, extraSectionRows]) {
expect(mocks.reads).toContain(table);
}
expect(result?.extras).toHaveLength(2);
});
});
+35 -16
View File
@@ -1,7 +1,6 @@
import "server-only";
import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
import * as z from "zod";
import { getDb } from "@/db";
import {
@@ -27,11 +26,10 @@ import {
weaponRecommendations,
weaponConditions,
} from "@/db/schema";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
import { publicMediaUrl } from "@/lib/media/storage";
import { aliasesByItem } from "@/lib/glossary";
import type { PosterBlendMode } from "@/components/public/material-poster";
const catalogVersionSchema = z.object({ version: z.string() });
const posterBlendModes = new Set(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]);
// Leave most of the per-pod pool available to other visitors during cache misses.
@@ -121,17 +119,7 @@ export async function getActiveCatalog() {
if (!characters.length) return null;
const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
let version = "unknown";
try {
const storage = await getMediaStorage();
version = catalogVersionSchema.parse(
await storage.file("catalog/current.json").json(),
).version;
} catch (error) {
console.error("Unable to read the active catalog version", error);
}
return {
version,
characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item, materialByKey), aliases: groupedAliases.character.get(item.key) ?? [] })),
weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
@@ -139,6 +127,37 @@ export async function getActiveCatalog() {
};
}
// Picker data deliberately excludes character descriptions, talents and materials.
// Editors obtain the selected character's details from their guide content.
export async function getCatalogOptions() {
const [characters, weapons, artifacts, aliases] = await runWithConcurrency([
() => getDb().select({
key: catalogCharacters.key,
name: catalogCharacters.name,
rarity: catalogCharacters.rarity,
weaponType: catalogCharacters.weaponType,
element: catalogCharacters.element,
imageKey: catalogCharacters.imageKey,
elementIconKey: catalogCharacters.elementIconKey,
}).from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
() => getDb().select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
() => getDb().select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
() => getDb().select().from(glossaryAliases),
]);
if (!characters.length) return null;
const groupedAliases = aliasesByItem(aliases);
return {
characters: characters.map((item) => ({
...item,
imageUrl: publicMediaUrl(item.imageKey),
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
aliases: groupedAliases.character.get(item.key) ?? [],
})),
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), aliases: groupedAliases.weapon.get(item.key) ?? [] })),
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), aliases: groupedAliases.artifact.get(item.key) ?? [] })),
};
}
export async function listGuideCards(includeTrash = false) {
const rows = await getDb()
.select({
@@ -375,7 +394,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
if (!row) return null;
const availableSections = publicOnly ? await populatedSections(row.guide.id) : undefined;
if (publicOnly && selectedSection !== "overview" && !availableSections?.some((section) => section.slug === selectedSection)) return null;
const wants = (section: string) => !publicOnly || selectedSection === section;
const wants = (section: string) => selectedSection === undefined || selectedSection === section;
const materialKeys = [...new Set(
[...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
)];
@@ -506,8 +525,8 @@ export function getPublicGuide(slug: string, section = "overview") {
return getGuideContent(slug, true, section);
}
export function getAdminGuideContent(slug: string) {
return getGuideContent(slug, false);
export function getAdminGuideContent(slug: string, section?: string) {
return getGuideContent(slug, false, section);
}
export async function getAdminGuideContentById(guideId: string) {