From c5037154cb281d4ebe56b29f8ea5036825e8b5a3 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 1 Oct 2026 13:03:38 +0700 Subject: [PATCH] perf : increase admin page speed --- .agents/skills/admin-performance/SKILL.md | 31 +++++++++ AGENTS.md | 4 ++ app/admin/[character]/[page]/page.tsx | 10 ++- app/admin/glossary/page.tsx | 4 +- app/admin/loading.tsx | 7 ++ lib/guides/queries.test.ts | 81 +++++++++++++++++++++++ lib/guides/queries.ts | 51 +++++++++----- 7 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 .agents/skills/admin-performance/SKILL.md create mode 100644 app/admin/loading.tsx create mode 100644 lib/guides/queries.test.ts diff --git a/.agents/skills/admin-performance/SKILL.md b/.agents/skills/admin-performance/SKILL.md new file mode 100644 index 0000000..2c3a9ed --- /dev/null +++ b/.agents/skills/admin-performance/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 643577d..07eb65d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + +## 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. diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 2a6ba78..7e4c150 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -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 (
diff --git a/app/admin/glossary/page.tsx b/app/admin/glossary/page.tsx index 0c75055..70ea7ef 100644 --- a/app/admin/glossary/page.tsx +++ b/app/admin/glossary/page.tsx @@ -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"); diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx new file mode 100644 index 0000000..7291489 --- /dev/null +++ b/app/admin/loading.tsx @@ -0,0 +1,7 @@ +export default function AdminLoading() { + return ( +
+

กำลังโหลด…

+
+ ); +} diff --git a/lib/guides/queries.test.ts b/lib/guides/queries.test.ts new file mode 100644 index 0000000..01b630a --- /dev/null +++ b/lib/guides/queries.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ reads: [] as unknown[], rows: new Map() })); +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); + }); +}); diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index 81ceaaf..9826fb9 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -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) {