diff --git a/src/db.rs b/src/db.rs index f738d0c..80cbf86 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1078,6 +1078,36 @@ impl Db { Ok(rows.collect::>>()?) } + /// Each counted Tangled logical change authored since `since`, as + /// `(author_did, earliest authored_at, latest committed_at, subject, + /// repo_key)`. + pub fn land_rows_logical(&self, since: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + r#" + select author_did, authored_at, committed_at, message, repo_key from ( + select min(c.author_did) as author_did, min(c.authored_at) as authored_at, + max(c.committed_at) as committed_at, min(c.message) as message, + min(r.repo_key) as repo_key + from commits c + join counted_refs r on r.sha = c.sha and r.source = 'tangled' + group by case when c.change_id != '' then c.change_id else c.sha end + ) + where authored_at >= ?1 + "#, + )?; + let rows = stmt.query_map([since], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + })?; + Ok(rows.collect::>>()?) + } + /// Logical Tangled commit count per repository key. Used by the /// filter-only audit to find already-indexed oversized histories without /// touching the network. diff --git a/src/land_gap.rs b/src/land_gap.rs new file mode 100644 index 0000000..45137c8 --- /dev/null +++ b/src/land_gap.rs @@ -0,0 +1,174 @@ +//! How long a change waits between being written and being committed in +//! its final form: the gap from a logical change's earliest author date to +//! the latest commit date among its counted Tangled commits. A rebase keeps +//! the author date and moves the commit date, so the gap covers review and +//! rework as well as plain waiting. + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, NaiveDate}; +use serde::Serialize; + +/// One counted change: `(author_did, authored_at, committed_at, subject, +/// repo_key)`. +pub type LandRow = (String, String, String, String, String); + +#[derive(Debug, Default, Serialize)] +pub struct LandGap { + /// Every change's gap in seconds, ascending. Nothing is bucketed, so + /// the extreme tail is drawn as it is. + pub gaps: Vec, + /// Each change's author date, as whole days after `start`, in the same + /// order as `gaps`. + pub authored_days: Vec, + /// The first day `authored_days` counts from. + pub start: String, + pub median_secs: i64, + pub p90_secs: i64, + pub p99_secs: i64, + /// The slowest 1% (at least one), slowest first, named. + pub slowest: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SlowChange { + pub gap_secs: i64, + pub subject: String, + pub repo: String, + pub authored_at: String, + pub committed_at: String, +} + +/// Summarize rows for authors in `mine`. A commit date before its author +/// date (a skewed clock) counts as no gap. `repo_names` maps a repo key to +/// the name shown for it. +pub fn summarize( + rows: &[LandRow], + mine: &HashSet, + repo_names: &HashMap, + start: NaiveDate, +) -> LandGap { + let mut changes: Vec<(i64, &LandRow)> = rows + .iter() + .filter(|row| mine.contains(&row.0)) + .filter_map(|row| { + let authored = DateTime::parse_from_rfc3339(&row.1).ok()?; + let committed = DateTime::parse_from_rfc3339(&row.2).ok()?; + Some(((committed - authored).num_seconds().max(0), row)) + }) + .collect(); + if changes.is_empty() { + return LandGap::default(); + } + changes.sort_by_key(|(gap, _)| *gap); + let gaps: Vec = changes.iter().map(|(gap, _)| *gap).collect(); + let authored_days = changes + .iter() + .map(|(_, row)| { + row.1 + .get(0..10) + .and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) + .map_or(0, |day| (day - start).num_days()) + }) + .collect(); + let quantile = |q: f64| gaps[((gaps.len() - 1) as f64 * q).round() as usize]; + let tail = (gaps.len() / 100).max(1); + let slowest = changes + .iter() + .rev() + .take(tail) + .map(|(gap, row)| SlowChange { + gap_secs: *gap, + subject: row + .3 + .lines() + .next() + .unwrap_or("") + .chars() + .take(120) + .collect(), + repo: repo_names + .get(&row.4) + .cloned() + .unwrap_or_else(|| row.4.clone()), + authored_at: row.1.clone(), + committed_at: row.2.clone(), + }) + .collect(); + LandGap { + median_secs: quantile(0.5), + p90_secs: quantile(0.9), + p99_secs: quantile(0.99), + slowest, + gaps, + authored_days, + start: start.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_gap_is_kept_and_the_slowest_are_named() { + let row = |did: &str, authored: &str, committed: &str, subject: &str| { + ( + did.to_string(), + authored.to_string(), + committed.to_string(), + subject.to_string(), + "tangled:did:plc:repo".to_string(), + ) + }; + let rows = [ + row( + "me", + "2026-10-25T10:00:00Z", + "2026-10-25T10:00:30Z", + "feat: a", + ), + row( + "me", + "2026-10-25T10:00:00Z", + "2026-10-25T09:00:00Z", + "fix: b", + ), + row( + "me", + "2026-10-25T10:00:00Z", + "2026-10-25T12:00:00Z", + "docs: c", + ), + row( + "me", + "2026-09-01T10:00:00Z", + "2026-10-25T10:00:00Z", + "feat: slow\nbody", + ), + row( + "someone", + "2026-10-25T10:00:00Z", + "2026-11-25T10:00:00Z", + "x", + ), + ]; + let mine: HashSet = ["me".to_string()].into(); + let names: HashMap = + [("tangled:did:plc:repo".to_string(), "me/repo".to_string())].into(); + let start = NaiveDate::from_ymd_opt(2026, 8, 30).unwrap(); + let gap = summarize(&rows, &mine, &names, start); + assert_eq!(gap.gaps, [0, 30, 7_200, 4_665_600]); + // The gaps' author dates, in the same order: three on Oct 25, one Sep 1. + assert_eq!(gap.authored_days, [56, 56, 56, 2]); + assert_eq!((gap.median_secs, gap.p99_secs), (7_200, 4_665_600)); + assert_eq!(gap.slowest.len(), 1); + assert_eq!( + ( + gap.slowest[0].subject.as_str(), + gap.slowest[0].repo.as_str() + ), + ("feat: slow", "me/repo") + ); + } +} diff --git a/src/main.rs b/src/main.rs index 5f10a7a..293322f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod didbot; mod github; mod global_summary; mod http; +mod land_gap; mod model; mod serve; mod site; diff --git a/src/stats.rs b/src/stats.rs index c8dd46c..b326620 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -23,6 +23,8 @@ pub struct SiteData { pub hourly: HourlySeries, /// Conventional Commits types per week over the same window. pub commit_types: crate::commit_types::CommitTypes, + /// Author-to-commit gaps of Tangled changes over the same window. + pub land_gap: crate::land_gap::LandGap, pub repos: Vec, /// Ranked by commits in the last 30 days; the sidebar reads this. pub projects: Vec, @@ -519,6 +521,23 @@ pub fn build( window_start, now.date_naive(), ); + let repo_names: HashMap = db + .repo_by_key()? + .into_iter() + .map(|(key, repo)| { + let owner = handles + .get(&repo.owner_did) + .cloned() + .unwrap_or(repo.owner_did); + (key, format!("{owner}/{}", repo.name)) + }) + .collect(); + let land_gap = crate::land_gap::summarize( + &db.land_rows_logical(&format!("{}T00:00:00Z", window_start.format("%Y-%m-%d")))?, + &me_set.union(&bot_set).cloned().collect(), + &repo_names, + window_start, + ); let github_homepages = db.github_homepages()?; let repos = repo_views( db, @@ -625,6 +644,7 @@ pub fn build( daily, hourly, commit_types, + land_gap, repos, projects, recent, diff --git a/web/app.js b/web/app.js index e50fbe5..8c4b384 100644 --- a/web/app.js +++ b/web/app.js @@ -151,6 +151,7 @@ function render() { renderTable(); renderWheel(); renderCommitTypes(); + renderLandGap(); renderRepos(); renderRecent(); renderStatus(); @@ -1489,6 +1490,210 @@ function renderRecent() { } } +/* ---------- written to landed ---------- */ + +function duration(secs) { + if (secs < 60) return `${Math.round(secs)}s`; + if (secs < 3600) return `${Math.round(secs / 60)}m`; + if (secs < 86400) return `${(secs / 3600).toFixed(secs < 36000 ? 1 : 0)}h`; + return `${(secs / 86400).toFixed(secs < 864000 ? 1 : 0)}d`; +} + +function renderLandGap() { + const card = document.querySelector(".gap-card"); + const data = state.data.land_gap; + const svg = $("gap"); + svg.replaceChildren(); + const gaps = (data && data.gaps) || []; + if (gaps.length === 0) { + card.hidden = true; + return; + } + card.hidden = false; + const ns = "http://www.w3.org/2000/svg"; + const make = (tag, attrs = {}) => { + const node = document.createElementNS(ns, tag); + for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value)); + return node; + }; + const W = 1000; + const H = 340; + const margin = { left: 52, right: 16, top: 34, bottom: 30 }; + const plotW = W - margin.left - margin.right; + const plotH = H - margin.top - margin.bottom; + const n = gaps.length; + // Log time from one second to just past the slowest change; a zero gap + // sits at the left edge. + const top = Math.log10(Math.max(60, gaps[n - 1]) * 1.15); + const x = (secs) => margin.left + (Math.log10(Math.max(1, secs)) / top) * plotW; + const secsAt = (px) => 10 ** (((px - margin.left) / plotW) * top); + // Height is the day the change was written, newest at the top. Within a + // day a fixed scatter (a murmur-style mix of the index) spreads the dots. + const days = data.authored_days || gaps.map(() => 0); + const start = new Date(`${data.start || "1970-01-01"}T00:00:00Z`); + const generated = new Date(state.data.summary.generated_at); + const today = Math.floor((generated - start) / 86400000); + const firstDay = Math.min(...days); + const span = Math.max(1, today + 1 - firstDay); + const yDay = (day) => margin.top + ((today + 1 - day) / span) * plotH; + const jitter = (index) => { + let h = Math.imul(index ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return yDay(days[index] + (h >>> 0) / 4294967296); + }; + + // Month rules down the left. + for (let month = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); ; ) { + const day = Math.round((month - start) / 86400000); + if (day > today) break; + if (day >= firstDay) { + const my = yDay(day); + svg.append(make("line", { x1: margin.left, x2: W - margin.right, y1: my, y2: my, class: "chart-grid" })); + const label = make("text", { x: margin.left - 8, y: my + 4, "text-anchor": "end", class: "chart-tick" }); + label.textContent = month.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" }); + svg.append(label); + } + month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1)); + } + + for (const [secs, label] of [ + [1, "≤1s"], [60, "1m"], [600, "10m"], [3600, "1h"], [21600, "6h"], [86400, "1d"], + [604800, "1w"], [2592000, "30d"], [7776000, "90d"], [31536000, "1y"], + ]) { + if (Math.log10(secs) > top) continue; + const gx = x(secs); + svg.append(make("line", { x1: gx, x2: gx, y1: margin.top, y2: margin.top + plotH, class: "chart-grid" })); + const tick = make("text", { x: gx, y: H - 9, "text-anchor": "middle", class: "chart-tick" }); + tick.textContent = label; + svg.append(tick); + } + + // No change written on a day can have waited longer than the time since: + // the empty upper right is that limit, not a trend. + const limit = []; + for (let day = firstDay; day <= today; day += 1) { + const secs = (today + 1 - day) * 86400; + if (Math.log10(secs) > top) continue; + limit.push(`${limit.length ? "L" : "M"} ${x(secs).toFixed(1)} ${yDay(day + 0.5).toFixed(1)}`); + } + if (limit.length > 1) { + svg.append(make("path", { d: limit.join(" "), class: "gap-limit" })); + // In the empty corner past the curve. + const note = make("text", { + x: W - margin.right, + y: margin.top + 14, + "text-anchor": "end", + class: "chart-tick gap-limit-label", + }); + note.textContent = "longest possible wait"; + svg.append(note); + } + + const slowCount = (data.slowest || []).length; + gaps.forEach((secs, index) => { + const slow = index >= n - slowCount; + svg.append(make("circle", { + cx: x(secs).toFixed(1), + cy: jitter(index).toFixed(1), + r: slow ? 2.8 : 1.8, + class: slow ? "gap-dot slow" : "gap-dot", + })); + }); + + for (const [secs, label] of [ + [data.median_secs, "median"], + [data.p90_secs, "90th"], + [data.p99_secs, "99th"], + ]) { + const qx = x(secs); + svg.append(make("line", { x1: qx, x2: qx, y1: margin.top - 6, y2: margin.top + plotH, class: "gap-quantile" })); + const text = make("text", { x: qx, y: margin.top - 12, "text-anchor": "middle", class: "chart-tick gap-quantile-label" }); + text.textContent = `${label} ${duration(secs)}`; + svg.append(text); + } + svg.append(make("line", { + x1: margin.left, + x2: W - margin.right, + y1: margin.top + plotH + 0.5, + y2: margin.top + plotH + 0.5, + class: "chart-axis", + })); + + // Hover reads the share landed within the pointer's gap, or names a + // slow change when the pointer is on one. + const cursor = make("line", { y1: margin.top, y2: margin.top + plotH, class: "gap-cursor", visibility: "hidden" }); + svg.append(cursor); + const hit = make("rect", { x: margin.left, y: margin.top - 6, width: plotW, height: plotH + 6, class: "chart-hit" }); + const tooltip = $("gap-tooltip"); + const within = (secs) => { + let lo = 0; + let hi = n; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (gaps[mid] <= secs) lo = mid + 1; + else hi = mid; + } + return lo; + }; + hit.addEventListener("pointermove", (event) => { + const box = svg.getBoundingClientRect(); + const scale = W / box.width; + const px = (event.clientX - box.left) * scale; + const py = (event.clientY - box.top) * scale; + tooltip.replaceChildren(); + let nearest = null; + (data.slowest || []).forEach((change, k) => { + const index = n - 1 - k; + const d = Math.hypot(x(change.gap_secs) - px, jitter(index) - py); + if (d < 10 && (!nearest || d < nearest.d)) nearest = { change, d }; + }); + if (nearest) { + const change = nearest.change; + tooltip.append(el("div", "tip-date", `${duration(change.gap_secs)} · ${change.repo}`)); + tooltip.append(el("div", "tip-subject", change.subject)); + tooltip.append(el("div", "tip-dates", `${shortDate(change.authored_at)} → ${shortDate(change.committed_at)}`)); + cursor.setAttribute("visibility", "hidden"); + } else { + const secs = secsAt(px); + const count = within(secs); + cursor.setAttribute("x1", px); + cursor.setAttribute("x2", px); + cursor.setAttribute("visibility", "visible"); + tooltip.append(el("div", "tip-date", `within ${duration(secs)}`)); + const row = el("div", "tip-row"); + row.append(el("span", "tip-name", `${fmt(count)} changes`), el("span", "tip-value", pct((count / n) * 100))); + tooltip.append(row); + } + tooltip.hidden = false; + const wrap = tooltip.parentElement.getBoundingClientRect(); + const width = tooltip.offsetWidth || 200; + let left = event.clientX - wrap.left + 14; + if (left + width > wrap.width - 6) left = Math.max(6, left - width - 28); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${Math.max(6, event.clientY - wrap.top - 60)}px`; + }); + hit.addEventListener("pointerleave", () => { + tooltip.hidden = true; + cursor.setAttribute("visibility", "hidden"); + }); + svg.append(hit); + + const slowest = (data.slowest || [])[0]; + const stats = $("gap-stats"); + stats.replaceChildren(); + for (const item of [ + { label: "median", value: duration(data.median_secs), meta: `${fmt(n)} changes` }, + { label: "90th percentile", value: duration(data.p90_secs), meta: "1 in 10 waits longer" }, + { label: "99th percentile", value: duration(data.p99_secs), meta: "1 in 100 waits longer" }, + { label: "slowest", value: duration(gaps[n - 1]), meta: slowest ? slowest.repo : "" }, + ]) { + const node = el("div", "matrix-stat"); + node.append(el("p", "k", item.label), el("p", "v", item.value), el("p", "m", item.meta)); + stats.append(node); + } +} + function renderStatus() { const wrap = $("status"); wrap.replaceChildren(); diff --git a/web/index.html b/web/index.html index 178e262..8e3c70e 100644 --- a/web/index.html +++ b/web/index.html @@ -173,6 +173,20 @@

