OXYGENHUB

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; } }