Profile pulse: the last 3, 6, 9 or 12 months c0095389312b
The pulse draws the shortest of the last 3, 6, 9 or 12 months that holds
the owner's first push of the year, so a month's work is not drawn
across a year; its heading says which ('Last 3 months'), with ticks on
the 1st and 15th when it is 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:50 UTC
- Signature
- ✔ signed by Claude Code
- Hash
c0095389312bf1b2c80f27971c055382946928e880bba8c1da8329e3d38239d6- Depends on
- 19bb31ff1f5c 2a0c09c09716 a2fbad481dd7 d23464c0ac60
- Depended on by
- a7aa76ac52d7 3b1bc27fe805
- 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
- 12 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/web/home.rs +9 −5
| … | 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]..]}`: the days since the account began, at |
|
| 264 | - | /// least 30 and at most 365, ending today (UTC). |
|
| 263 | + | /// "peaks": [[DAY, LABEL]..], "months": M}`: the last 3, 6, 9 or 12 |
|
| 264 | + | /// months, the shortest that holds their first push of the year, ending |
|
| 265 | + | /// today (UTC). |
|
| 265 | 266 | async fn pulse_json(app: &Shared, account: &db::accounts::Account, seen: &[db::repos::RepoRow]) -> sqlx::Result<(String, i64)> { |
|
| 266 | 267 | let today = db::now_ms() / DAY_MS; |
|
| 267 | - | let span = (today - account.created_ms / DAY_MS + 1).clamp(30, 365); |
|
| 268 | + | let ids: Vec<i64> = seen.iter().map(|r| r.id).collect(); |
|
| 269 | + | let rows = db::activity::pulse(&app.db, account.id, &ids, (today - 364) * DAY_MS).await?; |
|
| 270 | + | // Snap to 3, 6, 9 or 12 months: no point drawing a year for a month's work. |
|
| 271 | + | let used = rows.first().map(|r| today - r.1 / DAY_MS + 1).unwrap_or(0); |
|
| 272 | + | let (months, span) = [(3, 91), (6, 182), (9, 273), (12, 365)].into_iter().find(|(_, d)| used <= *d).unwrap_or((12, 365)); |
|
| 268 | 273 | let first = today - span + 1; |
|
| 269 | - | let ids: Vec<i64> = seen.iter().map(|r| r.id).collect(); |
|
| 270 | - | let rows = db::activity::pulse(&app.db, account.id, &ids, first * DAY_MS).await?; |
|
| 271 | 274 | let (pulse, total) = pulse_of(&rows, seen, first, span as usize); |
|
| 272 | 275 | let mut value = pulse; |
|
| 273 | 276 | let mut peaks = Vec::new(); |
|
| … | 2 unchanged lines |
||
| 276 | 279 | peaks.push(serde_json::json!([at / DAY_MS - first, format!("{name} {tag}")])); |
|
| 277 | 280 | } |
|
| 278 | 281 | value["peaks"] = serde_json::Value::Array(peaks); |
|
| 282 | + | value["months"] = months.into(); |
|
| 279 | 283 | Ok((value.to_string().replace('<', "\\u003c"), total)) |
|
| 280 | 284 | } |
|
| 281 | 285 | ||
| … | 180 unchanged lines |
modified crates/hub/tests/accounts.rs +5 −4
| … | 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. |
|
| 1152 | + | // The account is over a year old, but the span follows its first push. |
|
| 1153 | 1153 | sqlx::query("UPDATE accounts SET created_ms = ? WHERE id = ?") |
|
| 1154 | 1154 | .bind(db::now_ms() - 400 * 86_400_000) |
|
| 1155 | 1155 | .bind(andy.id) |
|
| … | 34 unchanged lines |
||
| 1190 | 1190 | let json: serde_json::Value = serde_json::from_str(&page[start..start + page[start..].find("</script>").unwrap()]).unwrap(); |
|
| 1191 | 1191 | assert_eq!(json["repos"], serde_json::json!(["andy/game"])); |
|
| 1192 | 1192 | let days = json["days"].as_array().unwrap(); |
|
| 1193 | - | assert_eq!(days.len(), 365); |
|
| 1193 | + | // The first push was 40 days ago: three months are drawn, not a year. |
|
| 1194 | + | assert_eq!((days.len(), json["months"].as_i64()), (91, Some(3))); |
|
| 1194 | 1195 | let total: i64 = days.iter().map(|d| d[0].as_i64().unwrap()).sum(); |
|
| 1195 | 1196 | let via: i64 = days.iter().map(|d| d[1].as_i64().unwrap()).sum(); |
|
| 1196 | 1197 | assert_eq!((total, via), (11, 2), "5 + 2 + 4 changes, 2 via an agent; the landing and the private push left out"); |
|
| 1197 | - | assert_eq!(days[361], serde_json::json!([7, 2, [[0, 7]]]), "three days ago"); |
|
| 1198 | - | assert_eq!(json["peaks"][0], serde_json::json!([361, "game v1.0"])); |
|
| 1198 | + | assert_eq!(days[87], serde_json::json!([7, 2, [[0, 7]]]), "three days ago"); |
|
| 1199 | + | assert_eq!(json["peaks"][0], serde_json::json!([87, "game v1.0"])); |
|
| 1199 | 1200 | db::accounts::create_account(&app.db, "bea", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 1200 | 1201 | assert!(get(app, "/bea", None).await.body.contains("No changes pushed"), "a new account with no pushes says so"); |
|
| 1201 | 1202 | assert!( |
|
| … | 4 unchanged lines |
modified templates/pulse.html +3 −2
| … | 2 unchanged lines |
||
| 3 | 3 | violet trace, releases on the peaks. Drawn by the script below from the |
|
| 4 | 4 | page's JSON; nothing is fetched. #} |
|
| 5 | 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> |
|
| 6 | + | <div class="card-head"><h2 id="pulse-title">Last 12 months</h2><span class="pulse-total" id="pulse-total" data-total="{{ pulse_total }}">{{ pulse_total }}</span><span class="small mute">changes pushed</span> |
|
| 7 | 7 | <div class="pulse-legend more" id="pulse-legend"></div></div> |
|
| 8 | 8 | {% if pulse_total == 0 %}<p class="empty small">No changes pushed in the last year.</p>{% else %} |
|
| 9 | 9 | <div class="pulse-stage" id="pulse-stage"><div class="pulse-card" id="pulse-card" aria-live="polite"></div></div> |
|
| … | 6 unchanged lines |
||
| 16 | 16 | const NS = "http://www.w3.org/2000/svg", reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; |
|
| 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 | + | document.getElementById("pulse-title").textContent = `Last ${data.months} months`; |
|
| 19 | 20 | const days = data.days.map((d, i) => ({ t: d[0], via: d[1], by: d[2], date: new Date(data.start + i * 864e5) })); |
|
| 20 | 21 | const N = days.length, LAST = N - 1; |
|
| 21 | 22 | const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / LAST); |
|
| … | 13 unchanged lines |
||
| 35 | 36 | const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip); |
|
| 36 | 37 | const grid = el("g", {}, s), ylab = []; |
|
| 37 | 38 | 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)); } |
|
| 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" }); }); |
|
| 39 | + | days.forEach((d, i) => { if (N > 100 ? d.date.getUTCDate() !== 1 : (d.date.getUTCDate() !== 1 && d.date.getUTCDate() !== 15)) 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 > 100 ? { month: "short", timeZone: "UTC" } : { day: "numeric", month: "short", timeZone: "UTC" }); }); |
|
| 39 | 40 | const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s); |
|
| 40 | 41 | const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot); |
|
| 41 | 42 | const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot); |
|
| … | 88 unchanged lines |