-
-
SunWedSat
+
+
+ MonThuSun
-
+
{#each weeks as week, wi (wi)}
-
+
{#each week as cell (cell.date)}
{@const count = values.get(cell.date) ?? 0}
+ {@const alpha = scale.alphaFor(count) || 0.06}
+ {@const label = annotations[cell.date]}
+ class="cell-label flex items-center justify-center font-semibold tabular-nums"
+ data-pale={label && alpha < 0.4 ? 'true' : undefined}
+ style={`width:${cellSize}px;height:${cellSize}px;border-radius:${radius}px;font-size:${fontSize}px;line-height:1;${
+ cell.inRange ? `background:rgba(${color},${alpha})` : 'background:transparent'
+ }`}
+ title={cell.inRange
+ ? `${cell.date}: ${count.toLocaleString('en-US')} finishes`
+ : cell.date}
+ >{label ?? ''}
{/each}
{/each}
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/src/lib/utils/calendar-scale.ts b/src/lib/utils/calendar-scale.ts
new file mode 100644
index 0000000..748d8dc
--- /dev/null
+++ b/src/lib/utils/calendar-scale.ts
@@ -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];
+ }
+ };
+}
\ No newline at end of file
diff --git a/src/lib/utils/stats-format.ts b/src/lib/utils/stats-format.ts
index 87ef115..93ba6b9 100644
--- a/src/lib/utils/stats-format.ts
+++ b/src/lib/utils/stats-format.ts
@@ -1,7 +1,7 @@
/** Small formatting helpers shared by the stats dashboard components. */
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 {
diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte
index 4836d6f..555e960 100644
--- a/src/routes/global/+page.svelte
+++ b/src/routes/global/+page.svelte
@@ -5,11 +5,50 @@
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
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
= {};
+ for (const day of [bestDay, medianDay, worstDay]) {
+ if (day) out[day.date] = compactCount(day.count);
+ }
+ return out;
+ });
Community Stats | Bibdle
-
+
@@ -20,7 +59,7 @@
- All-time finishes
+ All-time plays
{numOrDash(data.completions)}
@@ -36,21 +75,61 @@
{numOrDash(data.monthlyPlayers)}
- Yesterday's finishes
+ Latest day's plays
{numOrDash(data.yesterdayCompletions)}
- Avg guesses yesterday
+ Avg guesses that day
{numOrDash(data.yesterdayAvgGuesses)}
-
- Daily finishes (last 30 days)
-
+
+ Daily plays (last {calendar.length} days)
+
+
+
+
+ Numbers mark the best, median, and least popular days.
+
+
+
+
+
+
+
- {calendar.length}-day plays
+ - {numOrDash(total)}
+
+
+
- Daily average
+ -
+ {avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })}
+
+
+ {#if bestDay}
+
+
- Best day ({calendar.length}d)
+ -
+ {numOrDash(bestDay.count)}
+ on {formatDateLabel(bestDay.date)}
+
+
+ {/if}
+ {#if data.bestDayAllTime}
+
+
- Best day · all-time
+ -
+ {numOrDash(data.bestDayAllTime.count)}
+ on {formatDateLabel(data.bestDayAllTime.date)}
+
+
+ {/if}
+
+
+
- Updated once a day from completed games. Play today's verse →
+ Updated once a day from completed games.
Play today's verse →
-
\ No newline at end of file
+