Skip to content

Commit abda5ce

Browse files
authored
feat(lik-ui): stats page — live-session controls, readable deleted totals, timezone-correct over-time chart (#66)
Follow-up polish to the session-analytics `/stats` and `/all-stats` pages (from #65), based on hands-on testing. ## Live sessions - **Delete button** per session (owner-scoped — only the viewer's own sessions show it; on `/all-stats` another user's row has none). Reuses `/sessions/delete` with an allowlisted `next` so the delete returns to the stats page. - New columns: **Created**, **Deletes on**, **Shared** (from the local session row), and **Input / Output tokens** split out (was a single total). ## Deleted sessions - Totals rendered as a **compact grouped table** (replaces the tile grid): token components indented under Total tokens; **Tool calls split into MCP / Builtin** (derived at read time from the stored `tool_breakdown` — no schema change, reconciles to the total); "Incomplete captures" flagged. ## Tokens over time - **Buckets by `created_at`** (when sessions were used) instead of `deleted_at` (~7 days later, misleading). - **Bucketing happens client-side in the viewer's display time zone** (`stats.js`), matching how every other timestamp renders — the server stays UTC-only and emits the raw per-session series, since the display zone is a client-only preference. - Fixed the bar fill not rendering (inline `<span>` → `display:block`) and the value column word-wrapping. ## Tests 339 pass (+ new coverage for the delete button/`next` redirect, local-row columns, the MCP split, and the per-session series). Verified end-to-end with a headless render. No schema or prod-DB change in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 35ffddd commit abda5ce

10 files changed

Lines changed: 297 additions & 101 deletions

File tree

lik-ui/src/lik_ui/chat.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,10 +462,16 @@ async def delete_session(request: Request):
462462
user = require_user(request)
463463
form = await request.form()
464464
session_id = form.get("session_id", "")
465+
# Return to the page the delete was triggered from (the sessions list or a stats page),
466+
# allowlisted so a crafted ``next`` can't turn this into an open redirect. Defaults to
467+
# the sessions list, preserving the original behavior when no ``next`` is posted.
468+
dest = form.get("next", "/sessions")
469+
if dest not in ("/sessions", "/stats", "/all-stats"):
470+
dest = "/sessions"
465471
# Ownership check: only the owning user can delete, and only an existing row.
466472
session = request.app.state.store.get_session(session_id, user["id"])
467473
if not session:
468-
return RedirectResponse("/sessions", status_code=303)
474+
return RedirectResponse(dest, status_code=303)
469475
# Capture analytics before anything is destroyed — the transcript is still readable here.
470476
sessions_client: SessionsClient | None = request.app.state.sessions_client
471477
capture_session_analytics(request.app.state.store, sessions_client, session, "manual")
@@ -478,7 +484,7 @@ async def delete_session(request: Request):
478484
except Exception as exc: # noqa: BLE001 - surface session/SDK errors as a page, not a 500
479485
return HTMLResponse(f"Could not delete that session: {exc}", status_code=502)
480486
request.app.state.store.delete_session(session_id, user["id"])
481-
return RedirectResponse("/sessions", status_code=303)
487+
return RedirectResponse(dest, status_code=303)
482488

483489
@app.post("/chat/{session_id}/share")
484490
async def share_session(request: Request, session_id: str):

lik-ui/src/lik_ui/db.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ def list_all_sessions(self) -> list[dict]:
283283
return conn.execute(
284284
"""
285285
SELECT s.session_id, s.user_id, u.email AS user_email, s.agent_id,
286-
s.title, s.created_at, s.auto_delete_at
286+
s.title, s.shared, s.created_at, s.auto_delete_at
287287
FROM sessions s JOIN users u ON u.id = s.user_id
288288
ORDER BY s.created_at DESC
289289
"""
@@ -309,27 +309,38 @@ def session_analytics_totals(self, user_id: int | None = None) -> dict:
309309
COALESCE(sum(cache_read_tokens),0) AS cache_read_tokens,
310310
COALESCE(sum(cache_creation_tokens),0) AS cache_creation_tokens,
311311
COALESCE(sum({self._ANALYTICS_TOKENS}),0) AS total_tokens,
312-
COALESCE(sum(tool_use_count),0) AS tool_use_count,
312+
COALESCE(sum(sa.tool_use_count),0) AS tool_use_count,
313+
-- MCP vs. built-in split, derived from the per-server counts in tool_breakdown
314+
-- (every tool call is bucketed by server name, built-ins under 'builtin'), so it
315+
-- needs no dedicated column and reconciles to tool_use_count.
316+
COALESCE(sum(t.mcp),0) AS mcp_tool_calls,
317+
COALESCE(sum(t.builtin),0) AS builtin_tool_calls,
313318
COALESCE(sum(error_count),0) AS error_count,
314319
COALESCE(sum(CASE WHEN capture_incomplete THEN 1 ELSE 0 END),0) AS incomplete
315-
FROM session_analytics {where}
320+
FROM session_analytics sa
321+
LEFT JOIN LATERAL (
322+
SELECT
323+
COALESCE(sum(CASE WHEN kv.key <> 'builtin' THEN kv.value::int END),0) AS mcp,
324+
COALESCE(sum(CASE WHEN kv.key = 'builtin' THEN kv.value::int END),0) AS builtin
325+
FROM jsonb_each_text(COALESCE(sa.tool_breakdown->'servers', '{{}}'::jsonb)) AS kv
326+
) t ON true
327+
{where}
316328
""",
317329
params,
318330
).fetchone()
319331

320-
def session_analytics_daily(self, user_id: int | None = None) -> list[dict]:
321-
"""Per-day buckets of deleted-session count and total tokens, oldest first — the over-time
322-
view (R12). Scoped to one user when ``user_id`` is given, else across all users."""
323-
where, params = ("WHERE user_id = %s", (user_id,)) if user_id is not None else ("", ())
332+
def session_analytics_series(self, user_id: int | None = None) -> list[dict]:
333+
"""Per-session (created_at, total tokens) for deleted sessions, oldest first — the raw data
334+
for the over-time view (R12). Deliberately NOT pre-bucketed by day: day boundaries depend on
335+
the viewer's display time zone, which is a client-only preference (the server stays UTC), so
336+
the browser buckets these instants into local days. Scoped to one user when ``user_id`` is
337+
given, else across all users. Rows with no created_at (thin captures) are skipped."""
338+
where = "WHERE created_at IS NOT NULL" + (" AND user_id = %s" if user_id is not None else "")
339+
params = (user_id,) if user_id is not None else ()
324340
with self.db.connection() as conn:
325341
return conn.execute(
326-
f"""
327-
SELECT date_trunc('day', deleted_at) AS day,
328-
count(*) AS sessions,
329-
COALESCE(sum({self._ANALYTICS_TOKENS}),0) AS tokens
330-
FROM session_analytics {where}
331-
GROUP BY day ORDER BY day
332-
""",
342+
f"SELECT created_at, {self._ANALYTICS_TOKENS} AS tokens "
343+
f"FROM session_analytics {where} ORDER BY created_at",
333344
params,
334345
).fetchall()
335346

lik-ui/src/lik_ui/static/app.css

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,19 @@ details.skills .skill-note { color: var(--muted); font-size: .78rem; }
233233
.stat-tile.flagged .stat-value { color: var(--status-warn); }
234234
/* Over-time bars: one labelled row per day, bar width is a server-computed percentage. */
235235
.bar-chart { display: flex; flex-direction: column; gap: .3rem; margin: .5rem 0; }
236-
.bar-row { display: grid; grid-template-columns: 6rem 1fr 7rem; align-items: center; gap: .5rem; font-size: .85rem; }
237-
.bar-track { background: var(--bg); border: 1px solid var(--line); border-radius: 4px; height: 1rem; overflow: hidden; }
238-
.bar-fill { background: var(--accent); height: 100%; }
239-
.bar-value { color: var(--muted); text-align: right; }
236+
.bar-row { display: grid; grid-template-columns: 6rem 1fr 11rem; align-items: center; gap: .5rem; font-size: .85rem; }
237+
.bar-track { display: block; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; height: 1rem; overflow: hidden; }
238+
/* display:block so width/height apply — an inline <span> would ignore them and collapse the fill. */
239+
.bar-fill { display: block; background: var(--accent); height: 100%; min-width: 1px; }
240+
.bar-value { color: var(--muted); text-align: right; white-space: nowrap; }
240241
/* Tables reused for the per-user and live-session lists. */
241242
.stats-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
242243
.stats-table th, .stats-table td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid var(--line); }
243244
.stats-table td.num, .stats-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
244245
.stats-empty { color: var(--muted); font-style: italic; }
245246
.stats-unavailable { color: var(--status-warn); font-size: .8rem; }
247+
/* Deleted-session totals: a compact two-column table. Token components indent under the total. */
248+
.totals-table { max-width: 26rem; }
249+
.totals-table th[scope="row"] { font-weight: 600; }
250+
.totals-table tr.sub td:first-child { padding-left: 1.5rem; color: var(--muted); font-weight: 400; }
251+
.totals-table tr.flagged th, .totals-table tr.flagged td { color: var(--status-warn); }

lik-ui/src/lik_ui/static/stats.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Over-time chart for the stats pages. The server emits raw per-session {created_at (UTC ISO),
2+
// tokens} in #tokens-over-time[data-series]; this buckets them into DAYS in the viewer's display
3+
// zone and draws the bars. Day bucketing lives here (not in SQL) because the day boundary depends
4+
// on the display zone, which is a client-only preference (see tz.js) — the server stays UTC-only.
5+
(function () {
6+
var el = document.getElementById("tokens-over-time");
7+
if (!el) return;
8+
var series;
9+
try { series = JSON.parse(el.getAttribute("data-series") || "[]"); } catch (e) { return; }
10+
11+
// The effective display zone — same rule as tz.js: the stored choice, else the browser's zone.
12+
function zone() {
13+
try { var s = localStorage.getItem("lik-tz"); if (s && s !== "auto") return s; } catch (e) {}
14+
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch (e) { return "UTC"; }
15+
}
16+
var z = zone();
17+
18+
// Local calendar day (YYYY-MM-DD) of a UTC instant, in the display zone. en-CA yields ISO order.
19+
var fmt = new Intl.DateTimeFormat("en-CA", { timeZone: z, year: "numeric", month: "2-digit", day: "2-digit" });
20+
function localDay(iso) { return fmt.format(new Date(iso)); }
21+
22+
var byDay = {};
23+
series.forEach(function (s) {
24+
var k = localDay(s.created_at);
25+
if (!byDay[k]) byDay[k] = { tokens: 0, sessions: 0 };
26+
byDay[k].tokens += s.tokens;
27+
byDay[k].sessions += 1;
28+
});
29+
var days = Object.keys(byDay).sort();
30+
if (!days.length) { el.innerHTML = '<p class="stats-empty">No deleted sessions yet.</p>'; return; }
31+
32+
var peak = days.reduce(function (m, k) { return Math.max(m, byDay[k].tokens); }, 0);
33+
var rows = days.map(function (k) {
34+
var b = byDay[k];
35+
var pct = peak ? Math.round((100 * b.tokens) / peak) : 0;
36+
return '<div class="bar-row"><span>' + k + "</span>"
37+
+ '<span class="bar-track"><span class="bar-fill" style="width:' + pct + '%"></span></span>'
38+
+ '<span class="bar-value">' + b.tokens.toLocaleString() + " · " + b.sessions + " sess</span></div>";
39+
});
40+
el.innerHTML = rows.join("");
41+
})();

lik-ui/src/lik_ui/stats.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,24 @@
1414
from .analytics import build_live_section
1515

1616

17-
def _bars(rows: list[dict], value_key: str) -> list[dict]:
18-
"""Attach a 0–100 bar percentage to each daily bucket, sized against the largest bucket, so
19-
the template can draw the over-time view without doing math. An all-zero set yields 0% bars."""
20-
peak = max((row[value_key] for row in rows), default=0) or 0
21-
out = []
22-
for row in rows:
23-
pct = round(100 * row[value_key] / peak) if peak else 0
24-
out.append({**row, "pct": pct})
25-
return out
17+
def _series(rows: list[dict]) -> list[dict]:
18+
"""Shape the per-session over-time rows into JSON-safe records (UTC ISO instant + int tokens)
19+
for the client, which buckets them into local days in the viewer's display zone."""
20+
return [{"created_at": r["created_at"].isoformat(), "tokens": int(r["tokens"])} for r in rows]
2621

2722

28-
def _stats_view(store, sessions_client, *, live_sessions, user_id, scope_label, per_user):
23+
def _stats_view(store, sessions_client, *, live_sessions, user_id, scope_label, per_user, page_path):
2924
"""Assemble the shared stats view model for one scope. ``user_id`` is None for the all-users
30-
(/all-stats) scope and the viewer's id for /stats."""
31-
daily = store.session_analytics_daily(user_id)
25+
(/all-stats) scope and the viewer's id for /stats. ``page_path`` is where a live-session delete
26+
should return to."""
3227
return {
3328
"scope_label": scope_label,
29+
"page_path": page_path,
3430
"per_user": store.session_analytics_by_user() if per_user else None,
3531
"live": build_live_section(sessions_client, live_sessions),
3632
"deleted": {
3733
"totals": store.session_analytics_totals(user_id),
38-
"daily": _bars(daily, "tokens"),
34+
"series": _series(store.session_analytics_series(user_id)),
3935
},
4036
}
4137

@@ -57,6 +53,7 @@ async def stats_page(request: Request):
5753
user_id=user["id"],
5854
scope_label="your sessions",
5955
per_user=False,
56+
page_path="/stats",
6057
)
6158
return templates.TemplateResponse(request, "stats.html", {"user": user, "view": view})
6259

@@ -74,5 +71,6 @@ async def all_stats_page(request: Request):
7471
user_id=None,
7572
scope_label="all users",
7673
per_user=True,
74+
page_path="/all-stats",
7775
)
7876
return templates.TemplateResponse(request, "stats.html", {"user": user, "view": view})

lik-ui/src/lik_ui/templates/sessions.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ <h1>Choose a prior session to continue the conversation</h1>
1212
<a href="/chat/{{ s.session_id | urlencode }}" title="Session {{ s.session_id }}">{{ s.title or ("Session " ~ s.session_id) }}</a>
1313
<span class="session-delete-note{% if s.delete_soon %} delete-warning{% endif %}">
1414
{%- if s.delete_soon -%}
15-
Deletes {% if s.delete_days == 0 %}today{% else %}in {{ s.delete_days }} day{{ 's' if s.delete_days != 1 else '' }}{% endif %}
15+
Auto-deletes {% if s.delete_days == 0 %}today{% else %}in {{ s.delete_days }} day{{ 's' if s.delete_days != 1 else '' }}{% endif %}
1616
{%- else -%}
17-
Deletes <time data-utc="{{ s.auto_delete_at | utc_iso }}" data-format="date">{{ s.auto_delete_at | utcformat('%Y-%m-%d') }}</time>
17+
Auto-deletes on <time data-utc="{{ s.auto_delete_at | utc_iso }}" data-format="date">{{ s.auto_delete_at | utcformat('%Y-%m-%d') }}</time>
1818
{%- endif -%}
1919
</span>
2020
</span>

0 commit comments

Comments
 (0)