mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
feat: add return rate and retention metrics to global stats
- Overall return rate: % of all-time players who played more than once - New player return rate: 7-day rolling avg of daily first-timer return rates, with velocity vs prior 7 days - 7-day and 30-day retention over time: per-cohort-day retention series Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,12 @@ function prevDay(d: string): string {
|
||||
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);
|
||||
@@ -120,12 +126,17 @@ export const load: PageServerLoad = async () => {
|
||||
.where(gte(dailyCompletions.date, ninetyDaysAgo))
|
||||
.orderBy(asc(dailyCompletions.date));
|
||||
|
||||
// Group dates by user (ascending)
|
||||
// Group dates by user (ascending) and users by date
|
||||
const userDatesMap = new Map<string, string[]>();
|
||||
const dateUsersMap = new Map<string, Set<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]);
|
||||
|
||||
let s = dateUsersMap.get(row.date);
|
||||
if (!s) { s = new Set(); dateUsersMap.set(row.date, s); }
|
||||
s.add(row.anonymousId);
|
||||
}
|
||||
|
||||
// ── Streak distribution ───────────────────────────────────────────────────
|
||||
@@ -177,7 +188,8 @@ export const load: PageServerLoad = async () => {
|
||||
const firstDates = await db
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
firstDate: min(dailyCompletions.date)
|
||||
firstDate: min(dailyCompletions.date),
|
||||
totalCompletions: count()
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.groupBy(dailyCompletions.anonymousId);
|
||||
@@ -190,6 +202,109 @@ export const load: PageServerLoad = async () => {
|
||||
// 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<string, { cohort: number; returned: number }>();
|
||||
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
|
||||
});
|
||||
}
|
||||
series.reverse(); // chronological (oldest first)
|
||||
return series;
|
||||
}
|
||||
|
||||
const retention7dSeries = retentionSeries(7, 30);
|
||||
const retention30dSeries = retentionSeries(30, 30);
|
||||
|
||||
return {
|
||||
todayEst,
|
||||
stats: {
|
||||
@@ -212,6 +327,15 @@ export const load: PageServerLoad = async () => {
|
||||
netGrowth7d
|
||||
},
|
||||
last14Days,
|
||||
streakChart
|
||||
streakChart,
|
||||
retention7dSeries,
|
||||
retention30dSeries,
|
||||
overallReturnRate,
|
||||
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30),
|
||||
newPlayerReturnVelocity: {
|
||||
current7dAvg: current7dReturnAvg,
|
||||
prior7dAvg: prior7dReturnAvg,
|
||||
change: returnRateChange
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user