added global route for stat tracking

This commit is contained in:
George Powell
2026-03-14 22:17:33 -04:00
parent 61673a646d
commit 7007df2966
5 changed files with 427 additions and 5 deletions

View File

@@ -4,6 +4,7 @@
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.ico"; import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
onMount(() => { onMount(() => {
// Inject analytics script // Inject analytics script
@@ -31,5 +32,6 @@
<TitleAnimation /> <TitleAnimation />
<div class="font-normal"></div> <div class="font-normal"></div>
</h1> </h1>
<div class="hidden"><ThemeToggle /></div>
{@render children()} {@render children()}
</div> </div>

View File

@@ -8,7 +8,7 @@
import GuessesTable from "$lib/components/GuessesTable.svelte"; import GuessesTable from "$lib/components/GuessesTable.svelte";
import WinScreen from "$lib/components/WinScreen.svelte"; import WinScreen from "$lib/components/WinScreen.svelte";
import Credits from "$lib/components/Credits.svelte"; import Credits from "$lib/components/Credits.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import DevButtons from "$lib/components/DevButtons.svelte"; import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte"; import AuthModal from "$lib/components/AuthModal.svelte";
@@ -336,10 +336,7 @@
</div> </div>
{/if} {/if}
<!-- We will just go with the user's system color theme for now. -->
<div class="flex justify-center hidden mt-4">
<ThemeToggle />
</div>
</div> </div>
{#if isDev} {#if isDev}
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3"> <div class="mt-8 flex flex-col items-stretch md:items-center gap-3">

View File

@@ -0,0 +1,217 @@
import { db } from '$lib/server/db';
import { dailyCompletions, user } from '$lib/server/db/schema';
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
function estDateStr(daysAgo = 0): string {
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
return estNow.toISOString().slice(0, 10);
}
function prevDay(d: string): string {
const dt = new Date(d + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() - 1);
return dt.toISOString().slice(0, 10);
}
export const load: PageServerLoad = async () => {
const todayEst = estDateStr(0);
const yesterdayEst = estDateStr(1);
const sevenDaysAgo = estDateStr(7);
// Three weekly windows for first + second derivative calculations
// Week A: last 7 days (indices 06)
// Week B: 713 days ago (indices 713)
// Week C: 1420 days ago (indices 1420)
const weekAStart = estDateStr(6);
const weekBEnd = estDateStr(7);
const weekBStart = estDateStr(13);
const weekCEnd = estDateStr(14);
const weekCStart = estDateStr(20);
// ── Scalar stats ──────────────────────────────────────────────────────────
const [{ todayCount }] = await db
.select({ todayCount: count() })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const [{ totalCount }] = await db
.select({ totalCount: count() })
.from(dailyCompletions);
const [{ uniquePlayers }] = await db
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions);
const [{ weeklyPlayers }] = await db
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sevenDaysAgo));
const todayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const yesterdayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, yesterdayEst));
const todaySet = new Set(todayPlayers.map((r) => r.id));
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
const [{ avgGuessesRaw }] = await db
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
const [{ registeredUsers }] = await db
.select({ registeredUsers: count() })
.from(user);
const avgCompletionsPerPlayer =
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
// ── 21-day completions per day (covers all three weekly windows) ──────────
const rawPerDay21 = await db
.select({ date: dailyCompletions.date, dayCount: count() })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, weekCStart))
.groupBy(dailyCompletions.date)
.orderBy(asc(dailyCompletions.date));
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
// Build indexed array: index 0 = today, index 20 = 20 days ago
const completionsPerDay: number[] = [];
for (let i = 0; i <= 20; i++) {
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
}
// last14Days for the trend chart (most recent first)
const last14Days: { date: string; count: number }[] = [];
for (let i = 0; i <= 13; i++) {
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
}
// Weekly totals from the indexed array
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
// First derivative: avg daily completions change (week A vs week B)
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
// Second derivative: is velocity itself increasing or decreasing?
const completionsAcceleration =
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
const ninetyDaysAgo = estDateStr(90);
const recentCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, ninetyDaysAgo))
.orderBy(asc(dailyCompletions.date));
// Group dates by user (ascending)
const userDatesMap = new Map<string, string[]>();
for (const row of recentCompletions) {
const arr = userDatesMap.get(row.anonymousId);
if (arr) arr.push(row.date);
else userDatesMap.set(row.anonymousId, [row.date]);
}
// ── Streak distribution ───────────────────────────────────────────────────
const streakDistribution = new Map<number, number>();
for (const dates of userDatesMap.values()) {
const desc = dates.slice().reverse();
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
let streak = 1;
let cur = desc[0];
for (let i = 1; i < desc.length; i++) {
if (desc[i] === prevDay(cur)) {
streak++;
cur = desc[i];
} else {
break;
}
}
if (streak >= 2) {
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
}
}
const streakChart = Array.from(streakDistribution.entries())
.sort((a, b) => a[0] - b[0])
.map(([days, userCount]) => ({ days, count: userCount }));
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
const weekAUsers = new Set<string>();
const weekBUsers = new Set<string>();
const weekCUsers = new Set<string>();
for (const [userId, dates] of userDatesMap) {
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
}
// First derivative: weekly unique users change
const userVelocity = weekAUsers.size - weekBUsers.size;
// Second derivative: is user growth speeding up or slowing down?
const userAcceleration =
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
// ── New players + churn ───────────────────────────────────────────────────
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
// Checking against all-time data (not just the 90-day window) ensures accuracy.
const firstDates = await db
.select({
anonymousId: dailyCompletions.anonymousId,
firstDate: min(dailyCompletions.date)
})
.from(dailyCompletions)
.groupBy(dailyCompletions.anonymousId);
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
// Churned: played in week B but not at all in week A
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
// Net growth = truly new arrivals minus departures
const netGrowth7d = newUsers7d - churned7d;
return {
todayEst,
stats: {
todayCount,
totalCount,
uniquePlayers,
weeklyPlayers,
activeStreaks,
avgGuessesToday,
registeredUsers,
avgCompletionsPerPlayer
},
growth: {
completionsVelocity,
completionsAcceleration,
userVelocity,
userAcceleration,
newUsers7d,
churned7d,
netGrowth7d
},
last14Days,
streakChart
};
};