+
diff --git a/web/style.css b/web/style.css index eaca8f9..37552a7 100644 --- a/web/style.css +++ b/web/style.css @@ -1750,3 +1750,33 @@ h2::before { content: none; } .chart-pair .wheel-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } .chart-pair .wheel-stats .matrix-stat { padding: 0; border-top: 0; } .chart-pair .wheel-stats .matrix-stat .v { font-size: 16px; white-space: nowrap; } + +/* -------------------------------------------------------------------------- + Written to landed + -------------------------------------------------------------------------- */ + +.gap-layout { margin-top: 10px; } + +#gap { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.gap-dot { fill: var(--accent); fill-opacity: 0.32; } +.gap-dot.slow { fill-opacity: 1; stroke: var(--page); stroke-width: 1; } +.gap-quantile { stroke: var(--ink-2); stroke-width: 1; shape-rendering: crispEdges; } +.gap-quantile-label { fill: var(--ink-2); } +.gap-limit-label { paint-order: stroke; stroke: var(--page); stroke-width: 3px; } +.gap-limit { fill: none; stroke: var(--muted); stroke-width: 1; stroke-dasharray: 3 3; } +.tooltip .tip-subject { max-width: 240px; color: var(--ink); font-size: 12px; line-height: 1.35; } +.tooltip .tip-dates { margin-top: 4px; color: var(--muted); font-family: var(--mono); font-size: 10px; } + +.gap-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.gap-stats .matrix-stat .m { display: block; } +.gap-cursor { stroke: var(--ink-2); stroke-width: 1; shape-rendering: crispEdges; } + +@media (max-width: 620px) { + .gap-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +}