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 @@
+ {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 622d356..2ae7f6f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,7 +8,7 @@ import GuessesTable from "$lib/components/GuessesTable.svelte"; import WinScreen from "$lib/components/WinScreen.svelte"; import Credits from "$lib/components/Credits.svelte"; - import ThemeToggle from "$lib/components/ThemeToggle.svelte"; + import DevButtons from "$lib/components/DevButtons.svelte"; import AuthModal from "$lib/components/AuthModal.svelte"; @@ -336,10 +336,7 @@ {/if} - - + {#if isDev}
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 + + +
+

Global Stats

+

EST reference date: {todayEst}

+
+ +
+ {#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

+
+ + + + + + + + + + {#each last14Days as row (row.date)} + {@const barPct = Math.round((row.count / maxCount) * 100)} + + + + + + {/each} + +
DateCompletions
{row.date}{row.count} +
+
+
+
+
+
+ +
+

Active Streak Distribution

+ {#if streakChart.length === 0} +

No active streaks yet.

+ {:else} +
+ + + + + + + + + + {#each streakChart as row (row.days)} + {@const barPct = Math.round((row.count / maxStreakCount) * 100)} + + + + + + {/each} + +
DaysPlayers
{row.days}{row.count} +
+
+
+
+
+ {/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