View File

@@ -0,0 +1,198 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
interface Stats {
todayCount: number;
totalCount: number;
uniquePlayers: number;
weeklyPlayers: number;
activeStreaks: number;
avgGuessesToday: number | null;
registeredUsers: number;
avgCompletionsPerPlayer: number | null;
}
interface PageData {
todayEst: string;
stats: Stats;
last14Days: { date: string; count: number }[];
streakChart: { days: number; count: number }[];
growth: {
completionsVelocity: number;
completionsAcceleration: number;
userVelocity: number;
userAcceleration: number;
newUsers7d: number;
churned7d: number;
netGrowth7d: number;
};
}
let { data }: { data: PageData } = $props();
const { stats, last14Days, todayEst, streakChart, growth } = $derived(data);
function signed(n: number, unit = ''): string {
if (n > 0) return `+${n}${unit}`;
if (n < 0) return `${n}${unit}`;
return `0${unit}`;
}
function trendColor(n: number): string {
if (n > 0) return 'text-green-400';
if (n < 0) return 'text-red-400';
return 'text-gray-400';
}
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
const statCards = $derived([
{ label: 'Completions Today', value: String(stats.todayCount) },
{ label: 'All-Time Completions', value: String(stats.totalCount) },
{ label: 'Unique Players', value: String(stats.uniquePlayers) },
{ label: 'Players This Week', value: String(stats.weeklyPlayers) },
{ label: 'Active Streaks', value: String(stats.activeStreaks) },
{
label: 'Avg Guesses Today',
value: stats.avgGuessesToday != null ? stats.avgGuessesToday.toFixed(2) : 'N/A',
},
{ label: 'Registered Users', value: String(stats.registeredUsers) },
{
label: 'Avg Completions/Player',
value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A',
},
]);
</script>
<svelte:head>
<title>Global Stats | Bibdle</title>
</svelte:head>
<div class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100">
<div class="max-w-6xl mx-auto px-4 py-8">
<a href="/" class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors">
← Back to Game
</a>
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
<p class="text-gray-400 text-sm mt-1">EST reference date: {todayEst}</p>
</header>
<section class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10">
{#each statCards as card (card.label)}
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">{card.label}</span>
<span class="text-2xl md:text-3xl font-bold text-gray-100">{card.value}</span>
</Container>
{/each}
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Traffic &amp; Growth <span class="text-xs font-normal text-gray-400">(7-day windows)</span></h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Completions Velocity</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.completionsVelocity)}">{signed(growth.completionsVelocity, '/day')}</span>
<span class="text-xs text-gray-500 text-center">vs prior 7 days</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Completions Accel.</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.completionsAcceleration)}">{signed(growth.completionsAcceleration, '/day')}</span>
<span class="text-xs text-gray-500 text-center">rate of change of velocity</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">User Velocity</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.userVelocity)}">{signed(growth.userVelocity)}</span>
<span class="text-xs text-gray-500 text-center">unique players, wk/wk</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">User Acceleration</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.userAcceleration)}">{signed(growth.userAcceleration)}</span>
<span class="text-xs text-gray-500 text-center">rate of change of user velocity</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">New Players (7d)</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.newUsers7d)}">{String(growth.newUsers7d)}</span>
<span class="text-xs text-gray-500 text-center">first-time players</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Churned (7d)</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(0)}">{String(growth.churned7d)}</span>
<span class="text-xs text-gray-500 text-center">played wk prior, not this wk</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Net Growth (7d)</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.netGrowth7d)}">{signed(growth.netGrowth7d)}</span>
<span class="text-xs text-gray-500 text-center">new minus churned</span>
</Container>
</div>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Last 14 Days</h2>
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Date</th>
<th class="text-right px-4 py-3">Completions</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each last14Days as row (row.date)}
{@const barPct = Math.round((row.count / maxCount) * 100)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">{row.date}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Active Streak Distribution</h2>
{#if streakChart.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Days</th>
<th class="text-right px-4 py-3">Players</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each streakChart as row (row.days)}
{@const barPct = Math.round((row.count / maxStreakCount) * 100)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">{row.days}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>
</div>

View File

@@ -59,6 +59,14 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done # done
## march 14th
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
- Added traffic & growth analytics section: completions velocity + acceleration, user velocity + acceleration, new players (7d), churned players (7d), net growth (7d)
- Added active streak distribution chart (bar chart by streak length)
- Added 14-day completions trend table with inline bar chart
- Fixed BIBDLE header color in dark mode
## march 12th ## march 12th
- Added about page with social buttons and XML sitemap for SEO - Added about page with social buttons and XML sitemap for SEO