aboutsummaryrefslogtreecommitdiff
path: root/website/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-06 00:41:49 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-06 00:41:49 +0300
commit82487675908e986ec3d4996151d952f086140c7f (patch)
treeb5c835a407f424b5f33efd9b933dd4d5cafb540c /website/src
parentcbd9b07fac8a84ef2b55e85929983ff594edb0a4 (diff)
feat: add public stats page at herdr.dev/stats
Diffstat (limited to 'website/src')
-rw-r--r--website/src/components/MarketingLayout.astro2
-rw-r--r--website/src/pages/stats.astro270
-rw-r--r--website/src/scripts/stats-page.ts742
3 files changed, 1014 insertions, 0 deletions
diff --git a/website/src/components/MarketingLayout.astro b/website/src/components/MarketingLayout.astro
index 2a1d026b..adb9e6bc 100644
--- a/website/src/components/MarketingLayout.astro
+++ b/website/src/components/MarketingLayout.astro
@@ -77,6 +77,7 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
<a href="/">Home</a>
<a href="/compare/">Compare</a>
<a href="/plugins/">Plugins</a>
+ <!-- <a href="/stats/">Stats</a> -->
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
@@ -89,6 +90,7 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
<a href="/">Home</a>
<a href="/compare/">Compare</a>
<a href="/plugins/">Plugins</a>
+ <!-- <a href="/stats/">Stats</a> -->
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
diff --git a/website/src/pages/stats.astro b/website/src/pages/stats.astro
new file mode 100644
index 00000000..fb9490e2
--- /dev/null
+++ b/website/src/pages/stats.astro
@@ -0,0 +1,270 @@
+---
+import MarketingLayout from '../components/MarketingLayout.astro';
+---
+
+<MarketingLayout
+ title="Herdr stats: live public numbers"
+ description="Live public stats for Herdr — GitHub stars, downloads, and website traffic, pulled from publicly verifiable sources."
+ canonicalPath="/stats/"
+>
+ <main class="stats-page">
+ <section class="hero stats-hero">
+ <div class="hero-left">
+ <div class="hero-copy">
+ <p class="eyebrow">
+ <span class="status-dot dot-working"></span>
+ build in public · live numbers
+ </p>
+ <div class="hero-stat-value" id="star-count">—</div>
+ <div class="hero-stat-label"><span class="live-dot" aria-hidden="true"></span> GitHub stars · counting live</div>
+ <p class="hero-lede">
+ Live public stats for herdr: stars, installs, and traffic. Everything on this
+ page comes from sources you can check yourself, refreshed continuously.
+ </p>
+ <div class="hero-actions">
+ <a class="button button-primary" href="https://github.com/ogulcancelik/herdr">★ Star on GitHub</a>
+ <a class="button button-secondary" href="/docs/install/">Get herdr</a>
+ </div>
+ <p class="hero-note" id="hero-note"></p>
+ </div>
+ </div>
+ <div class="hero-right">
+ <div class="chart-card">
+ <div class="chart-card-title">
+ <span>github stars · all time</span>
+ <span class="when" id="stars-since"></span>
+ </div>
+ <div id="chart-stars" class="chart-slot"></div>
+ </div>
+ </div>
+ </section>
+
+ <div class="stats-main">
+ <section class="kpi-row" aria-label="Headline numbers">
+ <div class="kpi">
+ <div class="label">total installs</div>
+ <div class="value" id="kpi-installs">—</div>
+ <div class="sub" id="kpi-installs-sub"></div>
+ </div>
+ <div class="kpi">
+ <div class="label">daily visitors</div>
+ <div class="value" id="kpi-visitors">—</div>
+ <div class="sub">herdr.dev uniques · 7-day avg · cloudflare</div>
+ </div>
+ <div class="kpi">
+ <div class="label">releases shipped</div>
+ <div class="value" id="kpi-releases">—</div>
+ <div class="sub">stable + preview channels</div>
+ </div>
+ <div class="kpi">
+ <div class="label">days old</div>
+ <div class="value" id="kpi-age">—</div>
+ <div class="sub" id="kpi-age-sub"></div>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2>Week by week</h2>
+ <p class="section-sub">Weekly totals. The current week is still filling up, shown dimmed.</p>
+ <div class="velocity-grid">
+ <div class="chart-card">
+ <div class="chart-card-title"><span>stars / week</span></div>
+ <div id="chart-stars-week" class="chart-slot"></div>
+ </div>
+ <div class="chart-card">
+ <div class="chart-card-title"><span>downloads / week</span></div>
+ <div id="chart-downloads-week" class="chart-slot"></div>
+ </div>
+ <div class="chart-card">
+ <div class="chart-card-title"><span>avg daily visitors / week</span></div>
+ <div id="chart-visitors-week" class="chart-slot"></div>
+ </div>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2>Downloads</h2>
+ <p class="section-sub" id="downloads-sub"></p>
+ <div class="chart-card">
+ <div class="chart-card-title">
+ <span>release downloads · cumulative</span>
+ <span class="when">github api</span>
+ </div>
+ <div id="chart-downloads" class="chart-slot"></div>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2>Where the herd browses from</h2>
+ <p class="section-sub" id="traffic-sub"></p>
+ <div class="traffic-grid">
+ <div class="chart-card">
+ <div class="chart-card-title"><span>top countries</span><span class="when">visits</span></div>
+ <div id="rank-countries"></div>
+ </div>
+ <div class="chart-card">
+ <div class="chart-card-title"><span>top referrers</span><span class="when">visits</span></div>
+ <div id="rank-referrers"></div>
+ </div>
+ <div class="chart-card">
+ <div class="chart-card-title"><span>top pages</span><span class="when">page views</span></div>
+ <div id="rank-paths"></div>
+ </div>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2>Timeline</h2>
+ <p class="section-sub">From first commit to today.</p>
+ <div id="timeline"></div>
+ </section>
+
+ <p class="sources-note" id="sources-note">
+ Every number on this page comes from publicly verifiable sources: the
+ <a href="https://api.github.com/repos/ogulcancelik/herdr">GitHub API</a> for stars and release downloads, and
+ <a href="https://formulae.brew.sh/api/analytics/install/365d.json">Homebrew analytics</a> for brew installs.
+ Website traffic is measured by Cloudflare (unique visitors per day, herdr.dev zone).
+ <span id="updated-note"></span>
+ </p>
+ </div>
+
+ <script>
+ import { initStatsPage } from '../scripts/stats-page';
+ initStatsPage();
+ </script>
+ </main>
+</MarketingLayout>
+
+<style is:global>
+ .stats-hero { min-height: auto; }
+ .stats-hero .hero-left { padding: 3.5rem 3rem 3.5rem max(2.5rem, calc((100vw - var(--max)) / 2)); }
+ .stats-hero .hero-right { padding: 3rem max(2.5rem, calc((100vw - var(--max)) / 2)) 3rem 2.5rem; }
+ .stats-hero .chart-card { width: 100%; position: relative; z-index: 1; }
+ .hero-stat-value {
+ font-family: var(--mono);
+ font-weight: 600;
+ font-size: clamp(3.2rem, 7vw, 5.2rem);
+ line-height: 1;
+ letter-spacing: -0.04em;
+ color: var(--ink);
+ min-height: 1em;
+ }
+ .hero-stat-label {
+ display: flex; align-items: center; gap: 0.55rem;
+ margin-top: 0.55rem;
+ font-family: var(--mono); font-size: 0.95rem; color: var(--muted);
+ }
+ .live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: live-pulse 2.4s ease-in-out infinite; }
+ @keyframes live-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
+ @media (prefers-reduced-motion: reduce) { .live-dot { animation: none; } }
+
+ .stats-main { max-width: var(--max); margin: 0 auto; padding: 0 1.5rem 4rem; }
+ .stats-section { margin-top: 3.5rem; }
+ .stats-section > h2 { font-family: var(--mono); font-weight: 400; letter-spacing: -0.02em; font-size: 1.45rem; margin: 0 0 0.35rem; color: var(--ink); }
+ .stats-section > .section-sub { color: var(--muted); font-size: 0.95rem; margin: 0 0 1.4rem; max-width: 62ch; }
+
+ .chart-card {
+ border: 1px solid var(--line); border-radius: var(--radius-lg);
+ background: var(--bg-elevated); padding: 1rem 1.1rem 0.8rem; position: relative;
+ }
+ .chart-card-title {
+ display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
+ font-family: var(--mono); font-size: 0.85rem; color: var(--ink-soft); margin-bottom: 0.5rem;
+ }
+ .chart-card-title .when { color: var(--muted-2); font-size: 0.78rem; }
+ .chart { position: relative; }
+ .chart svg { display: block; width: 100%; height: auto; }
+ .chart svg:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }
+
+ .chart .grid { stroke: var(--line); stroke-width: 1; }
+ .chart .grid.strong { stroke: var(--line-strong); }
+ .chart .tick { fill: var(--muted-2); font-family: var(--mono); font-size: 11px; }
+ .chart .line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+ .chart .area { opacity: 0.1; }
+ .chart .dot { stroke: var(--bg-elevated); stroke-width: 2; }
+ .chart .event-dot { fill: var(--bg-elevated); stroke-width: 2; }
+ .chart .milestone-label { fill: var(--muted); font-family: var(--mono); font-size: 11.5px; font-weight: 600; }
+ .chart .milestone-leader { stroke: var(--line-strong); stroke-width: 1; }
+ .chart .cap-label { fill: var(--muted); font-family: var(--mono); font-size: 11.5px; font-weight: 600; }
+ .chart .crosshair { stroke: var(--line-strong); stroke-width: 1; }
+ .chart .hover-dot { stroke: var(--bg-elevated); stroke-width: 2; }
+ .chart .bar .hit { fill: transparent; }
+ .chart .bar.partial .fill { opacity: 0.45; }
+ .chart .bar:hover .fill, .chart .bar.active .fill { filter: brightness(1.12); }
+
+ .chart-tip {
+ position: absolute; pointer-events: none; z-index: 4;
+ background: var(--ink); color: var(--bg);
+ font-family: var(--mono); font-size: 0.78rem; line-height: 1.4;
+ padding: 0.35rem 0.55rem; border-radius: var(--radius-md);
+ white-space: nowrap; transform: translate(-50%, calc(-100% - 10px));
+ }
+ .chart-tip strong { font-size: 0.88rem; }
+ .chart-empty { color: var(--muted); font-family: var(--mono); font-size: 0.85rem; padding: 2rem 0; text-align: center; }
+
+ .chart-table { margin-top: 0.4rem; font-family: var(--mono); font-size: 0.78rem; }
+ .chart-table summary { color: var(--muted-2); cursor: pointer; padding: 0.25rem 0; }
+ .chart-table summary:hover { color: var(--muted); }
+ .chart-table-scroll {
+ max-height: 250px; overflow-y: auto; margin-top: 0.4rem;
+ scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent;
+ }
+ .chart-table-scroll::-webkit-scrollbar { width: 8px; }
+ .chart-table-scroll::-webkit-scrollbar-track { background: transparent; }
+ .chart-table-scroll::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
+ /* the site stylesheet styles tables globally (min-width, accent last column) — reset inside cards */
+ .chart-table table { width: 100%; min-width: 0; border-collapse: collapse; font-size: 0.78rem; }
+ .chart-table th, .chart-table td { text-align: left; padding: 0.28rem 0.6rem 0.28rem 0; border-bottom: 1px solid var(--line); color: var(--ink-soft); font-variant-numeric: tabular-nums; font-size: 0.78rem; }
+ .chart-table th { color: var(--muted); font-weight: 600; letter-spacing: 0.04em; }
+ .chart-table th:last-child, .chart-table td:last-child { background: none; color: var(--ink-soft); font-weight: 600; text-align: right; padding-right: 0; }
+ .chart-table tr:last-child td { border-bottom: 0; }
+
+ .kpi-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.9rem; margin-top: 3rem; }
+ .kpi {
+ border: 1px solid var(--line); border-radius: var(--radius-lg);
+ background: var(--bg-elevated); padding: 1rem 1.1rem;
+ }
+ .kpi .label { font-family: var(--mono); font-size: 0.78rem; color: var(--muted); }
+ .kpi .value { font-family: var(--mono); font-weight: 600; font-size: 1.9rem; letter-spacing: -0.02em; color: var(--ink); margin: 0.25rem 0 0.15rem; }
+ .kpi .sub { font-family: var(--mono); font-size: 0.75rem; color: var(--muted-2); }
+ .kpi .sub .up { color: var(--green); font-weight: 600; }
+
+ .velocity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.9rem; }
+ .traffic-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.9rem; }
+
+ .rank-list { display: flex; flex-direction: column; gap: 0.15rem; margin-top: 0.3rem; }
+ .rank-row {
+ display: grid; grid-template-columns: minmax(0, 45%) 1fr auto;
+ gap: 0.7rem; align-items: center; padding: 0.28rem 0;
+ }
+ .rank-name { font-family: var(--mono); font-size: 0.8rem; color: var(--ink-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .rank-bar { height: 8px; border-radius: 2px; background: color-mix(in srgb, var(--yellow) 14%, transparent); overflow: hidden; }
+ .rank-fill { display: block; height: 100%; border-radius: 2px; background: var(--yellow); }
+ .rank-value { font-family: var(--mono); font-size: 0.78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
+
+ .milestone-term {
+ background: var(--term-bg); border: 1px solid var(--term-border); border-radius: var(--radius-lg);
+ font-family: var(--mono); font-size: 0.92rem; padding: 1.1rem 1.3rem; max-width: 560px;
+ }
+ .term-line { display: flex; gap: 1.2rem; padding: 0.22rem 0; align-items: baseline; }
+ .term-cmd-line { margin-bottom: 0.5rem; gap: 0.6rem; }
+ .term-prompt { color: var(--term-prompt); }
+ .term-cmd { color: var(--term-text); }
+ .term-date { color: var(--term-dim); flex: 0 0 4.4em; }
+ .term-text { color: var(--term-text); flex: 1; }
+ .term-note { color: var(--term-muted); }
+ .term-line:last-child .term-text { color: var(--term-green-bright); font-weight: 600; }
+ .term-line:last-child .term-note { color: var(--term-green-bright); }
+
+ .sources-note { margin-top: 3.5rem; padding-top: 1.4rem; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.88rem; max-width: 72ch; }
+ .sources-note a { color: var(--accent); }
+
+ .stats-stale {
+ margin-top: 1rem; font-family: var(--mono); font-size: 0.8rem; color: var(--yellow);
+ }
+
+ @media (max-width: 900px) {
+ .kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .velocity-grid, .traffic-grid { grid-template-columns: minmax(0, 1fr); }
+ }
+</style>
diff --git a/website/src/scripts/stats-page.ts b/website/src/scripts/stats-page.ts
new file mode 100644
index 00000000..7fcada3d
--- /dev/null
+++ b/website/src/scripts/stats-page.ts
@@ -0,0 +1,742 @@
+// Client renderer for /stats/. Fetches the public stats snapshot from R2 and
+// renders every number and chart client-side, so the static page never goes stale.
+
+const STATS_URL = "https://assets.herdr.dev/stats/stats.json";
+const REPO_API_URL = "https://api.github.com/repos/ogulcancelik/herdr";
+
+const LINE_H = 320;
+const BAR_W = 420;
+const BAR_H = 240;
+const STAR_MILESTONES = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000];
+const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
+
+type SeriesPoint = { d: string; v: number };
+
+type StatsJson = {
+ schemaVersion: number;
+ generatedAt: string;
+ repo: string;
+ bornDay: string;
+ stars: { total: number; daily: SeriesPoint[] };
+ downloads: { github: number; brew: number; daily: SeriesPoint[] };
+ releases: { shipped: number };
+ traffic: {
+ windowDays: number;
+ daily: SeriesPoint[];
+ topCountries: Array<{ code: string; visits: number }>;
+ topReferrers: Array<{ host: string; visits: number }>;
+ topPaths: Array<{ path: string; views: number }>;
+ };
+ timeline: Array<{ d: string; label: string; note?: string; badge?: string }>;
+};
+
+type WeekPoint = { weekStart: string; value: number; partial: boolean; daysIn: number };
+type Milestone = { day: string; value: number; label: string };
+
+// ---------- formatting ----------
+
+function fmtInt(value: number): string {
+ return Math.round(value).toLocaleString("en-US");
+}
+
+function fmtCompact(value: number): string {
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
+ if (value >= 10_000) return `${Math.round(value / 1_000)}k`;
+ if (value >= 1_000) {
+ const scaled = value / 1_000;
+ return `${Number.isInteger(scaled) ? scaled : scaled.toFixed(1)}k`;
+ }
+ return String(Math.round(value));
+}
+
+function fmtDay(day: string): string {
+ const date = new Date(`${day}T00:00:00Z`);
+ return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&amp;")
+ .replaceAll("<", "&lt;")
+ .replaceAll(">", "&gt;")
+ .replaceAll('"', "&quot;");
+}
+
+// ---------- date/series math ----------
+
+function dayEpoch(day: string): number {
+ return Date.parse(`${day}T00:00:00Z`);
+}
+
+function daysBetween(fromDay: string, toDay: string): number {
+ return Math.round((dayEpoch(toDay) - dayEpoch(fromDay)) / 86_400_000);
+}
+
+function todayUtc(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+function weekStartOf(day: string): string {
+ const date = new Date(`${day}T00:00:00Z`);
+ const sinceMonday = (date.getUTCDay() + 6) % 7;
+ return new Date(date.getTime() - sinceMonday * 86_400_000).toISOString().slice(0, 10);
+}
+
+function weekMeta(weekStart: string, lastDataDay: string | null): { partial: boolean; daysIn: number } {
+ if (!lastDataDay) return { partial: false, daysIn: 7 };
+ const weekEnd = new Date(dayEpoch(weekStart) + 6 * 86_400_000).toISOString().slice(0, 10);
+ const partial = lastDataDay < weekEnd || weekEnd >= todayUtc();
+ const daysIn = partial ? Math.min(daysBetween(weekStart, lastDataDay) + 1, 7) : 7;
+ return { partial, daysIn };
+}
+
+function deltasFromCumulative(daily: SeriesPoint[]): SeriesPoint[] {
+ const deltas: SeriesPoint[] = [];
+ for (let index = 1; index < daily.length; index += 1) {
+ deltas.push({ d: daily[index].d, v: Math.max(daily[index].v - daily[index - 1].v, 0) });
+ }
+ return deltas;
+}
+
+function weeklySums(days: SeriesPoint[], lastDataDay: string | null): WeekPoint[] {
+ const byWeek = new Map<string, number>();
+ for (const point of days) {
+ const week = weekStartOf(point.d);
+ byWeek.set(week, (byWeek.get(week) ?? 0) + point.v);
+ }
+ return [...byWeek.entries()]
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([weekStart, value]) => ({ weekStart, value, ...weekMeta(weekStart, lastDataDay) }));
+}
+
+function weeklyAverages(days: SeriesPoint[], lastDataDay: string | null): WeekPoint[] {
+ const byWeek = new Map<string, { sum: number; count: number }>();
+ for (const point of days) {
+ const week = weekStartOf(point.d);
+ const bucket = byWeek.get(week) ?? { sum: 0, count: 0 };
+ bucket.sum += point.v;
+ bucket.count += 1;
+ byWeek.set(week, bucket);
+ }
+ return [...byWeek.entries()]
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([weekStart, bucket]) => ({
+ weekStart,
+ value: Math.round(bucket.sum / bucket.count),
+ ...weekMeta(weekStart, lastDataDay),
+ }));
+}
+
+function starMilestones(daily: SeriesPoint[]): Milestone[] {
+ const milestones: Milestone[] = [];
+ let next = 0;
+ for (const point of daily) {
+ while (next < STAR_MILESTONES.length && point.v >= STAR_MILESTONES[next]) {
+ const value = STAR_MILESTONES[next];
+ milestones.push({ day: point.d, value, label: value >= 1_000 ? `${value / 1_000}k` : String(value) });
+ next += 1;
+ }
+ }
+ return milestones;
+}
+
+function niceTicks(max: number, count = 4): number[] {
+ if (max <= 0) return [0];
+ const rawStep = max / count;
+ const power = 10 ** Math.floor(Math.log10(rawStep));
+ const step = [1, 2, 2.5, 5, 10].map((mult) => mult * power).find((candidate) => candidate >= rawStep) ?? rawStep;
+ const ticks: number[] = [];
+ for (let tick = 0; tick <= max; tick += step) ticks.push(tick);
+ return ticks;
+}
+
+function monthStartsBetween(firstDay: string, lastDay: string): string[] {
+ const starts: string[] = [];
+ const last = dayEpoch(lastDay);
+ const cursor = new Date(`${firstDay.slice(0, 7)}-01T00:00:00Z`);
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1);
+ while (cursor.getTime() <= last) {
+ starts.push(cursor.toISOString().slice(0, 10));
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1);
+ }
+ return starts;
+}
+
+// ---------- chart builders (SVG strings) ----------
+
+type LineChartOptions = {
+ points: SeriesPoint[];
+ colorVar: string;
+ unit: string;
+ width: number;
+ milestones?: Milestone[];
+ events?: Array<{ day: string; label: string }>;
+};
+
+function lineChart(options: LineChartOptions): string {
+ const { points, width } = options;
+ if (points.length < 2) return `<p class="chart-empty">not enough data yet</p>`;
+
+ const margin = { top: 34, right: 26, bottom: 30, left: 54 };
+ const innerW = width - margin.left - margin.right;
+ const innerH = LINE_H - margin.top - margin.bottom;
+
+ const x0 = dayEpoch(points[0].d);
+ const x1 = dayEpoch(points[points.length - 1].d);
+ const yMaxData = Math.max(...points.map((point) => point.v));
+ const ticks = niceTicks(yMaxData);
+ const yMax = Math.max(ticks[ticks.length - 1], yMaxData) * 1.04;
+
+ const xFor = (day: string): number => margin.left + ((dayEpoch(day) - x0) / (x1 - x0)) * innerW;
+ const yFor = (value: number): number => margin.top + innerH - (value / yMax) * innerH;
+
+ const coords = points.map((point) => ({ x: xFor(point.d), y: yFor(point.v), day: point.d, value: point.v }));
+ const linePath = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(1)},${c.y.toFixed(1)}`).join("");
+ const baseline = margin.top + innerH;
+ const areaPath = `${linePath}L${coords[coords.length - 1].x.toFixed(1)},${baseline}L${coords[0].x.toFixed(1)},${baseline}Z`;
+
+ const grid = ticks
+ .map((tick) => {
+ const y = yFor(tick).toFixed(1);
+ return `<line class="grid" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"/>` +
+ `<text class="tick" x="${margin.left - 8}" y="${y}" dy="0.32em" text-anchor="end">${fmtCompact(tick)}</text>`;
+ })
+ .join("");
+
+ const monthLabels = monthStartsBetween(points[0].d, points[points.length - 1].d)
+ .map((day) => {
+ const x = xFor(day).toFixed(1);
+ return `<text class="tick" x="${x}" y="${baseline + 20}" text-anchor="middle">${MONTHS[new Date(`${day}T00:00:00Z`).getUTCMonth()]}</text>`;
+ })
+ .join("");
+
+ const milestoneMarks = (options.milestones ?? [])
+ .map((milestone) => {
+ const x = xFor(milestone.day);
+ const y = yFor(milestone.value);
+ return (
+ `<line class="milestone-leader" x1="${x.toFixed(1)}" y1="${(y - 9).toFixed(1)}" x2="${x.toFixed(1)}" y2="${(y - 18).toFixed(1)}"/>` +
+ `<circle class="dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4.5" style="fill:var(${options.colorVar})"/>` +
+ `<text class="milestone-label" x="${x.toFixed(1)}" y="${(y - 23).toFixed(1)}" text-anchor="middle">${milestone.label}</text>`
+ );
+ })
+ .join("");
+
+ const events = (options.events ?? []).map((event) => {
+ const x = xFor(event.day);
+ const nearest = coords.reduce((best, c) => (Math.abs(c.x - x) < Math.abs(best.x - x) ? c : best), coords[0]);
+ return { ...event, dotX: nearest.x, dotY: nearest.y };
+ });
+ const eventBaseX = Math.min(...events.map((event) => event.dotX), Infinity) - 12;
+ const eventMarks = events
+ .map((event, index) => {
+ const labelY = margin.top + 14 + index * 16;
+ return (
+ `<line class="milestone-leader" x1="${(eventBaseX + 4).toFixed(1)}" y1="${(labelY + 4).toFixed(1)}" x2="${(event.dotX - 2).toFixed(1)}" y2="${(event.dotY - 6).toFixed(1)}"/>` +
+ `<circle class="event-dot" cx="${event.dotX.toFixed(1)}" cy="${event.dotY.toFixed(1)}" r="4" style="stroke:var(${options.colorVar})"/>` +
+ `<text class="milestone-label" x="${eventBaseX.toFixed(1)}" y="${labelY.toFixed(1)}" text-anchor="end">${escapeHtml(event.label)}</text>`
+ );
+ })
+ .join("");
+
+ const end = coords[coords.length - 1];
+ const endDot = `<circle class="dot" cx="${end.x.toFixed(1)}" cy="${end.y.toFixed(1)}" r="4.5" style="fill:var(${options.colorVar})"/>`;
+
+ // escape < so the JSON can never terminate the wrapping <script> tag
+ const data = JSON.stringify({
+ unit: options.unit,
+ width,
+ points: coords.map((c) => ({ x: Number(c.x.toFixed(1)), y: Number(c.y.toFixed(1)), day: c.day, value: c.value })),
+ }).replace(/</g, "\\u003c");
+
+ return `
+ <div class="chart">
+ <svg viewBox="0 0 ${width} ${LINE_H}" role="img" aria-label="${options.unit} over time" tabindex="0">
+ ${grid}
+ ${monthLabels}
+ <path class="area" d="${areaPath}" style="fill:var(${options.colorVar})"/>
+ <path class="line" d="${linePath}" style="stroke:var(${options.colorVar})"/>
+ ${milestoneMarks}
+ ${eventMarks}
+ ${endDot}
+ <line class="crosshair" y1="${margin.top}" y2="${baseline}" x1="-10" x2="-10" hidden></line>
+ <circle class="hover-dot" r="4.5" cx="-10" cy="-10" style="fill:var(${options.colorVar})" hidden></circle>
+ </svg>
+ <div class="chart-tip" hidden></div>
+ <script type="application/json">${data}</script>
+ </div>`;
+}
+
+type BarChartOptions = {
+ weeks: WeekPoint[];
+ colorVar: string;
+ unit: string;
+ maxWeeks?: number;
+};
+
+function barChart(options: BarChartOptions): string {
+ const weeks = options.weeks.slice(-(options.maxWeeks ?? 14));
+ if (weeks.length === 0) return `<p class="chart-empty">not enough data yet</p>`;
+
+ const margin = { top: 26, right: 12, bottom: 30, left: 44 };
+ const innerW = BAR_W - margin.left - margin.right;
+ const innerH = BAR_H - margin.top - margin.bottom;
+
+ const yMaxData = Math.max(...weeks.map((week) => week.value));
+ const ticks = niceTicks(yMaxData, 3);
+ const yMax = Math.max(ticks[ticks.length - 1], yMaxData) * 1.06;
+ const yFor = (value: number): number => margin.top + innerH - (value / yMax) * innerH;
+ const baseline = margin.top + innerH;
+
+ const band = innerW / weeks.length;
+ const barWidth = Math.min(24, Math.max(band - 2, 4));
+
+ const grid = ticks
+ .filter((tick) => tick > 0)
+ .map((tick) => {
+ const y = yFor(tick).toFixed(1);
+ return `<line class="grid" x1="${margin.left}" y1="${y}" x2="${BAR_W - margin.right}" y2="${y}"/>` +
+ `<text class="tick" x="${margin.left - 7}" y="${y}" dy="0.32em" text-anchor="end">${fmtCompact(tick)}</text>`;
+ })
+ .join("");
+
+ const maxValue = Math.max(...weeks.filter((week) => !week.partial).map((week) => week.value), 0);
+ const labelStep = Math.ceil(weeks.length / 5);
+
+ const bars = weeks
+ .map((week, index) => {
+ const xCenter = margin.left + band * index + band / 2;
+ const x = xCenter - barWidth / 2;
+ const yTop = yFor(week.value);
+ const r = Math.min(4, barWidth / 2, Math.max(baseline - yTop, 0));
+ const path =
+ `M${x.toFixed(1)},${baseline}` +
+ `L${x.toFixed(1)},${(yTop + r).toFixed(1)}` +
+ `Q${x.toFixed(1)},${yTop.toFixed(1)} ${(x + r).toFixed(1)},${yTop.toFixed(1)}` +
+ `L${(x + barWidth - r).toFixed(1)},${yTop.toFixed(1)}` +
+ `Q${(x + barWidth).toFixed(1)},${yTop.toFixed(1)} ${(x + barWidth).toFixed(1)},${(yTop + r).toFixed(1)}` +
+ `L${(x + barWidth).toFixed(1)},${baseline}Z`;
+ const capLabel = !week.partial && week.value === maxValue && week.value > 0
+ ? `<text class="cap-label" x="${xCenter.toFixed(1)}" y="${(yTop - 7).toFixed(1)}" text-anchor="middle">${fmtCompact(week.value)}</text>`
+ : "";
+ const axisLabel = index % labelStep === 0
+ ? `<text class="tick" x="${xCenter.toFixed(1)}" y="${baseline + 20}" text-anchor="middle">${fmtDay(week.weekStart)}</text>`
+ : "";
+ const tipText = week.partial
+ ? `week of ${fmtDay(week.weekStart)} · ${fmtInt(week.value)} ${options.unit} · day ${week.daysIn}/7`
+ : `week of ${fmtDay(week.weekStart)} · ${fmtInt(week.value)} ${options.unit}`;
+ return (
+ `<g class="bar${week.partial ? " partial" : ""}" data-tip="${escapeHtml(tipText)}">` +
+ `<rect class="hit" x="${(margin.left + band * index).toFixed(1)}" y="${margin.top}" width="${band.toFixed(1)}" height="${innerH}"></rect>` +
+ `<path class="fill" d="${path}" style="fill:var(${options.colorVar})"/>` +
+ `</g>${capLabel}${axisLabel}`
+ );
+ })
+ .join("");
+
+ return `
+ <div class="chart">
+ <svg viewBox="0 0 ${BAR_W} ${BAR_H}" role="img" aria-label="${options.unit} per week">
+ ${grid}
+ <line class="grid strong" x1="${margin.left}" y1="${baseline}" x2="${BAR_W - margin.right}" y2="${baseline}"/>
+ ${bars}
+ </svg>
+ <div class="chart-tip" hidden></div>
+ </div>`;
+}
+
+// ---------- tables, rank lists, timeline ----------
+
+function detailsTable(headers: [string, string], rows: string): string {
+ return `
+ <details class="chart-table">
+ <summary>view as table</summary>
+ <div class="chart-table-scroll">
+ <table><thead><tr><th>${headers[0]}</th><th>${headers[1]}</th></tr></thead><tbody>${rows}</tbody></table>
+ </div>
+ </details>`;
+}
+
+function weeklyTable(weeks: WeekPoint[], unit: string): string {
+ const rows = [...weeks]
+ .slice(-14)
+ .reverse()
+ .map(
+ (week) =>
+ `<tr><td>week of ${fmtDay(week.weekStart)}</td><td>${fmtInt(week.value)}${week.partial ? ` (day ${week.daysIn}/7)` : ""}</td></tr>`,
+ )
+ .join("");
+ return detailsTable(["week", unit], rows);
+}
+
+function cumulativeTable(points: SeriesPoint[], unit: string): string {
+ const byMonth = new Map<string, SeriesPoint>();
+ for (const point of points) byMonth.set(point.d.slice(0, 7), point);
+ const rows = [...byMonth.values()]
+ .map((point) => `<tr><td>${fmtDay(point.d)}</td><td>${fmtInt(point.v)}</td></tr>`)
+ .join("");
+ return detailsTable(["end of month", unit], rows);
+}
+
+function flagEmoji(code: string): string {
+ if (!/^[A-Z]{2}$/.test(code)) return "";
+ return String.fromCodePoint(...[...code].map((char) => 0x1f1a5 + char.charCodeAt(0)));
+}
+
+function countryName(code: string): string {
+ try {
+ return new Intl.DisplayNames(["en"], { type: "region" }).of(code) ?? code;
+ } catch {
+ return code;
+ }
+}
+
+function rankList(rows: Array<{ label: string; value: number }>, unit: string): string {
+ if (rows.length === 0) return `<p class="chart-empty">no data yet</p>`;
+ const max = Math.max(...rows.map((row) => row.value), 1);
+ const items = rows
+ .map(
+ (row) =>
+ `<div class="rank-row" title="${escapeHtml(row.label)} · ${fmtInt(row.value)} ${unit}">` +
+ `<span class="rank-name">${escapeHtml(row.label)}</span>` +
+ `<span class="rank-bar"><span class="rank-fill" style="width:${((row.value / max) * 100).toFixed(1)}%"></span></span>` +
+ `<span class="rank-value">${fmtCompact(row.value)}</span>` +
+ `</div>`,
+ )
+ .join("");
+ return `<div class="rank-list">${items}</div>`;
+}
+
+function timelineTerminal(stats: StatsJson, milestones: Milestone[]): string {
+ const lines: Array<{ day: string; text: string; note: string }> = [];
+ for (const entry of stats.timeline) {
+ const note = entry.note ?? `day ${daysBetween(stats.bornDay, entry.d)}`;
+ lines.push({ day: entry.d, text: escapeHtml(entry.label), note: escapeHtml(note) });
+ }
+ let previousDay = stats.timeline.find((entry) => entry.label.includes("first star"))?.d ?? stats.bornDay;
+ for (const milestone of milestones) {
+ lines.push({ day: milestone.day, text: `★ ${fmtInt(milestone.value)}`, note: `+${daysBetween(previousDay, milestone.day)} days` });
+ previousDay = milestone.day;
+ }
+ lines.sort((a, b) => a.day.localeCompare(b.day));
+ lines.push({ day: todayUtc(), text: `★ ${fmtInt(stats.stars.total)}`, note: "today" });
+
+ const body = lines
+ .map(
+ (line) =>
+ `<div class="term-line"><span class="term-date">${fmtDay(line.day)}</span><span class="term-text">${line.text}</span><span class="term-note">${line.note}</span></div>`,
+ )
+ .join("");
+
+ return `
+ <div class="milestone-term" role="img" aria-label="herdr timeline">
+ <div class="term-line term-cmd-line"><span class="term-prompt">$</span><span class="term-cmd">herdr stats --timeline</span></div>
+ ${body}
+ </div>`;
+}
+
+// ---------- interaction ----------
+
+function attachChartInteractions(root: ParentNode): void {
+ root.querySelectorAll<HTMLElement>(".chart").forEach((chart) => {
+ const dataEl = chart.querySelector<HTMLScriptElement>("script[type='application/json']");
+ const tip = chart.querySelector<HTMLElement>(".chart-tip");
+ const svg = chart.querySelector<SVGSVGElement>("svg");
+ if (!svg || !tip) return;
+
+ if (dataEl?.textContent) {
+ const data = JSON.parse(dataEl.textContent) as {
+ unit: string;
+ width: number;
+ points: Array<{ x: number; y: number; day: string; value: number }>;
+ };
+ const crosshair = svg.querySelector<SVGLineElement>(".crosshair");
+ const hoverDot = svg.querySelector<SVGCircleElement>(".hover-dot");
+ if (!crosshair || !hoverDot) return;
+ const points = data.points;
+ let active = -1;
+
+ const showIndex = (index: number): void => {
+ if (index < 0 || index >= points.length) return;
+ active = index;
+ const point = points[index];
+ crosshair.setAttribute("x1", String(point.x));
+ crosshair.setAttribute("x2", String(point.x));
+ crosshair.hidden = false;
+ hoverDot.setAttribute("cx", String(point.x));
+ hoverDot.setAttribute("cy", String(point.y));
+ hoverDot.hidden = false;
+ tip.innerHTML = "";
+ const strong = document.createElement("strong");
+ strong.textContent = `${point.value.toLocaleString("en-US")} ${data.unit}`;
+ tip.appendChild(strong);
+ tip.appendChild(document.createElement("br"));
+ tip.appendChild(document.createTextNode(fmtDay(point.day)));
+ const rect = svg.getBoundingClientRect();
+ const scale = rect.width / data.width;
+ tip.style.left = `${point.x * scale}px`;
+ tip.style.top = `${point.y * scale}px`;
+ tip.hidden = false;
+ };
+
+ const hide = (): void => {
+ active = -1;
+ crosshair.hidden = true;
+ hoverDot.hidden = true;
+ tip.hidden = true;
+ };
+
+ svg.addEventListener("pointermove", (event) => {
+ const rect = svg.getBoundingClientRect();
+ const x = ((event.clientX - rect.left) / rect.width) * data.width;
+ let best = 0;
+ let bestDist = Infinity;
+ for (let i = 0; i < points.length; i += 1) {
+ const dist = Math.abs(points[i].x - x);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = i;
+ }
+ }
+ showIndex(best);
+ });
+ svg.addEventListener("pointerleave", hide);
+ svg.addEventListener("focus", () => showIndex(points.length - 1));
+ svg.addEventListener("blur", hide);
+ svg.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowLeft") {
+ showIndex(Math.max(active - 1, 0));
+ event.preventDefault();
+ }
+ if (event.key === "ArrowRight") {
+ showIndex(Math.min(active + 1, points.length - 1));
+ event.preventDefault();
+ }
+ });
+ }
+
+ chart.querySelectorAll<SVGGElement>(".bar").forEach((bar) => {
+ bar.addEventListener("pointerenter", () => {
+ const hit = bar.querySelector(".hit");
+ if (!hit) return;
+ const svgRect = svg.getBoundingClientRect();
+ const barRect = hit.getBoundingClientRect();
+ tip.textContent = bar.getAttribute("data-tip");
+ tip.style.left = `${barRect.left - svgRect.left + barRect.width / 2}px`;
+ tip.style.top = `${barRect.top - svgRect.top + 18}px`;
+ tip.hidden = false;
+ bar.classList.add("active");
+ });
+ bar.addEventListener("pointerleave", () => {
+ tip.hidden = true;
+ bar.classList.remove("active");
+ });
+ });
+ });
+}
+
+// ---------- live star counter ----------
+
+function startLiveStars(initial: number): void {
+ const hero = document.getElementById("star-count");
+ if (!hero) return;
+ let current = initial;
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ const renderCount = (value: number): void => {
+ hero.textContent = Math.round(value).toLocaleString("en-US");
+ };
+ renderCount(current);
+
+ const animateTo = (target: number): void => {
+ if (reduceMotion || target <= current) {
+ current = Math.max(current, target);
+ renderCount(current);
+ return;
+ }
+ const from = current;
+ const start = performance.now();
+ const duration = 900;
+ const frame = (now: number): void => {
+ const t = Math.min((now - start) / duration, 1);
+ const eased = 1 - (1 - t) ** 3;
+ renderCount(from + (target - from) * eased);
+ if (t < 1) requestAnimationFrame(frame);
+ else current = target;
+ };
+ requestAnimationFrame(frame);
+ };
+
+ const poll = (): void => {
+ fetch(REPO_API_URL)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((repo) => {
+ if (repo && typeof repo.stargazers_count === "number") animateTo(repo.stargazers_count);
+ })
+ .catch(() => {
+ // keep last known number; the counter must never break the page
+ });
+ };
+ poll();
+ setInterval(poll, 60_000);
+}
+
+// ---------- page assembly ----------
+
+function setText(id: string, text: string): void {
+ const el = document.getElementById(id);
+ if (el) el.textContent = text;
+}
+
+function setHtml(id: string, html: string): void {
+ const el = document.getElementById(id);
+ if (el) el.innerHTML = html;
+}
+
+function render(stats: StatsJson): void {
+ const starsDaily = stats.stars.daily;
+ const lastStarDay = starsDaily.at(-1)?.d ?? null;
+ const milestones = starMilestones(starsDaily);
+ const badges = stats.timeline
+ .filter((entry) => entry.badge)
+ .map((entry) => ({ day: entry.d, label: entry.badge ?? "" }));
+
+ // hero
+ const last7d = (() => {
+ const last = starsDaily.at(-1);
+ if (!last) return null;
+ const weekAgoDay = new Date(dayEpoch(last.d) - 7 * 86_400_000).toISOString().slice(0, 10);
+ const anchor = [...starsDaily].reverse().find((point) => point.d <= weekAgoDay);
+ return anchor ? stats.stars.total - anchor.v : null;
+ })();
+ if (last7d !== null) setText("hero-note", `+${fmtInt(last7d)} stars in the last 7 days`);
+ if (starsDaily[0]) setText("stars-since", `since ${fmtDay(starsDaily[0].d)}`);
+ setHtml(
+ "chart-stars",
+ lineChart({ points: starsDaily, colorVar: "--accent", unit: "stars", width: 640, milestones, events: badges }) +
+ cumulativeTable(starsDaily, "stars"),
+ );
+
+ // KPIs
+ const downloadsDeltas = deltasFromCumulative(stats.downloads.daily);
+ const lastDownloadDay = stats.downloads.daily.at(-1)?.d ?? null;
+ const downloadsWeekly = weeklySums(downloadsDeltas, lastDownloadDay);
+ const lastFullWeek = [...downloadsWeekly].reverse().find((week) => !week.partial);
+ setText("kpi-installs", fmtInt(stats.downloads.github + stats.downloads.brew));
+ setHtml(
+ "kpi-installs-sub",
+ `${lastFullWeek ? `<span class="up">+${fmtInt(lastFullWeek.value)}</span> last week · ` : ""}releases + homebrew`,
+ );
+
+ const trafficDaily = stats.traffic.daily;
+ const lastSevenTraffic = trafficDaily.slice(-7);
+ const visitorsAvg = lastSevenTraffic.length
+ ? Math.round(lastSevenTraffic.reduce((sum, point) => sum + point.v, 0) / lastSevenTraffic.length)
+ : null;
+ setText("kpi-visitors", visitorsAvg === null ? "—" : fmtCompact(visitorsAvg));
+
+ setText("kpi-releases", fmtInt(stats.releases.shipped));
+ setText("kpi-age", fmtInt(daysBetween(stats.bornDay, todayUtc())));
+ setText("kpi-age-sub", `born ${fmtDay(stats.bornDay)}, ${stats.bornDay.slice(0, 4)}`);
+
+ // weekly velocity
+ const starsDeltas = deltasFromCumulative(starsDaily);
+ setHtml(
+ "chart-stars-week",
+ barChart({ weeks: weeklySums(starsDeltas, lastStarDay), colorVar: "--accent", unit: "stars" }) +
+ weeklyTable(weeklySums(starsDeltas, lastStarDay), "stars"),
+ );
+ setHtml(
+ "chart-downloads-week",
+ barChart({ weeks: downloadsWeekly, colorVar: "--green", unit: "downloads" }) + weeklyTable(downloadsWeekly, "downloads"),
+ );
+ const visitorsWeekly = weeklyAverages(trafficDaily, trafficDaily.at(-1)?.d ?? null);
+ setHtml(
+ "chart-visitors-week",
+ barChart({ weeks: visitorsWeekly, colorVar: "--yellow", unit: "avg daily visitors" }) +
+ weeklyTable(visitorsWeekly, "avg daily visitors"),
+ );
+
+ // downloads cumulative
+ const trackedSince = stats.downloads.daily[0]?.d;
+ setText(
+ "downloads-sub",
+ `GitHub release downloads, cumulative${trackedSince ? `, tracked since ${fmtDay(trackedSince)}` : ""}. ` +
+ `Homebrew adds ${fmtInt(stats.downloads.brew)} installs on top (not shown in the curve).`,
+ );
+ setHtml(
+ "chart-downloads",
+ lineChart({ points: stats.downloads.daily, colorVar: "--green", unit: "downloads", width: 960 }) +
+ cumulativeTable(stats.downloads.daily, "downloads"),
+ );
+
+ // traffic
+ setText("traffic-sub", `herdr.dev traffic over the last ${stats.traffic.windowDays} days, measured by Cloudflare.`);
+ setHtml(
+ "rank-countries",
+ rankList(
+ stats.traffic.topCountries.map((row) => ({ label: `${flagEmoji(row.code)} ${countryName(row.code)}`.trim(), value: row.visits })),
+ "visits",
+ ),
+ );
+ setHtml(
+ "rank-referrers",
+ rankList(stats.traffic.topReferrers.map((row) => ({ label: row.host, value: row.visits })), "visits"),
+ );
+ setHtml(
+ "rank-paths",
+ rankList(stats.traffic.topPaths.map((row) => ({ label: row.path, value: row.views })), "page views"),
+ );
+
+ // timeline + freshness
+ setHtml("timeline", timelineTerminal(stats, milestones));
+ const generated = new Date(stats.generatedAt);
+ const ageHours = (Date.now() - generated.getTime()) / 3_600_000;
+ const stampText = `Data updated ${fmtDay(stats.generatedAt.slice(0, 10))}, ${stats.generatedAt.slice(11, 16)} UTC.`;
+ setText("updated-note", stampText);
+ if (ageHours > 6) {
+ const note = document.createElement("p");
+ note.className = "stats-stale";
+ note.textContent = `note: snapshot is ${Math.round(ageHours)} hours old — the star counter above is still live.`;
+ document.getElementById("sources-note")?.after(note);
+ }
+
+ attachChartInteractions(document);
+ startLiveStars(stats.stars.total);
+}
+
+function assertStatsShape(stats: StatsJson): void {
+ const ok =
+ stats.schemaVersion === 1 &&
+ typeof stats.generatedAt === "string" &&
+ typeof stats.bornDay === "string" &&
+ typeof stats.stars?.total === "number" &&
+ Array.isArray(stats.stars?.daily) &&
+ typeof stats.downloads?.github === "number" &&
+ typeof stats.downloads?.brew === "number" &&
+ Array.isArray(stats.downloads?.daily) &&
+ typeof stats.releases?.shipped === "number" &&
+ Array.isArray(stats.traffic?.daily) &&
+ Array.isArray(stats.traffic?.topCountries) &&
+ Array.isArray(stats.traffic?.topReferrers) &&
+ Array.isArray(stats.traffic?.topPaths) &&
+ Array.isArray(stats.timeline);
+ if (!ok) throw new Error("stats snapshot has unexpected shape");
+}
+
+export function initStatsPage(): void {
+ fetch(STATS_URL, { headers: { accept: "application/json" } })
+ .then((res) => {
+ if (!res.ok) throw new Error(`stats fetch failed: ${res.status}`);
+ return res.json() as Promise<StatsJson>;
+ })
+ .then((stats) => {
+ assertStatsShape(stats);
+ render(stats);
+ })
+ .catch((error) => {
+ console.error("stats page render failed", error);
+ setText("hero-note", "stats are temporarily unavailable, try again in a minute");
+ startLiveStars(0);
+ });
+}