refactor: extract CollapsibleTable component and fix show more

Replaces 7 inline collapsible tables in the global stats page with a
reusable CollapsibleTable component. Adds mode tab toggle (Rolling 30d /
Calendar) into the component. Fixes show more/less which was broken due
to mode-based expanded tracking when no modes were provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-03-23 20:17:30 -04:00
parent f98ab24d2e
commit 4a5aef5a3d
2 changed files with 319 additions and 473 deletions

View File

@@ -0,0 +1,102 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
interface Header {
label: string;
align?: 'left' | 'right';
width?: string;
}
interface Mode {
value: string;
label: string;
}
interface Props {
rows: T[];
headers: Header[];
row: Snippet<[item: T]>;
empty?: Snippet;
initialRows?: number;
modes?: Mode[];
mode?: string;
}
let {
rows,
headers,
row: rowSnippet,
empty,
initialRows = 3,
modes,
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
}: Props = $props();
let expanded = $state(false);
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
$effect(() => {
mode;
expanded = false;
});
function toggleExpanded() {
expanded = !expanded;
}
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
</script>
{#if modes && modes.length > 1}
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
{#each modes as m (m.value)}
{@const active = mode === m.value}
<button
onclick={() => (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}
</button>
{/each}
</div>
{/if}
{#if rows.length === 0}
{#if empty}
{@render empty()}
{:else}
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
{/if}
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
{#each headers as header (header.label)}
<th
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
>
{header.label}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayedRows as item, i (i)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
{@render rowSnippet(item)}
</tr>
{/each}
</tbody>
</table>
</div>
{#if rows.length > initialRows}
<button
onclick={toggleExpanded}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Container from "$lib/components/Container.svelte"; import Container from "$lib/components/Container.svelte";
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
interface Stats { interface Stats {
todayCount: number; todayCount: number;
@@ -86,14 +87,6 @@
sessionDepthCards, 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);
let mauExpanded = $state(false);
let mauMode = $state<'rolling' | 'calendar'>('rolling'); let mauMode = $state<'rolling' | 'calendar'>('rolling');
function signed(n: number, unit = ""): string { function signed(n: number, unit = ""): string {
@@ -109,12 +102,10 @@
} }
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count))); const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau))); const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
const maxStreakCount = $derived( const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
Math.max(1, ...streakChart.map((r) => r.count)), const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
);
const statCards = $derived([ const statCards = $derived([
{ label: "Completions Today", value: String(stats.todayCount) }, { label: "Completions Today", value: String(stats.todayCount) },
@@ -129,6 +120,11 @@
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A", value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
}, },
]); ]);
const mauModes = [
{ value: 'rolling', label: 'Rolling 30d' },
{ value: 'calendar', label: 'Calendar' },
];
</script> </script>
<svelte:head> <svelte:head>
@@ -362,74 +358,34 @@
</Container> </Container>
</div> </div>
{#if newPlayerReturnSeries.length > 0} <CollapsibleTable
<div class="overflow-x-auto rounded-xl border border-white/10"> rows={newPlayerReturnSeries}
<table class="w-full text-sm"> headers={[
<thead> { label: 'Date' },
<tr { label: 'New Players', align: 'right' },
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide" { label: 'Return Rate', align: 'right' },
> { label: '7d Avg', align: 'right' },
<th class="text-left px-4 py-3">Date</th> { label: '', width: 'w-32' },
<th class="text-right px-4 py-3">New Players</th ]}
> >
<th class="text-right px-4 py-3">Return Rate</th {#snippet row(item)}
> <td class="px-4 py-3 text-gray-300">{item.date}</td>
<th class="text-right px-4 py-3">7d Avg</th> <td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
<th class="px-4 py-3 w-32"></th> <td class="px-4 py-3 text-right text-gray-400">
</tr> {item.rate != null ? `${item.rate}%` : '—'}
</thead> </td>
<tbody> <td class="px-4 py-3 text-right text-gray-100 font-medium">
{#each (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) as row (row.date)} {item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
<tr </td>
class="border-t border-white/5 hover:bg-white/5 transition-colors" <td class="px-4 py-3">
> <div class="w-full min-w-20">
<td class="px-4 py-3 text-gray-300" {#if item.rollingAvg != null}
>{row.date}</td <div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
> {/if}
<td </div>
class="px-4 py-3 text-right text-gray-400 text-xs" </td>
>{row.cohort}</td {/snippet}
> </CollapsibleTable>
<td
class="px-4 py-3 text-right text-gray-400"
>{row.rate != null
? `${row.rate}%`
: "—"}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.rollingAvg != null
? `${row.rollingAvg}%`
: "—"}</td
>
<td class="px-4 py-3">
<div class="w-full min-w-20">
{#if row.rollingAvg != null}
<div
class="bg-sky-500 h-4 rounded"
style="width: {row.rollingAvg}%"
></div>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</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}
<p class="text-gray-400 text-sm px-4 py-6">
Not enough data yet.
</p>
{/if}
</section> </section>
<section class="mt-8"> <section class="mt-8">
@@ -440,184 +396,113 @@
Unique players per 7-day window. Most recent week first. Avg Unique players per 7-day window. Most recent week first. Avg
WAU: <span class="text-gray-100 font-medium">{avgWau}</span> WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
</p> </p>
<div class="overflow-x-auto rounded-xl border border-white/10"> <CollapsibleTable
<table class="w-full text-sm"> rows={wauWeeks}
<thead> headers={[
<tr { label: 'Week' },
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide" { label: 'Active Users', align: 'right' },
> { label: 'Wk/Wk Change', align: 'right' },
<th class="text-left px-4 py-3">Week</th> { label: '', width: 'w-48' },
<th class="text-right px-4 py-3">Active Users</th> ]}
<th class="text-right px-4 py-3">Wk/Wk Change</th> >
<th class="px-4 py-3 w-48"></th> {#snippet row(item)}
</tr> {@const barPct = Math.round((item.wau / maxWau) * 100)}
</thead> <td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} {item.weekEnd}</td>
<tbody> <td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
{#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)} <td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
{@const barPct = Math.round( ? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
(row.wau / maxWau) * 100, : 'text-gray-500'}">
)} {item.changePct != null ? signed(item.changePct, '%') : '—'}
<tr </td>
class="border-t border-white/5 hover:bg-white/5 transition-colors" <td class="px-4 py-3">
> <div class="w-full min-w-24">
<td class="px-4 py-3 text-gray-300 text-xs" <div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
>{row.weekStart} {row.weekEnd}</td </div>
> </td>
<td {/snippet}
class="px-4 py-3 text-right text-gray-100 font-medium" </CollapsibleTable>
>{row.wau}</td
>
<td
class="px-4 py-3 text-right text-xs font-medium {row.changePct !=
null
? row.changePct > 0
? 'text-green-400'
: row.changePct < 0
? 'text-red-400'
: 'text-gray-400'
: 'text-gray-500'}"
>
{row.changePct != null
? signed(row.changePct, "%")
: "—"}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-indigo-500 h-4 rounded"
style="width: {barPct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</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">
<div class="flex items-center justify-between mb-1"> <h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
<h2 class="text-lg font-semibold text-gray-100">Monthly Active Users</h2>
<div class="flex gap-1 bg-white/5 rounded-lg p-1">
<button
onclick={() => (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</button>
<button
onclick={() => (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</button>
</div>
</div>
<p class="text-gray-400 text-sm mb-4"> <p class="text-gray-400 text-sm mb-4">
{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.'}
</p> </p>
{#if mauMode === 'rolling'} {#if mauMode === 'rolling'}
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)} <CollapsibleTable
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))} rows={mauMonths}
<div class="overflow-x-auto rounded-xl border border-white/10"> headers={[
<table class="w-full text-sm"> { label: 'Period' },
<thead> { label: 'Active Users', align: 'right' },
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"> { label: 'Mo/Mo Change', align: 'right' },
<th class="text-left px-4 py-3">Period</th> { label: '', width: 'w-48' },
<th class="text-right px-4 py-3">Active Users</th> ]}
<th class="text-right px-4 py-3">Mo/Mo Change</th> modes={mauModes}
<th class="px-4 py-3 w-48"></th> bind:mode={mauMode}
</tr> >
</thead> {#snippet row(item)}
<tbody> {@const barPct = Math.round((item.mau / maxMau) * 100)}
{#each displayedMauMonths as row (row.monthEnd)} <td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} {item.monthEnd}</td>
{@const barPct = Math.round((row.mau / maxMau) * 100)} <td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors"> <td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
<td class="px-4 py-3 text-gray-300 text-xs">{row.monthStart} {row.monthEnd}</td> ? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</td> : 'text-gray-500'}">
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}"> {item.changePct != null ? signed(item.changePct, '%') : '—'}
{row.changePct != null ? signed(row.changePct, '%') : '—'} </td>
</td> <td class="px-4 py-3">
<td class="px-4 py-3"> <div class="w-full min-w-24">
<div class="w-full min-w-24"> <div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div> </div>
</div> </td>
</td> {/snippet}
</tr> </CollapsibleTable>
{/each}
</tbody>
</table>
</div>
{#if mauMonths.length > 3}
<button
onclick={() => (mauExpanded = !mauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{:else} {:else}
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)} <CollapsibleTable
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))} rows={calendarMauMonths}
<div class="overflow-x-auto rounded-xl border border-white/10"> headers={[
<table class="w-full text-sm"> { label: 'Month' },
<thead> { label: 'Active Users', align: 'right' },
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"> { label: 'Mo/Mo Change', align: 'right' },
<th class="text-left px-4 py-3">Month</th> { label: '', width: 'w-48' },
<th class="text-right px-4 py-3">Active Users</th> ]}
<th class="text-right px-4 py-3">Mo/Mo Change</th> modes={mauModes}
<th class="px-4 py-3 w-48"></th> bind:mode={mauMode}
</tr> >
</thead> {#snippet row(item)}
<tbody> {@const displayMau = item.projectedMau ?? item.mau}
{#each displayedCalMau as row (row.monthStart)} {@const barPct = Math.round((displayMau / maxCalMau) * 100)}
{@const displayMau = row.projectedMau ?? row.mau} <td class="px-4 py-3 text-gray-300">
{@const barPct = Math.round((displayMau / maxCalMau) * 100)} {item.label}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors"> {#if item.isCurrentMonth}
<td class="px-4 py-3 text-gray-300"> <span class="text-xs text-gray-500 ml-1">(projected)</span>
{row.label} {/if}
{#if row.isCurrentMonth} </td>
<span class="text-xs text-gray-500 ml-1">(projected)</span> <td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{/if} {#if item.isCurrentMonth}
</td> <span class="text-gray-500 text-xs">{item.mau}</span>{item.projectedMau ?? item.mau}
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}"> {:else}
{#if row.isCurrentMonth} {item.mau}
<span class="text-gray-500 text-xs">{row.mau}</span>{row.projectedMau ?? row.mau} {/if}
{:else} </td>
{row.mau} <td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
{/if} ? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
</td> : 'text-gray-500'}">
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}"> {#if item.changePct != null}
{#if row.changePct != null} {item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')} {:else}
{:else}
{/if}
{/if} </td>
</td> <td class="px-4 py-3">
<td class="px-4 py-3"> <div class="w-full min-w-24">
<div class="w-full min-w-24"> <div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div> </div>
</div> </td>
</td> {/snippet}
</tr> </CollapsibleTable>
{/each}
</tbody>
</table>
</div>
{#if calendarMauMonths.length > 3}
<button
onclick={() => (mauExpanded = !mauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if} {/if}
</section> </section>
@@ -625,112 +510,53 @@
<h2 class="text-lg font-semibold text-gray-100 mb-4"> <h2 class="text-lg font-semibold text-gray-100 mb-4">
Last 14 Days — Completions Last 14 Days — Completions
</h2> </h2>
<div class="overflow-x-auto rounded-xl border border-white/10"> <CollapsibleTable
<table class="w-full text-sm"> rows={last14Days}
<thead> headers={[
<tr { label: 'Date' },
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide" { label: 'Completions', align: 'right' },
> { label: '', width: 'w-48' },
<th class="text-left px-4 py-3">Date</th> ]}
<th class="text-right px-4 py-3">Completions</th> >
<th class="px-4 py-3 w-48"></th> {#snippet row(item)}
</tr> {@const barPct = Math.round((item.count / maxCount) * 100)}
</thead> <td class="px-4 py-3 text-gray-300">{item.date}</td>
<tbody> <td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
{#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)} <td class="px-4 py-3">
{@const barPct = Math.round( <div class="w-full min-w-24">
(row.count / maxCount) * 100, <div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
)} </div>
<tr </td>
class="border-t border-white/5 hover:bg-white/5 transition-colors" {/snippet}
> </CollapsibleTable>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.count}</td
>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-amber-500 h-4 rounded"
style="width: {barPct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</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">
<h2 class="text-lg font-semibold text-gray-100 mb-4"> <h2 class="text-lg font-semibold text-gray-100 mb-4">
Active Streak Distribution Active Streak Distribution
</h2> </h2>
{#if streakChart.length === 0} <CollapsibleTable
<p class="text-gray-400 text-sm px-4 py-6"> rows={streakChart}
No active streaks yet. headers={[
</p> { label: 'Days' },
{:else} { label: 'Players', align: 'right' },
<div class="overflow-x-auto rounded-xl border border-white/10"> { label: '', width: 'w-48' },
<table class="w-full text-sm"> ]}
<thead> >
<tr {#snippet row(item)}
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide" {@const barPct = Math.round((item.count / maxStreakCount) * 100)}
> <td class="px-4 py-3 text-gray-300">{item.days}</td>
<th class="text-left px-4 py-3">Days</th> <td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<th class="text-right px-4 py-3">Players</th> <td class="px-4 py-3">
<th class="px-4 py-3 w-48"></th> <div class="w-full min-w-24">
</tr> <div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
</thead> </div>
<tbody> </td>
{#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)} {/snippet}
{@const barPct = Math.round( {#snippet empty()}
(row.count / maxStreakCount) * 100, <p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
)} {/snippet}
<tr </CollapsibleTable>
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.days}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.count}</td
>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-blue-500 h-4 rounded"
style="width: {barPct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</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}
</section> </section>
<section class="mt-8"> <section class="mt-8">
@@ -747,67 +573,26 @@
<h3 class="text-base font-semibold text-gray-200 mb-3"> <h3 class="text-base font-semibold text-gray-200 mb-3">
7-Day Retention 7-Day Retention
</h3> </h3>
{#if retention7dSeries.length === 0} <CollapsibleTable
<p class="text-gray-400 text-sm px-4 py-6"> rows={retention7dSeries}
Not enough data yet. headers={[
</p> { label: 'Cohort Date' },
{:else} { label: 'n', align: 'right' },
<div { label: 'Ret. %', align: 'right' },
class="overflow-x-auto rounded-xl border border-white/10" { label: '', width: 'w-32' },
> ]}
<table class="w-full text-sm"> >
<thead> {#snippet row(item)}
<tr <td class="px-4 py-3 text-gray-300">{item.date}</td>
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide" <td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
> <td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<th class="text-left px-4 py-3" <td class="px-4 py-3">
>Cohort Date</th <div class="w-full min-w-20">
> <div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
<th class="text-right px-4 py-3">n</th> </div>
<th class="text-right px-4 py-3" </td>
>Ret. %</th {/snippet}
> </CollapsibleTable>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) as row (row.date)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-400 text-xs"
>{row.cohortSize}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.rate}%</td
>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div
class="bg-emerald-500 h-4 rounded"
style="width: {row.rate}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</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}
</div> </div>
<!-- 30-day retention --> <!-- 30-day retention -->
@@ -815,67 +600,26 @@
<h3 class="text-base font-semibold text-gray-200 mb-3"> <h3 class="text-base font-semibold text-gray-200 mb-3">
30-Day Retention 30-Day Retention
</h3> </h3>
{#if retention30dSeries.length === 0} <CollapsibleTable
<p class="text-gray-400 text-sm px-4 py-6"> rows={retention30dSeries}
Not enough data yet. headers={[
</p> { label: 'Cohort Date' },
{:else} { label: 'n', align: 'right' },
<div { label: 'Ret. %', align: 'right' },
class="overflow-x-auto rounded-xl border border-white/10" { label: '', width: 'w-32' },
> ]}
<table class="w-full text-sm"> >
<thead> {#snippet row(item)}
<tr <td class="px-4 py-3 text-gray-300">{item.date}</td>
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide" <td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
> <td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<th class="text-left px-4 py-3" <td class="px-4 py-3">
>Cohort Date</th <div class="w-full min-w-20">
> <div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
<th class="text-right px-4 py-3">n</th> </div>
<th class="text-right px-4 py-3" </td>
>Ret. %</th {/snippet}
> </CollapsibleTable>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) as row (row.date)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-400 text-xs"
>{row.cohortSize}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.rate}%</td
>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div
class="bg-violet-500 h-4 rounded"
style="width: {row.rate}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</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}
</div> </div>
</div> </div>
</section> </section>