feat(stats): quantile-scaled, annotated calendar heatmap

Replace the linear count/max alpha with quantile buckets (GitHub-style)
so a single outlier day no longer flattens the rest of the graph, and
render best/median/worst day labels inside their cells.

- Add buildCalendarScale utility with empty-bucket handling.
- Make CalendarHeatmap size, gap, and annotations configurable; align
  weeks to Monday.
- Expand the community calendar to 90 days, add play totals/averages and
  all-time best day, and format large numbers with locale separators.
This commit is contained in:
George Powell committed 2026-09-28 20:21:48 -04:00
1 parent a562ee3cc9
commit da29ee7196
4 files changed
+249 -27

No files matched your search

@@ -1,22 +1,39 @@
<script lang="ts"> <script lang="ts">
import { buildCalendarScale } from '$lib/utils/calendar-scale';
interface Props { interface Props {
data: { date: string; count: number }[]; data: { date: string; count: number }[];
color?: string; color?: string;
/** Pixel size of each day square. */
cellSize?: number;
/** Gap between squares in pixels. */
gap?: number;
/** Optional date → short label rendered inside the square. */
annotations?: Record<string, string>;
} }
let { data, color = '52,211,153' }: Props = $props(); let {
data,
color = '52,211,153',
cellSize = 12,
gap = 3,
annotations = {}
}: Props = $props();
const values = $derived(new Map(data.map((d) => [d.date, d.count]))); const values = $derived(new Map(data.map((d) => [d.date, d.count])));
const max = $derived(Math.max(1, ...data.map((d) => d.count))); const scale = $derived(buildCalendarScale(data.map((d) => d.count)));
const fontSize = $derived(Math.max(8, Math.round(cellSize * 0.42)));
const radius = $derived(Math.max(2, Math.round(cellSize * 0.16)));
// Build Sunday-aligned weeks ending on the last date. // Build Monday-aligned weeks ending on the last date (weekend at the bottom).
const weeks = $derived.by(() => { const weeks = $derived.by(() => {
if (data.length === 0) return [] as { date: string; inRange: boolean }[][]; if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
const mondayOffset = (date: Date) => (date.getUTCDay() + 6) % 7;
const first = new Date(data[0].date + 'T00:00:00Z'); const first = new Date(data[0].date + 'T00:00:00Z');
const start = new Date(first); const start = new Date(first);
start.setUTCDate(start.getUTCDate() - start.getUTCDay()); start.setUTCDate(start.getUTCDate() - mondayOffset(start));
const last = new Date(data[data.length - 1].date + 'T00:00:00Z'); const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
const end = new Date(last); const end = new Date(last);
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay())); end.setUTCDate(end.getUTCDate() + (6 - mondayOffset(end)));
const range = new Set(data.map((d) => d.date)); const range = new Set(data.map((d) => d.date));
const out: { date: string; inRange: boolean }[][] = []; const out: { date: string; inRange: boolean }[][] = [];
let cursor = new Date(start); let cursor = new Date(start);
@@ -34,25 +51,55 @@
</script> </script>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div class="flex gap-1"> <div class="flex" style={`gap:${Math.max(4, gap)}px`}>
<div class="flex flex-col justify-between py-0.5 text-[9px] text-gray-500"> <div
<span>Sun</span><span>Wed</span><span>Sat</span> class="flex flex-col justify-between py-0.5 text-gray-500"
style={`font-size:${fontSize}px`}
>
<span>Mon</span><span>Thu</span><span>Sun</span>
</div> </div>
<div class="flex gap-[3px]"> <div class="flex" style={`gap:${gap}px`}>
{#each weeks as week, wi (wi)} {#each weeks as week, wi (wi)}
<div class="flex flex-col gap-[3px]"> <div class="flex flex-col" style={`gap:${gap}px`}>
{#each week as cell (cell.date)} {#each week as cell (cell.date)}
{@const count = values.get(cell.date) ?? 0} {@const count = values.get(cell.date) ?? 0}
{@const alpha = scale.alphaFor(count) || 0.06}
{@const label = annotations[cell.date]}
<div <div
class="h-3 w-3 rounded-[2px]" class="cell-label flex items-center justify-center font-semibold tabular-nums"
style={cell.inRange data-pale={label && alpha < 0.4 ? 'true' : undefined}
? `background:rgba(${color},${(0.12 + 0.88 * (count / max)).toFixed(3)})` style={`width:${cellSize}px;height:${cellSize}px;border-radius:${radius}px;font-size:${fontSize}px;line-height:1;${
: 'background:transparent'} cell.inRange ? `background:rgba(${color},${alpha})` : 'background:transparent'
title={cell.inRange ? `${cell.date}: ${count} finishes` : cell.date} }`}
></div> title={cell.inRange
? `${cell.date}: ${count.toLocaleString('en-US')} finishes`
: cell.date}
>{label ?? ''}</div>
{/each} {/each}
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
</div> </div>
<style>
/*
* Annotated numbers need to read on both light and dark cell backgrounds.
* Intensity is the dominant factor: the bright/high buckets are light enough
* for dark ink in either theme, while the lowest bucket is a dark green on a
* dark page and needs light ink there.
*/
.cell-label {
color: rgb(17 24 39 / 0.95);
}
:global(html.dark) .cell-label[data-pale='true'] {
color: rgb(249 250 251 / 0.95);
}
@media (prefers-color-scheme: dark) {
:global(html:not(.light)) .cell-label[data-pale='true'] {
color: rgb(249 250 251 / 0.95);
}
}
</style>
+96
View File
@@ -0,0 +1,96 @@
/**
* Quantile-bucketed color scale for calendar/heatmap charts.
*
* A naive `count / max` alpha collapses most cells into the same pale shade when
* a single day is an outlier. Bucketing by quantiles of the non-zero values
* (like GitHub's contribution graph) keeps typical variation visible and never
* changes when a new all-time-high day appears.
*/
export interface CalendarBucket {
/** Lowest count in this bucket. */
min: number;
/** Highest count in this bucket. */
max: number;
/** Alpha (0..1) applied to the base color. */
alpha: number;
/** How many non-zero days fall in this bucket. */
days: number;
}
export interface CalendarScale {
min: number;
max: number;
/** Non-empty intensity buckets, low → high. Zero is rendered separately. */
buckets: CalendarBucket[];
/** Alpha for a raw count (0 for a zero/empty day). */
alphaFor: (count: number) => number;
}
/** Linear-interpolated quantile over an already-sorted ascending array. */
export function quantile(sorted: number[], q: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const pos = (sorted.length - 1) * q;
const base = Math.floor(pos);
const rest = pos - base;
const next = sorted[base + 1];
return next === undefined ? sorted[base] : sorted[base] + rest * (next - sorted[base]);
}
/**
* Build a quantile scale from a list of daily counts.
*
* The non-zero counts are split into `steps` intensity buckets at evenly spaced
* quantiles; duplicate-heavy data is tolerated because empty buckets are dropped.
*/
export function buildCalendarScale(counts: number[], steps = 4): CalendarScale {
const nonzero = counts.filter((c) => c > 0).sort((a, b) => a - b);
const min = counts.length ? Math.min(...counts) : 0;
const max = counts.length ? Math.max(...counts) : 0;
if (nonzero.length === 0) {
return { min, max, buckets: [], alphaFor: () => 0 };
}
const alphas =
steps <= 1
? [0.9]
: Array.from({ length: steps }, (_, i) => 0.28 + (0.72 * i) / (steps - 1));
const thresholds: number[] = [];
for (let i = 1; i < steps; i++) thresholds.push(quantile(nonzero, i / steps));
const bucketIndex = (count: number): number => {
if (count <= 0) return -1;
let index = 0;
for (const threshold of thresholds) {
if (count > threshold) index++;
}
return index;
};
const buckets: CalendarBucket[] = alphas.map((alpha) => ({
alpha,
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
days: 0
}));
for (const count of nonzero) {
const bucket = buckets[bucketIndex(count)];
bucket.min = Math.min(bucket.min, count);
bucket.max = Math.max(bucket.max, count);
bucket.days++;
}
return {
min,
max,
buckets: buckets.filter((bucket) => bucket.days > 0),
alphaFor: (count: number) => {
const index = bucketIndex(count);
return index < 0 ? 0 : alphas[index];
}
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
/** Small formatting helpers shared by the stats dashboard components. */ /** Small formatting helpers shared by the stats dashboard components. */
export function numOrDash(n: number | null | undefined): string { export function numOrDash(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : String(n); return n === null || n === undefined ? '—' : n.toLocaleString('en-US');
} }
export function pctOrDash(n: number | null | undefined): string { export function pctOrDash(n: number | null | undefined): string {
+88 -9
View File
@@ -5,11 +5,50 @@
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format'; import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const calendar = $derived(data.calendar);
const active = $derived(calendar.filter((d) => d.count > 0));
const total = $derived(calendar.reduce((sum, d) => sum + d.count, 0));
const avgPerDay = $derived(calendar.length ? total / calendar.length : 0);
const bestDay = $derived(
active.reduce<{ date: string; count: number } | null>(
(best, d) => (best === null || d.count > best.count ? d : best),
null
)
);
const worstDay = $derived(
active.reduce<{ date: string; count: number } | null>(
(worst, d) => (worst === null || d.count < worst.count ? d : worst),
null
)
);
const medianDay = $derived.by(() => {
if (active.length === 0) return null;
const sorted = [...active].sort((a, b) => a.count - b.count);
return sorted[Math.floor((sorted.length - 1) / 2)];
});
/** Compact enough to sit inside a day square. */
function compactCount(n: number): string {
if (n >= 10000) return `${Math.round(n / 1000)}k`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
const annotations = $derived.by(() => {
const out: Record<string, string> = {};
for (const day of [bestDay, medianDay, worstDay]) {
if (day) out[day.date] = compactCount(day.count);
}
return out;
});
</script> </script>
<svelte:head> <svelte:head>
<title>Community Stats | Bibdle</title> <title>Community Stats | Bibdle</title>
<meta name="description" content="Bibdle community statistics, as of yesterday." /> <meta name="description" content="Bibdle community statistics, as of the latest completed day." />
</svelte:head> </svelte:head>
<div class="mx-auto max-w-3xl px-4 py-8"> <div class="mx-auto max-w-3xl px-4 py-8">
@@ -20,7 +59,7 @@
<div class="grid grid-cols-2 gap-3 md:grid-cols-3"> <div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<Container class="p-4 w-full items-start text-left"> <Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time finishes</p> <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time plays</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p>
</Container> </Container>
<Container class="p-4 w-full items-start text-left"> <Container class="p-4 w-full items-start text-left">
@@ -36,21 +75,61 @@
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p>
</Container> </Container>
<Container class="p-4 w-full items-start text-left"> <Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Yesterday's finishes</p> <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Latest day's plays</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p>
</Container> </Container>
<Container class="p-4 w-full items-start text-left"> <Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses yesterday</p> <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses that day</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p>
</Container> </Container>
</div> </div>
<Container class="mt-3 p-4 w-full items-stretch text-left gap-2"> <Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily finishes (last 30 days)</p> <p class="font-semibold text-gray-900 dark:text-gray-100">Daily plays (last {calendar.length} days)</p>
<CalendarHeatmap data={data.calendar} /> <div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="flex flex-col gap-1">
<CalendarHeatmap data={calendar} cellSize={22} annotations={annotations} />
<p class="text-[10px] text-gray-500 dark:text-gray-400">
Numbers mark the best, median, and least popular days.
</p>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-4">
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{calendar.length}-day plays</dt>
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">{numOrDash(total)}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Daily average</dt>
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })}
</dd>
</div>
{#if bestDay}
<div>
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Best day ({calendar.length}d)</dt>
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{numOrDash(bestDay.count)}
<span class="font-normal text-gray-500 dark:text-gray-400">on {formatDateLabel(bestDay.date)}</span>
</dd>
</div>
{/if}
{#if data.bestDayAllTime}
<div>
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Best day · all-time</dt>
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{numOrDash(data.bestDayAllTime.count)}
<span class="font-normal text-gray-500 dark:text-gray-400">on {formatDateLabel(data.bestDayAllTime.date)}</span>
</dd>
</div>
{/if}
</dl>
</div>
</div>
</Container> </Container>
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500"> <p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
Updated once a day from completed games. <a class="underline hover:text-gray-300" href="/">Play today's verse →</a> Updated once a day from completed games.<br /> <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
</p> </p>
</div> </div>