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); } function addDays(d: string, n: number): string { const dt = new Date(d + 'T00:00:00Z'); dt.setUTCDate(dt.getUTCDate() + n); 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) and users by date const userDatesMap = new Map(); const dateUsersMap = 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]); let s = dateUsersMap.get(row.date); if (!s) { s = new Set(); dateUsersMap.set(row.date, s); } s.add(row.anonymousId); } // ── 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), totalCompletions: count() }) .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 rate ─────────────────────────────────────────────────────────── // "Return rate": % of all-time unique players who have ever played more than once. const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length; const overallReturnRate = firstDates.length > 0 ? Math.round((playersWithReturn / firstDates.length) * 1000) / 10 : null; // Daily new-player return rate: for each day D, what % of first-time players // on D ever came back (i.e. totalCompletions >= 2)? const dailyNewPlayerReturn = new Map(); for (const r of firstDates) { if (!r.firstDate) continue; const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 }; existing.cohort++; if (r.totalCompletions >= 2) existing.returned++; dailyNewPlayerReturn.set(r.firstDate, existing); } // Build chronological array of daily rates (oldest first, days 60→1 ago) // Days with fewer than 3 new players get rate=null to exclude from rolling avg const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = []; for (let i = 60; i >= 1; i--) { const dateD = estDateStr(i); const d = dailyNewPlayerReturn.get(dateD); dailyReturnRates.push({ date: dateD, cohort: d?.cohort ?? 0, rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null }); } // 7-day trailing rolling average of the daily rates // Index 0 = 60 days ago, index 59 = yesterday const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => { const window = dailyReturnRates .slice(Math.max(0, idx - 6), idx + 1) .filter((d) => d.rate !== null); const avg = window.length > 0 ? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) / 10 : null; return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg }; }); // Velocity: avg of last 7 complete days (idx 53–59) vs prior 7 (idx 46–52) const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null); const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null); const current7dReturnAvg = recentWindow.length > 0 ? Math.round( (recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10 ) / 10 : null; const prior7dReturnAvg = priorWindow.length > 0 ? Math.round( (priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10 ) / 10 : null; const returnRateChange = current7dReturnAvg !== null && prior7dReturnAvg !== null ? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10 : null; // ── Retention over time ─────────────────────────────────────────────────── // For each cohort day D, retention = % of that day's players who played // again within the next N days. Only compute for days where D+N is in the past. function retentionSeries( windowDays: number, seriesLength: number ): { date: string; rate: number; cohortSize: number }[] { // Earliest computable cohort day: today - (windowDays + 1) // We use index windowDays+1 through windowDays+seriesLength const series: { date: string; rate: number; cohortSize: number }[] = []; for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) { const dateD = estDateStr(i); const cohort = dateUsersMap.get(dateD); if (!cohort || cohort.size < 3) continue; // skip tiny cohorts let retained = 0; for (const userId of cohort) { for (let j = 1; j <= windowDays; j++) { if (dateUsersMap.get(addDays(dateD, j))?.has(userId)) { retained++; break; } } } series.push({ date: dateD, rate: Math.round((retained / cohort.size) * 1000) / 10, cohortSize: cohort.size }); } return series; // newest first (loop iterates i from smallest = most recent) } const retention7dSeries = retentionSeries(7, 30); const retention30dSeries = retentionSeries(30, 30); return { todayEst, stats: { todayCount, totalCount, uniquePlayers, weeklyPlayers, activeStreaks, avgGuessesToday, registeredUsers, avgCompletionsPerPlayer }, growth: { completionsVelocity, completionsAcceleration, userVelocity, userAcceleration, newUsers7d, churned7d, netGrowth7d }, last14Days, streakChart, retention7dSeries, retention30dSeries, overallReturnRate, newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(), newPlayerReturnVelocity: { current7dAvg: current7dReturnAvg, prior7dAvg: prior7dReturnAvg, change: returnRateChange } }; };