lvwerra HF Staff commited on
Commit
246e758
·
verified ·
1 Parent(s): b0d67cc

Deploy msgboard

Browse files
src/msgboard/analytics.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Units, range validation and aggregation helpers for board analytics. SQL stays in store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+
8
+ from .errors import bad_request
9
+
10
+ DAY = 86400
11
+ MAX_TIMESTAMP = 253402300800 # exclusive end of UTC year 9999
12
+ WORD = re.compile(r"\b\w+(?:['’]\w+)*\b", re.UNICODE)
13
+ RESPONSE_BOUNDS = (0, 60, 300, 900, 3600, 14400, 86400)
14
+
15
+
16
+ def bounds(now: float, days: int = 7, start: float | None = None,
17
+ end: float | None = None) -> tuple[float, float]:
18
+ end = now if end is None else end
19
+ if not math.isfinite(end) or not 0 < end <= MAX_TIMESTAMP:
20
+ raise bad_request("to must be a finite positive Unix timestamp through UTC year 9999")
21
+ if start is None:
22
+ start = max(0, (math.ceil(end / DAY) - days) * DAY)
23
+ if not math.isfinite(start) or start < 0 or start >= end:
24
+ raise bad_request("from must be a finite nonnegative Unix timestamp before to")
25
+ if end - start > 365 * DAY:
26
+ raise bad_request("choose at most 365 days")
27
+ return start, end
28
+
29
+
30
+ def percentile(values: list[float], fraction: float) -> float | None:
31
+ if not values:
32
+ return None
33
+ ordered = sorted(values)
34
+ index = (len(ordered) - 1) * fraction
35
+ low = math.floor(index)
36
+ high = math.ceil(index)
37
+ return ordered[low] + (ordered[high] - ordered[low]) * (index - low)
38
+
39
+
40
+ def response_summary(requests: int, latencies: list[float]) -> dict:
41
+ return {"requests": requests, "answered": len(latencies), "unanswered": requests - len(latencies),
42
+ "median_seconds": percentile(latencies, .5), "p90_seconds": percentile(latencies, .9)}
43
+
44
+
45
+ def response_histogram(latencies: list[float]) -> list[dict]:
46
+ bins = [{"from_seconds": low, "to_seconds": high, "count": 0}
47
+ for low, high in zip(RESPONSE_BOUNDS, (*RESPONSE_BOUNDS[1:], None))]
48
+ for value in latencies:
49
+ for bucket in bins:
50
+ if bucket["to_seconds"] is None or value < bucket["to_seconds"]:
51
+ bucket["count"] += 1
52
+ break
53
+ return bins
54
+
55
+
56
+ def chord_graph(edges: list[dict], identities: dict[int, dict], top_k: int) -> dict:
57
+ weights: dict[int, int] = {}
58
+ for edge in edges:
59
+ for pid in (edge["source"], edge["target"]):
60
+ weights[pid] = weights.get(pid, 0) + edge["total"]
61
+ ranked = sorted(weights, key=lambda pid: (-weights[pid], pid))
62
+ selected = set(ranked[:top_k])
63
+ nodes = [dict(identities[pid], total=weights[pid], members=1) for pid in ranked[:top_k]]
64
+ hidden = set(ranked) - selected
65
+ if hidden:
66
+ nodes.append({"id": "other", "display_name": "Other", "external_id": "other", "identity_type": "other",
67
+ "total": sum(weights[pid] for pid in hidden), "members": len(hidden)})
68
+ links = {}
69
+ for edge in edges:
70
+ source = edge["source"] if edge["source"] in selected else "other"
71
+ target = edge["target"] if edge["target"] in selected else "other"
72
+ key = tuple(sorted((source, target), key=lambda pid: (pid == "other", str(pid))))
73
+ link = links.setdefault(key, {"source": key[0], "target": key[1], "total": 0,
74
+ "mentions": 0, "replies": 0, "dms": 0})
75
+ for field in ("total", "mentions", "replies", "dms"):
76
+ link[field] += edge[field]
77
+ result = sorted(links.values(), key=lambda link: (-link["total"], str(link["source"]), str(link["target"])))
78
+ return {"top_k": top_k, "total": sum(edge["total"] for edge in edges), "nodes": nodes, "links": result,
79
+ "other_internal": next((dict(link) for link in result if link["source"] == link["target"] == "other"),
80
+ {"source": "other", "target": "other", "total": 0, "mentions": 0, "replies": 0, "dms": 0})}
81
+
82
+
83
+ def listening_series(start: float, end: float, records: dict[int, tuple[float, int]],
84
+ started_at: float | None) -> dict:
85
+ width = max(60, math.ceil((end - start) / 1500 / 60) * 60)
86
+ first = math.floor(start / width) * width
87
+ buckets = {at: [] for at in range(first, math.ceil(end / width) * width, width)}
88
+ for minute, (observed_at, count) in records.items():
89
+ at = (minute // width) * width
90
+ if start <= observed_at < end and at in buckets:
91
+ buckets[at].append(count)
92
+ series = []
93
+ for at, counts in buckets.items():
94
+ lo, hi = max(at, start), min(at + width, end)
95
+ expected = math.ceil(hi / 60) - math.floor(lo / 60)
96
+ series.append({"at": at, "count": sum(counts) / len(counts) if counts else None,
97
+ "min": min(counts) if counts else None, "max": max(counts) if counts else None,
98
+ "samples": len(counts), "expected_samples": expected})
99
+ return {"sample_seconds": 60, "bucket_seconds": width, "started_at": started_at, "series": series}
src/msgboard/client.py CHANGED
@@ -328,9 +328,15 @@ class Client:
328
  """How to use this board, as markdown, ending with the workspace guidelines its admins wrote."""
329
  return self._request("GET", "/guide").text
330
 
331
- def analytics(self, days: int = 30) -> dict:
332
- """Board activity (admins and readers): totals, daily counts, channels, who posts."""
333
- return self._request("GET", "/analytics", params={"days": days}).json()
 
 
 
 
 
 
334
 
335
  def open_dm(self, principal_id: int) -> dict:
336
  """The direct-message channel with someone (created on first use); post to its `id`."""
 
328
  """How to use this board, as markdown, ending with the workspace guidelines its admins wrote."""
329
  return self._request("GET", "/guide").text
330
 
331
+ def analytics(self, days: int = 7, *, start: float | None = None, end: float | None = None,
332
+ top_k: int = 10) -> dict:
333
+ """Analytics for admins/readers. Optional Unix-second bounds select [start, end)."""
334
+ params = {"days": days, "top_k": top_k}
335
+ if start is not None:
336
+ params["from"] = start
337
+ if end is not None:
338
+ params["to"] = end
339
+ return self._request("GET", "/analytics", params=params).json()
340
 
341
  def open_dm(self, principal_id: int) -> dict:
342
  """The direct-message channel with someone (created on first use); post to its `id`."""
src/msgboard/dashboard.html CHANGED
@@ -612,6 +612,31 @@ button.fchip:hover { border-color: var(--ink); }
612
  .sbody p.lead a { color: var(--ink); }
613
  #guide-rendered { border: 1px solid var(--rule); padding: 4px 14px; min-height: 120px; }
614
  .an-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  .seg { display: inline-flex; border: 1px solid var(--rule-2); }
616
  .seg button { background: none; border: 0; padding: 4px 11px; font: 11.5px var(--mono); color: var(--ink-2); }
617
  .seg button + button { border-left: 1px solid var(--rule-2); }
@@ -755,6 +780,8 @@ dialog input[type=text] { width: 100%; margin: 4px 0 12px; }
755
  <button class="info-btn hidden" id="ch-doc" aria-label="Channel doc" title="Channel doc"></button>
756
  <button class="info-btn hidden" id="ch-info" aria-label="Channel info" aria-haspopup="dialog" aria-expanded="false" title="Channel info">
757
  <svg viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="7.2"/><path d="M9 8v5"/><path d="M9 5.2h.01" stroke-width="2.2" stroke-linecap="round"/></svg></button>
 
 
758
  </div>
759
  <div id="chat-view" style="display:contents">
760
  <div class="scroll" id="scroll"><div id="messages"></div></div>
@@ -764,6 +791,7 @@ dialog input[type=text] { width: 100%; margin: 4px 0 12px; }
764
  <div id="settings" class="hidden"></div>
765
  <div id="activity" class="scroll hidden" style="padding:0"></div>
766
  <div id="search" class="scroll hidden"></div>
 
767
  </main>
768
  <section id="panel" class="hidden">
769
  <div class="resizer" id="panel-resize" role="separator" aria-orientation="vertical" aria-label="Resize side panel" tabindex="0" title="Drag to resize · double-click to reset"></div>
@@ -987,7 +1015,7 @@ function decorate(html, mentions) {
987
  if (h) { // a link to a message, file or channel on this board: open it here, with a readable label
988
  a.dataset.internal = h; a.removeAttribute("target");
989
  if (a.textContent.trim() === a.getAttribute("href") || a.textContent.trim() === a.href) {
990
- a.textContent = h.startsWith("msg/") ? "↪ message" : h.startsWith("file/") ? "📎 file" : /^(settings|activity|search)$/.test(h) ? h : "#" + h;
991
  }
992
  } else { a.target = "_blank"; a.rel = "noopener noreferrer"; }
993
  });
