850a5062b8fe

count downloads per repo and day, shown at /admin/downloads with totals and top files (v3.0.161)

time
2026-09-30 23:18 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
1a625426812f
8 files package.json+1 −1public/css/kepr.css+20 −0src/admin/console.ts+1 −0src/admin/downloads.ts+59 −0src/releases/releases.ts+61 −5src/server/app.ts+2 −0src/server/db.ts+7 −0tests/downloads.test.ts+74 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.160",3+	"version": "3.0.161",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+20 −0
@@ -2205,6 +2205,26 @@22052205 	font-weight: 400;22062206 	color: var(--accent);22072207 }2208+.admin-days {2209+	margin: 0;2210+	padding: 0;2211+	list-style: none;2212+	font-size: 0.6875rem;2213+}2214+.admin-days li {2215+	display: grid;2216+	grid-template-columns: 11ch minmax(0, 1fr) 5ch;2217+	gap: 12px;2218+	align-items: center;2219+	padding: 2px 0;2220+}2221+.admin-days meter {2222+	width: 100%;2223+	accent-color: var(--accent);2224+}2225+.admin-days li > span:last-child {2226+	text-align: right;2227+}22082228 .admin-releases {22092229 	margin: 12px 0 0;22102230 	padding: 0;

view file

src/admin/console.ts+1 −0
@@ -23,6 +23,7 @@2323 	["/admin", "overview"],2424 	["/admin/repos", "repos"],2525 	["/admin/issues", "issues"],26+	["/admin/downloads", "downloads"],2627 	["/admin/notifications", "notifications"],2728 	["/admin/email", "email"],2829 	["/admin/bucket", "bucket"],

view file

src/admin/downloads.ts+59 −0
@@ -0,0 +1,59 @@1+import type { Hono } from "hono";2+import { html } from "../pages/html.ts";3+import { plural } from "../pages/layout.ts";4+import { downloadsByDay, repoDownloads, topFiles, utcDay } from "../releases/releases.ts";5+import { type AdminContext, adminPage } from "./console.ts";6+import { allRepos } from "./store.ts";7+8+const DAYS_SHOWN = 30;9+const TOP_FILES = 10;10+const DAY_MS = 24 * 60 * 60 * 1000;11+12+/** §24's Downloads section: the last 30 days, each repo's totals and the top files. */13+export function registerDownloads(app: Hono, ctx: AdminContext): void {14+	app.get("/admin/downloads", (c) => {15+		// An unknown repo narrows nothing, as the issues list treats one.16+		const repos = allRepos(ctx.kepr).map((repo) => repo.name);17+		const repo = repos.find((name) => name === c.req.query("repo")) ?? "";18+		const now = Date.now();19+		const days = Array.from({ length: DAYS_SHOWN }, (_, i) =>20+			utcDay(now - (DAYS_SHOWN - 1 - i) * DAY_MS),21+		);22+		const since = days[0] as string;23+		const counts = downloadsByDay(ctx.kepr, since, repo);24+		const peak = Math.max(1, ...counts.values());25+		const option = (name: string) =>26+			html`<option value="${name}"${name === repo ? html` selected` : ""}>${name}</option>`;27+		const body = html`<form class="issues-filter" method="get" action="/admin/downloads">28+<select class="issues-input" name="repo" aria-label="repository">29+<option value="">all repos</option>30+${repos.map(option)}31+</select>32+<button class="issues-submit" type="submit">show</button>33+</form>34+<section class="repo-section"><h2 class="section-label mono">last ${String(DAYS_SHOWN)} days</h2>35+<ol class="admin-days mono">36+${days.map((day) => {37+	const n = counts.get(day) ?? 0;38+	return html`<li><span>${day}</span>39+<meter min="0" max="${String(peak)}" value="${String(n)}" aria-label="${plural(n, "download")} on ${day}"></meter>40+<span>${String(n)}</span></li>`;41+})}42+</ol></section>43+<section class="repo-section"><h2 class="section-label mono">repositories</h2>44+${repoDownloads(ctx.kepr, since).map(45+	(row) => html`<p class="issue-row"><a href="/admin/downloads?repo=${row.repo}">${row.repo}</a>46+<span class="issue-meta mono">${plural(row.total, "download")} · ${String(row.recent)} in ${String(DAYS_SHOWN)} days</span></p>`,47+)}48+</section>49+<section class="repo-section"><h2 class="section-label mono">most downloaded files</h2>50+${topFiles(ctx.kepr, TOP_FILES).map(51+	(52+		row,53+	) => html`<p class="issue-row"><span class="mono">${row.repo} ${row.version} ${row.platform}</span>54+<span class="issue-meta mono">${plural(row.count, "download")}</span></p>`,55+)}56+</section>`;57+		return c.html(adminPage(c, ctx, "downloads", body));58+	});59+}

view file

src/releases/releases.ts+61 −5
@@ -71,18 +71,74 @@7171 	return "application/octet-stream";7272 }7373 74+const DAYS_KEPT = 400;75+const DAY_MS = 24 * 60 * 60 * 1000;76+77+/** A UTC day as `download_days` keys it. */78+export const utcDay = (ms: number): string => new Date(ms).toISOString().slice(0, 10);79+7480 export function countDownload(7581 	kepr: Database,7682 	repo: string,7783 	version: string,7884 	platform: string,7985 ): void {80-	kepr81-		.query(82-			`INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)83-			ON CONFLICT DO UPDATE SET count = count + 1`,86+	const now = Date.now();87+	kepr.transaction(() => {88+		kepr89+			.query(90+				`INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)91+				ON CONFLICT DO UPDATE SET count = count + 1`,92+			)93+			.run(repo, version, platform);94+		kepr95+			.query(96+				`INSERT INTO download_days (repo, day, count) VALUES (?, ?, 1)97+				ON CONFLICT DO UPDATE SET count = count + 1`,98+			)99+			.run(repo, utcDay(now));100+		kepr.query("DELETE FROM download_days WHERE day < ?").run(utcDay(now - DAYS_KEPT * DAY_MS));101+	})();102+}103+104+/** Downloads per UTC day from `since` on, for one repo or all of them (§24). */105+export function downloadsByDay(kepr: Database, since: string, repo: string): Map<string, number> {106+	const rows = kepr107+		.query<{ day: string; n: number }, [string, string, string]>(108+			`SELECT day, SUM(count) AS n109+			FROM download_days110+			WHERE day >= ? AND (? = '' OR repo = ?)111+			GROUP BY day`,84112 		)85-		.run(repo, version, platform);113+		.all(since, repo, repo);114+	return new Map(rows.map((row) => [row.day, row.n]));115+}116+117+/** Each repo's downloads ever and since a day, most downloaded first. */118+export function repoDownloads(kepr: Database, since: string) {119+	return kepr120+		.query<{ repo: string; total: number; recent: number }, [string]>(121+			`SELECT d.repo,122+				SUM(d.count) AS total,123+				COALESCE((SELECT SUM(count) FROM download_days WHERE repo = d.repo AND day >= ?), 0)124+					AS recent125+			FROM downloads d126+			GROUP BY d.repo127+			ORDER BY total DESC, d.repo`,128+		)129+		.all(since);130+}131+132+/** The most downloaded files across every repo. */133+export function topFiles(kepr: Database, limit: number) {134+	return kepr135+		.query<{ repo: string; version: string; platform: string; count: number }, [number]>(136+			`SELECT repo, version, platform, count137+			FROM downloads138+			ORDER BY count DESC, repo, version, platform139+			LIMIT ?`,140+		)141+		.all(limit);86142 }87143 88144 /** A Nix tarball fetch (§22.2). */

view file

src/server/app.ts+2 −0
@@ -7,6 +7,7 @@77 import { registerBackups } from "../admin/backups.ts";88 import { registerBucket } from "../admin/bucket.ts";99 import { registerStatus } from "../admin/console.ts";10+import { registerDownloads } from "../admin/downloads.ts";1011 import { registerEmail } from "../admin/email.ts";1112 import { registerIssues } from "../admin/issues.ts";1213 import { registerNotifications } from "../admin/notifications.ts";@@ -141,6 +142,7 @@141142 	registerStatus(app, admin);142143 	registerRepos(app, admin);143144 	registerIssues(app, admin);145+	registerDownloads(app, admin);144146 	registerSite(app, admin);145147 	registerBucket(app, admin);146148 	registerNotifications(app, admin);

view file

src/server/db.ts+7 −0
@@ -35,6 +35,13 @@3535 	count    INTEGER NOT NULL DEFAULT 0,3636 	PRIMARY KEY (repo, version, platform)3737 );38+-- §24's Downloads: the same downloads per repo and UTC day (YYYY-MM-DD), the newest 400 days.39+CREATE TABLE IF NOT EXISTS download_days (40+	repo  TEXT NOT NULL,41+	day   TEXT NOT NULL,42+	count INTEGER NOT NULL DEFAULT 0,43+	PRIMARY KEY (repo, day)44+);3845 CREATE TABLE IF NOT EXISTS installs (3946 	repo  TEXT PRIMARY KEY,4047 	count INTEGER NOT NULL DEFAULT 0

view file

tests/downloads.test.ts+74 −0
@@ -0,0 +1,74 @@1+// Covers §24's Downloads section: counts per UTC day, the last 30 days per repo or across all,2+// each repo's totals, the most downloaded files, and dropping days older than 400.3+4+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5+import { Browser, type Instance, instance } from "./browser.ts";6+import { makeRepos, writeRelease } from "./fixture.ts";7+8+describe("download analytics (§24)", () => {9+	let inst: Instance;10+	let operator: Browser;11+	const at = (iso: string) => setSystemTime(new Date(iso));12+	const download = (repo: string, platform: string) =>13+		inst.app.request(`/${repo}/releases/1.0.0/${platform}`);14+	const page = async (query = "") => (await operator.get(`/admin/downloads${query}`)).text();15+16+	beforeAll(async () => {17+		at("2026-09-30T12:00:00Z");18+		const repos = await makeRepos(["app", "tools"]);19+		for (const repo of ["app", "tools"]) {20+			await writeRelease(repos.bucketDir, repo, {21+				version: "1.0.0",22+				save: repos.saves[repo] as string,23+				files: [24+					{ platform: "linux-x86_64", name: repo, bytes: new Uint8Array([1]) },25+					{ platform: "macos-aarch64", name: repo, bytes: new Uint8Array([2]) },26+				],27+			});28+		}29+		inst = await instance(repos.bucketDir);30+		operator = new Browser(inst.app);31+		await operator.signIn();32+		for (const repo of ["app", "tools"])33+			await operator.post(`/admin/repos/${repo}`, { public: "1" });34+		at("2025-08-01T12:00:00Z");35+		await download("app", "linux-x86_64");36+		at("2026-09-28T23:59:00Z");37+		await download("app", "linux-x86_64");38+		await download("app", "macos-aarch64");39+		at("2026-09-30T00:01:00Z");40+		await download("app", "linux-x86_64");41+		await download("tools", "linux-x86_64");42+	});43+	afterAll(() => setSystemTime());44+45+	test("each download counts toward its repo's UTC day, and days past 400 are dropped", () => {46+		const days = inst.kepr47+			.query("SELECT repo, day, count FROM download_days ORDER BY day, repo")48+			.all();49+		expect(days).toEqual([50+			{ repo: "app", day: "2026-09-28", count: 2 },51+			{ repo: "app", day: "2026-09-30", count: 1 },52+			{ repo: "tools", day: "2026-09-30", count: 1 },53+		]);54+	});55+56+	test("the section shows 30 days, each repo's totals and the top files", async () => {57+		const html = await page();58+		expect(html).toContain('<meter min="0" max="2" value="2"');59+		expect(html).toContain("2026-09-28");60+		expect(html).not.toContain("2026-08-31");61+		expect(html).toContain("2026-09-01");62+		expect(html).toMatch(/app<\/a>\s*<span[^>]*>4 downloads · 3 in 30 days<\/span>/);63+		expect(html.indexOf("app 1.0.0 linux-x86_64")).toBeLessThan(64+			html.indexOf("tools 1.0.0 linux-x86_64"),65+		);66+	});67+68+	test("one repo narrows the days, and an unknown one narrows nothing", async () => {69+		const tools = await page("?repo=tools");70+		expect(tools).toContain('<meter min="0" max="1" value="1"');71+		expect(tools).not.toContain('value="2"');72+		expect(await page("?repo=nowhere")).toContain('<meter min="0" max="2" value="2"');73+	});74+});

view file