Profile pulse: the account's own span, fewer labels, neat chips a2fbad481dd7
The pulse covers the days since the account began (at least 30, at most 365) so a new account's weeks are not squeezed against the edge, with weekly ticks when it is short; at most four peaks are labelled, the biggest first and none crowding another, the other releases marked without words; the legend's chips keep their size under the GitHub look; the draw-in cannot be cut short. Refs #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:36 UTC
- Signature
- ✔ signed by Claude Code
- Hash
a2fbad481dd75ba1ee2afa48f6c29f346b63af1fba971e53348db5f6790bb8a4- Depends on
- 19bb31ff1f5c 2a0c09c09716 d23464c0ac60
- Depended on by
- a7aa76ac52d7 3b1bc27fe805 c0095389312b
- 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
- 20 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/web/home.rs +8 −6
| … | 259 unchanged lines |
||
| 260 | 260 | /// last year, in repositories the viewer may see, with the share pushed |
|
| 261 | 261 | /// through agent tokens, per repository, and its releases to label peaks. |
|
| 262 | 262 | /// `{"start": MS, "repos": [NAME], "days": [[TOTAL, VIA, [[REPO, N]..]]..], |
|
| 263 | - | /// "peaks": [[DAY, LABEL]..]}`, 365 days ending today (UTC). |
|
| 263 | + | /// "peaks": [[DAY, LABEL]..]}`: the days since the account began, at |
|
| 264 | + | /// least 30 and at most 365, ending today (UTC). |
|
| 264 | 265 | async fn pulse_json(app: &Shared, account: &db::accounts::Account, seen: &[db::repos::RepoRow]) -> sqlx::Result<(String, i64)> { |
|
| 265 | 266 | let today = db::now_ms() / DAY_MS; |
|
| 266 | - | let first = today - 364; |
|
| 267 | + | let span = (today - account.created_ms / DAY_MS + 1).clamp(30, 365); |
|
| 268 | + | let first = today - span + 1; |
|
| 267 | 269 | let ids: Vec<i64> = seen.iter().map(|r| r.id).collect(); |
|
| 268 | 270 | let rows = db::activity::pulse(&app.db, account.id, &ids, first * DAY_MS).await?; |
|
| 269 | - | let (pulse, total) = pulse_of(&rows, seen, first); |
|
| 271 | + | let (pulse, total) = pulse_of(&rows, seen, first, span as usize); |
|
| 270 | 272 | let mut value = pulse; |
|
| 271 | 273 | let mut peaks = Vec::new(); |
|
| 272 | 274 | for (repo_id, tag, at) in db::activity::releases_by(&app.db, account.id, &ids, first * DAY_MS).await? { |
|
| … | 6 unchanged lines |
||
| 279 | 281 | ||
| 280 | 282 | /// The days of a pulse from push rows `(repo_id, at_ms, changes, via)`: |
|
| 281 | 283 | /// 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) { |
|
| 284 | + | fn pulse_of(rows: &[(i64, i64, i64, String)], seen: &[db::repos::RepoRow], first: i64, span: usize) -> (serde_json::Value, i64) { |
|
| 283 | 285 | let mut per_repo: std::collections::BTreeMap<i64, i64> = std::collections::BTreeMap::new(); |
|
| 284 | 286 | for (repo, _, n, _) in rows { |
|
| 285 | 287 | *per_repo.entry(*repo).or_default() += n; |
|
| … | 5 unchanged lines |
||
| 291 | 293 | .map(|(id, _)| seen.iter().find(|r| r.id == *id).map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default()) |
|
| 292 | 294 | .collect(); |
|
| 293 | 295 | 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]; |
|
| 296 | + | let mut days: Vec<(i64, i64, std::collections::BTreeMap<usize, i64>)> = vec![(0, 0, Default::default()); span]; |
|
| 295 | 297 | let mut total = 0; |
|
| 296 | 298 | for (repo, at, n, via) in rows { |
|
| 297 | 299 | let d = at / DAY_MS - first; |
|
| 298 | - | if !(0..365).contains(&d) { |
|
| 300 | + | if d < 0 || d as usize >= span { |
|
| 299 | 301 | continue; |
|
| 300 | 302 | } |
|
| 301 | 303 | let day = &mut days[d as usize]; |
|
| … | 158 unchanged lines |
modified crates/hub/tests/accounts.rs +9 −0
| … | 1148 unchanged lines |
||
| 1149 | 1149 | let app = &h.app; |
|
| 1150 | 1150 | register(app, "andy", "").await; |
|
| 1151 | 1151 | let andy = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1152 | + | // A year-old account gets the whole year; a new one, from when it began. |
|
| 1153 | + | sqlx::query("UPDATE accounts SET created_ms = ? WHERE id = ?") |
|
| 1154 | + | .bind(db::now_ms() - 400 * 86_400_000) |
|
| 1155 | + | .bind(andy.id) |
|
| 1156 | + | .execute(&app.db) |
|
| 1157 | + | .await |
|
| 1158 | + | .unwrap(); |
|
| 1152 | 1159 | let game = db::repos::create(&app.db, "andy", "game", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 1153 | 1160 | let secret = db::repos::create(&app.db, "andy", "secret", "", hub_api::Visibility::Private).await.unwrap(); |
|
| 1154 | 1161 | let now = db::now_ms(); |
|
| … | 34 unchanged lines |
||
| 1189 | 1196 | assert_eq!((total, via), (11, 2), "5 + 2 + 4 changes, 2 via an agent; the landing and the private push left out"); |
|
| 1190 | 1197 | assert_eq!(days[361], serde_json::json!([7, 2, [[0, 7]]]), "three days ago"); |
|
| 1191 | 1198 | assert_eq!(json["peaks"][0], serde_json::json!([361, "game v1.0"])); |
|
| 1199 | + | db::accounts::create_account(&app.db, "bea", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 1200 | + | assert!(get(app, "/bea", None).await.body.contains("No changes pushed"), "a new account with no pushes says so"); |
|
| 1192 | 1201 | assert!( |
|
| 1193 | 1202 | page.contains("data-total=\"11\"") && page.find("pulse-box").unwrap() < page.find("<h2>Repositories</h2>").unwrap(), |
|
| 1194 | 1203 | "above the repositories" |
|
| … | 2 unchanged lines |
modified templates/pulse.html +24 −14
| … | 16 unchanged lines |
||
| 17 | 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 | 18 | const PAL = 7, col = r => r < PAL ? `var(--pu${r + 1})` : "var(--pu8)"; |
|
| 19 | 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); |
|
| 20 | + | const N = days.length, LAST = N - 1; |
|
| 21 | + | const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / LAST); |
|
| 21 | 22 | const s = el("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Changes pushed each day over the last year" }); |
|
| 22 | 23 | stage.prepend(s); |
|
| 23 | 24 | const defs = el("defs", {}, s); |
|
| … | 10 unchanged lines |
||
| 34 | 35 | const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip); |
|
| 35 | 36 | const grid = el("g", {}, s), ylab = []; |
|
| 36 | 37 | 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 | + | days.forEach((d, i) => { if (N > 62 ? d.date.getUTCDate() !== 1 : d.date.getUTCDay() !== 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", N > 62 ? { month: "short", timeZone: "UTC" } : { day: "numeric", month: "short", timeZone: "UTC" }); }); |
|
| 38 | 39 | const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s); |
|
| 39 | 40 | const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot); |
|
| 40 | 41 | const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot); |
|
| … | 10 unchanged lines |
||
| 51 | 52 | const v = days.map(val), max = Math.max(1, ...v); |
|
| 52 | 53 | ys = v.map(n => base - (base - T) * n / max); |
|
| 53 | 54 | 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 | + | line.setAttribute("d", d); area.setAttribute("d", d + `L${X(LAST)},${base}L${X(0)},${base}Z`); |
|
| 55 | 56 | 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 | 57 | 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 | + | for (let i = 0; i <= LAST; i += Math.max(1, Math.round(N / 52))) el("stop", { offset: (i / LAST).toFixed(4), style: `stop-color:${col(only < 0 ? top(days[i]) : only)}` }, grad); |
|
| 58 | 59 | strip.textContent = ""; |
|
| 59 | 60 | 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 | 61 | ylab.forEach((t, k) => { t.textContent = Math.round(max * (4 - k) / 4); }); |
|
| 61 | 62 | 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 | - | }); |
|
| 63 | + | if (only < 0) { |
|
| 64 | + | // At most four labels, the biggest days first, none crowding another; |
|
| 65 | + | // the other releases are marked without words. |
|
| 66 | + | const peaks = (data.peaks || []).filter(([i]) => i >= 0 && i <= LAST).sort((a, b) => ys[a[0]] - ys[b[0]]); |
|
| 67 | + | const labelled = []; |
|
| 68 | + | peaks.forEach(([i, label]) => { |
|
| 69 | + | const y = ys[i], free = labelled.length < 4 && labelled.every(j => Math.abs(X(j) - X(i)) > 120); |
|
| 70 | + | el("circle", { cx: X(i), cy: y, r: free ? 3.2 : 2.2, fill: "var(--ink)" }, peakG); |
|
| 71 | + | if (!free) return; |
|
| 72 | + | labelled.push(i); |
|
| 73 | + | el("circle", { cx: X(i), cy: y, r: 4, fill: "none", stroke: "var(--ink)", class: "pu-ring" }, peakG); |
|
| 74 | + | const right = X(i) < W - 160; |
|
| 75 | + | 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; |
|
| 76 | + | }); |
|
| 77 | + | } |
|
| 69 | 78 | } |
|
| 70 | 79 | build(); |
|
| 71 | 80 | // Legend: a repository's own year. |
|
| … | 18 unchanged lines |
||
| 90 | 99 | } |
|
| 91 | 100 | if (!reduce) { |
|
| 92 | 101 | 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); }; |
|
| 102 | + | let shown = false; |
|
| 103 | + | const reveal = now => { if (shown) return; 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 | 104 | requestAnimationFrame(reveal); |
|
| 95 | - | setTimeout(() => { clipRect.setAttribute("width", W); heartbeat(); }, 1700); |
|
| 105 | + | setTimeout(() => { shown = true; clipRect.setAttribute("width", W); heartbeat(); }, 1700); |
|
| 96 | 106 | } |
|
| 97 | 107 | // Pointer: spotlight, scan line and the day's card. |
|
| 98 | 108 | const fmt = d => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); |
|
| 99 | 109 | s.addEventListener("pointermove", e => { |
|
| 100 | 110 | idle = false; beat.setAttribute("opacity", "0"); |
|
| 101 | 111 | 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]; |
|
| 112 | + | const i = Math.max(0, Math.min(LAST, Math.round((x - L) / ((W - L - R) / LAST)))), d = days[i]; |
|
| 103 | 113 | spotRect.setAttribute("fill", "url(#pu-spot)"); spot.setAttribute("gradientTransform", `translate(${X(i)} ${ys[i]}) scale(160 240)`); |
|
| 104 | 114 | scan.setAttribute("x1", X(i)); scan.setAttribute("x2", X(i)); scan.setAttribute("opacity", ".45"); |
|
| 105 | 115 | dot.setAttribute("cx", X(i)); dot.setAttribute("cy", ys[i]); dot.style.stroke = col(only < 0 ? top(d) : only); dot.setAttribute("opacity", "1"); |
|
| … | 14 unchanged lines |
modified templates/style.css +4 −4
| … | 1278 unchanged lines |
||
| 1279 | 1279 | .pulse-box .card-head { flex-wrap: wrap; row-gap: 6px; } |
|
| 1280 | 1280 | .pulse-total { font-weight: 700; font-variant-numeric: tabular-nums; } |
|
| 1281 | 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); } |
|
| 1282 | + | :root .pulse-legend button.pu-chip { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; font-size: 11.5px; line-height: 1.6; border-radius: 999px; text-transform: none; letter-spacing: 0; font-weight: 500; min-height: 0; } |
|
| 1283 | + | :root .pulse-legend button.pu-chip::first-letter { text-transform: none; } |
|
| 1284 | + | :root .pulse-legend button.pu-chip i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } |
|
| 1285 | + | :root .pulse-legend button.pu-chip[aria-pressed="true"] { border-color: var(--ink); background: var(--soft); } |
|
| 1286 | 1286 | .pulse-stage { position: relative; padding: 8px 12px 4px; } |
|
| 1287 | 1287 | .pulse-stage svg { width: 100%; height: auto; display: block; touch-action: pan-y; } |
|
| 1288 | 1288 | .pulse-stage .pu-axis { font: 10px var(--mono, ui-monospace, monospace); fill: var(--mute); } |
|
| … | 9 unchanged lines |