OXYGENHUB

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