mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
2 Commits
83cfcc66c0
...
b6b41b6ba9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b41b6ba9 | ||
|
|
bdc08bc58e |
@@ -56,6 +56,12 @@ export const load: PageServerLoad = async () => {
|
|||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
.where(gte(dailyCompletions.date, sevenDaysAgo));
|
.where(gte(dailyCompletions.date, sevenDaysAgo));
|
||||||
|
|
||||||
|
const thirtyDaysAgo = estDateStr(30);
|
||||||
|
const [{ monthlyPlayers }] = await db
|
||||||
|
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, thirtyDaysAgo));
|
||||||
|
|
||||||
const todayPlayers = await db
|
const todayPlayers = await db
|
||||||
.selectDistinct({ id: dailyCompletions.anonymousId })
|
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
@@ -202,6 +208,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;
|
||||||
@@ -327,8 +353,99 @@ export const load: PageServerLoad = async () => {
|
|||||||
|
|
||||||
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
|
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
|
||||||
|
|
||||||
|
// ── Monthly Active Users history (6 months) ───────────────────────────────
|
||||||
|
|
||||||
|
const sixMonthsAgo = estDateStr(185);
|
||||||
|
const mauCompletions = await db
|
||||||
|
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, sixMonthsAgo));
|
||||||
|
|
||||||
|
// Rolling 30-day windows
|
||||||
|
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
|
||||||
|
for (let m = 0; m < 6; m++) {
|
||||||
|
const monthEnd = estDateStr(m * 30);
|
||||||
|
const monthStart = estDateStr(m * 30 + 29);
|
||||||
|
const users = new Set<string>();
|
||||||
|
for (const row of mauCompletions) {
|
||||||
|
if (row.date >= monthStart && row.date <= monthEnd) {
|
||||||
|
users.add(row.anonymousId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < mauMonths.length - 1; i++) {
|
||||||
|
const prev = mauMonths[i + 1].mau;
|
||||||
|
if (prev > 0) {
|
||||||
|
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar month windows
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
|
||||||
|
|
||||||
|
const calendarMauMonths: {
|
||||||
|
label: string;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
mau: number;
|
||||||
|
daysElapsed: number;
|
||||||
|
daysInMonth: number;
|
||||||
|
projectedMau: number | null;
|
||||||
|
changePct: number | null;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
let mo = todayMonth - i;
|
||||||
|
let yr = todayYear;
|
||||||
|
if (mo <= 0) { mo += 12; yr--; }
|
||||||
|
|
||||||
|
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
|
||||||
|
const daysInMonth = new Date(yr, mo, 0).getDate();
|
||||||
|
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
|
||||||
|
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||||
|
const isCurrentMonth = i === 0;
|
||||||
|
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
|
||||||
|
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
|
||||||
|
|
||||||
|
const users = new Set<string>();
|
||||||
|
for (const row of mauCompletions) {
|
||||||
|
if (row.date >= monthStart && row.date <= queryEnd) {
|
||||||
|
users.add(row.anonymousId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectedMau = isCurrentMonth && daysElapsed > 0
|
||||||
|
? Math.round(users.size * (daysInMonth / daysElapsed))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
calendarMauMonths.push({
|
||||||
|
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
|
||||||
|
monthStart,
|
||||||
|
monthEnd,
|
||||||
|
mau: users.size,
|
||||||
|
daysElapsed,
|
||||||
|
daysInMonth,
|
||||||
|
projectedMau,
|
||||||
|
changePct: null,
|
||||||
|
isCurrentMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
|
||||||
|
const curr = calendarMauMonths[i];
|
||||||
|
const prev = calendarMauMonths[i + 1];
|
||||||
|
if (prev.mau > 0) {
|
||||||
|
const compareVal = curr.projectedMau ?? curr.mau;
|
||||||
|
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todayEst,
|
todayEst,
|
||||||
|
sessionDepthCards,
|
||||||
stats: {
|
stats: {
|
||||||
todayCount,
|
todayCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
@@ -337,7 +454,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
activeStreaks,
|
activeStreaks,
|
||||||
avgGuessesToday,
|
avgGuessesToday,
|
||||||
registeredUsers,
|
registeredUsers,
|
||||||
avgCompletionsPerPlayer
|
monthlyPlayers
|
||||||
},
|
},
|
||||||
growth: {
|
growth: {
|
||||||
completionsVelocity,
|
completionsVelocity,
|
||||||
@@ -360,6 +477,8 @@ export const load: PageServerLoad = async () => {
|
|||||||
change: returnRateChange
|
change: returnRateChange
|
||||||
},
|
},
|
||||||
wauWeeks,
|
wauWeeks,
|
||||||
avgWau
|
avgWau,
|
||||||
|
mauMonths,
|
||||||
|
calendarMauMonths
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
activeStreaks: number;
|
activeStreaks: number;
|
||||||
avgGuessesToday: number | null;
|
avgGuessesToday: number | null;
|
||||||
registeredUsers: number;
|
registeredUsers: number;
|
||||||
avgCompletionsPerPlayer: number | null;
|
monthlyPlayers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
@@ -51,6 +51,19 @@
|
|||||||
changePct: number | null;
|
changePct: number | null;
|
||||||
}[];
|
}[];
|
||||||
avgWau: number;
|
avgWau: number;
|
||||||
|
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
|
||||||
|
calendarMauMonths: {
|
||||||
|
label: string;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
mau: number;
|
||||||
|
daysElapsed: number;
|
||||||
|
daysInMonth: number;
|
||||||
|
projectedMau: number | null;
|
||||||
|
changePct: number | null;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}[];
|
||||||
|
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -68,8 +81,21 @@
|
|||||||
newPlayerReturnVelocity,
|
newPlayerReturnVelocity,
|
||||||
wauWeeks,
|
wauWeeks,
|
||||||
avgWau,
|
avgWau,
|
||||||
|
mauMonths,
|
||||||
|
calendarMauMonths,
|
||||||
|
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');
|
||||||
|
|
||||||
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}`;
|
||||||
@@ -97,13 +123,7 @@
|
|||||||
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
|
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
|
||||||
{ label: "Active Streaks", value: String(stats.activeStreaks) },
|
{ label: "Active Streaks", value: String(stats.activeStreaks) },
|
||||||
{ label: "Registered Users", value: String(stats.registeredUsers) },
|
{ label: "Registered Users", value: String(stats.registeredUsers) },
|
||||||
{
|
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
|
||||||
label: "Avg Completions/Player",
|
|
||||||
value:
|
|
||||||
stats.avgCompletionsPerPlayer != null
|
|
||||||
? stats.avgCompletionsPerPlayer.toFixed(2)
|
|
||||||
: "N/A",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Overall Return Rate",
|
label: "Overall Return Rate",
|
||||||
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
||||||
@@ -259,6 +279,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 +379,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 +417,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 +453,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 +494,131 @@
|
|||||||
</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 class="mt-8">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<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">
|
||||||
|
{mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if mauMode === 'rolling'}
|
||||||
|
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)}
|
||||||
|
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))}
|
||||||
|
<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">
|
||||||
|
<th class="text-left px-4 py-3">Period</th>
|
||||||
|
<th class="text-right px-4 py-3">Active Users</th>
|
||||||
|
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedMauMonths as row (row.monthEnd)}
|
||||||
|
{@const barPct = Math.round((row.mau / maxMau) * 100)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300 text-xs">{row.monthStart} – {row.monthEnd}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</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-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/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}
|
||||||
|
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
|
||||||
|
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
|
||||||
|
<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">
|
||||||
|
<th class="text-left px-4 py-3">Month</th>
|
||||||
|
<th class="text-right px-4 py-3">Active Users</th>
|
||||||
|
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedCalMau as row (row.monthStart)}
|
||||||
|
{@const displayMau = row.projectedMau ?? row.mau}
|
||||||
|
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300">
|
||||||
|
{row.label}
|
||||||
|
{#if row.isCurrentMonth}
|
||||||
|
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||||
|
{#if row.isCurrentMonth}
|
||||||
|
<span class="text-gray-500 text-xs">{row.mau} → </span>{row.projectedMau ?? row.mau}
|
||||||
|
{:else}
|
||||||
|
{row.mau}
|
||||||
|
{/if}
|
||||||
|
</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'}">
|
||||||
|
{#if row.changePct != null}
|
||||||
|
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/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}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
@@ -462,7 +637,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 +664,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 +695,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 +722,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 +771,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 +799,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 +839,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 +867,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user