Files
buzz-sheet/lib/analytics/model.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

85 lines
3.4 KiB
TypeScript

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