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) {