Files
buzz-sheet/lib/analytics/queries.ts
T
gunshiz a3803118ef
CI / Verify (push) Successful in 2m10s
CI / Build immutable images and deploy (push) Successful in 2m33s
feat : add analytics page
2026-10-10 20:19:49 +07:00

152 lines
8.1 KiB
TypeScript

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;
}