feat : update
This commit is contained in:
Vendored
+5
@@ -8,6 +8,7 @@ import {
|
||||
characterCacheTag,
|
||||
dataSourceCacheTag,
|
||||
DIRECTORY_CACHE_TAG,
|
||||
GLOSSARY_CACHE_TAG,
|
||||
pageCacheTag,
|
||||
vectorCacheTag,
|
||||
} from "@/lib/cache/tags";
|
||||
@@ -16,6 +17,7 @@ export async function getCachedPublicDirectory() {
|
||||
"use cache: remote";
|
||||
cacheLife({ stale: 30, revalidate: 60, expire: 60 * 60 });
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
cacheTag(GLOSSARY_CACHE_TAG);
|
||||
return listPublicCharacters();
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export async function getCachedPublicPage(
|
||||
cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 });
|
||||
const result = await getPublicPage(characterSlug, pageSlug);
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
cacheTag(GLOSSARY_CACHE_TAG);
|
||||
if (result.type === "page") {
|
||||
cacheTag(
|
||||
pageCacheTag(result.snapshot.page.id),
|
||||
@@ -46,6 +49,7 @@ export async function getCachedPublicGuideCards() {
|
||||
"use cache: remote";
|
||||
cacheLife({ stale: 30, revalidate: 60, expire: 60 * 60 });
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
cacheTag(GLOSSARY_CACHE_TAG);
|
||||
return listPublicGuideCards();
|
||||
}
|
||||
|
||||
@@ -54,6 +58,7 @@ export async function getCachedPublicGuide(slug: string) {
|
||||
cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 });
|
||||
const result = await getPublicGuide(slug);
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
cacheTag(GLOSSARY_CACHE_TAG);
|
||||
if (result) cacheTag(pageCacheTag(result.id));
|
||||
return result;
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
export const DIRECTORY_CACHE_TAG = "buzz:directory";
|
||||
export const GLOSSARY_CACHE_TAG = "buzz:glossary";
|
||||
|
||||
export function pageCacheTag(pageId: string): string {
|
||||
return `buzz:page:${pageId}`;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogWeapons,
|
||||
glossaryAliases,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
glossaryAliasInputSchema,
|
||||
normalizeGlossaryAlias,
|
||||
type GlossaryKind,
|
||||
} from "@/lib/glossary";
|
||||
|
||||
const catalogTables = {
|
||||
character: catalogCharacters,
|
||||
weapon: catalogWeapons,
|
||||
artifact: catalogArtifacts,
|
||||
} as const;
|
||||
|
||||
export async function listGlossaryAliases() {
|
||||
return getDb().select().from(glossaryAliases).orderBy(
|
||||
asc(glossaryAliases.kind),
|
||||
asc(glossaryAliases.alias),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createGlossaryAlias(input: unknown) {
|
||||
const data = glossaryAliasInputSchema.parse(input);
|
||||
const table = catalogTables[data.kind];
|
||||
const [item] = await getDb()
|
||||
.select({ key: table.key })
|
||||
.from(table)
|
||||
.where(eq(table.key, data.catalogKey))
|
||||
.limit(1);
|
||||
if (!item) throw new Error("ไม่พบรายการใน Catalog");
|
||||
const [created] = await getDb().insert(glossaryAliases).values({
|
||||
...data,
|
||||
alias: data.alias.trim(),
|
||||
normalizedAlias: normalizeGlossaryAlias(data.alias),
|
||||
}).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function deleteGlossaryAlias(id: string) {
|
||||
const [deleted] = await getDb()
|
||||
.delete(glossaryAliases)
|
||||
.where(eq(glossaryAliases.id, id))
|
||||
.returning();
|
||||
if (!deleted) throw new Error("ไม่พบ Shortcut");
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function aliasesForItem(kind: GlossaryKind, catalogKey: string) {
|
||||
return getDb().select().from(glossaryAliases).where(and(
|
||||
eq(glossaryAliases.kind, kind),
|
||||
eq(glossaryAliases.catalogKey, catalogKey),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aliasesByItem, normalizeGlossaryAlias } from "./glossary";
|
||||
|
||||
describe("glossary aliases", () => {
|
||||
it("normalizes case, spacing, and unicode", () => {
|
||||
expect(normalizeGlossaryAlias(" HoMa ")).toBe("homa");
|
||||
expect(normalizeGlossaryAlias("ATK")).toBe("atk");
|
||||
});
|
||||
|
||||
it("groups aliases by item and ignores unknown kinds", () => {
|
||||
const grouped = aliasesByItem([
|
||||
{ kind: "weapon", catalogKey: "homa", alias: "SoH" },
|
||||
{ kind: "weapon", catalogKey: "homa", alias: "Homa" },
|
||||
{ kind: "unknown", catalogKey: "x", alias: "x" },
|
||||
]);
|
||||
expect(grouped.weapon.get("homa")).toEqual(["SoH", "Homa"]);
|
||||
expect(grouped.character.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const glossaryKinds = ["character", "weapon", "artifact"] as const;
|
||||
export type GlossaryKind = (typeof glossaryKinds)[number];
|
||||
|
||||
export const glossaryAliasInputSchema = z.strictObject({
|
||||
kind: z.enum(glossaryKinds),
|
||||
catalogKey: z.string().trim().min(1).max(64),
|
||||
alias: z.string().trim().min(1).max(120),
|
||||
});
|
||||
|
||||
export function normalizeGlossaryAlias(value: string): string {
|
||||
return value.trim().normalize("NFKC").toLocaleLowerCase("th");
|
||||
}
|
||||
|
||||
export function aliasesByItem(
|
||||
rows: ReadonlyArray<{ kind: string; catalogKey: string; alias: string }>,
|
||||
): Record<GlossaryKind, Map<string, string[]>> {
|
||||
const result: Record<GlossaryKind, Map<string, string[]>> = {
|
||||
character: new Map(),
|
||||
weapon: new Map(),
|
||||
artifact: new Map(),
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (!glossaryKinds.includes(row.kind as GlossaryKind)) continue;
|
||||
const kind = row.kind as GlossaryKind;
|
||||
const current = result[kind].get(row.catalogKey) ?? [];
|
||||
current.push(row.alias);
|
||||
result[kind].set(row.catalogKey, current);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
matrixToExtraSheet,
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
normalizeExtraSheetRichText,
|
||||
updateExtraSheetCell,
|
||||
} from "@/lib/guides/extra-sheet";
|
||||
|
||||
function idFactory() {
|
||||
@@ -87,4 +89,17 @@ describe("Extra sheet", () => {
|
||||
expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
|
||||
}
|
||||
});
|
||||
|
||||
it("updates and sanitizes rich-text cells while preserving legacy text", () => {
|
||||
const initial = createExtraSheet(idFactory());
|
||||
const updated = updateExtraSheetCell(initial, 0, 0, "legacy\ntext");
|
||||
const columnId = updated.columns[0].id;
|
||||
expect(normalizeExtraSheetRichText(updated).rows[0].cells[columnId]).toBe(
|
||||
"<p>legacy<br>text</p>",
|
||||
);
|
||||
const unsafe = updateExtraSheetCell(updated, 0, 0, "<p><strong>safe</strong><script>bad()</script></p>");
|
||||
expect(normalizeExtraSheetRichText(unsafe).rows[0].cells[columnId]).toBe(
|
||||
"<p><strong>safe</strong></p>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
const extraSheetId = z.string().min(1).max(128);
|
||||
|
||||
export const extraSheetSchema = z.strictObject({
|
||||
@@ -158,3 +160,33 @@ export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraShee
|
||||
if (sheet.rows.length === 1) return sheet;
|
||||
return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
|
||||
}
|
||||
|
||||
export function updateExtraSheetCell(
|
||||
sheet: ExtraSheet,
|
||||
rowIndex: number,
|
||||
columnIndex: number,
|
||||
value: string,
|
||||
): ExtraSheet {
|
||||
const row = sheet.rows[rowIndex];
|
||||
const column = sheet.columns[columnIndex];
|
||||
if (!row || !column) return sheet;
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((candidate, index) => index === rowIndex
|
||||
? { ...candidate, cells: { ...candidate.cells, [column.id]: value } }
|
||||
: candidate),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet {
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row) => ({
|
||||
...row,
|
||||
cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => [
|
||||
columnId,
|
||||
normalizeExtraRichText(value),
|
||||
])),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function saveWeapons(guideId: string, input: unknown) {
|
||||
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
const [created] = await tx.insert(weaponConditions).values({ guideId, note: group.note, sortOrder }).returning();
|
||||
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
||||
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
+19
-12
@@ -16,6 +16,7 @@ import {
|
||||
extraSectionRows,
|
||||
guides,
|
||||
guideSections,
|
||||
glossaryAliases,
|
||||
media,
|
||||
teamCompositions,
|
||||
teamMembers,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
weaponConditions,
|
||||
} from "@/db/schema";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
import { aliasesByItem } from "@/lib/glossary";
|
||||
|
||||
const catalogVersionSchema = z.object({ version: z.string() });
|
||||
|
||||
@@ -46,21 +48,23 @@ function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
|
||||
|
||||
export async function getActiveCatalog() {
|
||||
const db = getDb();
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
const [characters, weapons, artifacts, aliases] = await Promise.all([
|
||||
db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
|
||||
db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
|
||||
db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
|
||||
db.select().from(glossaryAliases),
|
||||
]);
|
||||
if (!characters.length) return null;
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
const storage = await getMediaStorage();
|
||||
const { version } = catalogVersionSchema.parse(
|
||||
await storage.file("catalog/current.json").json(),
|
||||
);
|
||||
return {
|
||||
version,
|
||||
characters: characters.map(catalogCharacterWithUrls),
|
||||
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item), 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) })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,13 +79,14 @@ export async function listGuideCards(includeTrash = false) {
|
||||
}
|
||||
|
||||
export async function listPublicGuideCards() {
|
||||
const rows = await getDb()
|
||||
const [rows, aliases] = await Promise.all([getDb()
|
||||
.select({ guide: guides, cover: media })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
|
||||
.orderBy(asc(guides.sortOrder), asc(guides.name));
|
||||
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||
.orderBy(asc(guides.sortOrder), asc(guides.name)), getDb().select().from(glossaryAliases)]);
|
||||
const grouped = aliasesByItem(aliases);
|
||||
return rows.map(({ guide, cover }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||
}
|
||||
|
||||
export async function getGuideByCharacterKey(characterKey: string) {
|
||||
@@ -109,14 +114,16 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases] = await Promise.all([
|
||||
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
|
||||
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
|
||||
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
|
||||
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
|
||||
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
|
||||
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
|
||||
getDb().select().from(glossaryAliases),
|
||||
]);
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
|
||||
const elementIconUrls = Object.fromEntries(
|
||||
(await getDb()
|
||||
@@ -161,11 +168,11 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
character: catalogCharacterWithUrls(row.character),
|
||||
character: { ...catalogCharacterWithUrls(row.character), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
||||
elementIconUrls,
|
||||
sections,
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
|
||||
artifactProfile: profile[0] ?? null,
|
||||
constellationGroups: constellationGroups.map((group) => ({
|
||||
@@ -180,7 +187,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
};
|
||||
}),
|
||||
})),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
|
||||
...extraRow,
|
||||
catalogItems: extraRow.catalogItems.map((reference) => {
|
||||
|
||||
@@ -35,6 +35,12 @@ describe("structured guide validation", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }] }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("supports an all-refinements weapon range", () => {
|
||||
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: "1-5", overallPercent: 100, personalPercent: null, note: null }] }] });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.groups[0].rows[0].refinement).toBe("1-5");
|
||||
});
|
||||
|
||||
it("preserves negative weapon percentages", () => {
|
||||
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] }] });
|
||||
expect(result.success).toBe(true);
|
||||
@@ -229,7 +235,7 @@ describe("structured guide validation", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1");
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("<p>=1+1</p>");
|
||||
}
|
||||
|
||||
const invalidSheet = structuredClone(sheet);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as z from "zod";
|
||||
|
||||
import { targetStatOptions } from "@/lib/guides/stats";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { extraSheetSchema } from "@/lib/guides/extra-sheet";
|
||||
import { extraSheetSchema, normalizeExtraSheetRichText } from "@/lib/guides/extra-sheet";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||
@@ -10,6 +10,11 @@ export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||
|
||||
const percent = z.coerce.number().finite().min(-9999).max(9999);
|
||||
const catalogKey = z.string().trim().min(1).max(64);
|
||||
export const weaponRefinementSchema = z.union([
|
||||
z.number().int().min(1).max(5).transform(String),
|
||||
z.enum(["1", "2", "3", "4", "5", "1-5"]),
|
||||
]);
|
||||
export type WeaponRefinement = z.output<typeof weaponRefinementSchema>;
|
||||
|
||||
export const createGuideSchema = z.strictObject({
|
||||
characterKey: catalogKey,
|
||||
@@ -40,7 +45,7 @@ export const weaponSectionSchema = z.strictObject({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: z.array(z.strictObject({
|
||||
weaponKey: catalogKey,
|
||||
refinement: z.number().int().min(1).max(5),
|
||||
refinement: weaponRefinementSchema,
|
||||
overallPercent: percent.nullable(),
|
||||
personalPercent: percent.nullable(),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
@@ -159,6 +164,7 @@ const extraRowSchema = z.strictObject({
|
||||
}).transform((row) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
sheet: row.sheet ? normalizeExtraSheetRichText(row.sheet) : null,
|
||||
highlights: [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { matchesTeamSearch } from "./team-search";
|
||||
import { matchesTeamSearch, sortTeamsByDps } from "./team-search";
|
||||
|
||||
const team = {
|
||||
team: ["Vaporize Team"],
|
||||
@@ -28,4 +28,14 @@ describe("team search", () => {
|
||||
it("matches every team when the query is empty", () => {
|
||||
expect(matchesTeamSearch(team, " ", "weapon")).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts every team by DPS and preserves order for ties", () => {
|
||||
const teams = [
|
||||
{ name: "middle", rotationSeconds: 10, members: [{ damage: 100 }] },
|
||||
{ name: "best", rotationSeconds: 5, members: [{ damage: 100 }] },
|
||||
{ name: "tie", rotationSeconds: 20, members: [{ damage: 200 }] },
|
||||
];
|
||||
expect(sortTeamsByDps(teams, "best").map((item) => item.name)).toEqual(["best", "middle", "tie"]);
|
||||
expect(sortTeamsByDps(teams, "worst").map((item) => item.name)).toEqual(["middle", "tie", "best"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,32 @@ export interface TeamSearchValues {
|
||||
artifact: string[];
|
||||
}
|
||||
|
||||
export interface DpsTeam {
|
||||
rotationSeconds: string | number;
|
||||
members: Array<{ damage: string | number }>;
|
||||
}
|
||||
|
||||
export function teamDps(team: DpsTeam): number {
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
if (!(seconds > 0)) return 0;
|
||||
return team.members.reduce((sum, member) => sum + Number(member.damage), 0) / seconds;
|
||||
}
|
||||
|
||||
export function sortTeamsByDps<Team extends DpsTeam>(
|
||||
teams: readonly Team[],
|
||||
order: "best" | "worst",
|
||||
): Team[] {
|
||||
return teams
|
||||
.map((team, index) => ({ team, index }))
|
||||
.sort((left, right) => {
|
||||
const difference = order === "best"
|
||||
? teamDps(right.team) - teamDps(left.team)
|
||||
: teamDps(left.team) - teamDps(right.team);
|
||||
return difference || left.index - right.index;
|
||||
})
|
||||
.map(({ team }) => team);
|
||||
}
|
||||
|
||||
export function matchesTeamSearch(
|
||||
values: TeamSearchValues,
|
||||
query: string,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PRESENCE_HEARTBEAT_MS, PRESENCE_TTL_MS } from "./presence-constants";
|
||||
|
||||
describe("presence timing", () => {
|
||||
it("expires only after several missed heartbeats", () => {
|
||||
expect(PRESENCE_HEARTBEAT_MS).toBe(15_000);
|
||||
expect(PRESENCE_TTL_MS).toBeGreaterThanOrEqual(PRESENCE_HEARTBEAT_MS * 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const PRESENCE_HEARTBEAT_MS = 15_000;
|
||||
export const PRESENCE_TTL_MS = 60_000;
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import "server-only";
|
||||
|
||||
import type Redis from "ioredis";
|
||||
|
||||
import { getRedisClient } from "@/lib/redis/client";
|
||||
import { PRESENCE_TTL_MS } from "@/lib/presence-constants";
|
||||
|
||||
type AdminPresence = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
guideId: string;
|
||||
};
|
||||
|
||||
function prefix(): string {
|
||||
return process.env.REDIS_PRESENCE_PREFIX || "buzz:presence";
|
||||
}
|
||||
|
||||
async function purge(
|
||||
redis: Redis,
|
||||
activeKey: string,
|
||||
detailKey: string,
|
||||
now: number,
|
||||
) {
|
||||
const expired = await redis.zrangebyscore(activeKey, 0, now);
|
||||
if (!expired.length) return;
|
||||
await redis.multi().zrem(activeKey, ...expired).hdel(detailKey, ...expired).exec();
|
||||
}
|
||||
|
||||
export async function updateVisitorPresence({
|
||||
tabId,
|
||||
guideId,
|
||||
active,
|
||||
}: {
|
||||
tabId: string;
|
||||
guideId: string | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
const redis = await getRedisClient();
|
||||
const activeKey = `${prefix()}:visitors`;
|
||||
const scopeKey = `${prefix()}:visitor-scopes`;
|
||||
const now = Date.now();
|
||||
if (active) {
|
||||
await redis.multi()
|
||||
.zadd(activeKey, now + PRESENCE_TTL_MS, tabId)
|
||||
.hset(scopeKey, tabId, guideId ?? "")
|
||||
.exec();
|
||||
} else {
|
||||
await redis.multi().zrem(activeKey, tabId).hdel(scopeKey, tabId).exec();
|
||||
}
|
||||
await purge(redis, activeKey, scopeKey, now);
|
||||
const ids = await redis.zrangebyscore(activeKey, now + 1, "+inf");
|
||||
const scopes = ids.length ? await redis.hmget(scopeKey, ...ids) : [];
|
||||
return {
|
||||
global: ids.length,
|
||||
guide: guideId
|
||||
? scopes.filter((scope) => scope === guideId).length
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateAdminPresence({
|
||||
memberId,
|
||||
user,
|
||||
guideId,
|
||||
active,
|
||||
}: {
|
||||
memberId: string;
|
||||
user: { id: string; name?: string | null; email: string };
|
||||
guideId: string | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
const redis = await getRedisClient();
|
||||
const activeKey = `${prefix()}:admins`;
|
||||
const detailKey = `${prefix()}:admin-details`;
|
||||
const now = Date.now();
|
||||
if (active && guideId) {
|
||||
const detail: AdminPresence = {
|
||||
userId: user.id,
|
||||
name: user.name?.trim() || user.email,
|
||||
email: user.email,
|
||||
guideId,
|
||||
};
|
||||
await redis.multi()
|
||||
.zadd(activeKey, now + PRESENCE_TTL_MS, memberId)
|
||||
.hset(detailKey, memberId, JSON.stringify(detail))
|
||||
.exec();
|
||||
} else {
|
||||
await redis.multi().zrem(activeKey, memberId).hdel(detailKey, memberId).exec();
|
||||
}
|
||||
await purge(redis, activeKey, detailKey, now);
|
||||
const ids = await redis.zrangebyscore(activeKey, now + 1, "+inf");
|
||||
const serialized = ids.length ? await redis.hmget(detailKey, ...ids) : [];
|
||||
const editorsByGuide: Record<string, Array<{ userId: string; name: string }>> = {};
|
||||
for (const value of serialized) {
|
||||
if (!value) continue;
|
||||
try {
|
||||
const detail = JSON.parse(value) as AdminPresence;
|
||||
const editors = editorsByGuide[detail.guideId] ?? [];
|
||||
if (!editors.some((editor) => editor.userId === detail.userId)) {
|
||||
editors.push({ userId: detail.userId, name: detail.name || detail.email });
|
||||
}
|
||||
editorsByGuide[detail.guideId] = editors;
|
||||
} catch {
|
||||
// Ignore malformed ephemeral entries.
|
||||
}
|
||||
}
|
||||
return { editorsByGuide };
|
||||
}
|
||||
Reference in New Issue
Block a user