@@ -1465,11 +1493,13 @@ async function boot() {
1465
  $("new-dm").classList.toggle("hidden", !can("post"));
1466
  $("settings-open").classList.toggle("hidden", !(can("invite_agents") || can("manage")));
1467
  $("add-agents").classList.toggle("hidden", !can("invite_agents"));
 
1468
  $("show-private-row").classList.toggle("hidden", !can("manage"));
1469
  $("show-private").checked = !!(state.me.prefs && state.me.prefs.show_private);
1470
  await Promise.all([refreshChannels(), renderLibs]);
1471
  if (location.hash === "#settings") return openSettings();
1472
  if (location.hash === "#activity") return openActivity();
 
1473
  if (/^#(msg|file)\//.test(location.hash)) { // a shared link: open a channel first, then the message or file
1474
  const first = state.channels.find((c) => c.name === store.get("channel")) || state.channels.find((c) => c.kind !== "dm") || state.channels[0];
1475
  const target = location.hash;
@@ -1614,7 +1644,7 @@ function updateMeta(live) {
1614
  }
1615
  function showChat() {
1616
  state.view = "chat";
1617
- $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.add("hidden"); $("chat-view").style.display = "contents";
1618
  }
1619
  async function openChannel(id) {
1620
  const c = state.channels.find((x) => x.id === id);
@@ -2632,7 +2662,7 @@ function internalHash(href) {
2632
  let u; try { u = new URL(href, location.href); } catch { return null; }
2633
  if (u.origin !== location.origin || u.pathname !== location.pathname || !u.hash) return null;
2634
  const h = decodeURIComponent(u.hash.slice(1));
2635
- return /^(msg|file)\/[\w-]+$/.test(h) || /^(settings|activity|search)$/.test(h) || state.chanIndex.has(h.toLowerCase()) ? h : null;
2636
  }
2637
  async function routeHash(h) {
2638
  h = decodeURIComponent((h || "").replace(/^#/, ""));
@@ -2645,6 +2675,7 @@ async function routeHash(h) {
2645
  if ((m = /^file\/([\w-]+)$/.exec(h))) { openViewer([m[1]], 0); return true; }
2646
  if (h === "settings") { openSettings(); return true; }
2647
  if (h === "activity") { openActivity(); return true; }
 
2648
  if (h === "search") { openSearch(""); return true; }
2649
  const c = state.chanIndex.get(h.toLowerCase()) || state.channels.find((x) => x.name === h);
2650
  if (c) { closePanel(); openChannel(c.id); return true; }
@@ -3184,7 +3215,7 @@ async function openActivity() {
3184
  if (state.poll) state.poll.abort();
3185
  state.current = null; closePanel(); renderChannels();
3186
  history.replaceState(null, "", location.pathname + "#activity");
3187
- $("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("search").classList.add("hidden"); $("activity").classList.remove("hidden");
3188
  $("ch-title").innerHTML = "Activity"; $("ch-desc").textContent = "";
3189
  $("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3190
  // show the last list right away and swap in the fresh one when it arrives
@@ -3370,7 +3401,7 @@ async function openSearch(q, opts = {}) {
3370
  if (state.poll) state.poll.abort();
3371
  state.current = null; closePanel(); renderChannels();
3372
  history.replaceState(null, "", location.pathname + "#search");
3373
- $("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.remove("hidden");
3374
  $("ch-title").innerHTML = "Search"; $("ch-desc").textContent = "";
3375
  $("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3376
  const f = state.searchFilters = Object.assign({ channel: "", sender: "", when: "", has: "", by: "", sort: "newest" }, opts.keep ? state.searchFilters : {}, opts.filters || {});
@@ -3464,19 +3495,19 @@ function openSettings(tab) {
3464
  if (state.poll) state.poll.abort();
3465
  state.current = null; renderChannels(); closePanel();
3466
  history.replaceState(null, "", location.pathname + "#settings");
3467
- $("chat-view").style.display = "none"; $("activity").classList.add("hidden"); $("search").classList.add("hidden"); $("settings").classList.remove("hidden");
3468
  $("ch-title").innerHTML = "Settings"; $("ch-desc").textContent = ""; $("ch-meta").textContent = "";
3469
  $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3470
  const tabs = [["invites", "Invites"]];
3471
  if (can("manage")) tabs.push(["people", "People"]);
3472
  tabs.push(["agents", can("manage") ? "Agents" : "My agents"], ["listening", "Who's listening"]);
3473
- if (can("manage")) tabs.push(["suspended", "Suspended"], ["board", "Board"], ["analytics", "Analytics"]);
3474
  tab = tab || state.settingsTab || "invites";
3475
  if (!tabs.some(([k]) => k === tab)) tab = "invites";
3476
  state.settingsTab = tab;
3477
  $("settings").innerHTML = `<div class="tabs">${tabs.map(([k, l]) => `<button data-tab="${k}" class="${k === tab ? "on" : ""}">${l}</button>`).join("")}</div><div class="sbody" id="sbody"><div class="empty">Loading…</div></div>`;
3478
  $("settings").querySelectorAll("[data-tab]").forEach((b) => b.onclick = () => openSettings(b.dataset.tab));
3479
- ({ invites: settingsInvites, people: settingsPeople, agents: settingsAgents, listening: settingsListening, suspended: settingsSuspended, board: settingsBoard, analytics: settingsAnalytics })[tab]()
3480
  .catch((e) => { $("sbody").innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
3481
  }
3482
  // A2 look on trial: a per-browser switch, applied before first paint by the snippet in <head>
@@ -3778,48 +3809,274 @@ async function settingsListening() {
3778
  hydrate($("sbody"));
3779
  $("sbody").querySelectorAll("[data-lf]").forEach((b) => b.onclick = () => { state.listenFilter = b.dataset.lf; settingsListening(); });
3780
  }
3781
- async function settingsAnalytics() {
3782
- const days = state.analyticsDays || 30;
3783
- const a = await api("GET", `/analytics?days=${days}`);
3784
- if (state.settingsTab !== "analytics") return;
3785
- const t = a.totals, filter = state.analyticsWho || "all";
3786
- const tile = (label, value, sub) => `<div class="tile"><div class="label">${label}</div><div class="num">${fmtN(value)}</div><div class="small">${sub}</div></div>`;
3787
- const posters = a.posters.filter((x) => filter === "all" || (filter === "agents") === (x.identity_type !== "user"));
3788
- const maxP = Math.max(1, ...a.posters.map((x) => x.messages)), maxC = Math.max(1, ...a.channels.map((c) => c.messages));
3789
- $("sbody").innerHTML = `
3790
- <div class="an-head"><h3>Analytics</h3><div class="seg" role="group" aria-label="Time range">${[7, 30, 90].map((d) =>
3791
- `<button data-days="${d}" class="${d === days ? "on" : ""}">${d} days</button>`).join("")}</div></div>
3792
- <p class="lead">Messages posted on this board, by day (UTC). Everything is counted, including private channels and DMs; DMs are only counted together.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3793
  <div class="tiles">
3794
- ${tile("Messages", t.messages_window, `${fmtN(t.messages)} all time`)}
3795
- ${tile("Active agents", t.active_agents_7d, `of ${t.agents} · last 7 days`)}
3796
- ${tile("Active people", t.active_people_7d, `of ${t.people} · last 7 days`)}
3797
- ${tile("Threads", t.threads_window, `with replies, last ${days} days`)}
3798
- ${tile("Files", t.attachments_window, `shared, last ${days} days`)}
3799
- ${tile("Channels", t.channels, "open")}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3800
  </div>
3801
- <div class="card chart-card"><div class="chart-top"><h4>Messages per day</h4>
3802
- <div class="legend"><span><i class="sw agents"></i>agents</span><span><i class="sw people"></i>people</span></div></div>
3803
- <div id="an-chart" class="an-chart"></div>
3804
- <details class="small"><summary>Show as table</summary><table class="an-table"><thead><tr><th>Day</th><th>Agents</th><th>People</th><th>Total</th></tr></thead><tbody>
3805
- ${a.daily.slice().reverse().map((d) => `<tr><td>${esc(dayLabel(d.day, true))}</td><td>${d.agents}</td><td>${d.people}</td><td>${d.agents + d.people}</td></tr>`).join("")}</tbody></table></details></div>
3806
  <div class="card"><h4>Channels</h4>
3807
- <table class="an-table wide"><thead><tr><th>Channel</th><th class="bar-col">Messages, last ${days} days</th><th>Posters</th><th class="hide-sm">All time</th><th class="hide-sm">Last message</th></tr></thead><tbody>
3808
- ${a.channels.map((c) => `<tr class="${c.archived ? "muted" : ""}"><td>${c.id ? `<span class="chan-link" data-channel="${c.id}" role="link" tabindex="0">${c.kind === "private" ? LOCK : "#"}${esc(c.name)}</span>` : `<i>${esc(c.name)}</i>`}${c.archived ? ' <span class="label">archived</span>' : ""}</td>
3809
  <td class="bar-col"><span class="hbar"><span style="width:${(100 * c.messages / maxC).toFixed(1)}%"></span></span><b>${c.messages}</b></td>
3810
  <td>${c.posters}</td><td class="hide-sm">${c.total}</td><td class="small hide-sm">${c.last_at ? esc(ago(c.last_at)) : "never"}</td></tr>`).join("")}</tbody></table></div>
3811
- <div class="card"><div class="chart-top"><h4>Who posts</h4><div class="seg small-seg" role="group" aria-label="Show">${[["all", "Everyone"], ["agents", "Agents"], ["people", "People"]].map(([k, l]) =>
3812
- `<button data-who="${k}" class="${k === filter ? "on" : ""}">${l}</button>`).join("")}</div></div>
3813
  ${posters.length ? `<table class="an-table wide"><thead><tr><th>Name</th><th class="hide-sm">Owner</th><th class="bar-col">Messages</th><th class="hide-sm">Daily</th><th class="hide-sm">Channels</th><th>Last post</th></tr></thead><tbody>
3814
  ${posters.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span><span class="label">${esc(kindLabel(x))}</span></span></td>
3815
  <td class="small hide-sm">${x.owner ? esc(nameOf(x.owner)) : ""}</td>
3816
  <td class="bar-col"><span class="hbar"><span style="width:${(100 * x.messages / maxP).toFixed(1)}%"></span></span><b>${x.messages}</b></td>
3817
  <td class="hide-sm">${spark(x.daily, a.daily)}</td><td class="hide-sm">${x.channels}</td><td class="small">${esc(ago(x.last_at))}</td></tr>`).join("")}</tbody></table>`
3818
- : `<div class="empty">Nobody in this group posted in the last ${days} days.</div>`}</div>`;
3819
- hydrate($("sbody"));
3820
- $("sbody").querySelectorAll("[data-days]").forEach((b) => b.onclick = () => { state.analyticsDays = Number(b.dataset.days); settingsAnalytics(); });
3821
- $("sbody").querySelectorAll("[data-who]").forEach((b) => b.onclick = () => { state.analyticsWho = b.dataset.who; settingsAnalytics(); });
3822
- drawDaily($("an-chart"), a.daily);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3823
  }
3824
  function spark(values, daily) {
3825
  const w = 84, h = 18, max = Math.max(1, ...values), bw = w / values.length;
@@ -3869,10 +4126,12 @@ function barPath(x, y0, w, h, roundTop) {
3869
  const r = roundTop ? Math.min(4, w / 2, h) : 0; // rounded only on the data end
3870
  return `M${x},${y0 + h}V${y0 + r}${r ? `Q${x},${y0} ${x + r},${y0}` : ""}H${x + w - r}${r ? `Q${x + w},${y0} ${x + w},${y0 + r}` : ""}V${y0 + h}Z`;
3871
  }
3872
- window.addEventListener("resize", () => { if (state.view === "settings" && state.settingsTab === "analytics" && $("an-chart")) settingsAnalytics(); });
 
3873
 
3874
  // ------------------------------------------------------------------ misc
3875
  $("me-who").onclick = () => state.me && openProfile(state.me.id);
 
3876
  $("menu").onclick = () => $("app").classList.toggle("nav");
3877
  $("app").addEventListener("click", (e) => {
3878
  if (!$("app").classList.contains("nav") || e.target.closest("aside, #menu")) return;
@@ -3935,6 +4194,7 @@ $("channel-form").addEventListener("submit", async (e) => {
3935
  window.addEventListener("hashchange", () => {
3936
  if (location.hash === "#settings") { if (state.view !== "settings") openSettings(); return; }
3937
  if (location.hash === "#activity") { if (state.view !== "activity") openActivity(); return; }
 
3938
  const c = state.channels.find((x) => x.name === decodeURIComponent(location.hash.slice(1)));
3939
  if (c && (!state.current || c.id !== state.current.id)) openChannel(c.id);
3940
  });
 
612
  .sbody p.lead a { color: var(--ink); }
613
  #guide-rendered { border: 1px solid var(--rule); padding: 4px 14px; min-height: 120px; }
614
  .an-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
615
+ #ch-info:not(.hidden) + #analytics-open, #ch-doc:not(.hidden) ~ #analytics-open { margin-left: 4px; }
616
+ .an-page { max-width: 1400px; }
617
+ .an-page .an-head { justify-content: flex-start; margin-bottom: 4px; }
618
+ .an-custom { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 6px; border: 1px solid transparent; }
619
+ .an-custom.on { border-color: var(--ink); }
620
+ .an-custom input[type=date] { font: 12px var(--mono); border: 1px solid var(--rule-2); background: var(--bg); color: var(--ink); padding: 2px 4px; }
621
+ .an-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 18px; margin-bottom: 18px; }
622
+ .an-grid .card { margin: 0; min-width: 0; }
623
+ .an-rule { margin: 2px 0 6px; color: var(--ink-3); }
624
+ .an-k { display: inline-flex; align-items: center; gap: 6px; } .an-k input { width: 140px; accent-color: var(--ink); }
625
+ .an-chart .line { fill: none; stroke: var(--ink); stroke-width: 2; stroke-linejoin: round; }
626
+ .an-chart .dotm { fill: var(--ink); stroke: var(--bg); stroke-width: 2; }
627
+ .an-chart .mark { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; }
628
+ .an-chart .tick.strong { fill: var(--ink); font-weight: 600; }
629
+ .an-chord svg { max-width: 560px; margin: 0 auto; }
630
+ .an-chord .ribbon { fill: var(--ink); fill-opacity: .16; stroke: var(--bg); stroke-width: 1; transition: fill-opacity .1s; }
631
+ .an-chord .ribbon.dim { fill-opacity: .05; } .an-chord .ribbon:not(.dim):hover { fill-opacity: .6; }
632
+ .an-chord .arc { stroke: var(--ink); stroke-width: 1; cursor: default; }
633
+ .an-chord .arc.agents { fill: var(--ink); } .an-chord .arc.people { fill: var(--ink-3); } .an-chord .arc.other { fill: var(--bg); stroke-dasharray: 2 2; }
634
+ .an-chord .node { fill: var(--ink); font: 11.5px var(--sans); }
635
+ .sw.other { background: var(--bg); border: 1px dashed var(--ink); }
636
+ .an-files .hbar { width: calc(100% - 76px); } .an-files .bar-col b { white-space: nowrap; }
637
+ .an-health { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); border: 1px solid var(--rule-2); }
638
+ .an-health div { padding: 10px 12px; border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); margin: 0 -1px -1px 0; }
639
+ .an-health b { display: block; font-size: 17px; font-variant-numeric: tabular-nums; } .an-health span { font-size: 12px; color: var(--ink-3); }
640
  .seg { display: inline-flex; border: 1px solid var(--rule-2); }
641
  .seg button { background: none; border: 0; padding: 4px 11px; font: 11.5px var(--mono); color: var(--ink-2); }
642
  .seg button + button { border-left: 1px solid var(--rule-2); }
 
780
  <button class="info-btn hidden" id="ch-doc" aria-label="Channel doc" title="Channel doc"></button>
781
  <button class="info-btn hidden" id="ch-info" aria-label="Channel info" aria-haspopup="dialog" aria-expanded="false" title="Channel info">
782
  <svg viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="7.2"/><path d="M9 8v5"/><path d="M9 5.2h.01" stroke-width="2.2" stroke-linecap="round"/></svg></button>
783
+ <button class="info-btn hidden" id="analytics-open" aria-label="Analytics" title="Analytics">
784
+ <svg viewBox="0 0 18 18" aria-hidden="true"><path d="M2.5 15.5h13M5 13V9M9 13V4.5M13 13V7"/></svg></button>
785
  </div>
786
  <div id="chat-view" style="display:contents">
787
  <div class="scroll" id="scroll"><div id="messages"></div></div>
 
791
  <div id="settings" class="hidden"></div>
792
  <div id="activity" class="scroll hidden" style="padding:0"></div>
793
  <div id="search" class="scroll hidden"></div>
794
+ <div id="analytics" class="scroll hidden"></div>
795
  </main>
796
  <section id="panel" class="hidden">
797
  <div class="resizer" id="panel-resize" role="separator" aria-orientation="vertical" aria-label="Resize side panel" tabindex="0" title="Drag to resize · double-click to reset"></div>
 
1015
  if (h) { // a link to a message, file or channel on this board: open it here, with a readable label
1016
  a.dataset.internal = h; a.removeAttribute("target");
1017
  if (a.textContent.trim() === a.getAttribute("href") || a.textContent.trim() === a.href) {
1018
+ a.textContent = h.startsWith("msg/") ? "↪ message" : h.startsWith("file/") ? "📎 file" : /^(settings|activity|search|analytics)$/.test(h) ? h : "#" + h;
1019
  }
1020
  } else { a.target = "_blank"; a.rel = "noopener noreferrer"; }
1021
  });
 
1493
  $("new-dm").classList.toggle("hidden", !can("post"));
1494
  $("settings-open").classList.toggle("hidden", !(can("invite_agents") || can("manage")));
1495
  $("add-agents").classList.toggle("hidden", !can("invite_agents"));
1496
+ $("analytics-open").classList.toggle("hidden", !canAnalytics());
1497
  $("show-private-row").classList.toggle("hidden", !can("manage"));
1498
  $("show-private").checked = !!(state.me.prefs && state.me.prefs.show_private);
1499
  await Promise.all([refreshChannels(), renderLibs]);
1500
  if (location.hash === "#settings") return openSettings();
1501
  if (location.hash === "#activity") return openActivity();
1502
+ if (location.hash === "#analytics" && canAnalytics()) return openAnalytics();
1503
  if (/^#(msg|file)\//.test(location.hash)) { // a shared link: open a channel first, then the message or file
1504
  const first = state.channels.find((c) => c.name === store.get("channel")) || state.channels.find((c) => c.kind !== "dm") || state.channels[0];
1505
  const target = location.hash;
 
1644
  }
1645
  function showChat() {
1646
  state.view = "chat";
1647
+ $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.add("hidden"); leaveAnalytics(); $("chat-view").style.display = "contents";
1648
  }
1649
  async function openChannel(id) {
1650
  const c = state.channels.find((x) => x.id === id);
 
2662
  let u; try { u = new URL(href, location.href); } catch { return null; }
2663
  if (u.origin !== location.origin || u.pathname !== location.pathname || !u.hash) return null;
2664
  const h = decodeURIComponent(u.hash.slice(1));
2665
+ return /^(msg|file)\/[\w-]+$/.test(h) || /^(settings|activity|search|analytics)$/.test(h) || state.chanIndex.has(h.toLowerCase()) ? h : null;
2666
  }
2667
  async function routeHash(h) {
2668
  h = decodeURIComponent((h || "").replace(/^#/, ""));
 
2675
  if ((m = /^file\/([\w-]+)$/.exec(h))) { openViewer([m[1]], 0); return true; }
2676
  if (h === "settings") { openSettings(); return true; }
2677
  if (h === "activity") { openActivity(); return true; }
2678
+ if (h === "analytics") { openAnalytics(); return true; }
2679
  if (h === "search") { openSearch(""); return true; }
2680
  const c = state.chanIndex.get(h.toLowerCase()) || state.channels.find((x) => x.name === h);
2681
  if (c) { closePanel(); openChannel(c.id); return true; }
 
3215
  if (state.poll) state.poll.abort();
3216
  state.current = null; closePanel(); renderChannels();
3217
  history.replaceState(null, "", location.pathname + "#activity");
3218
+ $("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("search").classList.add("hidden"); leaveAnalytics(); $("activity").classList.remove("hidden");
3219
  $("ch-title").innerHTML = "Activity"; $("ch-desc").textContent = "";
3220
  $("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3221
  // show the last list right away and swap in the fresh one when it arrives
 
3401
  if (state.poll) state.poll.abort();
3402
  state.current = null; closePanel(); renderChannels();
3403
  history.replaceState(null, "", location.pathname + "#search");
3404
+ $("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); leaveAnalytics(); $("search").classList.remove("hidden");
3405
  $("ch-title").innerHTML = "Search"; $("ch-desc").textContent = "";
3406
  $("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3407
  const f = state.searchFilters = Object.assign({ channel: "", sender: "", when: "", has: "", by: "", sort: "newest" }, opts.keep ? state.searchFilters : {}, opts.filters || {});
 
3495
  if (state.poll) state.poll.abort();
3496
  state.current = null; renderChannels(); closePanel();
3497
  history.replaceState(null, "", location.pathname + "#settings");
3498
+ $("chat-view").style.display = "none"; $("activity").classList.add("hidden"); $("search").classList.add("hidden"); leaveAnalytics(); $("settings").classList.remove("hidden");
3499
  $("ch-title").innerHTML = "Settings"; $("ch-desc").textContent = ""; $("ch-meta").textContent = "";
3500
  $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3501
  const tabs = [["invites", "Invites"]];
3502
  if (can("manage")) tabs.push(["people", "People"]);
3503
  tabs.push(["agents", can("manage") ? "Agents" : "My agents"], ["listening", "Who's listening"]);
3504
+ if (can("manage")) tabs.push(["suspended", "Suspended"], ["board", "Board"]);
3505
  tab = tab || state.settingsTab || "invites";
3506
  if (!tabs.some(([k]) => k === tab)) tab = "invites";
3507
  state.settingsTab = tab;
3508
  $("settings").innerHTML = `<div class="tabs">${tabs.map(([k, l]) => `<button data-tab="${k}" class="${k === tab ? "on" : ""}">${l}</button>`).join("")}</div><div class="sbody" id="sbody"><div class="empty">Loading…</div></div>`;
3509
  $("settings").querySelectorAll("[data-tab]").forEach((b) => b.onclick = () => openSettings(b.dataset.tab));
3510
+ ({ invites: settingsInvites, people: settingsPeople, agents: settingsAgents, listening: settingsListening, suspended: settingsSuspended, board: settingsBoard })[tab]()
3511
  .catch((e) => { $("sbody").innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
3512
  }
3513
  // A2 look on trial: a per-browser switch, applied before first paint by the snippet in <head>
 
3809
  hydrate($("sbody"));
3810
  $("sbody").querySelectorAll("[data-lf]").forEach((b) => b.onclick = () => { state.listenFilter = b.dataset.lf; settingsListening(); });
3811
  }
3812
+ // ------------------------------------------------------------------ analytics (its own page; admins and readers)
3813
+ // GET /v1/analytics?from=&to=&top_k= : message stats, response times, who talks to whom, files, listening, health.
3814
+ const DAY_S = 86400;
3815
+ const canAnalytics = () => !!(state.me && (state.me.role === "admin" || state.me.role === "reader"));
3816
+ function analyticsRange() {
3817
+ const r = state.anRange || { days: 7 };
3818
+ if (r.custom) return r;
3819
+ const to = (Math.floor(Date.now() / 1000 / DAY_S) + 1) * DAY_S; // the end of today (UTC), exclusive
3820
+ return { days: r.days, from: to - r.days * DAY_S, to };
3821
+ }
3822
+ function leaveAnalytics() { $("analytics").classList.add("hidden"); $("analytics-open").classList.remove("on"); }
3823
+ async function openAnalytics() {
3824
+ if (!canAnalytics()) return;
3825
+ state.view = "analytics";
3826
+ if (state.poll) state.poll.abort();
3827
+ state.current = null; closePanel(); renderChannels();
3828
+ history.replaceState(null, "", location.pathname + "#analytics");
3829
+ $("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.add("hidden");
3830
+ $("analytics").classList.remove("hidden"); $("analytics-open").classList.add("on");
3831
+ $("ch-title").innerHTML = "Analytics"; $("ch-desc").textContent = ""; $("ch-meta").textContent = "";
3832
+ $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
3833
+ $("analytics").innerHTML = `<div class="empty">Loading…</div>`;
3834
+ await renderAnalytics();
3835
+ }
3836
+ const fmtDur = (s) => s == null ? "—" : s < 1 ? "under 1 s" : s < 60 ? `${Math.round(s)} s` : s < 3600 ? `${(s / 60).toFixed(s < 600 ? 1 : 0)} min` : s < 86400 ? `${(s / 3600).toFixed(s < 36000 ? 1 : 0)} h` : `${(s / 86400).toFixed(1)} days`;
3837
+ const fmtBytes = (n) => n == null ? "—" : n < 1024 ** 3 ? fmtSize(n) : (n / 1024 ** 3).toFixed(1) + " GB";
3838
+ const anDate = (t) => new Date(t * 1000).toISOString().slice(0, 10);
3839
+ async function renderAnalytics() {
3840
+ const r = analyticsRange(), k = state.anTopK || 8, days = Math.round((r.to - r.from) / DAY_S);
3841
+ let a;
3842
+ try { a = await api("GET", `/analytics?from=${r.from}&to=${r.to}&top_k=${k}`); }
3843
+ catch (e) { if (state.view === "analytics") $("analytics").innerHTML = `<div class="empty">${esc(e.message)}</div>`; return; }
3844
+ if (state.view !== "analytics") return;
3845
+ const ms = a.message_stats || {}, rt = a.response_times || {}, fi = a.files || {}, li = a.listening || {}, t = a.totals || {};
3846
+ const filter = state.analyticsWho || "all";
3847
+ const tile = (label, value, sub) => `<div class="tile"><div class="label">${label}</div><div class="num">${value}</div><div class="small">${sub}</div></div>`;
3848
+ const posters = (a.posters || []).filter((x) => filter === "all" || (filter === "agents") === (x.identity_type !== "user"));
3849
+ const maxP = Math.max(1, ...(a.posters || []).map((x) => x.messages)), maxC = Math.max(1, ...(a.channels || []).map((c) => c.messages));
3850
+ const fmax = Math.max(1, ...(fi.by_principal || []).map((x) => x.bytes));
3851
+ const span = r.custom ? `${anDate(r.from)} → ${anDate(r.to - DAY_S)}` : `last ${days} days`;
3852
+ $("analytics").innerHTML = `<div class="sbody an-page">
3853
+ <div class="an-head"><div class="seg" role="group" aria-label="Time range">${[7, 30, 90].map((d) =>
3854
+ `<button data-days="${d}" class="${!r.custom && d === r.days ? "on" : ""}">${d} days</button>`).join("")}</div>
3855
+ <form class="an-custom${r.custom ? " on" : ""}" id="an-custom"><span class="label">Custom</span>
3856
+ <input type="date" id="an-from" value="${anDate(r.from)}" max="${anDate(Date.now() / 1000)}" aria-label="From">
3857
+ <span>→</span><input type="date" id="an-to" value="${anDate(r.to - DAY_S)}" max="${anDate(Date.now() / 1000)}" aria-label="To">
3858
+ <button class="btn ghost small">Apply</button></form></div>
3859
+ <p class="lead">Everything on the board in the chosen days (UTC), private channels and DMs included; DMs are only counted together.</p>
3860
  <div class="tiles">
3861
+ ${tile("Messages", fmtN(ms.messages ?? t.messages_window ?? 0), span)}
3862
+ ${tile("Words", fmtN(ms.words ?? 0), days ? `about ${fmtN(Math.round((ms.words || 0) / days))} a day` : "")}
3863
+ ${tile("Avg length", ms.average_length == null ? "—" : `${Math.round(ms.average_length)}`, "characters per message")}
3864
+ ${tile("Median response", fmtDur(rt.median_seconds), rt.p90_seconds == null ? "no answered requests" : `90% within ${fmtDur(rt.p90_seconds)}`)}
3865
+ ${tile("Files", fmtN(fi.count ?? 0), `${fmtBytes(fi.bytes || 0)} attached`)}
3866
+ ${tile("Listening now", li.now ?? "—", `<a class="linkish" href="#settings" id="an-listen-link">who's listening</a>`)}
3867
+ </div>
3868
+ <div class="an-grid">
3869
+ <div class="card"><div class="chart-top"><h4>Messages per day</h4>
3870
+ <div class="legend"><span><i class="sw agents"></i>agents</span><span><i class="sw people"></i>people</span></div></div>
3871
+ <div id="an-chart" class="an-chart"></div></div>
3872
+ <div class="card"><div class="chart-top"><h4>Words per day</h4></div><div id="an-words" class="an-chart"></div>
3873
+ <div class="chart-top"><h4>Average length, characters</h4></div><div id="an-len" class="an-chart"></div></div>
3874
+ <div class="card"><div class="chart-top"><h4>Time to response</h4><span class="small">${fmtN(rt.requests || 0)} requests · ${fmtN(rt.unanswered || 0)} unanswered</span></div>
3875
+ <p class="small an-rule">${esc(rt.rule || "An agent is mentioned or DMed; its first later message in that thread or DM answers it.")}</p>
3876
+ <div id="an-hist" class="an-chart"></div>
3877
+ ${(rt.agents || []).length ? `<table class="an-table"><thead><tr><th>Agent</th><th>Asked</th><th class="hide-sm">Unanswered</th><th>Median</th><th>90% within</th></tr></thead><tbody>
3878
+ ${rt.agents.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span></span></td><td>${x.requests}</td><td class="hide-sm">${x.unanswered}</td><td>${fmtDur(x.median_seconds)}</td><td>${fmtDur(x.p90_seconds)}</td></tr>`).join("")}</tbody></table>` : ""}</div>
3879
+ <div class="card"><div class="chart-top"><h4>Who talks to whom</h4>
3880
+ <label class="small an-k">Top <input type="range" id="an-k" min="3" max="30" value="${k}"> <b id="an-k-n">${k}</b></label></div>
3881
+ <p class="small an-rule">People and agents; a ribbon is the messages between the two: DMs, replies and mentions, both directions.</p>
3882
+ <div id="an-chord" class="an-chart an-chord"></div>
3883
+ <div class="legend"><span><i class="sw agents"></i>agent</span><span><i class="sw people"></i>person</span><span><i class="sw other"></i>other</span></div></div>
3884
+ <div class="card"><div class="chart-top"><h4>Agents listening</h4><span class="small">${li.started_at ? `collected since ${esc(fmtDate(li.started_at))}` : "not collected yet"}</span></div>
3885
+ <div id="an-listen" class="an-chart"></div><p class="small">Gaps are times with no data, not zero.</p></div>
3886
+ <div class="card"><div class="chart-top"><h4>Files</h4><span class="small">${fmtN(fi.count || 0)} files · ${fmtBytes(fi.bytes || 0)}</span></div>
3887
+ ${(fi.by_principal || []).length ? `<table class="an-table an-files"><thead><tr><th>Who</th><th>Files</th><th class="bar-col">Size</th></tr></thead><tbody>
3888
+ ${fi.by_principal.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span></span></td><td>${x.count}</td>
3889
+ <td class="bar-col"><span class="hbar"><span style="width:${(100 * x.bytes / fmax).toFixed(1)}%"></span></span><b>${fmtBytes(x.bytes)}</b></td></tr>`).join("")}</tbody></table>`
3890
+ : `<div class="empty">No files in these days.</div>`}</div>
3891
  </div>
3892
+ ${a.health ? `<div class="card"><h4>Board health</h4><p class="small">Right now, whatever days are selected.</p><div class="an-health">${healthCells(a.health)}</div></div>` : ""}
 
 
 
 
3893
  <div class="card"><h4>Channels</h4>
3894
+ <table class="an-table wide"><thead><tr><th>Channel</th><th class="bar-col">Messages</th><th>Posters</th><th class="hide-sm">All time</th><th class="hide-sm">Last message</th></tr></thead><tbody>
3895
+ ${(a.channels || []).map((c) => `<tr class="${c.archived ? "muted" : ""}"><td>${c.id ? `<span class="chan-link" data-channel="${c.id}" role="link" tabindex="0">${c.kind === "private" ? LOCK : "#"}${esc(c.name)}</span>` : `<i>${esc(c.name)}</i>`}${c.archived ? ' <span class="label">archived</span>' : ""}</td>
3896
  <td class="bar-col"><span class="hbar"><span style="width:${(100 * c.messages / maxC).toFixed(1)}%"></span></span><b>${c.messages}</b></td>
3897
  <td>${c.posters}</td><td class="hide-sm">${c.total}</td><td class="small hide-sm">${c.last_at ? esc(ago(c.last_at)) : "never"}</td></tr>`).join("")}</tbody></table></div>
3898
+ <div class="card"><div class="chart-top"><h4>Who posts</h4><div class="seg small-seg" role="group" aria-label="Show">${[["all", "Everyone"], ["agents", "Agents"], ["people", "People"]].map(([key, l]) =>
3899
+ `<button data-who="${key}" class="${key === filter ? "on" : ""}">${l}</button>`).join("")}</div></div>
3900
  ${posters.length ? `<table class="an-table wide"><thead><tr><th>Name</th><th class="hide-sm">Owner</th><th class="bar-col">Messages</th><th class="hide-sm">Daily</th><th class="hide-sm">Channels</th><th>Last post</th></tr></thead><tbody>
3901
  ${posters.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span><span class="label">${esc(kindLabel(x))}</span></span></td>
3902
  <td class="small hide-sm">${x.owner ? esc(nameOf(x.owner)) : ""}</td>
3903
  <td class="bar-col"><span class="hbar"><span style="width:${(100 * x.messages / maxP).toFixed(1)}%"></span></span><b>${x.messages}</b></td>
3904
  <td class="hide-sm">${spark(x.daily, a.daily)}</td><td class="hide-sm">${x.channels}</td><td class="small">${esc(ago(x.last_at))}</td></tr>`).join("")}</tbody></table>`
3905
+ : `<div class="empty">Nobody in this group posted in these days.</div>`}</div>
3906
+ </div>`;
3907
+ hydrate($("analytics"));
3908
+ const daily = ms.daily && ms.daily.length ? ms.daily : (a.daily || []);
3909
+ drawDaily($("an-chart"), daily);
3910
+ drawSeries($("an-words"), daily.map((d) => ({ t: d.day, v: d.words ?? null })), { label: "words", fmt: fmtN });
3911
+ drawSeries($("an-len"), daily.map((d) => ({ t: d.day, v: d.average_length ?? null })), { label: "characters", fmt: (v) => String(Math.round(v)) });
3912
+ drawHist($("an-hist"), rt);
3913
+ drawChord($("an-chord"), a.interactions);
3914
+ drawSeries($("an-listen"), ((li.series) || []).map((p) => ({ t: p.at, v: p.count, note: p.samples != null && p.expected_samples ? `${p.samples} of ${p.expected_samples} samples` : "" })),
3915
+ { label: "agents", fmt: (v) => (Math.round(v * 10) / 10).toString(), time: (li.bucket_seconds || DAY_S) < DAY_S });
3916
+ const root = $("analytics");
3917
+ root.querySelectorAll("[data-days]").forEach((b) => b.onclick = () => { state.anRange = { days: Number(b.dataset.days) }; renderAnalytics(); });
3918
+ root.querySelectorAll("[data-who]").forEach((b) => b.onclick = () => { state.analyticsWho = b.dataset.who; renderAnalytics(); });
3919
+ $("an-custom").onsubmit = (e) => {
3920
+ e.preventDefault();
3921
+ const f = Date.parse($("an-from").value + "T00:00:00Z") / 1000, to = Date.parse($("an-to").value + "T00:00:00Z") / 1000 + DAY_S;
3922
+ if (!(f < to)) return;
3923
+ state.anRange = { custom: true, from: f, to, days: Math.round((to - f) / DAY_S) }; renderAnalytics();
3924
+ };
3925
+ $("an-listen-link").onclick = (e) => { e.preventDefault(); openSettings("listening"); };
3926
+ // the slider refetches only the chord, and waits until dragging pauses
3927
+ let timer = 0;
3928
+ $("an-k").oninput = (e) => {
3929
+ $("an-k-n").textContent = e.target.value; clearTimeout(timer);
3930
+ timer = setTimeout(async () => {
3931
+ state.anTopK = Number(e.target.value);
3932
+ try { const b = await api("GET", `/analytics?from=${r.from}&to=${r.to}&top_k=${state.anTopK}`); if (state.view === "analytics" && $("an-chord")) drawChord($("an-chord"), b.interactions); } catch {}
3933
+ }, 300);
3934
+ };
3935
+ }
3936
+ // health: whatever the server reports, formatted by the unit in its name
3937
+ const HEALTH_KEYS = ["db_bytes", "wal_bytes", "disk_free_bytes", "free_disk_bytes", "uptime_seconds", "open_polls", "pending_writes", "write_queue", "queued_reads", "errors_5xx", "replication"];
3938
+ function healthCells(hl) {
3939
+ hl = Object.fromEntries(HEALTH_KEYS.filter((k) => k in hl).map((k) => [k, hl[k]]));
3940
+ const label = (k) => k.replace(/_(bytes|seconds|at)$/, "").replace(/_/g, " ").replace(/\bwal\b/, "WAL").replace(/\bdb\b/, "database").replace(/^./, (c) => c.toUpperCase());
3941
+ const val = (k, v) => v == null ? "—" : typeof v === "boolean" ? (v ? "yes" : "no")
3942
+ : /bytes$/.test(k) ? fmtBytes(v) : /_at$/.test(k) ? ago(v) : /seconds$/.test(k) ? fmtDur(v) : typeof v === "number" ? fmtN(v) : String(v);
3943
+ const cells = [];
3944
+ for (const [k, v] of Object.entries(hl)) {
3945
+ if (v && typeof v === "object") for (const [k2, v2] of Object.entries(v)) { if (typeof v2 !== "object") cells.push([`${k}_${k2}`, val(k2, v2)]); }
3946
+ else cells.push([k, val(k, v)]);
3947
+ }
3948
+ return cells.map(([k, v]) => `<div><b>${esc(v)}</b><span>${esc(label(k))}</span></div>`).join("");
3949
+ }
3950
+ // a hover box beside the mark, kept inside the chart
3951
+ function anTip(host, mark, html) {
3952
+ let tip = host.querySelector(".an-tip"); if (!tip) { tip = document.createElement("div"); tip.className = "an-tip"; host.append(tip); }
3953
+ tip.innerHTML = html; tip.classList.remove("hidden");
3954
+ const box = host.getBoundingClientRect(), rb = mark.getBoundingClientRect();
3955
+ const right = rb.right - box.left + 6, left = rb.left - box.left - tip.offsetWidth - 6;
3956
+ tip.style.left = (right + tip.offsetWidth <= box.width ? right : Math.max(0, left)) + "px";
3957
+ tip.style.top = Math.max(0, Math.min(rb.top - box.top, box.height - tip.offsetHeight)) + "px";
3958
+ }
3959
+ const niceTop = (max) => {
3960
+ max = Math.max(1, max);
3961
+ const p = Math.pow(10, Math.floor(Math.log10(max / 4))), step = Math.max(1, p * ([1, 2, 5, 10].find((m) => p * m >= max / 4) || 10));
3962
+ return [Math.ceil(max / step) * step, step];
3963
+ };
3964
+ // one line over time; null values break the line (no data, not zero)
3965
+ function drawSeries(host, pts, { label, fmt, time } = {}) {
3966
+ if (!pts.length || pts.every((p) => p.v == null)) { host.innerHTML = `<div class="empty small">No data in these days.</div>`; return; }
3967
+ const W = Math.max(280, host.clientWidth || 500), H = 120, L = 40, R = 10, T = 10, B = 22;
3968
+ const [top, step] = niceTop(Math.max(...pts.map((p) => p.v || 0)));
3969
+ const x = (i) => L + (pts.length === 1 ? (W - L - R) / 2 : i * (W - L - R) / (pts.length - 1)), y = (v) => T + (H - T - B) * (1 - v / top);
3970
+ let d = "", pen = false;
3971
+ pts.forEach((p, i) => { if (p.v == null) { pen = false; return; } d += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(p.v).toFixed(1)}`; pen = true; });
3972
+ const every = Math.ceil(pts.length / Math.max(2, Math.floor((W - L - R) / 70)));
3973
+ let svg = `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img" aria-label="${esc(label || "")} over time">`;
3974
+ for (let i = 0; i * step <= top; i++) { const v = i * step; svg += `<line class="grid" x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}"/><text class="tick" x="${L - 6}" y="${y(v) + 3.5}" text-anchor="end">${fmtN(v)}</text>`; }
3975
+ if (time) {
3976
+ const starts = pts.map((p, i) => [p, i]).filter(([p], i) => i === 0 || Math.floor(p.t / DAY_S) !== Math.floor(pts[i - 1].t / DAY_S)), gap = Math.ceil(starts.length / Math.max(2, Math.floor((W - L - R) / 70)));
3977
+ starts.forEach(([p, i], j) => { if (j % gap === 0) svg += `<text class="tick" x="${x(i)}" y="${H - 6}" text-anchor="middle">${esc(dayLabel(p.t))}</text>`; });
3978
+ } else pts.forEach((p, i) => { if (i % every === 0 || i === pts.length - 1) svg += `<text class="tick" x="${x(i)}" y="${H - 6}" text-anchor="middle">${esc(dayLabel(p.t))}</text>`; });
3979
+ svg += `<path class="line" d="${d}"/>`;
3980
+ const last = [...pts].reverse().find((p) => p.v != null), li = pts.lastIndexOf(last);
3981
+ svg += `<circle class="dotm" cx="${x(li)}" cy="${y(last.v)}" r="3.5"/>`;
3982
+ const slot = (W - L - R) / Math.max(1, pts.length - 1);
3983
+ pts.forEach((p, i) => { svg += `<rect class="hit" data-i="${i}" x="${x(i) - slot / 2}" y="${T}" width="${slot}" height="${H - T - B}"/>`; });
3984
+ host.innerHTML = svg + `</svg><div class="an-tip hidden"></div>`;
3985
+ host.querySelectorAll(".hit").forEach((r) => r.addEventListener("mouseenter", () => {
3986
+ const p = pts[Number(r.dataset.i)];
3987
+ host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); r.classList.add("on");
3988
+ anTip(host, r, `<b>${esc(time ? new Date(p.t * 1000).toLocaleString() : dayLabel(p.t, true))}</b><span>${p.v == null ? "no data" : `${esc(fmt ? fmt(p.v) : String(p.v))} ${esc(label || "")}`}</span>${p.note ? `<span>${esc(p.note)}</span>` : ""}`);
3989
+ }));
3990
+ host.querySelector("svg").addEventListener("mouseleave", () => { host.querySelector(".an-tip").classList.add("hidden"); host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); });
3991
+ }
3992
+ // response-time histogram with median and 90% markers
3993
+ function drawHist(host, rt) {
3994
+ const rows = (rt && rt.histogram) || [];
3995
+ if (!rows.length || !rows.some((b) => b.count)) { host.innerHTML = `<div class="empty small">No requests to agents in these days.</div>`; return; }
3996
+ const W = Math.max(280, host.clientWidth || 500), H = 170, L = 40, R = 8, T = 18, B = 26;
3997
+ const [top, step] = niceTop(Math.max(...rows.map((b) => b.count)));
3998
+ const slot = (W - L - R) / rows.length, bw = Math.max(4, slot - 4), y = (v) => T + (H - T - B) * (1 - v / top);
3999
+ const unit = (s) => s < 60 ? "s" : s < 3600 ? "m" : "h", num = (s) => s < 60 ? s : s < 3600 ? s / 60 : s / 3600;
4000
+ const short = (s) => `${num(s)}${unit(s)}`;
4001
+ // compact axis labels ("2–5m"); the hover says it in full
4002
+ const name = (b, i) => i === 0 ? `<${short(b.to_seconds)}` : b.to_seconds == null ? `>${short(b.from_seconds)}`
4003
+ : unit(b.from_seconds) === unit(b.to_seconds) ? `${num(b.from_seconds)}–${short(b.to_seconds)}` : `${short(b.from_seconds)}–${short(b.to_seconds)}`;
4004
+ const full = (b, i) => i === 0 ? `under ${fmtDur(b.to_seconds)}` : b.to_seconds == null ? `over ${fmtDur(b.from_seconds)}` : `${fmtDur(b.from_seconds)} to ${fmtDur(b.to_seconds)}`;
4005
+ let svg = `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img" aria-label="Time to response, distribution">`;
4006
+ for (let i = 0; i * step <= top; i++) { const v = i * step; svg += `<line class="grid" x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}"/><text class="tick" x="${L - 6}" y="${y(v) + 3.5}" text-anchor="end">${fmtN(v)}</text>`; }
4007
+ rows.forEach((b, i) => {
4008
+ const x = L + i * slot + (slot - bw) / 2;
4009
+ if (b.count) svg += `<path class="bar agents" d="${barPath(x, y(b.count), bw, y(0) - y(b.count), true)}"/>`;
4010
+ svg += `<text class="tick" x="${x + bw / 2}" y="${H - 8}" text-anchor="middle">${esc(name(b, i))}</text>`;
4011
+ svg += `<rect class="hit" data-i="${i}" x="${L + i * slot}" y="${T}" width="${slot}" height="${H - T - B}"/>`;
4012
+ });
4013
+ // markers sit inside their bucket, placed linearly between its edges
4014
+ const at = (s) => { const i = rows.findIndex((b) => s >= b.from_seconds && (b.to_seconds == null || s < b.to_seconds)); if (i < 0) return null;
4015
+ const b = rows[i], f = b.to_seconds == null ? 0.5 : (s - b.from_seconds) / (b.to_seconds - b.from_seconds); return L + (i + f) * slot; };
4016
+ const m1 = rt.median_seconds == null ? null : at(rt.median_seconds), m2 = rt.p90_seconds == null ? null : at(rt.p90_seconds);
4017
+ if (m1 != null) svg += `<line class="mark" x1="${m1}" x2="${m1}" y1="${T - 6}" y2="${y(0)}"/>`;
4018
+ if (m2 != null && (m1 == null || Math.abs(m2 - m1) > 1)) svg += `<line class="mark" x1="${m2}" x2="${m2}" y1="${T - 6}" y2="${y(0)}"/>`;
4019
+ if (m1 != null && m2 != null && m2 - m1 < 110) svg += `<text class="tick strong" x="${m1 + 4}" y="${T - 2}">median ${esc(fmtDur(rt.median_seconds))} · 90% ${esc(fmtDur(rt.p90_seconds))}</text>`;
4020
+ else {
4021
+ if (m1 != null) svg += `<text class="tick strong" x="${m1 + 4}" y="${T - 2}">median ${esc(fmtDur(rt.median_seconds))}</text>`;
4022
+ if (m2 != null) svg += `<text class="tick strong" x="${m2 + 4}" y="${T - 2}">90% ${esc(fmtDur(rt.p90_seconds))}</text>`;
4023
+ }
4024
+ host.innerHTML = svg + `<line class="axis" x1="${L}" x2="${W - R}" y1="${y(0)}" y2="${y(0)}"/></svg><div class="an-tip hidden"></div>`;
4025
+ host.querySelectorAll(".hit").forEach((r) => r.addEventListener("mouseenter", () => {
4026
+ const i = Number(r.dataset.i), b = rows[i];
4027
+ host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); r.classList.add("on");
4028
+ anTip(host, r, `<b>${esc(full(b, i))}</b><span>${b.count} answered</span>`);
4029
+ }));
4030
+ host.querySelector("svg").addEventListener("mouseleave", () => { host.querySelector(".an-tip").classList.add("hidden"); host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); });
4031
+ }
4032
+ // who talks to whom: a chord diagram drawn by hand (arcs per person or agent, ribbons per pair)
4033
+ function drawChord(host, inter) {
4034
+ const nodes = (inter && inter.nodes) || [], links = ((inter && inter.links) || []).filter((l) => l.source !== l.target && l.total > 0);
4035
+ if (!links.length) { host.innerHTML = `<div class="empty small">No messages between people and agents in these days.</div>`; return; }
4036
+ const W = Math.max(300, Math.min(host.clientWidth || 480, 560)), H = W, R = W / 2 - 92, r0 = R - 12, PAD = 0.03;
4037
+ const byId = new Map(nodes.map((n) => [String(n.id), n]));
4038
+ const vol = new Map(nodes.map((n) => [String(n.id), 0]));
4039
+ links.forEach((l) => { vol.set(String(l.source), (vol.get(String(l.source)) || 0) + l.total); vol.set(String(l.target), (vol.get(String(l.target)) || 0) + l.total); });
4040
+ // people first, then agents, then other; each group by volume
4041
+ const kind = (n) => n.id === "other" ? 2 : n.identity_type === "user" ? 0 : 1;
4042
+ const order = nodes.filter((n) => vol.get(String(n.id))).sort((p, q) => kind(p) - kind(q) || vol.get(String(q.id)) - vol.get(String(p.id)));
4043
+ const total = order.reduce((s, n) => s + vol.get(String(n.id)), 0), k = (2 * Math.PI - PAD * order.length) / total;
4044
+ const arcs = new Map(); let a0 = 0;
4045
+ order.forEach((n) => { const v = vol.get(String(n.id)); arcs.set(String(n.id), { n, a0, a1: a0 + v * k, cur: a0 }); a0 += v * k + PAD; });
4046
+ const P = (a, r) => [(r * Math.sin(a)).toFixed(2), (-r * Math.cos(a)).toFixed(2)];
4047
+ const arcPath = (s, e, ri, ro) => `M${P(s, ro)}A${ro},${ro} 0 ${e - s > Math.PI ? 1 : 0} 1 ${P(e, ro)}L${P(e, ri)}A${ri},${ri} 0 ${e - s > Math.PI ? 1 : 0} 0 ${P(s, ri)}Z`;
4048
+ // each arc is cut into one slice per link, biggest first
4049
+ const slices = links.slice().sort((p, q) => q.total - p.total).map((l) => {
4050
+ const s = arcs.get(String(l.source)), t = arcs.get(String(l.target)); if (!s || !t) return null;
4051
+ const ss = [s.cur, s.cur += l.total * k], ts = [t.cur, t.cur += l.total * k]; return { l, ss, ts };
4052
+ }).filter(Boolean);
4053
+ const big = (p) => p[1] - p[0] > Math.PI ? 1 : 0;
4054
+ const ribbon = ({ ss, ts }) => `M${P(ss[0], r0)}A${r0},${r0} 0 ${big(ss)} 1 ${P(ss[1], r0)}Q0,0 ${P(ts[0], r0)}A${r0},${r0} 0 ${big(ts)} 1 ${P(ts[1], r0)}Q0,0 ${P(ss[0], r0)}Z`;
4055
+ const cls = (n) => n.id === "other" ? "other" : n.identity_type === "user" ? "people" : "agents";
4056
+ const nm = (n) => n.id === "other" ? `other (${n.members})` : nameOf(n);
4057
+ let svg = `<svg viewBox="${-W / 2} ${-H / 2} ${W} ${H}" width="100%" height="${H}" role="img" aria-label="Who talks to whom">`;
4058
+ svg += slices.map((s, i) => `<path class="ribbon" data-i="${i}" data-a="${s.l.source}" data-b="${s.l.target}" d="${ribbon(s)}"/>`).join("");
4059
+ for (const { n, a0: s, a1: e } of arcs.values()) {
4060
+ const mid = (s + e) / 2, deg = mid * 180 / Math.PI - 90, flip = mid > Math.PI;
4061
+ svg += `<path class="arc ${cls(n)}" data-n="${n.id}" d="${arcPath(s, e, r0, R)}"/>`;
4062
+ svg += `<g transform="rotate(${deg.toFixed(1)}) translate(${R + 8},0)${flip ? " rotate(180)" : ""}"><text class="node" text-anchor="${flip ? "end" : "start"}" dy="-0.1em">${esc(nm(n))}</text>` +
4063
+ `<text class="tick" text-anchor="${flip ? "end" : "start"}" dy="1.1em">${fmtN(vol.get(String(n.id)))}</text></g>`;
4064
+ }
4065
+ host.innerHTML = svg + `</svg><div class="an-tip hidden"></div>`;
4066
+ const svgEl = host.querySelector("svg");
4067
+ const focus = (fn) => host.querySelectorAll(".ribbon").forEach((p) => p.classList.toggle("dim", !fn(p)));
4068
+ host.querySelectorAll(".ribbon").forEach((p) => p.addEventListener("mouseenter", () => {
4069
+ const l = slices[Number(p.dataset.i)].l, a = byId.get(String(l.source)), b = byId.get(String(l.target));
4070
+ focus((q) => q === p);
4071
+ anTip(host, p, `<b>${esc(nm(a))} ↔ ${esc(nm(b))}</b><span>${l.total} messages</span><span>${l.dms} DMs · ${l.replies} replies · ${l.mentions} mentions</span>`);
4072
+ }));
4073
+ host.querySelectorAll(".arc").forEach((p) => p.addEventListener("mouseenter", () => {
4074
+ const id = p.dataset.n, n = byId.get(id);
4075
+ focus((q) => q.dataset.a === id || q.dataset.b === id);
4076
+ const inner = n.id === "other" && inter.other_internal && inter.other_internal.total ? `<span>${inter.other_internal.total} among themselves</span>` : "";
4077
+ anTip(host, p, `<b>${esc(nm(n))}</b><span>${fmtN(vol.get(id))} messages with ${n.identity_type === "user" ? "agents" : "people"}</span>${inner}`);
4078
+ }));
4079
+ svgEl.addEventListener("mouseleave", () => { focus(() => true); host.querySelector(".an-tip").classList.add("hidden"); });
4080
  }
4081
  function spark(values, daily) {
4082
  const w = 84, h = 18, max = Math.max(1, ...values), bw = w / values.length;
 
4126
  const r = roundTop ? Math.min(4, w / 2, h) : 0; // rounded only on the data end
4127
  return `M${x},${y0 + h}V${y0 + r}${r ? `Q${x},${y0} ${x + r},${y0}` : ""}H${x + w - r}${r ? `Q${x + w},${y0} ${x + w},${y0 + r}` : ""}V${y0 + h}Z`;
4128
  }
4129
+ let anResize = 0;
4130
+ window.addEventListener("resize", () => { clearTimeout(anResize); anResize = setTimeout(() => { if (state.view === "analytics") renderAnalytics(); }, 250); });
4131
 
4132
  // ------------------------------------------------------------------ misc
4133
  $("me-who").onclick = () => state.me && openProfile(state.me.id);
4134
+ $("analytics-open").onclick = () => openAnalytics();
4135
  $("menu").onclick = () => $("app").classList.toggle("nav");
4136
  $("app").addEventListener("click", (e) => {
4137
  if (!$("app").classList.contains("nav") || e.target.closest("aside, #menu")) return;
 
4194
  window.addEventListener("hashchange", () => {
4195
  if (location.hash === "#settings") { if (state.view !== "settings") openSettings(); return; }
4196
  if (location.hash === "#activity") { if (state.view !== "activity") openActivity(); return; }
4197
+ if (location.hash === "#analytics") { if (state.view !== "analytics") openAnalytics(); return; }
4198
  const c = state.channels.find((x) => x.name === decodeURIComponent(location.hash.slice(1)));
4199
  if (c && (!state.current || c.id !== state.current.id)) openChannel(c.id);
4200
  });
src/msgboard/db.py CHANGED
@@ -445,10 +445,23 @@ CREATE TABLE principal_presence (
445
  );
446
  """
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  # Index i holds the SQL that moves the schema from version i to i + 1.
449
  MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
450
  SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
451
- SCHEMA_V23, SCHEMA_V24]
452
  SCHEMA_VERSION = len(MIGRATIONS)
453
 
454
 
 
445
  );
446
  """
447
 
448
+ # v25: sampled listening counts and bounded analytics lookups.
449
+ SCHEMA_V25 = """
450
+ CREATE TABLE presence_history (
451
+ minute INTEGER PRIMARY KEY,
452
+ sampled_at REAL NOT NULL,
453
+ agents INTEGER NOT NULL CHECK (agents >= 0)
454
+ );
455
+ CREATE INDEX messages_created_at ON messages(created_at);
456
+ CREATE INDEX messages_response_thread ON messages(sender_id, thread_id, created_at, id);
457
+ CREATE INDEX messages_response_dm ON messages(channel_id, sender_id, created_at, id);
458
+ CREATE INDEX attachments_published_at ON attachments(published, created_at);
459
+ """
460
+
461
  # Index i holds the SQL that moves the schema from version i to i + 1.
462
  MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
463
  SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
464
+ SCHEMA_V23, SCHEMA_V24, SCHEMA_V25]
465
  SCHEMA_VERSION = len(MIGRATIONS)
466
 
467
 
src/msgboard/presence.py CHANGED
@@ -20,6 +20,8 @@ class Presence:
20
  self._version = 0
21
  self._poll_sequence = 0
22
  self._latest_poll: dict[int, int] = {}
 
 
23
  self._lock = threading.Lock()
24
 
25
  def _put(self, pid: int, record: dict) -> None:
@@ -95,3 +97,28 @@ class Presence:
95
  for pid, version in versions.items():
96
  if self._dirty.get(pid) == version:
97
  del self._dirty[pid]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  self._version = 0
21
  self._poll_sequence = 0
22
  self._latest_poll: dict[int, int] = {}
23
+ self._history: dict[int, tuple[float, int]] = {}
24
+ self._last_sample_minute: int | None = None
25
  self._lock = threading.Lock()
26
 
27
  def _put(self, pid: int, record: dict) -> None:
 
97
  for pid, version in versions.items():
98
  if self._dirty.get(pid) == version:
99
  del self._dirty[pid]
100
+
101
+ def listening_now(self, now: float) -> int:
102
+ with self._lock:
103
+ return self._listening_now(now)
104
+
105
+ def _listening_now(self, now: float) -> int:
106
+ return sum(record["last_poll_at"] is not None and 0 <= now - record["last_poll_at"] <= ACTIVE_SECONDS
107
+ for record in self._records.values())
108
+
109
+ def sample(self, now: float) -> None:
110
+ minute = int(now // 60) * 60
111
+ with self._lock:
112
+ if self._last_sample_minute is None or minute > self._last_sample_minute:
113
+ self._last_sample_minute = minute
114
+ self._history[minute] = (now, self._listening_now(now))
115
+
116
+ def pending_history(self) -> dict[int, tuple[float, int]]:
117
+ with self._lock:
118
+ return dict(self._history)
119
+
120
+ def history_saved(self, history: dict[int, tuple[float, int]]) -> None:
121
+ with self._lock:
122
+ for minute, sample in history.items():
123
+ if self._history.get(minute) == sample:
124
+ del self._history[minute]
src/msgboard/server.py CHANGED
@@ -262,15 +262,18 @@ class Board:
262
 
263
  async def flush_presence(self) -> None:
264
  versions, records = self.presence.pending()
265
- if records:
266
- await self.db.write(store.save_presence, records)
 
267
  self.presence.saved(versions)
 
268
 
269
  async def presence_loop(self) -> None:
270
  while True:
271
  await asyncio.sleep(PRESENCE_FLUSH_SECONDS)
272
  if self.shutting_down:
273
  return
 
274
  try:
275
  await self.flush_presence()
276
  except Exception:
@@ -286,11 +289,13 @@ class Board:
286
  # writes, before the replicator takes its final snapshot. This also works
287
  # when begin_shutdown has closed the normal HTTP writer entry point.
288
  versions, records = self.presence.pending()
289
- if records:
 
290
  try:
291
- self.db._writer.submit(self.db._run_write, store.save_presence, (records,)).result()
292
  self.db.generation += 1
293
  self.presence.saved(versions)
 
294
  except Exception:
295
  # Ephemeral presence must never prevent the final snapshot of
296
  # acknowledged messages or leave the database/lease open.
@@ -333,6 +338,7 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
333
  async def lifespan(app):
334
  board.notifier.loop = asyncio.get_running_loop()
335
  board.commit_lock = asyncio.Lock()
 
336
  task = asyncio.create_task(board.presence_loop())
337
  try:
338
  yield
@@ -391,6 +397,9 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
391
  p = await authn(request)
392
  if not p.is_admin:
393
  raise forbidden("admin only")
 
 
 
394
  db_path = board.data_dir / DB_NAME
395
  wal = Path(str(db_path) + "-wal")
396
  return {
@@ -686,9 +695,16 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
686
  now=time.time()))
687
 
688
  @app.get("/v1/analytics")
689
- async def get_analytics(request: Request, days: int = Query(30, ge=1, le=365)):
 
 
 
690
  p = await authn(request)
691
- return await db.read(store.analytics, p, time.time(), days)
 
 
 
 
692
 
693
  def _origin(request: Request) -> str:
694
  proto = request.headers.get("x-forwarded-proto") or request.url.scheme
 
262
 
263
  async def flush_presence(self) -> None:
264
  versions, records = self.presence.pending()
265
+ history = self.presence.pending_history()
266
+ if records or history:
267
+ await self.db.write(store.save_presence, records, history)
268
  self.presence.saved(versions)
269
+ self.presence.history_saved(history)
270
 
271
  async def presence_loop(self) -> None:
272
  while True:
273
  await asyncio.sleep(PRESENCE_FLUSH_SECONDS)
274
  if self.shutting_down:
275
  return
276
+ self.presence.sample(time.time())
277
  try:
278
  await self.flush_presence()
279
  except Exception:
 
289
  # writes, before the replicator takes its final snapshot. This also works
290
  # when begin_shutdown has closed the normal HTTP writer entry point.
291
  versions, records = self.presence.pending()
292
+ history = self.presence.pending_history()
293
+ if records or history:
294
  try:
295
+ self.db._writer.submit(self.db._run_write, store.save_presence, (records, history)).result()
296
  self.db.generation += 1
297
  self.presence.saved(versions)
298
+ self.presence.history_saved(history)
299
  except Exception:
300
  # Ephemeral presence must never prevent the final snapshot of
301
  # acknowledged messages or leave the database/lease open.
 
338
  async def lifespan(app):
339
  board.notifier.loop = asyncio.get_running_loop()
340
  board.commit_lock = asyncio.Lock()
341
+ board.presence.sample(time.time())
342
  task = asyncio.create_task(board.presence_loop())
343
  try:
344
  yield
 
397
  p = await authn(request)
398
  if not p.is_admin:
399
  raise forbidden("admin only")
400
+ return health_summary()
401
+
402
+ def health_summary():
403
  db_path = board.data_dir / DB_NAME
404
  wal = Path(str(db_path) + "-wal")
405
  return {
 
695
  now=time.time()))
696
 
697
  @app.get("/v1/analytics")
698
+ async def get_analytics(request: Request, days: int = Query(7, ge=1, le=365),
699
+ start: float | None = Query(None, alias="from"),
700
+ end: float | None = Query(None, alias="to"),
701
+ top_k: int = Query(10, ge=1, le=100)):
702
  p = await authn(request)
703
+ result = await db.read(store.analytics, p, time.time(), days, start, end, top_k,
704
+ board.presence.pending_history())
705
+ result["listening"]["now"] = board.presence.listening_now(time.time())
706
+ result["health"] = health_summary()
707
+ return result
708
 
709
  def _origin(request: Request) -> str:
710
  proto = request.headers.get("x-forwarded-proto") or request.url.scheme
src/msgboard/store.py CHANGED
@@ -25,6 +25,7 @@ import sqlite3
25
  from typing import Any
26
 
27
  from . import auth
 
28
  from .auth import Principal
29
  from .config import LIMITS
30
  from .presence import FIELDS as PRESENCE_FIELDS, unknown as unknown_presence
@@ -409,7 +410,7 @@ def load_presence(conn) -> dict[int, dict]:
409
  return records
410
 
411
 
412
- def save_presence(conn, records: dict[int, dict]) -> None:
413
  conn.executemany(
414
  """INSERT INTO principal_presence
415
  (principal_id, last_seen_at, last_poll_at, listens, cursor, cursor_since, waiting, waiting_since)
@@ -421,6 +422,11 @@ def save_presence(conn, records: dict[int, dict]) -> None:
421
  json.dumps(record["listens"]) if record["listens"] is not None else None,
422
  record["cursor"], record["cursor_since"], record["waiting"], record["waiting_since"])
423
  for pid, record in records.items()])
 
 
 
 
 
424
 
425
 
426
  def attach_presence(conn, viewer: Principal, items: list[dict], records: dict[int, dict]) -> list[dict]:
@@ -2001,28 +2007,26 @@ def _reactions_to_me(conn, p: Principal, scope: str, limit: int) -> list[dict]:
2001
  # ------------------------------------------------------------------ analytics
2002
 
2003
 
2004
- def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
 
2005
  """Board activity for admins (and readers): totals, a daily series, channels and who posts."""
2006
  if not p.sees_everything:
2007
- raise forbidden("only admins can see analytics")
2008
  days = max(1, min(int(days), 365))
2009
- day0 = (int(now // 86400) - days + 1) * 86400 # start of the first UTC day in the window
2010
- kind = "CASE WHEN p.identity_type = 'user' THEN 'people' ELSE 'agents' END"
2011
- daily = {(day0 + i * 86400): {"people": 0, "agents": 0} for i in range(days)}
2012
- for d, k, n in conn.execute(f"""SELECT CAST(m.created_at / 86400 AS INTEGER) * 86400, {kind}, COUNT(*)
2013
- FROM messages m JOIN principals p ON p.id = m.sender_id
2014
- WHERE m.created_at >= ? GROUP BY 1, 2""", (day0,)):
2015
- if d in daily:
2016
- daily[d][k] = n
2017
- series = [{"day": d, **v} for d, v in sorted(daily.items())]
2018
 
2019
  chans: dict = {}
2020
  for r in conn.execute("""SELECT c.id, c.name, c.kind, c.archived,
2021
  (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id) AS total,
2022
- (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0) AS recent,
2023
- (SELECT COUNT(DISTINCT m.sender_id) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0) AS posters,
2024
  (SELECT MAX(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_at
2025
- FROM channels c""", {"d0": day0}):
2026
  key = "dm" if r["kind"] == "dm" else r["id"] # DMs are counted together, never by pair
2027
  c = chans.setdefault(key, {"id": None if key == "dm" else r["id"], "name": "direct messages" if key == "dm" else r["name"],
2028
  "kind": r["kind"], "archived": bool(r["archived"]) if key != "dm" else False,
@@ -2033,15 +2037,15 @@ def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
2033
 
2034
  per_day = {}
2035
  for pid, d, n in conn.execute("""SELECT sender_id, CAST(created_at / 86400 AS INTEGER) * 86400, COUNT(*) FROM messages
2036
- WHERE created_at >= ? GROUP BY 1, 2""", (day0,)):
2037
  per_day.setdefault(pid, {})[d] = n
2038
  posters = []
2039
  for r in conn.execute("""SELECT p.id, p.display_name, p.external_id, p.identity_type, p.role, p.owner_id, p.pattern_seed,
2040
  substr(av.sha256, 1, 12) AS avatar, COUNT(m.id) AS n, MAX(m.created_at) AS last_at,
2041
  COUNT(DISTINCT m.channel_id) AS nchannels
2042
- FROM principals p JOIN messages m ON m.sender_id = p.id AND m.created_at >= ?
2043
  LEFT JOIN avatars av ON av.principal_id = p.id
2044
- GROUP BY p.id ORDER BY n DESC""", (day0,)):
2045
  posters.append({"id": r["id"], "display_name": r["display_name"], "external_id": r["external_id"],
2046
  "identity_type": r["identity_type"], "role": r["role"], "avatar": r["avatar"], "pattern": r["pattern_seed"],
2047
  "owner": _brief(conn, r["owner_id"]), "messages": r["n"], "last_at": r["last_at"], "channels": r["nchannels"],
@@ -2049,6 +2053,7 @@ def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
2049
 
2050
  count = lambda sql, *a: conn.execute(sql, a).fetchone()[0]
2051
  week = now - 7 * 86400
 
2052
  totals = {
2053
  "messages": count("SELECT COUNT(*) FROM messages"),
2054
  "messages_window": sum(d["people"] + d["agents"] for d in series),
@@ -2056,14 +2061,136 @@ def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
2056
  "agents": count("SELECT COUNT(*) FROM principals WHERE identity_type != 'user' AND disabled = 0"),
2057
  "channels": count("SELECT COUNT(*) FROM channels WHERE kind != 'dm' AND archived = 0"),
2058
  "active_people_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
2059
- WHERE m.created_at >= ? AND p.identity_type = 'user'""", week),
2060
  "active_agents_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
2061
- WHERE m.created_at >= ? AND p.identity_type != 'user'""", week),
2062
- "threads_window": count("SELECT COUNT(DISTINCT thread_id) FROM messages WHERE thread_id IS NOT NULL AND created_at >= ?", day0),
2063
- "attachments_window": count("SELECT COUNT(*) FROM attachments WHERE published = 1 AND created_at >= ?", day0),
2064
  }
2065
- return {"days": days, "since": day0, "generated_at": now, "totals": totals, "daily": series,
2066
- "channels": channels, "posters": posters}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2067
 
2068
 
2069
  # ------------------------------------------------------------------ search
 
25
  from typing import Any
26
 
27
  from . import auth
28
+ from . import analytics as analyticsmod
29
  from .auth import Principal
30
  from .config import LIMITS
31
  from .presence import FIELDS as PRESENCE_FIELDS, unknown as unknown_presence
 
410
  return records
411
 
412
 
413
+ def save_presence(conn, records: dict[int, dict], history: dict | None = None) -> None:
414
  conn.executemany(
415
  """INSERT INTO principal_presence
416
  (principal_id, last_seen_at, last_poll_at, listens, cursor, cursor_since, waiting, waiting_since)
 
422
  json.dumps(record["listens"]) if record["listens"] is not None else None,
423
  record["cursor"], record["cursor_since"], record["waiting"], record["waiting_since"])
424
  for pid, record in records.items()])
425
+ if history:
426
+ conn.executemany("""INSERT INTO presence_history (minute, sampled_at, agents) VALUES (?, ?, ?)
427
+ ON CONFLICT(minute) DO UPDATE SET sampled_at=excluded.sampled_at, agents=excluded.agents
428
+ WHERE excluded.sampled_at >= presence_history.sampled_at""",
429
+ [(minute, sample[0], sample[1]) for minute, sample in history.items()])
430
 
431
 
432
  def attach_presence(conn, viewer: Principal, items: list[dict], records: dict[int, dict]) -> list[dict]:
 
2007
  # ------------------------------------------------------------------ analytics
2008
 
2009
 
2010
+ def analytics(conn, p: Principal, now: float, days: int = 7, start: float | None = None,
2011
+ end: float | None = None, top_k: int = 10, history: dict | None = None) -> dict:
2012
  """Board activity for admins (and readers): totals, a daily series, channels and who posts."""
2013
  if not p.sees_everything:
2014
+ raise forbidden("only admins and readers can see analytics")
2015
  days = max(1, min(int(days), 365))
2016
+ start, end = analyticsmod.bounds(now, days, start, end)
2017
+ if not 1 <= top_k <= 100:
2018
+ raise bad_request("top_k must be between 1 and 100")
2019
+ stats = _analytics_messages(conn, start, end)
2020
+ series = [{key: row[key] for key in ("day", "people", "agents")} for row in stats["daily"]]
2021
+ daily = {row["day"]: row for row in series}
 
 
 
2022
 
2023
  chans: dict = {}
2024
  for r in conn.execute("""SELECT c.id, c.name, c.kind, c.archived,
2025
  (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id) AS total,
2026
+ (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0 AND m.created_at < :end) AS recent,
2027
+ (SELECT COUNT(DISTINCT m.sender_id) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0 AND m.created_at < :end) AS posters,
2028
  (SELECT MAX(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_at
2029
+ FROM channels c""", {"d0": start, "end": end}):
2030
  key = "dm" if r["kind"] == "dm" else r["id"] # DMs are counted together, never by pair
2031
  c = chans.setdefault(key, {"id": None if key == "dm" else r["id"], "name": "direct messages" if key == "dm" else r["name"],
2032
  "kind": r["kind"], "archived": bool(r["archived"]) if key != "dm" else False,
 
2037
 
2038
  per_day = {}
2039
  for pid, d, n in conn.execute("""SELECT sender_id, CAST(created_at / 86400 AS INTEGER) * 86400, COUNT(*) FROM messages
2040
+ WHERE created_at >= ? AND created_at < ? GROUP BY 1, 2""", (start, end)):
2041
  per_day.setdefault(pid, {})[d] = n
2042
  posters = []
2043
  for r in conn.execute("""SELECT p.id, p.display_name, p.external_id, p.identity_type, p.role, p.owner_id, p.pattern_seed,
2044
  substr(av.sha256, 1, 12) AS avatar, COUNT(m.id) AS n, MAX(m.created_at) AS last_at,
2045
  COUNT(DISTINCT m.channel_id) AS nchannels
2046
+ FROM principals p JOIN messages m ON m.sender_id = p.id AND m.created_at >= ? AND m.created_at < ?
2047
  LEFT JOIN avatars av ON av.principal_id = p.id
2048
+ GROUP BY p.id ORDER BY n DESC, p.id""", (start, end)):
2049
  posters.append({"id": r["id"], "display_name": r["display_name"], "external_id": r["external_id"],
2050
  "identity_type": r["identity_type"], "role": r["role"], "avatar": r["avatar"], "pattern": r["pattern_seed"],
2051
  "owner": _brief(conn, r["owner_id"]), "messages": r["n"], "last_at": r["last_at"], "channels": r["nchannels"],
 
2053
 
2054
  count = lambda sql, *a: conn.execute(sql, a).fetchone()[0]
2055
  week = now - 7 * 86400
2056
+ files = _analytics_files(conn, start, end)
2057
  totals = {
2058
  "messages": count("SELECT COUNT(*) FROM messages"),
2059
  "messages_window": sum(d["people"] + d["agents"] for d in series),
 
2061
  "agents": count("SELECT COUNT(*) FROM principals WHERE identity_type != 'user' AND disabled = 0"),
2062
  "channels": count("SELECT COUNT(*) FROM channels WHERE kind != 'dm' AND archived = 0"),
2063
  "active_people_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
2064
+ WHERE m.created_at >= ? AND m.created_at < ? AND p.identity_type = 'user'""", week, now),
2065
  "active_agents_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
2066
+ WHERE m.created_at >= ? AND m.created_at < ? AND p.identity_type != 'user'""", week, now),
2067
+ "threads_window": count("SELECT COUNT(DISTINCT thread_id) FROM messages WHERE thread_id IS NOT NULL AND created_at >= ? AND created_at < ?", start, end),
2068
+ "attachments_window": files["count"],
2069
  }
2070
+ return {"days": len(series), "since": start, "generated_at": now, "totals": totals, "daily": series,
2071
+ "channels": channels, "posters": posters, "range": {"from": start, "to": end, "bucket_seconds": 86400},
2072
+ "message_stats": stats, "response_times": _analytics_responses(conn, start, min(now, end)),
2073
+ "interactions": _analytics_interactions(conn, start, end, top_k), "files": files,
2074
+ "listening": _analytics_listening(conn, start, end, history or {})}
2075
+
2076
+
2077
+ def _analytics_messages(conn, start: float, end: float) -> dict:
2078
+ import math
2079
+
2080
+ daily = {day: {"day": day, "messages": 0, "people": 0, "agents": 0, "words": 0, "characters": 0}
2081
+ for day in range(int(start // 86400) * 86400, math.ceil(end / 86400) * 86400, 86400)}
2082
+ for row in conn.execute("""SELECT m.created_at, m.body, p.identity_type FROM messages m
2083
+ JOIN principals p ON p.id = m.sender_id
2084
+ WHERE m.created_at >= ? AND m.created_at < ?""", (start, end)):
2085
+ data = daily[int(row["created_at"] // 86400) * 86400]
2086
+ data["messages"] += 1
2087
+ data["people" if row["identity_type"] == "user" else "agents"] += 1
2088
+ data["characters"] += len(row["body"])
2089
+ data["words"] += len(analyticsmod.WORD.findall(row["body"]))
2090
+ for data in daily.values():
2091
+ data["average_length"] = data["characters"] / data["messages"] if data["messages"] else None
2092
+ total = {key: sum(data[key] for data in daily.values()) for key in ("messages", "words", "characters")}
2093
+ return {**total, "average_length": total["characters"] / total["messages"] if total["messages"] else None,
2094
+ "daily": list(daily.values())}
2095
+
2096
+
2097
+ def _analytics_files(conn, start: float, end: float) -> dict:
2098
+ rows = conn.execute("""SELECT uploader_id, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
2099
+ FROM attachments WHERE published = 1 AND created_at >= ? AND created_at < ?
2100
+ GROUP BY uploader_id ORDER BY bytes DESC, uploader_id""", (start, end))
2101
+ people = [dict(_brief(conn, row["uploader_id"]), count=row["count"], bytes=row["bytes"]) for row in rows]
2102
+ return {"count": sum(item["count"] for item in people), "bytes": sum(item["bytes"] for item in people),
2103
+ "by_principal": people}
2104
+
2105
+
2106
+ def _analytics_responses(conn, start: float, end: float) -> dict:
2107
+ # Each request/agent appears once even when a DM also mentions its recipient.
2108
+ # Indexed first-response lookups avoid joining every request to every later message.
2109
+ rows = conn.execute("""WITH requests AS (
2110
+ SELECT m.id, mm.principal_id AS target, m.channel_id, COALESCE(m.thread_id, m.id) AS thread,
2111
+ m.created_at, c.kind
2112
+ FROM messages m JOIN message_mentions mm ON mm.message_id = m.id
2113
+ JOIN principals p ON p.id = mm.principal_id JOIN channels c ON c.id = m.channel_id
2114
+ WHERE m.created_at >= :start AND m.created_at < :end AND p.identity_type != 'user'
2115
+ AND mm.principal_id != m.sender_id
2116
+ UNION
2117
+ SELECT m.id, cm.principal_id, m.channel_id, COALESCE(m.thread_id, m.id), m.created_at, c.kind
2118
+ FROM messages m JOIN channels c ON c.id = m.channel_id
2119
+ JOIN channel_members cm ON cm.channel_id = c.id JOIN principals p ON p.id = cm.principal_id
2120
+ WHERE c.kind = 'dm' AND m.created_at >= :start AND m.created_at < :end
2121
+ AND p.identity_type != 'user' AND cm.principal_id != m.sender_id
2122
+ ) SELECT q.target, q.created_at,
2123
+ CASE WHEN q.kind = 'dm' THEN (
2124
+ SELECT r.created_at FROM messages r WHERE r.channel_id = q.channel_id AND r.sender_id = q.target
2125
+ AND r.created_at >= q.created_at AND r.created_at < :end AND r.id > q.id
2126
+ ORDER BY r.created_at, r.id LIMIT 1)
2127
+ ELSE (
2128
+ SELECT r.created_at FROM messages r WHERE r.sender_id = q.target AND r.thread_id = q.thread
2129
+ AND r.created_at >= q.created_at AND r.created_at < :end AND r.id > q.id
2130
+ ORDER BY r.created_at, r.id LIMIT 1)
2131
+ END AS answered_at FROM requests q""", {"start": start, "end": end})
2132
+ agents = {}
2133
+ latencies = []
2134
+ requests = 0
2135
+ for row in rows:
2136
+ requests += 1
2137
+ agent = agents.setdefault(row["target"], {"requests": 0, "latencies": []})
2138
+ agent["requests"] += 1
2139
+ if row["answered_at"] is not None:
2140
+ latency = row["answered_at"] - row["created_at"]
2141
+ latencies.append(latency)
2142
+ agent["latencies"].append(latency)
2143
+ per_agent = [dict(_brief(conn, pid), **analyticsmod.response_summary(data["requests"], data["latencies"]))
2144
+ for pid, data in agents.items()]
2145
+ return {"rule": "Mention or DM to an agent -> its first later message in that thread or DM, before the range ends. Reactions do not count.",
2146
+ **analyticsmod.response_summary(requests, latencies), "histogram": analyticsmod.response_histogram(latencies),
2147
+ "agents": sorted(per_agent, key=lambda item: (-item["requests"], item["id"]))}
2148
+
2149
+
2150
+ def _analytics_interactions(conn, start: float, end: float, top_k: int) -> dict:
2151
+ rows = conn.execute("""WITH events AS (
2152
+ SELECT m.id, m.sender_id AS source, cm.principal_id AS target, 0 AS priority
2153
+ FROM messages m JOIN channels c ON c.id = m.channel_id
2154
+ JOIN channel_members cm ON cm.channel_id = m.channel_id
2155
+ WHERE c.kind = 'dm' AND m.created_at >= :start AND m.created_at < :end AND cm.principal_id != m.sender_id
2156
+ UNION ALL
2157
+ SELECT m.id, m.sender_id, parent.sender_id, 1
2158
+ FROM messages m JOIN messages parent ON parent.id = m.reply_to JOIN channels c ON c.id = m.channel_id
2159
+ WHERE c.kind != 'dm' AND m.created_at >= :start AND m.created_at < :end
2160
+ UNION ALL
2161
+ SELECT m.id, m.sender_id, mm.principal_id, 2
2162
+ FROM messages m JOIN message_mentions mm ON mm.message_id = m.id JOIN channels c ON c.id = m.channel_id
2163
+ WHERE c.kind != 'dm' AND m.created_at >= :start AND m.created_at < :end
2164
+ ), pairs AS (
2165
+ SELECT e.id, min(source, target) AS a, max(source, target) AS b, min(priority) AS priority
2166
+ FROM events e JOIN principals s ON s.id = e.source JOIN principals t ON t.id = e.target
2167
+ WHERE (s.identity_type = 'user') != (t.identity_type = 'user') GROUP BY e.id, a, b
2168
+ ) SELECT a, b, priority, COUNT(*) AS count FROM pairs GROUP BY a, b, priority""", {"start": start, "end": end})
2169
+ edges = {}
2170
+ weights = {}
2171
+ for row in rows:
2172
+ a, b, count = row["a"], row["b"], row["count"]
2173
+ edge = edges.setdefault((a, b), {"source": a, "target": b, "total": 0, "mentions": 0, "replies": 0, "dms": 0})
2174
+ edge[("dms", "replies", "mentions")[row["priority"]]] += count
2175
+ edge["total"] += count
2176
+ for pid in (a, b):
2177
+ weights[pid] = weights.get(pid, 0) + count
2178
+ selected = sorted(weights, key=lambda pid: (-weights[pid], pid))[:top_k]
2179
+ identities = {pid: _brief(conn, pid) for pid in selected}
2180
+ return analyticsmod.chord_graph(list(edges.values()), identities, top_k)
2181
+
2182
+
2183
+ def _analytics_listening(conn, start: float, end: float, pending: dict) -> dict:
2184
+ first = conn.execute("SELECT sampled_at FROM presence_history ORDER BY minute LIMIT 1").fetchone()
2185
+ started_at = first[0] if first else None
2186
+ if pending:
2187
+ candidate = min(sample[0] for sample in pending.values())
2188
+ started_at = min(started_at, candidate) if started_at is not None else candidate
2189
+ rows = conn.execute("SELECT minute, sampled_at, agents FROM presence_history WHERE minute >= ? AND minute < ?",
2190
+ (int(start // 60) * 60, end))
2191
+ samples = {row[0]: (row[1], row[2]) for row in rows}
2192
+ samples.update(pending)
2193
+ return analyticsmod.listening_series(start, end, samples, started_at)
2194
 
2195
 
2196
  # ------------------------------------------------------------------ search