diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 3e58d5b..9a5e856 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -4,6 +4,7 @@
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
+ import ThemeToggle from "$lib/components/ThemeToggle.svelte";
onMount(() => {
// Inject analytics script
@@ -31,5 +32,6 @@
diff --git a/src/routes/global/+page.server.ts b/src/routes/global/+page.server.ts
new file mode 100644
index 0000000..ad30bbc
--- /dev/null
+++ b/src/routes/global/+page.server.ts
@@ -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 0–6)
+ // Week B: 7–13 days ago (indices 7–13)
+ // Week C: 14–20 days ago (indices 14–20)
+ 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
();
+ 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();
+ 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();
+ const weekBUsers = new Set();
+ const weekCUsers = new Set();
+
+ 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
+ };
+};
diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte
new file mode 100644
index 0000000..1af6970
--- /dev/null
+++ b/src/routes/global/+page.svelte
@@ -0,0 +1,198 @@
+
+
+
+ Global Stats | Bibdle
+
+
+
+
+
+
+ ← Back to Game
+
+
+
+
+
+ {#each statCards as card (card.label)}
+
+ {card.label}
+ {card.value}
+
+ {/each}
+
+
+
+ Traffic & Growth (7-day windows)
+
+
+ Completions Velocity
+ {signed(growth.completionsVelocity, '/day')}
+ vs prior 7 days
+
+
+ Completions Accel.
+ {signed(growth.completionsAcceleration, '/day')}
+ rate of change of velocity
+
+
+ User Velocity
+ {signed(growth.userVelocity)}
+ unique players, wk/wk
+
+
+ User Acceleration
+ {signed(growth.userAcceleration)}
+ rate of change of user velocity
+
+
+ New Players (7d)
+ {String(growth.newUsers7d)}
+ first-time players
+
+
+ Churned (7d)
+ {String(growth.churned7d)}
+ played wk prior, not this wk
+
+
+ Net Growth (7d)
+ {signed(growth.netGrowth7d)}
+ new minus churned
+
+
+
+
+
+ Last 14 Days
+
+
+
+
+ | Date |
+ Completions |
+ |
+
+
+
+ {#each last14Days as row (row.date)}
+ {@const barPct = Math.round((row.count / maxCount) * 100)}
+
+ | {row.date} |
+ {row.count} |
+
+
+ |
+
+ {/each}
+
+
+
+
+
+
+ Active Streak Distribution
+ {#if streakChart.length === 0}
+ No active streaks yet.
+ {:else}
+
+
+
+
+ | Days |
+ Players |
+ |
+
+
+
+ {#each streakChart as row (row.days)}
+ {@const barPct = Math.round((row.count / maxStreakCount) * 100)}
+
+ | {row.days} |
+ {row.count} |
+
+
+ |
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
diff --git a/todo.md b/todo.md
index e8ef345..c0b715d 100644
--- a/todo.md
+++ b/todo.md
@@ -59,6 +59,14 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# 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
- Added about page with social buttons and XML sitemap for SEO