TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { useAdminQuery } from "@/components/admin/use-query";5import type { PlayerAnalyticsResponse } from "@/components/admin/types";6import { PageHeader, RefreshButton, Panel, ErrorState, TileSkeleton, ChartSkeleton, StatGrid, StatTile, SegmentedControl } from "@/components/admin/primitives";7import { Bars, ChartFrame, CHART, Histogram } from "@/components/admin/charts";8import { compact, int, isoDay, num, pct, shortDate } from "@/components/admin/format";9import { cn } from "@/lib/utils";1011const DAYS = [7, 30, 90] as const;1213function dayRange(days: number, anchor: number): string[] {14 return Array.from({ length: days }, (_, i) => new Date(anchor - (days - 1 - i) * 86400_000).toISOString().slice(0, 10));15}1617export default function AdminPlayerAnalyticsPage() {18 const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);19 const q = useAdminQuery<PlayerAnalyticsResponse>(`/api/admin/analytics/players?days=${days}`);20 const d = q.data;2122 const anchor = q.updatedAt;23 const series = React.useMemo(() => {24 const signups = new Map((d?.signups ?? []).map((r) => [isoDay(r.day), num(r.n)]));25 const act = new Map((d?.activity ?? []).map((r) => [isoDay(r.day), r]));26 return dayRange(days, anchor).map((day) => ({ day, signups: signups.get(day) ?? 0, players: num(act.get(day)?.players ?? 0), spins: num(act.get(day)?.spins ?? 0) }));27 }, [d, days, anchor]);2829 const levels = React.useMemo(() => {30 const m = new Map((d?.levels ?? []).map((r) => [num(r.bucket), num(r.n)]));31 return Array.from({ length: 10 }, (_, i) => {32 const b = i + 1;33 const lo = 1 + (b - 1) * 10;34 const hi = b * 10;35 return { bucket: b, label: `${lo}–${hi}`, n: (m.get(b) ?? 0) + (b === 10 ? (m.get(11) ?? 0) : 0) };36 });37 }, [d]);3839 const totalSignups = series.reduce((a, r) => a + r.signups, 0);40 const totalSpins = series.reduce((a, r) => a + r.spins, 0);41 const peakPlayers = series.reduce((a, r) => Math.max(a, r.players), 0);42 const barSize = days > 30 ? 6 : days > 7 ? 12 : 24;4344 return (45 <>46 <PageHeader47 title="Player Analytics"48 description="Acquisition, activity, level progression and simple recency retention."49 actions={50 <>51 <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />52 <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />53 </>54 }55 />5657 {q.error && !d ? (58 <ErrorState error={q.error} onRetry={() => void q.refresh()} />59 ) : !d ? (60 <div className="space-y-4">61 <TileSkeleton count={5} cols={5} />62 <Panel>63 <ChartSkeleton />64 </Panel>65 </div>66 ) : (67 <div className={cn("space-y-4", q.stale && "opacity-70")}>68 <StatGrid cols={5}>69 <StatTile label={`Signups · ${days}d`} value={int(totalSignups)} sub={`${(totalSignups / days).toFixed(1)} per day`} />70 <StatTile label={`Spins · ${days}d`} value={compact(totalSpins)} sub={int(totalSpins)} />71 <StatTile label="Peak daily players" value={int(peakPlayers)} />72 <StatTile label="Retention d1 / d7" value={<span>{pct(d.retention.total ? d.retention.d1 / d.retention.total : null, 1)} <span className="text-fg-4">/</span> {pct(d.retention.total ? d.retention.d7 / d.retention.total : null, 1)}</span>} sub={`${int(d.retention.d1)} / ${int(d.retention.d7)} of ${int(d.retention.total)} signed in`} tone="accent" />73 <StatTile label="Total accounts" value={int(d.retention.total)} />74 </StatGrid>7576 <div className="grid gap-4 xl:grid-cols-3">77 <Panel title="Signups per day">78 <ChartFrame height={200}>79 <Bars data={series} x="day" series={[{ key: "signups", label: "Signups", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={barSize} yFormat={(v) => int(v)} />80 </ChartFrame>81 </Panel>82 <Panel title="Active players per day" description="Distinct players with at least one round">83 <ChartFrame height={200}>84 <Bars data={series} x="day" series={[{ key: "players", label: "Players", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={barSize} yFormat={(v) => int(v)} />85 </ChartFrame>86 </Panel>87 <Panel title="Spins per day">88 <ChartFrame height={200}>89 <Bars data={series} x="day" series={[{ key: "spins", label: "Spins", color: CHART.grey }]} xFormat={(v) => shortDate(String(v))} barSize={barSize} yFormat={(v) => compact(v)} />90 </ChartFrame>91 </Panel>92 </div>9394 <Panel title="Level distribution" description="Accounts per 10-level band (all time)">95 <ChartFrame height={200}>96 <Histogram data={levels} x="label" y="n" yFormat={(v) => int(v)} />97 </ChartFrame>98 </Panel>99 </div>100 )}101 </>102 );103}104