feat : add analytics page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 <AnalyticsDashboard data={await getCachedPublicAnalytics()} />;
|
||||
}
|
||||
|
||||
function AnalyticsSkeleton() {
|
||||
return <div aria-label="กำลังโหลดข้อมูลวิเคราะห์" className="flex flex-col gap-6">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }, (_, index) => <Skeleton key={index} className="h-28" />)}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, index) => <Skeleton key={index} className="h-96" />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return <div className="flex min-h-svh flex-col bg-background">
|
||||
<SiteHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<header className="flex flex-col gap-3">
|
||||
<h1 className="font-heading text-4xl font-semibold">วิเคราะห์ข้อมูล</h1>
|
||||
<p className="text-lg text-muted-foreground">ส่องภาพรวมตัวละคร อาวุธ และอาร์ติแฟกต์ที่ใช้และแนะนำบ่อยที่สุดในไกด์ทั้งหมด</p>
|
||||
</header>
|
||||
<Suspense fallback={<AnalyticsSkeleton />}><AnalyticsContent /></Suspense>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
return [
|
||||
{ url: siteUrl },
|
||||
{ url: `${siteUrl}/stygian` },
|
||||
{ url: `${siteUrl}/analytics` },
|
||||
...guides.map((guide) => ({
|
||||
url: `${siteUrl}/${encodeURIComponent(guide.slug)}`,
|
||||
})),
|
||||
|
||||
@@ -101,7 +101,7 @@ export function ConstellationImageGenerator({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูล Constellation</CardTitle>
|
||||
<CardDescription>กรอกดาเมจสะสม C1–C6 เทียบกับ C0 ที่ 100%</CardDescription>
|
||||
<CardDescription>กรอกดาเมจสะสม C1-C6 เทียบกับ C0 ที่ 100%</CardDescription>
|
||||
<CardAction>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={resetDraft}>
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
@@ -115,7 +115,7 @@ export function ConstellationImageGenerator({
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><PlusIcon /></EmptyMedia>
|
||||
<EmptyTitle>ยังไม่มี Constellation</EmptyTitle>
|
||||
<EmptyDescription>สร้างรายการ C1–C6 เพื่อเริ่มกรอกเปอร์เซ็นต์ดาเมจ</EmptyDescription>
|
||||
<EmptyDescription>สร้างรายการ C1-C6 เพื่อเริ่มกรอกเปอร์เซ็นต์ดาเมจ</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button type="button" onClick={() => setDraft((current) => ({ ...current, rows: constellationRows() }))}>
|
||||
|
||||
@@ -173,7 +173,7 @@ export function TalentLevelPicker({
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder="เลือกระดับ 1–10"
|
||||
placeholder="เลือกระดับ 1-10"
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function TeamImageGenerator({ characters, weapons, artifacts }: { charact
|
||||
|
||||
return <div className="grid items-start gap-6 xl:grid-cols-[minmax(24rem,1fr)_minmax(28rem,1fr)]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>ข้อมูล Team</CardTitle><CardDescription>รองรับตัวละคร 1–4 คน และคำนวณ DPS อัตโนมัติ</CardDescription><CardAction><Button type="button" variant="ghost" size="sm" onClick={resetDraft}><RotateCcwIcon data-icon="inline-start" />Reset</Button></CardAction></CardHeader>
|
||||
<CardHeader><CardTitle>ข้อมูล Team</CardTitle><CardDescription>รองรับตัวละคร 1-4 คน และคำนวณ DPS อัตโนมัติ</CardDescription><CardAction><Button type="button" variant="ghost" size="sm" onClick={resetDraft}><RotateCcwIcon data-icon="inline-start" />Reset</Button></CardAction></CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{!draft.members.length ? <Empty className="border"><EmptyHeader><EmptyMedia variant="icon"><PlusIcon /></EmptyMedia><EmptyTitle>ยังไม่มี Team</EmptyTitle><EmptyDescription>เพิ่มตัวละครคนแรกเพื่อเริ่มกรอกข้อมูล Team</EmptyDescription></EmptyHeader><EmptyContent><Button type="button" onClick={() => setDraft((current) => ({ ...current, members: [emptyMember()] }))}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button></EmptyContent></Empty> : <>
|
||||
<FieldGroup>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CommissionIntro() {
|
||||
<div>
|
||||
<h2 id="commission-how-it-works" className="font-heading text-2xl font-semibold">ขอคำนวณแบบที่ต้องการ</h2>
|
||||
<p className="mt-2 text-muted-foreground">เลือกได้ทั้งจัดทีม เปรียบเทียบอาวุธ และเปรียบเทียบกลุ่มดาว โดยเห็นยอดก่อนชำระเงิน</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">ระยะเวลาจัดทำ 1-7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{steps.map((step) => <Card key={step.title} size="sm">
|
||||
|
||||
@@ -142,7 +142,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
|
||||
return <FieldSet ref={root} disabled={busy || !hydrated} className="flex flex-col gap-6">
|
||||
<FieldLegend className="sr-only">คำขอคำนวณทีม อาวุธ และกลุ่มดาว</FieldLegend>
|
||||
<p className="text-sm text-muted-foreground" role="status">{draftNotice}</p>
|
||||
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
|
||||
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1-7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
|
||||
{attempted && issues.some((issue) => !issue.path) && <Alert variant="destructive" role="alert"><AlertDescription>เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ</AlertDescription></Alert>}
|
||||
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว หากกำหนดค่าสเตตัสเองให้ตัวละครใด ราคาทั้งคำขอจะเป็น 2 เท่าของยอดรวมปกติ</CardDescription></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
|
||||
@@ -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(<AnalyticsDashboard data={data} />));
|
||||
const toggle = container.querySelector<HTMLElement>('[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<HTMLDetailsElement>("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(<AnalyticsDashboard data={{ ...data, teamGroups: [...data.teamGroups, { guideId: "g1", signature: "new-team", count: 1 }] }} />));
|
||||
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(<AnalyticsDashboard data={{
|
||||
...data, team: { character: [], weapon: [], artifact: [] }, recommendation: { weapon: [], artifact: [] },
|
||||
}} />));
|
||||
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(<AnalyticsDashboard data={data} />));
|
||||
const guide = container.querySelector<HTMLButtonElement>('[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<HTMLElement>('[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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
return <g transform={`translate(${x},${y})`}>
|
||||
<title>{item.name} · {rarityLabel(item.rarity)}</title>
|
||||
{item.imageUrl && failedSource !== item.imageUrl ?
|
||||
<image href={item.imageUrl} x={-32} y={-16} width={32} height={32} preserveAspectRatio="xMidYMid meet"
|
||||
onError={() => setFailedSource(item.imageUrl)} /> :
|
||||
<text x={-16} y={0} textAnchor="middle" dominantBaseline="central" className="fill-foreground">{item.name.slice(0, 1)}</text>}
|
||||
</g>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle><h3>{title}</h3></CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{items.length ? <>
|
||||
<ul aria-label="สีตามระดับดาว" className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{rarities.map((rarity) => <li key={rarity ?? "unknown"} className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true" className="size-2.5 rounded-sm" style={{ backgroundColor: rarityColor(rarity) }} />
|
||||
{rarityLabel(rarity)}
|
||||
</li>)}
|
||||
</ul>
|
||||
<ChartContainer config={chartConfig} className="w-full aspect-auto" style={{ height: Math.max(150, top.length * 36 + 30) }} aria-label={`${title} 10 อันดับแรก`}>
|
||||
<BarChart accessibilityLayer data={top} layout="vertical" margin={{ left: 0, right: 36, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} axisLine={false} tickLine={false} tickFormatter={formatCount} />
|
||||
<YAxis dataKey="key" type="category" width={46} axisLine={false} tickLine={false}
|
||||
tick={({ x, y, payload }) => {
|
||||
const item = top.find((entry) => entry.key === payload.value);
|
||||
return item ? <ItemImageTick x={Number(x)} y={Number(y)} item={item} /> : <g />;
|
||||
}} />
|
||||
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_label, payload) => {
|
||||
const item = payload[0]?.payload;
|
||||
return item ? `${item.name} · ${rarityLabel(item.rarity)}` : "";
|
||||
}} />} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={4} isAnimationActive={false}>
|
||||
<LabelList dataKey="count" position="right" className="fill-foreground" formatter={(value) => formatCount(Number(value))} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<details data-ranking className="group">
|
||||
<summary className="cursor-pointer rounded-md py-2 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
ดูอันดับทั้งหมด ({formatCount(items.length)} รายการ)
|
||||
</summary>
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead className="w-12">อันดับ</TableHead>
|
||||
<TableHead>ชื่อ</TableHead>
|
||||
<TableHead className="text-right">ครั้ง</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{items.map((item, index) => <TableRow key={item.key}>
|
||||
<TableCell>{formatCount(index + 1)}</TableCell>
|
||||
<TableCell className="whitespace-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar>
|
||||
{item.imageUrl ? <AvatarImage src={item.imageUrl} alt="" loading="lazy" /> : null}
|
||||
<AvatarFallback>{item.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex min-w-0 flex-col gap-0.5 break-words">
|
||||
<span>{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{rarityLabel(item.rarity)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatCount(item.count)}</TableCell>
|
||||
</TableRow>)}</TableBody>
|
||||
</Table>
|
||||
</details>
|
||||
</> : <Empty>
|
||||
<EmptyHeader><EmptyTitle>ยังไม่มีข้อมูล</EmptyTitle><EmptyDescription>ยังไม่มีรายการในแหล่งข้อมูลที่เลือก</EmptyDescription></EmptyHeader>
|
||||
</Empty>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalyticsDashboard({ data }: { data: PublicAnalytics }) {
|
||||
const [includeStygian, setIncludeStygian] = useState(true);
|
||||
const [excludedGuideIds, setExcludedGuideIds] = useState<Set<string>>(() => 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 <div className="flex flex-col gap-8">
|
||||
<Field orientation="horizontal" className="max-w-xl">
|
||||
<Switch id="include-stygian" checked={includeStygian} onCheckedChange={setIncludeStygian} aria-labelledby="include-stygian-label" aria-describedby="stygian-description" />
|
||||
<FieldContent>
|
||||
<FieldLabel id="include-stygian-label" htmlFor="include-stygian">รวมข้อมูล Stygian</FieldLabel>
|
||||
<FieldDescription id="stygian-description">รวมทุกเวอร์ชัน ระดับบอส และทีมที่แสดงในไกด์ Stygian</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal" className="max-w-xl">
|
||||
<Switch id="ignore-repeated-teams" checked={ignoreRepeatedTeams} onCheckedChange={setIgnoreRepeatedTeams}
|
||||
aria-labelledby="ignore-repeated-teams-label" aria-describedby="repeated-teams-description" />
|
||||
<FieldContent>
|
||||
<FieldLabel id="ignore-repeated-teams-label" htmlFor="ignore-repeated-teams">นับทีมซ้ำเพียงครั้งเดียว</FieldLabel>
|
||||
<FieldDescription id="repeated-teams-description">ทีมที่มีตัวละคร อาวุธ เซ็ตอาร์ติแฟกต์ กลุ่มดาว และขัดเกลาเหมือนกัน จะนับครั้งเดียว ไม่ขึ้นกับลำดับสมาชิกหรือไกด์ที่บันทึก</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<details className="rounded-xl border p-4">
|
||||
<summary className="cursor-pointer rounded-md font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
เลือกไกด์ที่นำมานับ ({formatCount(selected.guideCount + selected.stygianScheduleCount)} ไกด์)
|
||||
</summary>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">กดปุ่มไกด์เพื่อไม่นับข้อมูล กดอีกครั้งเพื่อรวมกลับ</p>
|
||||
<Button variant="outline" size="sm" disabled={!excludedGuideIds.size} onClick={() => setExcludedGuideIds(new Set())}>รวมทุกไกด์</Button>
|
||||
</div>
|
||||
{(["guide", "stygian"] as const).map((source) => <div key={source} className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">{source === "guide" ? "ไกด์ตัวละคร" : "เวอร์ชัน Stygian"}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.guides.filter((guide) => guide.source === source).map((guide) => {
|
||||
const included = !excludedGuideIds.has(guide.id);
|
||||
return <Button key={guide.id} variant={included ? "secondary" : "outline"} size="sm"
|
||||
className="h-auto min-h-11 max-w-full whitespace-normal text-left"
|
||||
aria-label={`นับไกด์ ${guide.name}`} aria-pressed={included} disabled={source === "stygian" && !includeStygian}
|
||||
onClick={() => toggleGuide(guide.id)}>
|
||||
{included ? <CheckIcon data-icon="inline-start" aria-hidden="true" /> : <MinusIcon data-icon="inline-start" aria-hidden="true" />}
|
||||
{guide.name}
|
||||
</Button>;
|
||||
})}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</details>
|
||||
<section aria-label="ภาพรวมข้อมูล" className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{summaries.map(({ label, count }) => <Card key={label} size="sm">
|
||||
<CardHeader><CardDescription>{label}</CardDescription></CardHeader>
|
||||
<CardContent><p className="font-heading text-3xl font-semibold tabular-nums">{formatCount(count)}</p></CardContent>
|
||||
</Card>)}
|
||||
</section>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
นับจากข้อมูลที่บันทึกในไกด์สาธารณะ ไม่ใช่สถิติการเล่นของผู้เล่น
|
||||
แต่ละรายการแนะนำถูกนับแยกกัน รวมตัวเลือกซ้ำ
|
||||
{ignoreRepeatedTeams ? " ทีมที่มีตัวละครและบิลด์เหมือนกันจะนับเพียงครั้งเดียวจากไกด์ที่เลือก" : " แต่ละสมาชิกทีมถูกนับแยกกัน รวมทีมที่คัดลอกข้ามไกด์หรือเวอร์ชัน"}
|
||||
อาร์ติแฟกต์แบบผสมจะนับทั้งสองเซ็ต เซ็ตเดียวกันในรายการเดียวจะนับครั้งเดียว
|
||||
ไกด์ที่ซ่อน อยู่ในถังขยะ และส่วนที่ปิดการแสดงผลจะไม่ถูกนับ
|
||||
</p>
|
||||
<section aria-labelledby="team-usage-title" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="team-usage-title" className="font-heading text-2xl font-semibold">ใช้ในทีมบ่อยที่สุด</h2>
|
||||
<p className="text-sm text-muted-foreground">นับจำนวนครั้งที่ตัวละครและอุปกรณ์ปรากฏในสมาชิกทีม</p>
|
||||
</div>
|
||||
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<UsageRanking title="ตัวละคร" description="ตัวละครที่ปรากฏในทีมบ่อยที่สุด" items={selected.team.character} />
|
||||
<UsageRanking title="อาวุธ" description="อาวุธที่สมาชิกทีมใช้บ่อยที่สุด" items={selected.team.weapon} />
|
||||
<UsageRanking title="อาร์ติแฟกต์" description="เซ็ตอาร์ติแฟกต์ที่สมาชิกทีมใช้บ่อยที่สุด" items={selected.team.artifact} />
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="recommendation-title" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="recommendation-title" className="font-heading text-2xl font-semibold">แนะนำบ่อยที่สุด</h2>
|
||||
<p className="text-sm text-muted-foreground">นับรายการแนะนำอาวุธและอาร์ติแฟกต์ แยกจากอุปกรณ์ที่ใช้ในทีม{includeStygian ? " รวมอุปกรณ์ของตัวละครแนะนำใน Stygian" : ""}</p>
|
||||
</div>
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<UsageRanking title="อาวุธแนะนำ" description="อาวุธที่ปรากฏในรายการแนะนำบ่อยที่สุด" items={selected.recommendation.weapon} />
|
||||
<UsageRanking title="อาร์ติแฟกต์แนะนำ" description="เซ็ตอาร์ติแฟกต์ที่ปรากฏในรายการแนะนำบ่อยที่สุด" items={selected.recommendation.artifact} />
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -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 <>
|
||||
<nav aria-label="เมนูหลัก" className="hidden items-center gap-1 lg:flex">
|
||||
{destinations.slice(1).map(({ href, label, icon }) =>
|
||||
<Button key={href} variant="ghost" size="sm" nativeButton={false}
|
||||
render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined} />}>
|
||||
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : null}{label}
|
||||
</Button>)}
|
||||
<Link key={href} href={href} aria-current={isCurrent(href) ? "page" : undefined}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}>
|
||||
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : <ChartNoAxesCombinedIcon data-icon="inline-start" aria-hidden="true" />}{label}
|
||||
</Link>)}
|
||||
</nav>
|
||||
<div className="lg:hidden">
|
||||
<Drawer>
|
||||
@@ -48,7 +49,7 @@ export function PublicNavigation() {
|
||||
{destinations.map(({ href, label, icon }) =>
|
||||
<DrawerClose key={href} render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined}
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />}>
|
||||
{icon ? <Image src={icon} alt="" width={20} height={20} className="size-5 object-contain" /> : <BookOpenIcon aria-hidden="true" />}{label}
|
||||
{icon ? <Image src={icon} alt="" width={20} height={20} className="size-5 object-contain" /> : href === "/analytics" ? <ChartNoAxesCombinedIcon aria-hidden="true" /> : <BookOpenIcon aria-hidden="true" />}{label}
|
||||
{isCurrent(href) ? <span className="ml-auto text-xs text-primary">ปัจจุบัน</span> : null}
|
||||
</DrawerClose>)}
|
||||
</nav>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<AnalyticsKind, AnalyticsItem[]>;
|
||||
recommendation: Record<"weapon" | "artifact", AnalyticsItem[]>;
|
||||
}
|
||||
|
||||
export interface RankedAnalyticsItem extends AnalyticsItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
type AnalyticsSelection = { guideIds: Set<string>; teamDivisors: Map<string, number> };
|
||||
|
||||
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<string> = 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<string>();
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<PublicAnalytics> {
|
||||
const rows = await getDb().execute<AggregateRow>(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<string, AnalyticsItem>();
|
||||
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;
|
||||
}
|
||||
Vendored
+17
@@ -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 });
|
||||
});
|
||||
Vendored
+13
@@ -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();
|
||||
}
|
||||
Vendored
+5
-4
@@ -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([]);
|
||||
});
|
||||
|
||||
Vendored
+5
-4
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ const rarityTextClasses: Record<number, string> = {
|
||||
5: "text-[#A67B34]",
|
||||
};
|
||||
|
||||
const rarityTextColors: Record<number, string> = {
|
||||
const rarityColors: Record<number, string> = {
|
||||
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)";
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<ConstellationImageGenerator characters={[]} />,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ReturnType<typeof getPublicAnalytics>>;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user