diff --git a/src/lib/components/CollapsibleTable.svelte b/src/lib/components/CollapsibleTable.svelte
new file mode 100644
index 0000000..4cebf87
--- /dev/null
+++ b/src/lib/components/CollapsibleTable.svelte
@@ -0,0 +1,102 @@
+
+
+{#if modes && modes.length > 1}
+
+ {#each modes as m (m.value)}
+ {@const active = mode === m.value}
+ (mode = m.value)}
+ class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
+ >
+ {m.label}
+
+ {/each}
+
+{/if}
+
+{#if rows.length === 0}
+ {#if empty}
+ {@render empty()}
+ {:else}
+ Not enough data yet.
+ {/if}
+{:else}
+
+
+
+
+ {#each headers as header (header.label)}
+
+ {/each}
+
+
+
+ {#each displayedRows as item, i (i)}
+
+ {@render rowSnippet(item)}
+
+ {/each}
+
+
+
+
+ {#if rows.length > initialRows}
+
+ {expanded ? '▲ Show less' : '▼ Show more'}
+
+ {/if}
+{/if}
diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte
index 727d5af..2ec9ba6 100644
--- a/src/routes/global/+page.svelte
+++ b/src/routes/global/+page.svelte
@@ -1,5 +1,6 @@
@@ -362,74 +358,34 @@
- {#if newPlayerReturnSeries.length > 0}
-
-
-
-
- Date
- New Players
- Return Rate
- 7d Avg
-
-
-
-
- {#each (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) as row (row.date)}
-
- {row.date}
- {row.cohort}
- {row.rate != null
- ? `${row.rate}%`
- : "—"}
- {row.rollingAvg != null
- ? `${row.rollingAvg}%`
- : "—"}
-
-
- {#if row.rollingAvg != null}
-
- {/if}
-
-
-
- {/each}
-
-
-
- {#if newPlayerReturnSeries.length > 3}
- (returnExpanded = !returnExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {returnExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
- {:else}
-
- Not enough data yet.
-
- {/if}
+
+ {#snippet row(item)}
+ {item.date}
+ {item.cohort}
+
+ {item.rate != null ? `${item.rate}%` : '—'}
+
+
+ {item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
+
+
+
+ {#if item.rollingAvg != null}
+
+ {/if}
+
+
+ {/snippet}
+
@@ -440,184 +396,113 @@
Unique players per 7-day window. Most recent week first. Avg
WAU: {avgWau}
-
-
-
-
- Week
- Active Users
- Wk/Wk Change
-
-
-
-
- {#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)}
- {@const barPct = Math.round(
- (row.wau / maxWau) * 100,
- )}
-
- {row.weekStart} – {row.weekEnd}
- {row.wau}
-
- {row.changePct != null
- ? signed(row.changePct, "%")
- : "—"}
-
-
-
-
-
- {/each}
-
-
-
- {#if wauWeeks.length > 3}
- (wauExpanded = !wauExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {wauExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
+
+ {#snippet row(item)}
+ {@const barPct = Math.round((item.wau / maxWau) * 100)}
+ {item.weekStart} – {item.weekEnd}
+ {item.wau}
+
+ {item.changePct != null ? signed(item.changePct, '%') : '—'}
+
+
+
+
+ {/snippet}
+
-
-
Monthly Active Users
-
- (mauMode = 'rolling')}
- class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'rolling' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
- >Rolling 30d
- (mauMode = 'calendar')}
- class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'calendar' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
- >Calendar
-
-
+ 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.'}
+ {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))}
-
-
-
-
- Period
- Active Users
- Mo/Mo Change
-
-
-
-
- {#each displayedMauMonths as row (row.monthEnd)}
- {@const barPct = Math.round((row.mau / maxMau) * 100)}
-
- {row.monthStart} – {row.monthEnd}
- {row.mau}
-
- {row.changePct != null ? signed(row.changePct, '%') : '—'}
-
-
-
-
-
- {/each}
-
-
-
- {#if mauMonths.length > 3}
- (mauExpanded = !mauExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {mauExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
+
+ {#snippet row(item)}
+ {@const barPct = Math.round((item.mau / maxMau) * 100)}
+ {item.monthStart} – {item.monthEnd}
+ {item.mau}
+
+ {item.changePct != null ? signed(item.changePct, '%') : '—'}
+
+
+
+
+ {/snippet}
+
{:else}
- {@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
- {@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
-
-
-
-
- Month
- Active Users
- Mo/Mo Change
-
-
-
-
- {#each displayedCalMau as row (row.monthStart)}
- {@const displayMau = row.projectedMau ?? row.mau}
- {@const barPct = Math.round((displayMau / maxCalMau) * 100)}
-
-
- {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}
-
-
-
-
-
- {/each}
-
-
-
- {#if calendarMauMonths.length > 3}
- (mauExpanded = !mauExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {mauExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
+
+ {#snippet row(item)}
+ {@const displayMau = item.projectedMau ?? item.mau}
+ {@const barPct = Math.round((displayMau / maxCalMau) * 100)}
+
+ {item.label}
+ {#if item.isCurrentMonth}
+ (projected)
+ {/if}
+
+
+ {#if item.isCurrentMonth}
+ {item.mau} → {item.projectedMau ?? item.mau}
+ {:else}
+ {item.mau}
+ {/if}
+
+
+ {#if item.changePct != null}
+ {item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
+ {:else}
+ —
+ {/if}
+
+
+
+
+ {/snippet}
+
{/if}
@@ -625,112 +510,53 @@
Last 14 Days — Completions
-
-
-
-
- Date
- Completions
-
-
-
-
- {#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)}
- {@const barPct = Math.round(
- (row.count / maxCount) * 100,
- )}
-
- {row.date}
- {row.count}
-
-
-
-
- {/each}
-
-
-
- {#if last14Days.length > 3}
- (completionsExpanded = !completionsExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {completionsExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
+
+ {#snippet row(item)}
+ {@const barPct = Math.round((item.count / maxCount) * 100)}
+ {item.date}
+ {item.count}
+
+
+
+ {/snippet}
+
Active Streak Distribution
- {#if streakChart.length === 0}
-
- No active streaks yet.
-
- {:else}
-
-
-
-
- Days
- Players
-
-
-
-
- {#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)}
- {@const barPct = Math.round(
- (row.count / maxStreakCount) * 100,
- )}
-
- {row.days}
- {row.count}
-
-
-
-
- {/each}
-
-
-
- {#if streakChart.length > 3}
- (streakExpanded = !streakExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {streakExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
- {/if}
+
+ {#snippet row(item)}
+ {@const barPct = Math.round((item.count / maxStreakCount) * 100)}
+ {item.days}
+ {item.count}
+
+
+
+ {/snippet}
+ {#snippet empty()}
+ No active streaks yet.
+ {/snippet}
+
@@ -747,67 +573,26 @@
7-Day Retention
- {#if retention7dSeries.length === 0}
-
- Not enough data yet.
-
- {:else}
-
-
-
-
- Cohort Date
- n
- Ret. %
-
-
-
-
- {#each (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) as row (row.date)}
-
- {row.date}
- {row.cohortSize}
- {row.rate}%
-
-
-
-
- {/each}
-
-
-
- {#if retention7dSeries.length > 3}
- (ret7dExpanded = !ret7dExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {ret7dExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
- {/if}
+
+ {#snippet row(item)}
+ {item.date}
+ {item.cohortSize}
+ {item.rate}%
+
+
+
+ {/snippet}
+
@@ -815,67 +600,26 @@
30-Day Retention
- {#if retention30dSeries.length === 0}
-
- Not enough data yet.
-
- {:else}
-
-
-
-
- Cohort Date
- n
- Ret. %
-
-
-
-
- {#each (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) as row (row.date)}
-
- {row.date}
- {row.cohortSize}
- {row.rate}%
-
-
-
-
- {/each}
-
-
-
- {#if retention30dSeries.length > 3}
- (ret30dExpanded = !ret30dExpanded)}
- class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
- >
- {ret30dExpanded ? '▲ Show less' : '▼ Show more'}
-
- {/if}
- {/if}
+
+ {#snippet row(item)}
+ {item.date}
+ {item.cohortSize}
+ {item.rate}%
+
+
+
+ {/snippet}
+