1import type { Save } from "../mirror/queries.ts";2import { type Html, html } from "./html.ts";3import { formatDate } from "./layout.ts";45const TIP_CHARS = 120;678export type TimelineSave = { id: string; t: number; message: string; meta: string };91011121314151617export function timelineSvg(repo: string, saves: Save[]): Html {18 const times = saves.map((save) => save.created_at);19 20 const sorted = times.toSorted((a, b) => a - b);21 const first = sorted[0] ?? 0;22 const span = (sorted.at(-1) ?? first) - first;23 const reach = Math.max(span / 40, 3600);24 const dots = times.map((t) => {25 const x = span === 0 ? 50 : 1 + (98 * (t - first)) / span;26 const near = countBetween(sorted, t - reach, t + reach);27 const level = near >= 8 ? 3 : near >= 4 ? 2 : near >= 2 ? 1 : 0;28 return html`<circle class="dot d${level}" cx="${x.toFixed(3)}%" cy="32" r="${2.5 + level * 0.5}"/>`;29 });30 return html`<svg class="timeline" width="100%" height="64" role="img" aria-label="${31 saves.length32 } saves over time" data-src="/${repo}/timeline">33<line class="timeline-line" x1="0" x2="100%" y1="32" y2="32"/>34${dots}35</svg>`;36}373839export function timelineSaves(saves: Save[]): TimelineSave[] {40 return saves.map((save) => {41 const line = save.message.split("\n")[0] as string;42 return {43 id: save.id,44 t: save.created_at,45 message: line.length > TIP_CHARS ? `${line.slice(0, TIP_CHARS - 1)}…` : line,46 meta: [formatDate(save.created_at), save.agent, save.session].filter(Boolean).join(" · "),47 };48 });49}505152function countBetween(sorted: number[], low: number, high: number): number {53 54 const first = (before: (time: number) => boolean) => {55 let [lo, hi] = [0, sorted.length];56 while (lo < hi) {57 const mid = (lo + hi) >> 1;58 if (before(sorted[mid] as number)) lo = mid + 1;59 else hi = mid;60 }61 return lo;62 };63 return first((t) => t <= high) - first((t) => t < low);64}