From 82487675908e986ec3d4996151d952f086140c7f Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 6 Jul 2026 00:41:49 +0300 Subject: feat: add public stats page at herdr.dev/stats --- website/src/scripts/stats-page.ts | 742 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 website/src/scripts/stats-page.ts (limited to 'website/src/scripts') 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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +// ---------- 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(); + 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(); + 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 `

not enough data yet

`; + + 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 `` + + `${fmtCompact(tick)}`; + }) + .join(""); + + const monthLabels = monthStartsBetween(points[0].d, points[points.length - 1].d) + .map((day) => { + const x = xFor(day).toFixed(1); + return `${MONTHS[new Date(`${day}T00:00:00Z`).getUTCMonth()]}`; + }) + .join(""); + + const milestoneMarks = (options.milestones ?? []) + .map((milestone) => { + const x = xFor(milestone.day); + const y = yFor(milestone.value); + return ( + `` + + `` + + `${milestone.label}` + ); + }) + .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 ( + `` + + `` + + `${escapeHtml(event.label)}` + ); + }) + .join(""); + + const end = coords[coords.length - 1]; + const endDot = ``; + + // escape < so the JSON can never terminate the wrapping + `; +} + +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 `

not enough data yet

`; + + 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 `` + + `${fmtCompact(tick)}`; + }) + .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 + ? `${fmtCompact(week.value)}` + : ""; + const axisLabel = index % labelStep === 0 + ? `${fmtDay(week.weekStart)}` + : ""; + 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 ( + `` + + `` + + `` + + `${capLabel}${axisLabel}` + ); + }) + .join(""); + + return ` +
+ + ${grid} + + ${bars} + + +
`; +} + +// ---------- tables, rank lists, timeline ---------- + +function detailsTable(headers: [string, string], rows: string): string { + return ` +
+ view as table +
+ ${rows}
${headers[0]}${headers[1]}
+
+
`; +} + +function weeklyTable(weeks: WeekPoint[], unit: string): string { + const rows = [...weeks] + .slice(-14) + .reverse() + .map( + (week) => + `week of ${fmtDay(week.weekStart)}${fmtInt(week.value)}${week.partial ? ` (day ${week.daysIn}/7)` : ""}`, + ) + .join(""); + return detailsTable(["week", unit], rows); +} + +function cumulativeTable(points: SeriesPoint[], unit: string): string { + const byMonth = new Map(); + for (const point of points) byMonth.set(point.d.slice(0, 7), point); + const rows = [...byMonth.values()] + .map((point) => `${fmtDay(point.d)}${fmtInt(point.v)}`) + .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 `

no data yet

`; + const max = Math.max(...rows.map((row) => row.value), 1); + const items = rows + .map( + (row) => + `
` + + `${escapeHtml(row.label)}` + + `` + + `${fmtCompact(row.value)}` + + `
`, + ) + .join(""); + return `
${items}
`; +} + +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) => + `
${fmtDay(line.day)}${line.text}${line.note}
`, + ) + .join(""); + + return ` + `; +} + +// ---------- interaction ---------- + +function attachChartInteractions(root: ParentNode): void { + root.querySelectorAll(".chart").forEach((chart) => { + const dataEl = chart.querySelector("script[type='application/json']"); + const tip = chart.querySelector(".chart-tip"); + const svg = chart.querySelector("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(".crosshair"); + const hoverDot = svg.querySelector(".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(".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 ? `+${fmtInt(lastFullWeek.value)} 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; + }) + .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); + }); +} -- cgit v1.2.3