Profile: the year as a signal 2a0c09c09716
Above Repositories, a profile shows the changes its owner pushed each day of the last year, in repositories the viewer may see, as a waveform: coloured along its length by the repository most worked on, with a soft glow; a violet trace for the share pushed through tokens marked as an agent's; a strip of each day's main repository; release tags on the peaks. It draws itself in, a heartbeat runs along it while idle, and the pointer lights the year near it with a card for the day (changes, share via agents, repositories); a legend shows one repository's year. Reduced motion keeps only the pointer. Landings and failed pushes are not counted. Pushes now record the agent token they came through (migration 0067). Closes #217
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-15 19:22 UTC
- Signature
- ✔ signed by Claude Code
- Hash
2a0c09c097168374c2744f61ae27875085372b77ea0d698de042d7777000cdc2- Depends on
- 101740454617 155cab142b63 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 61ecbd299f22 62d6e969f180 810896d41be7 d0fbea2eb46e d23464c0ac60 df5c69cbd047
- Depended on by
- 3d086bc3d69b 166f6e9bf4f1 0ee5c3fb32eb a7aa76ac52d7 3b1bc27fe805 ea14c4ec5c3f 5d90e0e045e2 9a8e3b4419e0 c0095389312b a2fbad481dd7
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 17 atoms, 2 tree operations, 9 files
Browse the tree at this change
added crates/hub/migrations/0067_pushes_via.sql +3 −0
| 1 | + | -- The agent token a push came through, when it was one marked as an |
|
| 2 | + | -- agent's (oxygenhub#217: the profile pulse's "via agents" trace). |
|
| 3 | + | ALTER TABLE pushes ADD COLUMN via TEXT NOT NULL DEFAULT ''; |
added templates/pulse.html +119 −0
| 1 | + | {# The year as a signal (oxygenhub#217): changes pushed each day of the |
|
| 2 | + | last year, coloured by repository, the share pushed via agent tokens as a |
|
| 3 | + | violet trace, releases on the peaks. Drawn by the script below from the |
|
| 4 | + | page's JSON; nothing is fetched. #} |
|
| 5 | + | <section class="card-box pulse-box" aria-label="Changes over the last year"> |
|
| 6 | + | <div class="card-head"><h2>The year</h2><span class="pulse-total" id="pulse-total" data-total="{{ pulse_total }}">{{ pulse_total }}</span><span class="small mute">changes pushed</span> |
|
| 7 | + | <div class="pulse-legend more" id="pulse-legend"></div></div> |
|
| 8 | + | {% if pulse_total == 0 %}<p class="empty small">No changes pushed in the last year.</p>{% else %} |
|
| 9 | + | <div class="pulse-stage" id="pulse-stage"><div class="pulse-card" id="pulse-card" aria-live="polite"></div></div> |
|
| 10 | + | <script type="application/json" id="pulse-data">{{ pulse|safe }}</script> |
|
| 11 | + | <script> |
|
| 12 | + | (() => { |
|
| 13 | + | "use strict"; |
|
| 14 | + | const data = JSON.parse(document.getElementById("pulse-data").textContent); |
|
| 15 | + | const stage = document.getElementById("pulse-stage"), card = document.getElementById("pulse-card"); |
|
| 16 | + | const NS = "http://www.w3.org/2000/svg", reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; |
|
| 17 | + | const el = (t, a, p) => { const e = document.createElementNS(NS, t); for (const k in a) e.setAttribute(k, a[k]); if (p) p.appendChild(e); return e; }; |
|
| 18 | + | const PAL = 7, col = r => r < PAL ? `var(--pu${r + 1})` : "var(--pu8)"; |
|
| 19 | + | const days = data.days.map((d, i) => ({ t: d[0], via: d[1], by: d[2], date: new Date(data.start + i * 864e5) })); |
|
| 20 | + | const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / 364); |
|
| 21 | + | const s = el("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Changes pushed each day over the last year" }); |
|
| 22 | + | stage.prepend(s); |
|
| 23 | + | const defs = el("defs", {}, s); |
|
| 24 | + | const grad = el("linearGradient", { id: "pu-grad", x1: L, x2: W - R, y1: 0, y2: 0, gradientUnits: "userSpaceOnUse" }, defs); |
|
| 25 | + | const fade = el("linearGradient", { id: "pu-fade", x1: 0, x2: 0, y1: 0, y2: 1 }, defs); |
|
| 26 | + | el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": ".5" }, fade); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": "0" }, fade); |
|
| 27 | + | const fm = el("mask", { id: "pu-fm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs); el("rect", { width: W, height: H, fill: "url(#pu-fade)" }, fm); |
|
| 28 | + | const glow = el("filter", { id: "pu-glow", x: "-5%", y: "-30%", width: "110%", height: "160%" }, defs); |
|
| 29 | + | el("feGaussianBlur", { stdDeviation: "2.6", result: "b" }, glow); const mg = el("feMerge", {}, glow); el("feMergeNode", { in: "b" }, mg); el("feMergeNode", { in: "SourceGraphic" }, mg); |
|
| 30 | + | const spot = el("radialGradient", { id: "pu-spot", cx: "0", cy: "0", r: "1", gradientUnits: "userSpaceOnUse" }, defs); |
|
| 31 | + | el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": "1" }, spot); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": ".4" }, spot); |
|
| 32 | + | const sm = el("mask", { id: "pu-sm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs); |
|
| 33 | + | const spotRect = el("rect", { width: W, height: H, fill: "#fff" }, sm); |
|
| 34 | + | const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip); |
|
| 35 | + | const grid = el("g", {}, s), ylab = []; |
|
| 36 | + | for (let k = 0; k <= 4; k++) { const y = T + (base - T) * k / 4; el("line", { x1: L, x2: W - R, y1: y, y2: y, stroke: "var(--line)", "stroke-opacity": ".5" }, grid); ylab.push(el("text", { x: L - 6, y: y + 4, "text-anchor": "end", class: "pu-axis" }, grid)); } |
|
| 37 | + | days.forEach((d, i) => { if (d.date.getUTCDate() !== 1) return; el("line", { x1: X(i), x2: X(i), y1: base, y2: base + 5, stroke: "var(--line)" }, grid); const t = el("text", { x: X(i) + 3, y: base + 18, class: "pu-axis" }, grid); t.textContent = d.date.toLocaleDateString("en-GB", { month: "short", timeZone: "UTC" }); }); |
|
| 38 | + | const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s); |
|
| 39 | + | const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot); |
|
| 40 | + | const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot); |
|
| 41 | + | const viaLine = el("path", { fill: "none", stroke: "var(--pu-agent)", "stroke-width": 1.3, "stroke-dasharray": "1 3", "stroke-linecap": "round" }, plot); |
|
| 42 | + | const strip = el("g", {}, plot), peakG = el("g", {}, s); |
|
| 43 | + | const beat = el("circle", { r: 4.5, fill: "var(--panel)", stroke: "var(--acc)", "stroke-width": 2, filter: "url(#pu-glow)", opacity: 0 }, s); |
|
| 44 | + | const scan = el("line", { y1: T - 4, y2: base, stroke: "var(--ink)", "stroke-dasharray": "2 3", opacity: 0 }, s); |
|
| 45 | + | const dot = el("circle", { r: 5, fill: "var(--panel)", "stroke-width": 2.5, opacity: 0 }, s); |
|
| 46 | + | let only = -1, ys = []; |
|
| 47 | + | const val = d => only < 0 ? d.t : (d.by.find(p => p[0] === only) || [0, 0])[1]; |
|
| 48 | + | const top = d => d.by.length ? d.by.reduce((a, b) => b[1] > a[1] ? b : a)[0] : 0; |
|
| 49 | + | const smooth = pts => pts.reduce((acc, [x, y], i) => { if (!i) return `M${x},${y}`; const [x0, y0] = pts[i - 1], cx = (x0 + x) / 2; return acc + `C${cx},${y0} ${cx},${y} ${x},${y}`; }, ""); |
|
| 50 | + | function build() { |
|
| 51 | + | const v = days.map(val), max = Math.max(1, ...v); |
|
| 52 | + | ys = v.map(n => base - (base - T) * n / max); |
|
| 53 | + | const d = smooth(ys.map((y, i) => [X(i), y])); |
|
| 54 | + | line.setAttribute("d", d); area.setAttribute("d", d + `L${X(364)},${base}L${X(0)},${base}Z`); |
|
| 55 | + | viaLine.setAttribute("d", smooth(days.map((x, i) => [X(i), base - (base - T) * (only < 0 ? x.via : Math.round(val(x) * x.via / (x.t || 1))) / max]))); |
|
| 56 | + | grad.textContent = ""; |
|
| 57 | + | for (let i = 0; i <= 364; i += 7) el("stop", { offset: (i / 364).toFixed(4), style: `stop-color:${col(only < 0 ? top(days[i]) : only)}` }, grad); |
|
| 58 | + | strip.textContent = ""; |
|
| 59 | + | days.forEach((x, i) => { if (!val(x)) return; el("rect", { x: X(i) - .9, y: base + 28, width: 1.8, height: 9, rx: .9, style: `fill:${col(only < 0 ? top(x) : only)}` }, strip); }); |
|
| 60 | + | ylab.forEach((t, k) => { t.textContent = Math.round(max * (4 - k) / 4); }); |
|
| 61 | + | peakG.textContent = ""; |
|
| 62 | + | if (only < 0) (data.peaks || []).forEach(([i, label]) => { |
|
| 63 | + | if (i < 0 || i > 364) return; |
|
| 64 | + | const y = ys[i], right = X(i) < W - 160; |
|
| 65 | + | el("circle", { cx: X(i), cy: y, r: 4, fill: "none", stroke: "var(--ink)", class: "pu-ring" }, peakG); |
|
| 66 | + | el("circle", { cx: X(i), cy: y, r: 3.2, fill: "var(--ink)" }, peakG); |
|
| 67 | + | const t = el("text", { x: X(i) + (right ? 8 : -8), y: Math.max(T - 8, y - 8), "text-anchor": right ? "start" : "end", class: "pu-peak" }, peakG); t.textContent = label; |
|
| 68 | + | }); |
|
| 69 | + | } |
|
| 70 | + | build(); |
|
| 71 | + | // Legend: a repository's own year. |
|
| 72 | + | const lg = document.getElementById("pulse-legend"); |
|
| 73 | + | const btn = (label, c, idx) => { const b = document.createElement("button"); b.type = "button"; b.className = "pu-chip"; b.setAttribute("aria-pressed", String(idx === -1)); b.innerHTML = `<i style="background:${c}"></i>`; b.append(label); b.addEventListener("click", () => { only = idx; [...lg.children].forEach(c => c.setAttribute("aria-pressed", "false")); b.setAttribute("aria-pressed", "true"); build(); }); lg.appendChild(b); }; |
|
| 74 | + | btn("all", "var(--ink)", -1); |
|
| 75 | + | data.repos.slice(0, PAL).forEach((n, i) => btn(n.split("/").pop(), col(i), i)); |
|
| 76 | + | // The total counts up; the line draws itself in; then a heartbeat while idle. |
|
| 77 | + | const totalEl = document.getElementById("pulse-total"), total = Number(totalEl.dataset.total); |
|
| 78 | + | if (!reduce) { const t0 = performance.now(); const tick = now => { const p = Math.min(1, (now - t0) / 900); totalEl.textContent = Math.round(total * (1 - Math.pow(1 - p, 3))).toLocaleString("en-GB"); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } |
|
| 79 | + | else totalEl.textContent = total.toLocaleString("en-GB"); |
|
| 80 | + | let idle = true, timer = 0; |
|
| 81 | + | function heartbeat() { |
|
| 82 | + | if (reduce) return; |
|
| 83 | + | clearTimeout(timer); |
|
| 84 | + | timer = setTimeout(function run() { |
|
| 85 | + | if (!idle || document.hidden) { timer = setTimeout(run, 2000); return; } |
|
| 86 | + | const len = line.getTotalLength(), t0 = performance.now(); |
|
| 87 | + | const go = now => { const p = Math.min(1, (now - t0) / 2600), pt = line.getPointAtLength(len * p); beat.setAttribute("cx", pt.x); beat.setAttribute("cy", pt.y); beat.setAttribute("opacity", idle ? String(Math.sin(p * Math.PI)) : "0"); if (p < 1 && idle) requestAnimationFrame(go); else { beat.setAttribute("opacity", "0"); timer = setTimeout(run, 4500); } }; |
|
| 88 | + | requestAnimationFrame(go); |
|
| 89 | + | }, 800); |
|
| 90 | + | } |
|
| 91 | + | if (!reduce) { |
|
| 92 | + | const t0 = performance.now(); |
|
| 93 | + | const reveal = now => { const p = Math.min(1, (now - t0) / 1600); clipRect.setAttribute("width", L + (W - L) * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(reveal); }; |
|
| 94 | + | requestAnimationFrame(reveal); |
|
| 95 | + | setTimeout(() => { clipRect.setAttribute("width", W); heartbeat(); }, 1700); |
|
| 96 | + | } |
|
| 97 | + | // Pointer: spotlight, scan line and the day's card. |
|
| 98 | + | const fmt = d => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); |
|
| 99 | + | s.addEventListener("pointermove", e => { |
|
| 100 | + | idle = false; beat.setAttribute("opacity", "0"); |
|
| 101 | + | const r = s.getBoundingClientRect(), x = (e.clientX - r.left) * (W / r.width); |
|
| 102 | + | const i = Math.max(0, Math.min(364, Math.round((x - L) / ((W - L - R) / 364)))), d = days[i]; |
|
| 103 | + | spotRect.setAttribute("fill", "url(#pu-spot)"); spot.setAttribute("gradientTransform", `translate(${X(i)} ${ys[i]}) scale(160 240)`); |
|
| 104 | + | scan.setAttribute("x1", X(i)); scan.setAttribute("x2", X(i)); scan.setAttribute("opacity", ".45"); |
|
| 105 | + | dot.setAttribute("cx", X(i)); dot.setAttribute("cy", ys[i]); dot.style.stroke = col(only < 0 ? top(d) : only); dot.setAttribute("opacity", "1"); |
|
| 106 | + | const n = val(d), share = d.t ? Math.round(100 * d.via / d.t) : 0; |
|
| 107 | + | const bar = d.t ? d.by.map(([k, m]) => `<i style="width:${100 * m / d.t}%;background:${col(k)}"></i>`).join("") : ""; |
|
| 108 | + | const repos = d.by.slice().sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, m]) => `<div class="row"><span>${(data.repos[k] || "").split("/").pop()}</span><span>${m}</span></div>`).join(""); |
|
| 109 | + | card.innerHTML = `<b>${fmt(d.date)}</b><div class="row"><span>changes</span><span>${n}</span></div><div class="row"><span>via agents</span><span>${share}%</span></div>${only < 0 ? repos : ""}${bar ? `<div class="bar">${bar}</div>` : ""}`; |
|
| 110 | + | card.style.opacity = "1"; |
|
| 111 | + | const px = e.clientX - r.left, cw = card.offsetWidth; |
|
| 112 | + | card.style.left = Math.max(0, Math.min(r.width - cw, px + (px > r.width * .6 ? -cw - 14 : 14))) + "px"; |
|
| 113 | + | }); |
|
| 114 | + | s.addEventListener("pointerleave", () => { idle = true; spotRect.setAttribute("fill", "#fff"); scan.setAttribute("opacity", "0"); dot.setAttribute("opacity", "0"); card.style.opacity = "0"; }); |
|
| 115 | + | document.addEventListener("visibilitychange", () => { if (!document.hidden) heartbeat(); }); |
|
| 116 | + | })(); |
|
| 117 | + | </script> |
|
| 118 | + | {% endif %} |
|
| 119 | + | </section> |
modified crates/hub/src/db/activity.rs +33 −0
| … | 164 unchanged lines |
||
| 165 | 165 | .fetch_all(pool) |
|
| 166 | 166 | .await |
|
| 167 | 167 | } |
|
| 168 | + | ||
| 169 | + | /// One account's pushes of the last year in the repositories a viewer may |
|
| 170 | + | /// see, for the profile's pulse (#217): `(repo_id, at_ms, changes, via)`. |
|
| 171 | + | /// Landings are left out (their changes were pushed once already), as are |
|
| 172 | + | /// failed pushes and pushes that brought nothing. |
|
| 173 | + | pub async fn pulse(pool: &Pool, account_id: i64, repo_ids: &[i64], since_ms: i64) -> sqlx::Result<Vec<(i64, i64, i64, String)>> { |
|
| 174 | + | let ids = serde_json::to_string(repo_ids).unwrap_or_else(|_| "[]".into()); |
|
| 175 | + | sqlx::query_as( |
|
| 176 | + | "SELECT repo_id, at_ms, changes, via FROM pushes \ |
|
| 177 | + | WHERE account_id = ? AND ok = 1 AND changes > 0 AND at_ms >= ? AND reason NOT LIKE 'landed%' \ |
|
| 178 | + | AND repo_id IN (SELECT value FROM json_each(?)) ORDER BY at_ms", |
|
| 179 | + | ) |
|
| 180 | + | .bind(account_id) |
|
| 181 | + | .bind(since_ms) |
|
| 182 | + | .bind(ids) |
|
| 183 | + | .fetch_all(pool) |
|
| 184 | + | .await |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | /// Releases an account made in the last year in repositories a viewer may |
|
| 188 | + | /// see: `(repo_id, tag, created_ms)`, to label the pulse's peaks. |
|
| 189 | + | pub async fn releases_by(pool: &Pool, account_id: i64, repo_ids: &[i64], since_ms: i64) -> sqlx::Result<Vec<(i64, String, i64)>> { |
|
| 190 | + | let ids = serde_json::to_string(repo_ids).unwrap_or_else(|_| "[]".into()); |
|
| 191 | + | sqlx::query_as( |
|
| 192 | + | "SELECT repo_id, tag, created_ms FROM releases WHERE author_id = ? AND created_ms >= ? \ |
|
| 193 | + | AND repo_id IN (SELECT value FROM json_each(?)) ORDER BY created_ms", |
|
| 194 | + | ) |
|
| 195 | + | .bind(account_id) |
|
| 196 | + | .bind(since_ms) |
|
| 197 | + | .bind(ids) |
|
| 198 | + | .fetch_all(pool) |
|
| 199 | + | .await |
|
| 200 | + | } |
modified crates/hub/src/db/repos.rs +7 −1
| … | 199 unchanged lines |
||
| 200 | 200 | ||
| 201 | 201 | /// Record a push's per-reference results and bump the repository's activity. |
|
| 202 | 202 | pub async fn record_push(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, results: &[PushOutcome]) -> sqlx::Result<()> { |
|
| 203 | + | record_push_via(pool, id, pusher, "", results).await |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | /// [`record_push`], naming the agent token the push came through (#217). |
|
| 207 | + | pub async fn record_push_via(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, via: &str, results: &[PushOutcome]) -> sqlx::Result<()> { |
|
| 203 | 208 | let now = now_ms(); |
|
| 204 | 209 | let mut tx = super::begin_write(pool).await?; |
|
| 205 | 210 | for r in results { |
|
| 206 | - | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, account_id, pusher) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") |
|
| 211 | + | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, account_id, pusher, via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") |
|
| 207 | 212 | .bind(id) |
|
| 208 | 213 | .bind(now) |
|
| 209 | 214 | .bind(&r.reference) |
|
| … | 5 unchanged lines |
||
| 215 | 220 | .bind(r.changes) |
|
| 216 | 221 | .bind(pusher.map(|p| p.0).filter(|id| *id > 0)) |
|
| 217 | 222 | .bind(pusher.map(|p| p.1).unwrap_or("")) |
|
| 223 | + | .bind(via) |
|
| 218 | 224 | .execute(&mut *tx) |
|
| 219 | 225 | .await?; |
|
| 220 | 226 | } |
|
| … | 131 unchanged lines |
modified crates/hub/src/protocol.rs +7 −5
| … | 219 unchanged lines |
||
| 220 | 220 | } |
|
| 221 | 221 | let Ok(row) = self.row(&parsed) else { return }; |
|
| 222 | 222 | let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 223 | - | let pusher = match self.caller(headers) { |
|
| 224 | - | Ok(Caller::Token(account, _)) => Some((account.id, account.username)), |
|
| 225 | - | Ok(Caller::Site) => Some((0, "site token".to_string())), |
|
| 226 | - | _ => None, |
|
| 223 | + | let (pusher, via) = match self.caller(headers) { |
|
| 224 | + | Ok(Caller::Token(account, token)) => { |
|
| 225 | + | (Some((account.id, account.username)), if token.agent { token.name.clone() } else { String::new() }) |
|
| 226 | + | } |
|
| 227 | + | Ok(Caller::Site) => (Some((0, "site token".to_string())), String::new()), |
|
| 228 | + | _ => (None, String::new()), |
|
| 227 | 229 | }; |
|
| 228 | 230 | let pool = self.pool.clone(); |
|
| 229 | 231 | // A part of a long push (objects alone) moves nothing: the push it |
|
| … | 77 unchanged lines |
||
| 307 | 309 | let app_hint = self.app_hint.clone(); |
|
| 308 | 310 | self.runtime.spawn(async move { |
|
| 309 | 311 | let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str())); |
|
| 310 | - | if let Err(e) = db::repos::record_push(&pool, row.id, who, &results).await { |
|
| 312 | + | if let Err(e) = db::repos::record_push_via(&pool, row.id, who, &via, &results).await { |
|
| 311 | 313 | tracing::warn!("recording push to {}: {e}", parsed); |
|
| 312 | 314 | } |
|
| 313 | 315 | // Oldest first: a link made for one step lets the next step link. |
|
| … | 94 unchanged lines |
modified crates/hub/src/web/home.rs +65 −1
| … | 225 unchanged lines |
||
| 226 | 226 | repos: Vec<db::repos::RepoRow>, |
|
| 227 | 227 | feed: Vec<db::activity::Activity>, |
|
| 228 | 228 | is_me: bool, |
|
| 229 | + | /// The year's pulse as JSON for the page's script (#217); `<` escaped. |
|
| 230 | + | pulse: String, |
|
| 231 | + | /// Changes pushed in the last year, shown beside the pulse. |
|
| 232 | + | pulse_total: i64, |
|
| 229 | 233 | } |
|
| 230 | 234 | ||
| 231 | 235 | impl ProfilePage { |
|
| … | 18 unchanged lines |
||
| 250 | 254 | can_admin: bool, |
|
| 251 | 255 | } |
|
| 252 | 256 | ||
| 257 | + | const DAY_MS: i64 = 86_400_000; |
|
| 258 | + | ||
| 259 | + | /// The profile's pulse (#217): changes this account pushed each day of the |
|
| 260 | + | /// last year, in repositories the viewer may see, with the share pushed |
|
| 261 | + | /// through agent tokens, per repository, and its releases to label peaks. |
|
| 262 | + | /// `{"start": MS, "repos": [NAME], "days": [[TOTAL, VIA, [[REPO, N]..]]..], |
|
| 263 | + | /// "peaks": [[DAY, LABEL]..]}`, 365 days ending today (UTC). |
|
| 264 | + | async fn pulse_json(app: &Shared, account: &db::accounts::Account, seen: &[db::repos::RepoRow]) -> sqlx::Result<(String, i64)> { |
|
| 265 | + | let today = db::now_ms() / DAY_MS; |
|
| 266 | + | let first = today - 364; |
|
| 267 | + | let ids: Vec<i64> = seen.iter().map(|r| r.id).collect(); |
|
| 268 | + | let rows = db::activity::pulse(&app.db, account.id, &ids, first * DAY_MS).await?; |
|
| 269 | + | let (pulse, total) = pulse_of(&rows, seen, first); |
|
| 270 | + | let mut value = pulse; |
|
| 271 | + | let mut peaks = Vec::new(); |
|
| 272 | + | for (repo_id, tag, at) in db::activity::releases_by(&app.db, account.id, &ids, first * DAY_MS).await? { |
|
| 273 | + | let name = seen.iter().find(|r| r.id == repo_id).map(|r| r.name.as_str()).unwrap_or(""); |
|
| 274 | + | peaks.push(serde_json::json!([at / DAY_MS - first, format!("{name} {tag}")])); |
|
| 275 | + | } |
|
| 276 | + | value["peaks"] = serde_json::Value::Array(peaks); |
|
| 277 | + | Ok((value.to_string().replace('<', "\\u003c"), total)) |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | /// The days of a pulse from push rows `(repo_id, at_ms, changes, via)`: |
|
| 281 | + | /// repositories ordered by how much went to them, most first. |
|
| 282 | + | fn pulse_of(rows: &[(i64, i64, i64, String)], seen: &[db::repos::RepoRow], first: i64) -> (serde_json::Value, i64) { |
|
| 283 | + | let mut per_repo: std::collections::BTreeMap<i64, i64> = std::collections::BTreeMap::new(); |
|
| 284 | + | for (repo, _, n, _) in rows { |
|
| 285 | + | *per_repo.entry(*repo).or_default() += n; |
|
| 286 | + | } |
|
| 287 | + | let mut order: Vec<(i64, i64)> = per_repo.into_iter().collect(); |
|
| 288 | + | order.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); |
|
| 289 | + | let names: Vec<String> = order |
|
| 290 | + | .iter() |
|
| 291 | + | .map(|(id, _)| seen.iter().find(|r| r.id == *id).map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default()) |
|
| 292 | + | .collect(); |
|
| 293 | + | let slot = |id: i64| order.iter().position(|(r, _)| *r == id).unwrap_or(0); |
|
| 294 | + | let mut days: Vec<(i64, i64, std::collections::BTreeMap<usize, i64>)> = vec![(0, 0, Default::default()); 365]; |
|
| 295 | + | let mut total = 0; |
|
| 296 | + | for (repo, at, n, via) in rows { |
|
| 297 | + | let d = at / DAY_MS - first; |
|
| 298 | + | if !(0..365).contains(&d) { |
|
| 299 | + | continue; |
|
| 300 | + | } |
|
| 301 | + | let day = &mut days[d as usize]; |
|
| 302 | + | day.0 += n; |
|
| 303 | + | if !via.is_empty() { |
|
| 304 | + | day.1 += n; |
|
| 305 | + | } |
|
| 306 | + | *day.2.entry(slot(*repo)).or_default() += n; |
|
| 307 | + | total += n; |
|
| 308 | + | } |
|
| 309 | + | let days: Vec<serde_json::Value> = days |
|
| 310 | + | .into_iter() |
|
| 311 | + | .map(|(t, v, by)| serde_json::json!([t, v, by.into_iter().map(|(r, n)| serde_json::json!([r, n])).collect::<Vec<_>>()])) |
|
| 312 | + | .collect(); |
|
| 313 | + | (serde_json::json!({ "start": first * DAY_MS, "repos": names, "days": days }), total) |
|
| 314 | + | } |
|
| 315 | + | ||
| 253 | 316 | /// How many activity lines an owner page shows. |
|
| 254 | 317 | const OWNER_FEED: i64 = 20; |
|
| 255 | 318 | ||
| … | 16 unchanged lines |
||
| 272 | 335 | let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?; |
|
| 273 | 336 | let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?; |
|
| 274 | 337 | let is_me = viewer.account().is_some_and(|a| a.id == account.id); |
|
| 275 | - | return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me }).into_response()); |
|
| 338 | + | let (pulse, pulse_total) = pulse_json(&app, &account, &all).await.map_err(db_err)?; |
|
| 339 | + | return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me, pulse, pulse_total }).into_response()); |
|
| 276 | 340 | } |
|
| 277 | 341 | if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? { |
|
| 278 | 342 | let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?; |
|
| … | 117 unchanged lines |
modified crates/hub/tests/accounts.rs +56 −0
| … | 1137 unchanged lines |
||
| 1138 | 1138 | post(app, "/admin/assistants/models", Some(&andy), &format!("_csrf={csrf}&id={}&endpoint={ep}&api=chat-completions", a.id)).await; |
|
| 1139 | 1139 | assert!(r.body.contains("gemini-3-flash"), "{}", r.body); |
|
| 1140 | 1140 | } |
|
| 1141 | + | ||
| 1142 | + | /// #217: a profile shows the year's pulse: changes pushed per day in |
|
| 1143 | + | /// repositories the viewer may see, the share via agent tokens, releases on |
|
| 1144 | + | /// the peaks; landings and private repositories the viewer cannot see are |
|
| 1145 | + | /// left out. |
|
| 1146 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1147 | + | async fn a_profile_shows_the_year_as_a_signal() { |
|
| 1148 | + | let h = hub().await; |
|
| 1149 | + | let app = &h.app; |
|
| 1150 | + | register(app, "andy", "").await; |
|
| 1151 | + | let andy = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1152 | + | let game = db::repos::create(&app.db, "andy", "game", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 1153 | + | let secret = db::repos::create(&app.db, "andy", "secret", "", hub_api::Visibility::Private).await.unwrap(); |
|
| 1154 | + | let now = db::now_ms(); |
|
| 1155 | + | for (repo, ago_days, changes, reason, via) in [ |
|
| 1156 | + | (game.id, 3, 5, "", ""), |
|
| 1157 | + | (game.id, 3, 2, "", "Claude"), |
|
| 1158 | + | (game.id, 40, 4, "", ""), |
|
| 1159 | + | (game.id, 2, 9, "landed #1 (web)", ""), |
|
| 1160 | + | (secret.id, 1, 50, "", ""), |
|
| 1161 | + | ] { |
|
| 1162 | + | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, changes, account_id, pusher, via) VALUES (?, ?, 'main', 1, ?, ?, ?, 'andy', ?)") |
|
| 1163 | + | .bind(repo) |
|
| 1164 | + | .bind(now - ago_days * 86_400_000) |
|
| 1165 | + | .bind(reason) |
|
| 1166 | + | .bind(changes) |
|
| 1167 | + | .bind(andy.id) |
|
| 1168 | + | .bind(via) |
|
| 1169 | + | .execute(&app.db) |
|
| 1170 | + | .await |
|
| 1171 | + | .unwrap(); |
|
| 1172 | + | } |
|
| 1173 | + | sqlx::query("INSERT INTO releases (repo_id, tag, title, author_id, author, created_ms) VALUES (?, 'v1.0', 'One', ?, 'andy', ?)") |
|
| 1174 | + | .bind(game.id) |
|
| 1175 | + | .bind(andy.id) |
|
| 1176 | + | .bind(now - 3 * 86_400_000) |
|
| 1177 | + | .execute(&app.db) |
|
| 1178 | + | .await |
|
| 1179 | + | .unwrap(); |
|
| 1180 | + | // A stranger sees the public repository only, and no landing counts. |
|
| 1181 | + | let page = get(app, "/andy", None).await.body; |
|
| 1182 | + | let start = page.find("id=\"pulse-data\">").expect("the pulse is on the profile") + "id=\"pulse-data\">".len(); |
|
| 1183 | + | let json: serde_json::Value = serde_json::from_str(&page[start..start + page[start..].find("</script>").unwrap()]).unwrap(); |
|
| 1184 | + | assert_eq!(json["repos"], serde_json::json!(["andy/game"])); |
|
| 1185 | + | let days = json["days"].as_array().unwrap(); |
|
| 1186 | + | assert_eq!(days.len(), 365); |
|
| 1187 | + | let total: i64 = days.iter().map(|d| d[0].as_i64().unwrap()).sum(); |
|
| 1188 | + | let via: i64 = days.iter().map(|d| d[1].as_i64().unwrap()).sum(); |
|
| 1189 | + | assert_eq!((total, via), (11, 2), "5 + 2 + 4 changes, 2 via an agent; the landing and the private push left out"); |
|
| 1190 | + | assert_eq!(days[361], serde_json::json!([7, 2, [[0, 7]]]), "three days ago"); |
|
| 1191 | + | assert_eq!(json["peaks"][0], serde_json::json!([361, "game v1.0"])); |
|
| 1192 | + | assert!( |
|
| 1193 | + | page.contains("data-total=\"11\"") && page.find("pulse-box").unwrap() < page.find("<h2>Repositories</h2>").unwrap(), |
|
| 1194 | + | "above the repositories" |
|
| 1195 | + | ); |
|
| 1196 | + | } |
modified templates/profile.html +1 −0
| … | 27 unchanged lines |
||
| 28 | 28 | </div></section> |
|
| 29 | 29 | </aside> |
|
| 30 | 30 | <div> |
|
| 31 | + | {% include "pulse.html" %} |
|
| 31 | 32 | <section class="card-box"><div class="card-head"><h2>Repositories</h2><span class="count-chip">{{ repos.len() }}</span></div> |
|
| 32 | 33 | {% include "repo_list.html" %} |
|
| 33 | 34 | </section> |
|
| … | 6 unchanged lines |
modified templates/style.css +22 −0
| … | 1272 unchanged lines |
||
| 1273 | 1273 | .signin-or span { padding: 0 8px; } |
|
| 1274 | 1274 | .signin-with { flex-basis: 100%; } |
|
| 1275 | 1275 | } |
|
| 1276 | + | /* The profile's pulse (oxygenhub#217): repository colours that read on |
|
| 1277 | + | both looks and both themes, and the agents' violet. */ |
|
| 1278 | + | :root { --pu1: #2f81f7; --pu2: #0fb5c9; --pu3: #2da44e; --pu4: #e3832a; --pu5: #d1453b; --pu6: #c2307a; --pu7: #b08800; --pu8: #8c959f; --pu-agent: #8a63d2; } |
|
| 1279 | + | .pulse-box .card-head { flex-wrap: wrap; row-gap: 6px; } |
|
| 1280 | + | .pulse-total { font-weight: 700; font-variant-numeric: tabular-nums; } |
|
| 1281 | + | .pulse-legend { display: flex; flex-wrap: wrap; gap: 4px; } |
|
| 1282 | + | button.pu-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; font-size: 11.5px; border-radius: 999px; text-transform: none; letter-spacing: 0; } |
|
| 1283 | + | button.pu-chip::first-letter { text-transform: none; } |
|
| 1284 | + | button.pu-chip i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } |
|
| 1285 | + | button.pu-chip[aria-pressed="true"] { border-color: var(--ink); background: var(--soft); } |
|
| 1286 | + | .pulse-stage { position: relative; padding: 8px 12px 4px; } |
|
| 1287 | + | .pulse-stage svg { width: 100%; height: auto; display: block; touch-action: pan-y; } |
|
| 1288 | + | .pulse-stage .pu-axis { font: 10px var(--mono, ui-monospace, monospace); fill: var(--mute); } |
|
| 1289 | + | .pulse-stage .pu-peak { font: 11px var(--mono, ui-monospace, monospace); fill: var(--ink); } |
|
| 1290 | + | .pulse-card { position: absolute; top: 6px; pointer-events: none; opacity: 0; transition: opacity .15s; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; min-width: 170px; font-size: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.15); } |
|
| 1291 | + | .pulse-card b { display: block; margin-bottom: 3px; } |
|
| 1292 | + | .pulse-card .row { display: flex; justify-content: space-between; gap: 14px; color: var(--mute); font-variant-numeric: tabular-nums; } |
|
| 1293 | + | .pulse-card .row span:last-child { color: var(--ink); } |
|
| 1294 | + | .pulse-card .bar { display: flex; height: 5px; border-radius: 3px; overflow: hidden; margin-top: 6px; background: var(--soft); } |
|
| 1295 | + | @keyframes pu-ring { from { r: 4; opacity: .9; } to { r: 15; opacity: 0; } } |
|
| 1296 | + | .pu-ring { animation: pu-ring 2.4s ease-out infinite; } |
|
| 1297 | + | @media (prefers-reduced-motion: reduce) { .pu-ring { animation: none; opacity: 0; } } |