diff --git a/src/routes/global/+page.server.ts b/src/routes/global/+page.server.ts index e15eed1..6ec4826 100644 --- a/src/routes/global/+page.server.ts +++ b/src/routes/global/+page.server.ts @@ -56,6 +56,12 @@ export const load: PageServerLoad = async () => { .from(dailyCompletions) .where(gte(dailyCompletions.date, sevenDaysAgo)); + const thirtyDaysAgo = estDateStr(30); + const [{ monthlyPlayers }] = await db + .select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) }) + .from(dailyCompletions) + .where(gte(dailyCompletions.date, thirtyDaysAgo)); + const todayPlayers = await db .selectDistinct({ id: dailyCompletions.anonymousId }) .from(dailyCompletions) @@ -347,6 +353,96 @@ export const load: PageServerLoad = async () => { const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length); + // ── Monthly Active Users history (6 months) ─────────────────────────────── + + const sixMonthsAgo = estDateStr(185); + const mauCompletions = await db + .select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date }) + .from(dailyCompletions) + .where(gte(dailyCompletions.date, sixMonthsAgo)); + + // Rolling 30-day windows + const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = []; + for (let m = 0; m < 6; m++) { + const monthEnd = estDateStr(m * 30); + const monthStart = estDateStr(m * 30 + 29); + const users = new Set(); + for (const row of mauCompletions) { + if (row.date >= monthStart && row.date <= monthEnd) { + users.add(row.anonymousId); + } + } + mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null }); + } + for (let i = 0; i < mauMonths.length - 1; i++) { + const prev = mauMonths[i + 1].mau; + if (prev > 0) { + mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10; + } + } + + // Calendar month windows + const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number); + + const calendarMauMonths: { + label: string; + monthStart: string; + monthEnd: string; + mau: number; + daysElapsed: number; + daysInMonth: number; + projectedMau: number | null; + changePct: number | null; + isCurrentMonth: boolean; + }[] = []; + + for (let i = 0; i < 6; i++) { + let mo = todayMonth - i; + let yr = todayYear; + if (mo <= 0) { mo += 12; yr--; } + + // new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time + const daysInMonth = new Date(yr, mo, 0).getDate(); + const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`; + const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`; + const isCurrentMonth = i === 0; + const daysElapsed = isCurrentMonth ? todayDay : daysInMonth; + const queryEnd = isCurrentMonth ? todayEst : monthEnd; + + const users = new Set(); + for (const row of mauCompletions) { + if (row.date >= monthStart && row.date <= queryEnd) { + users.add(row.anonymousId); + } + } + + const projectedMau = isCurrentMonth && daysElapsed > 0 + ? Math.round(users.size * (daysInMonth / daysElapsed)) + : null; + + calendarMauMonths.push({ + label: `${MONTH_NAMES[mo - 1]} ${yr}`, + monthStart, + monthEnd, + mau: users.size, + daysElapsed, + daysInMonth, + projectedMau, + changePct: null, + isCurrentMonth + }); + } + + for (let i = 0; i < calendarMauMonths.length - 1; i++) { + const curr = calendarMauMonths[i]; + const prev = calendarMauMonths[i + 1]; + if (prev.mau > 0) { + const compareVal = curr.projectedMau ?? curr.mau; + curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10; + } + } + return { todayEst, sessionDepthCards, @@ -358,7 +454,7 @@ export const load: PageServerLoad = async () => { activeStreaks, avgGuessesToday, registeredUsers, - avgCompletionsPerPlayer + monthlyPlayers }, growth: { completionsVelocity, @@ -381,6 +477,8 @@ export const load: PageServerLoad = async () => { change: returnRateChange }, wauWeeks, - avgWau + avgWau, + mauMonths, + calendarMauMonths }; }; diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte index 3dc725a..727d5af 100644 --- a/src/routes/global/+page.svelte +++ b/src/routes/global/+page.svelte @@ -9,7 +9,7 @@ activeStreaks: number; avgGuessesToday: number | null; registeredUsers: number; - avgCompletionsPerPlayer: number | null; + monthlyPlayers: number; } interface PageData { @@ -51,6 +51,18 @@ changePct: number | null; }[]; avgWau: number; + mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[]; + calendarMauMonths: { + label: string; + monthStart: string; + monthEnd: string; + mau: number; + daysElapsed: number; + daysInMonth: number; + projectedMau: number | null; + changePct: number | null; + isCurrentMonth: boolean; + }[]; sessionDepthCards: { depth: number; players: number; returnRate: number | null }[]; } @@ -69,6 +81,8 @@ newPlayerReturnVelocity, wauWeeks, avgWau, + mauMonths, + calendarMauMonths, sessionDepthCards, } = $derived(data); @@ -79,6 +93,8 @@ let streakExpanded = $state(false); let ret7dExpanded = $state(false); let ret30dExpanded = $state(false); + let mauExpanded = $state(false); + let mauMode = $state<'rolling' | 'calendar'>('rolling'); function signed(n: number, unit = ""): string { if (n > 0) return `+${n}${unit}`; @@ -107,13 +123,7 @@ { label: "Players This Week", value: String(stats.weeklyPlayers) }, { label: "Active Streaks", value: String(stats.activeStreaks) }, { label: "Registered Users", value: String(stats.registeredUsers) }, - { - label: "Avg Completions/Player", - value: - stats.avgCompletionsPerPlayer != null - ? stats.avgCompletionsPerPlayer.toFixed(2) - : "N/A", - }, + { label: "Players This Month", value: String(stats.monthlyPlayers) }, { label: "Overall Return Rate", value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A", @@ -494,6 +504,123 @@ {/if} +
+
+

Monthly Active Users

+
+ + +
+
+

+ {mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'} +

+ + {#if mauMode === 'rolling'} + {@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)} + {@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))} +
+ + + + + + + + + + + {#each displayedMauMonths as row (row.monthEnd)} + {@const barPct = Math.round((row.mau / maxMau) * 100)} + + + + + + + {/each} + +
PeriodActive UsersMo/Mo Change
{row.monthStart} – {row.monthEnd}{row.mau} + {row.changePct != null ? signed(row.changePct, '%') : '—'} + +
+
+
+
+
+ {#if mauMonths.length > 3} + + {/if} + {:else} + {@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)} + {@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))} +
+ + + + + + + + + + + {#each displayedCalMau as row (row.monthStart)} + {@const displayMau = row.projectedMau ?? row.mau} + {@const barPct = Math.round((displayMau / maxCalMau) * 100)} + + + + + + + {/each} + +
MonthActive UsersMo/Mo Change
+ {row.label} + {#if row.isCurrentMonth} + (projected) + {/if} + + {#if row.isCurrentMonth} + {row.mau} → {row.projectedMau ?? row.mau} + {:else} + {row.mau} + {/if} + + {#if row.changePct != null} + {row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')} + {:else} + — + {/if} + +
+
+
+
+
+ {#if calendarMauMonths.length > 3} + + {/if} + {/if} +
+

Last 14 Days — Completions