Compare commits

..

3 Commits

Author SHA1 Message Date
George Powell
7007df2966 added global route for stat tracking 2026-03-14 22:17:33 -04:00
George Powell
61673a646d Added thank you message 2026-03-14 18:46:22 -04:00
George Powell
1eb8eb2f04 Added umami event to Apple signin and fixed spacing 2026-03-14 18:40:57 -04:00
7 changed files with 452 additions and 12 deletions

View File

@@ -96,6 +96,7 @@
<button
type="submit"
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
data-umami-event="Sign in with Apple"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>

View File

@@ -40,7 +40,7 @@
streak = 0,
streakPercentile = null,
isLoggedIn = false,
anonymousId = '',
anonymousId = "",
}: {
statsData: StatsData | null;
correctBookId: string;
@@ -307,6 +307,11 @@
{/if}
</div>
</div>
{#if streak >= 7}
<div class="big-text tracking-widest! font-black! text-center mt-4">
Thank you for making Bibdle part of your daily routine! —George
</div>
{/if}
</div>
{#if showSnippetOption}
@@ -326,15 +331,24 @@
{#if !isLoggedIn}
<div class="signin-prompt">
<p class="signin-text">Sign in to save your streak &amp; see your stats</p>
<p class="signin-text">
Sign in to save your streak &amp; track your progress
</p>
<form method="POST" action="/auth/apple">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="apple-signin-btn"
data-umami-event="Sign in with Apple"
>
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
<svg
class="apple-icon"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
/>
</svg>
Sign in with Apple
</button>
@@ -627,7 +641,7 @@
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0 0.25rem;
/*padding: 1rem 0 0.25rem;*/
}
.signin-text {
@@ -648,7 +662,8 @@
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 1.5rem;
padding: 0.6rem 4rem;
margin-bottom: 0.6rem;
background: #000;
color: #fff;
border-radius: 0.5rem;
@@ -656,7 +671,9 @@
font-weight: 600;
border: none;
cursor: pointer;
transition: background 150ms ease, transform 80ms ease;
transition:
background 150ms ease,
transform 80ms ease;
}
.apple-signin-btn:hover {

View File

@@ -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 @@
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="hidden"><ThemeToggle /></div>
{@render children()}
</div>

View File

@@ -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 @@
</div>
{/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>
{#if isDev}
<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
## 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