diff --git a/app/main.py b/app/main.py
index 4e31b95..5bfb6c6 100644
--- a/app/main.py
+++ b/app/main.py
@@ -133,6 +133,23 @@ async def api_usage(request: Request):
return JSONResponse({"error": e.detail}, status_code=e.status_code)
+@app.get("/api/metrics")
+async def api_metrics(request: Request):
+ """Co-op metrics over time, for the Co-op usage view.
+
+ Aggregate and non-personal (co-op size, requests served, spend), the same
+ class as the model leaderboard. Fetched at the widest range so the metric
+ and period toggles re-render client-side without refetching.
+ """
+ identity = get_user_identity(request)
+ if not identity:
+ return JSONResponse({"error": "unauthenticated"}, status_code=401)
+ try:
+ return await broker_get(identity, "/broker/metrics?days=730")
+ except HTTPException as e:
+ return JSONResponse({"error": e.detail}, status_code=e.status_code)
+
+
@app.post("/api/keys")
async def api_create_key(request: Request):
identity = get_user_identity(request)
@@ -363,6 +380,15 @@ PAGE_HTML = """
/* Leaderboard */
.board-note { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
+ /* The co-op over time: same chart as the admin panel's Trends tab, sized for
+ members. Inline SVG, no library, colours from the shared palette vars. */
+ .coop-trend { border: 1px solid var(--border, #e4e0d8); border-radius: 12px; padding: 14px 16px 12px; margin-bottom: 20px; }
+ .coop-trend-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
+ .coop-trend-title { font-weight: 700; font-size: 15px; color: var(--ink, #2d3327); }
+ .coop-trend-sub { color: var(--muted); font-size: 12px; }
+ .coop-trend-controls { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 10px; }
+ .trend-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+ .coop-trend-body svg { display: block; width: 100%; height: auto; overflow: visible; }
.board-row {
display: flex; align-items: flex-start; gap: 14px;
padding: 12px 0; border-top: 1px solid #f0ede6;
@@ -533,6 +559,28 @@ PAGE_HTML = """
+
+
+ The co-op over time
+
+
+
+
+ Show
+
+
+
+
+
+ Period
+
+
+
+
+
+
Loading
+
+
Usage across the co-op, last 30 days
Sort by
@@ -751,6 +799,152 @@ function renderModels(models) {
}).join('');
}
+// ---- The co-op over time (chart) ----
+// Same series the admin panel charts; the portal owns the data and serves it
+// through the broker as aggregate, non-personal numbers. Fetched once at the
+// widest range; the toggles re-render client-side without refetching.
+let trendsData = null;
+let trendMetric = "members"; // members | requests | spend
+let trendDays = 90; // 0 = all time
+
+const TREND_CONF = {
+ members: { kind: "line", title: "The co-op over time",
+ note: "How many members the co-op has had, day by day. A gap means days that were never measured, not zero members." },
+ requests: { kind: "bar", title: "Requests the co-op served",
+ note: "AI requests served to members each day. Days we did not record are gaps, not zeroes." },
+ spend: { kind: "bar", title: "What the co-op spent",
+ note: "Spend charged to the co-op each day, in US dollars. Days we did not record are gaps, not zeroes." }
+};
+
+async function loadMetrics() {
+ if (trendsData) { renderTrends(); return; }
+ const host = $("trend-body");
+ try {
+ const r = await fetch("/api/metrics");
+ if (r.status === 401 || r.status === 403) { host.innerHTML = "
Sign in to see this.
"; return; }
+ const d = await r.json();
+ if (d.error) { host.innerHTML = "