OXYGENHUB

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