Added survival curve metrics and table minimizing

This commit is contained in:
George Powell
2026-03-19 00:18:54 -04:00
parent 83cfcc66c0
commit bdc08bc58e
2 changed files with 107 additions and 6 deletions

View File

@@ -202,6 +202,26 @@ export const load: PageServerLoad = async () => {
// Net growth = truly new arrivals minus departures // Net growth = truly new arrivals minus departures
const netGrowth7d = newUsers7d - churned7d; const netGrowth7d = newUsers7d - churned7d;
// ── Session depth funnel ──────────────────────────────────────────────────
// For each depth d, count players with >= d completions.
// returnRate at depth d = (players with >= d+1) / (players with >= d).
const depthCounts = new Map<number, number>();
for (const r of firstDates) {
const n = r.totalCompletions;
for (let d = 1; d <= n; d++) {
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
}
}
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
const atD = depthCounts.get(d) ?? 0;
const atDplus1 = depthCounts.get(d + 1) ?? 0;
return {
depth: d,
players: atD,
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
};
});
// ── Return rate ─────────────────────────────────────────────────────────── // ── Return rate ───────────────────────────────────────────────────────────
// "Return rate": % of all-time unique players who have ever played more than once. // "Return rate": % of all-time unique players who have ever played more than once.
const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length; const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length;
@@ -329,6 +349,7 @@ export const load: PageServerLoad = async () => {
return { return {
todayEst, todayEst,
sessionDepthCards,
stats: { stats: {
todayCount, todayCount,
totalCount, totalCount,

View File

@@ -51,6 +51,7 @@
changePct: number | null; changePct: number | null;
}[]; }[];
avgWau: number; avgWau: number;
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
} }
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -68,8 +69,17 @@
newPlayerReturnVelocity, newPlayerReturnVelocity,
wauWeeks, wauWeeks,
avgWau, avgWau,
sessionDepthCards,
} = $derived(data); } = $derived(data);
// Collapsible table state
let returnExpanded = $state(false);
let wauExpanded = $state(false);
let completionsExpanded = $state(false);
let streakExpanded = $state(false);
let ret7dExpanded = $state(false);
let ret30dExpanded = $state(false);
function signed(n: number, unit = ""): string { function signed(n: number, unit = ""): string {
if (n > 0) return `+${n}${unit}`; if (n > 0) return `+${n}${unit}`;
if (n < 0) return `${n}${unit}`; if (n < 0) return `${n}${unit}`;
@@ -259,6 +269,28 @@
</div> </div>
</section> </section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
<p class="text-gray-400 text-sm mb-4">
Of players who completed N sessions, what % came back for N+1?
</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{#each sessionDepthCards as card (card.depth)}
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
>After {card.depth} plays</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100">
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>{card.players} players</span
>
</Container>
{/each}
</div>
</section>
<section class="mb-10"> <section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4"> <h2 class="text-lg font-semibold text-gray-100 mb-4">
New Player Return Rate <span New Player Return Rate <span
@@ -337,7 +369,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each newPlayerReturnSeries as row (row.date)} {#each (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) as row (row.date)}
<tr <tr
class="border-t border-white/5 hover:bg-white/5 transition-colors" class="border-t border-white/5 hover:bg-white/5 transition-colors"
> >
@@ -375,6 +407,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if newPlayerReturnSeries.length > 3}
<button
onclick={() => (returnExpanded = !returnExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{returnExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{:else} {:else}
<p class="text-gray-400 text-sm px-4 py-6"> <p class="text-gray-400 text-sm px-4 py-6">
Not enough data yet. Not enough data yet.
@@ -403,7 +443,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each wauWeeks as row (row.weekEnd)} {#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)}
{@const barPct = Math.round( {@const barPct = Math.round(
(row.wau / maxWau) * 100, (row.wau / maxWau) * 100,
)} )}
@@ -444,6 +484,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if wauWeeks.length > 3}
<button
onclick={() => (wauExpanded = !wauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{wauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
</section> </section>
<section class="mt-8"> <section class="mt-8">
@@ -462,7 +510,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each last14Days as row (row.date)} {#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)}
{@const barPct = Math.round( {@const barPct = Math.round(
(row.count / maxCount) * 100, (row.count / maxCount) * 100,
)} )}
@@ -489,6 +537,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if last14Days.length > 3}
<button
onclick={() => (completionsExpanded = !completionsExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{completionsExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
</section> </section>
<section class="mt-8"> <section class="mt-8">
@@ -512,7 +568,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each streakChart as row (row.days)} {#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)}
{@const barPct = Math.round( {@const barPct = Math.round(
(row.count / maxStreakCount) * 100, (row.count / maxStreakCount) * 100,
)} )}
@@ -539,6 +595,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if streakChart.length > 3}
<button
onclick={() => (streakExpanded = !streakExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{streakExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if} {/if}
</section> </section>
@@ -580,7 +644,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each retention7dSeries as row (row.date)} {#each (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) as row (row.date)}
<tr <tr
class="border-t border-white/5 hover:bg-white/5 transition-colors" class="border-t border-white/5 hover:bg-white/5 transition-colors"
> >
@@ -608,6 +672,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if retention7dSeries.length > 3}
<button
onclick={() => (ret7dExpanded = !ret7dExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{ret7dExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if} {/if}
</div> </div>
@@ -640,7 +712,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each retention30dSeries as row (row.date)} {#each (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) as row (row.date)}
<tr <tr
class="border-t border-white/5 hover:bg-white/5 transition-colors" class="border-t border-white/5 hover:bg-white/5 transition-colors"
> >
@@ -668,6 +740,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if retention30dSeries.length > 3}
<button
onclick={() => (ret30dExpanded = !ret30dExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{ret30dExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if} {/if}
</div> </div>
</div> </div>