SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.0 KB · 104 lines tsx
Raw Blame History
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