diff --git a/.agents/skills/fuzzy-search/SKILL.md b/.agents/skills/fuzzy-search/SKILL.md index 0bfe9ab..02eee85 100644 --- a/.agents/skills/fuzzy-search/SKILL.md +++ b/.agents/skills/fuzzy-search/SKILL.md @@ -13,7 +13,7 @@ Use this project’s shared search implementation for finding entities by name o - Attempt fuzzy matching only when the entire eligible collection has no direct results. Do not decide fallback independently for each item. - Restrict fuzzy candidates to names, aliases, and individual whitespace-separated words. Keep overview prose and internal keys available for existing direct searches, but exclude them from fuzzy candidates. - Normalize with NFC, trim, lowercase, and compare Unicode code points. Keep queries shorter than four code points strict. -- Use bounded optimal-string-alignment Damerau–Levenshtein distance, including adjacent transpositions. Allow one edit for 4–7 code points and two for longer queries, capped at 25% of query length. Rank by edit distance with stable source-order ties. +- Use bounded optimal-string-alignment Damerau-Levenshtein distance, including adjacent transpositions. Allow one edit for 4-7 code points and two for longer queries, capped at 25% of query length. Rank by edit distance with stable source-order ties. - Empty queries return the eligible collection. Display fuzzy results without an explanatory message. ## Reuse the project implementation diff --git a/Plan.md b/Plan.md index 89999a8..ec35715 100644 --- a/Plan.md +++ b/Plan.md @@ -20,11 +20,11 @@ Remove Guide moves content to a recoverable Trash before permanent deletion. ## Structured content -- Weapons: catalog weapon, R1–R5, overall percent and/or personal percent. +- Weapons: catalog weapon, R1-R5, overall percent and/or personal percent. - Artifacts: multiple named ranking groups, one-set or two-set rows, percent, up to two main stats per slot, ordered substats, targets, NA/E/Q priority and levels, and an optional note. -- Constellations: C1–C6 description and cumulative performance against C0=100%. +- Constellations: C1-C6 description and cumulative performance against C0=100%. - Team Calc: multiple four-character teams, raw damage, calculated member shares, rotation duration, total DPS, and combo text. - Extra: multiple sections of image-first rows with heading, short body, an diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index c85a5dd..cb5724f 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -19,7 +19,7 @@ import { navigationIconPath } from "@/lib/navigation"; const sections = [ { slug: "weapon", title: "Weapons", description: "จัดอันดับอาวุธ Refinement และเปอร์เซ็นต์" }, { slug: "artifact", title: "Artifacts & Stats", description: "ชุด Artifact, Main/Sub Stats และ Talent" }, - { slug: "constellations", title: "Constellations", description: "รายละเอียด C1–C6 และเปอร์เซ็นต์สะสม" }, + { slug: "constellations", title: "Constellations", description: "รายละเอียด C1-C6 และเปอร์เซ็นต์สะสม" }, { slug: "team", title: "Team Calc", description: "ตัวละคร 4 ตัว Damage share, DPS และ Combo" }, ]; diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..889cef1 --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { connection } from "next/server"; + +import { AnalyticsDashboard } from "@/components/public/analytics-dashboard"; +import { SiteFooter } from "@/components/public/site-footer"; +import { SiteHeader } from "@/components/public/site-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { getCachedPublicAnalytics } from "@/lib/cache/analytics"; + +export const metadata: Metadata = { + title: "วิเคราะห์ข้อมูล", + description: "อันดับตัวละคร อาวุธ และอาร์ติแฟกต์ที่ใช้และแนะนำบ่อยที่สุดในไกด์สาธารณะ พร้อมเลือกข้อมูล Stygian", +}; + +async function AnalyticsContent() { + await connection(); + return ; +} + +function AnalyticsSkeleton() { + return
+ +
+ {Array.from({ length: 6 }, (_, index) => )} +
+
+ {Array.from({ length: 3 }, (_, index) => )} +
+
; +} + +export default function AnalyticsPage() { + return
+ +
+
+

วิเคราะห์ข้อมูล

+

ส่องภาพรวมตัวละคร อาวุธ และอาร์ติแฟกต์ที่ใช้และแนะนำบ่อยที่สุดในไกด์ทั้งหมด

+
+ }> +
+ +
; +} diff --git a/app/sitemap.ts b/app/sitemap.ts index fb6bec7..9b8cf66 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -11,6 +11,7 @@ export default async function sitemap(): Promise { return [ { url: siteUrl }, { url: `${siteUrl}/stygian` }, + { url: `${siteUrl}/analytics` }, ...guides.map((guide) => ({ url: `${siteUrl}/${encodeURIComponent(guide.slug)}`, })), diff --git a/components/admin/constellation-image-generator.tsx b/components/admin/constellation-image-generator.tsx index 1458689..0c7c4db 100644 --- a/components/admin/constellation-image-generator.tsx +++ b/components/admin/constellation-image-generator.tsx @@ -101,7 +101,7 @@ export function ConstellationImageGenerator({ ข้อมูล Constellation - กรอกดาเมจสะสม C1–C6 เทียบกับ C0 ที่ 100% + กรอกดาเมจสะสม C1-C6 เทียบกับ C0 ที่ 100% + ข้อมูล Teamรองรับตัวละคร 1-4 คน และคำนวณ DPS อัตโนมัติ {!draft.members.length ? ยังไม่มี Teamเพิ่มตัวละครคนแรกเพื่อเริ่มกรอกข้อมูล Team : <> diff --git a/components/commission/commission-intro.tsx b/components/commission/commission-intro.tsx index 1b9c0fe..f194554 100644 --- a/components/commission/commission-intro.tsx +++ b/components/commission/commission-intro.tsx @@ -54,7 +54,7 @@ export function CommissionIntro() {

ขอคำนวณแบบที่ต้องการ

เลือกได้ทั้งจัดทีม เปรียบเทียบอาวุธ และเปรียบเทียบกลุ่มดาว โดยเห็นยอดก่อนชำระเงิน

-

ระยะเวลาจัดทำ 1–7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)

+

ระยะเวลาจัดทำ 1-7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)

{steps.map((step) => diff --git a/components/commission/request-form.tsx b/components/commission/request-form.tsx index 70b9b27..01dc9f9 100644 --- a/components/commission/request-form.tsx +++ b/components/commission/request-form.tsx @@ -142,7 +142,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u return
คำขอคำนวณทีม อาวุธ และกลุ่มดาว

{draftNotice}

-

ระยะเวลาจัดทำ 1–7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน

+

ระยะเวลาจัดทำ 1-7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน

{attempted && issues.some((issue) => !issue.path) && เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ} ทีม - ฿100 ต่อทีมเลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว หากกำหนดค่าสเตตัสเองให้ตัวละครใด ราคาทั้งคำขอจะเป็น 2 เท่าของยอดรวมปกติ diff --git a/components/public/analytics-dashboard.test.tsx b/components/public/analytics-dashboard.test.tsx new file mode 100644 index 0000000..5f18352 --- /dev/null +++ b/components/public/analytics-dashboard.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalyticsDashboard } from "./analytics-dashboard"; +import type { AnalyticsItem, PublicAnalytics } from "@/lib/analytics/model"; + +// Layout measurements belong to browser verification; the table exposes the same counts. +vi.mock("@/components/ui/chart", () => ({ + ChartContainer: () => null, ChartTooltip: () => null, ChartTooltipContent: () => null, +})); + +const item = (key: string, guideCount: number, stygianCount: number): AnalyticsItem => ({ + key, name: key, imageUrl: null, rarity: null, guideCount, stygianCount, + contributions: [{ guideId: "g0", teamSignature: "guide-team", count: guideCount }, { guideId: "s0", teamSignature: "stygian-team", count: stygianCount }], +}); +const entries = [item("Shared", 2, 5), item("Stygian only", 0, 3)]; +const data: PublicAnalytics = { + guides: [ + ...Array.from({ length: 4 }, (_, index) => ({ id: `g${index}`, source: "guide" as const, name: `Guide ${index}` })), + ...Array.from({ length: 2 }, (_, index) => ({ id: `s${index}`, source: "stygian" as const, name: `Stygian ${index}` })), + ], + teamGroups: [{ guideId: "g0", signature: "guide-team", count: 1 }, { guideId: "s0", signature: "stygian-team", count: 1 }], + guideCount: 4, stygianScheduleCount: 2, guideTeamCount: 6, stygianTeamCount: 3, + team: { character: entries, weapon: entries, artifact: entries }, + recommendation: { weapon: entries, artifact: entries }, +}; +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("public analytics dashboard", () => { + it("starts with Stygian enabled and updates all five rankings and totals on toggle", async () => { + await act(async () => root.render()); + const toggle = container.querySelector('[role="switch"][aria-labelledby="include-stygian-label"]')!; + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(container.querySelector(`#${toggle.getAttribute("aria-labelledby")}`)?.textContent).toBe("รวมข้อมูล Stygian"); + expect(container.querySelectorAll("details[data-ranking]")).toHaveLength(5); + expect(container.querySelectorAll("tbody tr")).toHaveLength(10); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด2"); + expect(Array.from(container.querySelectorAll("tbody tr:first-child td:last-child"), (cell) => cell.textContent)).toEqual(Array(5).fill("7")); + + const details = container.querySelector("details[data-ranking]")!; + details.open = true; + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(details.open).toBe(true); + expect(container.querySelectorAll("tbody tr")).toHaveLength(5); + expect(container.textContent).not.toContain("Stygian only"); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด1"); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("เวอร์ชัน Stygian0"); + expect(Array.from(container.querySelectorAll("tbody tr:first-child td:last-child"), (cell) => cell.textContent)).toEqual(Array(5).fill("2")); + + await act(async () => root.render()); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด2"); + }); + + it("shows empty states for sources without entries", async () => { + await act(async () => root.render()); + expect(container.querySelectorAll("details[data-ranking]")).toHaveLength(0); + expect(container.querySelectorAll('[data-slot="empty-title"]')).toHaveLength(5); + }); + + it("lets each guide be excluded and restored and supports repeated-team filtering", async () => { + await act(async () => root.render()); + const guide = container.querySelector('[aria-label="นับไกด์ Guide 0"]')!; + await act(async () => guide.click()); + expect(guide.getAttribute("aria-pressed")).toBe("false"); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ไกด์ตัวละคร3"); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด1"); + const reset = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "รวมทุกไกด์")!; + await act(async () => reset.click()); + expect(guide.getAttribute("aria-pressed")).toBe("true"); + const deduplicate = container.querySelector('[role="switch"][aria-labelledby="ignore-repeated-teams-label"]')!; + expect(deduplicate.getAttribute("aria-checked")).toBe("true"); + await act(async () => deduplicate.click()); + expect(deduplicate.getAttribute("aria-checked")).toBe("false"); + expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด2"); + }); +}); diff --git a/components/public/analytics-dashboard.tsx b/components/public/analytics-dashboard.tsx new file mode 100644 index 0000000..13ea8a3 --- /dev/null +++ b/components/public/analytics-dashboard.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState } from "react"; +import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts"; +import { CheckIcon, MinusIcon } from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; +import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { selectAnalytics, type PublicAnalytics, type RankedAnalyticsItem } from "@/lib/analytics/model"; +import { rarityColor } from "@/lib/catalog/rarity"; + +const formatCount = (count: number) => count.toLocaleString("th-TH"); +const chartConfig = { count: { label: "จำนวนครั้ง", color: "var(--muted-foreground)" } }; +const rarityLabel = (rarity: number | null) => rarity ? `${rarity} ดาว` : "ไม่ระบุดาว"; + +function ItemImageTick({ x, y, item }: { x: number; y: number; item: RankedAnalyticsItem }) { + const [failedSource, setFailedSource] = useState(null); + return + {item.name} · {rarityLabel(item.rarity)} + {item.imageUrl && failedSource !== item.imageUrl ? + setFailedSource(item.imageUrl)} /> : + {item.name.slice(0, 1)}} + ; +} + +function UsageRanking({ title, description, items }: { + title: string; + description: string; + items: RankedAnalyticsItem[]; +}) { + const top = items.slice(0, 10).map((item) => ({ ...item, fill: rarityColor(item.rarity) })); + const rarities = [...new Set(top.map((item) => item.rarity))].sort((a, b) => (b ?? 0) - (a ?? 0)); + return ( + + +

{title}

+ {description} +
+ + {items.length ? <> +
    + {rarities.map((rarity) =>
  • +
  • )} +
+ + + + + { + const item = top.find((entry) => entry.key === payload.value); + return item ? : ; + }} /> + { + const item = payload[0]?.payload; + return item ? `${item.name} · ${rarityLabel(item.rarity)}` : ""; + }} />} /> + + formatCount(Number(value))} /> + + + +
+ + ดูอันดับทั้งหมด ({formatCount(items.length)} รายการ) + + + + อันดับ + ชื่อ + ครั้ง + + {items.map((item, index) => + {formatCount(index + 1)} + +
+ + {item.imageUrl ? : null} + {item.name.slice(0, 1)} + + + {item.name} + {rarityLabel(item.rarity)} + +
+
+ {formatCount(item.count)} +
)}
+
+
+ : + ยังไม่มีข้อมูลยังไม่มีรายการในแหล่งข้อมูลที่เลือก + } +
+
+ ); +} + +export function AnalyticsDashboard({ data }: { data: PublicAnalytics }) { + const [includeStygian, setIncludeStygian] = useState(true); + const [excludedGuideIds, setExcludedGuideIds] = useState>(() => new Set()); + const [ignoreRepeatedTeams, setIgnoreRepeatedTeams] = useState(true); + const selected = selectAnalytics(data, includeStygian, excludedGuideIds, ignoreRepeatedTeams); + const toggleGuide = (id: string) => setExcludedGuideIds((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const summaries = [ + { label: "ไกด์ตัวละคร", count: selected.guideCount }, + { label: "เวอร์ชัน Stygian", count: selected.stygianScheduleCount }, + { label: "ทีมทั้งหมด", count: selected.teamCount }, + { label: "ตัวละครที่ใช้ในทีม", count: selected.team.character.length }, + { label: "อาวุธที่ใช้ในทีม", count: selected.team.weapon.length }, + { label: "เซ็ตอาร์ติแฟกต์ที่ใช้ในทีม", count: selected.team.artifact.length }, + ]; + + return
+ + + + รวมข้อมูล Stygian + รวมทุกเวอร์ชัน ระดับบอส และทีมที่แสดงในไกด์ Stygian + + + + + + นับทีมซ้ำเพียงครั้งเดียว + ทีมที่มีตัวละคร อาวุธ เซ็ตอาร์ติแฟกต์ กลุ่มดาว และขัดเกลาเหมือนกัน จะนับครั้งเดียว ไม่ขึ้นกับลำดับสมาชิกหรือไกด์ที่บันทึก + + +
+ + เลือกไกด์ที่นำมานับ ({formatCount(selected.guideCount + selected.stygianScheduleCount)} ไกด์) + +
+
+

กดปุ่มไกด์เพื่อไม่นับข้อมูล กดอีกครั้งเพื่อรวมกลับ

+ +
+ {(["guide", "stygian"] as const).map((source) =>
+

{source === "guide" ? "ไกด์ตัวละคร" : "เวอร์ชัน Stygian"}

+
+ {data.guides.filter((guide) => guide.source === source).map((guide) => { + const included = !excludedGuideIds.has(guide.id); + return ; + })} +
+
)} +
+
+
+ {summaries.map(({ label, count }) => + {label} +

{formatCount(count)}

+
)} +
+

+ นับจากข้อมูลที่บันทึกในไกด์สาธารณะ ไม่ใช่สถิติการเล่นของผู้เล่น + แต่ละรายการแนะนำถูกนับแยกกัน รวมตัวเลือกซ้ำ + {ignoreRepeatedTeams ? " ทีมที่มีตัวละครและบิลด์เหมือนกันจะนับเพียงครั้งเดียวจากไกด์ที่เลือก" : " แต่ละสมาชิกทีมถูกนับแยกกัน รวมทีมที่คัดลอกข้ามไกด์หรือเวอร์ชัน"} + อาร์ติแฟกต์แบบผสมจะนับทั้งสองเซ็ต เซ็ตเดียวกันในรายการเดียวจะนับครั้งเดียว + ไกด์ที่ซ่อน อยู่ในถังขยะ และส่วนที่ปิดการแสดงผลจะไม่ถูกนับ +

+
+
+

ใช้ในทีมบ่อยที่สุด

+

นับจำนวนครั้งที่ตัวละครและอุปกรณ์ปรากฏในสมาชิกทีม

+
+
+ + + +
+
+
+
+

แนะนำบ่อยที่สุด

+

นับรายการแนะนำอาวุธและอาร์ติแฟกต์ แยกจากอุปกรณ์ที่ใช้ในทีม{includeStygian ? " รวมอุปกรณ์ของตัวละครแนะนำใน Stygian" : ""}

+
+
+ + +
+
+
; +} diff --git a/components/public/public-navigation.tsx b/components/public/public-navigation.tsx index f91417b..45dbe86 100644 --- a/components/public/public-navigation.tsx +++ b/components/public/public-navigation.tsx @@ -3,9 +3,9 @@ import Link from "next/link"; import Image from "@/components/ui/resilient-image"; import { usePathname } from "next/navigation"; -import { BookOpenIcon, MenuIcon } from "lucide-react"; +import { BookOpenIcon, ChartNoAxesCombinedIcon, MenuIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Drawer, DrawerClose, @@ -20,6 +20,7 @@ const destinations = [ { href: "/", label: "หน้ารวม", icon: null }, { href: "/commission", label: "จ้างคำนวณทีม", icon: "/icon/commission.webp" }, { href: "/stygian", label: "ไกด์ Stygian", icon: "/icon/stygian.webp" }, + { href: "/analytics", label: "วิเคราะห์ข้อมูล", icon: null }, ] as const; export function PublicNavigation() { @@ -29,10 +30,10 @@ export function PublicNavigation() { return <>
@@ -48,7 +49,7 @@ export function PublicNavigation() { {destinations.map(({ href, label, icon }) => }> - {icon ? : )} diff --git a/lib/analytics/model.test.ts b/lib/analytics/model.test.ts new file mode 100644 index 0000000..2a5114f --- /dev/null +++ b/lib/analytics/model.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { rankAnalyticsItems, selectAnalytics, type AnalyticsItem, type PublicAnalytics } from "./model"; + +const item = (key: string, guideCount: number, stygianCount: number, name = key): AnalyticsItem => ({ + key, name, imageUrl: null, rarity: null, guideCount, stygianCount, + contributions: [{ guideId: "g0", teamSignature: "guide-team", count: guideCount }, { guideId: "s0", teamSignature: "stygian-team", count: stygianCount }], +}); + +describe("analytics source selection", () => { + it("merges sources, changes order, and removes Stygian-only items when excluded", () => { + const items = [item("shared", 2, 5), item("guide", 4, 0), item("stygian", 0, 3)]; + expect(rankAnalyticsItems(items, true).map(({ key, count }) => [key, count])).toEqual([ + ["shared", 7], ["guide", 4], ["stygian", 3], + ]); + expect(rankAnalyticsItems(items, false).map(({ key, count }) => [key, count])).toEqual([ + ["guide", 4], ["shared", 2], + ]); + expect(items[0].guideCount).toBe(2); + }); + + it("breaks ties by name and then key", () => { + expect(rankAnalyticsItems([item("z", 1, 0, "Alpha"), item("b", 1, 0, "Beta"), item("a", 1, 0, "Alpha")], true) + .map(({ key }) => key)).toEqual(["a", "z", "b"]); + }); + + it("updates every ranking and summary and handles empty sources", () => { + const entries = [item("shared", 2, 1), item("stygian", 0, 1)]; + const data: PublicAnalytics = { + guides: [ + ...Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, source: "guide" as const, name: `Guide ${index}` })), + ...Array.from({ length: 3 }, (_, index) => ({ id: `s${index}`, source: "stygian" as const, name: `Stygian ${index}` })), + ], + teamGroups: [{ guideId: "g0", signature: "guide-team", count: 6 }, { guideId: "s0", signature: "stygian-team", count: 9 }], + guideCount: 5, stygianScheduleCount: 3, guideTeamCount: 6, stygianTeamCount: 9, + team: { character: entries, weapon: entries, artifact: entries }, + recommendation: { weapon: entries, artifact: entries }, + }; + const included = selectAnalytics(data, true); + const excluded = selectAnalytics(data, false); + expect(included.teamCount).toBe(15); + expect(excluded.teamCount).toBe(6); + expect(excluded.stygianScheduleCount).toBe(0); + expect(excluded.guideCount).toBe(5); + for (const list of [...Object.values(excluded.team), ...Object.values(excluded.recommendation)]) { + expect(list.map(({ key, count }) => [key, count])).toEqual([["shared", 2]]); + } + expect(rankAnalyticsItems([], true)).toEqual([]); + expect(rankAnalyticsItems([item("unused", 0, 0)], false)).toEqual([]); + }); + + it("excludes guides before deduplicating and preserves per-member equipment counts", () => { + const entry: AnalyticsItem = { + ...item("weapon", 7, 2), + contributions: [ + { guideId: "g1", teamSignature: "shared", count: 4 }, + { guideId: "g2", teamSignature: "shared", count: 2 }, + { guideId: "g2", teamSignature: "different-build", count: 1 }, + { guideId: "s1", teamSignature: "shared", count: 2 }, + ], + }; + const recommendation = { ...entry, contributions: [{ guideId: "g1", teamSignature: null, count: 3 }, { guideId: "g2", teamSignature: null, count: 4 }] }; + const data: PublicAnalytics = { + guides: [{ id: "g1", source: "guide", name: "One" }, { id: "g2", source: "guide", name: "Two" }, { id: "s1", source: "stygian", name: "Stygian" }], + teamGroups: [{ guideId: "g1", signature: "shared", count: 2 }, { guideId: "g2", signature: "shared", count: 1 }, { guideId: "g2", signature: "different-build", count: 1 }, { guideId: "s1", signature: "shared", count: 1 }], + guideCount: 2, stygianScheduleCount: 1, guideTeamCount: 4, stygianTeamCount: 1, + team: { character: [], weapon: [entry], artifact: [] }, recommendation: { weapon: [recommendation], artifact: [] }, + }; + expect(selectAnalytics(data, true).team.weapon[0].count).toBe(9); + const unique = selectAnalytics(data, true, new Set(), true); + expect(unique.teamCount).toBe(2); + expect(unique.team.weapon[0].count).toBe(3); + expect(unique.recommendation.weapon[0].count).toBe(7); + const excluded = selectAnalytics(data, true, new Set(["g1"]), true); + expect(excluded.guideCount).toBe(1); + expect(excluded.team.weapon[0].count).toBe(3); + expect(excluded.recommendation.weapon[0].count).toBe(4); + expect(selectAnalytics(data, true, new Set(["g1", "g2"]), true).team.weapon[0].count).toBe(2); + const empty = selectAnalytics(data, false, new Set(["g1", "g2"]), true); + expect(empty.teamCount).toBe(0); + expect(empty.team.weapon).toEqual([]); + }); +}); diff --git a/lib/analytics/model.ts b/lib/analytics/model.ts new file mode 100644 index 0000000..90e1cb6 --- /dev/null +++ b/lib/analytics/model.ts @@ -0,0 +1,84 @@ +export type AnalyticsKind = "character" | "weapon" | "artifact"; +export type AnalyticsCategory = "team" | "recommendation"; + +export interface AnalyticsItem { + key: string; + name: string; + imageUrl: string | null; + rarity: number | null; + guideCount: number; + stygianCount: number; + contributions: Array<{ guideId: string; teamSignature: string | null; count: number }>; +} + +export interface AnalyticsGuide { + id: string; + source: "guide" | "stygian"; + name: string; +} + +export interface AnalyticsTeamGroup { + guideId: string; + signature: string; + count: number; +} + +export interface PublicAnalytics { + guides: AnalyticsGuide[]; + teamGroups: AnalyticsTeamGroup[]; + guideCount: number; + stygianScheduleCount: number; + guideTeamCount: number; + stygianTeamCount: number; + team: Record; + recommendation: Record<"weapon" | "artifact", AnalyticsItem[]>; +} + +export interface RankedAnalyticsItem extends AnalyticsItem { + count: number; +} + +type AnalyticsSelection = { guideIds: Set; teamDivisors: Map }; + +export function rankAnalyticsItems(items: AnalyticsItem[], includeStygian: boolean, selection?: AnalyticsSelection): RankedAnalyticsItem[] { + return items.map((item) => ({ + ...item, + count: selection ? item.contributions.reduce((count, entry) => { + if (!selection.guideIds.has(entry.guideId)) return count; + const divisor = entry.teamSignature === null ? 1 : selection.teamDivisors.get(`${entry.guideId}:${entry.teamSignature}`); + return divisor ? count + entry.count / divisor : count; + }, 0) : item.guideCount + (includeStygian ? item.stygianCount : 0), + })).filter((item) => item.count > 0).sort((a, b) => + b.count - a.count || a.name.localeCompare(b.name, "th") || a.key.localeCompare(b.key, "en"), + ); +} + +export function selectAnalytics(data: PublicAnalytics, includeStygian: boolean, excludedGuideIds: ReadonlySet = new Set(), ignoreRepeatedTeams = false) { + const includedGuides = data.guides.filter((guide) => !excludedGuideIds.has(guide.id) && (includeStygian || guide.source === "guide")); + const selection: AnalyticsSelection = { guideIds: new Set(includedGuides.map((guide) => guide.id)), teamDivisors: new Map() }; + const seen = new Set(); + let teamCount = 0; + // Choose one copy deterministically after applying guide exclusions. + for (const group of [...data.teamGroups].sort((a, b) => a.guideId.localeCompare(b.guideId))) { + if (!selection.guideIds.has(group.guideId)) continue; + if (ignoreRepeatedTeams && seen.has(group.signature)) continue; + seen.add(group.signature); + selection.teamDivisors.set(`${group.guideId}:${group.signature}`, ignoreRepeatedTeams ? group.count : 1); + teamCount += ignoreRepeatedTeams ? 1 : group.count; + } + const team = { + character: rankAnalyticsItems(data.team.character, includeStygian, selection), + weapon: rankAnalyticsItems(data.team.weapon, includeStygian, selection), + artifact: rankAnalyticsItems(data.team.artifact, includeStygian, selection), + }; + return { + guideCount: includedGuides.filter((guide) => guide.source === "guide").length, + stygianScheduleCount: includedGuides.filter((guide) => guide.source === "stygian").length, + teamCount, + team, + recommendation: { + weapon: rankAnalyticsItems(data.recommendation.weapon, includeStygian, selection), + artifact: rankAnalyticsItems(data.recommendation.artifact, includeStygian, selection), + }, + }; +} diff --git a/lib/analytics/queries.ts b/lib/analytics/queries.ts new file mode 100644 index 0000000..fc0aa2e --- /dev/null +++ b/lib/analytics/queries.ts @@ -0,0 +1,151 @@ +import "server-only"; + +import { sql } from "drizzle-orm"; + +import { getDb } from "@/db"; +import { + artifactConditions, artifactGroups, artifactRecommendations, + catalogArtifacts, catalogCharacters, catalogWeapons, guides, guideSections, + stygianRecommendationGroups, stygianRecommendationItems, stygianSchedules, + teamCompositions, teamMembers, weaponConditions, weaponRecommendations, +} from "@/db/schema"; +import { publicMediaUrl } from "@/lib/media/storage"; +import type { AnalyticsCategory, AnalyticsItem, AnalyticsKind, PublicAnalytics } from "./model"; + +type AggregateRow = { + source: "guide" | "stygian"; + category: AnalyticsCategory | "summary"; + kind: AnalyticsKind | "guide" | "schedule" | "team"; + key: string; + count: number; + name: string | null; + imageKey: string | null; + rarity: number | null; + guideId: string; + teamSignature: string | null; +}; + +// Aggregate structured records before joining only the catalog labels and images. +// EXISTS keeps multiple section records from multiplying usage counts. +export async function getPublicAnalytics(): Promise { + const rows = await getDb().execute(sql` + with public_guides as ( + select g.id, g.name from ${guides} g where g.is_public = true and g.trashed_at is null + ), public_sections as ( + select s.guide_id, s.kind from ${guideSections} s + join public_guides g on g.id = s.guide_id where s.enabled = true + ), guide_teams as ( + select t.id, t.guide_id from ${teamCompositions} t + where exists (select 1 from public_sections s where s.guide_id = t.guide_id and s.kind = 'team') + ), stygian_groups as ( + select g.id, g.kind, g.schedule_id from ${stygianRecommendationGroups} g + join ${stygianSchedules} s on s.schedule_id = g.schedule_id + ), stygian_items as ( + select i.*, g.kind as group_kind, g.schedule_id from ${stygianRecommendationItems} i + join stygian_groups g on g.id = i.group_id + ), members as ( + select 'guide' as source, 'guide:' || t.guide_id as guide_id, m.team_id, + m.character_key, m.weapon_key, m.artifact_key, m.second_artifact_key, m.constellation, m.refinement + from ${teamMembers} m join guide_teams t on t.id = m.team_id + union all + select 'stygian', 'stygian:' || i.schedule_id, i.group_id, + i.catalog_key, i.weapon_key, i.artifact_key, null, i.constellation_level::text, i.refinement::text + from stygian_items i where i.group_kind = 'team' and i.kind = 'character' + ), member_builds as ( + select team_id, jsonb_build_array(character_key, weapon_key, + (select jsonb_agg(key order by key) from ( + select distinct key from (values (artifact_key), (second_artifact_key)) a(key) where key is not null and key <> '' + ) artifact_sets), constellation, refinement) as build from members + ), fingerprints as ( + select team_id, md5(jsonb_agg(build order by build::text)::text) as signature from member_builds group by team_id + ), all_teams as ( + select 'guide' as source, 'guide:' || t.guide_id as guide_id, t.id from guide_teams t + union all select 'stygian', 'stygian:' || schedule_id, id from stygian_groups where kind = 'team' + ), team_groups as ( + select t.source, t.guide_id, coalesce(f.signature, md5('[]')) as signature, count(*)::int as count + from all_teams t left join fingerprints f on f.team_id = t.id + group by t.source, t.guide_id, coalesce(f.signature, md5('[]')) + ), usage as ( + select m.source, m.guide_id, f.signature, 'team' as category, v.kind, v.key + from members m join fingerprints f on f.team_id = m.team_id cross join lateral (values + ('character', m.character_key), ('weapon', m.weapon_key), ('artifact', m.artifact_key), + ('artifact', case when m.second_artifact_key <> m.artifact_key then m.second_artifact_key end) + ) v(kind, key) + union all + select 'guide', 'guide:' || c.guide_id, null, 'recommendation', 'weapon', r.weapon_key + from ${weaponRecommendations} r join ${weaponConditions} c on c.id = r.condition_id + where exists (select 1 from public_sections s where s.guide_id = c.guide_id and s.kind = 'weapon') + union all + select 'guide', 'guide:' || c.guide_id, null, 'recommendation', 'artifact', v.key + from ${artifactRecommendations} r + join ${artifactGroups} g on g.id = r.group_id + join ${artifactConditions} c on c.id = g.condition_id + cross join lateral (values (r.first_artifact_key), + (case when r.second_artifact_key <> r.first_artifact_key then r.second_artifact_key end) + ) v(key) + where exists (select 1 from public_sections s where s.guide_id = c.guide_id and s.kind = 'artifact') + union all + select 'stygian', 'stygian:' || i.schedule_id, null, 'recommendation', v.kind, v.key + from stygian_items i cross join lateral (values + ('weapon', case when i.kind = 'weapon' then i.catalog_key when i.kind = 'character' then i.weapon_key end), + ('artifact', case when i.kind = 'artifact' then i.catalog_key when i.kind = 'character' then i.artifact_key end) + ) v(kind, key) where i.group_kind = 'unit' + ), counts as ( + select source, guide_id, signature, category, kind, key, count(*)::int as count + from usage where key is not null and key <> '' group by source, guide_id, signature, category, kind, key + ), catalog as ( + select 'character' as kind, key, name, image_key, rarity from ${catalogCharacters} + union all select 'weapon', key, name, image_key, rarity from ${catalogWeapons} + union all select 'artifact', key, name, image_key, rarity from ${catalogArtifacts} + ) + select c.source, c.category, c.kind, c.key, c.count, i.name, i.image_key as "imageKey", + c.guide_id as "guideId", c.signature as "teamSignature", i.rarity + from counts c left join catalog i on i.kind = c.kind and i.key = c.key + union all select 'guide', 'summary', 'guide', '', 1, name, null, 'guide:' || id, null, null from public_guides + union all select 'stygian', 'summary', 'schedule', '', 1, challenge_name, null, 'stygian:' || schedule_id, null, null from ${stygianSchedules} + union all select source, 'summary', 'team', '', count, null, null, guide_id, signature, null from team_groups + `); + + const data: PublicAnalytics = { + guides: [], teamGroups: [], + guideCount: 0, stygianScheduleCount: 0, guideTeamCount: 0, stygianTeamCount: 0, + team: { character: [], weapon: [], artifact: [] }, + recommendation: { weapon: [], artifact: [] }, + }; + const items = new Map(); + for (const row of rows) { + if (row.category === "summary") { + if (row.kind === "guide" || row.kind === "schedule") { + data.guides.push({ id: row.guideId, source: row.source, name: row.name ?? row.guideId }); + if (row.kind === "guide") data.guideCount += row.count; + else data.stygianScheduleCount += row.count; + } + if (row.kind === "team") { + data.teamGroups.push({ guideId: row.guideId, signature: row.teamSignature!, count: row.count }); + if (row.source === "guide") data.guideTeamCount += row.count; + else data.stygianTeamCount += row.count; + } + continue; + } + if (row.kind !== "character" && row.kind !== "weapon" && row.kind !== "artifact") continue; + if (row.category === "recommendation" && row.kind === "character") continue; + const id = `${row.category}:${row.kind}:${row.key}`; + let item = items.get(id); + if (!item) { + item = { + key: row.key, name: row.name ?? row.key, + imageUrl: row.imageKey ? publicMediaUrl(row.imageKey) : null, + rarity: row.rarity, + guideCount: 0, stygianCount: 0, contributions: [], + }; + items.set(id, item); + if (row.category === "team") data.team[row.kind].push(item); + else if (row.kind !== "character") data.recommendation[row.kind].push(item); + } + if (row.source === "guide") item.guideCount += row.count; + else item.stygianCount += row.count; + item.contributions.push({ guideId: row.guideId, teamSignature: row.teamSignature, count: row.count }); + } + data.guides.sort((a, b) => a.source.localeCompare(b.source) || a.name.localeCompare(b.name, "th") || a.id.localeCompare(b.id)); + return data; +} diff --git a/lib/cache/analytics.test.ts b/lib/cache/analytics.test.ts new file mode 100644 index 0000000..e419768 --- /dev/null +++ b/lib/cache/analytics.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ tag: vi.fn(), life: vi.fn(), query: vi.fn() })); +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ cacheTag: mocks.tag, cacheLife: mocks.life })); +vi.mock("@/lib/analytics/queries", () => ({ getPublicAnalytics: mocks.query })); +import { getCachedPublicAnalytics } from "./analytics"; + +beforeEach(() => vi.resetAllMocks()); + +it("tags analytics for aggregate invalidation and uses the public revalidation interval", async () => { + const result = { guideCount: 2 }; + mocks.query.mockResolvedValue(result); + expect(await getCachedPublicAnalytics()).toBe(result); + expect(mocks.tag).toHaveBeenCalledWith("buzz:analytics"); + expect(mocks.life).toHaveBeenCalledWith({ stale: 15, revalidate: 60, expire: 3600 }); +}); diff --git a/lib/cache/analytics.ts b/lib/cache/analytics.ts new file mode 100644 index 0000000..661e874 --- /dev/null +++ b/lib/cache/analytics.ts @@ -0,0 +1,13 @@ +import "server-only"; + +import { cacheLife, cacheTag } from "next/cache"; + +import { getPublicAnalytics } from "@/lib/analytics/queries"; +import { ANALYTICS_CACHE_TAG } from "@/lib/cache/tags"; + +export async function getCachedPublicAnalytics() { + "use cache: remote"; + cacheTag(ANALYTICS_CACHE_TAG); + cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 }); + return getPublicAnalytics(); +} diff --git a/lib/cache/tags.test.ts b/lib/cache/tags.test.ts index e39ac66..369a8f1 100644 --- a/lib/cache/tags.test.ts +++ b/lib/cache/tags.test.ts @@ -6,14 +6,15 @@ import { GLOSSARY_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG, + ANALYTICS_CACHE_TAG, } from "./tags"; describe("public cache tags", () => { it("maps outbox events to dependency-aware tags", () => { - expect(cacheTagsForOutboxEvent({ eventType: "directory.updated", aggregateId: "id" })).toEqual([DIRECTORY_CACHE_TAG]); - expect(cacheTagsForOutboxEvent({ eventType: "page.updated", aggregateId: "page" })).toEqual(["buzz:page:page"]); - expect(cacheTagsForOutboxEvent({ eventType: "stygian.updated", aggregateId: "5269001" })).toEqual([STYGIAN_CACHE_TAG]); - expect(cacheTagsForOutboxEvent({ eventType: "catalog.updated", aggregateId: "catalog" })).toEqual([CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG]); + expect(cacheTagsForOutboxEvent({ eventType: "directory.updated", aggregateId: "id" })).toEqual([DIRECTORY_CACHE_TAG, ANALYTICS_CACHE_TAG]); + expect(cacheTagsForOutboxEvent({ eventType: "page.updated", aggregateId: "page" })).toEqual(["buzz:page:page", ANALYTICS_CACHE_TAG]); + expect(cacheTagsForOutboxEvent({ eventType: "stygian.updated", aggregateId: "5269001" })).toEqual([STYGIAN_CACHE_TAG, ANALYTICS_CACHE_TAG]); + expect(cacheTagsForOutboxEvent({ eventType: "catalog.updated", aggregateId: "catalog" })).toEqual([CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG, ANALYTICS_CACHE_TAG]); expect(cacheTagsForOutboxEvent({ eventType: "glossary.updated", aggregateId: "alias" })).toEqual([GLOSSARY_CACHE_TAG]); expect(cacheTagsForOutboxEvent({ eventType: "admin.updated", aggregateId: "id" })).toEqual([]); }); diff --git a/lib/cache/tags.ts b/lib/cache/tags.ts index 52659b2..09bce69 100644 --- a/lib/cache/tags.ts +++ b/lib/cache/tags.ts @@ -2,6 +2,7 @@ export const CATALOG_CACHE_TAG = "buzz:catalog"; export const DIRECTORY_CACHE_TAG = "buzz:directory"; export const GLOSSARY_CACHE_TAG = "buzz:glossary"; export const STYGIAN_CACHE_TAG = "buzz:stygian"; +export const ANALYTICS_CACHE_TAG = "buzz:analytics"; export function pageCacheTag(pageId: string): string { return `buzz:page:${pageId}`; @@ -13,10 +14,10 @@ export type OutboxCacheEvent = { }; export function cacheTagsForOutboxEvent(event: OutboxCacheEvent): string[] { - if (event.eventType === "catalog.updated") return [CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG]; + if (event.eventType === "catalog.updated") return [CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG, ANALYTICS_CACHE_TAG]; if (event.eventType === "glossary.updated") return [GLOSSARY_CACHE_TAG]; - if (event.eventType === "directory.updated") return [DIRECTORY_CACHE_TAG]; - if (event.eventType === "page.updated") return [pageCacheTag(event.aggregateId)]; - if (event.eventType === "stygian.updated") return [STYGIAN_CACHE_TAG]; + if (event.eventType === "directory.updated") return [DIRECTORY_CACHE_TAG, ANALYTICS_CACHE_TAG]; + if (event.eventType === "page.updated") return [pageCacheTag(event.aggregateId), ANALYTICS_CACHE_TAG]; + if (event.eventType === "stygian.updated") return [STYGIAN_CACHE_TAG, ANALYTICS_CACHE_TAG]; return []; } diff --git a/lib/catalog/rarity.ts b/lib/catalog/rarity.ts index ccbfcb4..706e00e 100644 --- a/lib/catalog/rarity.ts +++ b/lib/catalog/rarity.ts @@ -23,7 +23,9 @@ const rarityTextClasses: Record = { 5: "text-[#A67B34]", }; -const rarityTextColors: Record = { +const rarityColors: Record = { + 1: "#697177", + 2: "#5D8972", 3: "#4D7691", 4: "#6D6A9C", 5: "#A67B34", @@ -46,5 +48,9 @@ export function rarityTextClass(rarity: number | null | undefined): string | und } export function rarityTextColor(rarity: number | null | undefined): string { - return (rarity && rarityTextColors[rarity]) || "#17171c"; + return (rarity && rarity >= 3 && rarityColors[rarity]) || "#17171c"; +} + +export function rarityColor(rarity: number | null | undefined): string { + return (rarity && rarityColors[rarity]) || "var(--muted-foreground)"; } diff --git a/lib/image-generator-empty.test.tsx b/lib/image-generator-empty.test.tsx index 7e0ce9c..887493e 100644 --- a/lib/image-generator-empty.test.tsx +++ b/lib/image-generator-empty.test.tsx @@ -24,7 +24,7 @@ describe("admin image generator empty states", () => { expect(html).not.toContain("weapon-image-condition"); }); - it("starts Constellation with one action that creates C1–C6", () => { + it("starts Constellation with one action that creates C1-C6", () => { const html = renderToStaticMarkup( , ); diff --git a/lib/outbox/processor.test.ts b/lib/outbox/processor.test.ts index 66b772b..c24bf25 100644 --- a/lib/outbox/processor.test.ts +++ b/lib/outbox/processor.test.ts @@ -32,7 +32,7 @@ describe("transactional outbox processor", () => { expect(calls).toEqual(["invalidate", "publish"]); expect(transport.invalidate).toHaveBeenCalledWith( - ["buzz:page:72df08ab-50dd-4cbd-9a69-70949d34cf9f"], + ["buzz:page:72df08ab-50dd-4cbd-9a69-70949d34cf9f", "buzz:analytics"], 1234, ); expect(transport.publish).toHaveBeenCalledWith( diff --git a/tests/analytics.integration.test.ts b/tests/analytics.integration.test.ts new file mode 100644 index 0000000..6841339 --- /dev/null +++ b/tests/analytics.integration.test.ts @@ -0,0 +1,177 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const { closeDb, getDb } = await import("@/db"); +const schema = await import("@/db/schema"); +const { getPublicAnalytics } = await import("@/lib/analytics/queries"); +const { selectAnalytics } = await import("@/lib/analytics/model"); + +const databaseUrl = process.env.DATABASE_INTEGRATION_URL; +const describeWithDatabase = databaseUrl ? describe : describe.skip; + +describeWithDatabase("public analytics aggregation", () => { + const prefix = randomUUID(); + const guideIds = Array.from({ length: 4 }, () => randomUUID()); + const mediaId = randomUUID(); + const characterKey = `char-${prefix}`; + const missingKey = `missing-${prefix}`; + const weaponKey = `weapon-${prefix}`; + const artifactKey = `artifact-${prefix}`; + const mixedKey = `mixed-${prefix}`; + const scheduleIds = [2_000_000_000 + Math.floor(Math.random() * 100_000), 2_000_200_000 + Math.floor(Math.random() * 100_000)]; + const originalDatabaseUrl = process.env.DATABASE_URL; + const originalPublicUrl = process.env.S3_PUBLIC_URL; + let baseline: Awaited>; + + beforeAll(async () => { + process.env.DATABASE_URL = databaseUrl; + process.env.S3_PUBLIC_URL = "https://media.example.test"; + const db = getDb(); + baseline = await getPublicAnalytics(); + await db.insert(schema.catalogCharacters).values({ key: characterKey, name: "Test Character", imageKey: "character.png", rarity: 5 }); + await db.insert(schema.catalogWeapons).values({ key: weaponKey, name: "Test Weapon", imageKey: "weapon.png", rarity: 3 }); + await db.insert(schema.catalogArtifacts).values([ + { key: artifactKey, name: "Test Artifact", imageKey: "artifact.png", rarity: 4 }, + { key: mixedKey, name: "Mixed Artifact", imageKey: "mixed.png" }, + ]); + await db.insert(schema.media).values({ id: mediaId, objectKey: `test/${prefix}.png`, fileName: "test.png", mimeType: "image/png", byteSize: 1, status: "ready" }); + + for (const [index, id] of guideIds.entries()) { + // Public, hidden, trashed, and public with disabled sections. + await db.insert(schema.guides).values({ + id, characterKey: `${prefix}-${index}`, slug: `${prefix}-${index}`, name: "Test Guide", overview: "", + coverMediaId: mediaId, isPublic: index !== 1, trashedAt: index === 2 ? new Date() : null, + }); + await db.insert(schema.guideSections).values(["team", "weapon", "artifact"].map((kind) => ({ + guideId: id, kind, slug: kind, title: kind, enabled: index !== 3, + }))); + // A second enabled section of the same kind must not multiply counts. + if (index === 0) await db.insert(schema.guideSections).values({ guideId: id, kind: "team", slug: "other-team", title: "Other team" }); + const teamCount = index === 0 ? 2 : 1; + for (let variant = 0; variant < teamCount; variant++) { + const [team] = await db.insert(schema.teamCompositions).values({ guideId: id, title: "Test", combo: "", rotationSeconds: "20" }).returning(); + await db.insert(schema.teamMembers).values({ + teamId: team.id, position: 0, characterKey, weaponKey, artifactKey, + secondArtifactKey: variant === 0 ? mixedKey : artifactKey, damage: "1", + }); + } + const [weaponCondition] = await db.insert(schema.weaponConditions).values({ guideId: id }).returning(); + await db.insert(schema.weaponRecommendations).values(["1", "5"].map((refinement) => ({ conditionId: weaponCondition.id, weaponKey, refinement }))); + const [artifactCondition] = await db.insert(schema.artifactConditions).values({ guideId: id }).returning(); + const [artifactGroup] = await db.insert(schema.artifactGroups).values({ conditionId: artifactCondition.id, name: "Test" }).returning(); + await db.insert(schema.artifactRecommendations).values([mixedKey, artifactKey].map((secondArtifactKey) => ({ + groupId: artifactGroup.id, firstArtifactKey: artifactKey, secondArtifactKey, percent: "100", + }))); + } + + for (const [index, scheduleId] of scheduleIds.entries()) { + await db.insert(schema.stygianSchedules).values({ + scheduleId, challengeName: "Test", scheduleStartTime: new Date(index ? "2099-01-01" : "2020-01-01"), + scheduleEndTime: new Date(index ? "2099-02-01" : "2020-02-01"), + }); + const [team] = await db.insert(schema.stygianRecommendationGroups).values({ scheduleId, level: index ? 110 : 105, bossPosition: index, kind: "team" }).returning(); + await db.insert(schema.stygianRecommendationItems).values([ + { groupId: team.id, position: index ? 1 : 0, kind: "character", catalogKey: characterKey, weaponKey, artifactKey }, + { groupId: team.id, position: index ? 0 : 1, kind: "character", catalogKey: missingKey }, + ]); + if (index === 0) { + for (const kind of ["weapon", "artifact", "character", "element"]) { + const [unit] = await db.insert(schema.stygianRecommendationGroups).values({ scheduleId, level: 105, bossPosition: 0, kind: "unit" }).returning(); + await db.insert(schema.stygianRecommendationItems).values({ + groupId: unit.id, position: 0, kind, + catalogKey: kind === "weapon" ? weaponKey : kind === "artifact" ? artifactKey : kind === "character" ? characterKey : null, + weaponKey: kind === "character" ? weaponKey : null, + artifactKey: kind === "character" ? artifactKey : null, + element: kind === "element" ? "Pyro" : null, + }); + } + } + } + }, 30_000); + + afterAll(async () => { + const db = getDb(); + await db.delete(schema.stygianSchedules).where(inArray(schema.stygianSchedules.scheduleId, scheduleIds)); + await db.delete(schema.guides).where(inArray(schema.guides.id, guideIds)); + await db.delete(schema.media).where(eq(schema.media.id, mediaId)); + await db.delete(schema.catalogCharacters).where(eq(schema.catalogCharacters.key, characterKey)); + await db.delete(schema.catalogWeapons).where(eq(schema.catalogWeapons.key, weaponKey)); + await db.delete(schema.catalogArtifacts).where(inArray(schema.catalogArtifacts.key, [artifactKey, mixedKey])); + await closeDb(); + if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalDatabaseUrl; + if (originalPublicUrl === undefined) delete process.env.S3_PUBLIC_URL; + else process.env.S3_PUBLIC_URL = originalPublicUrl; + }); + + it("counts variants, mixed sets, and both Stygian versions while excluding nonpublic content", async () => { + const data = await getPublicAnalytics(); + expect(data.guideCount).toBe(baseline.guideCount + 2); + expect(data.guideTeamCount).toBe(baseline.guideTeamCount + 2); + expect(data.stygianScheduleCount).toBe(baseline.stygianScheduleCount + 2); + expect(data.stygianTeamCount).toBe(baseline.stygianTeamCount + 2); + expect(data.team.character.find(({ key }) => key === characterKey)).toMatchObject({ guideCount: 2, stygianCount: 2, rarity: 5 }); + expect(data.team.weapon.find(({ key }) => key === weaponKey)).toMatchObject({ guideCount: 2, stygianCount: 2, rarity: 3 }); + expect(data.team.artifact.find(({ key }) => key === artifactKey)).toMatchObject({ guideCount: 2, stygianCount: 2, rarity: 4 }); + expect(data.team.artifact.find(({ key }) => key === mixedKey)).toMatchObject({ guideCount: 1, stygianCount: 0 }); + }); + + it("keeps standalone recommendations separate from teams and ignores elements and null equipment", async () => { + const data = await getPublicAnalytics(); + expect(data.recommendation.weapon.find(({ key }) => key === weaponKey)).toMatchObject({ guideCount: 2, stygianCount: 2 }); + expect(data.recommendation.artifact.find(({ key }) => key === artifactKey)).toMatchObject({ guideCount: 2, stygianCount: 2 }); + expect(data.recommendation.artifact.find(({ key }) => key === mixedKey)).toMatchObject({ guideCount: 1, stygianCount: 0 }); + expect(data.team.character.find(({ key }) => key === missingKey)).toMatchObject({ name: missingKey, imageUrl: null, rarity: null, guideCount: 0, stygianCount: 2 }); + const excluded = selectAnalytics(data, false); + expect(excluded.team.character.some(({ key }) => key === missingKey)).toBe(false); + expect(excluded.recommendation.weapon.find(({ key }) => key === weaponKey)?.count).toBe(2); + expect(data.team.weapon.some(({ key }) => !key)).toBe(false); + }); + + it("reflects visibility and section changes on the next aggregate read", async () => { + const db = getDb(); + try { + await db.update(schema.guides).set({ isPublic: false }).where(eq(schema.guides.id, guideIds[0])); + const hidden = await getPublicAnalytics(); + expect(hidden.team.character.find(({ key }) => key === characterKey)?.guideCount).toBe(0); + expect(hidden.recommendation.weapon.find(({ key }) => key === weaponKey)?.guideCount).toBe(0); + await db.update(schema.guides).set({ isPublic: true }).where(eq(schema.guides.id, guideIds[0])); + await db.update(schema.guideSections).set({ enabled: false }).where(eq(schema.guideSections.guideId, guideIds[0])); + const disabled = await getPublicAnalytics(); + expect(disabled.guideTeamCount).toBe(baseline.guideTeamCount); + expect(disabled.recommendation.artifact.find(({ key }) => key === artifactKey)?.guideCount).toBe(0); + } finally { + await db.update(schema.guides).set({ isPublic: true }).where(eq(schema.guides.id, guideIds[0])); + await db.update(schema.guideSections).set({ enabled: true }).where(eq(schema.guideSections.guideId, guideIds[0])); + } + }); + + it("deduplicates identical builds across guides and Stygian after guide exclusions, preserving build variants", async () => { + const db = getDb(); + const section = and(eq(schema.guideSections.guideId, guideIds[3]), eq(schema.guideSections.kind, "team")); + try { + await db.update(schema.guideSections).set({ enabled: true }).where(section); + const data = await getPublicAnalytics(); + const unique = selectAnalytics(data, true, new Set(), true); + expect(unique.teamCount).toBe(selectAnalytics(baseline, true, new Set(), true).teamCount + 3); + expect(unique.team.character.find(({ key }) => key === characterKey)?.count).toBe(3); + const excludeOriginal = selectAnalytics(data, true, new Set([`guide:${guideIds[0]}`]), true); + expect(excludeOriginal.team.character.find(({ key }) => key === characterKey)?.count).toBe(2); + expect(excludeOriginal.recommendation.weapon.find(({ key }) => key === weaponKey)?.count).toBe(2); + const guideOnly = selectAnalytics(data, false, new Set([`guide:${guideIds[0]}`]), true); + expect(guideOnly.team.character.find(({ key }) => key === characterKey)?.count).toBe(1); + const excludedSchedule = selectAnalytics(data, true, new Set([`stygian:${scheduleIds[0]}`]), true); + expect(excludedSchedule.team.character.find(({ key }) => key === characterKey)?.count).toBe(3); + expect(excludedSchedule.recommendation.weapon.find(({ key }) => key === weaponKey)?.count).toBe(2); + const copyTeams = await db.select({ id: schema.teamCompositions.id }).from(schema.teamCompositions).where(eq(schema.teamCompositions.guideId, guideIds[3])); + await db.update(schema.teamMembers).set({ constellation: "1" }).where(inArray(schema.teamMembers.teamId, copyTeams.map(({ id }) => id))); + const changedBuild = selectAnalytics(await getPublicAnalytics(), true, new Set(), true); + expect(changedBuild.team.character.find(({ key }) => key === characterKey)?.count).toBe(4); + } finally { + await db.update(schema.guideSections).set({ enabled: false }).where(section); + } + }); +});