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
↓ kepr-850a5062.tar.gzatlas at this save
files changed8 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 −0package.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": {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;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"],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+}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). */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);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 0tests/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+});